diff --git a/docs/requirements.md b/docs/requirements.md index c3ef234..b23b69b 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -315,11 +315,11 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des Each option in the dialog displays: the schematic name (ship `id` from `ships.toml`, module `id` from `modules.toml`, or assembler recipe `id` from `recipes.toml`) and the schematic type (ship, module, or assembler recipe). The artifact option (if present) is displayed as a distinct entry with the name "Artifact". - Each option additionally displays a vertical list of item names labeled "Unlocks recipes for:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the output items of miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect: + Each option additionally displays a vertical list of recipe names labeled "Unlocks recipes:", showing which miner and assembler recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — specifically, the miner recipes and assembler recipes (without `unlock_at_station_level`) that are not currently implicitly unlocked but would become so after applying this option's effect: - For a ship or module schematic, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation. - For an assembler recipe schematic, its output item is added to the base set per REQ-LOCK-IMPLICIT step 1b before recomputation. - Item names are deduplicated and sorted alphabetically. If no recipes would be newly unlocked, the list shows "None". + Each recipe is listed by its `id` (using the same display convention as the assembler recipe-selection dialog), sorted alphabetically. Hovering a recipe in this list displays the recipe info tooltip described for a recipe in REQ-UI-SELECT-TOOLTIP (the recipe name; the name and quantity of each input item; the completion time; and the name and quantity of the produced output item). If no recipes would be newly unlocked, the list shows "None". The player selects one option by clicking it. If the player selects the artifact option, the player's artifact count is incremented by 1 (REQ-WIN-ARTIFACT-COUNT) and the dialog closes; no schematic is applied. Otherwise, the selected schematic is applied and the dialog closes: diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index aa25d15..77c52ea 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -20,8 +20,8 @@ struct SchematicChoiceOption SchematicType type; std::string displayName; - // Display names of items produced by recipes that would newly become - // implicitly unlocked (REQ-LOCK-IMPLICIT) if this option is selected. - // Deduplicated and sorted alphabetically; empty if none. - std::vector newlyUnlockedItemNames; + // Ids of miner/assembler recipes that would newly become implicitly + // unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted + // alphabetically by display name; empty if none. + std::vector newlyUnlockedRecipeIds; }; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 6f03816..bc40fe9 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -730,7 +730,7 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) const UnlockedSets hypothetical = computeUnlockedSets( hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); - option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical); + option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); m_pendingSchematicChoices.push_back(option); } @@ -918,23 +918,20 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets( return result; } -std::vector Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const +std::vector Simulation::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const { - std::set itemNames; + std::vector recipeIds; for (const std::string& recipeId : hypothetical.recipeIds) { if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; } - for (const RecipeDef& def : m_config.recipes.recipes) - { - if (def.id != recipeId) { continue; } - for (const RecipeOutput& out : def.outputs) - { - itemNames.insert(toDisplayName(out.item)); - } - break; - } + recipeIds.push_back(recipeId); } - return std::vector(itemNames.begin(), itemNames.end()); + std::sort(recipeIds.begin(), recipeIds.end(), + [](const std::string& lhs, const std::string& rhs) + { + return toDisplayName(lhs) < toDisplayName(rhs); + }); + return recipeIds; } bool Simulation::isRecipeUnlocked(const std::string& recipeId) const diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index b79ed1d..91e35dd 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -233,9 +233,9 @@ private: // True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ). bool prerequisitesSatisfied(const std::vector& unlockRequires) const; - // Display names (deduplicated, alphabetical) of output items of recipes in + // Ids (sorted alphabetically by display name) of the recipes in // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. - std::vector computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const; + std::vector computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const; EntityAdmin m_admin; BeltSystem m_beltSystem; diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index 7abff0a..dd2f390 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -283,7 +283,7 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items // Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP) // --------------------------------------------------------------------------- -TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups", +TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups", "[recipe_schematic]") { Simulation sim(loadConfig()); @@ -295,10 +295,11 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) { - // Strictly ascending implies sorted and deduplicated. - for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j) + // Strictly ascending display names imply sorted and deduplicated. + for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j) { - CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]); + CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1]) + < toDisplayName(opt.newlyUnlockedRecipeIds[j])); } } @@ -306,7 +307,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and } } -TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked", +TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked", "[recipe_schematic]") { Simulation sim(loadConfig()); @@ -336,21 +337,22 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually SimulationTestAccess::applySchematicChoice(sim, 0); - std::set expectedNames; + std::vector expected; for (const RecipeDef& def : cfg.recipes.recipes) { if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) && sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0) { - for (const RecipeOutput& out : def.outputs) - { - expectedNames.insert(toDisplayName(out.item)); - } + expected.push_back(def.id); } } - const std::vector expected(expectedNames.begin(), expectedNames.end()); + std::sort(expected.begin(), expected.end(), + [](const std::string& lhs, const std::string& rhs) + { + return toDisplayName(lhs) < toDisplayName(rhs); + }); - REQUIRE(choice.newlyUnlockedItemNames == expected); + REQUIRE(choice.newlyUnlockedRecipeIds == expected); } } diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index a80618f..d04e11c 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -13,6 +13,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.h ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.h ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.h + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.h PARENT_SCOPE ) @@ -30,5 +31,6 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsPanel.cpp ${CMAKE_CURRENT_SOURCE_DIR}/SchematicChoiceDialog.cpp ${CMAKE_CURRENT_SOURCE_DIR}/RecipeSelectionDialog.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/RecipeTooltip.cpp PARENT_SCOPE ) diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3e9bfd2..3265e8a 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -142,7 +142,7 @@ void MainWindow::handleEvent(std::shared_ptrgameSpeed(); m_gameWorldView->setGameSpeed(0.0); - SchematicChoiceDialog dialog(event->choices, this); + SchematicChoiceDialog dialog(event->choices, m_sim->config().recipes, this); dialog.exec(); std::shared_ptr command = diff --git a/src/ui/RecipeSelectionDialog.cpp b/src/ui/RecipeSelectionDialog.cpp index 7783d85..42548d2 100644 --- a/src/ui/RecipeSelectionDialog.cpp +++ b/src/ui/RecipeSelectionDialog.cpp @@ -12,6 +12,7 @@ #include "DisplayName.h" #include "GameConfig.h" #include "RecipesConfig.h" +#include "RecipeTooltip.h" #include "ShipsConfig.h" #include "Simulation.h" @@ -25,39 +26,6 @@ QString itemLine(const std::string& itemId, int amount) + QStringLiteral(" ×") + QString::number(amount); } -QString recipeTooltip(const RecipeDef& recipe) -{ - QStringList lines; - lines << QObject::tr("Recipe: %1") - .arg(QString::fromStdString(toDisplayName(recipe.id))); - - if (recipe.inputs.empty()) - { - lines << QObject::tr("Inputs: none"); - } - else - { - lines << QObject::tr("Inputs:"); - for (const RecipeIngredient& ingredient : recipe.inputs) - { - lines << itemLine(ingredient.item, ingredient.amount); - } - } - - lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds); - - if (!recipe.outputs.empty()) - { - lines << QObject::tr("Produces:"); - for (const RecipeOutput& output : recipe.outputs) - { - lines << itemLine(output.item, output.amount); - } - } - - return lines.join('\n'); -} - QString shipTooltip(const ShipDef& def) { const QString name = QString::fromStdString(toDisplayName(def.id)); @@ -116,7 +84,7 @@ std::vector buildRecipeSelectionOptions( } options.push_back({recipe.id, QString::fromStdString(toDisplayName(recipe.id)), - recipeTooltip(recipe)}); + buildRecipeTooltip(recipe)}); } } diff --git a/src/ui/RecipeTooltip.cpp b/src/ui/RecipeTooltip.cpp new file mode 100644 index 0000000..0a4f5c1 --- /dev/null +++ b/src/ui/RecipeTooltip.cpp @@ -0,0 +1,52 @@ +#include "RecipeTooltip.h" + +#include +#include + +#include "DisplayName.h" +#include "RecipesConfig.h" + +namespace +{ + +QString itemLine(const std::string& itemId, int amount) +{ + return QStringLiteral(" ") + + QString::fromStdString(toDisplayName(itemId)) + + QStringLiteral(" ×") + QString::number(amount); +} + +} // namespace + +QString buildRecipeTooltip(const RecipeDef& recipe) +{ + QStringList lines; + lines << QObject::tr("Recipe: %1") + .arg(QString::fromStdString(toDisplayName(recipe.id))); + + if (recipe.inputs.empty()) + { + lines << QObject::tr("Inputs: none"); + } + else + { + lines << QObject::tr("Inputs:"); + for (const RecipeIngredient& ingredient : recipe.inputs) + { + lines << itemLine(ingredient.item, ingredient.amount); + } + } + + lines << QObject::tr("Completion time: %1 s").arg(recipe.durationSeconds); + + if (!recipe.outputs.empty()) + { + lines << QObject::tr("Produces:"); + for (const RecipeOutput& output : recipe.outputs) + { + lines << itemLine(output.item, output.amount); + } + } + + return lines.join('\n'); +} diff --git a/src/ui/RecipeTooltip.h b/src/ui/RecipeTooltip.h new file mode 100644 index 0000000..5671637 --- /dev/null +++ b/src/ui/RecipeTooltip.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +struct RecipeDef; + +// Builds the recipe info tooltip text (REQ-UI-SELECT-TOOLTIP): the recipe name, +// each input item name and quantity, the completion time, and the produced +// output item name and quantity. Shared by the recipe-selection dialog and the +// schematic choice dialog's "Unlocks recipes:" list so both render identically. +QString buildRecipeTooltip(const RecipeDef& recipe); diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 7ffc2e6..ee43372 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -3,11 +3,29 @@ #include #include #include -#include #include +#include "DisplayName.h" +#include "RecipeTooltip.h" +#include "RecipesConfig.h" + +namespace +{ + +const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id) +{ + for (const RecipeDef& recipe : recipes.recipes) + { + if (recipe.id == id) { return &recipe; } + } + return nullptr; +} + +} // namespace + SchematicChoiceDialog::SchematicChoiceDialog( const std::vector& options, + const RecipesConfig& recipes, QWidget* parent) : QDialog(parent) , m_chosenIndex(0) @@ -70,30 +88,33 @@ SchematicChoiceDialog::SchematicChoiceDialog( typeLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(typeLabel); - QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); + QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card); QFont unlocksHeaderFont = unlocksHeaderLabel->font(); unlocksHeaderFont.setBold(true); unlocksHeaderLabel->setFont(unlocksHeaderFont); unlocksHeaderLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(unlocksHeaderLabel); - QString unlocksText; - if (option.newlyUnlockedItemNames.empty()) + if (option.newlyUnlockedRecipeIds.empty()) { - unlocksText = tr("None"); + QLabel* noneLabel = new QLabel(tr("None"), card); + noneLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(noneLabel); } else { - QStringList itemLines; - for (const std::string& itemName : option.newlyUnlockedItemNames) + for (const std::string& recipeId : option.newlyUnlockedRecipeIds) { - itemLines << QString::fromStdString(itemName); + QLabel* recipeLabel = new QLabel( + QString::fromStdString(toDisplayName(recipeId)), card); + recipeLabel->setAlignment(Qt::AlignCenter); + if (const RecipeDef* def = findRecipe(recipes, recipeId)) + { + recipeLabel->setToolTip(buildRecipeTooltip(*def)); + } + cardLayout->addWidget(recipeLabel); } - unlocksText = itemLines.join("\n"); } - QLabel* unlocksLabel = new QLabel(unlocksText, card); - unlocksLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(unlocksLabel); } QPushButton* selectButton = new QPushButton(tr("Select"), card); diff --git a/src/ui/SchematicChoiceDialog.h b/src/ui/SchematicChoiceDialog.h index a242a94..36a7446 100644 --- a/src/ui/SchematicChoiceDialog.h +++ b/src/ui/SchematicChoiceDialog.h @@ -6,12 +6,15 @@ #include "SchematicChoiceOption.h" +struct RecipesConfig; + class SchematicChoiceDialog : public QDialog { Q_OBJECT public: SchematicChoiceDialog(const std::vector& options, + const RecipesConfig& recipes, QWidget* parent = nullptr); int getChosenIndex() const;