diff --git a/src/lib/sim/CombatSystem.cpp b/src/lib/sim/CombatSystem.cpp index 008be73..3159f89 100644 --- a/src/lib/sim/CombatSystem.cpp +++ b/src/lib/sim/CombatSystem.cpp @@ -5,6 +5,8 @@ #include "Ship.h" #include "ShipSystem.h" +static constexpr Tick kWeaponImpactDelayTicks = 5; // 0.15 s × 30 Hz, rounded to nearest + CombatSystem::CombatSystem(const GameConfig& config) : m_config(config) { @@ -71,15 +73,7 @@ void CombatSystem::resolveShipWeapon(Ship& ship, Tick currentTick, return; } - // Apply damage to the correct pool. - if (ships.findShip(targetId)) - { - ships.damageShip(targetId, w.damage); - } - else - { - buildings.damageBuilding(targetId, w.damage); - } + m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks}); FireEvent evt; evt.shooter = ship.id; @@ -153,14 +147,7 @@ void CombatSystem::resolveStationWeapon(Building& station, Tick currentTick, return; } - if (ships.findShip(targetId)) - { - ships.damageShip(targetId, w.damage); - } - else - { - buildings.damageBuilding(targetId, w.damage); - } + m_pendingDamage.push_back({targetId, w.damage, currentTick + kWeaponImpactDelayTicks}); FireEvent evt; evt.shooter = station.id; @@ -203,6 +190,32 @@ std::optional CombatSystem::acquireStationTarget( return best; } +void CombatSystem::applyPendingDamage(Tick currentTick, + ShipSystem& ships, + BuildingSystem& buildings) +{ + auto it = m_pendingDamage.begin(); + while (it != m_pendingDamage.end()) + { + if (it->appliesAt <= currentTick) + { + if (ships.findShip(it->target)) + { + ships.damageShip(it->target, it->amount); + } + else if (buildings.findBuilding(it->target)) + { + buildings.damageBuilding(it->target, it->amount); + } + it = m_pendingDamage.erase(it); + } + else + { + ++it; + } + } +} + std::optional CombatSystem::targetPosition( EntityId id, const ShipSystem& ships, diff --git a/src/lib/sim/CombatSystem.h b/src/lib/sim/CombatSystem.h index a26f17e..30ce208 100644 --- a/src/lib/sim/CombatSystem.h +++ b/src/lib/sim/CombatSystem.h @@ -23,15 +23,28 @@ public: explicit CombatSystem(const GameConfig& config); // Advance weapon cooldowns, acquire targets for stations, fire when ready, - // apply damage, and append FireEvents. Damage is applied immediately via - // ShipSystem::damageShip and BuildingSystem::damageBuilding; step 9 - // removes entities whose HP dropped to zero or below. + // queue deferred damage, and append FireEvents. Call applyPendingDamage() + // after tick() (step 8b) and before death processing (step 9). void tick(Tick currentTick, ShipSystem& ships, BuildingSystem& buildings, std::vector& outFireEvents); + // Apply any queued damage whose impact tick has arrived. Silently drops + // damage if the target no longer exists. + void applyPendingDamage(Tick currentTick, + ShipSystem& ships, + BuildingSystem& buildings); + private: + struct PendingDamage + { + EntityId target; + float amount; + Tick appliesAt; + }; + + std::vector m_pendingDamage; // Process one ship's weapon for this tick. void resolveShipWeapon(Ship& ship, Tick currentTick, ShipSystem& ships, diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 9ef6351..97a0fa9 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -159,6 +159,9 @@ void Simulation::tick() m_combatSystem->tick(m_currentTick, *m_shipSystem, *m_buildingSystem, m_fireEvents); + // Step 8b: deferred damage whose impact tick has arrived + m_combatSystem->applyPendingDamage(m_currentTick, *m_shipSystem, *m_buildingSystem); + // Step 9: deaths & loot if (!m_gameOver) { diff --git a/src/test/CombatSystemTest.cpp b/src/test/CombatSystemTest.cpp index 97396a1..3892939 100644 --- a/src/test/CombatSystemTest.cpp +++ b/src/test/CombatSystemTest.cpp @@ -87,6 +87,7 @@ TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[comb CombatSystem combat(cfg); std::vector events; combat.tick(0, ships, buildings, events); + combat.applyPendingDamage(5, ships, buildings); float hpAfter = 0.0f; for (const Ship& s : ships.allShips()) @@ -324,6 +325,227 @@ TEST_CASE("CombatSystem: player ship fires at enemy station in range", "[combat] REQUIRE(playerFiredAtStation); } +// --------------------------------------------------------------------------- +// Deferred damage timing +// --------------------------------------------------------------------------- + +TEST_CASE("CombatSystem: damage not applied before impact tick", "[combat]") +{ + const GameConfig cfg = loadConfig(); + std::mt19937 rng(42); + + const ShipDef* combatDef = findCombatShip(cfg); + REQUIRE(combatDef != nullptr); + + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + EntityId nextShipId = 1; + EntityId nextBldId = 100; + ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); + BuildingSystem buildings(cfg, belts, + [&nextBldId]() { return nextBldId++; }, + [](int){}, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + + ships.forEach([&](Ship& s) + { + if (s.id == enemyId && s.weapon) + { + s.weapon->currentTarget = playerId; + s.weapon->cooldownTicks = 0.0f; + if (s.threatResponse) + { + s.threatResponse->currentTarget = playerId; + } + } + }); + + float hpBefore = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hpBefore = s.hp; } + } + + CombatSystem combat(cfg); + std::vector events; + combat.tick(0, ships, buildings, events); + + // Ticks 1-4: damage must not have arrived yet. + for (Tick t = 1; t < 5; ++t) + { + combat.applyPendingDamage(t, ships, buildings); + float hp = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hp = s.hp; } + } + REQUIRE(hp == Approx(hpBefore)); + } +} + +TEST_CASE("CombatSystem: damage applied exactly at impact tick", "[combat]") +{ + const GameConfig cfg = loadConfig(); + std::mt19937 rng(42); + + const ShipDef* combatDef = findCombatShip(cfg); + REQUIRE(combatDef != nullptr); + + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + EntityId nextShipId = 1; + EntityId nextBldId = 100; + ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); + BuildingSystem buildings(cfg, belts, + [&nextBldId]() { return nextBldId++; }, + [](int){}, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + + ships.forEach([&](Ship& s) + { + if (s.id == enemyId && s.weapon) + { + s.weapon->currentTarget = playerId; + s.weapon->cooldownTicks = 0.0f; + if (s.threatResponse) + { + s.threatResponse->currentTarget = playerId; + } + } + }); + + float hpBefore = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hpBefore = s.hp; } + } + + CombatSystem combat(cfg); + std::vector events; + combat.tick(0, ships, buildings, events); + combat.applyPendingDamage(5, ships, buildings); + + float hpAfter = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hpAfter = s.hp; } + } + + REQUIRE(hpAfter < hpBefore); +} + +TEST_CASE("CombatSystem: damage silently dropped if target already dead", "[combat]") +{ + const GameConfig cfg = loadConfig(); + std::mt19937 rng(42); + + const ShipDef* combatDef = findCombatShip(cfg); + REQUIRE(combatDef != nullptr); + + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + EntityId nextShipId = 1; + EntityId nextBldId = 100; + ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); + BuildingSystem buildings(cfg, belts, + [&nextBldId]() { return nextBldId++; }, + [](int){}, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + + ships.forEach([&](Ship& s) + { + if (s.id == enemyId && s.weapon) + { + s.weapon->currentTarget = playerId; + s.weapon->cooldownTicks = 0.0f; + if (s.threatResponse) + { + s.threatResponse->currentTarget = playerId; + } + } + }); + + CombatSystem combat(cfg); + std::vector events; + combat.tick(0, ships, buildings, events); + + // Target is removed before impact. + ships.despawn(playerId); + + // Should not crash; damage is silently dropped. + combat.applyPendingDamage(5, ships, buildings); + + REQUIRE(ships.findShip(playerId) == nullptr); +} + +TEST_CASE("CombatSystem: damage still applied if shooter already dead", "[combat]") +{ + const GameConfig cfg = loadConfig(); + std::mt19937 rng(42); + + const ShipDef* combatDef = findCombatShip(cfg); + REQUIRE(combatDef != nullptr); + + BeltSystem belts(cfg.world.beltSpeedTilesPerSecond); + EntityId nextShipId = 1; + EntityId nextBldId = 100; + ShipSystem ships(cfg, [&nextShipId]() { return nextShipId++; }); + BuildingSystem buildings(cfg, belts, + [&nextBldId]() { return nextBldId++; }, + [](int){}, + [](const std::string&, QVector2D) {}, + rng); + + const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(5.0f, 5.0f), true); + const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(4.0f, 5.0f), false); + + ships.forEach([&](Ship& s) + { + if (s.id == enemyId && s.weapon) + { + s.weapon->currentTarget = playerId; + s.weapon->cooldownTicks = 0.0f; + if (s.threatResponse) + { + s.threatResponse->currentTarget = playerId; + } + } + }); + + float hpBefore = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hpBefore = s.hp; } + } + + CombatSystem combat(cfg); + std::vector events; + combat.tick(0, ships, buildings, events); + + // Shooter is removed before impact. + ships.despawn(enemyId); + + // Damage must still land on the target. + combat.applyPendingDamage(5, ships, buildings); + + float hpAfter = 0.0f; + for (const Ship& s : ships.allShips()) + { + if (s.id == playerId) { hpAfter = s.hp; } + } + + REQUIRE(hpAfter < hpBefore); +} + // --------------------------------------------------------------------------- // Deaths & loot (tick step 9) // ---------------------------------------------------------------------------