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

@@ -305,26 +305,35 @@ TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
for (const SchematicChoiceOption& opt : choices)
{
bool validId = false;
for (const ShipDef& def : sim.getConfig().ships.ships)
if (opt.isArtifact) { continue; }
bool validGroup = false;
for (const UnlockGroupDef& group : sim.getConfig().unlocks.groups)
{
if (def.id == opt.schematicId) { validId = true; break; }
if (group.id == opt.unlockGroupId) { validGroup = true; break; }
}
if (!validId)
REQUIRE(validGroup);
// Every granted item must resolve to a defined ship/module/building/recipe.
for (const GrantedSchematic& grant : opt.grantedItems)
{
bool validId = false;
for (const ShipDef& def : sim.getConfig().ships.ships)
{
if (def.id == grant.id) { validId = true; break; }
}
for (const ModuleDef& def : sim.getConfig().modules.modules)
{
if (def.id == opt.schematicId) { validId = true; break; }
if (def.id == grant.id) { validId = true; break; }
}
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.id == grant.id) { validId = true; break; }
}
}
if (!validId)
{
for (const RecipeDef& def : sim.getConfig().recipes.recipes)
{
if (def.id == opt.schematicId) { validId = true; break; }
if (def.id == grant.id) { validId = true; break; }
}
REQUIRE(validId);
}
REQUIRE(validId);
}
}
@@ -343,7 +352,7 @@ TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
std::set<std::string> ids;
for (const SchematicChoiceOption& opt : choices)
{
ids.insert(opt.schematicId);
ids.insert(opt.isArtifact ? std::string("<artifact>") : opt.unlockGroupId);
}
REQUIRE(ids.size() == choices.size());
}