Files
dota_factory/src/test/WaveSystemTest.cpp
Malte Langkabel df7f60c898 migrate every factory query off BuildingSystem onto the free functions
The eleven forwarding members added last commit are gone; callers now read the
data directly through FactoryQueries.h. Simulation and ArenaSimulation expose
getFactoryState() so the UI, the balancing view and the tests can reach it.

isQueuedForDeconstruction joined the free functions along the way — it only
reaches findBuilding, so it was state-pure too.

No facade was introduced. The chained form was the reason one looked attractive,
but rewriting sim.getBuildings().findBuilding(id) to findBuilding(sim.getFactoryState(), id)
turned out to be mechanical, and the result says which data is read rather than
which system happens to own it.

BuildingSystem.cpp is down to 1735 lines and no longer answers questions about
the factory — it only changes it. What remains on it are the mutators, the tick
phases, and the queries that also need GameConfig.

Verified with a golden-checksum capture before and after — all four sample ticks
identical.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YHcUerKAZKWNvSKJxYKbnG
2026-08-04 21:52:49 +02:00

407 lines
14 KiB
C++

#include "catch.hpp"
#include "FactoryQueries.h"
#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 "SimulationTestAccess.h"
#include "Tick.h"
#include "ThreatCostCalculator.h"
#include "WaveSystem.h"
#include "TestConfig.h"
// ---------------------------------------------------------------------------
// Threat accumulation
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: threat accumulates at boss wave counter rate", "[wave]")
{
const GameConfig cfg = loadTestConfig();
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.getThreatLevel() == Approx(1.0));
}
TEST_CASE("WaveSystem: threatAccumulationRate matches the rate formula outside quiet windows",
"[wave]")
{
const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
// threat_rate_formula = "x", boss wave counter starts at 1 → rate = 1 threat/s.
REQUIRE(ws.getThreatAccumulationRate() == Approx(1.0));
}
TEST_CASE("WaveSystem: threatAccumulationRate is 0 during a quiet window", "[wave]")
{
GameConfig cfg = loadTestConfig();
// Start with the boss countdown already at the pre-boss quiet threshold.
cfg.world.waves.bossCountdownSeconds = cfg.world.waves.bossQuietBeforeSeconds;
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.getThreatAccumulationRate() == Approx(0.0));
const double before = ws.getThreatLevel();
for (int i = 0; i < static_cast<int>(secondsToTicks(1.0)); ++i)
{
ws.tickThreatAccumulation();
}
REQUIRE(ws.getThreatLevel() == Approx(before));
}
TEST_CASE("WaveSystem: generation starts at 0 and increments on station destruction", "[wave]")
{
const GameConfig cfg = loadTestConfig();
std::mt19937 rng(42);
WaveSystem ws(cfg, rng);
REQUIRE(ws.getGeneration() == 0);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.getGeneration() == 1);
ws.onEnemyStationsDestroyed();
REQUIRE(ws.getGeneration() == 2);
}
// ---------------------------------------------------------------------------
// Pre-placed structures
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: Simulation pre-places HQ + 2 player + 2 enemy stations", "[wave]")
{
const Simulation sim(loadTestConfig(), 42);
// HQ is still a Building (for belt integration).
int hqCount = 0;
for (const Building& b : getAllBuildings(sim.getFactoryState()))
{
if (b.type == BuildingType::Hq) { ++hqCount; }
}
// Stations are ECS entities.
int playerCount = 0;
int enemyCount = 0;
sim.getAdmin().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(loadTestConfig(), 42);
const float expectedHp =
static_cast<float>(sim.getConfig().stations.hq.hpFormula.evaluate(0.0));
bool found = false;
float actualHp = 0.0f;
sim.getAdmin().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(loadTestConfig(), 42);
for (const Building& b : getAllBuildings(sim.getFactoryState()))
{
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(loadTestConfig(), 42);
int armedPlayerStations = 0;
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().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(loadTestConfig(), 42);
int armedEnemyStations = 0;
sim.getAdmin().forEach<WeaponComponent, ModuleOwnerComponent>(
[&](entt::entity /*e*/, const WeaponComponent& w, const ModuleOwnerComponent& mo)
{
if (!sim.getAdmin().hasAll<StationBodyComponent>(mo.owner)) { return; }
const FactionComponent& f = sim.getAdmin().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(loadTestConfig(), 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.getAdmin().forEach<ShipIdentityComponent, FactionComponent>(
[&](entt::entity /*e*/, const ShipIdentityComponent& /*si*/,
const FactionComponent& f)
{
if (f.isEnemy) { foundEnemyShip = true; }
});
}
}
REQUIRE(foundEnemyShip);
}
TEST_CASE("WaveSystem: all ships have positive dynamic threat cost", "[wave]")
{
const GameConfig cfg = loadTestConfig();
for (const ShipDef& def : cfg.ships.ships)
{
const double cost = calculateShipThreatCost(cfg.threatCosts, cfg,
def.id, def.defaultModules);
CHECK(cost > 0.0);
}
}
// ---------------------------------------------------------------------------
// Push
// ---------------------------------------------------------------------------
TEST_CASE("WaveSystem: destroying both enemy stations triggers a push", "[wave]")
{
Simulation sim(loadTestConfig(), 42);
// Damage both enemy stations to 0.
sim.getAdmin().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.getAdmin().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(loadTestConfig(), 42);
sim.getAdmin().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(loadTestConfig(), 42);
sim.getAdmin().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)
{
if (opt.isArtifact) { continue; }
bool validGroup = false;
for (const UnlockGroupDef& group : sim.getConfig().unlocks.groups)
{
if (group.id == opt.unlockGroupId) { validGroup = true; break; }
}
REQUIRE(validGroup);
// Every granted item must resolve to a defined ship/module/building/recipe.
for (const GrantedSchematic& grant : opt.grantedItems)
{
bool validId = false;
for (const ShipDef& def : sim.getConfig().ships.ships)
{
if (def.id == grant.id) { validId = true; break; }
}
for (const ModuleDef& def : sim.getConfig().modules.modules)
{
if (def.id == grant.id) { validId = true; break; }
}
for (const BuildingDef& def : sim.getConfig().buildings.buildings)
{
if (def.id == grant.id) { validId = true; break; }
}
for (const RecipeDef& def : sim.getConfig().recipes.recipes)
{
if (def.id == grant.id) { validId = true; break; }
}
REQUIRE(validId);
}
}
}
TEST_CASE("WaveSystem: schematic choices have no duplicates", "[wave]")
{
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().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.isArtifact ? std::string("<artifact>") : opt.unlockGroupId);
}
REQUIRE(ids.size() == choices.size());
}
TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]")
{
Simulation sim(loadTestConfig(), 42);
sim.getAdmin().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());
SimulationTestAccess::applySchematicChoice(sim, 0);
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
}
TEST_CASE("WaveSystem: push places new enemy stations further right", "[wave]")
{
Simulation sim(loadTestConfig(), 42);
// Record the X position of the initial enemy stations.
int initialX = std::numeric_limits<int>::min();
sim.getAdmin().forEach<StationBodyComponent, FactionComponent>(
[&](entt::entity /*e*/, const StationBodyComponent& sb, const FactionComponent& f)
{
if (f.isEnemy && sb.anchor.x() > initialX)
{
initialX = sb.anchor.x();
}
});
sim.getAdmin().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.getAdmin().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);
}