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

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