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:
2026-07-21 22:38:38 +02:00
parent 8dc76284e6
commit 1f339bd8d7
35 changed files with 963 additions and 516 deletions

View File

@@ -7,10 +7,9 @@
# Combat stats are placeholders until the arena balancing pass;
# production_time_seconds values come from the numbers pass.
#
# Unlock progression is mostly disabled (unlock_at_station_level = -1) pending
# the pacing pass. The railguns are the exception: railgun_m is gated to
# station level 1, and railgun_l requires railgun_m to be unlocked first
# (unlock_requires) — a demonstration of the prerequisite chain.
# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a
# module id granted by an unlock group starts locked and is awarded via a
# defence station drop; ids absent from unlocks.toml (railgun_s) start unlocked.
#
# Surface mask footprint ladder — footprints gate which hulls can mount a
# module, purely through geometry (see ships.toml for the matching hull
@@ -33,7 +32,6 @@
[[module]]
id = "railgun_s"
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "railgun_s_module", amount = 1}]
production_time_seconds = 1
@@ -49,7 +47,6 @@ attack_rate_hz = 2.0
[[module]]
id = "railgun_m"
tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
unlock_at_station_level = 2
surface_mask = [
"OO",
"OO"]
@@ -67,8 +64,6 @@ attack_rate_hz = 1.5
[[module]]
id = "railgun_l"
tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
unlock_at_station_level = 6
unlock_requires = ["railgun_m"]
surface_mask = [
"OOO",
"OOO",
@@ -90,7 +85,6 @@ attack_rate_hz = 0.8
[[module]]
id = "salvager"
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
unlock_at_station_level = -1
surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}]
production_time_seconds = 1
@@ -106,7 +100,6 @@ collection_rate_hz = 0.5
[[module]]
id = "repair_tool"
tooltip = "Repairs damaged friendly ships and defence stations within range."
unlock_at_station_level = 0
surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}]
production_time_seconds = 1
@@ -125,7 +118,6 @@ repair_range_m = 80
[[module]]
id = "afterburner"
tooltip = "Greatly boosts top speed and forward acceleration."
unlock_at_station_level = 2
surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}]
production_time_seconds = 1
@@ -140,7 +132,6 @@ added_main_acceleration_mpss = 60
[[module]]
id = "maneuvering_thrusters"
tooltip = "Improves top speed and lateral/braking acceleration."
unlock_at_station_level = 1
surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
production_time_seconds = 1
@@ -158,7 +149,6 @@ added_maneuvering_acceleration_mpss = 10
[[module]]
id = "armor_plates"
tooltip = "Adds a large flat bonus to the ship's hit points."
unlock_at_station_level = 0
surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}]
production_time_seconds = 1
@@ -172,7 +162,6 @@ added_hp = 1200
[[module]]
id = "sensor_booster"
tooltip = "Extends the ship's sensor range."
unlock_at_station_level = 1
surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}]
production_time_seconds = 1
@@ -189,7 +178,6 @@ added_sensor_range_m = 50
[[module]]
id = "weapon_upgrade"
tooltip = "Increases the damage of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [
"OO",
"OX",
@@ -206,7 +194,6 @@ multiplied_damage = 1.2
[[module]]
id = "weapon_primer"
tooltip = "Increases the fire rate of all weapons on the ship."
unlock_at_station_level = 4
surface_mask = [
"OO",
"OX",
@@ -223,7 +210,6 @@ multiplied_attack_rate_hz = 1.2
[[module]]
id = "weapon_stabilizer"
tooltip = "Extends weapon range at the cost of some fire rate."
unlock_at_station_level = 3
surface_mask = [
"OO",
"OX",
@@ -247,7 +233,6 @@ multiplied_attack_rate_hz = 0.8
[[module]]
id = "drone_bay"
tooltip = "Drone launch bay (capability not yet implemented)."
unlock_at_station_level = 5
surface_mask = [
"OO",
"OO"]
@@ -260,7 +245,6 @@ glyph = "Db"
[[module]]
id = "drone_hangar"
tooltip = "Large drone hangar (capability not yet implemented)."
unlock_at_station_level = 9
surface_mask = [
"OOOOOO",
"OOOOOO"]