#include "catch.hpp" #include #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "FactoryState.h" #include "BuildingType.h" #include "CombatSystem.h" #include "ConfigLoader.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "BeamFiredEvent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" #include "ModuleOwnerComponent.h" #include "DebrisComponent.h" #include "DebrisSystem.h" #include "ShipSystem.h" #include "Simulation.h" #include "AttackBehavior.h" #include "StationBodyComponent.h" #include "Tick.h" #include "WeaponComponent.h" #include "TestConfig.h" static const ShipDef* findCombatShip(const GameConfig& cfg) { for (const ShipDef& def : cfg.ships.ships) { if (!def.defaultModules.empty()) { return &def; } } return nullptr; } static entt::entity findWeaponChild(EntityAdmin& admin, entt::entity ship) { entt::entity result = entt::null; admin.forEach( [&](entt::entity ce, const WeaponComponent&, const ModuleOwnerComponent& o) { if (o.owner == ship && result == entt::null) { result = ce; } }); return result; } // Helper fixture for unit tests that need ships + combat but not a full Simulation. struct CombatFixture { GameConfig cfg; FactoryState state = makeFactoryState(cfg); std::mt19937 rng; EntityAdmin admin; BuildingId nextBuildingId; BeltSystem belts; ShipSystem ships; BuildingSystem buildings; CombatSystem combat; explicit CombatFixture() : cfg(loadTestConfig()) , rng(42) , nextBuildingId(1) , belts(cfg.world.beltSpeed_tps) , ships(cfg, admin) , buildings(cfg, belts, [this]() { return nextBuildingId++; }, [](int){}, [](const std::string&, QVector2D, const std::optional&) {}, [](const std::string&) -> bool { return true; }, rng) , combat(cfg) { } void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget) { // Set the target directly on the weapon child entity. CombatSystem now // fires at whatever target a weapon already has (AttackExecutor would set // it in a full tick); setting it here drives CombatSystem in isolation. const entt::entity wc = findWeaponChild(admin, enemy); if (wc != entt::null) { admin.get(wc).currentTarget = playerTarget; admin.get(wc).cooldownTicks = 0.0f; } if (admin.hasAll(enemy)) { admin.get(enemy).currentTarget = playerTarget; } } }; // --------------------------------------------------------------------------- // Ship weapon firing // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); const float hpBefore = f.admin.get(player).hp; std::vector events; f.combat.tick(0, f.admin, events); f.combat.applyPendingDamage(5, f.admin); REQUIRE(f.admin.get(player).hp < hpBefore); REQUIRE(events.size() >= 1); } TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); { const entt::entity wc = findWeaponChild(f.admin, enemy); REQUIRE(f.admin.isValid(wc)); f.admin.get(wc).cooldownTicks = 3.0f; // override to 3 } auto enemyFiredIn = [&enemy](const std::vector& evts) { for (const BeamFiredEvent& evt : evts) { if (evt.shooter == enemy) { return true; } } return false; }; std::vector events; f.combat.tick(0, f.admin, events); REQUIRE_FALSE(enemyFiredIn(events)); f.combat.tick(1, f.admin, events); REQUIRE_FALSE(enemyFiredIn(events)); f.combat.tick(2, f.admin, events); REQUIRE(enemyFiredIn(events)); } TEST_CASE("CombatSystem: no fire when target is out of range", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(0.0f, 0.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(500.0f, 0.0f), false); f.wireEnemyTarget(enemy, player); std::vector events; f.combat.tick(0, f.admin, events); REQUIRE(events.empty()); } // --------------------------------------------------------------------------- // Station weapon firing // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]") { Simulation sim(loadTestConfig(), 42); // Find the player station entity via ECS. entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.getAdmin().forEach( [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f) { if (!f.isEnemy && stationEntity == entt::null) { stationEntity = e; stationCenter = QVector2D( sb.anchor.x() + sb.footprint.width() / 2.0f, sb.anchor.y() + sb.footprint.height() / 2.0f); } }); REQUIRE(sim.getAdmin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.getConfig()); REQUIRE(combatDef != nullptr); const entt::entity enemyShip = sim.getShips().spawn( combatDef->id, QVector2D(stationCenter.x() + 1.0f, stationCenter.y()), /*isEnemy=*/true); sim.tick(); const std::vector events = sim.drainBeamFiredEvents(); bool stationFired = false; for (const BeamFiredEvent& evt : events) { if (evt.shooter == stationEntity) { stationFired = true; } } REQUIRE(stationFired); } TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]") { Simulation sim(loadTestConfig(), 42); entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.getAdmin().forEach( [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f) { if (f.isEnemy && stationEntity == entt::null) { stationEntity = e; stationCenter = QVector2D( sb.anchor.x() + sb.footprint.width() / 2.0f, sb.anchor.y() + sb.footprint.height() / 2.0f); } }); REQUIRE(sim.getAdmin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.getConfig()); REQUIRE(combatDef != nullptr); sim.getShips().spawn( combatDef->id, QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), /*isEnemy=*/false); sim.tick(); const std::vector events = sim.drainBeamFiredEvents(); bool stationFired = false; for (const BeamFiredEvent& evt : events) { if (evt.shooter == stationEntity) { stationFired = true; } } REQUIRE(stationFired); } TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]") { Simulation sim(loadTestConfig(), 42); entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.getAdmin().forEach( [&](entt::entity e, const StationBodyComponent& sb, const FactionComponent& f) { if (f.isEnemy && stationEntity == entt::null) { stationEntity = e; stationCenter = QVector2D( sb.anchor.x() + sb.footprint.width() / 2.0f, sb.anchor.y() + sb.footprint.height() / 2.0f); } }); REQUIRE(sim.getAdmin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.getConfig()); REQUIRE(combatDef != nullptr); const entt::entity playerShip = sim.getShips().spawn( combatDef->id, QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), /*isEnemy=*/false); sim.tick(); const std::vector events = sim.drainBeamFiredEvents(); bool playerFiredAtStation = false; for (const BeamFiredEvent& evt : events) { if (evt.shooter == playerShip && evt.target == stationEntity) { playerFiredAtStation = true; } } REQUIRE(playerFiredAtStation); } // --------------------------------------------------------------------------- // Deferred damage timing // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); const float hpBefore = f.admin.get(player).hp; std::vector events; f.combat.tick(0, f.admin, events); for (Tick t = 1; t < 5; ++t) { f.combat.applyPendingDamage(t, f.admin); REQUIRE(f.admin.get(player).hp == Approx(hpBefore)); } } TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); const float hpBefore = f.admin.get(player).hp; std::vector events; f.combat.tick(0, f.admin, events); f.combat.applyPendingDamage(5, f.admin); REQUIRE(f.admin.get(player).hp < hpBefore); } TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); std::vector events; f.combat.tick(0, f.admin, events); f.ships.despawn(player); // Should not crash. f.combat.applyPendingDamage(5, f.admin); REQUIRE_FALSE(f.admin.isValid(player)); } TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]") { CombatFixture f; const ShipDef* combatDef = findCombatShip(f.cfg); REQUIRE(combatDef != nullptr); const entt::entity enemy = f.ships.spawn(combatDef->id, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); const float hpBefore = f.admin.get(player).hp; std::vector events; f.combat.tick(0, f.admin, events); f.ships.despawn(enemy); f.combat.applyPendingDamage(5, f.admin); REQUIRE(f.admin.get(player).hp < hpBefore); } // --------------------------------------------------------------------------- // Deaths & loot (tick step 9) // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]") { Simulation sim(loadTestConfig(), 42); const ShipDef* combatDef = findCombatShip(sim.getConfig()); REQUIRE(combatDef != nullptr); const entt::entity ship = sim.getShips().spawn(combatDef->id, QVector2D(10.0f, 10.0f)); sim.getAdmin().get(ship).hp = -1.0f; sim.tick(); REQUIRE_FALSE(sim.getAdmin().isValid(ship)); } TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]") { Simulation sim(loadTestConfig(), 42); // Scrap dropped on death is derived from the ship's as-built threat cost // (REQ-RES-DEBRIS-DROP): round(threat * scrap_per_threat). The interceptor's // threat is 59.0 and the test config sets scrap_per_threat = 1.0, so it drops // round(59.0 * 1.0) = 59 scrap. const entt::entity ship = sim.getShips().spawn("interceptor", QVector2D(10.0f, 10.0f)); sim.getAdmin().get(ship).hp = -1.0f; sim.tick(); const std::vector scraps = getAllDebrisInfo(sim.getAdmin()); REQUIRE(scraps.size() == 1); CHECK(sim.getAdmin().get(scraps[0].entity).amount == 59); } TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") { Simulation sim(loadTestConfig(), 42); // Damage the HQ proxy entity (has HqProxy + Health). sim.getAdmin().forEach( [](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h) { h.hp = -1.0f; }); sim.tick(); REQUIRE(sim.isGameOver()); }