#include "catch.hpp" #include #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" #include "BuildingType.h" #include "CombatSystem.h" #include "ConfigLoader.h" #include "EntityAdmin.h" #include "FactionComponent.h" #include "FireEvent.h" #include "HealthComponent.h" #include "HqProxyComponent.h" #include "ModuleOwnerComponent.h" #include "ScrapSystem.h" #include "ShipSystem.h" #include "Simulation.h" #include "StationBodyComponent.h" #include "Tick.h" #include "ThreatResponseBehaviorComponent.h" #include "WeaponComponent.h" static GameConfig loadConfig() { return ConfigLoader::loadFromDirectory(CONFIG_DIR); } 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; std::mt19937 rng; EntityAdmin admin; BuildingId nextBuildingId; BeltSystem belts; ShipSystem ships; BuildingSystem buildings; CombatSystem combat; explicit CombatFixture() : cfg(loadConfig()) , 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&) {}, rng) , combat(cfg) { } void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget) { // Set target on weapon child entity (CombatSystem syncs from ThreatResponse each tick, // but also setting directly ensures the first tick fires without waiting for sync). 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, 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, f.buildings, 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, 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 FireEvent& evt : evts) { if (evt.shooter == enemy) { return true; } } return false; }; std::vector events; f.combat.tick(0, f.admin, f.buildings, events); REQUIRE_FALSE(enemyFiredIn(events)); f.combat.tick(1, f.admin, f.buildings, events); REQUIRE_FALSE(enemyFiredIn(events)); f.combat.tick(2, f.admin, f.buildings, 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, 1, QVector2D(0.0f, 0.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.0f), false); f.wireEnemyTarget(enemy, player); std::vector events; f.combat.tick(0, f.admin, f.buildings, events); REQUIRE(events.empty()); } // --------------------------------------------------------------------------- // Station weapon firing // --------------------------------------------------------------------------- TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]") { Simulation sim(loadConfig(), 42); // Find the player station entity via ECS. entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.admin().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.admin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); const entt::entity enemyShip = sim.ships().spawn( combatDef->id, 1, QVector2D(stationCenter.x() + 1.0f, stationCenter.y()), /*isEnemy=*/true); sim.tick(); const std::vector events = sim.drainFireEvents(); bool stationFired = false; for (const FireEvent& evt : events) { if (evt.shooter == stationEntity) { stationFired = true; } } REQUIRE(stationFired); } TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]") { Simulation sim(loadConfig(), 42); entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.admin().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.admin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); sim.ships().spawn( combatDef->id, 1, QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), /*isEnemy=*/false); sim.tick(); const std::vector events = sim.drainFireEvents(); bool stationFired = false; for (const FireEvent& evt : events) { if (evt.shooter == stationEntity) { stationFired = true; } } REQUIRE(stationFired); } TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat]") { Simulation sim(loadConfig(), 42); entt::entity stationEntity = entt::null; QVector2D stationCenter; sim.admin().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.admin().isValid(stationEntity)); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); const entt::entity playerShip = sim.ships().spawn( combatDef->id, 1, QVector2D(stationCenter.x() - 1.0f, stationCenter.y()), /*isEnemy=*/false); sim.tick(); const std::vector events = sim.drainFireEvents(); bool playerFiredAtStation = false; for (const FireEvent& 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, 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, f.buildings, 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, 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, f.buildings, 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); f.wireEnemyTarget(enemy, player); std::vector events; f.combat.tick(0, f.admin, f.buildings, 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, 1, QVector2D(5.0f, 5.0f), true); const entt::entity player = f.ships.spawn(combatDef->id, 1, 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, f.buildings, 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(loadConfig(), 42); const ShipDef* combatDef = findCombatShip(sim.config()); REQUIRE(combatDef != nullptr); const entt::entity ship = sim.ships().spawn(combatDef->id, 1, QVector2D(10.0f, 10.0f)); sim.admin().get(ship).hp = -1.0f; sim.tick(); REQUIRE_FALSE(sim.admin().isValid(ship)); } TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]") { Simulation sim(loadConfig(), 42); const ShipDef* droppingDef = nullptr; for (const ShipDef& def : sim.config().ships.ships) { if (def.loot.scrapDrop > 0) { droppingDef = &def; break; } } REQUIRE(droppingDef != nullptr); const entt::entity ship = sim.ships().spawn(droppingDef->id, 1, QVector2D(10.0f, 10.0f)); sim.admin().get(ship).hp = -1.0f; sim.tick(); REQUIRE(!sim.scraps().allScrapInfo().empty()); } TEST_CASE("CombatSystem: HQ death sets game over", "[combat]") { Simulation sim(loadConfig(), 42); // Damage the HQ proxy entity (has HqProxy + Health). sim.admin().forEach( [](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h) { h.hp = -1.0f; }); sim.tick(); REQUIRE(sim.isGameOver()); }