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

@@ -10,6 +10,7 @@
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "UnlocksConfig.h"
namespace
{
@@ -407,99 +408,42 @@ void copyConfigInto(const std::filesystem::path& dir)
} // namespace
TEST_CASE("unlock_requires parses into a ship def", "[config]")
TEST_CASE("loadUnlocks parses id, station_level, requires, and grants", "[config]")
{
TempConfigDir dir;
writeFile(dir.path() / "ships.toml", R"(
[[ship]]
id = "alpha"
unlock_at_station_level = 1
unlock_requires = ["beta", "gamma"]
layout = ["O"]
[ship.schematic]
materials = []
production_time_seconds = 5
[ship.health]
hp = 10
[ship.movement]
speed_mps = 1
main_acceleration_mpss = 1
maneuvering_acceleration_mpss = 1
angular_acceleration_radpss = 1
max_rotation_speed_radps = 1
[ship.sensor]
sensor_range_m = 10
writeFile(dir.path() / "unlocks.toml", R"(
[[unlock]]
id = "base"
station_level = 0
buildings = ["salvage_bay"]
[[unlock]]
id = "gated"
station_level = 3
requires = ["base"]
ships = ["cruiser"]
modules = ["railgun_m"]
recipes = ["shortcut_steel_plate"]
)");
const ShipsConfig cfg = ConfigLoader::loadShips((dir.path() / "ships.toml").string());
REQUIRE(cfg.ships.size() == 1);
CHECK(cfg.ships[0].unlockRequires == std::vector<std::string>{"beta", "gamma"});
const UnlocksConfig cfg = ConfigLoader::loadUnlocks((dir.path() / "unlocks.toml").string());
REQUIRE(cfg.groups.size() == 2);
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]")
{
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]")
TEST_CASE("validateUnlocks rejects a grant that names no definition", "[config]")
{
TempConfigDir dir;
copyConfigInto(dir.path());
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\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";
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
out << "\n[[unlock]]\nid = \"bogus\"\nstation_level = 0\nships = [\"___nope___\"]\n";
out.close();
try
@@ -509,26 +453,103 @@ TEST_CASE("unlock_requires referencing an unknown schematic is rejected at load"
}
catch (const std::runtime_error& e)
{
const std::string msg = e.what();
REQUIRE(msg.find("___does_not_exist___") != std::string::npos);
REQUIRE(std::string(e.what()).find("___nope___") != 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;
copyConfigInto(dir.path());
// "interceptor" is a ship schematic in the test config — a valid prerequisite.
std::ofstream out((dir.path() / "recipes.toml").string(), std::ios::app);
out << "\n[[recipe]]\n"
"id = \"gated_ok\"\n"
"building = \"assembler\"\n"
"unlock_at_station_level = 1\n"
"unlock_requires = [\"interceptor\"]\n"
"inputs = []\n"
"outputs = [{item = \"circuit_board\", amount = 1}]\n"
"duration_seconds = 1.0\n";
// repair_ship is already granted by the test config's unlocks.toml.
std::ofstream out((dir.path() / "unlocks.toml").string(), std::ios::app);
out << "\n[[unlock]]\nid = \"dup\"\nstation_level = 0\nships = [\"repair_ship\"]\n";
out.close();
try
{
ConfigLoader::loadFromDirectory(dir.path().string());
FAIL("Expected exception");
}
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();
REQUIRE_NOTHROW(ConfigLoader::loadFromDirectory(dir.path().string()));