replay: record every run to a replay file (Phase 2)
Hooks a recorder into the command chokepoint so each run is written to disk as it plays, ready for playback in Phase 3. - ReplayRecorder (lib): line-oriented append-friendly file — keyed header (version, build, seed, config_hash, timestamp), then '---', then tick-tagged command lines interleaved with "# checksum <tick> <hex>" RNG fingerprints. Each line is flushed so a crash leaves a valid partial file. Config hash is a 64-bit FNV over the config dir's *.toml files; build tag is __DATE__/__TIME__. - CommandSerializer (lib): per-command text (length-prefixed variable parts; ship layouts and splitter filters serialized inline). Reset is a file boundary, never a stream entry. - CommandManager owns an optional ReplayRecorder: drain() records each applied command + a post-apply checksum; a drained Reset rolls to a new file; recordTickCheckpoint() (called per tick from onFrame) writes a checksum every 30 ticks. - Random seed generated in main and on restart (std::random_device); Simulation retains it via getSeed() for the header. - GameWorldView attaches the recorder at construction (first file + tick-0 checksum); replays land in <data>/replays named <timestamp>_<seed>.replay. ReplayRecorderTest covers serialization, file well-formedness, file rolling, and the CommandManager drain->record integration. Full suite green (346 cases / 3377 assertions); app, tests, and balancing all build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
134
src/lib/sim/CommandSerializer.cpp
Normal file
134
src/lib/sim/CommandSerializer.cpp
Normal 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();
|
||||
}
|
||||
15
src/lib/sim/CommandSerializer.h
Normal file
15
src/lib/sim/CommandSerializer.h
Normal 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);
|
||||
130
src/lib/sim/ReplayRecorder.cpp
Normal file
130
src/lib/sim/ReplayRecorder.cpp
Normal 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;
|
||||
}
|
||||
53
src/lib/sim/ReplayRecorder.h
Normal file
53
src/lib/sim/ReplayRecorder.h
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,4 +23,5 @@ add_files(
|
||||
RecipeSchematicTest.cpp
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
ReplayRecorderTest.cpp
|
||||
)
|
||||
|
||||
186
src/test/ReplayRecorderTest.cpp
Normal file
186
src/test/ReplayRecorderTest.cpp
Normal 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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user