diff --git a/docs/requirements.md b/docs/requirements.md index f3e1536..09ebbbf 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -380,9 +380,16 @@ Any ship, module, building, or assembler recipe id that appears in no unlock gro Each option in the dialog displays the unlock group's display name — derived from its `id` (same display convention as building, module, and recipe ids) — and the list of items it would grant: its ship, module, building, and assembler-recipe ids (each shown with the same display convention as its respective selection dialog). The artifact option (if present) is displayed as a distinct entry with the name "Artifact". - Each option additionally displays a vertical list of recipe lines labeled "Unlocks recipes:", showing which recipes would newly become implicitly unlocked (REQ-LOCK-IMPLICIT) if this option were selected — every recipe, of whatever building, that is not currently implicitly unlocked but would become so after applying this option's effect. A Smelter or Reprocessing Plant recipe appears here exactly when the award makes something it can yield useful for the first time, which is as much a new production path as a miner recipe is. To compute this, all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, and the output items of the group's granted assembler recipes are added per step 1b, before recomputation. + Each option additionally displays what selecting it would **add to what the player can produce**. The unit is *usability*, not unlocking: a recipe is usable when it is unlocked (REQ-LOCK-IMPLICIT), the building that runs it can be placed (REQ-LOCK-BUILDING), and it has at least one output group it could be handed (REQ-LOCK-OUTPUT-POOL) — a recipe the player cannot run is no gain, whichever of those three is missing. Usability is computed for the current state and for the state the award would bring about, and the difference is what the option displays. Computing the second: all `materials` of the group's granted ship and module schematics are added to the base set per REQ-LOCK-IMPLICIT step 1a, the output items of the group's granted assembler recipes per step 1b, and the group's granted buildings are treated as placeable, before recomputation. - Each recipe is shown as a **recipe line** of the same two-row card the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON) and the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — on the first row, and the recipe drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) on the second. The lines are sorted alphabetically by recipe name. The line says everything there is to say about the recipe, so nothing in this list carries a tooltip describing it, as in the selection dialog (REQ-UI-SELECT-OPTIONS); the items the line names do explain themselves, on hover or click: the card is not itself clicked, the option being taken with the button beneath it (REQ-UI-ITEM-VALUE-TOOLTIP). If no recipes would be newly unlocked, the list shows "None". + The difference is shown as **two vertical lists of recipe lines**, because an award adds production in two different ways and a player shown the second as if it were the first reads it as a mistake — a recipe they already run, listed as new: + + - **"Unlocks recipes:"** — recipes not usable before and usable after. This covers all three ways that can happen: the recipe unlocked, its building became placeable, or its first output group became yieldable. A recipe unlocked long ago but never usable belongs to whichever later award finally makes it usable, not to the one that unlocked it. If there are none, the list shows "None". + - **"Upgrades recipes:"** — recipes already usable that gain at least one further output group. A Reprocessing Plant that starts yielding voidsteel is a real gain and one a list of recipe names alone cannot express. This list is omitted entirely when empty, rather than showing "None": having nothing to upgrade is the ordinary case, and a second "None" would only be noise. + + Each line states the recipe **whole**, including the groups it could already yield — the caption is what says whether the recipe is new to the player or newly better, so the line does not have to. Groups the recipe still could not be handed after the award are left out of both lists, exactly as they are wherever a recipe is drawn (REQ-UI-RECIPE-SUMMARY); the difference here is only that eligibility is judged against the state the award would bring about rather than the one the player is still in. + + Each recipe is shown as a **recipe line** of the same two-row card the item production tooltip uses (REQ-UI-ITEM-TOOLTIP): the icon of the building that runs it (REQ-UI-BUILD-ICON) and the recipe's name — by its `id`, using the same display convention as the assembler recipe-selection dialog — on the first row, and the recipe drawn as the recipe summary draws it (REQ-UI-RECIPE-SUMMARY) on the second. The lines are sorted alphabetically by recipe name, within each list. The line says everything there is to say about the recipe, so nothing in either list carries a tooltip describing it, as in the selection dialog (REQ-UI-SELECT-OPTIONS); the items the line names do explain themselves, on hover or click: the card is not itself clicked, the option being taken with the button beneath it (REQ-UI-ITEM-VALUE-TOOLTIP). **The dialog cannot be dismissed.** Clicking an option is the only thing that closes it: it has no close button, no Cancel, neither Escape nor Q dismisses it, and a click outside it does nothing (REQ-UI-DIALOG-DISMISS). The drop is a reward the player has earned by destroying the station set, and every way out of the dialog would have to either forfeit it or pick an option the player did not — so there is no way out but choosing. The dialog is modal and the game is paused meanwhile, so nothing is waiting on the decision. diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index 79867c6..d28af07 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -21,6 +21,17 @@ struct GrantedSchematic std::string displayName; }; +// One recipe shown in an option's preview, together with the output groups the player +// would be able to get out of it afterwards -- indices into the recipe's own +// `outputGroups`, in config order (REQ-DEF-SCHEMATIC-DROP). The indices are what the +// preview draws: which groups a recipe can yield is decided here, against the unlock +// state as it would stand after the award, and not by the dialog against the current one. +struct PreviewedRecipe +{ + std::string recipeId; + std::vector outputGroupIndices; +}; + // One option presented to the player in the schematic choice dialog // (REQ-DEF-SCHEMATIC-DROP). Built by the simulation when enemy stations are // destroyed; the UI reads these to populate the dialog. A non-artifact option @@ -40,8 +51,16 @@ struct SchematicChoiceOption // (empty for the artifact option). std::vector grantedItems; - // 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; + // What this option would add to the player's production options + // (REQ-DEF-SCHEMATIC-DROP), each sorted alphabetically by display name. The unit is + // usability rather than unlocking: a recipe is of no use while the building that + // runs it cannot be placed, so unlocking that building is as much a gain as + // unlocking the recipe was. + // + // Recipes the player could not use at all before and could after. + std::vector newlyUsableRecipes; + // Recipes they could already use that would gain at least one further output group + // -- a plant that starts yielding voidsteel is a real gain, and one a list of recipe + // names alone cannot express. + std::vector upgradedRecipes; }; diff --git a/src/lib/sim/UnlockState.cpp b/src/lib/sim/UnlockState.cpp index c18a415..f261440 100644 --- a/src/lib/sim/UnlockState.cpp +++ b/src/lib/sim/UnlockState.cpp @@ -6,6 +6,21 @@ #include "DisplayName.h" #include "StateChecksum.h" +namespace +{ + +// Both preview lists read as a list of names, so they are ordered as one (REQ-DEF-SCHEMATIC-DROP). +void sortByDisplayName(std::vector& recipes) +{ + std::sort(recipes.begin(), recipes.end(), + [](const PreviewedRecipe& lhs, const PreviewedRecipe& rhs) + { + return toDisplayName(lhs.recipeId) < toDisplayName(rhs.recipeId); + }); +} + +} // namespace + UnlockState::UnlockState(const GameConfig& config) : m_config(config) { @@ -145,20 +160,49 @@ SchematicChoiceOption UnlockState::makeUnlockOption(const UnlockGroupDef& group) option.grantedItems.push_back({SchematicType::Recipe, id, toDisplayName(id)}); } - // REQ-DEF-SCHEMATIC-DROP: preview recipes newly implicitly unlocked by - // awarding this whole group. Seed the hypothetical explicit-unlock sets with - // every grant (ship + module materials via step 1a, recipe outputs via step - // 1b), then diff against the current implicit set. + // REQ-DEF-SCHEMATIC-DROP: preview what awarding this whole group would add. Seed the + // hypothetical explicit-unlock sets with every grant (ship + module materials via + // step 1a, recipe outputs via step 1b, and the granted buildings, which no traversal + // covers), then diff what is usable before against what would be usable after. std::set hypotheticalShipIds = getUnlockedShipSchematicIds(); std::set hypotheticalModuleIds = getUnlockedModuleSchematicIds(); std::set hypotheticalRecipeSchematicIds = m_unlockedRecipeSchematicIds; - for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); } - for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); } - for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); } + std::set hypotheticalBuildingIds = getUnlockedBuildingIds(); + for (const std::string& id : group.ships) { hypotheticalShipIds.insert(id); } + for (const std::string& id : group.modules) { hypotheticalModuleIds.insert(id); } + for (const std::string& id : group.recipes) { hypotheticalRecipeSchematicIds.insert(id); } + for (const std::string& id : group.buildings) { hypotheticalBuildingIds.insert(id); } + + UnlockedSets current; + current.itemIds = m_unlockedItemIds; + current.recipeIds = m_unlockedRecipeIds; const UnlockedSets hypothetical = computeUnlockedSets( hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); - option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); + + const std::map> usableBefore = + computeUsableRecipeGroups(current, getUnlockedBuildingIds()); + const std::map> usableAfter = + computeUsableRecipeGroups(hypothetical, hypotheticalBuildingIds); + + for (const std::pair>& entry : usableAfter) + { + const std::map>::const_iterator before = + usableBefore.find(entry.first); + if (before == usableBefore.end()) + { + option.newlyUsableRecipes.push_back(PreviewedRecipe{entry.first, entry.second}); + } + else if (before->second.size() < entry.second.size()) + { + // Nothing is ever taken away -- unlocking only ever adds -- so a group count + // that grew means groups were gained, and the after list contains the before + // list. Comparing counts is therefore the whole test. + option.upgradedRecipes.push_back(PreviewedRecipe{entry.first, entry.second}); + } + } + sortByDisplayName(option.newlyUsableRecipes); + sortByDisplayName(option.upgradedRecipes); return option; } @@ -214,6 +258,16 @@ std::set UnlockState::getUnlockedModuleSchematicIds() const return ids; } +std::set UnlockState::getUnlockedBuildingIds() const +{ + std::set ids; + for (const auto& [id, state] : m_buildingLevels) + { + if (state.unlocked) { ids.insert(id); } + } + return ids; +} + UnlockState::UnlockedSets UnlockState::computeUnlockedSets( const std::set& unlockedShipSchematicIds, const std::set& unlockedModuleSchematicIds, @@ -340,20 +394,37 @@ UnlockState::UnlockedSets UnlockState::computeUnlockedSets( return result; } -std::vector UnlockState::computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const +std::map> UnlockState::computeUsableRecipeGroups( + const UnlockedSets& sets, const std::set& unlockedBuildingIds) const { - std::vector recipeIds; - for (const std::string& recipeId : hypothetical.recipeIds) + const std::function isItemUnlockedHere = + [&sets](const std::string& itemId) { return sets.itemIds.count(itemId) > 0; }; + + std::map> usable; + for (const RecipeDef& recipe : m_config.recipes.recipes) { - if (m_unlockedRecipeIds.count(recipeId) > 0) { continue; } - recipeIds.push_back(recipeId); + if (sets.recipeIds.count(recipe.id) == 0) { continue; } + + // Building types with no config entry are unrestricted, as isBuildingUnlocked + // treats them (REQ-LOCK-BUILDING). + const BuildingDef* def = m_config.buildings.findBuildingDef(recipe.building); + if (def != nullptr && unlockedBuildingIds.count(def->id) == 0) { continue; } + + std::vector groupIndices; + for (std::size_t i = 0; i < recipe.outputGroups.size(); ++i) + { + if (isOutputGroupUnlocked(recipe.outputGroups[i], isItemUnlockedHere)) + { + groupIndices.push_back(static_cast(i)); + } + } + // An unlocked recipe has at least one yieldable group by REQ-LOCK-IMPLICIT step + // 4, so this is a consistency check on that rather than a case to handle. + if (groupIndices.empty()) { continue; } + + usable[recipe.id] = std::move(groupIndices); } - std::sort(recipeIds.begin(), recipeIds.end(), - [](const std::string& lhs, const std::string& rhs) - { - return toDisplayName(lhs) < toDisplayName(rhs); - }); - return recipeIds; + return usable; } // --------------------------------------------------------------------------- diff --git a/src/lib/sim/UnlockState.h b/src/lib/sim/UnlockState.h index 5e013f7..cbff04e 100644 --- a/src/lib/sim/UnlockState.h +++ b/src/lib/sim/UnlockState.h @@ -94,10 +94,18 @@ private: // Current explicit-unlock id sets, derived from m_schematicLevels / m_moduleSchematicLevels. std::set getUnlockedShipSchematicIds() const; std::set getUnlockedModuleSchematicIds() const; + std::set getUnlockedBuildingIds() const; - // Ids (sorted alphabetically by display name) of the recipes in - // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. - std::vector computeNewlyUnlockedRecipeIds(const UnlockedSets& hypothetical) const; + // What the player can actually get out of the game in a given state: for every recipe + // they could run, which of its output groups they could be handed. A recipe absent + // from the map is of no use to them -- either it is locked (REQ-LOCK-IMPLICIT) or the + // building that runs it cannot be placed (REQ-LOCK-BUILDING), and a recipe they + // cannot run is no different from one that does not exist. + // + // Diffing this between the current state and the state an award would bring about is + // what the schematic choice dialog previews (REQ-DEF-SCHEMATIC-DROP). + std::map> computeUsableRecipeGroups( + const UnlockedSets& sets, const std::set& unlockedBuildingIds) const; // Determinism helpers — fold sub-state into the hasher in deterministic order. static void appendSchematicMap(Hasher& hasher, diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index 661ed21..c1453aa 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -1,4 +1,8 @@ #include +#include +#include +#include +#include #include "catch.hpp" @@ -13,6 +17,7 @@ #include "SimulationTestAccess.h" #include "StationBodyComponent.h" #include "TestConfig.h" +#include "UnlockState.h" // Zeros the HP of both enemy defence stations and advances one tick so that // tickDeathsAndLoot fires, triggering the push and schematic choices. @@ -316,13 +321,182 @@ TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items } // --------------------------------------------------------------------------- -// Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP) +// Unlock dialog: what an award adds (REQ-DEF-SCHEMATIC-DROP) // --------------------------------------------------------------------------- -TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups", +namespace +{ + +// A miniature config for the preview diff. One ship the player starts with and one they +// can be granted, an assembler recipe feeding each, and a reprocessing recipe with a +// group for either material -- run in a plant that is itself gated. Between them these +// express all three ways an award can add something: a recipe, an output group, and the +// building needed to run either. +// +// Hand-built rather than taken from the test config, which has no gated building and no +// recipe that gains a group, so neither case would be reachable through it. +GameConfig makePreviewConfig() +{ + GameConfig cfg; + + BuildingDef assembler{}; + assembler.id = "assembler"; + assembler.type = BuildingType::Assembler; + cfg.buildings.buildings.push_back(assembler); + + BuildingDef plant{}; + plant.id = "reprocessing_plant"; + plant.type = BuildingType::ReprocessingPlant; + cfg.buildings.buildings.push_back(plant); + + ShipDef starter{}; + starter.id = "starter"; + starter.schematic.materials.push_back(RecipeIngredient{"alpha", 1}); + cfg.ships.ships.push_back(starter); + + ShipDef late{}; + late.id = "late"; + late.schematic.materials.push_back(RecipeIngredient{"beta", 1}); + cfg.ships.ships.push_back(late); + + RecipeDef makeAlpha{}; + makeAlpha.id = "make_alpha"; + makeAlpha.building = BuildingType::Assembler; + makeAlpha.inputs.push_back(RecipeIngredient{"ore", 1}); + makeAlpha.outputGroups.push_back( + RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, std::nullopt}); + cfg.recipes.recipes.push_back(makeAlpha); + + RecipeDef makeBeta{}; + makeBeta.id = "make_beta"; + makeBeta.building = BuildingType::Assembler; + makeBeta.inputs.push_back(RecipeIngredient{"ore", 1}); + makeBeta.outputGroups.push_back( + RecipeOutputGroup{{RecipeOutput{"beta", 1}}, std::nullopt}); + cfg.recipes.recipes.push_back(makeBeta); + + RecipeDef reprocess{}; + reprocess.id = "reprocess"; + reprocess.building = BuildingType::ReprocessingPlant; + reprocess.inputs.push_back(RecipeIngredient{"junk", 4}); + reprocess.outputGroups.push_back( + RecipeOutputGroup{{RecipeOutput{"alpha", 1}}, 0.5}); + reprocess.outputGroups.push_back( + RecipeOutputGroup{{RecipeOutput{"beta", 1}}, 0.5}); + cfg.recipes.recipes.push_back(reprocess); + + UnlockGroupDef lateShipGroup{}; + lateShipGroup.id = "late_ship"; + lateShipGroup.ships.push_back("late"); + cfg.unlocks.groups.push_back(lateShipGroup); + + UnlockGroupDef plantGroup{}; + plantGroup.id = "plant"; + plantGroup.buildings.push_back("reprocessing_plant"); + cfg.unlocks.groups.push_back(plantGroup); + + return cfg; +} + +std::vector recipeIdsOf(const std::vector& previewed) +{ + std::vector ids; + for (const PreviewedRecipe& entry : previewed) + { + ids.push_back(entry.recipeId); + } + return ids; +} + +} // namespace + +TEST_CASE("RecipeSchematic: a recipe is no gain while its building is locked", + "[recipe_schematic]") +{ + // reprocess is unlocked from the start -- one of its groups yields alpha, which the + // starting ship calls for -- but the plant that runs it is not. Granting the late + // ship makes beta wanted, which would add reprocess's second group, yet the plant is + // still unbuildable, so the option offers the player nothing through it. + const GameConfig cfg = makePreviewConfig(); + UnlockState state(cfg); + state.initializeUnlockState(); + + REQUIRE(state.isRecipeUnlocked("reprocess")); + REQUIRE_FALSE(state.isBuildingUnlocked(BuildingType::ReprocessingPlant)); + + const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]); + REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector{"make_beta"}); + REQUIRE(option.upgradedRecipes.empty()); +} + +TEST_CASE("RecipeSchematic: unlocking the building is itself the gain", "[recipe_schematic]") +{ + // The recipe was unlocked all along and never listed, because it was of no use. The + // award that makes it usable is the one that must say so, however long ago the recipe + // itself unlocked. + const GameConfig cfg = makePreviewConfig(); + UnlockState state(cfg); + state.initializeUnlockState(); + + const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[1]); + REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector{"reprocess"}); + // Only the group it can actually yield: beta is still nobody's material. + REQUIRE(option.newlyUsableRecipes[0].outputGroupIndices == std::vector{0}); + REQUIRE(option.upgradedRecipes.empty()); +} + +TEST_CASE("RecipeSchematic: a recipe gaining an output group is an upgrade, not an unlock", + "[recipe_schematic]") +{ + // With the plant built, reprocess yields alpha alone. Granting the late ship makes + // beta wanted and so opens its second group: the same recipe, worth more. Listing it + // as newly unlocked would be a lie the player would read as a bug. + const GameConfig cfg = makePreviewConfig(); + UnlockState state(cfg); + state.initializeUnlockState(); + state.awardUnlockGroup(state.makeUnlockOption(cfg.unlocks.groups[1])); + + const SchematicChoiceOption option = state.makeUnlockOption(cfg.unlocks.groups[0]); + REQUIRE(recipeIdsOf(option.newlyUsableRecipes) == std::vector{"make_beta"}); + REQUIRE(recipeIdsOf(option.upgradedRecipes) == std::vector{"reprocess"}); + // Drawn whole, both groups, the one it already had included: the caption is what says + // the recipe is not new, so the line does not have to. + REQUIRE(option.upgradedRecipes[0].outputGroupIndices == std::vector({0, 1})); +} + +// --------------------------------------------------------------------------- +// Unlock dialog: preview shape and ordering (REQ-DEF-SCHEMATIC-DROP) +// --------------------------------------------------------------------------- + +TEST_CASE("RecipeSchematic: the preview lists are sorted, deduplicated, and drawable", "[recipe_schematic]") { Simulation sim(loadTestConfig()); + const GameConfig cfg = loadTestConfig(); + + const std::function&)> checkList = + [&cfg](const std::vector& previewed) + { + // Strictly ascending display names imply sorted and deduplicated. + for (std::size_t j = 1; j < previewed.size(); ++j) + { + CHECK(toDisplayName(previewed[j - 1].recipeId) + < toDisplayName(previewed[j].recipeId)); + } + // Every entry must be drawable: a recipe the dialog can find, and at least + // one group to put on the right of the arrow (REQ-DEF-SCHEMATIC-DROP). + for (const PreviewedRecipe& entry : previewed) + { + const RecipeDef* def = cfg.recipes.findRecipeDef(entry.recipeId); + REQUIRE(def != nullptr); + CHECK_FALSE(entry.outputGroupIndices.empty()); + for (int index : entry.outputGroupIndices) + { + CHECK(index >= 0); + CHECK(static_cast(index) < def->outputGroups.size()); + } + } + }; for (int i = 0; i < 100; ++i) { @@ -331,44 +505,43 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) { - // Strictly ascending display names imply sorted and deduplicated. - for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j) - { - CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1]) - < toDisplayName(opt.newlyUnlockedRecipeIds[j])); - } + checkList(opt.newlyUsableRecipes); + checkList(opt.upgradedRecipes); } SimulationTestAccess::applySchematicChoice(sim, 0); } } -TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked", +TEST_CASE("RecipeSchematic: newlyUsableRecipes matches what the award really makes usable", "[recipe_schematic]") { Simulation sim(loadTestConfig()); const GameConfig cfg = loadTestConfig(); - auto unlockedTrackedRecipeIds = [&]() - { - std::set ids; - for (const RecipeDef& def : cfg.recipes.recipes) + // The oracle asks the two public conditions, not the group bookkeeping the preview is + // built from: a recipe is of use when the player may run it and may place what runs + // it (REQ-LOCK-UI-RECIPE, REQ-LOCK-BUILDING). + const std::function()> usableRecipeIds = + [&sim, &cfg]() { - if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) - && sim.isRecipeUnlocked(def.id)) + std::set ids; + for (const RecipeDef& def : cfg.recipes.recipes) { - ids.insert(def.id); + if (sim.isRecipeUnlocked(def.id) && sim.isBuildingUnlocked(def.building)) + { + ids.insert(def.id); + } } - } - return ids; - }; + return ids; + }; for (int i = 0; i < 100; ++i) { killEnemyStations(sim); if (!sim.hasSchematicChoicesPending()) { continue; } - const std::set unlockedBefore = unlockedTrackedRecipeIds(); + const std::set usableBefore = usableRecipeIds(); const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0]; SimulationTestAccess::applySchematicChoice(sim, 0); @@ -376,8 +549,8 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually 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) + if (sim.isRecipeUnlocked(def.id) && sim.isBuildingUnlocked(def.building) + && usableBefore.count(def.id) == 0) { expected.push_back(def.id); } @@ -388,7 +561,12 @@ TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually return toDisplayName(lhs) < toDisplayName(rhs); }); - REQUIRE(choice.newlyUnlockedRecipeIds == expected); + std::vector previewed; + for (const PreviewedRecipe& entry : choice.newlyUsableRecipes) + { + previewed.push_back(entry.recipeId); + } + REQUIRE(previewed == expected); } } diff --git a/src/ui/RecipeLineRow.cpp b/src/ui/RecipeLineRow.cpp index 01e1428..8ae5a4d 100644 --- a/src/ui/RecipeLineRow.cpp +++ b/src/ui/RecipeLineRow.cpp @@ -48,20 +48,30 @@ void clearRow(QHBoxLayout* layout) } // namespace +namespace +{ + +std::vector toAmounts(const RecipeOutputGroup& group) +{ + std::vector amounts; + amounts.reserve(group.items.size()); + for (const RecipeOutput& out : group.items) + { + amounts.push_back(RecipeLineRow::Amount{ out.item, out.amount }); + } + return amounts; +} + +} // namespace + std::vector> RecipeLineRow::toOutputGroups( - const RecipeDef& recipe) + const RecipeDef& recipe, const std::vector& groupIndices) { std::vector> groups; - groups.reserve(recipe.outputGroups.size()); - for (const RecipeOutputGroup& group : recipe.outputGroups) + groups.reserve(groupIndices.size()); + for (int index : groupIndices) { - std::vector amounts; - amounts.reserve(group.items.size()); - for (const RecipeOutput& out : group.items) - { - amounts.push_back(Amount{ out.item, out.amount }); - } - groups.push_back(std::move(amounts)); + groups.push_back(toAmounts(recipe.outputGroups[static_cast(index)])); } return groups; } @@ -76,13 +86,7 @@ std::vector> RecipeLineRow::toUnlockedOutputG { if (!isOutputGroupUnlocked(group, isItemUnlocked)) { continue; } - std::vector amounts; - amounts.reserve(group.items.size()); - for (const RecipeOutput& out : group.items) - { - amounts.push_back(Amount{ out.item, out.amount }); - } - groups.push_back(std::move(amounts)); + groups.push_back(toAmounts(group)); } return groups; } diff --git a/src/ui/RecipeLineRow.h b/src/ui/RecipeLineRow.h index 85b01e1..f290904 100644 --- a/src/ui/RecipeLineRow.h +++ b/src/ui/RecipeLineRow.h @@ -85,12 +85,16 @@ public: bool isEmpty() const { return inputs.empty() && outputGroups.empty(); } }; - // A recipe's output groups as this row states them (REQ-MAT-OUTPUT-GROUP). Shared, so - // that every place drawing a recipe -- the summary, the option buttons, the tooltip - // lines -- converts it the same way rather than each keeping its own copy. - static std::vector> toOutputGroups(const RecipeDef& recipe); + // The named output groups of a recipe as this row states them + // (REQ-MAT-OUTPUT-GROUP), by index into the recipe's own `outputGroups`. For a caller + // that has already been told which groups to draw -- the unlock preview, whose + // groups were chosen against the state an award would bring about rather than the + // current one (REQ-DEF-SCHEMATIC-DROP). + static std::vector> toOutputGroups( + const RecipeDef& recipe, const std::vector& groupIndices); - // The same, less the groups the player cannot be handed: a group is dropped whole + // Every group the player can be handed, for a caller stating what a recipe does + // now. A group is dropped whole // where any of its items is locked, matching what the pool would pick // (REQ-LOCK-OUTPUT-POOL). This is what keeps a Reprocessing Plant from advertising a // yield it will never produce (REQ-UI-RECIPE-SUMMARY). diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index 4e08601..958b84d 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -25,6 +25,61 @@ std::vector toAmounts( } +// One captioned list of previewed recipes. Both lists are drawn identically -- the +// caption is what says whether the recipe is new to the player or newly better +// (REQ-DEF-SCHEMATIC-DROP), so each line states the whole recipe either way, including +// the groups it could already yield. +void addRecipePreview(QWidget* card, QVBoxLayout* cardLayout, const QString& caption, + const std::vector& previewed, bool showNone, + const RecipesConfig& recipes, const ItemTooltipContext& context) +{ + QLabel* headerLabel = new QLabel(caption, card); + QFont headerFont = headerLabel->font(); + headerFont.setBold(true); + headerLabel->setFont(headerFont); + headerLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(headerLabel); + + if (previewed.empty()) + { + if (showNone) + { + QLabel* noneLabel = new QLabel(SchematicChoiceDialog::tr("None"), card); + noneLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(noneLabel); + } + return; + } + + for (const PreviewedRecipe& entry : previewed) + { + const RecipeDef* def = recipes.findRecipeDef(entry.recipeId); + if (def == nullptr) { continue; } + + // The recipe drawn as it is everywhere else, led by the building that runs it + // and its name, so the line says everything there is to say about the recipe + // itself (REQ-DEF-SCHEMATIC-DROP). Its groups were decided against the unlock + // state the award would bring about, which is why they arrive named rather than + // being filtered here against the state the player is still in. The items it + // names still explain themselves, and the card around them is not clicked -- the + // Select button below it is -- so a click on one says it at once + // (REQ-UI-ITEM-VALUE-TOOLTIP). + RecipeLineRow::Spec spec; + spec.building = def->building; + spec.name = QString::fromStdString(toDisplayName(def->id)); + spec.inputs = toAmounts(def->inputs); + spec.outputGroups = RecipeLineRow::toOutputGroups(*def, entry.outputGroupIndices); + spec.durationSeconds = def->durationSeconds; + + RecipeLineRow* line = + new RecipeLineRow(context.itemIcons, context.buildingIcons, card); + line->setCardChrome(true); + line->setItemTooltips(context, TooltipTrigger::Trigger::HoverAndClick); + cardLayout->addWidget(line); + line->setLine(spec); + } +} + QString grantKindLabel(SchematicType type) { switch (type) @@ -108,47 +163,19 @@ SchematicChoiceDialog::SchematicChoiceDialog( cardLayout->addWidget(grantLabel); } - 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); - - if (option.newlyUnlockedRecipeIds.empty()) + // What the option would add to what the player can produce + // (REQ-DEF-SCHEMATIC-DROP): recipes they could not use at all, then recipes + // they already run that would start yielding something further. The second + // list is left out entirely when empty -- an option that upgrades nothing is + // the ordinary case, and a second "None" would only be noise. + addRecipePreview(card, cardLayout, tr("Unlocks recipes:"), + option.newlyUsableRecipes, /*showNone=*/true, + recipes, context); + if (!option.upgradedRecipes.empty()) { - QLabel* noneLabel = new QLabel(tr("None"), card); - noneLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(noneLabel); - } - else - { - for (const std::string& recipeId : option.newlyUnlockedRecipeIds) - { - const RecipeDef* def = recipes.findRecipeDef(recipeId); - if (def == nullptr) { continue; } - - // The recipe drawn as it is everywhere else, led by the building - // that runs it and its name, so the line says everything there is to - // say about the recipe itself (REQ-DEF-SCHEMATIC-DROP). The items it - // names still explain themselves, and the card around them is not - // clicked -- the Select button below it is -- so a click on one says - // it at once (REQ-UI-ITEM-VALUE-TOOLTIP). - RecipeLineRow::Spec spec; - spec.building = def->building; - spec.name = QString::fromStdString(toDisplayName(def->id)); - spec.inputs = toAmounts(def->inputs); - spec.outputGroups = RecipeLineRow::toOutputGroups(*def); - spec.durationSeconds = def->durationSeconds; - - RecipeLineRow* line = - new RecipeLineRow(context.itemIcons, context.buildingIcons, card); - line->setCardChrome(true); - line->setItemTooltips(context, - TooltipTrigger::Trigger::HoverAndClick); - cardLayout->addWidget(line); - line->setLine(spec); - } + addRecipePreview(card, cardLayout, tr("Upgrades recipes:"), + option.upgradedRecipes, /*showNone=*/false, + recipes, context); } }