Files
dota_factory/src/test/CombatSystemTest.cpp

412 lines
13 KiB
C++

#include "catch.hpp"
#include <random>
#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 "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.combat)
{
return &def;
}
}
return nullptr;
}
// 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.beltSpeedTilesPerSecond)
, ships(cfg, admin)
, buildings(cfg, belts,
[this]() { return nextBuildingId++; },
[](int){},
[](const std::string&, QVector2D, const std::optional<ShipLayoutConfig>&) {},
rng)
, combat(cfg)
{
}
void wireEnemyTarget(entt::entity enemy, entt::entity playerTarget)
{
if (admin.hasAll<WeaponComponent>(enemy))
{
admin.get<WeaponComponent>(enemy).currentTarget = playerTarget;
admin.get<WeaponComponent>(enemy).cooldownTicks = 0.0f;
}
if (admin.hasAll<ThreatResponseBehaviorComponent>(enemy))
{
admin.get<ThreatResponseBehaviorComponent>(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<HealthComponent>(player).hp;
std::vector<FireEvent> events;
f.combat.tick(0, f.admin, f.buildings, 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, 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);
f.admin.get<WeaponComponent>(enemy).cooldownTicks = 3.0f; // override to 3
std::vector<FireEvent> events;
f.combat.tick(0, f.admin, f.buildings, events);
f.combat.tick(1, f.admin, f.buildings, events);
REQUIRE(events.empty());
f.combat.tick(2, f.admin, f.buildings, events);
REQUIRE(events.size() == 1);
}
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<FireEvent> 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<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.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<FireEvent> 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<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.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<FireEvent> 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<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.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<FireEvent> 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<HealthComponent>(player).hp;
std::vector<FireEvent> 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<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, 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<HealthComponent>(player).hp;
std::vector<FireEvent> events;
f.combat.tick(0, f.admin, f.buildings, 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, 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<FireEvent> 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<HealthComponent>(player).hp;
std::vector<FireEvent> events;
f.combat.tick(0, f.admin, f.buildings, 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(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<HealthComponent>(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<HealthComponent>(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<HqProxyComponent, HealthComponent>(
[](entt::entity /*e*/, const HqProxyComponent& /*hq*/, HealthComponent& h)
{
h.hp = -1.0f;
});
sim.tick();
REQUIRE(sim.isGameOver());
}