diff --git a/bin/app/data/config/modules.toml b/bin/app/data/config/modules.toml index 66d39b7..28a750f 100644 --- a/bin/app/data/config/modules.toml +++ b/bin/app/data/config/modules.toml @@ -7,10 +7,9 @@ # Combat stats are placeholders until the arena balancing pass; # production_time_seconds values come from the numbers pass. # -# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending -# the pacing pass. The railguns are the exception: railgun_m is gated to -# station level 1, and railgun_l requires railgun_m to be unlocked first -# (unlock_requires) — a demonstration of the prerequisite chain. +# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a +# module id granted by an unlock group starts locked and is awarded via a +# defence station drop; ids absent from unlocks.toml (railgun_s) start unlocked. # # Surface mask footprint ladder — footprints gate which hulls can mount a # module, purely through geometry (see ships.toml for the matching hull @@ -33,7 +32,6 @@ [[module]] id = "railgun_s" tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull." -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "railgun_s_module", amount = 1}] production_time_seconds = 1 @@ -49,7 +47,6 @@ attack_rate_hz = 2.0 [[module]] id = "railgun_m" tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot." -unlock_at_station_level = 2 surface_mask = [ "OO", "OO"] @@ -67,8 +64,6 @@ attack_rate_hz = 1.5 [[module]] id = "railgun_l" tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot." -unlock_at_station_level = 6 -unlock_requires = ["railgun_m"] surface_mask = [ "OOO", "OOO", @@ -90,7 +85,6 @@ attack_rate_hz = 0.8 [[module]] id = "salvager" tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold." -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "salvager_module", amount = 1}] production_time_seconds = 1 @@ -106,7 +100,6 @@ collection_rate_hz = 0.5 [[module]] id = "repair_tool" tooltip = "Repairs damaged friendly ships and defence stations within range." -unlock_at_station_level = 0 surface_mask = ["O"] materials = [{item = "repair_tool_module", amount = 1}] production_time_seconds = 1 @@ -125,7 +118,6 @@ repair_range_m = 80 [[module]] id = "afterburner" tooltip = "Greatly boosts top speed and forward acceleration." -unlock_at_station_level = 2 surface_mask = ["OOO"] materials = [{item = "afterburner_module", amount = 1}] production_time_seconds = 1 @@ -140,7 +132,6 @@ added_main_acceleration_mpss = 60 [[module]] id = "maneuvering_thrusters" tooltip = "Improves top speed and lateral/braking acceleration." -unlock_at_station_level = 1 surface_mask = ["OO"] materials = [{item = "maneuvering_thrusters_module", amount = 1}] production_time_seconds = 1 @@ -158,7 +149,6 @@ added_maneuvering_acceleration_mpss = 10 [[module]] id = "armor_plates" tooltip = "Adds a large flat bonus to the ship's hit points." -unlock_at_station_level = 0 surface_mask = ["OO"] materials = [{item = "armor_plates_module", amount = 1}] production_time_seconds = 1 @@ -172,7 +162,6 @@ added_hp = 1200 [[module]] id = "sensor_booster" tooltip = "Extends the ship's sensor range." -unlock_at_station_level = 1 surface_mask = ["OO"] materials = [{item = "sensor_booster_module", amount = 1}] production_time_seconds = 1 @@ -189,7 +178,6 @@ added_sensor_range_m = 50 [[module]] id = "weapon_upgrade" tooltip = "Increases the damage of all weapons on the ship." -unlock_at_station_level = 4 surface_mask = [ "OO", "OX", @@ -206,7 +194,6 @@ multiplied_damage = 1.2 [[module]] id = "weapon_primer" tooltip = "Increases the fire rate of all weapons on the ship." -unlock_at_station_level = 4 surface_mask = [ "OO", "OX", @@ -223,7 +210,6 @@ multiplied_attack_rate_hz = 1.2 [[module]] id = "weapon_stabilizer" tooltip = "Extends weapon range at the cost of some fire rate." -unlock_at_station_level = 3 surface_mask = [ "OO", "OX", @@ -247,7 +233,6 @@ multiplied_attack_rate_hz = 0.8 [[module]] id = "drone_bay" tooltip = "Drone launch bay (capability not yet implemented)." -unlock_at_station_level = 5 surface_mask = [ "OO", "OO"] @@ -260,7 +245,6 @@ glyph = "Db" [[module]] id = "drone_hangar" tooltip = "Large drone hangar (capability not yet implemented)." -unlock_at_station_level = 9 surface_mask = [ "OOOOOO", "OOOOOO"] diff --git a/bin/app/data/config/recipes.toml b/bin/app/data/config/recipes.toml index 8558826..546882b 100644 --- a/bin/app/data/config/recipes.toml +++ b/bin/app/data/config/recipes.toml @@ -142,12 +142,12 @@ duration_seconds = 1.5 # Depth-3 chain (ore -> ingot -> plate -> block) is the factory's # doubling-time knob; see the block economy rules in docs/balancing/rules.md. -# Explicitly unlocked at start (-1): building blocks appear in no -# schematic's materials, so implicit unlocking can never reach this recipe. +# unlocked_at_start: building blocks appear in no schematic's materials, so the +# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT). [[recipe]] id = "building_block" -unlock_at_station_level = -1 building = "assembler" +unlocked_at_start = true inputs = [{item = "steel_plate", amount = 2}] outputs = [{item = "building_block", amount = 4}] duration_seconds = 2.0 @@ -220,15 +220,14 @@ outputs = [{item = "capital_core", amount = 1}] duration_seconds = 10.0 # ----------------------------------------------------------------------------- -# Shortcut recipes — drop-only assembler recipe schematics -# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by +# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in +# unlocks.toml (REQ-LOCK-EXPLICIT). Pure rewards: item threat stays defined by # the base (expensive) path via the max rule, so shortcuts give real factory # efficiency without shifting any balance. # ----------------------------------------------------------------------------- [[recipe]] id = "shortcut_steel_plate" -unlock_at_station_level = 1 building = "assembler" inputs = [{item = "iron_ore", amount = 3}] outputs = [{item = "steel_plate", amount = 1}] @@ -236,7 +235,6 @@ duration_seconds = 2.0 [[recipe]] id = "shortcut_control_chip" -unlock_at_station_level = 2 building = "assembler" inputs = [{item = "quartz", amount = 2}] outputs = [{item = "control_chip", amount = 1}] @@ -244,7 +242,6 @@ duration_seconds = 4.0 [[recipe]] id = "shortcut_hardened_steel" -unlock_at_station_level = 2 building = "assembler" inputs = [{item = "iron_ingot", amount = 4}] outputs = [{item = "hardened_steel", amount = 1}] diff --git a/bin/app/data/config/ships.toml b/bin/app/data/config/ships.toml index 4fc7883..1ca3d98 100644 --- a/bin/app/data/config/ships.toml +++ b/bin/app/data/config/ships.toml @@ -4,10 +4,9 @@ # content; stats, materials, and production times are placeholders until the # recipe and balancing passes. # -# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending -# the balancing pass. The capital hulls are the exception: battleship is gated -# to station level 1, and dreadnought (level 2) requires battleship to be -# unlocked first (unlock_requires) — a demonstration of the prerequisite chain. +# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a ship +# id granted by an unlock group starts locked and is awarded via a defence +# station drop; ids absent from unlocks.toml (drone, frigate) start unlocked. # # Size classes: # xs drone 1 cell — exactly one 1x1 module @@ -22,7 +21,6 @@ [[ship]] id = "drone" -unlock_at_station_level = -1 layout = ["O"] default_modules = [{type = "railgun_s", x = 0, y = 0, rotation = "east"}] @@ -49,7 +47,6 @@ sensor_range_m = 150 # L-shaped weapon modifier, or an afterburner spanning the full center line. [[ship]] id = "frigate" -unlock_at_station_level = -1 layout = [ "XOX", "OOO", @@ -84,7 +81,6 @@ sensor_range_m = 200 # mount medium hardware. [[ship]] id = "destroyer" -unlock_at_station_level = 0 layout = [ "OXOXO", "OOOOO", @@ -120,7 +116,6 @@ sensor_range_m = 220 # supports; no 3x3 area exists for an l gun. [[ship]] id = "cruiser" -unlock_at_station_level = 2 layout = [ "XOOX", "OOOO", @@ -158,8 +153,6 @@ sensor_range_m = 250 # stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar. [[ship]] id = "battlecruiser" -unlock_at_station_level = 4 -unlock_requires = ["cruiser"] layout = [ "OOXXOO", "OOOOOO", @@ -201,8 +194,6 @@ sensor_range_m = 260 # so no 2x6 drone hangar fits. [[ship]] id = "battleship" -unlock_at_station_level = 6 -unlock_requires = ["battlecruiser"] layout = [ "XOOOOX", "OOOOOO", @@ -244,8 +235,6 @@ sensor_range_m = 280 # stay the only hangar hull. Bow and stern strips hold supports. [[ship]] id = "dreadnought" -unlock_at_station_level = 8 -unlock_requires = ["battleship"] layout = [ "XXXOOOOOXXX", "OOOXOOOXOOO", @@ -288,8 +277,6 @@ sensor_range_m = 300 # the lower decks hold supports and 2x2 point-defense m guns. [[ship]] id = "carrier" -unlock_at_station_level = 9 -unlock_requires = ["battleship"] layout = [ "XOOOOOOOOX", "OOOOOOOOOO", diff --git a/bin/app/data/config/unlocks.toml b/bin/app/data/config/unlocks.toml new file mode 100644 index 0000000..bf38d05 --- /dev/null +++ b/bin/app/data/config/unlocks.toml @@ -0,0 +1,140 @@ +# Unlock groups (REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). +# +# Each [[unlock]] is a group of ships/modules/buildings/recipes awarded together +# from a single defence station drop. Anything NOT granted by any group is +# available from game start. `station_level` gates when a group becomes eligible; +# `requires` lists prerequisite unlock-group ids (REQ-LOCK-PREREQ). +# +# Most entries below are single-item groups that reproduce the previous per-item +# progression. The salvage_operations and reprocessing groups are the grouped +# unlocks: they lock the salvager module + salvage bay, and the reprocessing +# plant, from game start. + +# --- Grouped unlocks ------------------------------------------------------- + +[[unlock]] +id = "salvage_operations" +station_level = 1 +modules = ["salvager"] +buildings = ["salvage_bay"] + +[[unlock]] +id = "reprocessing" +station_level = 2 +buildings = ["reprocessing_plant"] + +# --- Ships ----------------------------------------------------------------- + +[[unlock]] +id = "destroyer" +station_level = 0 +ships = ["destroyer"] + +[[unlock]] +id = "cruiser" +station_level = 2 +ships = ["cruiser"] + +[[unlock]] +id = "battlecruiser" +station_level = 4 +requires = ["cruiser"] +ships = ["battlecruiser"] + +[[unlock]] +id = "battleship" +station_level = 6 +requires = ["battlecruiser"] +ships = ["battleship"] + +[[unlock]] +id = "dreadnought" +station_level = 8 +requires = ["battleship"] +ships = ["dreadnought"] + +[[unlock]] +id = "carrier" +station_level = 9 +requires = ["battleship"] +ships = ["carrier"] + +# --- Modules --------------------------------------------------------------- + +[[unlock]] +id = "repair_tool" +station_level = 0 +modules = ["repair_tool"] + +[[unlock]] +id = "armor_plates" +station_level = 0 +modules = ["armor_plates"] + +[[unlock]] +id = "maneuvering_thrusters" +station_level = 1 +modules = ["maneuvering_thrusters"] + +[[unlock]] +id = "sensor_booster" +station_level = 1 +modules = ["sensor_booster"] + +[[unlock]] +id = "railgun_m" +station_level = 2 +modules = ["railgun_m"] + +[[unlock]] +id = "afterburner" +station_level = 2 +modules = ["afterburner"] + +[[unlock]] +id = "weapon_stabilizer" +station_level = 3 +modules = ["weapon_stabilizer"] + +[[unlock]] +id = "weapon_upgrade" +station_level = 4 +modules = ["weapon_upgrade"] + +[[unlock]] +id = "weapon_primer" +station_level = 4 +modules = ["weapon_primer"] + +[[unlock]] +id = "drone_bay" +station_level = 5 +modules = ["drone_bay"] + +[[unlock]] +id = "railgun_l" +station_level = 6 +requires = ["railgun_m"] +modules = ["railgun_l"] + +[[unlock]] +id = "drone_hangar" +station_level = 9 +modules = ["drone_hangar"] + +# --- Assembler recipes ----------------------------------------------------- + +[[unlock]] +id = "shortcut_steel_plate" +station_level = 1 +recipes = ["shortcut_steel_plate"] + +[[unlock]] +id = "shortcut_control_chip" +station_level = 2 +recipes = ["shortcut_control_chip"] + +[[unlock]] +id = "shortcut_hardened_steel" +station_level = 2 +recipes = ["shortcut_hardened_steel"] diff --git a/bin/test/data/config/modules.toml b/bin/test/data/config/modules.toml index d185d7a..2c738b3 100644 --- a/bin/test/data/config/modules.toml +++ b/bin/test/data/config/modules.toml @@ -1,7 +1,6 @@ [[module]] id = "armor_plate" tooltip = "Adds a large flat bonus to hit points." -unlock_at_station_level = -1 surface_mask = ["OO"] materials = [{item = "iron_ingot", amount = 2}] production_time_seconds = 3 @@ -13,7 +12,6 @@ multiplied_hp = 1.5 [[module]] id = "sensor_booster" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "circuit_board", amount = 1}] production_time_seconds = 2 @@ -25,7 +23,6 @@ added_sensor_range_m = 100 [[module]] id = "weapon_upgrade" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}] production_time_seconds = 4 @@ -37,7 +34,6 @@ multiplied_damage = 1.2 [[module]] id = "laser_cannon" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}] production_time_seconds = 5 @@ -51,7 +47,6 @@ attack_rate_hz = 2.0 [[module]] id = "salvager" -unlock_at_station_level = -1 surface_mask = ["OO"] materials = [{item = "iron_ingot", amount = 2}] production_time_seconds = 5 @@ -65,7 +60,6 @@ collection_rate_hz = 0.5 [[module]] id = "repair_tool" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "circuit_board", amount = 2}] production_time_seconds = 5 @@ -79,7 +73,6 @@ repair_range_m = 800 [[module]] id = "weapon_primer" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}] production_time_seconds = 4 @@ -91,7 +84,6 @@ multiplied_attack_rate_hz = 1.2 [[module]] id = "weapon_stabilizer" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}] production_time_seconds = 4 @@ -104,7 +96,6 @@ multiplied_attack_rate_hz = 0.8 [[module]] id = "afterburner" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}] production_time_seconds = 2 @@ -117,7 +108,6 @@ added_main_acceleration_mpss = 60 [[module]] id = "maneuvering_thrusters" -unlock_at_station_level = -1 surface_mask = ["O"] materials = [{item = "iron_ingot", amount = 1}] production_time_seconds = 2 diff --git a/bin/test/data/config/recipes.toml b/bin/test/data/config/recipes.toml index 883307e..acf41ed 100644 --- a/bin/test/data/config/recipes.toml +++ b/bin/test/data/config/recipes.toml @@ -43,7 +43,7 @@ duration_seconds = 4.0 [[recipe]] id = "premium_circuit" building = "assembler" -unlock_at_station_level = -1 +unlocked_at_start = true inputs = [{item = "circuit_board", amount = 1}] outputs = [{item = "premium_circuit", amount = 1}] duration_seconds = 8.0 @@ -51,7 +51,6 @@ duration_seconds = 8.0 [[recipe]] id = "quick_circuit" building = "assembler" -unlock_at_station_level = 0 inputs = [{item = "copper_ingot", amount = 3}] outputs = [{item = "circuit_board", amount = 1}] duration_seconds = 3.0 @@ -59,7 +58,6 @@ duration_seconds = 3.0 [[recipe]] id = "advanced_circuit" building = "assembler" -unlock_at_station_level = 1 inputs = [{item = "iron_ingot", amount = 5}] outputs = [{item = "circuit_board", amount = 1}] duration_seconds = 6.0 @@ -67,7 +65,6 @@ duration_seconds = 6.0 [[recipe]] id = "exotic_alloy" building = "assembler" -unlock_at_station_level = 0 inputs = [{item = "exotic_ore", amount = 2}] outputs = [{item = "exotic_alloy", amount = 1}] duration_seconds = 10.0 diff --git a/bin/test/data/config/ships.toml b/bin/test/data/config/ships.toml index afa3cd2..223f18c 100644 --- a/bin/test/data/config/ships.toml +++ b/bin/test/data/config/ships.toml @@ -1,6 +1,5 @@ [[ship]] id = "interceptor" -unlock_at_station_level = -1 layout = ["XOX", "OOO", "XOX"] default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] @@ -24,7 +23,6 @@ sensor_range_m = 2000 [[ship]] id = "destroyer" -unlock_at_station_level = -1 layout = ["XOOX", "OOOO", "XOOX"] default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}] @@ -48,7 +46,6 @@ sensor_range_m = 3000 [[ship]] id = "salvage_ship" -unlock_at_station_level = -1 layout = ["OOO", "OOO"] [ship.schematic] @@ -71,7 +68,6 @@ sensor_range_m = 2500 [[ship]] id = "repair_ship" -unlock_at_station_level = 0 layout = ["XOX", "OOO", "XOX"] [ship.schematic] diff --git a/bin/test/data/config/unlocks.toml b/bin/test/data/config/unlocks.toml new file mode 100644 index 0000000..6f51b30 --- /dev/null +++ b/bin/test/data/config/unlocks.toml @@ -0,0 +1,21 @@ +# Unlock groups for the test config (REQ-LOCK-EXPLICIT). Mirrors the previous +# per-item gating: repair_ship, quick_circuit, advanced_circuit start locked and +# are awarded via defence station drops. exotic_alloy is intentionally NOT here: +# it stays implicitly gated (its output/inputs are unreachable), so it is never +# unlocked. premium_circuit uses unlocked_at_start in recipes.toml. Everything +# else (interceptor, destroyer, salvage_ship, all modules) starts unlocked. + +[[unlock]] +id = "repair_ship" +station_level = 0 +ships = ["repair_ship"] + +[[unlock]] +id = "quick_circuit" +station_level = 0 +recipes = ["quick_circuit"] + +[[unlock]] +id = "advanced_circuit" +station_level = 1 +recipes = ["advanced_circuit"] diff --git a/src/lib/config/CMakeLists.txt b/src/lib/config/CMakeLists.txt index 6bdc176..d6d29f6 100644 --- a/src/lib/config/CMakeLists.txt +++ b/src/lib/config/CMakeLists.txt @@ -8,6 +8,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h + ${CMAKE_CURRENT_SOURCE_DIR}/UnlocksConfig.h ${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h ${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index 96a8f44..f8a9e64 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -404,18 +404,10 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path) } def.building = *parsedType; - if (def.building == BuildingType::Assembler) + if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start")) { - const auto level = mt["unlock_at_station_level"].value(); - if (level) - { - def.unlockAtStationLevel = static_cast(*level); - } - if (mt.contains("unlock_requires")) - { - def.unlockRequires = requireStringArray(mt["unlock_requires"], file, - elemPath + ".unlock_requires"); - } + def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file, + elemPath + ".unlocked_at_start"); } // inputs may be omitted (e.g. miner recipes). An empty array is fine. @@ -454,11 +446,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path) ShipDef def; def.id = requireString(mt["id"], file, elemPath + ".id"); - def.unlockAtStationLevel = static_cast(requireInt(mt["unlock_at_station_level"], file, elemPath + ".unlock_at_station_level")); - if (mt.contains("unlock_requires")) - { - def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires"); - } def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout"); // Schematic @@ -608,12 +595,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path) ModuleDef def; def.id = requireString(mt["id"], file, elemPath + ".id"); - def.unlockAtStationLevel = static_cast( - mt["unlock_at_station_level"].value_or(-1)); - if (mt.contains("unlock_requires")) - { - def.unlockRequires = requireStringArray(mt["unlock_requires"], file, elemPath + ".unlock_requires"); - } def.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask"); def.productionTimeSeconds = requireDouble( mt["production_time_seconds"], file, elemPath + ".production_time_seconds"); @@ -731,62 +712,141 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path) return cfg; } +UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path) +{ + const std::string file = "unlocks.toml"; + toml::table tbl = parseFile(path, file); + + UnlocksConfig cfg; + if (!tbl.contains("unlock")) + { + return cfg; + } + + const toml::array& arr = requireArray(tbl["unlock"], file, "unlock"); + + for (std::size_t i = 0; i < arr.size(); ++i) + { + const std::string elemPath = "unlock[" + std::to_string(i) + "]"; + const toml::table* ut = arr[i].as_table(); + if (ut == nullptr) + { + throw makeError(file, elemPath, "not a table"); + } + toml::table& mt = const_cast(*ut); + + UnlockGroupDef def; + def.id = requireString(mt["id"], file, elemPath + ".id"); + def.stationLevel = static_cast( + requireInt(mt["station_level"], file, elemPath + ".station_level")); + if (mt.contains("requires")) + { + def.requiredGroupIds = requireStringArray(mt["requires"], file, elemPath + ".requires"); + } + if (mt.contains("ships")) + { + def.ships = requireStringArray(mt["ships"], file, elemPath + ".ships"); + } + if (mt.contains("modules")) + { + def.modules = requireStringArray(mt["modules"], file, elemPath + ".modules"); + } + if (mt.contains("buildings")) + { + def.buildings = requireStringArray(mt["buildings"], file, elemPath + ".buildings"); + } + if (mt.contains("recipes")) + { + def.recipes = requireStringArray(mt["recipes"], file, elemPath + ".recipes"); + } + + cfg.groups.push_back(std::move(def)); + } + + return cfg; +} + namespace { -// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic. -void checkUnlockRequires(const std::vector& requiredIds, - const std::unordered_set& schematicIds, - const std::string& file, - const std::string& path) +// Validates unlocks.toml against the rest of the config (REQ-LOCK-EXPLICIT, +// REQ-LOCK-PREREQ): each granted id resolves to the right kind of definition +// (recipe grants must name assembler recipes), each grantable id is granted by +// at most one group, group ids are unique, every group grants at least one +// item, and every `requires` id names a defined group. +void validateUnlocks(const GameConfig& cfg) { - for (const std::string& requiredId : requiredIds) - { - if (schematicIds.count(requiredId) == 0) - { - throw makeError(file, path, - "references unknown schematic '" + requiredId + "'"); - } - } -} + const std::string file = "unlocks.toml"; -// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ) -// resolves to an explicitly-unlockable schematic: a ship, a module, or an -// assembler recipe that carries `unlock_at_station_level` (a recipe schematic, -// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load. -void validateUnlockRequires(const GameConfig& cfg) -{ - std::unordered_set schematicIds; - for (const ShipDef& def : cfg.ships.ships) - { - schematicIds.insert(def.id); - } - for (const ModuleDef& def : cfg.modules.modules) - { - schematicIds.insert(def.id); - } + std::unordered_set shipIds; + for (const ShipDef& def : cfg.ships.ships) { shipIds.insert(def.id); } + std::unordered_set moduleIds; + for (const ModuleDef& def : cfg.modules.modules) { moduleIds.insert(def.id); } + std::unordered_set buildingIds; + for (const BuildingDef& def : cfg.buildings.buildings) { buildingIds.insert(def.id); } + std::unordered_set assemblerRecipeIds; for (const RecipeDef& def : cfg.recipes.recipes) { - if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value()) - { - schematicIds.insert(def.id); - } + if (def.building == BuildingType::Assembler) { assemblerRecipeIds.insert(def.id); } } - for (const ShipDef& def : cfg.ships.ships) + std::unordered_set groupIds; + std::unordered_set grantedShipIds; + std::unordered_set grantedModuleIds; + std::unordered_set grantedBuildingIds; + std::unordered_set grantedRecipeIds; + + const auto checkGrants = [&](const std::vector& ids, + const std::unordered_set& valid, + std::unordered_set& granted, + const std::string& kind, + const std::string& gPath) { - checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml", - "ship '" + def.id + "'.unlock_requires"); + for (const std::string& id : ids) + { + if (valid.count(id) == 0) + { + throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'"); + } + if (!granted.insert(id).second) + { + throw makeError(file, gPath, + "grants " + kind + " '" + id + "' which is already granted by another unlock group"); + } + } + }; + + for (const UnlockGroupDef& group : cfg.unlocks.groups) + { + const std::string gPath = "unlock '" + group.id + "'"; + if (!groupIds.insert(group.id).second) + { + throw makeError(file, gPath, "duplicate unlock group id"); + } + + if (group.ships.empty() && group.modules.empty() + && group.buildings.empty() && group.recipes.empty()) + { + throw makeError(file, gPath, "grants no items (must grant at least one)"); + } + + checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath); + checkGrants(group.modules, moduleIds, grantedModuleIds, "module", gPath); + checkGrants(group.buildings, buildingIds, grantedBuildingIds, "building", gPath); + checkGrants(group.recipes, assemblerRecipeIds, grantedRecipeIds, "assembler recipe", gPath); } - for (const ModuleDef& def : cfg.modules.modules) + + // requires must reference defined group ids (checked once all group ids known). + for (const UnlockGroupDef& group : cfg.unlocks.groups) { - checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml", - "module '" + def.id + "'.unlock_requires"); - } - for (const RecipeDef& def : cfg.recipes.recipes) - { - checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml", - "recipe '" + def.id + "'.unlock_requires"); + for (const std::string& req : group.requiredGroupIds) + { + if (groupIds.count(req) == 0) + { + throw makeError(file, "unlock '" + group.id + "'.requires", + "references unknown unlock group '" + req + "'"); + } + } } } @@ -801,7 +861,8 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir) cfg.ships = loadShips(configDir + "/ships.toml"); cfg.stations = loadStations(configDir + "/stations.toml"); cfg.modules = loadModules(configDir + "/modules.toml"); - validateUnlockRequires(cfg); + cfg.unlocks = loadUnlocks(configDir + "/unlocks.toml"); + validateUnlocks(cfg); cfg.threatCosts = computeThreatCostTable(cfg); return cfg; } diff --git a/src/lib/config/ConfigLoader.h b/src/lib/config/ConfigLoader.h index 1c909ae..2bebbb3 100644 --- a/src/lib/config/ConfigLoader.h +++ b/src/lib/config/ConfigLoader.h @@ -22,4 +22,5 @@ public: static ShipsConfig loadShips(const std::string& path); static StationsConfig loadStations(const std::string& path); static ModulesConfig loadModules(const std::string& path); + static UnlocksConfig loadUnlocks(const std::string& path); }; diff --git a/src/lib/config/GameConfig.h b/src/lib/config/GameConfig.h index ff6b8cb..23aaff5 100644 --- a/src/lib/config/GameConfig.h +++ b/src/lib/config/GameConfig.h @@ -6,6 +6,7 @@ #include "ShipsConfig.h" #include "StationsConfig.h" #include "ModulesConfig.h" +#include "UnlocksConfig.h" #include "ThreatCostCalculator.h" // Aggregate of all simulation config files. Loaded at startup and reloaded @@ -18,5 +19,6 @@ struct GameConfig ShipsConfig ships; StationsConfig stations; ModulesConfig modules; + UnlocksConfig unlocks; ThreatCostTable threatCosts; }; diff --git a/src/lib/config/ModulesConfig.h b/src/lib/config/ModulesConfig.h index ebf11d0..402bbf7 100644 --- a/src/lib/config/ModulesConfig.h +++ b/src/lib/config/ModulesConfig.h @@ -40,10 +40,6 @@ struct ModuleRepairCapability struct ModuleDef { std::string id; - int unlockAtStationLevel; - // Prerequisite schematic ids that must be explicitly unlocked before this - // schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none. - std::vector unlockRequires; std::vector surfaceMask; std::vector materials; double productionTimeSeconds; diff --git a/src/lib/config/RecipesConfig.h b/src/lib/config/RecipesConfig.h index 99c109d..c75e9ab 100644 --- a/src/lib/config/RecipesConfig.h +++ b/src/lib/config/RecipesConfig.h @@ -32,14 +32,12 @@ struct RecipeDef std::vector inputs; std::vector outputs; double durationSeconds; - // Assembler only. nullopt = implicit-only locking. -1 = explicitly unlocked - // at game start. >= 0 = locked; schematic enters drop pool at that station - // level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT). - std::optional unlockAtStationLevel; - // Assembler recipe schematics only. Prerequisite schematic ids that must be - // explicitly unlocked before this schematic can enter the drop pool - // (REQ-LOCK-PREREQ). Empty = none. - std::vector unlockRequires; + // Assembler only. When true, this recipe is available from game start + // regardless of the implicit item graph — used for base recipes that no + // schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT. + // Otherwise an assembler recipe is either explicitly gated (granted by an + // unlock group, REQ-LOCK-EXPLICIT) or implicitly gated via the item graph. + bool unlockedAtStart = false; }; struct RecipesConfig diff --git a/src/lib/config/ShipsConfig.h b/src/lib/config/ShipsConfig.h index cb550f2..278a721 100644 --- a/src/lib/config/ShipsConfig.h +++ b/src/lib/config/ShipsConfig.h @@ -35,10 +35,6 @@ struct ShipSensor struct ShipDef { std::string id; - int unlockAtStationLevel; - // Prerequisite schematic ids that must be explicitly unlocked before this - // schematic can enter the drop pool (REQ-LOCK-PREREQ). Empty = none. - std::vector unlockRequires; std::vector layout; ShipSchematic schematic; diff --git a/src/lib/config/UnlocksConfig.h b/src/lib/config/UnlocksConfig.h new file mode 100644 index 0000000..d323e19 --- /dev/null +++ b/src/lib/config/UnlocksConfig.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +// A single [[unlock]] entry from unlocks.toml — an unlock group, the unit of +// loot awarded by defence station drops (REQ-DEF-SCHEMATIC-DROP, +// REQ-LOCK-EXPLICIT). Awarding a group unlocks all of its granted members at +// once. Anything not granted by any group is available from game start. +struct UnlockGroupDef +{ + std::string id; // Unique unlock-group id. + int stationLevel; // Min destroyed station level to become eligible. + std::vector requiredGroupIds; // Prerequisite unlock-group ids (REQ-LOCK-PREREQ). + + // Granted ids by kind (each defaults to empty; a group grants >= 1 item). + std::vector ships; + std::vector modules; + std::vector buildings; + std::vector recipes; // Assembler recipe ids only. +}; + +struct UnlocksConfig +{ + std::vector groups; +}; diff --git a/src/lib/core/SchematicChoiceOption.h b/src/lib/core/SchematicChoiceOption.h index 77c52ea..79867c6 100644 --- a/src/lib/core/SchematicChoiceOption.h +++ b/src/lib/core/SchematicChoiceOption.h @@ -3,23 +3,43 @@ #include #include +// The kind of a single granted schematic within an unlock option. enum class SchematicType { Ship, Module, - Recipe, - Artifact + Building, + Recipe +}; + +// One item granted by an unlock group (REQ-LOCK-EXPLICIT), shown in the +// schematic choice dialog's granted-items list. +struct GrantedSchematic +{ + SchematicType type; + std::string id; + std::string displayName; }; // 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. +// destroyed; the UI reads these to populate the dialog. A non-artifact option +// represents one unlock group; selecting it awards the whole group. struct SchematicChoiceOption { - std::string schematicId; - SchematicType type; + bool isArtifact = false; + + // Unlock group id (empty for the artifact option). + std::string unlockGroupId; + + // Display name shown as the option's title: the unlock group's derived name, + // or "Artifact" for the artifact option. std::string displayName; + // The ships, modules, buildings, and assembler recipes this group grants + // (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. diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 784387d..be1aa0b 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -12,6 +12,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h diff --git a/src/lib/eventsystem/event/UnlockedBuildingsChangedEvent.h b/src/lib/eventsystem/event/UnlockedBuildingsChangedEvent.h new file mode 100644 index 0000000..f0d55c1 --- /dev/null +++ b/src/lib/eventsystem/event/UnlockedBuildingsChangedEvent.h @@ -0,0 +1,13 @@ +#ifndef UNLOCKED_BUILDINGS_CHANGED_EVENT_H +#define UNLOCKED_BUILDINGS_CHANGED_EVENT_H + +#include "Event.h" + +// Emitted when the set of unlocked building types changes (REQ-LOCK-BUILDING), +// i.e. after an unlock group granting a building is awarded (REQ-DEF-SCHEMATIC-DROP) +// or on Restart. The build button grid re-evaluates which buttons are shown. +class UnlockedBuildingsChangedEvent : public Event +{ +}; + +#endif // UNLOCKED_BUILDINGS_CHANGED_EVENT_H diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 2f9e94a..458e2f9 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -75,34 +75,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed) m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(m_config); - // Initialize ship schematic unlock state. - for (const ShipDef& def : m_config.ships.ships) - { - SchematicState state; - state.unlocked = (def.unlockAtStationLevel == -1); - m_schematicLevels[def.id] = state; - } - - // Initialize module schematic unlock state. - for (const ModuleDef& def : m_config.modules.modules) - { - SchematicState state; - state.unlocked = (def.unlockAtStationLevel == -1); - m_moduleSchematicLevels[def.id] = state; - } - - // Initialize assembler recipe schematic unlock state. - for (const RecipeDef& def : m_config.recipes.recipes) - { - if (def.building == BuildingType::Assembler - && def.unlockAtStationLevel.has_value() - && def.unlockAtStationLevel.value() == -1) - { - m_unlockedRecipeSchematicIds.insert(def.id); - } - } - - recomputeUnlocked(); + initializeUnlockState(); placeInitialStructures(); registerForEvents(); } @@ -174,11 +147,33 @@ void Simulation::reset(unsigned int seed) m_waveSystem = std::make_unique(m_config, m_rng); m_combatSystem = std::make_unique(m_config); + initializeUnlockState(); + placeInitialStructures(); +} + +void Simulation::initializeUnlockState() +{ + // Cache the ids granted by some unlock group (REQ-LOCK-EXPLICIT); an item + // starts locked iff it is granted by a group. + m_grantedShipIds.clear(); + m_grantedModuleIds.clear(); + m_grantedBuildingIds.clear(); + m_grantedRecipeIds.clear(); + for (const UnlockGroupDef& group : m_config.unlocks.groups) + { + m_grantedShipIds.insert(group.ships.begin(), group.ships.end()); + m_grantedModuleIds.insert(group.modules.begin(), group.modules.end()); + m_grantedBuildingIds.insert(group.buildings.begin(), group.buildings.end()); + m_grantedRecipeIds.insert(group.recipes.begin(), group.recipes.end()); + } + + m_awardedUnlockGroupIds.clear(); + m_schematicLevels.clear(); for (const ShipDef& def : m_config.ships.ships) { SchematicState state; - state.unlocked = (def.unlockAtStationLevel == -1); + state.unlocked = (m_grantedShipIds.count(def.id) == 0); m_schematicLevels[def.id] = state; } @@ -186,23 +181,23 @@ void Simulation::reset(unsigned int seed) for (const ModuleDef& def : m_config.modules.modules) { SchematicState state; - state.unlocked = (def.unlockAtStationLevel == -1); + state.unlocked = (m_grantedModuleIds.count(def.id) == 0); m_moduleSchematicLevels[def.id] = state; } - m_unlockedRecipeSchematicIds.clear(); - for (const RecipeDef& def : m_config.recipes.recipes) + m_buildingLevels.clear(); + for (const BuildingDef& def : m_config.buildings.buildings) { - if (def.building == BuildingType::Assembler - && def.unlockAtStationLevel.has_value() - && def.unlockAtStationLevel.value() == -1) - { - m_unlockedRecipeSchematicIds.insert(def.id); - } + SchematicState state; + state.unlocked = (m_grantedBuildingIds.count(def.id) == 0); + m_buildingLevels[def.id] = state; } + // Gated assembler recipes start locked; unlocked_at_start recipes are handled + // in the REQ-LOCK-IMPLICIT traversal, not tracked here. + m_unlockedRecipeSchematicIds.clear(); + recomputeUnlocked(); - placeInitialStructures(); } // --------------------------------------------------------------------------- @@ -628,43 +623,16 @@ void Simulation::tickDeathsAndLoot() void Simulation::generateSchematicChoices(int destroyedStationLevel) { - enum class DropType { Ship, Module, Recipe }; - struct PoolEntry { std::string id; DropType type; }; - std::vector pool; - - // Owned schematics leave the pool (REQ-DEF-SCHEMATIC-DROP): only offer - // ship/module schematics that are gated to a station level in range and not - // yet unlocked. Schematics with unlock_at_station_level -1 start unlocked, so - // they are always owned and never eligible. - for (const ShipDef& def : m_config.ships.ships) + // Build the eligible pool of unlock groups (REQ-DEF-SCHEMATIC-DROP, + // REQ-LOCK-EXPLICIT, REQ-LOCK-PREREQ): not yet awarded, station level in + // range, and every prerequisite group already awarded. + std::vector pool; + for (const UnlockGroupDef& group : m_config.unlocks.groups) { - if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; } - if (m_schematicLevels.at(def.id).unlocked) { continue; } - if (!prerequisitesSatisfied(def.unlockRequires)) { continue; } - pool.push_back({def.id, DropType::Ship}); - } - for (const ModuleDef& def : m_config.modules.modules) - { - if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; } - if (m_moduleSchematicLevels.at(def.id).unlocked) { continue; } - if (!prerequisitesSatisfied(def.unlockRequires)) { continue; } - pool.push_back({def.id, DropType::Module}); - } - for (const RecipeDef& def : m_config.recipes.recipes) - { - if (def.building != BuildingType::Assembler) { continue; } - if (!def.unlockAtStationLevel.has_value()) { continue; } - const int level = def.unlockAtStationLevel.value(); - if (level < 0 || level > destroyedStationLevel) { continue; } - if (m_unlockedRecipeSchematicIds.count(def.id) > 0) { continue; } - bool outputUnlocked = false; - for (const RecipeOutput& out : def.outputs) - { - if (m_unlockedItemIds.count(out.item) > 0) { outputUnlocked = true; break; } - } - if (!outputUnlocked) { continue; } - if (!prerequisitesSatisfied(def.unlockRequires)) { continue; } - pool.push_back({def.id, DropType::Recipe}); + if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; } + if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; } + if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; } + pool.push_back(&group); } if (pool.empty()) { return; } @@ -679,9 +647,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) const int numChoices = std::min(static_cast(pool.size()), artifactRolled ? 2 : 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); @@ -689,68 +654,67 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel) const std::size_t rollIdx = static_cast(roll); const std::size_t endIdx = pool.size() - 1 - static_cast(i); std::swap(pool[rollIdx], pool[endIdx]); - const PoolEntry& entry = pool[endIdx]; - SchematicChoiceOption option; - option.schematicId = entry.id; - - if (entry.type == DropType::Ship) - { - option.type = SchematicType::Ship; - option.displayName = toDisplayName(entry.id); - } - else if (entry.type == DropType::Module) - { - option.type = SchematicType::Module; - option.displayName = toDisplayName(entry.id); - } - else - { - option.type = SchematicType::Recipe; - option.displayName = toDisplayName(entry.id); - } - - // 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) - { - hypotheticalShipIds.insert(entry.id); - } - else if (entry.type == DropType::Module) - { - hypotheticalModuleIds.insert(entry.id); - } - else if (entry.type == DropType::Recipe) - { - hypotheticalRecipeSchematicIds.insert(entry.id); - } - - const UnlockedSets hypothetical = computeUnlockedSets( - hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); - option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); - - m_pendingSchematicChoices.push_back(option); + m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx])); } if (artifactRolled) { SchematicChoiceOption artifactOption; - artifactOption.schematicId = ""; - artifactOption.type = SchematicType::Artifact; - artifactOption.displayName = "Artifact"; + artifactOption.isArtifact = true; + artifactOption.displayName = "Artifact"; m_pendingSchematicChoices.push_back(std::move(artifactOption)); } } +SchematicChoiceOption Simulation::makeUnlockOption(const UnlockGroupDef& group) const +{ + SchematicChoiceOption option; + option.isArtifact = false; + option.unlockGroupId = group.id; + option.displayName = toDisplayName(group.id); + + for (const std::string& id : group.ships) + { + option.grantedItems.push_back({SchematicType::Ship, id, toDisplayName(id)}); + } + for (const std::string& id : group.modules) + { + option.grantedItems.push_back({SchematicType::Module, id, toDisplayName(id)}); + } + for (const std::string& id : group.buildings) + { + option.grantedItems.push_back({SchematicType::Building, id, toDisplayName(id)}); + } + for (const std::string& id : group.recipes) + { + 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. + 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); } + + const UnlockedSets hypothetical = computeUnlockedSets( + hypotheticalShipIds, hypotheticalModuleIds, hypotheticalRecipeSchematicIds); + option.newlyUnlockedRecipeIds = computeNewlyUnlockedRecipeIds(hypothetical); + + return option; +} + void Simulation::applySchematicChoice(int choiceIndex) { assert(choiceIndex >= 0 && choiceIndex < static_cast(m_pendingSchematicChoices.size())); const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast(choiceIndex)]; - if (chosen.type == SchematicType::Artifact) + if (chosen.isArtifact) { m_artifactCount += 1; if (m_artifactCount >= m_config.world.artifacts.artifactWinCount) @@ -761,16 +725,18 @@ void Simulation::applySchematicChoice(int choiceIndex) return; } - if (chosen.type == SchematicType::Recipe) + // Award the whole unlock group (REQ-DEF-SCHEMATIC-DROP): unlock every granted + // ship, module, building, and assembler recipe at once. + m_awardedUnlockGroupIds.insert(chosen.unlockGroupId); + for (const GrantedSchematic& grant : chosen.grantedItems) { - m_unlockedRecipeSchematicIds.insert(chosen.schematicId); - } - else - { - SchematicState& state = (chosen.type == SchematicType::Module) - ? m_moduleSchematicLevels.at(chosen.schematicId) - : m_schematicLevels.at(chosen.schematicId); - state.unlocked = true; + switch (grant.type) + { + case SchematicType::Ship: m_schematicLevels.at(grant.id).unlocked = true; break; + case SchematicType::Module: m_moduleSchematicLevels.at(grant.id).unlocked = true; break; + case SchematicType::Building: m_buildingLevels.at(grant.id).unlocked = true; break; + case SchematicType::Recipe: m_unlockedRecipeSchematicIds.insert(grant.id); break; + } } recomputeUnlocked(); @@ -809,28 +775,13 @@ std::set Simulation::getUnlockedModuleSchematicIds() const return ids; } -bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const +bool Simulation::prerequisitesSatisfied(const std::vector& requiredGroupIds) const { - // A prerequisite is satisfied only by an explicitly-unlocked schematic - // (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe - // schematic in the explicit-unlock set. Cross-type ids are looked up with - // find/count so an id from one namespace never throws against another. - const std::map::const_iterator shipIt = - m_schematicLevels.find(schematicId); - if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; } - - const std::map::const_iterator moduleIt = - m_moduleSchematicLevels.find(schematicId); - if (moduleIt != m_moduleSchematicLevels.end() && moduleIt->second.unlocked) { return true; } - - return m_unlockedRecipeSchematicIds.count(schematicId) > 0; -} - -bool Simulation::prerequisitesSatisfied(const std::vector& unlockRequires) const -{ - for (const std::string& requiredId : unlockRequires) + // A prerequisite is satisfied only once the named unlock group has been + // awarded (REQ-LOCK-PREREQ). + for (const std::string& groupId : requiredGroupIds) { - if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; } + if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; } } return true; } @@ -860,9 +811,11 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets( } for (const RecipeDef& def : m_config.recipes.recipes) { + // An assembler recipe seeds the base set when it is explicitly available: + // flagged unlocked_at_start (base recipes the graph can't reach), or a + // gated recipe whose unlock group has been awarded (REQ-LOCK-EXPLICIT). if (def.building == BuildingType::Assembler - && def.unlockAtStationLevel.has_value() - && unlockedRecipeSchematicIds.count(def.id) > 0) + && (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0)) { for (const RecipeOutput& out : def.outputs) { @@ -883,8 +836,10 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets( { continue; } + // Skip a gated assembler recipe (granted by an unlock group) whose + // group has not yet been awarded (REQ-LOCK-IMPLICIT step 2). if (recipe.building == BuildingType::Assembler - && recipe.unlockAtStationLevel.has_value() + && m_grantedRecipeIds.count(recipe.id) > 0 && unlockedRecipeSchematicIds.count(recipe.id) == 0) { continue; @@ -1000,6 +955,8 @@ unsigned long long Simulation::computeStateChecksum() const // Schematic / unlock state (std::map and std::set iterate in sorted order). appendSchematicMap(hasher, m_schematicLevels); appendSchematicMap(hasher, m_moduleSchematicLevels); + appendSchematicMap(hasher, m_buildingLevels); + appendStringSet(hasher, m_awardedUnlockGroupIds); appendStringSet(hasher, m_unlockedRecipeSchematicIds); appendStringSet(hasher, m_unlockedRecipeIds); appendStringSet(hasher, m_unlockedItemIds); @@ -1193,8 +1150,28 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const return it->second.unlocked; } +bool Simulation::isBuildingUnlocked(BuildingType type) const +{ + const BuildingDef* def = m_config.buildings.findBuildingDef(type); + if (def == nullptr) + { + // Types without a config entry (e.g. HQ, defence stations) are unrestricted. + return true; + } + const std::map::const_iterator it = + m_buildingLevels.find(def->id); + return it == m_buildingLevels.end() ? true : it->second.unlocked; +} + std::optional Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) { + // Locked building types cannot be placed (REQ-LOCK-BUILDING); the build menu + // hides them, this is the simulation-side backstop (e.g. blueprint placement). + if (!isBuildingUnlocked(type)) + { + return std::nullopt; + } + if (!m_buildingSystem->isPlacementValid(type, anchor, rotation)) { return std::nullopt; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 1e055b2..0fed492 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -100,6 +100,10 @@ public: bool isRecipeUnlocked(const std::string& recipeId) const; bool isItemUnlocked(const std::string& itemId) const; + // Building unlock query (REQ-LOCK-BUILDING). True if the building type is not + // gated by any unlock group, or its granting group has been awarded. + bool isBuildingUnlocked(BuildingType type) const; + // -- Determinism (see docs/replay_design.md) ----------------------------- // 64-bit fingerprint of the RNG stream state. Cheap; written to the replay // file periodically + after each command for desync detection. @@ -196,6 +200,24 @@ private: }; std::map m_schematicLevels; std::map m_moduleSchematicLevels; + std::map m_buildingLevels; + + // Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids. + std::set m_awardedUnlockGroupIds; + + // Ids granted by some unlock group, per kind — cached from config at init. + // An item starts locked iff it appears in the corresponding set. + std::set m_grantedShipIds; + std::set m_grantedModuleIds; + std::set m_grantedBuildingIds; + std::set m_grantedRecipeIds; + + // Builds the granted-id sets and initializes all per-item unlock maps from + // them (shared by the constructor and reset). Ends with recomputeUnlocked(). + void initializeUnlockState(); + + // Builds a schematic choice option for one unlock group (REQ-DEF-SCHEMATIC-DROP). + SchematicChoiceOption makeUnlockOption(const UnlockGroupDef& group) const; // Determinism helpers — fold sub-state into the hasher in deterministic order. static void appendSchematicMap(Hasher& hasher, @@ -228,11 +250,8 @@ private: std::set getUnlockedShipSchematicIds() const; std::set getUnlockedModuleSchematicIds() const; - // True if schematicId names a currently explicitly-unlocked ship, module, or - // assembler recipe schematic (REQ-LOCK-PREREQ satisfaction basis). - bool isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const; - // True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ). - bool prerequisitesSatisfied(const std::vector& unlockRequires) const; + // True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ). + bool prerequisitesSatisfied(const std::vector& requiredGroupIds) const; // Ids (sorted alphabetically by display name) of the recipes in // hypothetical.recipeIds that are not yet in m_unlockedRecipeIds. diff --git a/src/test/ArtifactWinConditionTest.cpp b/src/test/ArtifactWinConditionTest.cpp index a5dfd13..767c0b1 100644 --- a/src/test/ArtifactWinConditionTest.cpp +++ b/src/test/ArtifactWinConditionTest.cpp @@ -36,7 +36,7 @@ static int findArtifactChoiceIndex(const Simulation& sim) const auto& choices = sim.getPendingSchematicChoices(); for (int i = 0; i < static_cast(choices.size()); ++i) { - if (choices[static_cast(i)].type == SchematicType::Artifact) + if (choices[static_cast(i)].isArtifact) { return i; } @@ -86,7 +86,7 @@ TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula ret const auto& choices = sim.getPendingSchematicChoices(); const long artifactCount = std::count_if(choices.begin(), choices.end(), - [](const SchematicChoiceOption& opt) { return opt.type == SchematicType::Artifact; }); + [](const SchematicChoiceOption& opt) { return opt.isArtifact; }); CHECK(artifactCount == 1); // Exactly 2 schematic picks + 1 artifact (or fewer if pool was small) CHECK(choices.size() <= 3); @@ -105,7 +105,7 @@ TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artif const auto& choices = sim.getPendingSchematicChoices(); const long schematicCount = std::count_if(choices.begin(), choices.end(), - [](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; }); + [](const SchematicChoiceOption& opt) { return !opt.isArtifact; }); CHECK(schematicCount <= 2); } @@ -161,7 +161,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm // Pick the first non-artifact choice. const auto& choices = sim.getPendingSchematicChoices(); const auto it = std::find_if(choices.begin(), choices.end(), - [](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; }); + [](const SchematicChoiceOption& opt) { return !opt.isArtifact; }); REQUIRE(it != choices.end()); SimulationTestAccess::applySchematicChoice(sim,static_cast(it - choices.begin())); diff --git a/src/test/BuildingConfigTest.cpp b/src/test/BuildingConfigTest.cpp index d21986f..366fc34 100644 --- a/src/test/BuildingConfigTest.cpp +++ b/src/test/BuildingConfigTest.cpp @@ -1,5 +1,7 @@ #include "catch.hpp" +#include + #include "Building.h" #include "BuildingConfig.h" #include "BuildingSystem.h" @@ -46,7 +48,17 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) { - if (def.unlockAtStationLevel == -1) { return &def; } + // A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT). + bool granted = false; + for (const UnlockGroupDef& group : cfg.unlocks.groups) + { + if (std::find(group.ships.begin(), group.ships.end(), def.id) != group.ships.end()) + { + granted = true; + break; + } + } + if (!granted) { return &def; } } return nullptr; } diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 2f910e0..598845a 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -25,6 +25,7 @@ add_files( ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp UnlockPrereqTest.cpp + UnlockGroupTest.cpp ArtifactWinConditionTest.cpp DeterminismTest.cpp CommandTest.cpp diff --git a/src/test/ConfigLoaderTest.cpp b/src/test/ConfigLoaderTest.cpp index e45fd56..ec5d659 100644 --- a/src/test/ConfigLoaderTest.cpp +++ b/src/test/ConfigLoaderTest.cpp @@ -10,6 +10,7 @@ #include "BuildingType.h" #include "ConfigLoader.h" +#include "UnlocksConfig.h" namespace { @@ -407,99 +408,42 @@ void copyConfigInto(const std::filesystem::path& dir) } // namespace -TEST_CASE("unlock_requires parses into a ship def", "[config]") +TEST_CASE("loadUnlocks parses id, station_level, requires, and grants", "[config]") { TempConfigDir dir; - writeFile(dir.path() / "ships.toml", R"( -[[ship]] -id = "alpha" -unlock_at_station_level = 1 -unlock_requires = ["beta", "gamma"] -layout = ["O"] -[ship.schematic] -materials = [] -production_time_seconds = 5 -[ship.health] -hp = 10 -[ship.movement] -speed_mps = 1 -main_acceleration_mpss = 1 -maneuvering_acceleration_mpss = 1 -angular_acceleration_radpss = 1 -max_rotation_speed_radps = 1 -[ship.sensor] -sensor_range_m = 10 + writeFile(dir.path() / "unlocks.toml", R"( +[[unlock]] +id = "base" +station_level = 0 +buildings = ["salvage_bay"] + +[[unlock]] +id = "gated" +station_level = 3 +requires = ["base"] +ships = ["cruiser"] +modules = ["railgun_m"] +recipes = ["shortcut_steel_plate"] )"); - const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string()); - REQUIRE(cfg.ships.size() == 1); - CHECK(cfg.ships[0].unlockRequires == std::vector{"beta", "gamma"}); + const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string()); + REQUIRE(cfg.groups.size() == 2); + CHECK(cfg.groups[0].id == "base"); + CHECK(cfg.groups[0].stationLevel == 0); + CHECK(cfg.groups[0].buildings == std::vector{"salvage_bay"}); + CHECK(cfg.groups[1].requiredGroupIds == std::vector{"base"}); + CHECK(cfg.groups[1].ships == std::vector{"cruiser"}); + CHECK(cfg.groups[1].modules == std::vector{"railgun_m"}); + CHECK(cfg.groups[1].recipes == std::vector{"shortcut_steel_plate"}); } -TEST_CASE("unlock_requires parses into a module def", "[config]") -{ - TempConfigDir dir; - writeFile(dir.path() / "modules.toml", R"( -[[module]] -id = "m1" -unlock_at_station_level = 0 -unlock_requires = ["dep"] -surface_mask = ["O"] -materials = [{item = "iron_ingot", amount = 1}] -production_time_seconds = 1 -fill_color = "#ffffff" -glyph = "M" -)"); - - const ModulesConfig cfg = ConfigLoader::loadModules((dir.path() / "modules.toml").string()); - REQUIRE(cfg.modules.size() == 1); - CHECK(cfg.modules[0].unlockRequires == std::vector{"dep"}); -} - -TEST_CASE("unlock_requires parses only for assembler recipes", "[config]") -{ - TempConfigDir dir; - writeFile(dir.path() / "recipes.toml", R"( -[[recipe]] -id = "gated_asm" -building = "assembler" -unlock_at_station_level = 1 -unlock_requires = ["prereq_recipe"] -inputs = [] -outputs = [{item = "foo", amount = 1}] -duration_seconds = 1.0 - -[[recipe]] -id = "a_miner" -building = "miner" -unlock_requires = ["ignored"] -inputs = [] -outputs = [{item = "ore", amount = 1}] -duration_seconds = 1.0 -)"); - - const RecipesConfig cfg = ConfigLoader::loadRecipes((dir.path() / "recipes.toml").string()); - REQUIRE(cfg.recipes.size() == 2); - CHECK(cfg.recipes[0].unlockRequires == std::vector{"prereq_recipe"}); - // The field is only consumed for assembler recipe schematics; a miner recipe - // leaves it unparsed (empty). - CHECK(cfg.recipes[1].unlockRequires.empty()); -} - -TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load", "[config]") +TEST_CASE("validateUnlocks rejects a grant that names no definition", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); - std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app); - out << "\n[[recipe]]\n" - "id = \"gated_bogus\"\n" - "building = \"assembler\"\n" - "unlock_at_station_level = 1\n" - "unlock_requires = [\"___does_not_exist___\"]\n" - "inputs = []\n" - "outputs = [{item = \"circuit_board\", amount = 1}]\n" - "duration_seconds = 1.0\n"; + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\n"; out.close(); try @@ -509,26 +453,103 @@ TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load" } catch (const std::runtime_error& e) { - const std::string msg = e.what(); - REQUIRE(msg.find("___does_not_exist___") != std::string::npos); + REQUIRE(std::string(e.what()).find("___nope___") != std::string::npos); } } -TEST_CASE("unlock_requires referencing a real schematic loads without error", "[config]") +TEST_CASE("validateUnlocks rejects an id granted by two groups", "[config]") { TempConfigDir dir; copyConfigInto(dir.path()); - // "interceptor" is a ship schematic in the test config — a valid prerequisite. - std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app); - out << "\n[[recipe]]\n" - "id = \"gated_ok\"\n" - "building = \"assembler\"\n" - "unlock_at_station_level = 1\n" - "unlock_requires = [\"interceptor\"]\n" - "inputs = []\n" - "outputs = [{item = \"circuit_board\", amount = 1}]\n" - "duration_seconds = 1.0\n"; + // repair_ship is already granted by the test config's unlocks.toml. + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n"; + out.close(); + + try + { + ConfigLoader::loadFromDirectory(dir.path().string()); + FAIL("Expected exception"); + } + catch (const std::runtime_error& e) + { + REQUIRE(std::string(e.what()).find("already granted") != std::string::npos); + } +} + +TEST_CASE("validateUnlocks rejects a non-assembler recipe grant", "[config]") +{ + TempConfigDir dir; + copyConfigInto(dir.path()); + + // mine_iron_ore is a miner recipe, not an assembler recipe. + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"badrecipe\"\nstation_level = 0\nrecipes = [\"mine_iron_ore\"]\n"; + out.close(); + + try + { + ConfigLoader::loadFromDirectory(dir.path().string()); + FAIL("Expected exception"); + } + catch (const std::runtime_error& e) + { + REQUIRE(std::string(e.what()).find("mine_iron_ore") != std::string::npos); + } +} + +TEST_CASE("validateUnlocks rejects requires naming an unknown group", "[config]") +{ + TempConfigDir dir; + copyConfigInto(dir.path()); + + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"needsmissing\"\nstation_level = 0\n" + "requires = [\"___missing___\"]\nships = [\"interceptor\"]\n"; + out.close(); + + try + { + ConfigLoader::loadFromDirectory(dir.path().string()); + FAIL("Expected exception"); + } + catch (const std::runtime_error& e) + { + REQUIRE(std::string(e.what()).find("___missing___") != std::string::npos); + } +} + +TEST_CASE("validateUnlocks rejects a group that grants nothing", "[config]") +{ + TempConfigDir dir; + copyConfigInto(dir.path()); + + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"empty\"\nstation_level = 0\n"; + out.close(); + + try + { + ConfigLoader::loadFromDirectory(dir.path().string()); + FAIL("Expected exception"); + } + catch (const std::runtime_error& e) + { + REQUIRE(std::string(e.what()).find("grants no items") != std::string::npos); + } +} + +TEST_CASE("validateUnlocks accepts a well-formed unlock group", "[config]") +{ + TempConfigDir dir; + copyConfigInto(dir.path()); + + // salvage_ship starts unlocked (not granted elsewhere); repair_ship is an + // existing group id, so this new group is valid. + std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app); + out << "\n[[unlock]]\nid = \"extra\"\nstation_level = 2\n" + "requires = [\"repair_ship\"]\nships = [\"salvage_ship\"]\n"; out.close(); REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string())); diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index d1fef03..183aa73 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -57,36 +57,36 @@ static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId, } // --------------------------------------------------------------------------- -// ConfigLoader: parsing unlock_at_station_level on assembler recipes +// ConfigLoader: unlocked_at_start and unlock-group gating on assembler recipes // --------------------------------------------------------------------------- -TEST_CASE("RecipeSchematic: unlock_at_station_level = -1 parsed correctly", "[recipe_schematic]") +TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]") { const GameConfig cfg = loadConfig(); const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), [](const RecipeDef& r) { return r.id == "premium_circuit"; }); REQUIRE(it != cfg.recipes.recipes.end()); - REQUIRE(it->unlockAtStationLevel.has_value()); - CHECK(it->unlockAtStationLevel.value() == -1); + CHECK(it->unlockedAtStart); } -TEST_CASE("RecipeSchematic: unlock_at_station_level = 0 parsed correctly", "[recipe_schematic]") +TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]") { const GameConfig cfg = loadConfig(); - const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), - [](const RecipeDef& r) { return r.id == "quick_circuit"; }); - REQUIRE(it != cfg.recipes.recipes.end()); - REQUIRE(it->unlockAtStationLevel.has_value()); - CHECK(it->unlockAtStationLevel.value() == 0); + const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(), + [](const UnlockGroupDef& g) + { + return std::find(g.recipes.begin(), g.recipes.end(), "quick_circuit") != g.recipes.end(); + }); + CHECK(granted); } -TEST_CASE("RecipeSchematic: assembler recipe without the key has nullopt", "[recipe_schematic]") +TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]") { const GameConfig cfg = loadConfig(); const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(), [](const RecipeDef& r) { return r.id == "circuit_board"; }); REQUIRE(it != cfg.recipes.recipes.end()); - CHECK_FALSE(it->unlockAtStationLevel.has_value()); + CHECK_FALSE(it->unlockedAtStart); } // --------------------------------------------------------------------------- @@ -180,11 +180,11 @@ TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on s REQUIRE(awaitRecipeUnlock(sim, "quick_circuit")); } -TEST_CASE("RecipeSchematic: recipe whose output is not implicitly unlocked is never awarded", +TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is never awarded", "[recipe_schematic]") { - // exotic_alloy produces exotic_alloy (not implicitly unlocked), so the - // output-unlocked guard must keep it out of the drop pool at every level. + // exotic_alloy is in no unlock group and its output/inputs are unreachable + // via the item graph, so it can never be dropped or implicitly unlocked. Simulation sim(loadConfig()); for (int i = 0; i < 50; ++i) { @@ -241,11 +241,11 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices", { for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) { - if (opt.type == SchematicType::Recipe) + for (const GrantedSchematic& grant : opt.grantedItems) { - foundRecipeChoice = true; - break; + if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; } } + if (foundRecipeChoice) { break; } } SimulationTestAccess::applySchematicChoice(sim, 0); } @@ -377,11 +377,14 @@ TEST_CASE("SchematicDrop: an owned ship schematic is never offered again", const std::vector& choices = sim.getPendingSchematicChoices(); - // Locate the repair_ship option, if present in this drop. + // Locate the option granting repair_ship, if present in this drop. int repairShipIndex = -1; for (int j = 0; j < static_cast(choices.size()); ++j) { - if (choices[j].schematicId == "repair_ship") { repairShipIndex = j; } + for (const GrantedSchematic& grant : choices[j].grantedItems) + { + if (grant.id == "repair_ship") { repairShipIndex = j; break; } + } } // Once owned, it must never appear in the pool again. diff --git a/src/test/ShipyardTest.cpp b/src/test/ShipyardTest.cpp index 79f95f6..d4e5980 100644 --- a/src/test/ShipyardTest.cpp +++ b/src/test/ShipyardTest.cpp @@ -1,5 +1,7 @@ #include "catch.hpp" +#include + #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" @@ -20,11 +22,24 @@ static GameConfig loadConfig() return ConfigLoader::loadFromDirectory(CONFIG_DIR); } +// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT). +static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId) +{ + for (const UnlockGroupDef& group : cfg.unlocks.groups) + { + if (std::find(group.ships.begin(), group.ships.end(), shipId) != group.ships.end()) + { + return false; + } + } + return true; +} + static const ShipDef* findAvailableSchematic(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) { - if (def.unlockAtStationLevel == -1 && !def.schematic.materials.empty()) + if (startsUnlocked(cfg, def.id) && !def.schematic.materials.empty()) { return &def; } diff --git a/src/test/UnlockGroupTest.cpp b/src/test/UnlockGroupTest.cpp new file mode 100644 index 0000000..664cb07 --- /dev/null +++ b/src/test/UnlockGroupTest.cpp @@ -0,0 +1,117 @@ +// Unlock groups as the unit of loot (REQ-LOCK-EXPLICIT, REQ-LOCK-BUILDING, +// REQ-DEF-SCHEMATIC-DROP): buildings can be locked, and one drop can grant a +// building and a module together. + +#include +#include +#include +#include + +#include "catch.hpp" + +#include "BuildingType.h" +#include "ConfigLoader.h" +#include "FactionComponent.h" +#include "GameConfig.h" +#include "HealthComponent.h" +#include "SchematicChoiceOption.h" +#include "Simulation.h" +#include "SimulationTestAccess.h" +#include "StationBodyComponent.h" +#include "UnlocksConfig.h" + +namespace +{ + +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +void killEnemyStations(Simulation& sim) +{ + sim.getAdmin().forEach( + [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health) + { + if (faction.isEnemy) { health.hp = 0.0f; } + }); + sim.tick(); +} + +// A config whose only unlock group grants the salvager module and the salvage +// bay together, gated to the given station level. +GameConfig configWithSalvageGroup(int stationLevel) +{ + GameConfig cfg = loadConfig(); + cfg.unlocks.groups.clear(); + cfg.unlocks.groups.push_back( + UnlockGroupDef{"salvage_operations", stationLevel, {}, {}, {"salvager"}, {"salvage_bay"}, {}}); + return cfg; +} + +} // namespace + +TEST_CASE("UnlockGroup: a building granted by a group is locked at game start", "[unlock_group]") +{ + const Simulation sim(configWithSalvageGroup(0)); + CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay)); + CHECK_FALSE(sim.isModuleSchematicUnlocked("salvager")); + // A building in no unlock group is available from the start. + CHECK(sim.isBuildingUnlocked(BuildingType::Miner)); +} + +TEST_CASE("UnlockGroup: the drop option lists every granted item", "[unlock_group]") +{ + Simulation sim(configWithSalvageGroup(0)); + killEnemyStations(sim); // level 0 + REQUIRE(sim.hasSchematicChoicesPending()); + + const std::vector& options = sim.getPendingSchematicChoices(); + const auto it = std::find_if(options.begin(), options.end(), + [](const SchematicChoiceOption& o) { return o.unlockGroupId == "salvage_operations"; }); + REQUIRE(it != options.end()); + + const bool grantsModule = std::any_of(it->grantedItems.begin(), it->grantedItems.end(), + [](const GrantedSchematic& g) { return g.type == SchematicType::Module && g.id == "salvager"; }); + const bool grantsBuilding = std::any_of(it->grantedItems.begin(), it->grantedItems.end(), + [](const GrantedSchematic& g) { return g.type == SchematicType::Building && g.id == "salvage_bay"; }); + CHECK(grantsModule); + CHECK(grantsBuilding); +} + +TEST_CASE("UnlockGroup: awarding a group unlocks the building and module at once", "[unlock_group]") +{ + Simulation sim(configWithSalvageGroup(0)); + killEnemyStations(sim); // level 0 + REQUIRE(sim.hasSchematicChoicesPending()); + + // The only eligible group is salvage_operations, so option 0 grants it. + REQUIRE(sim.getPendingSchematicChoices()[0].unlockGroupId == "salvage_operations"); + SimulationTestAccess::applySchematicChoice(sim, 0); + + CHECK(sim.isBuildingUnlocked(BuildingType::SalvageBay)); + CHECK(sim.isModuleSchematicUnlocked("salvager")); +} + +TEST_CASE("UnlockGroup: a group above the destroyed station level is not offered", "[unlock_group]") +{ + Simulation sim(configWithSalvageGroup(5)); // eligible only at level >= 5 + killEnemyStations(sim); // level 0 push + // Pool is empty at level 0, so no choice dialog opens at all. + CHECK_FALSE(sim.hasSchematicChoicesPending()); + CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay)); +} + +TEST_CASE("UnlockGroup: reset re-locks a previously awarded building", "[unlock_group]") +{ + Simulation sim(configWithSalvageGroup(0)); + killEnemyStations(sim); + REQUIRE(sim.getPendingSchematicChoices()[0].unlockGroupId == "salvage_operations"); + SimulationTestAccess::applySchematicChoice(sim, 0); + REQUIRE(sim.isBuildingUnlocked(BuildingType::SalvageBay)); + + sim.reset(); + + CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay)); + CHECK_FALSE(sim.isModuleSchematicUnlocked("salvager")); +} diff --git a/src/test/UnlockPrereqTest.cpp b/src/test/UnlockPrereqTest.cpp index b54ffc3..233ef26 100644 --- a/src/test/UnlockPrereqTest.cpp +++ b/src/test/UnlockPrereqTest.cpp @@ -1,10 +1,10 @@ -// Drop-pool behaviour for unlock_requires schematic prerequisites -// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its -// unlock_requires is already explicitly unlocked, on top of the station-level -// (and, for recipes, output-implicitly-unlocked) checks. +// Drop-pool behaviour for unlock-group prerequisites (REQ-LOCK-PREREQ). An +// unlock group enters the drop pool only when every id in its `requires` names a +// group that has already been awarded, on top of the station-level check. // -// Configs are built in memory (load the test config, then mutate) so each case -// is isolated and does not perturb the shared test config used by other suites. +// Configs are built in memory (load the test config, then mutate the unlock +// groups) so each case is isolated and does not perturb the shared test config +// used by other suites. #include #include @@ -17,13 +17,11 @@ #include "FactionComponent.h" #include "GameConfig.h" #include "HealthComponent.h" -#include "ModulesConfig.h" -#include "RecipesConfig.h" #include "SchematicChoiceOption.h" -#include "ShipsConfig.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "StationBodyComponent.h" +#include "UnlocksConfig.h" namespace { @@ -33,34 +31,14 @@ GameConfig loadConfig() return ConfigLoader::loadFromDirectory(CONFIG_DIR); } -ShipDef& findShip(GameConfig& cfg, const std::string& id) +UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id) { - for (ShipDef& def : cfg.ships.ships) + for (UnlockGroupDef& group : cfg.unlocks.groups) { - if (def.id == id) { return def; } + if (group.id == id) { return group; } } - FAIL("ship not found: " + id); - return cfg.ships.ships.front(); -} - -ModuleDef& findModule(GameConfig& cfg, const std::string& id) -{ - for (ModuleDef& def : cfg.modules.modules) - { - if (def.id == id) { return def; } - } - FAIL("module not found: " + id); - return cfg.modules.modules.front(); -} - -RecipeDef& findRecipe(GameConfig& cfg, const std::string& id) -{ - for (RecipeDef& def : cfg.recipes.recipes) - { - if (def.id == id) { return def; } - } - FAIL("recipe not found: " + id); - return cfg.recipes.recipes.front(); + FAIL("unlock group not found: " + id); + return cfg.unlocks.groups.front(); } // Zeros the HP of both enemy defence stations and advances one tick so that @@ -87,23 +65,28 @@ void killEnemyStationsAndApply(Simulation& sim) } } +// True if any pending option grants the given schematic id. bool optionOffered(const Simulation& sim, const std::string& id) { const std::vector& options = sim.getPendingSchematicChoices(); return std::any_of(options.begin(), options.end(), - [&](const SchematicChoiceOption& o) { return o.schematicId == id; }); + [&](const SchematicChoiceOption& option) + { + return std::any_of(option.grantedItems.begin(), option.grantedItems.end(), + [&](const GrantedSchematic& grant) { return grant.id == id; }); + }); } } // namespace -TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from the pool", +TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the pool", "[unlock_prereq]") { - // quick_circuit (assembler recipe, level 0, output circuit_board is implicitly - // unlocked) would normally be eligible at the first station destruction. Gate - // it behind repair_ship (ship, level 0, locked at game start). + // quick_circuit (recipe group, level 0) would normally be eligible at the + // first station destruction. Gate it behind the repair_ship group (level 0, + // locked at game start). GameConfig cfg = loadConfig(); - findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"}; + findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"}; Simulation sim(std::move(cfg), 123); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); @@ -111,22 +94,22 @@ TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from th killEnemyStations(sim); // destroyed set level 0 REQUIRE(sim.hasSchematicChoicesPending()); - // Prerequisite unmet -> the gated recipe must not be offered, while the - // prerequisite schematic itself (the only other eligible pick) is. + // Prerequisite unmet -> the gated group must not be offered, while the + // prerequisite group itself (the only other eligible pick) is. CHECK_FALSE(optionOffered(sim, "quick_circuit")); CHECK(optionOffered(sim, "repair_ship")); } -TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked", +TEST_CASE("UnlockPrereq: the gated group becomes eligible once its prerequisite is awarded", "[unlock_prereq]") { GameConfig cfg = loadConfig(); - findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"}; + findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"}; Simulation sim(std::move(cfg), 123); // Applying choice 0 each drop unlocks repair_ship first (the only eligible - // pick at level 0), which then opens quick_circuit for a later drop. + // pick at level 0), which then opens the quick_circuit group for a later drop. bool unlocked = false; for (int i = 0; i < 150 && !unlocked; ++i) { @@ -138,16 +121,16 @@ TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite CHECK(unlocked); } -TEST_CASE("UnlockPrereq: a module gated behind another module is withheld until it unlocks", +TEST_CASE("UnlockPrereq: a module group gated behind another module is withheld until it unlocks", "[unlock_prereq]") { - // Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two - // test-config modules made lockable at the same station level. + // Make laser_cannon and armor_plate (both start-unlocked in the test config) + // lockable via new unlock groups, with armor_plate gated behind laser_cannon. GameConfig cfg = loadConfig(); - findModule(cfg, "laser_cannon").unlockAtStationLevel = 0; - ModuleDef& armor = findModule(cfg, "armor_plate"); - armor.unlockAtStationLevel = 0; - armor.unlockRequires = {"laser_cannon"}; + cfg.unlocks.groups.push_back( + UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}}); + cfg.unlocks.groups.push_back( + UnlockGroupDef{"armor_plate", 0, {"laser_cannon"}, {}, {"armor_plate"}, {}, {}}); Simulation sim(std::move(cfg), 7); REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon")); diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index 247f376..30f8b87 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -305,26 +305,35 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]") for (const SchematicChoiceOption& opt : choices) { - bool validId = false; - for (const ShipDef& def : sim.getConfig().ships.ships) + if (opt.isArtifact) { continue; } + bool validGroup = false; + for (const UnlockGroupDef& group : sim.getConfig().unlocks.groups) { - if (def.id == opt.schematicId) { validId = true; break; } + if (group.id == opt.unlockGroupId) { validGroup = true; break; } } - if (!validId) + REQUIRE(validGroup); + // Every granted item must resolve to a defined ship/module/building/recipe. + for (const GrantedSchematic& grant : opt.grantedItems) { + bool validId = false; + for (const ShipDef& def : sim.getConfig().ships.ships) + { + if (def.id == grant.id) { validId = true; break; } + } for (const ModuleDef& def : sim.getConfig().modules.modules) { - if (def.id == opt.schematicId) { validId = true; break; } + if (def.id == grant.id) { validId = true; break; } + } + for (const BuildingDef& def : sim.getConfig().buildings.buildings) + { + if (def.id == grant.id) { validId = true; break; } } - } - if (!validId) - { for (const RecipeDef& def : sim.getConfig().recipes.recipes) { - if (def.id == opt.schematicId) { validId = true; break; } + if (def.id == grant.id) { validId = true; break; } } + REQUIRE(validId); } - REQUIRE(validId); } } @@ -343,7 +352,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]") std::set ids; for (const SchematicChoiceOption& opt : choices) { - ids.insert(opt.schematicId); + ids.insert(opt.isArtifact ? std::string("") : opt.unlockGroupId); } REQUIRE(ids.size() == choices.size()); } diff --git a/src/ui/BuildButtonGrid.cpp b/src/ui/BuildButtonGrid.cpp index aafa76c..3aa02e8 100644 --- a/src/ui/BuildButtonGrid.cpp +++ b/src/ui/BuildButtonGrid.cpp @@ -82,6 +82,7 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWid std::make_shared()); }); + updateVisibility(); registerForEvents(); } @@ -119,6 +120,17 @@ void BuildButtonGrid::updateAffordability() } } +void BuildButtonGrid::updateVisibility() +{ + // A locked building type's button is hidden until its unlock group is awarded + // (REQ-LOCK-BUILDING). Buttons keep their index so hotkeys/affordability stay + // stable; they simply appear once the type is unlocked. + for (std::size_t i = 0; i < m_buttons.size(); ++i) + { + m_buttons[i]->setVisible(m_sim->isBuildingUnlocked(m_types[i])); + } +} + void BuildButtonGrid::clearActiveButton() { if (m_activeIndex) @@ -165,6 +177,12 @@ void BuildButtonGrid::handleEvent(std::shared_ptr /*event*/) +{ + updateVisibility(); + updateAffordability(); +} + void BuildButtonGrid::handleEvent(std::shared_ptr event) { m_demolishButton->setChecked(event->active); @@ -176,9 +194,10 @@ void BuildButtonGrid::handleEvent(std::shared_ptr { if (m_types[i] == event->type) { - // Equivalent to clicking the build button: a disabled (unaffordable) - // button cannot be clicked, so the hotkey is likewise inert. - if (m_buttons[i]->isEnabled()) + // Equivalent to clicking the build button: a disabled (unaffordable) or + // hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the + // hotkey is likewise inert. + if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible()) { onBuildButton(static_cast(i)); } diff --git a/src/ui/BuildButtonGrid.h b/src/ui/BuildButtonGrid.h index 7bfbc0d..c314d4c 100644 --- a/src/ui/BuildButtonGrid.h +++ b/src/ui/BuildButtonGrid.h @@ -13,6 +13,7 @@ #include "DemolishModeChangedEvent.h" #include "EventHandler.h" #include "GameConfig.h" +#include "UnlockedBuildingsChangedEvent.h" class QPushButton; class Simulation; @@ -21,7 +22,8 @@ class BuildButtonGrid : public QWidget, public CombinedEventHandler + BuildingBlocksChangedEvent, + UnlockedBuildingsChangedEvent> { Q_OBJECT @@ -37,10 +39,15 @@ private: // be afforded, it exits builder mode so the button does not stay selected. void updateAffordability(); + // Re-evaluates which build buttons are visible from the current per-building + // unlock state (REQ-LOCK-BUILDING); a locked building type's button is hidden. + void updateVisibility(); + void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; private slots: void onBuildButton(int index); diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index b57b967..b3b5eec 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -67,6 +67,7 @@ #include "BuilderModeExitedEvent.h" #include "BlueprintModeExitedEvent.h" #include "BuildingBlocksChangedEvent.h" +#include "UnlockedBuildingsChangedEvent.h" #include "ExpansionCostChangedEvent.h" #include "GameSpeedChangedEvent.h" #include "SchematicChoicesAvailableEvent.h" @@ -367,6 +368,20 @@ void GameWorldView::onFrame() EventManager::getInstance()->sendEventImmediately( std::make_shared(newBoss, newCountdown)); } + + // Unlocked building set changes only after a drop is applied or on Restart + // (REQ-LOCK-BUILDING); the build grid rebuilds its visible buttons. + int newUnlockedBuildingCount = 0; + for (const BuildingDef& def : m_sim->getConfig().buildings.buildings) + { + if (m_sim->isBuildingUnlocked(def.type)) { ++newUnlockedBuildingCount; } + } + if (newUnlockedBuildingCount != m_lastUnlockedBuildingCount) + { + m_lastUnlockedBuildingCount = newUnlockedBuildingCount; + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + } } // Sim-state polls are input sources for the live game; in replay these are @@ -801,13 +816,18 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) for (const BlueprintBuilding& bb : bp.buildings) { + // Locked building types are excluded from this placement entirely + // (REQ-LOCK-BUILDING, REQ-LOCK-UI-BLUEPRINT): not validity-checked here. + if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (!isValidPlacement(bb.type, center + bb.offset, bb.rotation)) { return; } } - // Cost only applies to buildings that are genuinely new (not rotate-in-place). + // Cost only applies to buildings that are genuinely new (not rotate-in-place), + // and excludes locked building types (REQ-LOCK-UI-BLUEPRINT). int totalCost = 0; for (const BlueprintBuilding& bb : bp.buildings) { + if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } if (m_sim->getBuildings().findRotateInPlaceTarget( bb.type, center + bb.offset, bb.rotation).has_value()) { @@ -820,6 +840,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) for (const BlueprintBuilding& bb : bp.buildings) { + if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = center + bb.offset; const std::optional rotateTarget = m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); @@ -1946,6 +1967,9 @@ void GameWorldView::drawOverlays(QPainter& painter) { for (const BlueprintBuilding& bb : m_blueprintMode->buildings) { + // Locked building types are omitted from the blueprint (REQ-LOCK-BUILDING, + // REQ-LOCK-UI-BLUEPRINT), so they are not ghosted either. + if (!m_sim->isBuildingUnlocked(bb.type)) { continue; } const QPoint anchor = m_blueprintGhostTile + bb.offset; const bool valid = isValidPlacement(bb.type, anchor, bb.rotation); drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid, diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 4a59dd4..ddb1edc 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -351,4 +351,5 @@ private: int m_lastBossCounter = -1; Tick m_lastBossCountdown = Tick(-1); int m_lastArtifactCount = -1; + int m_lastUnlockedBuildingCount = -1; }; diff --git a/src/ui/SchematicChoiceDialog.cpp b/src/ui/SchematicChoiceDialog.cpp index ee43372..ddab1c7 100644 --- a/src/ui/SchematicChoiceDialog.cpp +++ b/src/ui/SchematicChoiceDialog.cpp @@ -21,6 +21,18 @@ const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id) return nullptr; } +QString grantKindLabel(SchematicType type) +{ + switch (type) + { + case SchematicType::Ship: return SchematicChoiceDialog::tr("Ship"); + case SchematicType::Module: return SchematicChoiceDialog::tr("Module"); + case SchematicType::Building: return SchematicChoiceDialog::tr("Building"); + case SchematicType::Recipe: return SchematicChoiceDialog::tr("Recipe"); + } + return QString(); +} + } // namespace SchematicChoiceDialog::SchematicChoiceDialog( @@ -63,7 +75,7 @@ SchematicChoiceDialog::SchematicChoiceDialog( nameLabel->setAlignment(Qt::AlignCenter); cardLayout->addWidget(nameLabel); - if (option.type == SchematicType::Artifact) + if (option.isArtifact) { QLabel* artifactTypeLabel = new QLabel(tr("Artifact"), card); artifactTypeLabel->setAlignment(Qt::AlignCenter); @@ -71,22 +83,22 @@ SchematicChoiceDialog::SchematicChoiceDialog( } else { - QString typeText; - if (option.type == SchematicType::Ship) + QLabel* grantsHeaderLabel = new QLabel(tr("Unlocks:"), card); + QFont grantsHeaderFont = grantsHeaderLabel->font(); + grantsHeaderFont.setBold(true); + grantsHeaderLabel->setFont(grantsHeaderFont); + grantsHeaderLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(grantsHeaderLabel); + + for (const GrantedSchematic& grant : option.grantedItems) { - typeText = tr("Ship"); + QLabel* grantLabel = new QLabel( + grantKindLabel(grant.type) + ": " + + QString::fromStdString(grant.displayName), + card); + grantLabel->setAlignment(Qt::AlignCenter); + cardLayout->addWidget(grantLabel); } - else if (option.type == SchematicType::Module) - { - typeText = tr("Module"); - } - else - { - typeText = tr("Recipe"); - } - QLabel* typeLabel = new QLabel(typeText, card); - typeLabel->setAlignment(Qt::AlignCenter); - cardLayout->addWidget(typeLabel); QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card); QFont unlocksHeaderFont = unlocksHeaderLabel->font();