From 44c3c83080f0be58c1722f546b9def28d5bcdee4 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Thu, 9 Jul 2026 13:34:55 +0200 Subject: [PATCH] List unlocked recipes with tooltips in schematic choice dialog Implement REQ-DEF-SCHEMATIC-DROP: the schematic option's per-option list now shows the miner/assembler recipes that would newly become implicitly unlocked (labeled "Unlocks recipes:") rather than a deduplicated list of output items. Each recipe entry shows the recipe info tooltip (REQ-UI-SELECT-TOOLTIP) on hover. - Extract the recipe tooltip builder from RecipeSelectionDialog's anonymous namespace into a shared RecipeTooltip.h/.cpp so both dialogs render identical tooltips. - SchematicChoiceOption carries newlyUnlockedRecipeIds (sorted by display name) instead of newlyUnlockedItemNames; Simulation collects recipe ids directly. - SchematicChoiceDialog takes the RecipesConfig to render one label per recipe with its tooltip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01VGNNLeFWhVzvxkK9qVXP2K --- src/lib/core/SchematicChoiceOption.h | 8 ++--- src/lib/sim/Simulation.cpp | 23 ++++++------ src/lib/sim/Simulation.h | 4 +-- src/test/RecipeSchematicTest.cpp | 26 +++++++------- src/ui/CMakeLists.txt | 2 ++ src/ui/MainWindow.cpp | 2 +- src/ui/RecipeSelectionDialog.cpp | 36 ++----------------- src/ui/RecipeTooltip.cpp | 52 ++++++++++++++++++++++++++++ src/ui/RecipeTooltip.h | 11 ++++++ src/ui/SchematicChoiceDialog.cpp | 45 +++++++++++++++++------- src/ui/SchematicChoiceDialog.h | 3 ++ 11 files changed, 134 insertions(+), 78 deletions(-) create mode 100644 src/ui/RecipeTooltip.cpp create mode 100644 src/ui/RecipeTooltip.h 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;