Replay: deterministic record & playback #4

Merged
mlangkabel merged 7 commits from feature/replay into master 2026-07-01 19:20:09 +00:00
16 changed files with 650 additions and 20 deletions
Showing only changes of commit 97b6f0d8fd - Show all commits

View File

@@ -382,20 +382,29 @@ Reshape mutations to flow through one path; behaviour unchanged.
still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly
(verified by grep — convention, not compile-enforced).
### Phase 2 — Recording
### Phase 2 — Recording — DONE
- Implement the **line-oriented append writer**: header (seed, config hash, build/version,
timestamp) + one line per command + checksum lines.
- Generate a **random seed outside the sim** (in `main`/reset), write to header.
- Compute the **config hash** over the loaded config.
- Hook the **recorder at the apply chokepoint** in `CommandManager::drain()`: append each command
(tick-tagged), append an **RNG checksum after every command** and **every 30 ticks**.
- **Lifecycle:** open a new file on `Simulation` construction and on each `reset()` (restart =
boundary); store in `data/`, named by timestamp+seed; retain everything.
- **Files:** new replay writer in `lib`; `main.cpp` (seed), config hash helper;
`CommandManager`/`Simulation` for the tick checksum hook.
- **Exit criteria:** every run produces a well-formed, growing replay file; a crash mid-run still
leaves a valid partial file.
- `ReplayRecorder` (lib) writes the **line-oriented append file**: header (`version`, `build`,
`seed`, `config_hash`, `timestamp`) then `---`, then one tick-tagged line per command
interleaved with `# checksum <tick> <hex>` lines. Each line is flushed, so a crash mid-run
leaves a valid partial file. `CommandSerializer` produces the per-command text (length-prefixed
variable parts; `ShipLayoutConfig`/filters serialized inline). The build tag is
`__DATE__ " " __TIME__`; the config hash is a 64-bit FNV over the `*.toml` files in the config
dir (re-hashed on playback to detect mismatch).
- **Random seed** generated in `main` (and on each restart in `MainWindow`) via
`std::random_device`; `Simulation` retains it (`getSeed()`) for the header.
- **Recorder hooked at the chokepoint:** `CommandManager` owns an optional `ReplayRecorder`;
`drain()` records each applied command (tick-tagged) + a post-apply RNG checksum, and
`recordTickCheckpoint()` (called per tick from the `onFrame` loop) writes a checksum every 30
ticks. A drained `Reset` rolls the recorder to a new file (restart = boundary).
- **Lifecycle:** `GameWorldView` attaches the recorder at construction (opens the first file with
the initial seed + a tick-0 checksum); files live in `<data>/replays`, named
`<timestamp>_<seed>.replay`; everything is retained.
- **Files:** new `lib/sim/ReplayRecorder.{h,cpp}`, `CommandSerializer.{h,cpp}`; `Simulation`
(`getSeed`); `CommandManager` (recorder + tick checkpoint); `main.cpp` (seed);
`MainWindow.cpp` / `GameWorldView.{h,cpp}` (wiring); new `ReplayRecorderTest.cpp`.
- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is
well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.)
### Phase 3 — Playback

View File

@@ -1,4 +1,5 @@
#include <memory>
#include <random>
#include <QApplication>
#include <QDir>
@@ -32,7 +33,12 @@ int main(int argc, char *argv[])
}
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
// Random seed generated outside the sim so the Simulation stays a pure
// function of (seed, config, commands); the seed is written to the replay
// header (see docs/replay_design.md "Seed and config").
const unsigned int seed = std::random_device{}();
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
MainWindow window(sim.get(), std::string(CONFIG_DIR));
window.show();

View File

@@ -3,6 +3,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
@@ -21,6 +23,8 @@ SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp

View File

