implement waves

This commit is contained in:
2026-04-20 14:10:01 +02:00
parent 65de4ddc5c
commit 498b97db20
17 changed files with 1798 additions and 18 deletions

View File

@@ -0,0 +1,351 @@
#include "catch.hpp"
#include <random>
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "CombatSystem.h"
#include "ConfigLoader.h"
#include "FireEvent.h"
#include "ScrapSystem.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// Find the first ShipDef with a combat component.
static const ShipDef* findCombatShip(const GameConfig& cfg)
{
for (const ShipDef& def : cfg.ships.ships)
{
if (def.combat)
{
return &def;
}
}
return nullptr;
}
// ---------------------------------------------------------------------------
// Ship weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: ship fires when cooldown=0 and target in range", "[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){},
rng);
// Spawn an enemy combat ship close to the player side.
const EntityId enemyId = ships.spawn(combatDef->id, 1,
QVector2D(5.0f, 5.0f), /*isEnemy=*/true);
// Spawn a player combat ship in front of the enemy.
const EntityId playerId = ships.spawn(combatDef->id, 1,
QVector2D(4.0f, 5.0f), /*isEnemy=*/false);
// Wire the enemy's weapon target manually.
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;
}
}
});
// Record player HP before combat.
float hpBefore = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpBefore = s.hp; }
}
CombatSystem combat(cfg);
std::vector<FireEvent> events;
combat.tick(0, ships, buildings, events);
float hpAfter = 0.0f;
for (const Ship& s : ships.allShips())
{
if (s.id == playerId) { hpAfter = s.hp; }
}
REQUIRE(hpAfter < hpBefore);
REQUIRE(events.size() >= 1);
}
TEST_CASE("CombatSystem: cooldown prevents firing before it expires", "[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){},
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);
// Set cooldown to 3 so it won't fire on tick 0 or 1 or 2.
ships.forEach([&](Ship& s)
{
if (s.id == enemyId && s.weapon)
{
s.weapon->currentTarget = playerId;
s.weapon->cooldownTicks = 3.0f;
if (s.threatResponse)
{
s.threatResponse->currentTarget = playerId;
}
}
});
CombatSystem combat(cfg);
std::vector<FireEvent> events;
// Ticks 0 and 1: cooldown still > 0 after decrement → no fire.
combat.tick(0, ships, buildings, events);
combat.tick(1, ships, buildings, events);
REQUIRE(events.empty());
// Tick 2: cooldown reaches 0 → fires.
combat.tick(2, ships, buildings, events);
REQUIRE(events.size() == 1);
}
TEST_CASE("CombatSystem: no fire when target is out of range", "[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){},
rng);
const EntityId enemyId = ships.spawn(combatDef->id, 1, QVector2D(0.0f, 0.0f), true);
const EntityId playerId = ships.spawn(combatDef->id, 1, QVector2D(500.0f, 0.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<FireEvent> events;
combat.tick(0, ships, buildings, events);
REQUIRE(events.empty());
}
// ---------------------------------------------------------------------------
// Station weapon firing
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: player station fires at enemy ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the player defence station.
EntityId stationId = kInvalidEntityId;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation)
{
stationId = b.id;
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
// Place an enemy ship close to the player station.
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.id == stationId)
{
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
// Find a combat ship blueprint for the enemy.
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId enemyId = sim.ships().spawn(
combatDef->id, 1,
QVector2D(stationCenter.x() + 1.0f, stationCenter.y()),
/*isEnemy=*/true);
// Tick to let station auto-acquire and fire.
sim.tick();
// Check that a fire event was emitted with stationId as shooter.
const std::vector<FireEvent> events = sim.drainFireEvents();
bool stationFired = false;
for (const FireEvent& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
TEST_CASE("CombatSystem: enemy station fires at player ship in range", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find the enemy defence station.
EntityId stationId = kInvalidEntityId;
QVector2D stationCenter;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
stationId = b.id;
stationCenter = QVector2D(
b.anchor.x() + b.footprint.width() / 2.0f,
b.anchor.y() + b.footprint.height() / 2.0f);
break;
}
}
REQUIRE(stationId != kInvalidEntityId);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
// Spawn a player ship right next to the enemy station.
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& e : events)
{
if (e.shooter == stationId) { stationFired = true; }
}
REQUIRE(stationFired);
}
// ---------------------------------------------------------------------------
// Deaths & loot (tick step 9)
// ---------------------------------------------------------------------------
TEST_CASE("CombatSystem: dead ship is removed after tick step 9", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
const ShipDef* combatDef = findCombatShip(cfg);
REQUIRE(combatDef != nullptr);
const EntityId shipId = sim.ships().spawn(combatDef->id, 1,
QVector2D(10.0f, 10.0f));
// Set hp to lethal.
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
REQUIRE(sim.ships().findShip(shipId) == nullptr);
}
TEST_CASE("CombatSystem: scrap is spawned on ship death", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Find a ship def that drops scrap.
const ShipDef* droppingDef = nullptr;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.loot.scrapDrop > 0)
{
droppingDef = &def;
break;
}
}
REQUIRE(droppingDef != nullptr);
const EntityId shipId = sim.ships().spawn(droppingDef->id, 1,
QVector2D(10.0f, 10.0f));
sim.ships().damageShip(shipId, 9999.0f);
sim.tick();
// At least one scrap entity should now exist.
REQUIRE(!sim.scraps().allScraps().empty());
}
TEST_CASE("CombatSystem: HQ death sets game over", "[combat]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::Hq)
{
b.hp = -1.0f;
}
});
sim.tick();
REQUIRE(sim.isGameOver());
}