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

357
src/test/WaveSystemTest.cpp Normal file
View File

@@ -0,0 +1,357 @@
#include "catch.hpp"
#include <random>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "Rotation.h"
#include "Ship.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "Tick.h"
#include "WaveSystem.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat stays 0 for first 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "1*x - 30", which is <= 0 for x <= 30.
const int ticks30s = static_cast<int>(secondsToTicks(30.0));
for (int i = 0; i < ticks30s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() == Approx(0.0));
}
TEST_CASE("WaveSystem: threat accumulates after 30 game-seconds", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Run 31 seconds worth of ticks.
const int ticks31s = static_cast<int>(secondsToTicks(31.0));
for (int i = 0; i < ticks31s; ++i)
{
ws.tickThreatAccumulation(static_cast<Tick>(i));
}
REQUIRE(ws.threatLevel() > 0.0);
}
TEST_CASE("WaveSystem: applyPush increases threat accumulation rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// Accumulate for 1 tick past the 30s mark to get a baseline rate.
const Tick baseTick = secondsToTicks(31.0);
ws.tickThreatAccumulation(baseTick);
const double levelBefore = ws.threatLevel();
// Apply push: multiplier should increase.
ws.applyPush();
WaveSystem ws2(cfg, rng);
ws2.tickThreatAccumulation(baseTick);
// After the push the same tick adds more threat.
ws.tickThreatAccumulation(baseTick + 1);
ws2.tickThreatAccumulation(baseTick + 1);
// ws has the push multiplier applied; ws2 does not.
REQUIRE(ws.threatLevel() > ws2.threatLevel());
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on push", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
ws.applyPush();
REQUIRE(ws.generation() == 1);
ws.applyPush();
REQUIRE(ws.generation() == 2);
}
// ---------------------------------------------------------------------------
// Pre-placed structures
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int hqCount = 0;
int playerCount = 0;
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq) { ++hqCount; }
else if (b.type == BuildingType::PlayerDefenceStation) { ++playerCount; }
else if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(hqCount == 1);
REQUIRE(playerCount == 2);
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
const float expectedHp =
static_cast<float>(cfg.stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::Hq)
{
found = true;
actualHp = b.hp;
break;
}
}
REQUIRE(found);
REQUIRE(actualHp == Approx(expectedHp));
}
TEST_CASE("WaveSystem: HQ anchor is at asteroid right edge", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type != BuildingType::Hq) { continue; }
// Rightmost body cell must be at x = -1 (asteroid right edge).
int maxX = std::numeric_limits<int>::min();
for (const QPoint& cell : b.bodyCells)
{
if (cell.x() > maxX) { maxX = cell.x(); }
}
REQUIRE(maxX == -1);
}
}
TEST_CASE("WaveSystem: player stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedPlayerStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::PlayerDefenceStation && b.weapon)
{
++armedPlayerStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedPlayerStations == 2);
}
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
{
const GameConfig cfg = loadConfig();
const Simulation sim(cfg, 42);
int armedEnemyStations = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation && b.weapon)
{
++armedEnemyStations;
REQUIRE(b.weapon->damage > 0.0f);
REQUIRE(b.weapon->range > 0.0f);
REQUIRE(b.weapon->fireRateHz > 0.0f);
}
}
REQUIRE(armedEnemyStations == 2);
}
// ---------------------------------------------------------------------------
// Wave spawning
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// The maximum gap is gapMaxSeconds = 45s → 1350 ticks.
// Run 1500 ticks to guarantee at least one wave has triggered.
const int limit = static_cast<int>(secondsToTicks(50.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
bool foundEnemyShip = false;
for (const Ship& s : sim.ships().allShips())
{
if (s.isEnemy)
{
foundEnemyShip = true;
break;
}
}
REQUIRE(foundEnemyShip);
}
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Run long enough for several waves.
const int limit = static_cast<int>(secondsToTicks(120.0));
for (int i = 0; i < limit; ++i)
{
sim.tick();
}
for (const Ship& s : sim.ships().allShips())
{
if (!s.isEnemy) { continue; }
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
REQUIRE(s.blueprintId != "salvage_ship");
REQUIRE(s.blueprintId != "repair_ship");
}
}
// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Damage both enemy stations to 0.
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
// After push: should have 2 new enemy stations.
int enemyCount = 0;
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation) { ++enemyCount; }
}
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: push emits exactly one BlueprintDropEvent", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
}
TEST_CASE("WaveSystem: push blueprint drop awards a known ship id", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation)
{
b.hp = -1.0f;
}
});
sim.tick();
const std::vector<BlueprintDropEvent> events = sim.drainBlueprintDropEvents();
REQUIRE(events.size() == 1);
bool validId = false;
for (const ShipDef& def : cfg.ships.ships)
{
if (def.id == events[0].blueprintId)
{
validId = true;
break;
}
}
REQUIRE(validId);
REQUIRE(events[0].newLevel >= 1);
}
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
{
const GameConfig cfg = loadConfig();
Simulation sim(cfg, 42);
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > initialX) { initialX = b.anchor.x(); }
}
}
sim.buildings().forEachBuilding([](Building& b)
{
if (b.type == BuildingType::EnemyDefenceStation) { b.hp = -1.0f; }
});
sim.tick();
int newX = std::numeric_limits<int>::min();
for (const Building& b : sim.buildings().allBuildings())
{
if (b.type == BuildingType::EnemyDefenceStation)
{
if (b.anchor.x() > newX) { newX = b.anchor.x(); }
}
}
REQUIRE(newX > initialX);
}