Replay: deterministic record & playback (#4)
Add deterministic record/playback for a run. Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause. Reviewed-on: #4 Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com> Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "Tick.h"
|
||||
|
||||
@@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
|
||||
const QPoint offsetA(-1, 0);
|
||||
const QPoint offsetB( 1, 0);
|
||||
|
||||
const BuildingId idA = sim.tryPlaceBuilding(
|
||||
const BuildingId idA = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
||||
const BuildingId idB = sim.tryPlaceBuilding(
|
||||
const BuildingId idB = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
||||
|
||||
REQUIRE(idA != kInvalidBuildingId);
|
||||
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
|
||||
const int startBlocks = sim.buildingBlocksStock();
|
||||
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
|
||||
}
|
||||
|
||||
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
|
||||
int col = -2;
|
||||
while (sim.buildingBlocksStock() >= minerCost)
|
||||
{
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
col -= 2;
|
||||
}
|
||||
|
||||
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
||||
const BuildingId id = sim.tryPlaceBuilding(
|
||||
const BuildingId id = SimulationTestAccess::place(sim,
|
||||
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
||||
|
||||
// Placement must fail and leave the stock unchanged.
|
||||
@@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
|
||||
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
|
||||
// rule, so it must be rejected without consuming building blocks.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id == kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks);
|
||||
@@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
|
||||
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
|
||||
// (-3,0),(-2,0),(-3,1); a valid spot.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
|
||||
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
sim.buildings().setRecipe(id, "mine_iron_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
sim.buildings().setRecipe(id, "mine_copper_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
|
||||
|
||||
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
|
||||
// Run 301 ticks (0..300) to process the completion tick.
|
||||
@@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
|
||||
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
|
||||
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
pm.rotation = Rotation::North;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
// Shipyard construction_time_seconds = 30 in the test config.
|
||||
double constructionTime = 0.0;
|
||||
|
||||
@@ -21,4 +21,8 @@ add_files(
|
||||
ShipModuleTest.cpp
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
ReplayRecorderTest.cpp
|
||||
ReplayPlaybackTest.cpp
|
||||
)
|
||||
|
||||
106
src/test/CommandTest.cpp
Normal file
106
src/test/CommandTest.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandManager.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// The command chokepoint (Simulation::apply) must produce exactly the same state
|
||||
// as driving the underlying mutators directly — that equivalence is what lets a
|
||||
// recorded command stream reproduce a live run (see docs/replay_design.md).
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-3, 0);
|
||||
command.rotation = Rotation::East;
|
||||
viaCommand.apply(command);
|
||||
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-2, 0);
|
||||
command.rotation = Rotation::East;
|
||||
command.recipeId = "mine_iron_ore";
|
||||
viaCommand.apply(command);
|
||||
|
||||
const BuildingId id =
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
|
||||
{
|
||||
Simulation viaCommand(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
const BuildingId idA =
|
||||
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId idB =
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(idA == idB);
|
||||
|
||||
DemolishCommand command;
|
||||
command.id = idA;
|
||||
viaCommand.apply(command);
|
||||
|
||||
SimulationTestAccess::demolish(viaDirect, idB);
|
||||
|
||||
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
|
||||
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
|
||||
{
|
||||
Simulation viaManager(loadConfig(), 99);
|
||||
Simulation viaDirect(loadConfig(), 99);
|
||||
|
||||
CommandManager manager(viaManager);
|
||||
|
||||
std::shared_ptr<PlaceBuildingCommand> first = std::make_shared<PlaceBuildingCommand>();
|
||||
first->type = BuildingType::Miner;
|
||||
first->anchor = QPoint(-3, 0);
|
||||
std::shared_ptr<PlaceBuildingCommand> second = std::make_shared<PlaceBuildingCommand>();
|
||||
second->type = BuildingType::Belt;
|
||||
second->anchor = QPoint(-2, 0);
|
||||
|
||||
manager.enqueue(first);
|
||||
manager.enqueue(second);
|
||||
REQUIRE(manager.hasPending());
|
||||
|
||||
manager.drain();
|
||||
REQUIRE_FALSE(manager.hasPending());
|
||||
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(viaDirect, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
|
||||
|
||||
REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum());
|
||||
}
|
||||
158
src/test/DeterminismTest.cpp
Normal file
158
src/test/DeterminismTest.cpp
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StateChecksum.h"
|
||||
#include "Tick.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
constexpr int kScriptTicks = 2000;
|
||||
|
||||
// Runs a fixed scripted session and returns the full-state checksum after every
|
||||
// tick. The script places a small factory, demolishes part of it mid-run, and
|
||||
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
|
||||
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
|
||||
{
|
||||
Simulation sim(loadConfig(), seed);
|
||||
|
||||
// Tick 0: a miner feeding a short belt line on the asteroid.
|
||||
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
|
||||
|
||||
std::vector<std::uint64_t> checksums;
|
||||
checksums.reserve(kScriptTicks);
|
||||
|
||||
for (int t = 0; t < kScriptTicks; ++t)
|
||||
{
|
||||
if (t == 500)
|
||||
{
|
||||
// Demolish the second belt mid-run to exercise the removal paths.
|
||||
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
|
||||
}
|
||||
|
||||
sim.tick();
|
||||
checksums.push_back(sim.computeStateChecksum());
|
||||
}
|
||||
|
||||
return checksums;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hasher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(42);
|
||||
a.append(3.5f);
|
||||
a.append(std::string("ore"));
|
||||
b.append(42);
|
||||
b.append(3.5f);
|
||||
b.append(std::string("ore"));
|
||||
|
||||
REQUIRE(a.value() == b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(42);
|
||||
b.append(43);
|
||||
|
||||
REQUIRE(a.value() != b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(std::string("ab"));
|
||||
a.append(std::string("c"));
|
||||
b.append(std::string("a"));
|
||||
b.append(std::string("bc"));
|
||||
|
||||
REQUIRE(a.value() != b.value());
|
||||
}
|
||||
|
||||
TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
|
||||
{
|
||||
Hasher a;
|
||||
Hasher b;
|
||||
a.append(-0.0f);
|
||||
b.append(0.0f);
|
||||
|
||||
REQUIRE(a.value() == b.value());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RNG fingerprint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]")
|
||||
{
|
||||
std::mt19937 a(12345);
|
||||
std::mt19937 b(12345);
|
||||
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||
|
||||
a(); // advance one draw
|
||||
REQUIRE(fingerprintRng(a) != fingerprintRng(b));
|
||||
|
||||
b(); // advance b to the same point
|
||||
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
|
||||
{
|
||||
const Simulation a(loadConfig(), 777);
|
||||
const Simulation b(loadConfig(), 777);
|
||||
|
||||
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Double-run determinism
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state",
|
||||
"[determinism]")
|
||||
{
|
||||
const std::vector<std::uint64_t> first = runScriptedSession(424242);
|
||||
const std::vector<std::uint64_t> second = runScriptedSession(424242);
|
||||
|
||||
REQUIRE(first.size() == second.size());
|
||||
REQUIRE(first.size() == static_cast<std::size_t>(kScriptTicks));
|
||||
|
||||
for (std::size_t i = 0; i < first.size(); ++i)
|
||||
{
|
||||
INFO("divergence at tick " << i);
|
||||
REQUIRE(first[i] == second[i]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]")
|
||||
{
|
||||
const std::vector<std::uint64_t> a = runScriptedSession(111);
|
||||
const std::vector<std::uint64_t> b = runScriptedSession(222);
|
||||
|
||||
// The two sessions must differ at some point (the checksum is sensitive to
|
||||
// the RNG-driven divergence; a constant checksum would be a broken hash).
|
||||
REQUIRE(a != b);
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "RecipesConfig.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim)
|
||||
killEnemyStations(sim);
|
||||
if (sim.hasSchematicChoicesPending())
|
||||
{
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
||||
break;
|
||||
}
|
||||
}
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
CHECK(foundRecipeChoice);
|
||||
@@ -308,7 +309,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
|
||||
}
|
||||
}
|
||||
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +341,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
|
||||
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
|
||||
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
|
||||
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
|
||||
std::set<std::string> expectedNames;
|
||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||
|
||||
292
src/test/ReplayPlaybackTest.cpp
Normal file
292
src/test/ReplayPlaybackTest.cpp
Normal file
@@ -0,0 +1,292 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandManager.h"
|
||||
#include "CommandSerializer.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "ReplayPlayer.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
GameConfig loadConfig()
|
||||
{
|
||||
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
}
|
||||
|
||||
std::string tempOutputDir()
|
||||
{
|
||||
return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString();
|
||||
}
|
||||
|
||||
std::shared_ptr<PlaceBuildingCommand> place(BuildingType type, QPoint anchor)
|
||||
{
|
||||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||
command->type = type;
|
||||
command->anchor = anchor;
|
||||
return command;
|
||||
}
|
||||
|
||||
void requireRoundTrip(const Command& command)
|
||||
{
|
||||
const std::string text = serializeCommand(command);
|
||||
const std::shared_ptr<Command> parsed = parseCommand(text);
|
||||
REQUIRE(parsed != nullptr);
|
||||
REQUIRE(serializeCommand(*parsed) == text);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("parseCommand inverts serializeCommand", "[replay]")
|
||||
{
|
||||
PlaceBuildingCommand placeCommand;
|
||||
placeCommand.type = BuildingType::Shipyard;
|
||||
placeCommand.anchor = QPoint(-3, 2);
|
||||
placeCommand.rotation = Rotation::West;
|
||||
placeCommand.recipeId = "some_ship";
|
||||
ShipLayoutConfig layout;
|
||||
layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 0), Rotation::North});
|
||||
placeCommand.shipLayout = layout;
|
||||
|
||||
const std::string text = serializeCommand(placeCommand);
|
||||
const std::shared_ptr<Command> parsed = parseCommand(text);
|
||||
REQUIRE(parsed != nullptr);
|
||||
REQUIRE(serializeCommand(*parsed) == text);
|
||||
}
|
||||
|
||||
TEST_CASE("parseCommand round-trips every command verb", "[replay]")
|
||||
{
|
||||
SetSplitterFiltersCommand filters;
|
||||
filters.tile = QPoint(3, 9);
|
||||
filters.filterA = { ItemType{"iron_ore"} };
|
||||
filters.filterB = { ItemType{"coal"}, ItemType{"copper_ore"} };
|
||||
|
||||
ClearBeltTilesCommand clear;
|
||||
clear.tiles = { QPoint(0, 0), QPoint(-1, 4) };
|
||||
|
||||
DemolishCommand demolish;
|
||||
demolish.id = 5;
|
||||
|
||||
for (const Command* command : { static_cast<const Command*>(&filters),
|
||||
static_cast<const Command*>(&clear),
|
||||
static_cast<const Command*>(&demolish) })
|
||||
{
|
||||
const std::string text = serializeCommand(*command);
|
||||
const std::shared_ptr<Command> parsed = parseCommand(text);
|
||||
REQUIRE(parsed != nullptr);
|
||||
REQUIRE(serializeCommand(*parsed) == text);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("parseCommand rejects malformed input", "[replay]")
|
||||
{
|
||||
REQUIRE(parseCommand("") == nullptr);
|
||||
REQUIRE(parseCommand("place not_a_type 0 0 E") == nullptr);
|
||||
REQUIRE(parseCommand("place miner 0 0 E bogus") == nullptr);
|
||||
REQUIRE(parseCommand("nonsense 1 2 3") == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("every command verb round-trips through serialize/parse", "[replay]")
|
||||
{
|
||||
SetRecipeCommand setRecipe;
|
||||
setRecipe.id = 4;
|
||||
setRecipe.recipeId = "smelt_iron";
|
||||
requireRoundTrip(setRecipe);
|
||||
|
||||
SetShipLayoutCommand setLayout;
|
||||
setLayout.id = 9;
|
||||
setLayout.layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(0, 1), Rotation::East});
|
||||
setLayout.layout.placedModules.push_back(PlacedModule{"armor", QPoint(2, -1), Rotation::South});
|
||||
requireRoundTrip(setLayout);
|
||||
|
||||
SetSiteSplitterFiltersCommand siteFilters;
|
||||
siteFilters.id = 11;
|
||||
siteFilters.filterA = { ItemType{"iron_ore"} };
|
||||
siteFilters.filterB = {};
|
||||
requireRoundTrip(siteFilters);
|
||||
|
||||
RotateInPlaceCommand rotate;
|
||||
rotate.id = 3;
|
||||
rotate.newRotation = Rotation::South;
|
||||
requireRoundTrip(rotate);
|
||||
|
||||
ApplySchematicChoiceCommand schematic;
|
||||
schematic.choiceIndex = 1;
|
||||
requireRoundTrip(schematic);
|
||||
|
||||
PlaceBuildingCommand placeWithFilters;
|
||||
placeWithFilters.type = BuildingType::Splitter;
|
||||
placeWithFilters.anchor = QPoint(2, 2);
|
||||
placeWithFilters.hasSplitterFilters = true;
|
||||
placeWithFilters.splitterFilterA = { ItemType{"iron_ore"}, ItemType{"coal"} };
|
||||
placeWithFilters.splitterFilterB = { ItemType{"copper_ore"} };
|
||||
requireRoundTrip(placeWithFilters);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Record -> read -> replay equivalence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("a recorded run replays to byte-identical state with no desync", "[replay]")
|
||||
{
|
||||
const unsigned int seed = 314159u;
|
||||
|
||||
// --- Record a scripted run, mimicking the frame cadence (drain, then ticks). ---
|
||||
std::string replayPath;
|
||||
std::uint64_t recordedFinalChecksum = 0;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
manager.setRecorder(std::move(recorder));
|
||||
|
||||
// Frame at tick 0: place a miner.
|
||||
manager.enqueue(place(BuildingType::Miner, QPoint(-3, 0)));
|
||||
manager.drain();
|
||||
for (int i = 0; i < 90; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
|
||||
|
||||
// Frame at tick 90 (a checksum boundary): place a belt — exercises the
|
||||
// periodic-checksum-then-command ordering at one tick.
|
||||
manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0)));
|
||||
manager.drain();
|
||||
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
|
||||
|
||||
replayPath = recorderPtr->currentFilePath();
|
||||
recordedFinalChecksum = rec.computeStateChecksum();
|
||||
manager.setRecorder(nullptr); // close the file
|
||||
}
|
||||
|
||||
// --- Read it back. ---
|
||||
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
|
||||
REQUIRE(parsed.has_value());
|
||||
REQUIRE(parsed->header.seed == seed);
|
||||
REQUIRE(parsed->header.version == 1);
|
||||
REQUIRE_FALSE(parsed->entries.empty());
|
||||
|
||||
// --- Replay it. ---
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
{
|
||||
play.tick();
|
||||
player.advanceTo(play.currentTick());
|
||||
}
|
||||
|
||||
REQUIRE_FALSE(player.getDesyncTick().has_value());
|
||||
REQUIRE(play.currentTick() == 150);
|
||||
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
|
||||
|
||||
QFile::remove(QString::fromStdString(replayPath));
|
||||
}
|
||||
|
||||
TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay]")
|
||||
{
|
||||
const unsigned int seed = 5u;
|
||||
|
||||
std::string replayPath;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
manager.setRecorder(std::move(recorder));
|
||||
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
|
||||
replayPath = recorderPtr->currentFilePath();
|
||||
manager.setRecorder(nullptr);
|
||||
}
|
||||
|
||||
std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
|
||||
REQUIRE(parsed.has_value());
|
||||
|
||||
// Corrupt the periodic checksum recorded at tick 30.
|
||||
bool corrupted = false;
|
||||
for (ReplayEntry& entry : parsed->entries)
|
||||
{
|
||||
if (!entry.isCommand && entry.tick == 30)
|
||||
{
|
||||
entry.fingerprint ^= 0x1ull;
|
||||
corrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
REQUIRE(corrupted);
|
||||
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
{
|
||||
play.tick();
|
||||
player.advanceTo(play.currentTick());
|
||||
}
|
||||
|
||||
REQUIRE(player.getDesyncTick().has_value());
|
||||
REQUIRE(*player.getDesyncTick() == 30);
|
||||
|
||||
QFile::remove(QString::fromStdString(replayPath));
|
||||
}
|
||||
|
||||
TEST_CASE("a long recorded run (through waves and combat) replays with no desync", "[replay]")
|
||||
{
|
||||
const unsigned int seed = 271828u;
|
||||
|
||||
std::string replayPath;
|
||||
std::uint64_t recordedFinalChecksum = 0;
|
||||
{
|
||||
Simulation rec(loadConfig(), seed);
|
||||
CommandManager manager(rec);
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
manager.setRecorder(std::move(recorder));
|
||||
|
||||
std::shared_ptr<PlaceBuildingCommand> miner = place(BuildingType::Miner, QPoint(-3, 0));
|
||||
miner->recipeId = "mine_iron_ore";
|
||||
manager.enqueue(miner);
|
||||
manager.drain();
|
||||
for (int i = 0; i < 1500; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
|
||||
|
||||
manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0)));
|
||||
manager.drain();
|
||||
for (int i = 0; i < 900; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
|
||||
|
||||
replayPath = recorderPtr->currentFilePath();
|
||||
recordedFinalChecksum = rec.computeStateChecksum();
|
||||
manager.setRecorder(nullptr);
|
||||
}
|
||||
|
||||
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
|
||||
REQUIRE(parsed.has_value());
|
||||
|
||||
Simulation play(loadConfig(), parsed->header.seed);
|
||||
ReplayPlayer player(play, parsed->entries);
|
||||
player.start();
|
||||
while (!player.isFinished())
|
||||
{
|
||||
play.tick();
|
||||
player.advanceTo(play.currentTick());
|
||||
}
|
||||
|
||||
REQUIRE_FALSE(player.getDesyncTick().has_value());
|
||||
REQUIRE(play.currentTick() == 2400);
|
||||
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
|
||||
|
||||
QFile::remove(QString::fromStdString(replayPath));
|
||||
}
|
||||
212
src/test/ReplayRecorderTest.cpp
Normal file
212
src/test/ReplayRecorderTest.cpp
Normal file
@@ -0,0 +1,212 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandManager.h"
|
||||
#include "CommandSerializer.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
std::string readFile(const std::string& path)
|
||||
{
|
||||
std::ifstream stream(path, std::ios::in | std::ios::binary);
|
||||
std::ostringstream buffer;
|
||||
buffer << stream.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
std::string tempOutputDir()
|
||||
{
|
||||
return (QDir::tempPath() + "/dota_factory_replay_test").toStdString();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command serialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("serializeCommand: plain placement", "[replay]")
|
||||
{
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-3, 5);
|
||||
command.rotation = Rotation::East;
|
||||
|
||||
REQUIRE(serializeCommand(command) == "place miner -3 5 E");
|
||||
}
|
||||
|
||||
TEST_CASE("serializeCommand: placement with recipe", "[replay]")
|
||||
{
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Miner;
|
||||
command.anchor = QPoint(-2, 0);
|
||||
command.rotation = Rotation::North;
|
||||
command.recipeId = "mine_iron_ore";
|
||||
|
||||
REQUIRE(serializeCommand(command) == "place miner -2 0 N recipe mine_iron_ore");
|
||||
}
|
||||
|
||||
TEST_CASE("serializeCommand: placement with ship layout", "[replay]")
|
||||
{
|
||||
PlaceBuildingCommand command;
|
||||
command.type = BuildingType::Shipyard;
|
||||
command.anchor = QPoint(-3, 0);
|
||||
command.rotation = Rotation::East;
|
||||
ShipLayoutConfig layout;
|
||||
layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 2), Rotation::South});
|
||||
command.shipLayout = layout;
|
||||
|
||||
REQUIRE(serializeCommand(command)
|
||||
== "place shipyard -3 0 E layout 1 weapon_basic 1 2 S");
|
||||
}
|
||||
|
||||
TEST_CASE("serializeCommand: splitter filters are length-prefixed", "[replay]")
|
||||
{
|
||||
SetSplitterFiltersCommand command;
|
||||
command.tile = QPoint(4, 7);
|
||||
command.filterA = { ItemType{"iron_ore"}, ItemType{"copper_ore"} };
|
||||
command.filterB = { ItemType{"coal"} };
|
||||
|
||||
REQUIRE(serializeCommand(command)
|
||||
== "splitterfilters 4 7 2 iron_ore copper_ore 1 coal");
|
||||
}
|
||||
|
||||
TEST_CASE("serializeCommand: demolish / rotate / schematic / clearbelt", "[replay]")
|
||||
{
|
||||
DemolishCommand demolish;
|
||||
demolish.id = 12;
|
||||
REQUIRE(serializeCommand(demolish) == "demolish 12");
|
||||
|
||||
RotateInPlaceCommand rotate;
|
||||
rotate.id = 7;
|
||||
rotate.newRotation = Rotation::West;
|
||||
REQUIRE(serializeCommand(rotate) == "rotate 7 W");
|
||||
|
||||
ApplySchematicChoiceCommand schematic;
|
||||
schematic.choiceIndex = 2;
|
||||
REQUIRE(serializeCommand(schematic) == "schematic 2");
|
||||
|
||||
ClearBeltTilesCommand clear;
|
||||
clear.tiles = { QPoint(1, 2), QPoint(3, 4) };
|
||||
REQUIRE(serializeCommand(clear) == "clearbelt 2 1 2 3 4");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReplayRecorder file output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]")
|
||||
{
|
||||
ReplayRecorder recorder(CONFIG_DIR, tempOutputDir());
|
||||
recorder.startNewRun(42u, 0x1122334455667788ull);
|
||||
|
||||
PlaceBuildingCommand place;
|
||||
place.type = BuildingType::Miner;
|
||||
place.anchor = QPoint(-3, 0);
|
||||
place.rotation = Rotation::East;
|
||||
recorder.recordCommand(5, place, 0xabcdef0123456789ull);
|
||||
|
||||
recorder.recordChecksum(30, 0x0ffffffffffffff0ull);
|
||||
|
||||
const std::string path = recorder.currentFilePath();
|
||||
REQUIRE_FALSE(path.empty());
|
||||
recorder.close();
|
||||
|
||||
const std::string content = readFile(path);
|
||||
|
||||
// Header.
|
||||
REQUIRE(content.find("# dota_factory replay") != std::string::npos);
|
||||
REQUIRE(content.find("version 1") != std::string::npos);
|
||||
REQUIRE(content.find("seed 42") != std::string::npos);
|
||||
REQUIRE(content.find("config_hash ") != std::string::npos);
|
||||
REQUIRE(content.find("---") != std::string::npos);
|
||||
|
||||
// Initial + per-command + periodic checksums.
|
||||
REQUIRE(content.find("# checksum 0 1122334455667788") != std::string::npos);
|
||||
REQUIRE(content.find("5 place miner -3 0 E") != std::string::npos);
|
||||
REQUIRE(content.find("# checksum 5 abcdef0123456789") != std::string::npos);
|
||||
REQUIRE(content.find("# checksum 30 0ffffffffffffff0") != std::string::npos);
|
||||
|
||||
QFile::remove(QString::fromStdString(path));
|
||||
}
|
||||
|
||||
TEST_CASE("CommandManager records commands and an initial checksum on drain", "[replay]")
|
||||
{
|
||||
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 7u);
|
||||
CommandManager manager(sim);
|
||||
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
// setRecorder opens the file and writes the header + the tick-0 checksum.
|
||||
manager.setRecorder(std::move(recorder));
|
||||
const std::string path = recorderPtr->currentFilePath();
|
||||
REQUIRE_FALSE(path.empty());
|
||||
|
||||
std::shared_ptr<PlaceBuildingCommand> place = std::make_shared<PlaceBuildingCommand>();
|
||||
place->type = BuildingType::Miner;
|
||||
place->anchor = QPoint(-3, 0);
|
||||
manager.enqueue(place);
|
||||
manager.drain();
|
||||
|
||||
const std::string content = readFile(path);
|
||||
REQUIRE(content.find("seed 7") != std::string::npos);
|
||||
REQUIRE(content.find("# checksum 0 ") != std::string::npos);
|
||||
// Drained at tick 0 (no ticks have run), so the command line is tagged tick 0.
|
||||
REQUIRE(content.find("0 place miner -3 0 E") != std::string::npos);
|
||||
|
||||
QFile::remove(QString::fromStdString(path));
|
||||
}
|
||||
|
||||
TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]")
|
||||
{
|
||||
ReplayRecorder recorder(CONFIG_DIR, tempOutputDir());
|
||||
|
||||
recorder.startNewRun(1u, 0ull);
|
||||
const std::string first = recorder.currentFilePath();
|
||||
|
||||
recorder.startNewRun(2u, 0ull);
|
||||
const std::string second = recorder.currentFilePath();
|
||||
|
||||
REQUIRE(first != second);
|
||||
REQUIRE(second.find("_2.replay") != std::string::npos);
|
||||
recorder.close();
|
||||
}
|
||||
|
||||
TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
|
||||
{
|
||||
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u);
|
||||
CommandManager manager(sim);
|
||||
|
||||
std::unique_ptr<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
manager.setRecorder(std::move(recorder));
|
||||
const std::string firstPath = recorderPtr->currentFilePath();
|
||||
|
||||
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
|
||||
reset->config = std::make_shared<GameConfig>(ConfigLoader::loadFromDirectory(CONFIG_DIR));
|
||||
reset->seed = 999u;
|
||||
manager.enqueue(reset);
|
||||
manager.drain();
|
||||
|
||||
const std::string secondPath = recorderPtr->currentFilePath();
|
||||
REQUIRE(firstPath != secondPath);
|
||||
REQUIRE(secondPath.find("_999.replay") != std::string::npos);
|
||||
|
||||
manager.setRecorder(nullptr);
|
||||
QFile::remove(QString::fromStdString(firstPath));
|
||||
QFile::remove(QString::fromStdString(secondPath));
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "ShipStatsCalculator.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
@@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
||||
|
||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||
{
|
||||
return sim.buildings().placeImmediate(
|
||||
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||
BuildingType::Shipyard,
|
||||
yardDef.surfaceMask,
|
||||
QPoint(0, 0),
|
||||
@@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
|
||||
const ShipDef& def,
|
||||
const ShipLayoutConfig& layout)
|
||||
{
|
||||
sim.buildings().forEachBuilding([&](Building& b) {
|
||||
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
|
||||
if (b.id != yardId)
|
||||
{
|
||||
return;
|
||||
@@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
@@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b != nullptr);
|
||||
@@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
// Fill materials and tick to start production.
|
||||
ShipLayoutConfig emptyLayout;
|
||||
@@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
@@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
@@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b1 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->shipLayout.has_value());
|
||||
|
||||
sim.buildings().setRecipe(yardId, "destroyer");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
||||
|
||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||
{
|
||||
return sim.buildings().placeImmediate(
|
||||
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||
BuildingType::Shipyard,
|
||||
yardDef.surfaceMask,
|
||||
QPoint(0, 0),
|
||||
@@ -62,7 +63,7 @@ static int countShips(Simulation& sim)
|
||||
|
||||
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
|
||||
{
|
||||
sim.buildings().forEachBuilding([&](Building& b)
|
||||
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
|
||||
{
|
||||
if (b.id != yardId)
|
||||
{
|
||||
@@ -94,7 +95,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
REQUIRE(yardId != kInvalidBuildingId);
|
||||
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
fillMaterials(sim, yardId, *def);
|
||||
|
||||
// First tick: materials consumed, production cycle starts — no ship yet.
|
||||
@@ -153,7 +154,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
||||
const int shipsBefore = countShips(sim);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
// Materials remain at zero (default after setRecipe); no cycle starts.
|
||||
|
||||
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
||||
@@ -175,7 +176,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
|
||||
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
||||
|
||||
|
||||
40
src/test/SimulationTestAccess.h
Normal file
40
src/test/SimulationTestAccess.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <QPoint>
|
||||
|
||||
#include "BuildingId.h"
|
||||
#include "BuildingType.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
class BeltSystem;
|
||||
class BuildingSystem;
|
||||
|
||||
// Test-only backdoor to Simulation's private player-action mutators and mutable
|
||||
// subsystem accessors. Declared a friend of Simulation, so it can hand tests the
|
||||
// same mutation surface that Simulation::apply uses internally — without exposing
|
||||
// those mutators to production (UI/app) code, which must go through the command
|
||||
// chokepoint (docs/replay_design.md).
|
||||
//
|
||||
// This header lives under src/test and is not on the lib/ui/app include path, so
|
||||
// only test translation units can reach it. Non-player test setup that has no
|
||||
// command equivalent (e.g. placeImmediate, forEachBuilding buffer injection) is
|
||||
// reached via buildings(sim)/belts(sim).
|
||||
struct SimulationTestAccess
|
||||
{
|
||||
static BuildingSystem& buildings(Simulation& sim) { return sim.buildingsMutable(); }
|
||||
static BeltSystem& belts(Simulation& sim) { return sim.beltsMutable(); }
|
||||
|
||||
static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor,
|
||||
Rotation rotation)
|
||||
{
|
||||
return sim.tryPlaceBuilding(type, anchor, rotation);
|
||||
}
|
||||
|
||||
static void demolish(Simulation& sim, BuildingId id) { sim.demolish(id); }
|
||||
|
||||
static void applySchematicChoice(Simulation& sim, int choiceIndex)
|
||||
{
|
||||
sim.applySchematicChoice(choiceIndex);
|
||||
}
|
||||
};
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "WaveSystem.h"
|
||||
@@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
|
||||
|
||||
sim.tick();
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user