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

@@ -404,18 +404,10 @@ RecipesConfig ConfigLoader::loadRecipes(const std::string& path)
}
def.building = *parsedType;
if (def.building == BuildingType::Assembler)
if (def.building == BuildingType::Assembler && mt.contains("unlocked_at_start"))
{
const auto level = mt["unlock_at_station_level"].value<int64_t>();
if (level)
{
def.unlockAtStationLevel = static_cast<int>(*level);
}
if (mt.contains("unlock_requires"))
{
def.unlockRequires = requireStringArray(mt["unlock_requires"], file,
elemPath + ".unlock_requires");
}
def.unlockedAtStart = requireBool(mt["unlocked_at_start"], file,
elemPath + ".unlocked_at_start");
}
// inputs may be omitted (e.g. miner recipes). An empty array is fine.
@@ -454,11 +446,6 @@ ShipsConfig ConfigLoader::loadShips(const std::string& path)
ShipDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<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");
// Schematic
@@ -608,12 +595,6 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
ModuleDef def;
def.id = requireString(mt["id"], file, elemPath + ".id");
def.unlockAtStationLevel = static_cast<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.productionTimeSeconds = requireDouble(
mt["production_time_seconds"], file, elemPath + ".production_time_seconds");
@@ -731,62 +712,141 @@ ModulesConfig ConfigLoader::loadModules(const std::string& path)
return cfg;
}
UnlocksConfig ConfigLoader::loadUnlocks(const std::string& path)
{
const std::string file = "unlocks.toml";
toml::table tbl = parseFile(path, file);
UnlocksConfig cfg;
if (!tbl.contains("unlock"))
{
return cfg;
}
const toml::array& arr = requireArray(tbl["unlock"], file, "unlock");
for (std::size_t i = 0; i < arr.size(); ++i)
{
const std::string elemPath = "unlock[" + std::to_string(i) + "]";
const toml::table* ut = arr[i].as_table();
if (ut == nullptr)
{
throw makeError(file, elemPath, "not a table");
}
toml::table& mt = const_cast<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
{
// Throws if any id in requiredIds is not a valid explicitly-unlockable schematic.
void checkUnlockRequires(const std::vector<std::string>& requiredIds,
const std::unordered_set<std::string>& schematicIds,
const std::string& file,
const std::string& path)
// Validates unlocks.toml against the rest of the config (REQ-LOCK-EXPLICIT,
// REQ-LOCK-PREREQ): each granted id resolves to the right kind of definition
// (recipe grants must name assembler recipes), each grantable id is granted by
// at most one group, group ids are unique, every group grants at least one
// item, and every `requires` id names a defined group.
void validateUnlocks(const GameConfig& cfg)
{
for (const std::string& requiredId : requiredIds)
{
if (schematicIds.count(requiredId) == 0)
{
throw makeError(file, path,
"references unknown schematic '" + requiredId + "'");
}
}
}
const std::string file = "unlocks.toml";
// Validates that every id listed in an `unlock_requires` (REQ-LOCK-PREREQ)
// resolves to an explicitly-unlockable schematic: a ship, a module, or an
// assembler recipe that carries `unlock_at_station_level` (a recipe schematic,
// per REQ-LOCK-EXPLICIT). An unresolved id is a config error surfaced at load.
void validateUnlockRequires(const GameConfig& cfg)
{
std::unordered_set<std::string> schematicIds;
for (const ShipDef& def : cfg.ships.ships)
{
schematicIds.insert(def.id);
}
for (const ModuleDef& def : cfg.modules.modules)
{
schematicIds.insert(def.id);
}
std::unordered_set<std::string> shipIds;
for (const ShipDef& def : cfg.ships.ships) { shipIds.insert(def.id); }
std::unordered_set<std::string> moduleIds;
for (const ModuleDef& def : cfg.modules.modules) { moduleIds.insert(def.id); }
std::unordered_set<std::string> buildingIds;
for (const BuildingDef& def : cfg.buildings.buildings) { buildingIds.insert(def.id); }
std::unordered_set<std::string> assemblerRecipeIds;
for (const RecipeDef& def : cfg.recipes.recipes)
{
if (def.building == BuildingType::Assembler && def.unlockAtStationLevel.has_value())
{
schematicIds.insert(def.id);
}
if (def.building == BuildingType::Assembler) { assemblerRecipeIds.insert(def.id); }
}
for (const ShipDef& def : cfg.ships.ships)
std::unordered_set<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",
"ship '" + def.id + "'.unlock_requires");
for (const std::string& id : ids)
{
if (valid.count(id) == 0)
{
throw makeError(file, gPath, "grants unknown " + kind + " '" + id + "'");
}
if (!granted.insert(id).second)
{
throw makeError(file, gPath,
"grants " + kind + " '" + id + "' which is already granted by another unlock group");
}
}
};
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
const std::string gPath = "unlock '" + group.id + "'";
if (!groupIds.insert(group.id).second)
{
throw makeError(file, gPath, "duplicate unlock group id");
}
if (group.ships.empty() && group.modules.empty()
&& group.buildings.empty() && group.recipes.empty())
{
throw makeError(file, gPath, "grants no items (must grant at least one)");
}
checkGrants(group.ships, shipIds, grantedShipIds, "ship", gPath);
checkGrants(group.modules, moduleIds, grantedModuleIds, "module", gPath);
checkGrants(group.buildings, buildingIds, grantedBuildingIds, "building", gPath);
checkGrants(group.recipes, assemblerRecipeIds, grantedRecipeIds, "assembler recipe", gPath);
}
for (const ModuleDef& def : cfg.modules.modules)
// requires must reference defined group ids (checked once all group ids known).
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "modules.toml",
"module '" + def.id + "'.unlock_requires");
}
for (const RecipeDef& def : cfg.recipes.recipes)
{
checkUnlockRequires(def.unlockRequires, schematicIds, "recipes.toml",
"recipe '" + def.id + "'.unlock_requires");
for (const std::string& req : group.requiredGroupIds)
{
if (groupIds.count(req) == 0)
{
throw makeError(file, "unlock '" + group.id + "'.requires",
"references unknown unlock group '" + req + "'");
}
}
}
}
@@ -801,7 +861,8 @@ GameConfig ConfigLoader::loadFromDirectory(const std::string& configDir)
cfg.ships = loadShips(configDir + "/ships.toml");
cfg.stations = loadStations(configDir + "/stations.toml");
cfg.modules = loadModules(configDir + "/modules.toml");
validateUnlockRequires(cfg);
cfg.unlocks = loadUnlocks(configDir + "/unlocks.toml");
validateUnlocks(cfg);
cfg.threatCosts = computeThreatCostTable(cfg);
return cfg;
}