#include "catch.hpp" #include "FactoryQueries.h" #include #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "ConfigLoader.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" #include "ModuleOwnerComponent.h" #include "Rotation.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" #include "StationBodyComponent.h" #include "WeaponComponent.h" #include "ModulesConfig.h" #include "RecipesConfig.h" #include "SchematicChoiceOption.h" #include "ShipsConfig.h" #include "Simulation.h" #include "SimulationTestAccess.h" #include "Tick.h" #include "ThreatCostCalculator.h" #include "WaveSystem.h" #include "TestConfig.h" // --------------------------------------------------------------------------- // Threat accumulation // --------------------------------------------------------------------------- TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]") { const GameConfig cfg = loadTestConfig(); std::mt19937 rng(42); WaveSystem ws(cfg, rng); // threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s. // After 1 second: threat ≈ 1.0. const int ticks1s = static_cast(secondsToTicks(1.0)); for (int i = 0; i < ticks1s; ++i) { ws.tickThreatAccumulation(); } REQUIRE(ws.getThreatLevel() == Approx(1.0)); } TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows", "[wave]") { const GameConfig cfg = loadTestConfig(); std::mt19937 rng(42); WaveSystem ws(cfg, rng); // threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s. REQUIRE(ws.getThreatAccumulationRate() == Approx(1.0)); } TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]") { GameConfig cfg = loadTestConfig(); // Start with the boss countdown already at the pre-boss quiet threshold. cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds; std::mt19937 rng(42); WaveSystem ws(cfg, rng); REQUIRE(ws.getThreatAccumulationRate() == Approx(0.0)); const double before = ws.getThreatLevel(); for (int i = 0; i < static_cast(secondsToTicks(1.0)); ++i) { ws.tickThreatAccumulation(); } REQUIRE(ws.getThreatLevel() == Approx(before)); } TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]") { const GameConfig cfg = loadTestConfig(); std::mt19937 rng(42); WaveSystem ws(cfg, rng); REQUIRE(ws.getGeneration() == 0); ws.onEnemyStationsDestroyed(); REQUIRE(ws.getGeneration() == 1); ws.onEnemyStationsDestroyed(); REQUIRE(ws.getGeneration() == 2); } // --------------------------------------------------------------------------- // Pre-placed structures // --------------------------------------------------------------------------- TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]") { const Simulation sim(loadTestConfig(), 42); // HQ is still a Building (for belt integration). int hqCount = 0; for (const Building& b : getAllBuildings(sim.getFactoryState())) { if (b.type == BuildingType::Hq) { ++hqCount; } } // Stations are ECS entities. int playerCount = 0; int enemyCount = 0; sim.getAdmin().forEach( [&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f) { if (f.isEnemy) { ++enemyCount; } else { ++playerCount; } }); REQUIRE(hqCount == 1); REQUIRE(playerCount == 2); REQUIRE(enemyCount == 2); } TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]") { const Simulation sim(loadTestConfig(), 42); const float expectedHp = static_cast(sim.getConfig().stations.hq.hpFormula.evaluate(0.0)); bool found = false; float actualHp = 0.0f; sim.getAdmin().forEach( [&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, const HealthComponent& h) { found = true; actualHp = h.hp; }); REQUIRE(found); REQUIRE(actualHp == Approx(expectedHp)); } TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]") { const Simulation sim(loadTestConfig(), 42); for (const Building& b : getAllBuildings(sim.getFactoryState())) { if (b.type != BuildingType::Hq) { continue; } // Rightmost body cell must be at x = -1 (asteroid right edge). int maxX = std::numeric_limits::min(); for (const QPoint& cell : b.bodyCells) { if (cell.x() > maxX) { maxX = cell.x(); } } REQUIRE(maxX == -1); } } TEST_CASE("WaveSystem: player stations have weapon set", "[wave]") { Simulation sim(loadTestConfig(), 42); int armedPlayerStations = 0; sim.getAdmin().forEach( [&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo) { if (!sim.getAdmin().hasAll(mo.owner)) { return; } const FactionComponent& f = sim.getAdmin().get(mo.owner); if (!f.isEnemy) { ++armedPlayerStations; REQUIRE(w.damage > 0.0f); REQUIRE(w.range_tiles > 0.0f); REQUIRE(w.fireRateHz > 0.0f); } }); REQUIRE(armedPlayerStations == 2); } TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]") { Simulation sim(loadTestConfig(), 42); int armedEnemyStations = 0; sim.getAdmin().forEach( [&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo) { if (!sim.getAdmin().hasAll(mo.owner)) { return; } const FactionComponent& f = sim.getAdmin().get(mo.owner); if (f.isEnemy) { ++armedEnemyStations; REQUIRE(w.damage > 0.0f); REQUIRE(w.range_tiles > 0.0f); REQUIRE(w.fireRateHz > 0.0f); } }); REQUIRE(armedEnemyStations == 2); } // --------------------------------------------------------------------------- // Wave spawning // --------------------------------------------------------------------------- TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]") { Simulation sim(loadTestConfig(), 42); // The maximum gap is gapMaxSeconds = 45s → 1350 ticks. // Run 1500 ticks to guarantee at least one wave has triggered. // Check each tick: enemy ships may be killed quickly by player stations, // so we must detect them while they are alive, not only after the loop. const int limit = static_cast(secondsToTicks(50.0)); bool foundEnemyShip = false; for (int i = 0; i < limit; ++i) { sim.tick(); if (!foundEnemyShip) { sim.getAdmin().forEach( [&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/, const FactionComponent& f) { if (f.isEnemy) { foundEnemyShip = true; } }); } } REQUIRE(foundEnemyShip); } TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]") { const GameConfig cfg = loadTestConfig(); for (const ShipDef& def : cfg.ships.ships) { const double cost = calculateShipThreatCost(cfg.threatCosts, cfg, def.id, def.defaultModules); CHECK(cost > 0.0); } } // --------------------------------------------------------------------------- // Push // --------------------------------------------------------------------------- TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]") { Simulation sim(loadTestConfig(), 42); // Damage both enemy stations to 0. sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); // After push: should have 2 new enemy stations. int enemyCount = 0; sim.getAdmin().forEach( [&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f) { if (f.isEnemy) { ++enemyCount; } }); REQUIRE(enemyCount == 2); } TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]") { Simulation sim(loadTestConfig(), 42); sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); REQUIRE(sim.hasSchematicChoicesPending()); const std::vector& choices = sim.getPendingSchematicChoices(); REQUIRE(choices.size() >= 1); REQUIRE(choices.size() <= 3); } TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]") { Simulation sim(loadTestConfig(), 42); sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); const std::vector& choices = sim.getPendingSchematicChoices(); REQUIRE_FALSE(choices.empty()); for (const SchematicChoiceOption& opt : choices) { if (opt.isArtifact) { continue; } bool validGroup = false; for (const UnlockGroupDef& group : sim.getConfig().unlocks.groups) { if (group.id == opt.unlockGroupId) { validGroup = true; break; } } 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 == grant.id) { validId = true; break; } } for (const BuildingDef& def : sim.getConfig().buildings.buildings) { if (def.id == grant.id) { validId = true; break; } } for (const RecipeDef& def : sim.getConfig().recipes.recipes) { if (def.id == grant.id) { validId = true; break; } } REQUIRE(validId); } } } TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]") { Simulation sim(loadTestConfig(), 42); sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); const std::vector& choices = sim.getPendingSchematicChoices(); std::set ids; for (const SchematicChoiceOption& opt : choices) { ids.insert(opt.isArtifact ? std::string("") : opt.unlockGroupId); } REQUIRE(ids.size() == choices.size()); } TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]") { Simulation sim(loadTestConfig(), 42); sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); REQUIRE(sim.hasSchematicChoicesPending()); SimulationTestAccess::applySchematicChoice(sim, 0); REQUIRE_FALSE(sim.hasSchematicChoicesPending()); } TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]") { Simulation sim(loadTestConfig(), 42); // Record the X position of the initial enemy stations. int initialX = std::numeric_limits::min(); sim.getAdmin().forEach( [&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f) { if (f.isEnemy && sb.anchor.x() > initialX) { initialX = sb.anchor.x(); } }); sim.getAdmin().forEach( [](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h) { if (f.isEnemy) { h.hp = -1.0f; } }); sim.tick(); int newX = std::numeric_limits::min(); sim.getAdmin().forEach( [&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f) { if (f.isEnemy && sb.anchor.x() > newX) { newX = sb.anchor.x(); } }); REQUIRE(newX > initialX); }