From 3716c2b734854a5ce29dcb716e97a2a6095f0917 Mon Sep 17 00:00:00 2001 From: mlangkabel Date: Sat, 13 Jun 2026 18:01:59 +0200 Subject: [PATCH] show implicitly unlocked items in schematic unlock dialog --- docs/requirements.md | 7 ++ src/lib/core/SchematicChoiceOption.h | 6 ++ src/lib/sim/Simulation.cpp | 100 +++++++++++++++++++++++---- src/lib/sim/Simulation.h | 20 ++++++ src/test/RecipeSchematicTest.cpp | 83 ++++++++++++++++++++++ src/ui/SchematicChoiceDialog.cpp | 26 +++++++ 6 files changed, 230 insertions(+), 12 deletions(-) diff --git a/docs/requirements.md b/docs/requirements.md index f9ceb55..6aa5e2a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -276,6 +276,13 @@ Modules in `modules.toml` define a `surface_mask` — a list of strings that des Each option in the dialog displays: the schematic name (ship `display_name` from `ships.toml`, module `id` from `modules.toml`, or the output item type for assembler recipes), the schematic type (ship, module, or assembler recipe), and whether selecting it would be a **new unlock** or a **level-up** (showing the target level for level-ups). Assembler recipe schematics are always new unlocks since they are removed from the pool once awarded. + 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: + - For a ship or module schematic that would be a **new unlock**, its `materials` are added to the base set per REQ-LOCK-IMPLICIT step 1a before recomputation. + - For a ship or module schematic **level-up**, the implicit unlock set is unchanged, so the list is always empty. + - 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". + The player selects one option by clicking it. The selected schematic is applied and the dialog closes: For a **ship or module schematic**: if the player does not yet have the schematic, it is unlocked (ship schematics unlock the corresponding shipyard selection; module schematics unlock the module type for placement in the layout configuration dialog (REQ-MOD-UI-DIALOG)). If the player already has it, the schematic's `player_production_level` is incremented by 1 — for ship schematics, subsequent ships of that type are produced at a higher level; for module schematics, all instances of that module type use the higher level in their stat formulas. diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index a5e038d..aa3a8e4 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -1,6 +1,7 @@ #pragma once #include +#include enum class SchematicType { @@ -19,4 +20,9 @@ struct SchematicChoiceOption std::string displayName; bool isNewUnlock; int targetLevel; + + // 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; }; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 1f73f57..99bda9b 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -579,6 +579,9 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) const int numChoices = std::min(static_cast(pool.size()), 3); m_pendingSchematicChoices.clear(); + const std::set currentShipIds = getUnlockedShipSchematicIds(); + const std::set currentModuleIds = getUnlockedModuleSchematicIds(); + for (int i = 0; i < numChoices; ++i) { std::uniform_int_distribution dist(0, static_cast(pool.size()) - 1 - i); @@ -622,6 +625,28 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) } } + // REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by this option. + std::set hypotheticalShipIds = currentShipIds; + std::set hypotheticalModuleIds = currentModuleIds; + std::set hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds; + + if (entry.type == DropType::Ship && option.isNewUnlock) + { + hypotheticalShipIds.insert(entry.id); + } + else if (entry.type == DropType::Module && option.isNewUnlock) + { + hypotheticalModuleIds.insert(entry.id); + } + else if (entry.type == DropType::Recipe) + { + hypotheticalRecipeSchematicIds.insert(entry.id); + } + + const UnlockedSets hypothetical = computeUnlockedSets( + hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); + option.newlyUnlockedItemNames = computeNewlyUnlockedItemNames(hypothetical); + m_pendingSchematicChoices.push_back(option); } } @@ -654,34 +679,64 @@ void Simulation::applySchematicChoice(int choiceIndex) void Simulation::recomputeUnlocked() { - m_unlockedItemIds.clear(); - m_unlockedRecipeIds.clear(); + const UnlockedSets result = computeUnlockedSets( + getUnlockedShipSchematicIds(), getUnlockedModuleSchematicIds(), m_unlockedRecipeSchematicIds); + m_unlockedItemIds = result.itemIds; + m_unlockedRecipeIds = result.recipeIds; +} + +std::set Simulation::getUnlockedShipSchematicIds() const +{ + std::set ids; + for (const auto& [id, state] : m_schematicLevels) + { + if (state.unlocked) { ids.insert(id); } + } + return ids; +} + +std::set Simulation::getUnlockedModuleSchematicIds() const +{ + std::set ids; + for (const auto& [id, state] : m_moduleSchematicLevels) + { + if (state.unlocked) { ids.insert(id); } + } + return ids; +} + +Simulation::UnlockedSets Simulation::computeUnlockedSets( + const std::set& unlockedShipSchematicIds, + const std::set& unlockedModuleSchematicIds, + const std::set& unlockedRecipeSchematicIds) const +{ + UnlockedSets result; for (const ShipDef& def : m_config.ships.ships) { - if (!isSchematicUnlocked(def.id)) { continue; } + if (unlockedShipSchematicIds.count(def.id) == 0) { continue; } for (const RecipeIngredient& mat : def.schematic.materials) { - m_unlockedItemIds.insert(mat.item); + result.itemIds.insert(mat.item); } } for (const ModuleDef& def : m_config.modules.modules) { - if (!isModuleSchematicUnlocked(def.id)) { continue; } + if (unlockedModuleSchematicIds.count(def.id) == 0) { continue; } for (const RecipeIngredient& mat : def.materials) { - m_unlockedItemIds.insert(mat.item); + result.itemIds.insert(mat.item); } } for (const RecipeDef& def : m_config.recipes.recipes) { if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value() - && m_unlockedRecipeSchematicIds.count(def.id) > 0) + && unlockedRecipeSchematicIds.count(def.id) > 0) { for (const RecipeOutput& out : def.outputs) { - m_unlockedItemIds.insert(out.item); + result.itemIds.insert(out.item); } } } @@ -700,14 +755,14 @@ void Simulation::recomputeUnlocked() } if (recipe.building == BuildingType::Assembler && recipe.unlockAtStationLevel.has_value() - && m_unlockedRecipeSchematicIds.count(recipe.id) == 0) + && unlockedRecipeSchematicIds.count(recipe.id) == 0) { continue; } bool producesUnlocked = false; for (const RecipeOutput& out : recipe.outputs) { - if (m_unlockedItemIds.count(out.item) > 0) + if (result.itemIds.count(out.item) > 0) { producesUnlocked = true; break; @@ -718,17 +773,38 @@ void Simulation::recomputeUnlocked() if (recipe.building == BuildingType::Miner || recipe.building == BuildingType::Assembler) { - m_unlockedRecipeIds.insert(recipe.id); + result.recipeIds.insert(recipe.id); } for (const RecipeIngredient& ing : recipe.inputs) { - if (m_unlockedItemIds.insert(ing.item).second) + if (result.itemIds.insert(ing.item).second) { changed = true; } } } } + + return result; +} + +std::vector Simulation::computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const +{ + std::set itemNames; + 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; + } + } + return std::vector(itemNames.begin(), itemNames.end()); } bool Simulation::isRecipeUnlocked(const std::string& recipeId) const diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 98d24ff..8b3f363 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -154,6 +154,26 @@ private: // Recomputes m_unlockedRecipeIds and m_unlockedItemIds from current schematic state. void recomputeUnlocked(); + // Result of the REQ-LOCK-IMPLICIT traversal. + struct UnlockedSets + { + std::set itemIds; + std::set recipeIds; + }; + + // Pure REQ-LOCK-IMPLICIT traversal given hypothetical explicit-unlock sets. + UnlockedSets computeUnlockedSets(const std::set& unlockedShipSchematicIds, + const std::set& unlockedModuleSchematicIds, + const std::set& unlockedRecipeSchematicIds) const; + + // Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels. + std::set getUnlockedShipSchematicIds() const; + std::set getUnlockedModuleSchematicIds() const; + + // Display names (deduplicated, alphabetical) of output items of recipes in + // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. + std::vector computeNewlyUnlockedItemNames(const UnlockedSets& hypothetical) const; + EntityAdmin m_admin; BeltSystem m_beltSystem; std::unique_ptr m_buildingSystem; diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index 47f484d..c48b338 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -3,6 +3,7 @@ #include "catch.hpp" #include "ConfigLoader.h" +#include "DisplayName.h" #include "FactionComponent.h" #include "GameConfig.h" #include "HealthComponent.h" @@ -277,3 +278,85 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items REQUIRE(sim.isItemUnlocked("premium_circuit")); } +// --------------------------------------------------------------------------- +// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP) +// --------------------------------------------------------------------------- + +TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and empty for level-ups", + "[recipe_schematic]") +{ + Simulation sim(loadConfig()); + + for (int i = 0; i < 100; ++i) + { + killEnemyStations(sim); + if (!sim.hasSchematicChoicesPending()) { continue; } + + for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) + { + // Strictly ascending implies sorted and deduplicated. + for (std::size_t j = 1; j < opt.newlyUnlockedItemNames.size(); ++j) + { + CHECK(opt.newlyUnlockedItemNames[j - 1] < opt.newlyUnlockedItemNames[j]); + } + + // A level-up doesn't change the explicit unlock state, so the + // implicit unlock set - and thus this preview - must be empty. + if (!opt.isNewUnlock) + { + CHECK(opt.newlyUnlockedItemNames.empty()); + } + } + + sim.applySchematicChoice(0); + } +} + +TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually become unlocked", + "[recipe_schematic]") +{ + Simulation sim(loadConfig()); + const GameConfig cfg = loadConfig(); + + auto unlockedTrackedRecipeIds = [&]() + { + std::set ids; + for (const RecipeDef& def : cfg.recipes.recipes) + { + if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) + && sim.isRecipeUnlocked(def.id)) + { + ids.insert(def.id); + } + } + return ids; + }; + + for (int i = 0; i < 100; ++i) + { + killEnemyStations(sim); + if (!sim.hasSchematicChoicesPending()) { continue; } + + const std::set unlockedBefore = unlockedTrackedRecipeIds(); + const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0]; + + sim.applySchematicChoice(0); + + std::set expectedNames; + 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)); + } + } + } + const std::vector expected(expectedNames.begin(), expectedNames.end()); + + REQUIRE(choice.newlyUnlockedItemNames == expected); + } +} + diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 5eec3a9..92b8091 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include SchematicChoiceDialog::SchematicChoiceDialog( @@ -74,6 +75,31 @@ SchematicChoiceDialog::SchematicChoiceDialog( statusLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(statusLabel); + QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes for:"), card); + QFont unlocksHeaderFont = unlocksHeaderLabel->font(); + unlocksHeaderFont.setBold(true); + unlocksHeaderLabel->setFont(unlocksHeaderFont); + unlocksHeaderLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksHeaderLabel); + + QString unlocksText; + if (option.newlyUnlockedItemNames.empty()) + { + unlocksText = tr("None"); + } + else + { + QStringList itemLines; + for (const std::string& itemName : option.newlyUnlockedItemNames) + { + itemLines << QString::fromStdString(itemName); + } + unlocksText = itemLines.join("\n"); + } + QLabel* unlocksLabel = new QLabel(unlocksText, card); + unlocksLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(unlocksLabel); + QPushButton* selectButton = new QPushButton(tr("Select"), card); cardLayout->addWidget(selectButton);