Files
dota_factory/src/test/WaveSystemTest.cpp
2026-06-13 14:19:51 +02:00

379 lines
12 KiB
C++

#include "catch.hpp"
#include <random>
#include "Building.h"
#include "BuildingSystem.h"
#include "BuildingType.h"
#include "ConfigLoader.h"
#include "EntityAdmin.h"
#include "FactionComponent.h"
#include "HealthComponent.h"
#include "HqProxyComponent.h"
#include "ModuleOwnerComponent.h"
#include "Rotation.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "StationBodyComponent.h"
#include "WeaponComponent.h"
#include "ModulesConfig.h"
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "Tick.h"
#include "WaveSystem.h"
static GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
// ---------------------------------------------------------------------------
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
// After 1 second: threat ≈ 1.0.
const int ticks1s = static_cast<int>(secondsToTicks(1.0));
for (int i = 0; i < ticks1s; ++i)
{
ws.tickThreatAccumulation();
}
REQUIRE(ws.threatLevel() == Approx(1.0));
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
{
const GameConfig cfg = loadConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.generation() == 0);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.generation() == 1);
ws.onEnemyStationsDestroyed();
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<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& 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;
sim.admin().forEach<HqProxyComponent, HealthComponent>(
[&](entt::entity /*e*/, const HqProxyComponent& /*hq*/, const HealthComponent& h)
{
found = true;
actualHp = h.hp;
});
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]")
{
Simulation sim(loadConfig(), 42);
int armedPlayerStations = 0;
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().get<FactionComponent>(mo.owner);
if (!f.isEnemy)
{
++armedPlayerStations;
REQUIRE(w.damage > 0.0f);
REQUIRE(w.range_tiles > 0.0f);
REQUIRE(w.fireRateHz > 0.0f);
}
});
REQUIRE(armedPlayerStations == 2);
}
TEST_CASE("WaveSystem: enemy stations have weapon set", "[wave]")
{
Simulation sim(loadConfig(), 42);
int armedEnemyStations = 0;
sim.admin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.admin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.admin().get<FactionComponent>(mo.owner);
if (f.isEnemy)
{
++armedEnemyStations;
REQUIRE(w.damage > 0.0f);
REQUIRE(w.range_tiles > 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.
// Check each tick: enemy ships may be killed quickly by player stations,
// so we must detect them while they are alive, not only after the loop.
const int limit = static_cast<int>(secondsToTicks(50.0));
bool foundEnemyShip = false;
for (int i = 0; i < limit; ++i)
{
sim.tick();
if (!foundEnemyShip)
{
sim.admin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& 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<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& si, const FactionComponent& 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<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
// After push: should have 2 new enemy stations.
int enemyCount = 0;
sim.admin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f)
{
if (f.isEnemy) { ++enemyCount; }
});
REQUIRE(enemyCount == 2);
}
TEST_CASE("WaveSystem: push generates pending schematic choices", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
REQUIRE(sim.hasSchematicChoicesPending());
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
REQUIRE(choices.size() >= 1);
REQUIRE(choices.size() <= 3);
}
TEST_CASE("WaveSystem: push schematic choices have valid ids", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
REQUIRE_FALSE(choices.empty());
for (const SchematicChoiceOption& opt : choices)
{
bool validId = false;
for (const ShipDef& def : sim.config().ships.ships)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
if (!validId)
{
for (const ModuleDef& def : sim.config().modules.modules)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
if (!validId)
{
for (const RecipeDef& def : sim.config().recipes.recipes)
{
if (def.id == opt.schematicId) { validId = true; break; }
}
}
REQUIRE(validId);
}
}
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
const std::vector<SchematicChoiceOption>& choices = sim.getPendingSchematicChoices();
std::set<std::string> ids;
for (const SchematicChoiceOption& opt : choices)
{
ids.insert(opt.schematicId);
}
REQUIRE(ids.size() == choices.size());
}
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
{
Simulation sim(loadConfig(), 42);
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
REQUIRE(sim.hasSchematicChoicesPending());
sim.applySchematicChoice(0);
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
}
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<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > initialX)
{
initialX = sb.anchor.x();
}
});
sim.admin().forEach<StationBodyComponent, FactionComponent, HealthComponent>(
[](entt::entity /*e*/, const StationBodyComponent& /*sb*/, const FactionComponent& f, HealthComponent& h)
{
if (f.isEnemy) { h.hp = -1.0f; }
});
sim.tick();
int newX = std::numeric_limits<int>::min();
sim.admin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > newX)
{
newX = sb.anchor.x();
}
});
REQUIRE(newX > initialX);
}