#include #include "catch.hpp" #include "ConfigLoader.h" #include "DisplayName.h" #include "FactionComponent.h" #include "GameConfig.h" #include "HealthComponent.h" #include "RecipesConfig.h" #include "SchematicChoiceOption.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "StationBodyComponent.h" #include "TestConfig.h" // Zeros the HP of both enemy defence stations and advances one tick so that // tickDeathsAndLoot fires, triggering the push and schematic choices. static void killEnemyStations(Simulation& sim) { sim.getAdmin().forEach( [](entt::entity, StationBodyComponent&, FactionComponent& faction, HealthComponent& health) { if (faction.isEnemy) { health.hp = 0.0f; } }); sim.tick(); } // Kills enemy stations and applies the first schematic choice (index 0). static void killEnemyStationsAndApply(Simulation& sim) { killEnemyStations(sim); if (sim.hasSchematicChoicesPending()) { SimulationTestAccess::applySchematicChoice(sim, 0); } } // Destroys station sets until recipeId is unlocked or maxDestructions is reached. // Applies schematic choice 0 after each destruction. Returns true if unlocked. static bool awaitRecipeUnlock(Simulation& sim, const std::string& recipeId, int maxDestructions = 150) { for (int i = 0; i < maxDestructions; ++i) { if (sim.isRecipeUnlocked(recipeId)) { return true; } killEnemyStationsAndApply(sim); } return sim.isRecipeUnlocked(recipeId); } // --------------------------------------------------------------------------- // ConfigLoader: unlocked_at_start and unlock-group gating on assembler recipes // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: unlocked_at_start = true parsed correctly", "[recipe_schematic]") { const GameConfig cfg = loadTestConfig(); 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()); CHECK(it->unlockedAtStart); } TEST_CASE("RecipeSchematic: a gated recipe is granted by an unlock group", "[recipe_schematic]") { const GameConfig cfg = loadTestConfig(); 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: an untagged assembler recipe has unlocked_at_start = false", "[recipe_schematic]") { const GameConfig cfg = loadTestConfig(); 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->unlockedAtStart); } // --------------------------------------------------------------------------- // Initial explicit lock state (REQ-LOCK-EXPLICIT) // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = -1 is unlocked at game start", "[recipe_schematic]") { const Simulation sim(loadTestConfig()); REQUIRE(sim.isRecipeUnlocked("premium_circuit")); } TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 0 is locked at game start", "[recipe_schematic]") { const Simulation sim(loadTestConfig()); REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit")); } TEST_CASE("RecipeSchematic: recipe with unlock_at_station_level = 1 is locked at game start", "[recipe_schematic]") { const Simulation sim(loadTestConfig()); REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit")); } // --------------------------------------------------------------------------- // Implicit unlock graph (REQ-LOCK-IMPLICIT) // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: -1 recipe seeds its output item into the implicit unlock set", "[recipe_schematic]") { // premium_circuit is not needed by any ship or module schematic, so it can // only reach the implicit set via the -1 recipe seed in Phase 1. const Simulation sim(loadTestConfig()); REQUIRE(sim.isItemUnlocked("premium_circuit")); } TEST_CASE("RecipeSchematic: -1 recipe's inputs are in the implicit unlock set", "[recipe_schematic]") { // premium_circuit takes circuit_board as input; that item was already // implicitly unlocked by ship schematics, so it must remain unlocked. const Simulation sim(loadTestConfig()); REQUIRE(sim.isItemUnlocked("circuit_board")); } TEST_CASE("RecipeSchematic: locked recipe's unique input is not in the implicit unlock set", "[recipe_schematic]") { // exotic_alloy has unlock_at_station_level = 0 (locked at start) and takes // exotic_ore as input. exotic_ore is only reachable through this locked // recipe, so it must not appear in the implicit set. const Simulation sim(loadTestConfig()); REQUIRE_FALSE(sim.isItemUnlocked("exotic_ore")); } TEST_CASE("RecipeSchematic: locked recipe's output item is not in the implicit unlock set", "[recipe_schematic]") { // exotic_alloy is produced only by the locked recipe of the same name and // is not needed by any schematic, so neither the item nor the recipe should // be unlocked at game start. const Simulation sim(loadTestConfig()); REQUIRE_FALSE(sim.isItemUnlocked("exotic_alloy")); REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy")); } TEST_CASE("RecipeSchematic: normal implicit unlock is unaffected for untagged assembler recipes", "[recipe_schematic]") { // circuit_board carries no unlock_at_station_level and is needed by ships // that start unlocked, so it must still be implicitly unlocked. const Simulation sim(loadTestConfig()); REQUIRE(sim.isRecipeUnlocked("circuit_board")); } // --------------------------------------------------------------------------- // Drop pool and station destruction (REQ-DEF-SCHEMATIC-DROP) // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: eligible recipe schematic is eventually awarded on station destruction", "[recipe_schematic]") { // quick_circuit has unlock_at_station_level = 0 and produces circuit_board // (already implicitly unlocked), so it is eligible from the first station // destruction. With up to 150 trials it must be awarded at least once. Simulation sim(loadTestConfig()); REQUIRE(awaitRecipeUnlock(sim, "quick_circuit")); } TEST_CASE("RecipeSchematic: an implicitly-gated recipe with no unlock group is never awarded", "[recipe_schematic]") { // 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(loadTestConfig()); for (int i = 0; i < 50; ++i) { killEnemyStationsAndApply(sim); } REQUIRE_FALSE(sim.isRecipeUnlocked("exotic_alloy")); } TEST_CASE("RecipeSchematic: recipe with level > destroyed station level is not awarded", "[recipe_schematic]") { // advanced_circuit has unlock_at_station_level = 1. Destroying a single // level-0 station set must not award it regardless of the RNG outcome. Simulation sim(loadTestConfig()); killEnemyStationsAndApply(sim); REQUIRE_FALSE(sim.isRecipeUnlocked("advanced_circuit")); } TEST_CASE("RecipeSchematic: recipe with higher level is awarded once eligible station level is reached", "[recipe_schematic]") { // After enough destructions to pass station level 1, advanced_circuit must // eventually be awarded. Simulation sim(loadTestConfig()); REQUIRE(awaitRecipeUnlock(sim, "advanced_circuit", 300)); } TEST_CASE("RecipeSchematic: awarded recipe schematic stays unlocked and is not awarded again", "[recipe_schematic]") { Simulation sim(loadTestConfig()); awaitRecipeUnlock(sim, "quick_circuit"); REQUIRE(sim.isRecipeUnlocked("quick_circuit")); // Destroy 30 more station sets; the recipe is no longer in the pool. for (int i = 0; i < 30; ++i) { killEnemyStationsAndApply(sim); } REQUIRE(sim.isRecipeUnlocked("quick_circuit")); } TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices", "[recipe_schematic]") { Simulation sim(loadTestConfig()); bool foundRecipeChoice = false; for (int i = 0; i < 150 && !foundRecipeChoice; ++i) { killEnemyStations(sim); if (sim.hasSchematicChoicesPending()) { for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) { for (const GrantedSchematic& grant : opt.grantedItems) { if (grant.type == SchematicType::Recipe) { foundRecipeChoice = true; break; } } if (foundRecipeChoice) { break; } } SimulationTestAccess::applySchematicChoice(sim, 0); } } CHECK(foundRecipeChoice); } // --------------------------------------------------------------------------- // reset() restores initial lock state (REQ-LOCK-EXPLICIT, REQ-CFG-RELOAD) // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: reset re-locks a previously awarded recipe schematic", "[recipe_schematic]") { Simulation sim(loadTestConfig()); awaitRecipeUnlock(sim, "quick_circuit"); REQUIRE(sim.isRecipeUnlocked("quick_circuit")); sim.reset(); REQUIRE_FALSE(sim.isRecipeUnlocked("quick_circuit")); } TEST_CASE("RecipeSchematic: reset keeps -1 recipes unlocked and their seed items accessible", "[recipe_schematic]") { Simulation sim(loadTestConfig()); sim.reset(); REQUIRE(sim.isRecipeUnlocked("premium_circuit")); REQUIRE(sim.isItemUnlocked("premium_circuit")); } // --------------------------------------------------------------------------- // Unlock dialog: newly-unlocked recipe preview (REQ-DEF-SCHEMATIC-DROP) // --------------------------------------------------------------------------- TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds is sorted, deduplicated, and empty for level-ups", "[recipe_schematic]") { Simulation sim(loadTestConfig()); for (int i = 0; i < 100; ++i) { killEnemyStations(sim); if (!sim.hasSchematicChoicesPending()) { continue; } for (const SchematicChoiceOption& opt : sim.getPendingSchematicChoices()) { // Strictly ascending display names imply sorted and deduplicated. for (std::size_t j = 1; j < opt.newlyUnlockedRecipeIds.size(); ++j) { CHECK(toDisplayName(opt.newlyUnlockedRecipeIds[j - 1]) < toDisplayName(opt.newlyUnlockedRecipeIds[j])); } } SimulationTestAccess::applySchematicChoice(sim, 0); } } TEST_CASE("RecipeSchematic: newlyUnlockedRecipeIds matches recipes that actually become unlocked", "[recipe_schematic]") { Simulation sim(loadTestConfig()); const GameConfig cfg = loadTestConfig(); auto unlockedTrackedRecipeIds = [&]() { std::set ids; for (const RecipeDef& def : cfg.recipes.recipes) { if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) && sim.isRecipeUnlocked(def.id)) { ids.insert(def.id); } } return ids; }; for (int i = 0; i < 100; ++i) { killEnemyStations(sim); if (!sim.hasSchematicChoicesPending()) { continue; } const std::set unlockedBefore = unlockedTrackedRecipeIds(); const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0]; SimulationTestAccess::applySchematicChoice(sim, 0); std::vector expected; for (const RecipeDef& def : cfg.recipes.recipes) { if ((def.building == BuildingType::Miner || def.building == BuildingType::Assembler) && sim.isRecipeUnlocked(def.id) && unlockedBefore.count(def.id) == 0) { expected.push_back(def.id); } } std::sort(expected.begin(), expected.end(), [](const std::string& lhs, const std::string& rhs) { return toDisplayName(lhs) < toDisplayName(rhs); }); REQUIRE(choice.newlyUnlockedRecipeIds == expected); } } // --------------------------------------------------------------------------- // Owned ship/module schematics leave the drop pool (REQ-DEF-SCHEMATIC-DROP) // --------------------------------------------------------------------------- TEST_CASE("SchematicDrop: an owned ship schematic is never offered again", "[recipe_schematic]") { // repair_ship has unlock_at_station_level = 0 in the test config, so it is // locked at start and becomes eligible once a station set is destroyed. Simulation sim(loadTestConfig()); REQUIRE_FALSE(sim.isSchematicUnlocked("repair_ship")); bool wasUnlocked = false; for (int i = 0; i < 200; ++i) { killEnemyStations(sim); if (!sim.hasSchematicChoicesPending()) { continue; } const std::vector& choices = sim.getPendingSchematicChoices(); // Locate the option granting repair_ship, if present in this drop. int repairShipIndex = -1; for (int j = 0; j < static_cast(choices.size()); ++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. if (sim.isSchematicUnlocked("repair_ship")) { CHECK(repairShipIndex == -1); } // Prefer picking repair_ship the first time it shows up so we exercise // the transition from unowned to owned; otherwise just take choice 0. int pick = 0; if (repairShipIndex >= 0 && !sim.isSchematicUnlocked("repair_ship")) { pick = repairShipIndex; wasUnlocked = true; } SimulationTestAccess::applySchematicChoice(sim, pick); } // Sanity: we actually unlocked it at some point, so the exclusion CHECK above // was meaningfully exercised. CHECK(wasUnlocked); CHECK(sim.isSchematicUnlocked("repair_ship")); }