// 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. // // 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. #include #include #include #include #include "catch.hpp" #include "ConfigLoader.h" #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" namespace { GameConfig loadConfig() { return ConfigLoader::loadFromDirectory(CONFIG_DIR); } ShipDef& findShip(GameConfig& cfg, const std::string& id) { for (ShipDef& def : cfg.ships.ships) { if (def.id == id) { return def; } } 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(); } // Zeros the HP of both enemy defence stations and advances one tick so that // tickDeathsAndLoot fires, triggering the push and schematic choices. void killEnemyStations(Simulation& sim) { sim.admin().forEach( [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health) { if (faction.isEnemy) { health.hp = 0.0f; } }); sim.tick(); } void killEnemyStationsAndApply(Simulation& sim) { killEnemyStations(sim); if (sim.hasSchematicChoicesPending()) { SimulationTestAccess::applySchematicChoice(sim, 0); } } bool optionOffered(const Simulation& sim, const std::string& id) { const std::vector& options = sim.getPendingSchematicChoices(); return std::any_of(options.begin(), options.end(), [&](const SchematicChoiceOption& o) { return o.schematicId == id; }); } } // namespace TEST_CASE("UnlockPrereq: a recipe 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). GameConfig cfg = loadConfig(); findRecipe(cfg, "quick_circuit").unlockRequires = {"repair_ship"}; Simulation sim(std::move(cfg), 123); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); 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. CHECK_FALSE(optionOffered(sim, "quick_circuit")); CHECK(optionOffered(sim, "repair_ship")); } TEST_CASE("UnlockPrereq: the gated recipe becomes eligible once its prerequisite is unlocked", "[unlock_prereq]") { GameConfig cfg = loadConfig(); findRecipe(cfg, "quick_circuit").unlockRequires = {"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. bool unlocked = false; for (int i = 0; i < 150 && !unlocked; ++i) { killEnemyStationsAndApply(sim); unlocked = sim.isRecipeUnlocked("quick_circuit"); } CHECK(sim.isSchematicUnlocked("repair_ship")); // prerequisite got unlocked along the way CHECK(unlocked); } TEST_CASE("UnlockPrereq: a module 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. GameConfig cfg = loadConfig(); findModule(cfg, "laser_cannon").unlockAtStationLevel = 0; ModuleDef& armor = findModule(cfg, "armor_plate"); armor.unlockAtStationLevel = 0; armor.unlockRequires = {"laser_cannon"}; Simulation sim(std::move(cfg), 7); REQUIRE_FALSE(sim.isModuleSchematicUnlocked("laser_cannon")); REQUIRE_FALSE(sim.isModuleSchematicUnlocked("armor_plate")); killEnemyStations(sim); // level 0 REQUIRE(sim.hasSchematicChoicesPending()); // armor_plate is gated behind the still-locked laser_cannon module. CHECK_FALSE(optionOffered(sim, "armor_plate")); // Drive the drops until the gated module unlocks; it can only do so after its // prerequisite has itself been unlocked. bool armorUnlocked = false; for (int i = 0; i < 300 && !armorUnlocked; ++i) { killEnemyStationsAndApply(sim); armorUnlocked = sim.isModuleSchematicUnlocked("armor_plate"); if (armorUnlocked) { CHECK(sim.isModuleSchematicUnlocked("laser_cannon")); } } CHECK(armorUnlocked); }