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:
2026-07-01 19:20:08 +00:00
committed by mlangkabel
parent cf68ac2862
commit d74ba5bfad
40 changed files with 3385 additions and 129 deletions

View File

@@ -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;

View File

@@ -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
View 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());
}

View 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);
}

View File

@@ -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)

View 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));
}

View 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));
}

View File

@@ -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);

View File

@@ -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);

View 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);
}
};

View File

@@ -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());
}