@@ -1,13 +1,25 @@
#include "CommandManager.h"
#include <utility>
#include "Command.h"
#include "ReplayRecorder.h"
#include "Simulation.h"
#include "Tick.h"
namespace
{
// Periodic RNG checksum cadence (see docs/replay_design.md "Cadence").
constexpr Tick kChecksumIntervalTicks = 30;
} // namespace
CommandManager::CommandManager(Simulation& simulation)
: m_simulation(simulation)
{
}
CommandManager::~CommandManager() = default;
void CommandManager::enqueue(std::shared_ptr<const Command> command)
{
if (command)
@@ -23,7 +35,27 @@ void CommandManager::drain()
// fresh state.
for (const std::shared_ptr<const Command>& command : m_queue)
{
m_simulation.apply(*command);
if (command->kind == CommandKind::Reset)
{
m_simulation.apply(*command);
if (m_recorder)
{
// Restart is a file boundary: a fresh file with the new seed.
m_recorder->startNewRun(m_simulation.getSeed(),
m_simulation.rngFingerprint());
}
}
else
{
// Commands drain before the tick batch, so currentTick is the count of
// completed ticks the command is pinned to.
const Tick tick = m_simulation.currentTick();
m_simulation.apply(*command);
if (m_recorder)
{
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
}
}
}
m_queue.clear();
}
@@ -32,3 +64,20 @@ bool CommandManager::hasPending() const
{
return !m_queue.empty();
}
void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
{
m_recorder = std::move(recorder);
if (m_recorder)
{
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
}
}
void CommandManager::recordTickCheckpoint()
{
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
{
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
}
}

View File

@@ -4,6 +4,7 @@
#include <vector>
struct Command;
class ReplayRecorder;
class Simulation;
// Ordered queue that funnels every player command into the single
@@ -20,17 +21,30 @@ class CommandManager
{
public:
explicit CommandManager(Simulation& simulation);
// Defined out-of-line so the unique_ptr<ReplayRecorder> member can be
// destroyed where ReplayRecorder is a complete type.
~CommandManager();
// Append a command for application at the next drain (FIFO order).
void enqueue(std::shared_ptr<const Command> command);
// Apply all queued commands in FIFO order through Simulation::apply, then
// clear the queue.
// clear the queue. If a recorder is attached, each applied command is recorded
// (a Reset rolls the recorder to a new file).
void drain();
bool hasPending() const;
// Attach a recorder and start recording the current run. Ownership is taken.
// Passing nullptr detaches/stops recording.
void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
// Record a periodic RNG checksum if the current tick is on the checksum
// cadence. Call once per simulated tick (from the tick loop).
void recordTickCheckpoint();
private:
Simulation& m_simulation;
std::vector<std::shared_ptr<const Command>> m_queue;
std::unique_ptr<ReplayRecorder> m_recorder;
};

View File

