From 97b6f0d8fd6760925a74d0b991e1ee1f88e75bd2 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 20:08:04 +0200 Subject: [PATCH] replay: record every run to a replay file (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 " 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 /replays named _.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 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 35 +++--- src/app/main.cpp | 8 +- src/lib/sim/CMakeLists.txt | 4 + src/lib/sim/CommandManager.cpp | 51 +++++++- src/lib/sim/CommandManager.h | 16 ++- src/lib/sim/CommandSerializer.cpp | 134 +++++++++++++++++++++ src/lib/sim/CommandSerializer.h | 15 +++ src/lib/sim/ReplayRecorder.cpp | 130 +++++++++++++++++++++ src/lib/sim/ReplayRecorder.h | 53 +++++++++ src/lib/sim/Simulation.cpp | 7 ++ src/lib/sim/Simulation.h | 3 + src/test/CMakeLists.txt | 1 + src/test/ReplayRecorderTest.cpp | 186 ++++++++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 16 ++- src/ui/GameWorldView.h | 4 +- src/ui/MainWindow.cpp | 7 +- 16 files changed, 650 insertions(+), 20 deletions(-) create mode 100644 src/lib/sim/CommandSerializer.cpp create mode 100644 src/lib/sim/CommandSerializer.h create mode 100644 src/lib/sim/ReplayRecorder.cpp create mode 100644 src/lib/sim/ReplayRecorder.h create mode 100644 src/test/ReplayRecorderTest.cpp diff --git a/docs/replay_design.md b/docs/replay_design.md index 07d8deb..f90f66f 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -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 ` 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 `/replays`, named + `_.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 diff --git a/src/app/main.cpp b/src/app/main.cpp index 2657167..c692c35 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -32,7 +33,12 @@ int main(int argc, char *argv[]) } GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR); - std::unique_ptr sim = std::make_unique(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 sim = std::make_unique(std::move(config), seed); MainWindow window(sim.get(), std::string(CONFIG_DIR)); window.show(); diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 997cf5f..0ea2184 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -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 diff --git a/src/lib/sim/CommandManager.cpp b/src/lib/sim/CommandManager.cpp index 3e92a17..9163c96 100644 --- a/src/lib/sim/CommandManager.cpp +++ b/src/lib/sim/CommandManager.cpp @@ -1,13 +1,25 @@ #include "CommandManager.h" +#include + #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 command) { if (command) @@ -23,7 +35,27 @@ void CommandManager::drain() // fresh state. for (const std::shared_ptr& 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 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()); + } +} diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h index 4a128eb..29e57ff 100644 --- a/src/lib/sim/CommandManager.h +++ b/src/lib/sim/CommandManager.h @@ -4,6 +4,7 @@ #include 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 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 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 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> m_queue; + std::unique_ptr m_recorder; }; diff --git a/src/lib/sim/CommandSerializer.cpp b/src/lib/sim/CommandSerializer.cpp new file mode 100644 index 0000000..ec41e49 --- /dev/null +++ b/src/lib/sim/CommandSerializer.cpp @@ -0,0 +1,134 @@ +#include "CommandSerializer.h" + +#include + +#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'; +} + +// " ( )*" +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); + } +} + +// " ()* ()*" +void appendFilters(std::ostringstream& out, + const std::vector& filterA, + const std::vector& 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(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(command).id; + break; + case CommandKind::RotateInPlace: + { + const RotateInPlaceCommand& c = static_cast(command); + out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation); + break; + } + case CommandKind::SetRecipe: + { + const SetRecipeCommand& c = static_cast(command); + out << "setrecipe " << c.id << ' ' << c.recipeId; + break; + } + case CommandKind::SetShipLayout: + { + const SetShipLayoutCommand& c = static_cast(command); + out << "setlayout " << c.id << ' '; + appendLayout(out, c.layout); + break; + } + case CommandKind::SetSiteSplitterFilters: + { + const SetSiteSplitterFiltersCommand& c = + static_cast(command); + out << "sitefilters " << c.id << ' '; + appendFilters(out, c.filterA, c.filterB); + break; + } + case CommandKind::SetSplitterFilters: + { + const SetSplitterFiltersCommand& c = + static_cast(command); + out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' '; + appendFilters(out, c.filterA, c.filterB); + break; + } + case CommandKind::ClearBeltTiles: + { + const ClearBeltTilesCommand& c = static_cast(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(command).choiceIndex; + break; + case CommandKind::Reset: + // A reset rolls the replay file; it is never written as a stream entry. + break; + } + + return out.str(); +} diff --git a/src/lib/sim/CommandSerializer.h b/src/lib/sim/CommandSerializer.h new file mode 100644 index 0000000..990eadd --- /dev/null +++ b/src/lib/sim/CommandSerializer.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +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); diff --git a/src/lib/sim/ReplayRecorder.cpp b/src/lib/sim/ReplayRecorder.cpp new file mode 100644 index 0000000..e0cba69 --- /dev/null +++ b/src/lib/sim/ReplayRecorder.cpp @@ -0,0 +1,130 @@ +#include "ReplayRecorder.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#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(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; +} diff --git a/src/lib/sim/ReplayRecorder.h b/src/lib/sim/ReplayRecorder.h new file mode 100644 index 0000000..d29af0a --- /dev/null +++ b/src/lib/sim/ReplayRecorder.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#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 _), + // 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; +}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index a3b3c2f..e735fef 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -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; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index a375156..f404655 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -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; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 5d1be1c..1a29625 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -23,4 +23,5 @@ add_files( RecipeSchematicTest.cpp DeterminismTest.cpp CommandTest.cpp + ReplayRecorderTest.cpp ) diff --git a/src/test/ReplayRecorderTest.cpp b/src/test/ReplayRecorderTest.cpp new file mode 100644 index 0000000..967891d --- /dev/null +++ b/src/test/ReplayRecorderTest.cpp @@ -0,0 +1,186 @@ +#include "catch.hpp" + +#include +#include +#include + +#include + +#include +#include + +#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 recorder = + std::make_unique(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 place = std::make_shared(); + 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(); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index ee21b20..326776e 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -6,10 +6,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -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 /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( + 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(); } } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index db117e9..e3ebee7 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -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; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ff68345..79a0952 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,6 +1,7 @@ #include "MainWindow.h" #include +#include #include #include @@ -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 /*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 command = std::make_shared(); command->config = std::move(newConfig); + command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } @@ -332,6 +334,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) // Restart is a command boundary; the view resets when the drain applies it. std::shared_ptr command = std::make_shared(); command->config = std::move(newConfig); + command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); }