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

@@ -36,7 +36,7 @@ static int findArtifactChoiceIndex(const Simulation& sim)
const auto& choices = sim.getPendingSchematicChoices();
for (int i = 0; i < static_cast<int>(choices.size()); ++i)
{
if (choices[static_cast<std::size_t>(i)].type == SchematicType::Artifact)
if (choices[static_cast<std::size_t>(i)].isArtifact)
{
return i;
}
@@ -86,7 +86,7 @@ TEST_CASE("ArtifactWinCondition: artifact option appears when chance formula ret
const auto& choices = sim.getPendingSchematicChoices();
const long artifactCount = std::count_if(choices.begin(), choices.end(),
[](const SchematicChoiceOption& opt) { return opt.type == SchematicType::Artifact; });
[](const SchematicChoiceOption& opt) { return opt.isArtifact; });
CHECK(artifactCount == 1);
// Exactly 2 schematic picks + 1 artifact (or fewer if pool was small)
CHECK(choices.size() <= 3);
@@ -105,7 +105,7 @@ TEST_CASE("ArtifactWinCondition: at most 2 schematic options accompany the artif
const auto& choices = sim.getPendingSchematicChoices();
const long schematicCount = std::count_if(choices.begin(), choices.end(),
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
[](const SchematicChoiceOption& opt) { return !opt.isArtifact; });
CHECK(schematicCount <= 2);
}
@@ -161,7 +161,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm
// Pick the first non-artifact choice.
const auto& choices = sim.getPendingSchematicChoices();
const auto it = std::find_if(choices.begin(), choices.end(),
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
[](const SchematicChoiceOption& opt) { return !opt.isArtifact; });
REQUIRE(it != choices.end());
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));

View File

@@ -1,5 +1,7 @@
#include "catch.hpp"
#include <algorithm>
#include "Building.h"
#include "BuildingConfig.h"
#include "BuildingSystem.h"
@@ -46,7 +48,17 @@ const ShipDef* findAvailableSchematic(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.unlockAtStationLevel == -1) { return &def; }
// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT).
bool granted = false;
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
if (std::find(group.ships.begin(), group.ships.end(), def.id) != group.ships.end())
{
granted = true;
break;
}
}
if (!granted) { return &def; }
}
return nullptr;
}

View File

@@ -25,6 +25,7 @@ add_files(
ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp
UnlockPrereqTest.cpp
UnlockGroupTest.cpp
ArtifactWinConditionTest.cpp
DeterminismTest.cpp
CommandTest.cpp

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()));

View File

