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

@@ -142,12 +142,12 @@ duration_seconds = 1.5
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
# doubling-time knob; see the block economy rules in docs/balancing/rules.md.
# Explicitly unlocked at start (-1): building blocks appear in no
# schematic's materials, so implicit unlocking can never reach this recipe.
# unlocked_at_start: building blocks appear in no schematic's materials, so the
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
[[recipe]]
id = "building_block"
unlock_at_station_level = -1
building = "assembler"
unlocked_at_start = true
inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "building_block", amount = 4}]
duration_seconds = 2.0
@@ -220,15 +220,14 @@ outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 10.0
# -----------------------------------------------------------------------------
# Shortcut recipes — drop-only assembler recipe schematics
# (unlock_at_station_level >= 0). Pure rewards: item threat stays defined by
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
# unlocks.toml (REQ-LOCK-EXPLICIT). Pure rewards: item threat stays defined by
# the base (expensive) path via the max rule, so shortcuts give real factory
# efficiency without shifting any balance.
# -----------------------------------------------------------------------------
[[recipe]]
id = "shortcut_steel_plate"
unlock_at_station_level = 1
building = "assembler"
inputs = [{item = "iron_ore", amount = 3}]
outputs = [{item = "steel_plate", amount = 1}]
@@ -236,7 +235,6 @@ duration_seconds = 2.0
[[recipe]]
id = "shortcut_control_chip"
unlock_at_station_level = 2
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
@@ -244,7 +242,6 @@ duration_seconds = 4.0
[[recipe]]
id = "shortcut_hardened_steel"
unlock_at_station_level = 2
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "hardened_steel", amount = 1}]