@@ -0,0 +1,134 @@
#include "CommandSerializer.h"
#include <sstream>
#include "BuildingType.h"
#include "Command.h"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
char rotationToChar(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return 'N';
case Rotation::East: return 'E';
case Rotation::South: return 'S';
case Rotation::West: return 'W';
}
return 'E';
}
// "<count> (<moduleId> <x> <y> <rot>)*"
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
{
out << layout.placedModules.size();
for (const PlacedModule& placed : layout.placedModules)
{
out << ' ' << placed.moduleId
<< ' ' << placed.position.x()
<< ' ' << placed.position.y()
<< ' ' << rotationToChar(placed.rotation);
}
}
// "<countA> (<itemId>)* <countB> (<itemId>)*"
void appendFilters(std::ostringstream& out,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB)
{
out << filterA.size();
for (const ItemType& type : filterA) { out << ' ' << type.id; }
out << ' ' << filterB.size();
for (const ItemType& type : filterB) { out << ' ' << type.id; }
}
} // namespace
std::string serializeCommand(const Command& command)
{
std::ostringstream out;
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
out << "place " << buildingTypeId(c.type)
<< ' ' << c.anchor.x() << ' ' << c.anchor.y()
<< ' ' << rotationToChar(c.rotation);
if (c.recipeId.has_value())
{
out << " recipe " << *c.recipeId;
}
if (c.shipLayout.has_value())
{
out << " layout ";
appendLayout(out, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
out << " filters ";
appendFilters(out, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
out << "setrecipe " << c.id << ' ' << c.recipeId;
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
out << "setlayout " << c.id << ' ';
appendLayout(out, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
out << "sitefilters " << c.id << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
{
const SetSplitterFiltersCommand& c =
static_cast<const SetSplitterFiltersCommand&>(command);
out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
case CommandKind::ClearBeltTiles:
{
const ClearBeltTilesCommand& c = static_cast<const ClearBeltTilesCommand&>(command);
out << "clearbelt " << c.tiles.size();
for (const QPoint& tile : c.tiles)
{
out << ' ' << tile.x() << ' ' << tile.y();
}
break;
}
case CommandKind::ApplySchematicChoice:
out << "schematic " << static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex;
break;
case CommandKind::Reset:
// A reset rolls the replay file; it is never written as a stream entry.
break;
}
return out.str();
}

View File

@@ -0,0 +1,15 @@
#pragma once
#include <string>
struct Command;
// Serializes a command to a single-line, space-delimited token sequence for the
// replay file (see docs/replay_design.md "File format: line-oriented ...").
// Config ids (building types, recipes, items, modules) are whitespace-free
// identifiers, so space delimiting is unambiguous; variable-length parts are
// length-prefixed so the matching parser (added in Phase 3) is unambiguous.
//
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it
// is never serialized here.
std::string serializeCommand(const Command& command);

View File

@@ -0,0 +1,130 @@
#include "ReplayRecorder.h"
#include <iomanip>
#include <sstream>
#include <utility>
#include <QByteArray>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QString>
#include <QStringList>
#include "Command.h"
#include "CommandSerializer.h"
#include "StateChecksum.h"
namespace
{
constexpr const char* kReplayFormatVersion = "1";
// Build fingerprint: even a new local build can desync old replays (float
// reasons), so the header carries a per-build tag to warn on mismatch.
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
std::string toHex(std::uint64_t value)
{
std::ostringstream out;
out << std::hex << std::setw(16) << std::setfill('0') << value;
return out.str();
}
} // namespace
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
: m_configDir(std::move(configDir))
, m_outputDir(std::move(outputDir))
{
}
ReplayRecorder::~ReplayRecorder()
{
close();
}
std::string ReplayRecorder::computeConfigHash() const
{
Hasher hasher;
QDir dir(QString::fromStdString(m_configDir));
const QStringList files =
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
for (const QString& name : files)
{
hasher.append(name.toStdString());
QFile file(dir.filePath(name));
if (file.open(QIODevice::ReadOnly))
{
const QByteArray bytes = file.readAll();
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}
}
return toHex(hasher.value());
}
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
{
close();
QDir().mkpath(QString::fromStdString(m_outputDir));
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString fileName = timestamp + "_" + QString::number(seed) + ".replay";
m_filePath = QDir(QString::fromStdString(m_outputDir)).filePath(fileName).toStdString();
m_stream.open(m_filePath, std::ios::out | std::ios::trunc);
if (!m_stream.is_open())
{
return;
}
m_stream << "# dota_factory replay\n";
m_stream << "version " << kReplayFormatVersion << "\n";
m_stream << "build " << kBuildTag << "\n";
m_stream << "seed " << seed << "\n";
m_stream << "config_hash " << computeConfigHash() << "\n";
m_stream << "timestamp "
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
m_stream << "---\n";
m_stream << "# checksum 0 " << toHex(initialRngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::recordCommand(Tick tick, const Command& command,
std::uint64_t rngFingerprint)
{
if (!m_stream.is_open())
{
return;
}
m_stream << tick << ' ' << serializeCommand(command) << "\n";
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::recordChecksum(Tick tick, std::uint64_t rngFingerprint)
{
if (!m_stream.is_open())
{
return;
}
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::close()
{
if (m_stream.is_open())
{
m_stream.flush();
m_stream.close();
}
}
bool ReplayRecorder::isOpen() const
{
return m_stream.is_open();
}
const std::string& ReplayRecorder::currentFilePath() const
{
return m_filePath;
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <cstdint>
#include <fstream>
#include <string>
#include "Tick.h"
struct Command;
// Writes a replay file as the game runs (see docs/replay_design.md). The format
// is line-oriented append-friendly text: a small keyed header, then one line per
// command (tick-tagged) interleaved with RNG-state checksum lines for desync
// detection. Each line is flushed so a crash mid-run still leaves a valid partial
// file.
//
// One file = one run between Reset boundaries; startNewRun() closes the current
// file and opens a fresh one.
class ReplayRecorder
{
public:
// configDir: hashed (its *.toml files) into the header for config-mismatch
// detection on playback. outputDir: where .replay files are written.
ReplayRecorder(std::string configDir, std::string outputDir);
~ReplayRecorder();
ReplayRecorder(const ReplayRecorder&) = delete;
ReplayRecorder& operator=(const ReplayRecorder&) = delete;
// Close any current file, then open a fresh one (named <timestamp>_<seed>),
// write the header, and record an initial tick-0 checksum. A run boundary.
void startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint);
// Append one command line tagged with the tick it was applied at, followed by
// the post-apply RNG checksum.
void recordCommand(Tick tick, const Command& command, std::uint64_t rngFingerprint);
// Append a periodic RNG checksum line.
void recordChecksum(Tick tick, std::uint64_t rngFingerprint);
void close();
bool isOpen() const;
const std::string& currentFilePath() const;
private:
// 64-bit hash over the *.toml files in m_configDir, as a 16-char hex string.
std::string computeConfigHash() const;
std::string m_configDir;
std::string m_outputDir;
std::string m_filePath;
std::ofstream m_stream;
};

View File

@@ -33,6 +33,7 @@
Simulation::Simulation(GameConfig config, unsigned int seed)
: m_config(std::move(config))
, m_rng(seed)
, m_seed(seed)
, m_currentTick(0)
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
, m_nextBuildingId(1)
@@ -134,6 +135,7 @@ void Simulation::reset(unsigned int seed)
{
EventManager::getInstance()->clearEvents();
m_rng.seed(seed);
m_seed = seed;
m_currentTick = 0;
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextBuildingId = 1;
@@ -1057,6 +1059,11 @@ Tick Simulation::currentTick() const
return m_currentTick;
}
unsigned int Simulation::getSeed() const
{
return m_seed;
}
int Simulation::buildingBlocksStock() const
{
return m_buildingBlocksStock;

View File

@@ -74,6 +74,8 @@ public:
void applySchematicChoice(int choiceIndex);
Tick currentTick() const;
// The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const;
int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
@@ -144,6 +146,7 @@ private:
GameConfig m_config;
std::mt19937 m_rng;
unsigned int m_seed;
Tick m_currentTick;
Tick m_nextDepartureTick;

View File

@@ -23,4 +23,5 @@ add_files(
RecipeSchematicTest.cpp
DeterminismTest.cpp
CommandTest.cpp
ReplayRecorderTest.cpp
)

View File

@@ -0,0 +1,186 @@
#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();
}

View File

@@ -6,10 +6,12 @@
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <QColor>
#include <QCursor>
#include <QDir>
#include <QFont>
#include <QKeyEvent>
#include <QMessageBox>
@@ -25,6 +27,7 @@
#include "Building.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
@@ -113,7 +116,8 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent)
const VisualsConfig* visuals, const std::string& configDir,
QWidget* parent)
: QOpenGLWidget(parent)
, m_sim(sim)
, m_config(config)
@@ -145,6 +149,14 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
m_frameTimer.start();
registerForEvents();
// Record every run to disk. Replays live under <data>/replays, alongside the
// config dir that was loaded. Attaching the recorder opens the first file and
// writes the header for the initial run.
QDir replayDir(QString::fromStdString(configDir));
replayDir.cdUp();
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
configDir, replayDir.filePath("replays").toStdString()));
}
GameWorldView::~GameWorldView()
@@ -180,6 +192,8 @@ void GameWorldView::onFrame()
for (int i = 0; i < ticks; ++i)
{
m_sim->tick();
// Periodic checksum (every 30 ticks) for replay desync detection.
m_commandManager.recordTickCheckpoint();
}
}

View File

@@ -3,6 +3,7 @@
#include <optional>
#include <random>
#include <set>
#include <string>
#include <vector>
#include <QElapsedTimer>
@@ -66,7 +67,8 @@ class GameWorldView : public QOpenGLWidget,
public:
GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent = nullptr);
const VisualsConfig* visuals, const std::string& configDir,
QWidget* parent = nullptr);
~GameWorldView() override;
double gameSpeed() const;

View File

@@ -1,6 +1,7 @@
#include "MainWindow.h"
#include <map>
#include <random>
#include <set>
#include <QApplication>
@@ -41,7 +42,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
m_headerBar = new HeaderBar(this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this);
m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
@@ -184,9 +185,10 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
return;
}
// Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). Seed stays 0 in Phase 1.
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
@@ -332,6 +334,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}