@@ -57,36 +57,36 @@ static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId,
}
// ---------------------------------------------------------------------------
// ConfigLoader: parsing unlock_at_station_level on assembler recipes
// ConfigLoader: unlocked_at_start and unlock-group gating on assembler recipes
// ---------------------------------------------------------------------------
TEST_CASE("RecipeSchematic: unlock_at_station_level = -1 parsed correctly", "[recipe_schematic]")
TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "premium_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == -1);
CHECK(it->unlockedAtStart);
}
TEST_CASE("RecipeSchematic: unlock_at_station_level = 0 parsed correctly", "[recipe_schematic]")
TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "quick_circuit"; });
REQUIRE(it != cfg.recipes.recipes.end());
REQUIRE(it->unlockAtStationLevel.has_value());
CHECK(it->unlockAtStationLevel.value() == 0);
const bool granted = std::any_of(cfg.unlocks.groups.begin(), cfg.unlocks.groups.end(),
[](const UnlockGroupDef& g)
{
return std::find(g.recipes.begin(), g.recipes.end(), "quick_circuit") != g.recipes.end();
});
CHECK(granted);
}
TEST_CASE("RecipeSchematic: assembler recipe without the key has nullopt", "[recipe_schematic]")
TEST_CASE("RecipeSchematic: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]")
{
const GameConfig cfg = loadConfig();
const auto it = std::find_if(cfg.recipes.recipes.begin(), cfg.recipes.recipes.end(),
[](const RecipeDef& r) { return r.id == "circuit_board"; });
REQUIRE(it != cfg.recipes.recipes.end());
CHECK_FALSE(it->unlockAtStationLevel.has_value());
CHECK_FALSE(it->unlockedAtStart);
}
// ---------------------------------------------------------------------------
@@ -180,11 +180,11 @@ TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on s
REQUIRE(awaitRecipeUnlock(sim, "quick_circuit"));
}
TEST_CASE("RecipeSchematic: recipe whose output is not implicitly unlocked is never awarded",
TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is never awarded",
"[recipe_schematic]")
{
// exotic_alloy produces exotic_alloy (not implicitly unlocked), so the
// output-unlocked guard must keep it out of the drop pool at every level.
// exotic_alloy is in no unlock group and its output/inputs are unreachable
// via the item graph, so it can never be dropped or implicitly unlocked.
Simulation sim(loadConfig());
for (int i = 0; i < 50; ++i)
{
@@ -241,11 +241,11 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
{
for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices())
{
if (opt.type == SchematicType::Recipe)
for (const GrantedSchematic& grant : opt.grantedItems)
{
foundRecipeChoice = true;
break;
if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; }
}
if (foundRecipeChoice) { break; }
}
SimulationTestAccess::applySchematicChoice(sim, 0);
}
@@ -377,11 +377,14 @@ TEST_CASE("SchematicDrop: an owned ship schematic is never offered again",
const std::vector<SchematicChoiceOption>& choices =
sim.getPendingSchematicChoices();
// Locate the repair_ship option, if present in this drop.
// Locate the option granting repair_ship, if present in this drop.
int repairShipIndex = -1;
for (int j = 0; j < static_cast<int>(choices.size()); ++j)
{
if (choices[j].schematicId == "repair_ship") { repairShipIndex = j; }
for (const GrantedSchematic& grant : choices[j].grantedItems)
{
if (grant.id == "repair_ship") { repairShipIndex = j; break; }
}
}
// Once owned, it must never appear in the pool again.

View File

@@ -1,5 +1,7 @@
#include "catch.hpp"
#include <algorithm>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
@@ -20,11 +22,24 @@ static GameConfig loadConfig()
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// A ship starts unlocked iff no unlock group grants it (REQ-LOCK-EXPLICIT).
static bool startsUnlocked(const GameConfig& cfg, const std::string& shipId)
{
for (const UnlockGroupDef& group : cfg.unlocks.groups)
{
if (std::find(group.ships.begin(), group.ships.end(), shipId) != group.ships.end())
{
return false;
}
}
return true;
}
static const ShipDef* findAvailableSchematic(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.unlockAtStationLevel == -1 && !def.schematic.materials.empty())
if (startsUnlocked(cfg, def.id) && !def.schematic.materials.empty())
{
return &def;
}

View File

@@ -0,0 +1,117 @@
// Unlock groups as the unit of loot (REQ-LOCK-EXPLICIT, REQ-LOCK-BUILDING,
// REQ-DEF-SCHEMATIC-DROP): buildings can be locked, and one drop can grant a
// building and a module together.
#include <algorithm>
#include <string>
#include <utility>
#include <vector>
#include "catch.hpp"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "FactionComponent.h"
#include "GameConfig.h"
#include "HealthComponent.h"
#include "SchematicChoiceOption.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
#include "UnlocksConfig.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
void killEnemyStations(Simulation& sim)
{
sim.getAdmin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health)
{
if (faction.isEnemy) { health.hp = 0.0f; }
});
sim.tick();
}
// A config whose only unlock group grants the salvager module and the salvage
// bay together, gated to the given station level.
GameConfig configWithSalvageGroup(int stationLevel)
{
GameConfig cfg = loadConfig();
cfg.unlocks.groups.clear();
cfg.unlocks.groups.push_back(
UnlockGroupDef{"salvage_operations", stationLevel, {}, {}, {"salvager"}, {"salvage_bay"}, {}});
return cfg;
}
} // namespace
TEST_CASE("UnlockGroup: a building granted by a group is locked at game start", "[unlock_group]")
{
const Simulation sim(configWithSalvageGroup(0));
CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay));
CHECK_FALSE(sim.isModuleSchematicUnlocked("salvager"));
// A building in no unlock group is available from the start.
CHECK(sim.isBuildingUnlocked(BuildingType::Miner));
}
TEST_CASE("UnlockGroup: the drop option lists every granted item", "[unlock_group]")
{
Simulation sim(configWithSalvageGroup(0));
killEnemyStations(sim); // level 0
REQUIRE(sim.hasSchematicChoicesPending());
const std::vector<SchematicChoiceOption>& options = sim.getPendingSchematicChoices();
const auto it = std::find_if(options.begin(), options.end(),
[](const SchematicChoiceOption& o) { return o.unlockGroupId == "salvage_operations"; });
REQUIRE(it != options.end());
const bool grantsModule = std::any_of(it->grantedItems.begin(), it->grantedItems.end(),
[](const GrantedSchematic& g) { return g.type == SchematicType::Module && g.id == "salvager"; });
const bool grantsBuilding = std::any_of(it->grantedItems.begin(), it->grantedItems.end(),
[](const GrantedSchematic& g) { return g.type == SchematicType::Building && g.id == "salvage_bay"; });
CHECK(grantsModule);
CHECK(grantsBuilding);
}
TEST_CASE("UnlockGroup: awarding a group unlocks the building and module at once", "[unlock_group]")
{
Simulation sim(configWithSalvageGroup(0));
killEnemyStations(sim); // level 0
REQUIRE(sim.hasSchematicChoicesPending());
// The only eligible group is salvage_operations, so option 0 grants it.
REQUIRE(sim.getPendingSchematicChoices()[0].unlockGroupId == "salvage_operations");
SimulationTestAccess::applySchematicChoice(sim, 0);
CHECK(sim.isBuildingUnlocked(BuildingType::SalvageBay));
CHECK(sim.isModuleSchematicUnlocked("salvager"));
}
TEST_CASE("UnlockGroup: a group above the destroyed station level is not offered", "[unlock_group]")
{
Simulation sim(configWithSalvageGroup(5)); // eligible only at level >= 5
killEnemyStations(sim); // level 0 push
// Pool is empty at level 0, so no choice dialog opens at all.
CHECK_FALSE(sim.hasSchematicChoicesPending());
CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay));
}
TEST_CASE("UnlockGroup: reset re-locks a previously awarded building", "[unlock_group]")
{
Simulation sim(configWithSalvageGroup(0));
killEnemyStations(sim);
REQUIRE(sim.getPendingSchematicChoices()[0].unlockGroupId == "salvage_operations");
SimulationTestAccess::applySchematicChoice(sim, 0);
REQUIRE(sim.isBuildingUnlocked(BuildingType::SalvageBay));
sim.reset();
CHECK_FALSE(sim.isBuildingUnlocked(BuildingType::SalvageBay));
CHECK_FALSE(sim.isModuleSchematicUnlocked("salvager"));
}

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"));

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());
}