Implement config-driven unlock groups so that multiple things can be unlocked at once (including buildings)

This commit is contained in:
2026-07-22 21:34:59 +02:00
parent a8a6a04f1e
commit a9082c57f3
36 changed files with 1005 additions and 543 deletions

View File

@@ -1,10 +1,10 @@
// Drop-pool behaviour for unlock_requires schematic prerequisites
// (REQ-LOCK-PREREQ). A schematic enters the drop pool only when every id in its
// unlock_requires is already explicitly unlocked, on top of the station-level
// (and, for recipes, output-implicitly-unlocked) checks.
// Drop-pool behaviour for unlock-group prerequisites (REQ-LOCK-PREREQ). An
// unlock group enters the drop pool only when every id in its `requires` names a
// group that has already been awarded, on top of the station-level check.
//
// Configs are built in memory (load the test config, then mutate) so each case
// is isolated and does not perturb the shared test config used by other suites.
// Configs are built in memory (load the test config, then mutate the unlock
// groups) so each case is isolated and does not perturb the shared test config
// used by other suites.
#include <algorithm>
#include <string>
@@ -17,13 +17,11 @@
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "ModulesConfig.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
#include "UnlocksConfig.h"
namespace
{
@@ -33,34 +31,14 @@ GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
ShipDef& findShip(GameConfig& cfg, const std::string& id)
UnlockGroupDef& findGroup(GameConfig& cfg, const std::string& id)
{
for (ShipDef& def : cfg.ships.ships)
for (UnlockGroupDef& group : cfg.unlocks.groups)
{
if (def.id == id) { return def; }
if (group.id == id) { return group; }
}
FAIL("ship not found: " + id);
return cfg.ships.ships.front();
}
ModuleDef& findModule(GameConfig& cfg, const std::string& id)
{
for (ModuleDef& def : cfg.modules.modules)
{
if (def.id == id) { return def; }
}
FAIL("module not found: " + id);
return cfg.modules.modules.front();
}
RecipeDef& findRecipe(GameConfig& cfg, const std::string& id)
{
for (RecipeDef& def : cfg.recipes.recipes)
{
if (def.id == id) { return def; }
}
FAIL("recipe not found: " + id);
return cfg.recipes.recipes.front();
FAIL("unlock group not found: " + id);
return cfg.unlocks.groups.front();
}
// Zeros the HP of both enemy defence stations and advances one tick so that
@@ -87,23 +65,28 @@ void killEnemyStationsAndApply(Simulation& sim)
}
}
// True if any pending option grants the given schematic id.
bool optionOffered(const Simulation& sim, const std::string& id)
{
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
return std::any_of(options.begin(), options.end(),
[&](const SchematicChoiceOption& o) { return o.schematicId == id; });
[&](const SchematicChoiceOption& option)
{
return std::any_of(option.grantedItems.begin(), option.grantedItems.end(),
[&](const GrantedSchematic& grant) { return grant.id == id; });
});
}
} // namespace
TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from the pool",
TEST_CASE("UnlockPrereq: a group gated behind a locked ship is withheld from the pool",
"[unlock_prereq]")
{
// quick_circuit (assembler recipe, level 0, output circuit_board is implicitly
// unlocked) would normally be eligible at the first station destruction. Gate
// it behind repair_ship (ship, level 0, locked at game start).
// quick_circuit (recipe group, level 0) would normally be eligible at the
// first station destruction. Gate it behind the repair_ship group (level 0,
// locked at game start).
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship"));
@@ -111,22 +94,22 @@ TEST_CASE("UnlockPrereq: a recipe gated behind a locked ship is withheld from th
killEnemyStations(sim); // destroyed set level 0
REQUIRE(sim.hasSchematicChoicesPending());
// Prerequisite unmet -> the gated recipe must not be offered, while the
// prerequisite schematic itself (the only other eligible pick) is.
// Prerequisite unmet -> the gated group must not be offered, while the
// prerequisite group itself (the only other eligible pick) is.
CHECK_FALSE(optionOffered(sim, "quick_circuit"));
CHECK(optionOffered(sim, "repair_ship"));
}
TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked",
TEST_CASE("UnlockPrereq: the gated group becomes eligible once its prerequisite is awarded",
"[unlock_prereq]")
{
GameConfig cfg = loadConfig();
findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"};
findGroup(cfg, "quick_circuit").requiredGroupIds = {"repair_ship"};
Simulation sim(std::move(cfg), 123);
// Applying choice 0 each drop unlocks repair_ship first (the only eligible
// pick at level 0), which then opens quick_circuit for a later drop.
// pick at level 0), which then opens the quick_circuit group for a later drop.
bool unlocked = false;
for (int i = 0; i < 150 && !unlocked; ++i)
{
@@ -138,16 +121,16 @@ TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite
CHECK(unlocked);
}
TEST_CASE("UnlockPrereq: a module gated behind another module is withheld until it unlocks",
TEST_CASE("UnlockPrereq: a module group gated behind another module is withheld until it unlocks",
"[unlock_prereq]")
{
// Mirror the app-config demo (laser_cannon_m -> laser_cannon_l) using two
// test-config modules made lockable at the same station level.
// Make laser_cannon and armor_plate (both start-unlocked in the test config)
// lockable via new unlock groups, with armor_plate gated behind laser_cannon.
GameConfig cfg = loadConfig();
findModule(cfg, "laser_cannon").unlockAtStationLevel = 0;
ModuleDef& armor = findModule(cfg, "armor_plate");
armor.unlockAtStationLevel = 0;
armor.unlockRequires = {"laser_cannon"};
cfg.unlocks.groups.push_back(
UnlockGroupDef{"laser_cannon", 0, {}, {}, {"laser_cannon"}, {}, {}});
cfg.unlocks.groups.push_back(
UnlockGroupDef{"armor_plate", 0, {"laser_cannon"}, {}, {"armor_plate"}, {}, {}});
Simulation sim(std::move(cfg), 7);
REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon"));