Implement config-driven unlock groups
Lift unlocking into first-class unlock groups defined in unlocks.toml, so one defence-station drop can grant a bundle of ships/modules/buildings/ recipes at once (e.g. the salvager module + salvage bay), and buildings can now be locked at game start and unlocked via a drop. Config: - New UnlocksConfig + ConfigLoader::loadUnlocks + validateUnlocks (unknown/ duplicate/non-assembler grants, unknown requires, empty group all fail load). - Drop unlock_at_station_level/unlock_requires from ship/module/recipe defs; add unlocked_at_start bool to recipes for graph-unreachable base recipes. Simulation: - Track awarded groups + per-building unlock state; an item starts unlocked iff no group grants it. Rework generateSchematicChoices/applySchematicChoice to operate on groups (grant all members at once). Extend state checksum. - isBuildingUnlocked query; tryPlaceBuilding rejects locked types. UI: - Build-menu buttons for locked buildings start hidden, revealed via a new UnlockedBuildingsChangedEvent (emitted from GameWorldView's poll loop). - Schematic choice dialog lists a group's granted items. - Blueprint placement excludes locked building types (REQ-LOCK-UI-BLUEPRINT). Data: unlocks.toml (app + test) reproduces the prior per-item gates as singleton groups and adds salvage_operations (salvager + salvage_bay, lvl 1) and reprocessing (reprocessing_plant, lvl 2). Tests: rewrote unlock/config/recipe-schematic/artifact/wave/shipyard tests for the group model; added UnlockGroupTest. All 443 cases pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
This commit is contained in:
@@ -7,10 +7,9 @@
|
|||||||
# Combat stats are placeholders until the arena balancing pass;
|
# Combat stats are placeholders until the arena balancing pass;
|
||||||
# production_time_seconds values come from the numbers pass.
|
# production_time_seconds values come from the numbers pass.
|
||||||
#
|
#
|
||||||
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
|
# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a
|
||||||
# the pacing pass. The railguns are the exception: railgun_m is gated to
|
# module id granted by an unlock group starts locked and is awarded via a
|
||||||
# station level 1, and railgun_l requires railgun_m to be unlocked first
|
# defence station drop; ids absent from unlocks.toml (railgun_s) start unlocked.
|
||||||
# (unlock_requires) — a demonstration of the prerequisite chain.
|
|
||||||
#
|
#
|
||||||
# Surface mask footprint ladder — footprints gate which hulls can mount a
|
# Surface mask footprint ladder — footprints gate which hulls can mount a
|
||||||
# module, purely through geometry (see ships.toml for the matching hull
|
# module, purely through geometry (see ships.toml for the matching hull
|
||||||
@@ -33,7 +32,6 @@
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "railgun_s"
|
id = "railgun_s"
|
||||||
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
|
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "railgun_s_module", amount = 1}]
|
materials = [{item = "railgun_s_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -49,7 +47,6 @@ attack_rate_hz = 2.0
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "railgun_m"
|
id = "railgun_m"
|
||||||
tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
|
tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
|
||||||
unlock_at_station_level = 2
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OO"]
|
"OO"]
|
||||||
@@ -67,8 +64,6 @@ attack_rate_hz = 1.5
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "railgun_l"
|
id = "railgun_l"
|
||||||
tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
|
tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
|
||||||
unlock_at_station_level = 6
|
|
||||||
unlock_requires = ["railgun_m"]
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OOO",
|
"OOO",
|
||||||
"OOO",
|
"OOO",
|
||||||
@@ -90,7 +85,6 @@ attack_rate_hz = 0.8
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "salvager"
|
id = "salvager"
|
||||||
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
|
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "salvager_module", amount = 1}]
|
materials = [{item = "salvager_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -106,7 +100,6 @@ collection_rate_hz = 0.5
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "repair_tool"
|
id = "repair_tool"
|
||||||
tooltip = "Repairs damaged friendly ships and defence stations within range."
|
tooltip = "Repairs damaged friendly ships and defence stations within range."
|
||||||
unlock_at_station_level = 0
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "repair_tool_module", amount = 1}]
|
materials = [{item = "repair_tool_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -125,7 +118,6 @@ repair_range_m = 80
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "afterburner"
|
id = "afterburner"
|
||||||
tooltip = "Greatly boosts top speed and forward acceleration."
|
tooltip = "Greatly boosts top speed and forward acceleration."
|
||||||
unlock_at_station_level = 2
|
|
||||||
surface_mask = ["OOO"]
|
surface_mask = ["OOO"]
|
||||||
materials = [{item = "afterburner_module", amount = 1}]
|
materials = [{item = "afterburner_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -140,7 +132,6 @@ added_main_acceleration_mpss = 60
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "maneuvering_thrusters"
|
id = "maneuvering_thrusters"
|
||||||
tooltip = "Improves top speed and lateral/braking acceleration."
|
tooltip = "Improves top speed and lateral/braking acceleration."
|
||||||
unlock_at_station_level = 1
|
|
||||||
surface_mask = ["OO"]
|
surface_mask = ["OO"]
|
||||||
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
|
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -158,7 +149,6 @@ added_maneuvering_acceleration_mpss = 10
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "armor_plates"
|
id = "armor_plates"
|
||||||
tooltip = "Adds a large flat bonus to the ship's hit points."
|
tooltip = "Adds a large flat bonus to the ship's hit points."
|
||||||
unlock_at_station_level = 0
|
|
||||||
surface_mask = ["OO"]
|
surface_mask = ["OO"]
|
||||||
materials = [{item = "armor_plates_module", amount = 1}]
|
materials = [{item = "armor_plates_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -172,7 +162,6 @@ added_hp = 1200
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "sensor_booster"
|
id = "sensor_booster"
|
||||||
tooltip = "Extends the ship's sensor range."
|
tooltip = "Extends the ship's sensor range."
|
||||||
unlock_at_station_level = 1
|
|
||||||
surface_mask = ["OO"]
|
surface_mask = ["OO"]
|
||||||
materials = [{item = "sensor_booster_module", amount = 1}]
|
materials = [{item = "sensor_booster_module", amount = 1}]
|
||||||
production_time_seconds = 1
|
production_time_seconds = 1
|
||||||
@@ -189,7 +178,6 @@ added_sensor_range_m = 50
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_upgrade"
|
id = "weapon_upgrade"
|
||||||
tooltip = "Increases the damage of all weapons on the ship."
|
tooltip = "Increases the damage of all weapons on the ship."
|
||||||
unlock_at_station_level = 4
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OX",
|
"OX",
|
||||||
@@ -206,7 +194,6 @@ multiplied_damage = 1.2
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_primer"
|
id = "weapon_primer"
|
||||||
tooltip = "Increases the fire rate of all weapons on the ship."
|
tooltip = "Increases the fire rate of all weapons on the ship."
|
||||||
unlock_at_station_level = 4
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OX",
|
"OX",
|
||||||
@@ -223,7 +210,6 @@ multiplied_attack_rate_hz = 1.2
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_stabilizer"
|
id = "weapon_stabilizer"
|
||||||
tooltip = "Extends weapon range at the cost of some fire rate."
|
tooltip = "Extends weapon range at the cost of some fire rate."
|
||||||
unlock_at_station_level = 3
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OX",
|
"OX",
|
||||||
@@ -247,7 +233,6 @@ multiplied_attack_rate_hz = 0.8
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "drone_bay"
|
id = "drone_bay"
|
||||||
tooltip = "Drone launch bay (capability not yet implemented)."
|
tooltip = "Drone launch bay (capability not yet implemented)."
|
||||||
unlock_at_station_level = 5
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OO",
|
"OO",
|
||||||
"OO"]
|
"OO"]
|
||||||
@@ -260,7 +245,6 @@ glyph = "Db"
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "drone_hangar"
|
id = "drone_hangar"
|
||||||
tooltip = "Large drone hangar (capability not yet implemented)."
|
tooltip = "Large drone hangar (capability not yet implemented)."
|
||||||
unlock_at_station_level = 9
|
|
||||||
surface_mask = [
|
surface_mask = [
|
||||||
"OOOOOO",
|
"OOOOOO",
|
||||||
"OOOOOO"]
|
"OOOOOO"]
|
||||||
|
|||||||
@@ -142,12 +142,12 @@ duration_seconds = 1.5
|
|||||||
|
|
||||||
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
|
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
|
||||||
# doubling-time knob; see the block economy rules in docs/balancing/rules.md.
|
# doubling-time knob; see the block economy rules in docs/balancing/rules.md.
|
||||||
# Explicitly unlocked at start (-1): building blocks appear in no
|
# unlocked_at_start: building blocks appear in no schematic's materials, so the
|
||||||
# schematic's materials, so implicit unlocking can never reach this recipe.
|
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "building_block"
|
id = "building_block"
|
||||||
unlock_at_station_level = -1
|
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
|
unlocked_at_start = true
|
||||||
inputs = [{item = "steel_plate", amount = 2}]
|
inputs = [{item = "steel_plate", amount = 2}]
|
||||||
outputs = [{item = "building_block", amount = 4}]
|
outputs = [{item = "building_block", amount = 4}]
|
||||||
duration_seconds = 2.0
|
duration_seconds = 2.0
|
||||||
@@ -220,15 +220,14 @@ outputs = [{item = "capital_core", amount = 1}]
|
|||||||
duration_seconds = 10.0
|
duration_seconds = 10.0
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Shortcut recipes — drop-only assembler recipe schematics
|
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
|
||||||
# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by
|
# 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
|
# the base (expensive) path via the max rule, so shortcuts give real factory
|
||||||
# efficiency without shifting any balance.
|
# efficiency without shifting any balance.
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_steel_plate"
|
id = "shortcut_steel_plate"
|
||||||
unlock_at_station_level = 1
|
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ore", amount = 3}]
|
inputs = [{item = "iron_ore", amount = 3}]
|
||||||
outputs = [{item = "steel_plate", amount = 1}]
|
outputs = [{item = "steel_plate", amount = 1}]
|
||||||
@@ -236,7 +235,6 @@ duration_seconds = 2.0
|
|||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_control_chip"
|
id = "shortcut_control_chip"
|
||||||
unlock_at_station_level = 2
|
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "quartz", amount = 2}]
|
inputs = [{item = "quartz", amount = 2}]
|
||||||
outputs = [{item = "control_chip", amount = 1}]
|
outputs = [{item = "control_chip", amount = 1}]
|
||||||
@@ -244,7 +242,6 @@ duration_seconds = 4.0
|
|||||||
|
|
||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "shortcut_hardened_steel"
|
id = "shortcut_hardened_steel"
|
||||||
unlock_at_station_level = 2
|
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
inputs = [{item = "iron_ingot", amount = 4}]
|
inputs = [{item = "iron_ingot", amount = 4}]
|
||||||
outputs = [{item = "hardened_steel", amount = 1}]
|
outputs = [{item = "hardened_steel", amount = 1}]
|
||||||
|
|||||||
@@ -4,10 +4,9 @@
|
|||||||
# content; stats, materials, and production times are placeholders until the
|
# content; stats, materials, and production times are placeholders until the
|
||||||
# recipe and balancing passes.
|
# recipe and balancing passes.
|
||||||
#
|
#
|
||||||
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
|
# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a ship
|
||||||
# the balancing pass. The capital hulls are the exception: battleship is gated
|
# id granted by an unlock group starts locked and is awarded via a defence
|
||||||
# to station level 1, and dreadnought (level 2) requires battleship to be
|
# station drop; ids absent from unlocks.toml (drone, frigate) start unlocked.
|
||||||
# unlocked first (unlock_requires) — a demonstration of the prerequisite chain.
|
|
||||||
#
|
#
|
||||||
# Size classes:
|
# Size classes:
|
||||||
# xs drone 1 cell — exactly one 1x1 module
|
# xs drone 1 cell — exactly one 1x1 module
|
||||||
@@ -22,7 +21,6 @@
|
|||||||
|
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "drone"
|
id = "drone"
|
||||||
unlock_at_station_level = -1
|
|
||||||
layout = ["O"]
|
layout = ["O"]
|
||||||
default_modules = [{type = "railgun_s", x = 0, y = 0, rotation = "east"}]
|
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.
|
# L-shaped weapon modifier, or an afterburner spanning the full center line.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "frigate"
|
id = "frigate"
|
||||||
unlock_at_station_level = -1
|
|
||||||
layout = [
|
layout = [
|
||||||
"XOX",
|
"XOX",
|
||||||
"OOO",
|
"OOO",
|
||||||
@@ -84,7 +81,6 @@ sensor_range_m = 200
|
|||||||
# mount medium hardware.
|
# mount medium hardware.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "destroyer"
|
id = "destroyer"
|
||||||
unlock_at_station_level = 0
|
|
||||||
layout = [
|
layout = [
|
||||||
"OXOXO",
|
"OXOXO",
|
||||||
"OOOOO",
|
"OOOOO",
|
||||||
@@ -120,7 +116,6 @@ sensor_range_m = 220
|
|||||||
# supports; no 3x3 area exists for an l gun.
|
# supports; no 3x3 area exists for an l gun.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "cruiser"
|
id = "cruiser"
|
||||||
unlock_at_station_level = 2
|
|
||||||
layout = [
|
layout = [
|
||||||
"XOOX",
|
"XOOX",
|
||||||
"OOOO",
|
"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.
|
# stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "battlecruiser"
|
id = "battlecruiser"
|
||||||
unlock_at_station_level = 4
|
|
||||||
unlock_requires = ["cruiser"]
|
|
||||||
layout = [
|
layout = [
|
||||||
"OOXXOO",
|
"OOXXOO",
|
||||||
"OOOOOO",
|
"OOOOOO",
|
||||||
@@ -201,8 +194,6 @@ sensor_range_m = 260
|
|||||||
# so no 2x6 drone hangar fits.
|
# so no 2x6 drone hangar fits.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "battleship"
|
id = "battleship"
|
||||||
unlock_at_station_level = 6
|
|
||||||
unlock_requires = ["battlecruiser"]
|
|
||||||
layout = [
|
layout = [
|
||||||
"XOOOOX",
|
"XOOOOX",
|
||||||
"OOOOOO",
|
"OOOOOO",
|
||||||
@@ -244,8 +235,6 @@ sensor_range_m = 280
|
|||||||
# stay the only hangar hull. Bow and stern strips hold supports.
|
# stay the only hangar hull. Bow and stern strips hold supports.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "dreadnought"
|
id = "dreadnought"
|
||||||
unlock_at_station_level = 8
|
|
||||||
unlock_requires = ["battleship"]
|
|
||||||
layout = [
|
layout = [
|
||||||
"XXXOOOOOXXX",
|
"XXXOOOOOXXX",
|
||||||
"OOOXOOOXOOO",
|
"OOOXOOOXOOO",
|
||||||
@@ -288,8 +277,6 @@ sensor_range_m = 300
|
|||||||
# the lower decks hold supports and 2x2 point-defense m guns.
|
# the lower decks hold supports and 2x2 point-defense m guns.
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "carrier"
|
id = "carrier"
|
||||||
unlock_at_station_level = 9
|
|
||||||
unlock_requires = ["battleship"]
|
|
||||||
layout = [
|
layout = [
|
||||||
"XOOOOOOOOX",
|
"XOOOOOOOOX",
|
||||||
"OOOOOOOOOO",
|
"OOOOOOOOOO",
|
||||||
|
|||||||
140
bin/app/data/config/unlocks.toml
Normal file
140
bin/app/data/config/unlocks.toml
Normal file
@@ -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"]
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
[[module]]
|
[[module]]
|
||||||
id = "armor_plate"
|
id = "armor_plate"
|
||||||
tooltip = "Adds a large flat bonus to hit points."
|
tooltip = "Adds a large flat bonus to hit points."
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["OO"]
|
surface_mask = ["OO"]
|
||||||
materials = [{item = "iron_ingot", amount = 2}]
|
materials = [{item = "iron_ingot", amount = 2}]
|
||||||
production_time_seconds = 3
|
production_time_seconds = 3
|
||||||
@@ -13,7 +12,6 @@ multiplied_hp = 1.5
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "sensor_booster"
|
id = "sensor_booster"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "circuit_board", amount = 1}]
|
materials = [{item = "circuit_board", amount = 1}]
|
||||||
production_time_seconds = 2
|
production_time_seconds = 2
|
||||||
@@ -25,7 +23,6 @@ added_sensor_range_m = 100
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_upgrade"
|
id = "weapon_upgrade"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
|
||||||
production_time_seconds = 4
|
production_time_seconds = 4
|
||||||
@@ -37,7 +34,6 @@ multiplied_damage = 1.2
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "laser_cannon"
|
id = "laser_cannon"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}]
|
||||||
production_time_seconds = 5
|
production_time_seconds = 5
|
||||||
@@ -51,7 +47,6 @@ attack_rate_hz = 2.0
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "salvager"
|
id = "salvager"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["OO"]
|
surface_mask = ["OO"]
|
||||||
materials = [{item = "iron_ingot", amount = 2}]
|
materials = [{item = "iron_ingot", amount = 2}]
|
||||||
production_time_seconds = 5
|
production_time_seconds = 5
|
||||||
@@ -65,7 +60,6 @@ collection_rate_hz = 0.5
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "repair_tool"
|
id = "repair_tool"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "circuit_board", amount = 2}]
|
materials = [{item = "circuit_board", amount = 2}]
|
||||||
production_time_seconds = 5
|
production_time_seconds = 5
|
||||||
@@ -79,7 +73,6 @@ repair_range_m = 800
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_primer"
|
id = "weapon_primer"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}]
|
||||||
production_time_seconds = 4
|
production_time_seconds = 4
|
||||||
@@ -91,7 +84,6 @@ multiplied_attack_rate_hz = 1.2
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "weapon_stabilizer"
|
id = "weapon_stabilizer"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}]
|
||||||
production_time_seconds = 4
|
production_time_seconds = 4
|
||||||
@@ -104,7 +96,6 @@ multiplied_attack_rate_hz = 0.8
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "afterburner"
|
id = "afterburner"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}]
|
||||||
production_time_seconds = 2
|
production_time_seconds = 2
|
||||||
@@ -117,7 +108,6 @@ added_main_acceleration_mpss = 60
|
|||||||
|
|
||||||
[[module]]
|
[[module]]
|
||||||
id = "maneuvering_thrusters"
|
id = "maneuvering_thrusters"
|
||||||
unlock_at_station_level = -1
|
|
||||||
surface_mask = ["O"]
|
surface_mask = ["O"]
|
||||||
materials = [{item = "iron_ingot", amount = 1}]
|
materials = [{item = "iron_ingot", amount = 1}]
|
||||||
production_time_seconds = 2
|
production_time_seconds = 2
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ duration_seconds = 4.0
|
|||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "premium_circuit"
|
id = "premium_circuit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
unlock_at_station_level = -1
|
unlocked_at_start = true
|
||||||
inputs = [{item = "circuit_board", amount = 1}]
|
inputs = [{item = "circuit_board", amount = 1}]
|
||||||
outputs = [{item = "premium_circuit", amount = 1}]
|
outputs = [{item = "premium_circuit", amount = 1}]
|
||||||
duration_seconds = 8.0
|
duration_seconds = 8.0
|
||||||
@@ -51,7 +51,6 @@ duration_seconds = 8.0
|
|||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "quick_circuit"
|
id = "quick_circuit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
unlock_at_station_level = 0
|
|
||||||
inputs = [{item = "copper_ingot", amount = 3}]
|
inputs = [{item = "copper_ingot", amount = 3}]
|
||||||
outputs = [{item = "circuit_board", amount = 1}]
|
outputs = [{item = "circuit_board", amount = 1}]
|
||||||
duration_seconds = 3.0
|
duration_seconds = 3.0
|
||||||
@@ -59,7 +58,6 @@ duration_seconds = 3.0
|
|||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "advanced_circuit"
|
id = "advanced_circuit"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
unlock_at_station_level = 1
|
|
||||||
inputs = [{item = "iron_ingot", amount = 5}]
|
inputs = [{item = "iron_ingot", amount = 5}]
|
||||||
outputs = [{item = "circuit_board", amount = 1}]
|
outputs = [{item = "circuit_board", amount = 1}]
|
||||||
duration_seconds = 6.0
|
duration_seconds = 6.0
|
||||||
@@ -67,7 +65,6 @@ duration_seconds = 6.0
|
|||||||
[[recipe]]
|
[[recipe]]
|
||||||
id = "exotic_alloy"
|
id = "exotic_alloy"
|
||||||
building = "assembler"
|
building = "assembler"
|
||||||
unlock_at_station_level = 0
|
|
||||||
inputs = [{item = "exotic_ore", amount = 2}]
|
inputs = [{item = "exotic_ore", amount = 2}]
|
||||||
outputs = [{item = "exotic_alloy", amount = 1}]
|
outputs = [{item = "exotic_alloy", amount = 1}]
|
||||||
duration_seconds = 10.0
|
duration_seconds = 10.0
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
[[ship]]
|
[[ship]]
|
||||||
id = "interceptor"
|
id = "interceptor"
|
||||||
unlock_at_station_level = -1
|
|
||||||
layout = ["XOX", "OOO", "XOX"]
|
layout = ["XOX", "OOO", "XOX"]
|
||||||
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ sensor_range_m = 2000
|
|||||||
|
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "destroyer"
|
id = "destroyer"
|
||||||
unlock_at_station_level = -1
|
|
||||||
layout = ["XOOX", "OOOO", "XOOX"]
|
layout = ["XOOX", "OOOO", "XOOX"]
|
||||||
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ sensor_range_m = 3000
|
|||||||
|
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "salvage_ship"
|
id = "salvage_ship"
|
||||||
unlock_at_station_level = -1
|
|
||||||
layout = ["OOO", "OOO"]
|
layout = ["OOO", "OOO"]
|
||||||
|
|
||||||
[ship.schematic]
|
[ship.schematic]
|
||||||
@@ -71,7 +68,6 @@ sensor_range_m = 2500
|
|||||||
|
|
||||||
[[ship]]
|
[[ship]]
|
||||||
id = "repair_ship"
|
id = "repair_ship"
|
||||||
unlock_at_station_level = 0
|
|
||||||
layout = ["XOX", "OOO", "XOX"]
|
layout = ["XOX", "OOO", "XOX"]
|
||||||
|
|
||||||
[ship.schematic]
|
[ship.schematic]
|
||||||
|
|||||||
21
bin/test/data/config/unlocks.toml
Normal file
21
bin/test/data/config/unlocks.toml
Normal file
@@ -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"]
|
||||||
@@ -8,6 +8,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/StationsConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ModulesConfig.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/UnlocksConfig.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ConfigLoader.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
${CMAKE_CURRENT_SOURCE_DIR}/SurfaceMask.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintSerializer.h
|
||||||
|
|||||||
@@ -404,18 +404,10 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
|
|||||||
}
|
}
|
||||||
def.building = *parsedType;
|
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<int64_t>();
|
def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file,
|
||||||
if (level)
|
elemPath + ".unlocked_at_start");
|
||||||
{
|
|
||||||
def.unlockAtStationLevel = static_cast<int>(*level);
|
|
||||||
}
|
|
||||||
if (mt.contains("unlock_requires"))
|
|
||||||
{
|
|
||||||
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
|
|
||||||
elemPath + ".unlock_requires");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
|
// 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;
|
ShipDef def;
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||||
def.unlockAtStationLevel = static_cast<int>(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");
|
def.layout = requireStringArray(mt["layout"], file, elemPath + ".layout");
|
||||||
|
|
||||||
// Schematic
|
// Schematic
|
||||||
@@ -608,12 +595,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
|||||||
|
|
||||||
ModuleDef def;
|
ModuleDef def;
|
||||||
def.id = requireString(mt["id"], file, elemPath + ".id");
|
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||||
def.unlockAtStationLevel = static_cast<int>(
|
|
||||||
mt["unlock_at_station_level"].value_or<int64_t>(-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.surfaceMask = requireStringArray(mt["surface_mask"], file, elemPath + ".surface_mask");
|
||||||
def.productionTimeSeconds = requireDouble(
|
def.productionTimeSeconds = requireDouble(
|
||||||
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
|
||||||
@@ -731,62 +712,141 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
|
|||||||
return cfg;
|
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<toml::table&>(*ut);
|
||||||
|
|
||||||
|
UnlockGroupDef def;
|
||||||
|
def.id = requireString(mt["id"], file, elemPath + ".id");
|
||||||
|
def.stationLevel = static_cast<int>(
|
||||||
|
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
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic.
|
// Validates unlocks.toml against the rest of the config (REQ-LOCK-EXPLICIT,
|
||||||
void checkUnlockRequires(const std::vector<std::string>& requiredIds,
|
// REQ-LOCK-PREREQ): each granted id resolves to the right kind of definition
|
||||||
const std::unordered_set<std::string>& schematicIds,
|
// (recipe grants must name assembler recipes), each grantable id is granted by
|
||||||
const std::string& file,
|
// at most one group, group ids are unique, every group grants at least one
|
||||||
const std::string& path)
|
// item, and every `requires` id names a defined group.
|
||||||
|
void validateUnlocks(const GameConfig& cfg)
|
||||||
{
|
{
|
||||||
for (const std::string& requiredId : requiredIds)
|
const std::string file = "unlocks.toml";
|
||||||
{
|
|
||||||
if (schematicIds.count(requiredId) == 0)
|
|
||||||
{
|
|
||||||
throw makeError(file, path,
|
|
||||||
"references unknown schematic '" + requiredId + "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ)
|
std::unordered_set<std::string> shipIds;
|
||||||
// resolves to an explicitly-unlockable schematic: a ship, a module, or an
|
for (const ShipDef& def : cfg.ships.ships) { shipIds.insert(def.id); }
|
||||||
// assembler recipe that carries `unlock_at_station_level` (a recipe schematic,
|
std::unordered_set<std::string> moduleIds;
|
||||||
// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load.
|
for (const ModuleDef& def : cfg.modules.modules) { moduleIds.insert(def.id); }
|
||||||
void validateUnlockRequires(const GameConfig& cfg)
|
std::unordered_set<std::string> buildingIds;
|
||||||
{
|
for (const BuildingDef& def : cfg.buildings.buildings) { buildingIds.insert(def.id); }
|
||||||
std::unordered_set<std::string> schematicIds;
|
std::unordered_set<std::string> assemblerRecipeIds;
|
||||||
for (const ShipDef& def : cfg.ships.ships)
|
|
||||||
{
|
|
||||||
schematicIds.insert(def.id);
|
|
||||||
}
|
|
||||||
for (const ModuleDef& def : cfg.modules.modules)
|
|
||||||
{
|
|
||||||
schematicIds.insert(def.id);
|
|
||||||
}
|
|
||||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||||
{
|
{
|
||||||
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
|
if (def.building == BuildingType::Assembler) { assemblerRecipeIds.insert(def.id); }
|
||||||
{
|
|
||||||
schematicIds.insert(def.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ShipDef& def : cfg.ships.ships)
|
std::unordered_set<std::string> groupIds;
|
||||||
|
std::unordered_set<std::string> grantedShipIds;
|
||||||
|
std::unordered_set<std::string> grantedModuleIds;
|
||||||
|
std::unordered_set<std::string> grantedBuildingIds;
|
||||||
|
std::unordered_set<std::string> grantedRecipeIds;
|
||||||
|
|
||||||
|
const auto checkGrants = [&](const std::vector<std::string>& ids,
|
||||||
|
const std::unordered_set<std::string>& valid,
|
||||||
|
std::unordered_set<std::string>& granted,
|
||||||
|
const std::string& kind,
|
||||||
|
const std::string& gPath)
|
||||||
{
|
{
|
||||||
checkUnlockRequires(def.unlockRequires, schematicIds, "ships.toml",
|
for (const std::string& id : ids)
|
||||||
"ship '" + def.id + "'.unlock_requires");
|
{
|
||||||
|
if (valid.count(id) == 0)
|
||||||
|
{
|
||||||
|
throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
|
||||||
}
|
}
|
||||||
for (const ModuleDef& def : cfg.modules.modules)
|
if (!granted.insert(id).second)
|
||||||
{
|
{
|
||||||
checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml",
|
throw makeError(file, gPath,
|
||||||
"module '" + def.id + "'.unlock_requires");
|
"grants " + kind + " '" + id + "' which is already granted by another unlock group");
|
||||||
}
|
}
|
||||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const UnlockGroupDef& group : cfg.unlocks.groups)
|
||||||
{
|
{
|
||||||
checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml",
|
const std::string gPath = "unlock '" + group.id + "'";
|
||||||
"recipe '" + def.id + "'.unlock_requires");
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// requires must reference defined group ids (checked once all group ids known).
|
||||||
|
for (const UnlockGroupDef& group : cfg.unlocks.groups)
|
||||||
|
{
|
||||||
|
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.ships = loadShips(configDir + "/ships.toml");
|
||||||
cfg.stations = loadStations(configDir + "/stations.toml");
|
cfg.stations = loadStations(configDir + "/stations.toml");
|
||||||
cfg.modules = loadModules(configDir + "/modules.toml");
|
cfg.modules = loadModules(configDir + "/modules.toml");
|
||||||
validateUnlockRequires(cfg);
|
cfg.unlocks = loadUnlocks(configDir + "/unlocks.toml");
|
||||||
|
validateUnlocks(cfg);
|
||||||
cfg.threatCosts = computeThreatCostTable(cfg);
|
cfg.threatCosts = computeThreatCostTable(cfg);
|
||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ public:
|
|||||||
static ShipsConfig loadShips(const std::string& path);
|
static ShipsConfig loadShips(const std::string& path);
|
||||||
static StationsConfig loadStations(const std::string& path);
|
static StationsConfig loadStations(const std::string& path);
|
||||||
static ModulesConfig loadModules(const std::string& path);
|
static ModulesConfig loadModules(const std::string& path);
|
||||||
|
static UnlocksConfig loadUnlocks(const std::string& path);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include "ShipsConfig.h"
|
#include "ShipsConfig.h"
|
||||||
#include "StationsConfig.h"
|
#include "StationsConfig.h"
|
||||||
#include "ModulesConfig.h"
|
#include "ModulesConfig.h"
|
||||||
|
#include "UnlocksConfig.h"
|
||||||
#include "ThreatCostCalculator.h"
|
#include "ThreatCostCalculator.h"
|
||||||
|
|
||||||
// Aggregate of all simulation config files. Loaded at startup and reloaded
|
// Aggregate of all simulation config files. Loaded at startup and reloaded
|
||||||
@@ -18,5 +19,6 @@ struct GameConfig
|
|||||||
ShipsConfig ships;
|
ShipsConfig ships;
|
||||||
StationsConfig stations;
|
StationsConfig stations;
|
||||||
ModulesConfig modules;
|
ModulesConfig modules;
|
||||||
|
UnlocksConfig unlocks;
|
||||||
ThreatCostTable threatCosts;
|
ThreatCostTable threatCosts;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,6 @@ struct ModuleRepairCapability
|
|||||||
struct ModuleDef
|
struct ModuleDef
|
||||||
{
|
{
|
||||||
std::string id;
|
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<std::string> unlockRequires;
|
|
||||||
std::vector<std::string> surfaceMask;
|
std::vector<std::string> surfaceMask;
|
||||||
std::vector<RecipeIngredient> materials;
|
std::vector<RecipeIngredient> materials;
|
||||||
double productionTimeSeconds;
|
double productionTimeSeconds;
|
||||||
|
|||||||
@@ -32,14 +32,12 @@ struct RecipeDef
|
|||||||
std::vector<RecipeIngredient> inputs;
|
std::vector<RecipeIngredient> inputs;
|
||||||
std::vector<RecipeOutput> outputs;
|
std::vector<RecipeOutput> outputs;
|
||||||
double durationSeconds;
|
double durationSeconds;
|
||||||
// Assembler only. nullopt = implicit-only locking. -1 = explicitly unlocked
|
// Assembler only. When true, this recipe is available from game start
|
||||||
// at game start. >= 0 = locked; schematic enters drop pool at that station
|
// regardless of the implicit item graph — used for base recipes that no
|
||||||
// level once the output item is implicitly unlocked (REQ-LOCK-EXPLICIT).
|
// schematic's materials reach (e.g. building blocks). See REQ-LOCK-IMPLICIT.
|
||||||
std::optional<int> unlockAtStationLevel;
|
// Otherwise an assembler recipe is either explicitly gated (granted by an
|
||||||
// Assembler recipe schematics only. Prerequisite schematic ids that must be
|
// unlock group, REQ-LOCK-EXPLICIT) or implicitly gated via the item graph.
|
||||||
// explicitly unlocked before this schematic can enter the drop pool
|
bool unlockedAtStart = false;
|
||||||
// (REQ-LOCK-PREREQ). Empty = none.
|
|
||||||
std::vector<std::string> unlockRequires;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RecipesConfig
|
struct RecipesConfig
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ struct ShipSensor
|
|||||||
struct ShipDef
|
struct ShipDef
|
||||||
{
|
{
|
||||||
std::string id;
|
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<std::string> unlockRequires;
|
|
||||||
std::vector<std::string> layout;
|
std::vector<std::string> layout;
|
||||||
|
|
||||||
ShipSchematic schematic;
|
ShipSchematic schematic;
|
||||||
|
|||||||
26
src/lib/config/UnlocksConfig.h
Normal file
26
src/lib/config/UnlocksConfig.h
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
// 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<std::string> requiredGroupIds; // Prerequisite unlock-group ids (REQ-LOCK-PREREQ).
|
||||||
|
|
||||||
|
// Granted ids by kind (each defaults to empty; a group grants >= 1 item).
|
||||||
|
std::vector<std::string> ships;
|
||||||
|
std::vector<std::string> modules;
|
||||||
|
std::vector<std::string> buildings;
|
||||||
|
std::vector<std::string> recipes; // Assembler recipe ids only.
|
||||||
|
};
|
||||||
|
|
||||||
|
struct UnlocksConfig
|
||||||
|
{
|
||||||
|
std::vector<UnlockGroupDef> groups;
|
||||||
|
};
|
||||||
@@ -3,23 +3,43 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
|
// The kind of a single granted schematic within an unlock option.
|
||||||
enum class SchematicType
|
enum class SchematicType
|
||||||
{
|
{
|
||||||
Ship,
|
Ship,
|
||||||
Module,
|
Module,
|
||||||
Recipe,
|
Building,
|
||||||
Artifact
|
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
|
// One option presented to the player in the schematic choice dialog
|
||||||
// (REQ-DEF-SCHEMATIC-DROP). Built by the simulation when enemy stations are
|
// (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
|
struct SchematicChoiceOption
|
||||||
{
|
{
|
||||||
std::string schematicId;
|
bool isArtifact = false;
|
||||||
SchematicType type;
|
|
||||||
|
// 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;
|
std::string displayName;
|
||||||
|
|
||||||
|
// The ships, modules, buildings, and assembler recipes this group grants
|
||||||
|
// (empty for the artifact option).
|
||||||
|
std::vector<GrantedSchematic> grantedItems;
|
||||||
|
|
||||||
// Ids of miner/assembler recipes that would newly become implicitly
|
// Ids of miner/assembler recipes that would newly become implicitly
|
||||||
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
|
// unlocked (REQ-LOCK-IMPLICIT) if this option is selected. Sorted
|
||||||
// alphabetically by display name; empty if none.
|
// alphabetically by display name; empty if none.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/GameOverEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WinEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ArtifactCountChangedEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/UnlockedBuildingsChangedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BuilderModeExitedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BlueprintModeExitedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/EscapeMenuRequestedEvent.h
|
||||||
|
|||||||
13
src/lib/eventsystem/event/UnlockedBuildingsChangedEvent.h
Normal file
13
src/lib/eventsystem/event/UnlockedBuildingsChangedEvent.h
Normal file
@@ -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
|
||||||
@@ -75,34 +75,7 @@ Simulation::Simulation(GameConfig config, unsigned int seed)
|
|||||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||||
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
||||||
|
|
||||||
// Initialize ship schematic unlock state.
|
initializeUnlockState();
|
||||||
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();
|
|
||||||
placeInitialStructures();
|
placeInitialStructures();
|
||||||
registerForEvents();
|
registerForEvents();
|
||||||
}
|
}
|
||||||
@@ -174,11 +147,33 @@ void Simulation::reset(unsigned int seed)
|
|||||||
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
m_waveSystem = std::make_unique<WaveSystem>(m_config, m_rng);
|
||||||
m_combatSystem = std::make_unique<CombatSystem>(m_config);
|
m_combatSystem = std::make_unique<CombatSystem>(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();
|
m_schematicLevels.clear();
|
||||||
for (const ShipDef& def : m_config.ships.ships)
|
for (const ShipDef& def : m_config.ships.ships)
|
||||||
{
|
{
|
||||||
SchematicState state;
|
SchematicState state;
|
||||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
state.unlocked = (m_grantedShipIds.count(def.id) == 0);
|
||||||
m_schematicLevels[def.id] = state;
|
m_schematicLevels[def.id] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,23 +181,23 @@ void Simulation::reset(unsigned int seed)
|
|||||||
for (const ModuleDef& def : m_config.modules.modules)
|
for (const ModuleDef& def : m_config.modules.modules)
|
||||||
{
|
{
|
||||||
SchematicState state;
|
SchematicState state;
|
||||||
state.unlocked = (def.unlockAtStationLevel == -1);
|
state.unlocked = (m_grantedModuleIds.count(def.id) == 0);
|
||||||
m_moduleSchematicLevels[def.id] = state;
|
m_moduleSchematicLevels[def.id] = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
m_unlockedRecipeSchematicIds.clear();
|
m_buildingLevels.clear();
|
||||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
for (const BuildingDef& def : m_config.buildings.buildings)
|
||||||
{
|
{
|
||||||
if (def.building == BuildingType::Assembler
|
SchematicState state;
|
||||||
&& def.unlockAtStationLevel.has_value()
|
state.unlocked = (m_grantedBuildingIds.count(def.id) == 0);
|
||||||
&& def.unlockAtStationLevel.value() == -1)
|
m_buildingLevels[def.id] = state;
|
||||||
{
|
|
||||||
m_unlockedRecipeSchematicIds.insert(def.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gated assembler recipes start locked; unlocked_at_start recipes are handled
|
||||||
|
// in the REQ-LOCK-IMPLICIT traversal, not tracked here.
|
||||||
|
m_unlockedRecipeSchematicIds.clear();
|
||||||
|
|
||||||
recomputeUnlocked();
|
recomputeUnlocked();
|
||||||
placeInitialStructures();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -628,43 +623,16 @@ void Simulation::tickDeathsAndLoot()
|
|||||||
|
|
||||||
void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
||||||
{
|
{
|
||||||
enum class DropType { Ship, Module, Recipe };
|
// Build the eligible pool of unlock groups (REQ-DEF-SCHEMATIC-DROP,
|
||||||
struct PoolEntry { std::string id; DropType type; };
|
// REQ-LOCK-EXPLICIT, REQ-LOCK-PREREQ): not yet awarded, station level in
|
||||||
std::vector<PoolEntry> pool;
|
// range, and every prerequisite group already awarded.
|
||||||
|
std::vector<const UnlockGroupDef*> pool;
|
||||||
// Owned schematics leave the pool (REQ-DEF-SCHEMATIC-DROP): only offer
|
for (const UnlockGroupDef& group : m_config.unlocks.groups)
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
if (def.unlockAtStationLevel < 0 || def.unlockAtStationLevel > destroyedStationLevel) { continue; }
|
if (m_awardedUnlockGroupIds.count(group.id) > 0) { continue; }
|
||||||
if (m_schematicLevels.at(def.id).unlocked) { continue; }
|
if (group.stationLevel < 0 || group.stationLevel > destroyedStationLevel) { continue; }
|
||||||
if (!prerequisitesSatisfied(def.unlockRequires)) { continue; }
|
if (!prerequisitesSatisfied(group.requiredGroupIds)) { continue; }
|
||||||
pool.push_back({def.id, DropType::Ship});
|
pool.push_back(&group);
|
||||||
}
|
|
||||||
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 (pool.empty()) { return; }
|
if (pool.empty()) { return; }
|
||||||
@@ -679,9 +647,6 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
|||||||
const int numChoices = std::min(static_cast<int>(pool.size()), artifactRolled ? 2 : 3);
|
const int numChoices = std::min(static_cast<int>(pool.size()), artifactRolled ? 2 : 3);
|
||||||
m_pendingSchematicChoices.clear();
|
m_pendingSchematicChoices.clear();
|
||||||
|
|
||||||
const std::set<std::string> currentShipIds = getUnlockedShipSchematicIds();
|
|
||||||
const std::set<std::string> currentModuleIds = getUnlockedModuleSchematicIds();
|
|
||||||
|
|
||||||
for (int i = 0; i < numChoices; ++i)
|
for (int i = 0; i < numChoices; ++i)
|
||||||
{
|
{
|
||||||
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
|
std::uniform_int_distribution<int> dist(0, static_cast<int>(pool.size()) - 1 - i);
|
||||||
@@ -689,68 +654,67 @@ void Simulation::generateSchematicChoices(int destroyedStationLevel)
|
|||||||
const std::size_t rollIdx = static_cast<std::size_t>(roll);
|
const std::size_t rollIdx = static_cast<std::size_t>(roll);
|
||||||
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
|
const std::size_t endIdx = pool.size() - 1 - static_cast<std::size_t>(i);
|
||||||
std::swap(pool[rollIdx], pool[endIdx]);
|
std::swap(pool[rollIdx], pool[endIdx]);
|
||||||
const PoolEntry& entry = pool[endIdx];
|
|
||||||
|
|
||||||
SchematicChoiceOption option;
|
m_pendingSchematicChoices.push_back(makeUnlockOption(*pool[endIdx]));
|
||||||
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<std::string> hypotheticalShipIds = currentShipIds;
|
|
||||||
std::set<std::string> hypotheticalModuleIds = currentModuleIds;
|
|
||||||
std::set<std::string> 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artifactRolled)
|
if (artifactRolled)
|
||||||
{
|
{
|
||||||
SchematicChoiceOption artifactOption;
|
SchematicChoiceOption artifactOption;
|
||||||
artifactOption.schematicId = "";
|
artifactOption.isArtifact = true;
|
||||||
artifactOption.type = SchematicType::Artifact;
|
|
||||||
artifactOption.displayName = "Artifact";
|
artifactOption.displayName = "Artifact";
|
||||||
m_pendingSchematicChoices.push_back(std::move(artifactOption));
|
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<std::string> hypotheticalShipIds = getUnlockedShipSchematicIds();
|
||||||
|
std::set<std::string> hypotheticalModuleIds = getUnlockedModuleSchematicIds();
|
||||||
|
std::set<std::string> 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)
|
void Simulation::applySchematicChoice(int choiceIndex)
|
||||||
{
|
{
|
||||||
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
assert(choiceIndex >= 0 && choiceIndex < static_cast<int>(m_pendingSchematicChoices.size()));
|
||||||
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
|
const SchematicChoiceOption& chosen = m_pendingSchematicChoices[static_cast<std::size_t>(choiceIndex)];
|
||||||
|
|
||||||
if (chosen.type == SchematicType::Artifact)
|
if (chosen.isArtifact)
|
||||||
{
|
{
|
||||||
m_artifactCount += 1;
|
m_artifactCount += 1;
|
||||||
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
|
if (m_artifactCount >= m_config.world.artifacts.artifactWinCount)
|
||||||
@@ -761,16 +725,18 @@ void Simulation::applySchematicChoice(int choiceIndex)
|
|||||||
return;
|
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);
|
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;
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
SchematicState& state = (chosen.type == SchematicType::Module)
|
|
||||||
? m_moduleSchematicLevels.at(chosen.schematicId)
|
|
||||||
: m_schematicLevels.at(chosen.schematicId);
|
|
||||||
state.unlocked = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
recomputeUnlocked();
|
recomputeUnlocked();
|
||||||
@@ -809,28 +775,13 @@ std::set<std::string> Simulation::getUnlockedModuleSchematicIds() const
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Simulation::isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const
|
bool Simulation::prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const
|
||||||
{
|
{
|
||||||
// A prerequisite is satisfied only by an explicitly-unlocked schematic
|
// A prerequisite is satisfied only once the named unlock group has been
|
||||||
// (REQ-LOCK-PREREQ): a ship or module schematic marked unlocked, or a recipe
|
// awarded (REQ-LOCK-PREREQ).
|
||||||
// schematic in the explicit-unlock set. Cross-type ids are looked up with
|
for (const std::string& groupId : requiredGroupIds)
|
||||||
// find/count so an id from one namespace never throws against another.
|
|
||||||
const std::map<std::string, SchematicState>::const_iterator shipIt =
|
|
||||||
m_schematicLevels.find(schematicId);
|
|
||||||
if (shipIt != m_schematicLevels.end() && shipIt->second.unlocked) { return true; }
|
|
||||||
|
|
||||||
const std::map<std::string, SchematicState>::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<std::string>& unlockRequires) const
|
|
||||||
{
|
|
||||||
for (const std::string& requiredId : unlockRequires)
|
|
||||||
{
|
{
|
||||||
if (!isSchematicIdExplicitlyUnlocked(requiredId)) { return false; }
|
if (m_awardedUnlockGroupIds.count(groupId) == 0) { return false; }
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -860,9 +811,11 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
|
|||||||
}
|
}
|
||||||
for (const RecipeDef& def : m_config.recipes.recipes)
|
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
|
if (def.building == BuildingType::Assembler
|
||||||
&& def.unlockAtStationLevel.has_value()
|
&& (def.unlockedAtStart || unlockedRecipeSchematicIds.count(def.id) > 0))
|
||||||
&& unlockedRecipeSchematicIds.count(def.id) > 0)
|
|
||||||
{
|
{
|
||||||
for (const RecipeOutput& out : def.outputs)
|
for (const RecipeOutput& out : def.outputs)
|
||||||
{
|
{
|
||||||
@@ -883,8 +836,10 @@ Simulation::UnlockedSets Simulation::computeUnlockedSets(
|
|||||||
{
|
{
|
||||||
continue;
|
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
|
if (recipe.building == BuildingType::Assembler
|
||||||
&& recipe.unlockAtStationLevel.has_value()
|
&& m_grantedRecipeIds.count(recipe.id) > 0
|
||||||
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
|
&& unlockedRecipeSchematicIds.count(recipe.id) == 0)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -1000,6 +955,8 @@ unsigned long long Simulation::computeStateChecksum() const
|
|||||||
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
||||||
appendSchematicMap(hasher, m_schematicLevels);
|
appendSchematicMap(hasher, m_schematicLevels);
|
||||||
appendSchematicMap(hasher, m_moduleSchematicLevels);
|
appendSchematicMap(hasher, m_moduleSchematicLevels);
|
||||||
|
appendSchematicMap(hasher, m_buildingLevels);
|
||||||
|
appendStringSet(hasher, m_awardedUnlockGroupIds);
|
||||||
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
|
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
|
||||||
appendStringSet(hasher, m_unlockedRecipeIds);
|
appendStringSet(hasher, m_unlockedRecipeIds);
|
||||||
appendStringSet(hasher, m_unlockedItemIds);
|
appendStringSet(hasher, m_unlockedItemIds);
|
||||||
@@ -1193,8 +1150,28 @@ bool Simulation::isModuleSchematicUnlocked(const std::string& moduleId) const
|
|||||||
return it->second.unlocked;
|
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<std::string, SchematicState>::const_iterator it =
|
||||||
|
m_buildingLevels.find(def->id);
|
||||||
|
return it == m_buildingLevels.end() ? true : it->second.unlocked;
|
||||||
|
}
|
||||||
|
|
||||||
std::optional<BuildingId> Simulation::tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
std::optional<BuildingId> 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))
|
if (!m_buildingSystem->isPlacementValid(type, anchor, rotation))
|
||||||
{
|
{
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ public:
|
|||||||
bool isRecipeUnlocked(const std::string& recipeId) const;
|
bool isRecipeUnlocked(const std::string& recipeId) const;
|
||||||
bool isItemUnlocked(const std::string& itemId) 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) -----------------------------
|
// -- Determinism (see docs/replay_design.md) -----------------------------
|
||||||
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
|
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
|
||||||
// file periodically + after each command for desync detection.
|
// file periodically + after each command for desync detection.
|
||||||
@@ -196,6 +200,24 @@ private:
|
|||||||
};
|
};
|
||||||
std::map<std::string, SchematicState> m_schematicLevels;
|
std::map<std::string, SchematicState> m_schematicLevels;
|
||||||
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
||||||
|
std::map<std::string, SchematicState> m_buildingLevels;
|
||||||
|
|
||||||
|
// Unlock groups awarded so far (REQ-LOCK-EXPLICIT). Group ids.
|
||||||
|
std::set<std::string> 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<std::string> m_grantedShipIds;
|
||||||
|
std::set<std::string> m_grantedModuleIds;
|
||||||
|
std::set<std::string> m_grantedBuildingIds;
|
||||||
|
std::set<std::string> 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.
|
// Determinism helpers — fold sub-state into the hasher in deterministic order.
|
||||||
static void appendSchematicMap(Hasher& hasher,
|
static void appendSchematicMap(Hasher& hasher,
|
||||||
@@ -228,11 +250,8 @@ private:
|
|||||||
std::set<std::string> getUnlockedShipSchematicIds() const;
|
std::set<std::string> getUnlockedShipSchematicIds() const;
|
||||||
std::set<std::string> getUnlockedModuleSchematicIds() const;
|
std::set<std::string> getUnlockedModuleSchematicIds() const;
|
||||||
|
|
||||||
// True if schematicId names a currently explicitly-unlocked ship, module, or
|
// True if every prerequisite unlock group has been awarded (REQ-LOCK-PREREQ).
|
||||||
// assembler recipe schematic (REQ-LOCK-PREREQ satisfaction basis).
|
bool prerequisitesSatisfied(const std::vector<std::string>& requiredGroupIds) const;
|
||||||
bool isSchematicIdExplicitlyUnlocked(const std::string& schematicId) const;
|
|
||||||
// True if every prerequisite in unlockRequires is explicitly unlocked (REQ-LOCK-PREREQ).
|
|
||||||
bool prerequisitesSatisfied(const std::vector<std::string>& unlockRequires) const;
|
|
||||||
|
|
||||||
// Ids (sorted alphabetically by display name) of the recipes in
|
// Ids (sorted alphabetically by display name) of the recipes in
|
||||||
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
|
// hypothetical.recipeIds that are not yet in m_unlockedRecipeIds.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ static int findArtifactChoiceIndex(const Simulation& sim)
|
|||||||
const auto& choices = sim.getPendingSchematicChoices();
|
const auto& choices = sim.getPendingSchematicChoices();
|
||||||
for (int i = 0; i < static_cast<int>(choices.size()); ++i)
|
for (int i = 0; i < static_cast<int>(choices.size()); ++i)
|
||||||
{
|
{
|
||||||
if (choices[static_cast<std::size_t>(i)].type == SchematicType::Artifact)
|
if (choices[static_cast<std::size_t>(i)].isArtifact)
|
||||||
{
|
{
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula ret
|
|||||||
const auto& choices = sim.getPendingSchematicChoices();
|
const auto& choices = sim.getPendingSchematicChoices();
|
||||||
|
|
||||||
const long artifactCount = std::count_if(choices.begin(), choices.end(),
|
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);
|
CHECK(artifactCount == 1);
|
||||||
// Exactly 2 schematic picks + 1 artifact (or fewer if pool was small)
|
// Exactly 2 schematic picks + 1 artifact (or fewer if pool was small)
|
||||||
CHECK(choices.size() <= 3);
|
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 auto& choices = sim.getPendingSchematicChoices();
|
||||||
|
|
||||||
const long schematicCount = std::count_if(choices.begin(), choices.end(),
|
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);
|
CHECK(schematicCount <= 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm
|
|||||||
// Pick the first non-artifact choice.
|
// Pick the first non-artifact choice.
|
||||||
const auto& choices = sim.getPendingSchematicChoices();
|
const auto& choices = sim.getPendingSchematicChoices();
|
||||||
const auto it = std::find_if(choices.begin(), choices.end(),
|
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());
|
REQUIRE(it != choices.end());
|
||||||
|
|
||||||
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));
|
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "catch.hpp"
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingConfig.h"
|
#include "BuildingConfig.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
@@ -46,7 +48,17 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
|
|||||||
{
|
{
|
||||||
for (const ShipDef& def : cfg.ships.ships)
|
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;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ add_files(
|
|||||||
ThreatCostCalculatorTest.cpp
|
ThreatCostCalculatorTest.cpp
|
||||||
RecipeSchematicTest.cpp
|
RecipeSchematicTest.cpp
|
||||||
UnlockPrereqTest.cpp
|
UnlockPrereqTest.cpp
|
||||||
|
UnlockGroupTest.cpp
|
||||||
ArtifactWinConditionTest.cpp
|
ArtifactWinConditionTest.cpp
|
||||||
DeterminismTest.cpp
|
DeterminismTest.cpp
|
||||||
CommandTest.cpp
|
CommandTest.cpp
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
#include "ConfigLoader.h"
|
#include "ConfigLoader.h"
|
||||||
|
#include "UnlocksConfig.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -407,99 +408,42 @@ void copyConfigInto(const std::filesystem::path& dir)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
TEST_CASE("unlock_requires parses into a ship def", "[config]")
|
TEST_CASE("loadUnlocks parses id, station_level, requires, and grants", "[config]")
|
||||||
{
|
{
|
||||||
TempConfigDir dir;
|
TempConfigDir dir;
|
||||||
writeFile(dir.path() / "ships.toml", R"(
|
writeFile(dir.path() / "unlocks.toml", R"(
|
||||||
[[ship]]
|
[[unlock]]
|
||||||
id = "alpha"
|
id = "base"
|
||||||
unlock_at_station_level = 1
|
station_level = 0
|
||||||
unlock_requires = ["beta", "gamma"]
|
buildings = ["salvage_bay"]
|
||||||
layout = ["O"]
|
|
||||||
[ship.schematic]
|
[[unlock]]
|
||||||
materials = []
|
id = "gated"
|
||||||
production_time_seconds = 5
|
station_level = 3
|
||||||
[ship.health]
|
requires = ["base"]
|
||||||
hp = 10
|
ships = ["cruiser"]
|
||||||
[ship.movement]
|
modules = ["railgun_m"]
|
||||||
speed_mps = 1
|
recipes = ["shortcut_steel_plate"]
|
||||||
main_acceleration_mpss = 1
|
|
||||||
maneuvering_acceleration_mpss = 1
|
|
||||||
angular_acceleration_radpss = 1
|
|
||||||
max_rotation_speed_radps = 1
|
|
||||||
[ship.sensor]
|
|
||||||
sensor_range_m = 10
|
|
||||||
)");
|
)");
|
||||||
|
|
||||||
const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string());
|
const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string());
|
||||||
REQUIRE(cfg.ships.size() == 1);
|
REQUIRE(cfg.groups.size() == 2);
|
||||||
CHECK(cfg.ships[0].unlockRequires == std::vector<std::string>{"beta", "gamma"});
|
CHECK(cfg.groups[0].id == "base");
|
||||||
|
CHECK(cfg.groups[0].stationLevel == 0);
|
||||||
|
CHECK(cfg.groups[0].buildings == std::vector<std::string>{"salvage_bay"});
|
||||||
|
CHECK(cfg.groups[1].requiredGroupIds == std::vector<std::string>{"base"});
|
||||||
|
CHECK(cfg.groups[1].ships == std::vector<std::string>{"cruiser"});
|
||||||
|
CHECK(cfg.groups[1].modules == std::vector<std::string>{"railgun_m"});
|
||||||
|
CHECK(cfg.groups[1].recipes == std::vector<std::string>{"shortcut_steel_plate"});
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("unlock_requires parses into a module def", "[config]")
|
TEST_CASE("validateUnlocks rejects a grant that names no definition", "[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<std::string>{"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<std::string>{"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]")
|
|
||||||
{
|
{
|
||||||
TempConfigDir dir;
|
TempConfigDir dir;
|
||||||
copyConfigInto(dir.path());
|
copyConfigInto(dir.path());
|
||||||
|
|
||||||
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
||||||
out << "\n[[recipe]]\n"
|
out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\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";
|
|
||||||
out.close();
|
out.close();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -509,26 +453,103 @@ TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load"
|
|||||||
}
|
}
|
||||||
catch (const std::runtime_error& e)
|
catch (const std::runtime_error& e)
|
||||||
{
|
{
|
||||||
const std::string msg = e.what();
|
REQUIRE(std::string(e.what()).find("___nope___") != std::string::npos);
|
||||||
REQUIRE(msg.find("___does_not_exist___") != 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;
|
TempConfigDir dir;
|
||||||
copyConfigInto(dir.path());
|
copyConfigInto(dir.path());
|
||||||
|
|
||||||
// "interceptor" is a ship schematic in the test config — a valid prerequisite.
|
// repair_ship is already granted by the test config's unlocks.toml.
|
||||||
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
|
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
|
||||||
out << "\n[[recipe]]\n"
|
out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n";
|
||||||
"id = \"gated_ok\"\n"
|
out.close();
|
||||||
"building = \"assembler\"\n"
|
|
||||||
"unlock_at_station_level = 1\n"
|
try
|
||||||
"unlock_requires = [\"interceptor\"]\n"
|
{
|
||||||
"inputs = []\n"
|
ConfigLoader::loadFromDirectory(dir.path().string());
|
||||||
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
|
FAIL("Expected exception");
|
||||||
"duration_seconds = 1.0\n";
|
}
|
||||||
|
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();
|
out.close();
|
||||||
|
|
||||||
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
|
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));
|
||||||
|
|||||||
@@ -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 GameConfig cfg = loadConfig();
|
||||||
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
|
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
|
||||||
REQUIRE(it != cfg.recipes.recipes.end());
|
REQUIRE(it != cfg.recipes.recipes.end());
|
||||||
REQUIRE(it->unlockAtStationLevel.has_value());
|
CHECK(it->unlockedAtStart);
|
||||||
CHECK(it->unlockAtStationLevel.value() == -1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 GameConfig cfg = loadConfig();
|
||||||
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "quick_circuit"; });
|
[](const UnlockGroupDef& g)
|
||||||
REQUIRE(it != cfg.recipes.recipes.end());
|
{
|
||||||
REQUIRE(it->unlockAtStationLevel.has_value());
|
return std::find(g.recipes.begin(), g.recipes.end(), "quick_circuit") != g.recipes.end();
|
||||||
CHECK(it->unlockAtStationLevel.value() == 0);
|
});
|
||||||
|
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 GameConfig cfg = loadConfig();
|
||||||
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
|
||||||
[](const RecipeDef& r) { return r.id == "circuit_board"; });
|
[](const RecipeDef& r) { return r.id == "circuit_board"; });
|
||||||
REQUIRE(it != cfg.recipes.recipes.end());
|
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"));
|
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]")
|
"[recipe_schematic]")
|
||||||
{
|
{
|
||||||
// exotic_alloy produces exotic_alloy (not implicitly unlocked), so the
|
// exotic_alloy is in no unlock group and its output/inputs are unreachable
|
||||||
// output-unlocked guard must keep it out of the drop pool at every level.
|
// via the item graph, so it can never be dropped or implicitly unlocked.
|
||||||
Simulation sim(loadConfig());
|
Simulation sim(loadConfig());
|
||||||
for (int i = 0; i < 50; ++i)
|
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())
|
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
|
||||||
{
|
{
|
||||||
if (opt.type == SchematicType::Recipe)
|
for (const GrantedSchematic& grant : opt.grantedItems)
|
||||||
{
|
{
|
||||||
foundRecipeChoice = true;
|
if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; }
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
if (foundRecipeChoice) { break; }
|
||||||
}
|
}
|
||||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||||
}
|
}
|
||||||
@@ -377,11 +377,14 @@ TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
|
|||||||
const std::vector<SchematicChoiceOption>& choices =
|
const std::vector<SchematicChoiceOption>& choices =
|
||||||
sim.getPendingSchematicChoices();
|
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;
|
int repairShipIndex = -1;
|
||||||
for (int j = 0; j < static_cast<int>(choices.size()); ++j)
|
for (int j = 0; j < static_cast<int>(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.
|
// Once owned, it must never appear in the pool again.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "catch.hpp"
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "BuildingType.h"
|
#include "BuildingType.h"
|
||||||
@@ -20,11 +22,24 @@ static GameConfig loadConfig()
|
|||||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
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)
|
static const ShipDef* findAvailableSchematic(const GameConfig& cfg)
|
||||||
{
|
{
|
||||||
for (const ShipDef& def : cfg.ships.ships)
|
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;
|
return &def;
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/test/UnlockGroupTest.cpp
Normal file
117
src/test/UnlockGroupTest.cpp
Normal file
@@ -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 <algorithm>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<StationBodyComponent, FactionComponent, HealthComponent>(
|
||||||
|
[](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<SchematicChoiceOption>& 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"));
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// Drop-pool behaviour for unlock_requires schematic prerequisites
|
// Drop-pool behaviour for unlock-group prerequisites (REQ-LOCK-PREREQ). An
|
||||||
// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its
|
// unlock group enters the drop pool only when every id in its `requires` names a
|
||||||
// unlock_requires is already explicitly unlocked, on top of the station-level
|
// group that has already been awarded, on top of the station-level check.
|
||||||
// (and, for recipes, output-implicitly-unlocked) checks.
|
|
||||||
//
|
//
|
||||||
// Configs are built in memory (load the test config, then mutate) so each case
|
// Configs are built in memory (load the test config, then mutate the unlock
|
||||||
// is isolated and does not perturb the shared test config used by other suites.
|
// groups) so each case is isolated and does not perturb the shared test config
|
||||||
|
// used by other suites.
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -17,13 +17,11 @@
|
|||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
#include "ModulesConfig.h"
|
|
||||||
#include "RecipesConfig.h"
|
|
||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
#include "ShipsConfig.h"
|
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
#include "SimulationTestAccess.h"
|
#include "SimulationTestAccess.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
|
#include "UnlocksConfig.h"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
@@ -33,34 +31,14 @@ GameConfig loadConfig()
|
|||||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
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);
|
FAIL("unlock group not found: " + id);
|
||||||
return cfg.ships.ships.front();
|
return cfg.unlocks.groups.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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zeros the HP of both enemy defence stations and advances one tick so that
|
// 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)
|
bool optionOffered(const Simulation& sim, const std::string& id)
|
||||||
{
|
{
|
||||||
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
|
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
|
||||||
return std::any_of(options.begin(), options.end(),
|
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
|
} // 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]")
|
"[unlock_prereq]")
|
||||||
{
|
{
|
||||||
// quick_circuit (assembler recipe, level 0, output circuit_board is implicitly
|
// quick_circuit (recipe group, level 0) would normally be eligible at the
|
||||||
// unlocked) would normally be eligible at the first station destruction. Gate
|
// first station destruction. Gate it behind the repair_ship group (level 0,
|
||||||
// it behind repair_ship (ship, level 0, locked at game start).
|
// locked at game start).
|
||||||
GameConfig cfg = loadConfig();
|
GameConfig cfg = loadConfig();
|
||||||
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
|
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
|
||||||
|
|
||||||
Simulation sim(std::move(cfg), 123);
|
Simulation sim(std::move(cfg), 123);
|
||||||
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
|
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
|
killEnemyStations(sim); // destroyed set level 0
|
||||||
REQUIRE(sim.hasSchematicChoicesPending());
|
REQUIRE(sim.hasSchematicChoicesPending());
|
||||||
|
|
||||||
// Prerequisite unmet -> the gated recipe must not be offered, while the
|
// Prerequisite unmet -> the gated group must not be offered, while the
|
||||||
// prerequisite schematic itself (the only other eligible pick) is.
|
// prerequisite group itself (the only other eligible pick) is.
|
||||||
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
|
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
|
||||||
CHECK(optionOffered(sim, "repair_ship"));
|
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]")
|
"[unlock_prereq]")
|
||||||
{
|
{
|
||||||
GameConfig cfg = loadConfig();
|
GameConfig cfg = loadConfig();
|
||||||
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
|
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
|
||||||
|
|
||||||
Simulation sim(std::move(cfg), 123);
|
Simulation sim(std::move(cfg), 123);
|
||||||
|
|
||||||
// Applying choice 0 each drop unlocks repair_ship first (the only eligible
|
// 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;
|
bool unlocked = false;
|
||||||
for (int i = 0; i < 150 && !unlocked; ++i)
|
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);
|
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]")
|
"[unlock_prereq]")
|
||||||
{
|
{
|
||||||
// Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two
|
// Make laser_cannon and armor_plate (both start-unlocked in the test config)
|
||||||
// test-config modules made lockable at the same station level.
|
// lockable via new unlock groups, with armor_plate gated behind laser_cannon.
|
||||||
GameConfig cfg = loadConfig();
|
GameConfig cfg = loadConfig();
|
||||||
findModule(cfg, "laser_cannon").unlockAtStationLevel = 0;
|
cfg.unlocks.groups.push_back(
|
||||||
ModuleDef& armor = findModule(cfg, "armor_plate");
|
UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}});
|
||||||
armor.unlockAtStationLevel = 0;
|
cfg.unlocks.groups.push_back(
|
||||||
armor.unlockRequires = {"laser_cannon"};
|
UnlockGroupDef{"armor_plate", 0, {"laser_cannon"}, {}, {"armor_plate"}, {}, {}});
|
||||||
|
|
||||||
Simulation sim(std::move(cfg), 7);
|
Simulation sim(std::move(cfg), 7);
|
||||||
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));
|
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));
|
||||||
|
|||||||
@@ -304,28 +304,37 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
|
|||||||
REQUIRE_FALSE(choices.empty());
|
REQUIRE_FALSE(choices.empty());
|
||||||
|
|
||||||
for (const SchematicChoiceOption& opt : choices)
|
for (const SchematicChoiceOption& opt : choices)
|
||||||
|
{
|
||||||
|
if (opt.isArtifact) { continue; }
|
||||||
|
bool validGroup = false;
|
||||||
|
for (const UnlockGroupDef& group : sim.getConfig().unlocks.groups)
|
||||||
|
{
|
||||||
|
if (group.id == opt.unlockGroupId) { validGroup = true; break; }
|
||||||
|
}
|
||||||
|
REQUIRE(validGroup);
|
||||||
|
// Every granted item must resolve to a defined ship/module/building/recipe.
|
||||||
|
for (const GrantedSchematic& grant : opt.grantedItems)
|
||||||
{
|
{
|
||||||
bool validId = false;
|
bool validId = false;
|
||||||
for (const ShipDef& def : sim.getConfig().ships.ships)
|
for (const ShipDef& def : sim.getConfig().ships.ships)
|
||||||
{
|
{
|
||||||
if (def.id == opt.schematicId) { validId = true; break; }
|
if (def.id == grant.id) { validId = true; break; }
|
||||||
}
|
}
|
||||||
if (!validId)
|
|
||||||
{
|
|
||||||
for (const ModuleDef& def : sim.getConfig().modules.modules)
|
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 (!validId)
|
|
||||||
{
|
{
|
||||||
|
if (def.id == grant.id) { validId = true; break; }
|
||||||
|
}
|
||||||
for (const RecipeDef& def : sim.getConfig().recipes.recipes)
|
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);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
||||||
@@ -343,7 +352,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
|
|||||||
std::set<std::string> ids;
|
std::set<std::string> ids;
|
||||||
for (const SchematicChoiceOption& opt : choices)
|
for (const SchematicChoiceOption& opt : choices)
|
||||||
{
|
{
|
||||||
ids.insert(opt.schematicId);
|
ids.insert(opt.isArtifact ? std::string("<artifact>") : opt.unlockGroupId);
|
||||||
}
|
}
|
||||||
REQUIRE(ids.size() == choices.size());
|
REQUIRE(ids.size() == choices.size());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ BuildButtonGrid::BuildButtonGrid(Simulation* sim, const GameConfig* config, QWid
|
|||||||
std::make_shared<DemolishModeToggleRequestedEvent>());
|
std::make_shared<DemolishModeToggleRequestedEvent>());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateVisibility();
|
||||||
registerForEvents();
|
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()
|
void BuildButtonGrid::clearActiveButton()
|
||||||
{
|
{
|
||||||
if (m_activeIndex)
|
if (m_activeIndex)
|
||||||
@@ -165,6 +177,12 @@ void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildingBlocksChangedEve
|
|||||||
updateAffordability();
|
updateAffordability();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BuildButtonGrid::handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> /*event*/)
|
||||||
|
{
|
||||||
|
updateVisibility();
|
||||||
|
updateAffordability();
|
||||||
|
}
|
||||||
|
|
||||||
void BuildButtonGrid::handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event)
|
void BuildButtonGrid::handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event)
|
||||||
{
|
{
|
||||||
m_demolishButton->setChecked(event->active);
|
m_demolishButton->setChecked(event->active);
|
||||||
@@ -176,9 +194,10 @@ void BuildButtonGrid::handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent>
|
|||||||
{
|
{
|
||||||
if (m_types[i] == event->type)
|
if (m_types[i] == event->type)
|
||||||
{
|
{
|
||||||
// Equivalent to clicking the build button: a disabled (unaffordable)
|
// Equivalent to clicking the build button: a disabled (unaffordable) or
|
||||||
// button cannot be clicked, so the hotkey is likewise inert.
|
// hidden (locked, REQ-LOCK-BUILDING) button cannot be clicked, so the
|
||||||
if (m_buttons[i]->isEnabled())
|
// hotkey is likewise inert.
|
||||||
|
if (m_buttons[i]->isEnabled() && m_buttons[i]->isVisible())
|
||||||
{
|
{
|
||||||
onBuildButton(static_cast<int>(i));
|
onBuildButton(static_cast<int>(i));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "DemolishModeChangedEvent.h"
|
#include "DemolishModeChangedEvent.h"
|
||||||
#include "EventHandler.h"
|
#include "EventHandler.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
|
#include "UnlockedBuildingsChangedEvent.h"
|
||||||
|
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
@@ -21,7 +22,8 @@ class BuildButtonGrid : public QWidget,
|
|||||||
public CombinedEventHandler<BuilderModeExitedEvent,
|
public CombinedEventHandler<BuilderModeExitedEvent,
|
||||||
DemolishModeChangedEvent,
|
DemolishModeChangedEvent,
|
||||||
BuildHotkeyPressedEvent,
|
BuildHotkeyPressedEvent,
|
||||||
BuildingBlocksChangedEvent>
|
BuildingBlocksChangedEvent,
|
||||||
|
UnlockedBuildingsChangedEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
@@ -37,10 +39,15 @@ private:
|
|||||||
// be afforded, it exits builder mode so the button does not stay selected.
|
// be afforded, it exits builder mode so the button does not stay selected.
|
||||||
void updateAffordability();
|
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<const BuilderModeExitedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuilderModeExitedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const DemolishModeChangedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuildHotkeyPressedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BuildingBlocksChangedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const UnlockedBuildingsChangedEvent> event) override;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onBuildButton(int index);
|
void onBuildButton(int index);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@
|
|||||||
#include "BuilderModeExitedEvent.h"
|
#include "BuilderModeExitedEvent.h"
|
||||||
#include "BlueprintModeExitedEvent.h"
|
#include "BlueprintModeExitedEvent.h"
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
|
#include "UnlockedBuildingsChangedEvent.h"
|
||||||
#include "ExpansionCostChangedEvent.h"
|
#include "ExpansionCostChangedEvent.h"
|
||||||
#include "GameSpeedChangedEvent.h"
|
#include "GameSpeedChangedEvent.h"
|
||||||
#include "SchematicChoicesAvailableEvent.h"
|
#include "SchematicChoicesAvailableEvent.h"
|
||||||
@@ -367,6 +368,20 @@ void GameWorldView::onFrame()
|
|||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<BossWaveUpdatedEvent>(newBoss, newCountdown));
|
std::make_shared<BossWaveUpdatedEvent>(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<UnlockedBuildingsChangedEvent>());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sim-state polls are input sources for the live game; in replay these are
|
// 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)
|
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; }
|
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;
|
int totalCost = 0;
|
||||||
for (const BlueprintBuilding& bb : bp.buildings)
|
for (const BlueprintBuilding& bb : bp.buildings)
|
||||||
{
|
{
|
||||||
|
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
|
||||||
if (m_sim->getBuildings().findRotateInPlaceTarget(
|
if (m_sim->getBuildings().findRotateInPlaceTarget(
|
||||||
bb.type, center + bb.offset, bb.rotation).has_value())
|
bb.type, center + bb.offset, bb.rotation).has_value())
|
||||||
{
|
{
|
||||||
@@ -820,6 +840,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
|||||||
|
|
||||||
for (const BlueprintBuilding& bb : bp.buildings)
|
for (const BlueprintBuilding& bb : bp.buildings)
|
||||||
{
|
{
|
||||||
|
if (!m_sim->isBuildingUnlocked(bb.type)) { continue; }
|
||||||
const QPoint anchor = center + bb.offset;
|
const QPoint anchor = center + bb.offset;
|
||||||
const std::optional<BuildingId> rotateTarget =
|
const std::optional<BuildingId> rotateTarget =
|
||||||
m_sim->getBuildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
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)
|
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 QPoint anchor = m_blueprintGhostTile + bb.offset;
|
||||||
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
|
const bool valid = isValidPlacement(bb.type, anchor, bb.rotation);
|
||||||
drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid,
|
drawBuildingGhost(painter, bb.type, anchor, bb.rotation, valid,
|
||||||
|
|||||||
@@ -351,4 +351,5 @@ private:
|
|||||||
int m_lastBossCounter = -1;
|
int m_lastBossCounter = -1;
|
||||||
Tick m_lastBossCountdown = Tick(-1);
|
Tick m_lastBossCountdown = Tick(-1);
|
||||||
int m_lastArtifactCount = -1;
|
int m_lastArtifactCount = -1;
|
||||||
|
int m_lastUnlockedBuildingCount = -1;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,18 @@ const RecipeDef* findRecipe(const RecipesConfig& recipes, const std::string& id)
|
|||||||
return nullptr;
|
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
|
} // namespace
|
||||||
|
|
||||||
SchematicChoiceDialog::SchematicChoiceDialog(
|
SchematicChoiceDialog::SchematicChoiceDialog(
|
||||||
@@ -63,7 +75,7 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
|||||||
nameLabel->setAlignment(Qt::AlignCenter);
|
nameLabel->setAlignment(Qt::AlignCenter);
|
||||||
cardLayout->addWidget(nameLabel);
|
cardLayout->addWidget(nameLabel);
|
||||||
|
|
||||||
if (option.type == SchematicType::Artifact)
|
if (option.isArtifact)
|
||||||
{
|
{
|
||||||
QLabel* artifactTypeLabel = new QLabel(tr("Artifact"), card);
|
QLabel* artifactTypeLabel = new QLabel(tr("Artifact"), card);
|
||||||
artifactTypeLabel->setAlignment(Qt::AlignCenter);
|
artifactTypeLabel->setAlignment(Qt::AlignCenter);
|
||||||
@@ -71,22 +83,22 @@ SchematicChoiceDialog::SchematicChoiceDialog(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
QString typeText;
|
QLabel* grantsHeaderLabel = new QLabel(tr("Unlocks:"), card);
|
||||||
if (option.type == SchematicType::Ship)
|
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);
|
QLabel* unlocksHeaderLabel = new QLabel(tr("Unlocks recipes:"), card);
|
||||||
QFont unlocksHeaderFont = unlocksHeaderLabel->font();
|
QFont unlocksHeaderFont = unlocksHeaderLabel->font();
|
||||||
|
|||||||
Reference in New Issue
Block a user