438 lines
14 KiB
C++
438 lines
14 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <random>
|
|
|
|
#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<WeaponComponent, ModuleOwnerComponent>(
|
|
[&](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<ShipLayoutConfig>&) {},
|
|
[](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<WeaponComponent>(wc).currentTarget = playerTarget;
|
|
admin.get<WeaponComponent>(wc).cooldownTicks = 0.0f;
|
|
}
|
|
if (admin.hasAll<AttackBehavior>(enemy))
|
|
{
|
|
admin.get<AttackBehavior>(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<HealthComponent>(player).hp;
|
|
|
|
std::vector<BeamFiredEvent> events;
|
|
f.combat.tick(0, f.admin, events);
|
|
f.combat.applyPendingDamage(5, f.admin);
|
|
|
|
REQUIRE(f.admin.get<HealthComponent>(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<WeaponComponent>(wc).cooldownTicks = 3.0f; // override to 3
|
|
}
|
|
|
|
auto enemyFiredIn = [&enemy](const std::vector<BeamFiredEvent>& evts)
|
|
{
|
|
for (const BeamFiredEvent& evt : evts)
|
|
{
|
|
if (evt.shooter == enemy) { return true; }
|
|
}
|
|
return false;
|
|
};
|
|
|
|
std::vector<BeamFiredEvent> 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<BeamFiredEvent> 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<StationBodyComponent, FactionComponent>(
|
|
[&](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<BeamFiredEvent> 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<StationBodyComponent, FactionComponent>(
|
|
[&](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<BeamFiredEvent> 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<StationBodyComponent, FactionComponent>(
|
|
[&](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<BeamFiredEvent> 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<HealthComponent>(player).hp;
|
|
|
|
std::vector<BeamFiredEvent> 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<HealthComponent>(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<HealthComponent>(player).hp;
|
|
|
|
std::vector<BeamFiredEvent> events;
|
|
f.combat.tick(0, f.admin, events);
|
|
f.combat.applyPendingDamage(5, f.admin);
|
|
|
|
REQUIRE(f.admin.get<HealthComponent>(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<BeamFiredEvent> 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<HealthComponent>(player).hp;
|
|
|
|
std::vector<BeamFiredEvent> events;
|
|
f.combat.tick(0, f.admin, events);
|
|
|
|
f.ships.despawn(enemy);
|
|
|
|
f.combat.applyPendingDamage(5, f.admin);
|
|
|
|
REQUIRE(f.admin.get<HealthComponent>(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<HealthComponent>(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<HealthComponent>(ship).hp = -1.0f;
|
|
|
|
sim.tick();
|
|
|
|
const std::vector<DebrisInfo> scraps = getAllDebrisInfo(sim.getAdmin());
|
|
REQUIRE(scraps.size() == 1);
|
|
CHECK(sim.getAdmin().get<DebrisComponent>(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<HqProxyComponent, HealthComponent>(
|
|
[](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h)
|
|
{
|
|
h.hp = -1.0f;
|
|
});
|
|
|
|
sim.tick();
|
|
|
|
REQUIRE(sim.isGameOver());
|
|
}
|