356 lines
10 KiB
C++
356 lines
10 KiB
C++
#include "catch.hpp"
|
|
|
|
#include <random>
|
|
|
|
#include "Building.h"
|
|
#include "BuildingSystem.h"
|
|
#include "BuildingType.h"
|
|
#include "ConfigLoader.h"
|
|
#include "EcsComponents.h"
|
|
#include "EntityAdmin.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(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 Simulation sim(loadConfig(), 42);
|
|
|
|
// HQ is still a Building (for belt integration).
|
|
int hqCount = 0;
|
|
for (const Building& b : sim.buildings().allBuildings())
|
|
{
|
|
if (b.type == BuildingType::Hq) { ++hqCount; }
|
|
}
|
|
|
|
// Stations are ECS entities.
|
|
int playerCount = 0;
|
|
int enemyCount = 0;
|
|
sim.admin().forEach<StationBody, Faction>(
|
|
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
|
|
{
|
|
if (f.isEnemy) { ++enemyCount; }
|
|
else { ++playerCount; }
|
|
});
|
|
|
|
REQUIRE(hqCount == 1);
|
|
REQUIRE(playerCount == 2);
|
|
REQUIRE(enemyCount == 2);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: HQ has correct initial HP from config", "[wave]")
|
|
{
|
|
const Simulation sim(loadConfig(), 42);
|
|
|
|
const float expectedHp =
|
|
static_cast<float>(sim.config().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 Simulation sim(loadConfig(), 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 Simulation sim(loadConfig(), 42);
|
|
|
|
int armedPlayerStations = 0;
|
|
sim.admin().forEach<StationBody, Faction, StationWeapon>(
|
|
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
|
|
const StationWeapon& w)
|
|
{
|
|
if (!f.isEnemy)
|
|
{
|
|
++armedPlayerStations;
|
|
REQUIRE(w.damage > 0.0f);
|
|
REQUIRE(w.range > 0.0f);
|
|
REQUIRE(w.fireRateHz > 0.0f);
|
|
}
|
|
});
|
|
REQUIRE(armedPlayerStations == 2);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
|
|
{
|
|
const Simulation sim(loadConfig(), 42);
|
|
|
|
int armedEnemyStations = 0;
|
|
sim.admin().forEach<StationBody, Faction, StationWeapon>(
|
|
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f,
|
|
const StationWeapon& w)
|
|
{
|
|
if (f.isEnemy)
|
|
{
|
|
++armedEnemyStations;
|
|
REQUIRE(w.damage > 0.0f);
|
|
REQUIRE(w.range > 0.0f);
|
|
REQUIRE(w.fireRateHz > 0.0f);
|
|
}
|
|
});
|
|
REQUIRE(armedEnemyStations == 2);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wave spawning
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("WaveSystem: enemy ships spawn after the initial gap elapses", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 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;
|
|
sim.admin().forEach<ShipIdentity, Faction>(
|
|
[&](entt::entity /*e*/, const ShipIdentity& /*si*/, const Faction& f)
|
|
{
|
|
if (f.isEnemy) { foundEnemyShip = true; }
|
|
});
|
|
REQUIRE(foundEnemyShip);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: only eligible ships (cost > 0) appear in waves", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 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();
|
|
}
|
|
|
|
sim.admin().forEach<ShipIdentity, Faction>(
|
|
[&](entt::entity /*e*/, const ShipIdentity& si, const Faction& f)
|
|
{
|
|
if (!f.isEnemy) { return; }
|
|
// salvage_ship and repair_ship have cost_formula = "0" and must not spawn.
|
|
REQUIRE(si.schematicId != "salvage_ship");
|
|
REQUIRE(si.schematicId != "repair_ship");
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Push
|
|
// ---------------------------------------------------------------------------
|
|
|
|
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
// Damage both enemy stations to 0.
|
|
sim.admin().forEach<StationBody, Faction, Health>(
|
|
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
|
{
|
|
if (f.isEnemy) { h.hp = -1.0f; }
|
|
});
|
|
|
|
sim.tick();
|
|
|
|
// After push: should have 2 new enemy stations.
|
|
int enemyCount = 0;
|
|
sim.admin().forEach<StationBody, Faction>(
|
|
[&](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f)
|
|
{
|
|
if (f.isEnemy) { ++enemyCount; }
|
|
});
|
|
REQUIRE(enemyCount == 2);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: push emits exactly one SchematicDropEvent", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
sim.admin().forEach<StationBody, Faction, Health>(
|
|
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
|
{
|
|
if (f.isEnemy) { h.hp = -1.0f; }
|
|
});
|
|
|
|
sim.tick();
|
|
|
|
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
|
|
REQUIRE(events.size() == 1);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: push schematic drop awards a known ship id", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
sim.admin().forEach<StationBody, Faction, Health>(
|
|
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
|
{
|
|
if (f.isEnemy) { h.hp = -1.0f; }
|
|
});
|
|
|
|
sim.tick();
|
|
const std::vector<SchematicDropEvent> events = sim.drainSchematicDropEvents();
|
|
REQUIRE(events.size() == 1);
|
|
|
|
bool validId = false;
|
|
for (const ShipDef& def : sim.config().ships.ships)
|
|
{
|
|
if (def.id == events[0].schematicId)
|
|
{
|
|
validId = true;
|
|
break;
|
|
}
|
|
}
|
|
REQUIRE(validId);
|
|
REQUIRE(events[0].newLevel >= 1);
|
|
}
|
|
|
|
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
|
|
{
|
|
Simulation sim(loadConfig(), 42);
|
|
|
|
// Record the X position of the initial enemy stations.
|
|
int initialX = std::numeric_limits<int>::min();
|
|
sim.admin().forEach<StationBody, Faction>(
|
|
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
|
|
{
|
|
if (f.isEnemy && sb.anchor.x() > initialX)
|
|
{
|
|
initialX = sb.anchor.x();
|
|
}
|
|
});
|
|
|
|
sim.admin().forEach<StationBody, Faction, Health>(
|
|
[](entt::entity /*e*/, const StationBody& /*sb*/, const Faction& f, Health& h)
|
|
{
|
|
if (f.isEnemy) { h.hp = -1.0f; }
|
|
});
|
|
sim.tick();
|
|
|
|
int newX = std::numeric_limits<int>::min();
|
|
sim.admin().forEach<StationBody, Faction>(
|
|
[&](entt::entity /*e*/, const StationBody& sb, const Faction& f)
|
|
{
|
|
if (f.isEnemy && sb.anchor.x() > newX)
|
|
{
|
|
newX = sb.anchor.x();
|
|
}
|
|
});
|
|
|
|
REQUIRE(newX > initialX);
|
|
}
|