diff --git a/docs/replay_design.md b/docs/replay_design.md index f90f66f..b7380bb 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -406,23 +406,32 @@ Reshape mutations to flow through one path; behaviour unchanged. - **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 +### Phase 3 — Playback — DONE -- Implement the **reader/parser** for the format (header + commands + checksums). -- Add the `--replay ` CLI path in `main`: validate config hash + version (warn on - mismatch), construct `Simulation` from seed+config, construct `CommandManager` in **replay - mode** (pre-filled, `addCommand` is a no-op). -- Replay driver: each frame, drain commands due at the reached tick (same drain path), step - ticks, **keep manual speed/pause**, playback only moves forward. -- **Gate the two `onFrame` polls** (schematic-choices, game-over) off in replay mode; everything - else (recipe/layout dialogs, escape menu) is input-driven and falls away automatically. -- **Desync detection:** recompute the RNG checksum at each checkpoint, compare to the file, - report "desync at tick N" on mismatch. -- **Passive end:** when the command stream is exhausted / recorded game-over is reached, stop - with a "replay ended" overlay instead of the restart dialog. -- **Files:** new reader in `lib`; `main.cpp`; `CommandManager` (replay mode); `GameWorldView.cpp` - (poll gating, end overlay). -- **Exit criteria:** a recorded file plays back identically; checksums match throughout. +- `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command + (with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer` + gained the inverse `parseCommand` (round-tripping every verb). +- `--replay ` CLI path in `main`: reads the file, **warns** on version / config-hash + mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the + parsed replay through `MainWindow` to `GameWorldView`. +- `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies + each command at its **exact recorded tick** and verifies checksums **in file order**: + `start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)` + consumes that tick's entries (periodic checksum first, then command + its checksum — the order + the file already has). This makes playback independent of replay-time speed/pause. +- `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept, + forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two + sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are + input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** / + **"Desync at tick N"** overlay replaces the restart dialog. +- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer` + (`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager` + (replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new + `ReplayPlaybackTest.cpp`. +- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back, + replays it, and asserts **no desync** and a **byte-identical final state checksum** — including + the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but + not auto-tested here.) ### Phase 4 — Closing tests & polish diff --git a/src/app/main.cpp b/src/app/main.cpp index c692c35..e08a5e2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,5 +1,7 @@ #include +#include #include +#include #include #include @@ -9,6 +11,8 @@ #include "logging.h" #include "LogManager.h" #include "MainWindow.h" +#include "ReplayReader.h" +#include "ReplayRecorder.h" #include "Simulation.h" int main(int argc, char *argv[]) @@ -32,15 +36,54 @@ int main(int argc, char *argv[]) QDir().mkdir(dataDir.dirName()); } + // Optional "--replay " launches view-only playback of a recorded run. + std::optional replayPath; + for (int i = 1; i + 1 < argc; ++i) + { + if (std::string(argv[i]) == "--replay") + { + replayPath = argv[i + 1]; + break; + } + } + GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR); - // 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{}(); + unsigned int seed = 0; + std::shared_ptr replay; + if (replayPath.has_value()) + { + std::optional parsed = readReplayFile(*replayPath); + if (!parsed.has_value()) + { + LOG_ERROR("Failed to read replay file: " + *replayPath); + return 1; + } + // Warn (but proceed) on identity mismatches: a different config or build + // can desync playback (see docs/replay_design.md). + if (parsed->header.version != 1) + { + LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version + << " differs from 1; playback may fail"); + } + if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash) + { + LOG_WARNING("Replay config hash mismatch; playback may desync"); + } + seed = parsed->header.seed; + replay = std::make_shared(std::move(*parsed)); + } + else + { + // Random seed generated outside the sim so the Simulation stays a pure + // function of (seed, config, commands); written to the replay header + // (see docs/replay_design.md "Seed and config"). + seed = std::random_device{}(); + } + std::unique_ptr sim = std::make_unique(std::move(config), seed); - MainWindow window(sim.get(), std::string(CONFIG_DIR)); + MainWindow window(sim.get(), std::string(CONFIG_DIR), replay); window.show(); const int ret = application.exec(); diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 0ea2184..24c819d 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -5,6 +5,8 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h + ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h + ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h @@ -25,6 +27,8 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.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 9163c96..74bd61b 100644 --- a/src/lib/sim/CommandManager.cpp +++ b/src/lib/sim/CommandManager.cpp @@ -22,6 +22,10 @@ CommandManager::~CommandManager() = default; void CommandManager::enqueue(std::shared_ptr command) { + if (m_replayMode) + { + return; // playback is driven by the recorded stream; ignore live input + } if (command) { m_queue.push_back(std::move(command)); @@ -74,6 +78,11 @@ void CommandManager::setRecorder(std::unique_ptr recorder) } } +void CommandManager::setReplayMode(bool replayMode) +{ + m_replayMode = replayMode; +} + void CommandManager::recordTickCheckpoint() { if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0)) diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h index 29e57ff..ddbd686 100644 --- a/src/lib/sim/CommandManager.h +++ b/src/lib/sim/CommandManager.h @@ -39,6 +39,10 @@ public: // Passing nullptr detaches/stops recording. void setRecorder(std::unique_ptr recorder); + // In replay mode, enqueue() is a no-op: the queue is driven by the recorded + // stream (ReplayPlayer), so stray live input produces nothing. + void setReplayMode(bool replayMode); + // 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(); @@ -47,4 +51,5 @@ private: Simulation& m_simulation; std::vector> m_queue; std::unique_ptr m_recorder; + bool m_replayMode = false; }; diff --git a/src/lib/sim/CommandSerializer.cpp b/src/lib/sim/CommandSerializer.cpp index ec41e49..1a0d80b 100644 --- a/src/lib/sim/CommandSerializer.cpp +++ b/src/lib/sim/CommandSerializer.cpp @@ -1,6 +1,7 @@ #include "CommandSerializer.h" #include +#include #include "BuildingType.h" #include "Command.h" @@ -21,6 +22,59 @@ char rotationToChar(Rotation rotation) return 'E'; } +Rotation rotationFromString(const std::string& token) +{ + if (token == "N") { return Rotation::North; } + if (token == "S") { return Rotation::South; } + if (token == "W") { return Rotation::West; } + return Rotation::East; +} + +// Reads " ( )*" from the stream. Sets ok=false on +// a stream failure. +ShipLayoutConfig parseLayout(std::istringstream& in, bool& ok) +{ + ShipLayoutConfig layout; + int count = 0; + if (!(in >> count) || count < 0) { ok = false; return layout; } + for (int i = 0; i < count; ++i) + { + PlacedModule placed; + std::string rotToken; + int x = 0; + int y = 0; + if (!(in >> placed.moduleId >> x >> y >> rotToken)) { ok = false; return layout; } + placed.position = QPoint(x, y); + placed.rotation = rotationFromString(rotToken); + layout.placedModules.push_back(placed); + } + return layout; +} + +// Reads " ()* ()*" from the stream. +void parseFilters(std::istringstream& in, + std::vector& filterA, + std::vector& filterB, + bool& ok) +{ + int countA = 0; + if (!(in >> countA) || countA < 0) { ok = false; return; } + for (int i = 0; i < countA; ++i) + { + std::string id; + if (!(in >> id)) { ok = false; return; } + filterA.push_back(ItemType{id}); + } + int countB = 0; + if (!(in >> countB) || countB < 0) { ok = false; return; } + for (int i = 0; i < countB; ++i) + { + std::string id; + if (!(in >> id)) { ok = false; return; } + filterB.push_back(ItemType{id}); + } +} + // " ( )*" void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout) { @@ -132,3 +186,129 @@ std::string serializeCommand(const Command& command) return out.str(); } + +std::shared_ptr parseCommand(const std::string& tokens) +{ + std::istringstream in(tokens); + std::string verb; + if (!(in >> verb)) + { + return nullptr; + } + + bool ok = true; + + if (verb == "place") + { + std::shared_ptr c = std::make_shared(); + std::string typeToken; + std::string rotToken; + int x = 0; + int y = 0; + if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; } + const std::optional type = parseBuildingType(typeToken); + if (!type.has_value()) { return nullptr; } + c->type = *type; + c->anchor = QPoint(x, y); + c->rotation = rotationFromString(rotToken); + + std::string segment; + while (in >> segment) + { + if (segment == "recipe") + { + std::string id; + if (!(in >> id)) { return nullptr; } + c->recipeId = id; + } + else if (segment == "layout") + { + c->shipLayout = parseLayout(in, ok); + if (!ok) { return nullptr; } + } + else if (segment == "filters") + { + parseFilters(in, c->splitterFilterA, c->splitterFilterB, ok); + if (!ok) { return nullptr; } + c->hasSplitterFilters = true; + } + else + { + return nullptr; + } + } + return c; + } + if (verb == "demolish") + { + std::shared_ptr c = std::make_shared(); + if (!(in >> c->id)) { return nullptr; } + return c; + } + if (verb == "rotate") + { + std::shared_ptr c = std::make_shared(); + std::string rotToken; + if (!(in >> c->id >> rotToken)) { return nullptr; } + c->newRotation = rotationFromString(rotToken); + return c; + } + if (verb == "setrecipe") + { + std::shared_ptr c = std::make_shared(); + if (!(in >> c->id >> c->recipeId)) { return nullptr; } + return c; + } + if (verb == "setlayout") + { + std::shared_ptr c = std::make_shared(); + if (!(in >> c->id)) { return nullptr; } + c->layout = parseLayout(in, ok); + if (!ok) { return nullptr; } + return c; + } + if (verb == "sitefilters") + { + std::shared_ptr c = + std::make_shared(); + if (!(in >> c->id)) { return nullptr; } + parseFilters(in, c->filterA, c->filterB, ok); + if (!ok) { return nullptr; } + return c; + } + if (verb == "splitterfilters") + { + std::shared_ptr c = + std::make_shared(); + int x = 0; + int y = 0; + if (!(in >> x >> y)) { return nullptr; } + c->tile = QPoint(x, y); + parseFilters(in, c->filterA, c->filterB, ok); + if (!ok) { return nullptr; } + return c; + } + if (verb == "clearbelt") + { + std::shared_ptr c = std::make_shared(); + int count = 0; + if (!(in >> count) || count < 0) { return nullptr; } + for (int i = 0; i < count; ++i) + { + int x = 0; + int y = 0; + if (!(in >> x >> y)) { return nullptr; } + c->tiles.push_back(QPoint(x, y)); + } + return c; + } + if (verb == "schematic") + { + std::shared_ptr c = + std::make_shared(); + if (!(in >> c->choiceIndex)) { return nullptr; } + return c; + } + + return nullptr; +} diff --git a/src/lib/sim/CommandSerializer.h b/src/lib/sim/CommandSerializer.h index 990eadd..6f5df8e 100644 --- a/src/lib/sim/CommandSerializer.h +++ b/src/lib/sim/CommandSerializer.h @@ -1,5 +1,6 @@ #pragma once +#include #include struct Command; @@ -13,3 +14,8 @@ struct Command; // 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); + +// Inverse of serializeCommand: parses the command token sequence (the part of a +// replay line after the leading tick) back into a Command. Returns nullptr if the +// tokens are malformed or reference an unknown building type. +std::shared_ptr parseCommand(const std::string& tokens); diff --git a/src/lib/sim/ReplayPlayer.cpp b/src/lib/sim/ReplayPlayer.cpp new file mode 100644 index 0000000..bc4aa50 --- /dev/null +++ b/src/lib/sim/ReplayPlayer.cpp @@ -0,0 +1,56 @@ +#include "ReplayPlayer.h" + +#include + +#include "Command.h" +#include "Simulation.h" + +ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector entries) + : m_simulation(simulation) + , m_entries(std::move(entries)) +{ +} + +void ReplayPlayer::start() +{ + processEntriesAt(0); +} + +void ReplayPlayer::advanceTo(Tick tick) +{ + processEntriesAt(tick); +} + +bool ReplayPlayer::isFinished() const +{ + return m_desyncTick.has_value() || m_cursor >= m_entries.size(); +} + +std::optional ReplayPlayer::getDesyncTick() const +{ + return m_desyncTick; +} + +void ReplayPlayer::processEntriesAt(Tick tick) +{ + // Consume entries for this tick in file order. The recording writes, at a + // given tick: the periodic checksum (if any) first, then command lines each + // followed by their post-apply checksum — so applying/verifying in file order + // reproduces the original sequence exactly. + while (!m_desyncTick.has_value() + && m_cursor < m_entries.size() + && m_entries[m_cursor].tick == tick) + { + const ReplayEntry& entry = m_entries[m_cursor]; + ++m_cursor; + + if (entry.isCommand) + { + m_simulation.apply(*entry.command); + } + else if (m_simulation.rngFingerprint() != entry.fingerprint) + { + m_desyncTick = tick; + } + } +} diff --git a/src/lib/sim/ReplayPlayer.h b/src/lib/sim/ReplayPlayer.h new file mode 100644 index 0000000..558dab2 --- /dev/null +++ b/src/lib/sim/ReplayPlayer.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +#include "ReplayReader.h" +#include "Tick.h" + +class Simulation; + +// Drives playback of a parsed replay against a Simulation: applies each recorded +// command at its recorded tick (through Simulation::apply) and verifies the RNG +// checksums, reporting the first desync. Entries are consumed in file order, +// which reproduces the exact command/tick interleaving of the original run. +// +// Cadence (mirrors how the run was recorded so checksums line up): +// player.start(); // process tick-0 entries before the first tick +// each frame, for each tick to run: +// if (player.isFinished()) break; +// sim.tick(); +// player.advanceTo(sim.currentTick()); +class ReplayPlayer +{ +public: + ReplayPlayer(Simulation& simulation, std::vector entries); + + // Process all entries recorded at tick 0 (the initial checksum and any + // commands issued before the first tick). Call once, before the first tick. + void start(); + + // Process all entries recorded at `tick`. Call once after each sim.tick(). + void advanceTo(Tick tick); + + // True once all entries are consumed or a desync was detected. + bool isFinished() const; + + // The tick at which the recomputed checksum first diverged, if any. + std::optional getDesyncTick() const; + +private: + void processEntriesAt(Tick tick); + + Simulation& m_simulation; + std::vector m_entries; + std::size_t m_cursor = 0; + std::optional m_desyncTick; +}; diff --git a/src/lib/sim/ReplayReader.cpp b/src/lib/sim/ReplayReader.cpp new file mode 100644 index 0000000..5ce1d03 --- /dev/null +++ b/src/lib/sim/ReplayReader.cpp @@ -0,0 +1,145 @@ +#include "ReplayReader.h" + +#include +#include +#include + +#include "Command.h" +#include "CommandSerializer.h" + +namespace +{ +void stripCarriageReturn(std::string& line) +{ + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } +} + +// Splits "key value with spaces" into (key, remainder). Remainder may be empty. +void splitKeyValue(const std::string& line, std::string& key, std::string& value) +{ + const std::string::size_type space = line.find(' '); + if (space == std::string::npos) + { + key = line; + value.clear(); + } + else + { + key = line.substr(0, space); + value = line.substr(space + 1); + } +} +} // namespace + +std::optional readReplayFile(const std::string& path) +{ + std::ifstream stream(path, std::ios::in); + if (!stream.is_open()) + { + return std::nullopt; + } + + ParsedReplay replay; + std::string line; + + // --- Header (up to the "---" separator) --- + bool sawSeparator = false; + while (std::getline(stream, line)) + { + stripCarriageReturn(line); + if (line == "---") + { + sawSeparator = true; + break; + } + if (line.empty() || line[0] == '#') + { + continue; // banner / blank + } + + std::string key; + std::string value; + splitKeyValue(line, key, value); + try + { + if (key == "version") { replay.header.version = std::stoi(value); } + else if (key == "build") { replay.header.build = value; } + else if (key == "seed") { replay.header.seed = static_cast(std::stoul(value)); } + else if (key == "config_hash") { replay.header.configHash = value; } + else if (key == "timestamp") { replay.header.timestamp = value; } + } + catch (const std::exception&) + { + return std::nullopt; + } + } + + if (!sawSeparator) + { + return std::nullopt; + } + + // --- Command / checksum stream --- + while (std::getline(stream, line)) + { + stripCarriageReturn(line); + if (line.empty()) + { + continue; + } + + if (line[0] == '#') + { + // "# checksum " + std::istringstream in(line); + std::string hash; + std::string keyword; + ReplayEntry entry; + in >> hash >> keyword >> entry.tick >> hash; + if (keyword != "checksum") + { + continue; // unknown comment line — ignore + } + try + { + entry.fingerprint = std::stoull(hash, nullptr, 16); + } + catch (const std::exception&) + { + return std::nullopt; + } + entry.isCommand = false; + replay.entries.push_back(std::move(entry)); + continue; + } + + // " " + const std::string::size_type space = line.find(' '); + if (space == std::string::npos) + { + return std::nullopt; + } + ReplayEntry entry; + try + { + entry.tick = std::stoll(line.substr(0, space)); + } + catch (const std::exception&) + { + return std::nullopt; + } + std::shared_ptr command = parseCommand(line.substr(space + 1)); + if (!command) + { + return std::nullopt; + } + entry.isCommand = true; + entry.command = std::move(command); + replay.entries.push_back(std::move(entry)); + } + + return replay; +} diff --git a/src/lib/sim/ReplayReader.h b/src/lib/sim/ReplayReader.h new file mode 100644 index 0000000..17d9d15 --- /dev/null +++ b/src/lib/sim/ReplayReader.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "Tick.h" + +struct Command; + +struct ReplayHeader +{ + int version = 0; + std::string build; + unsigned int seed = 0; + std::string configHash; + std::string timestamp; +}; + +// One entry of the replay stream: either a command (applied at its tick) or an +// RNG checksum (verified at its tick). Entries are kept in file order, which is +// the canonical order the playback driver reproduces. +struct ReplayEntry +{ + Tick tick = 0; + bool isCommand = false; + std::shared_ptr command; // set iff isCommand + std::uint64_t fingerprint = 0; // set iff !isCommand +}; + +struct ParsedReplay +{ + ReplayHeader header; + std::vector entries; +}; + +// Parses a replay file (header + command/checksum stream). Returns nullopt on an +// I/O failure or malformed content (e.g. an unparseable command line). +std::optional readReplayFile(const std::string& path); diff --git a/src/lib/sim/ReplayRecorder.cpp b/src/lib/sim/ReplayRecorder.cpp index e0cba69..ac03dd7 100644 --- a/src/lib/sim/ReplayRecorder.cpp +++ b/src/lib/sim/ReplayRecorder.cpp @@ -42,10 +42,10 @@ ReplayRecorder::~ReplayRecorder() close(); } -std::string ReplayRecorder::computeConfigHash() const +std::string computeReplayConfigHash(const std::string& configDir) { Hasher hasher; - QDir dir(QString::fromStdString(m_configDir)); + QDir dir(QString::fromStdString(configDir)); const QStringList files = dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name); for (const QString& name : files) @@ -80,7 +80,7 @@ void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFing m_stream << "version " << kReplayFormatVersion << "\n"; m_stream << "build " << kBuildTag << "\n"; m_stream << "seed " << seed << "\n"; - m_stream << "config_hash " << computeConfigHash() << "\n"; + m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n"; m_stream << "timestamp " << QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n"; m_stream << "---\n"; diff --git a/src/lib/sim/ReplayRecorder.h b/src/lib/sim/ReplayRecorder.h index d29af0a..0e1dff6 100644 --- a/src/lib/sim/ReplayRecorder.h +++ b/src/lib/sim/ReplayRecorder.h @@ -8,6 +8,10 @@ struct Command; +// 64-bit hash (16-char hex) over the *.toml files in configDir. Stored in the +// replay header and recomputed on playback to detect a config mismatch. +std::string computeReplayConfigHash(const std::string& configDir); + // 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 @@ -43,9 +47,6 @@ public: 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; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 1a29625..cfa47c6 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -24,4 +24,5 @@ add_files( DeterminismTest.cpp CommandTest.cpp ReplayRecorderTest.cpp + ReplayPlaybackTest.cpp ) diff --git a/src/test/ReplayPlaybackTest.cpp b/src/test/ReplayPlaybackTest.cpp new file mode 100644 index 0000000..e34addd --- /dev/null +++ b/src/test/ReplayPlaybackTest.cpp @@ -0,0 +1,151 @@ +#include "catch.hpp" + +#include +#include + +#include +#include + +#include "Command.h" +#include "CommandManager.h" +#include "CommandSerializer.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "ReplayPlayer.h" +#include "ReplayReader.h" +#include "ReplayRecorder.h" +#include "Rotation.h" +#include "Simulation.h" + +namespace +{ +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +std::string tempOutputDir() +{ + return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString(); +} + +std::shared_ptr place(BuildingType type, QPoint anchor) +{ + std::shared_ptr command = std::make_shared(); + command->type = type; + command->anchor = anchor; + return command; +} +} // namespace + +// --------------------------------------------------------------------------- +// Round-trip +// --------------------------------------------------------------------------- + +TEST_CASE("parseCommand inverts serializeCommand", "[replay]") +{ + PlaceBuildingCommand placeCommand; + placeCommand.type = BuildingType::Shipyard; + placeCommand.anchor = QPoint(-3, 2); + placeCommand.rotation = Rotation::West; + placeCommand.recipeId = "some_ship"; + ShipLayoutConfig layout; + layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 0), Rotation::North}); + placeCommand.shipLayout = layout; + + const std::string text = serializeCommand(placeCommand); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); +} + +TEST_CASE("parseCommand round-trips every command verb", "[replay]") +{ + SetSplitterFiltersCommand filters; + filters.tile = QPoint(3, 9); + filters.filterA = { ItemType{"iron_ore"} }; + filters.filterB = { ItemType{"coal"}, ItemType{"copper_ore"} }; + + ClearBeltTilesCommand clear; + clear.tiles = { QPoint(0, 0), QPoint(-1, 4) }; + + DemolishCommand demolish; + demolish.id = 5; + + for (const Command* command : { static_cast(&filters), + static_cast(&clear), + static_cast(&demolish) }) + { + const std::string text = serializeCommand(*command); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); + } +} + +TEST_CASE("parseCommand rejects malformed input", "[replay]") +{ + REQUIRE(parseCommand("") == nullptr); + REQUIRE(parseCommand("place not_a_type 0 0 E") == nullptr); + REQUIRE(parseCommand("place miner 0 0 E bogus") == nullptr); + REQUIRE(parseCommand("nonsense 1 2 3") == nullptr); +} + +// --------------------------------------------------------------------------- +// Record -> read -> replay equivalence +// --------------------------------------------------------------------------- + +TEST_CASE("a recorded run replays to byte-identical state with no desync", "[replay]") +{ + const unsigned int seed = 314159u; + + // --- Record a scripted run, mimicking the frame cadence (drain, then ticks). --- + std::string replayPath; + std::uint64_t recordedFinalChecksum = 0; + { + Simulation rec(loadConfig(), seed); + CommandManager manager(rec); + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + + // Frame at tick 0: place a miner. + manager.enqueue(place(BuildingType::Miner, QPoint(-3, 0))); + manager.drain(); + for (int i = 0; i < 90; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + // Frame at tick 90 (a checksum boundary): place a belt — exercises the + // periodic-checksum-then-command ordering at one tick. + manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0))); + manager.drain(); + for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + replayPath = recorderPtr->currentFilePath(); + recordedFinalChecksum = rec.computeStateChecksum(); + manager.setRecorder(nullptr); // close the file + } + + // --- Read it back. --- + const std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + REQUIRE(parsed->header.seed == seed); + REQUIRE(parsed->header.version == 1); + REQUIRE_FALSE(parsed->entries.empty()); + + // --- Replay it. --- + Simulation play(loadConfig(), parsed->header.seed); + ReplayPlayer player(play, parsed->entries); + player.start(); + while (!player.isFinished()) + { + play.tick(); + player.advanceTo(play.currentTick()); + } + + REQUIRE_FALSE(player.getDesyncTick().has_value()); + REQUIRE(play.currentTick() == 150); + REQUIRE(play.computeStateChecksum() == recordedFinalChecksum); + + QFile::remove(QString::fromStdString(replayPath)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 326776e..c6d5d9a 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -27,6 +27,8 @@ #include "Building.h" #include "BuildingSystem.h" #include "Command.h" +#include "ReplayPlayer.h" +#include "ReplayReader.h" #include "ReplayRecorder.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" @@ -117,7 +119,7 @@ QPoint portBodyTile(QPoint portTile, Rotation direction) GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, const VisualsConfig* visuals, const std::string& configDir, - QWidget* parent) + const ParsedReplay* replay, QWidget* parent) : QOpenGLWidget(parent) , m_sim(sim) , m_config(config) @@ -150,13 +152,24 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, 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())); + if (replay) + { + // View-only playback: ignore live input and drive ticks from the recorded + // stream. No recorder (we are not creating a new run). + m_commandManager.setReplayMode(true); + m_replayPlayer = std::make_unique(*sim, replay->entries); + m_replayPlayer->start(); // process tick-0 entries before the first tick + } + else + { + // 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() @@ -173,20 +186,33 @@ void GameWorldView::onFrame() { const qint64 elapsed = m_frameTimer.restart(); - // Drain queued player commands once per frame, before the tick batch. This - // runs even at 0x so a paused player sees placed construction sites - // immediately, while staying deterministic (see docs/replay_design.md). - m_commandManager.drain(); - - // A drained Reset reinitialized the simulation; reset the view to match. - if (m_viewResetPending) + if (m_replayPlayer) { - m_viewResetPending = false; - resetForNewGame(); + // Playback: apply recorded commands at their ticks and verify checksums. + // Manual speed/pause still works; playback only moves forward. + const int ticks = m_tickDriver.advance( + static_cast(elapsed), m_gameSpeedMultiplier); + for (int i = 0; i < ticks; ++i) + { + if (m_replayPlayer->isFinished()) { break; } + m_sim->tick(); + m_replayPlayer->advanceTo(m_sim->currentTick()); + } } - - // Advance simulation + else { + // Drain queued player commands once per frame, before the tick batch. This + // runs even at 0x so a paused player sees placed construction sites + // immediately, while staying deterministic (see docs/replay_design.md). + m_commandManager.drain(); + + // A drained Reset reinitialized the simulation; reset the view to match. + if (m_viewResetPending) + { + m_viewResetPending = false; + resetForNewGame(); + } + const int ticks = m_tickDriver.advance( static_cast(elapsed), m_gameSpeedMultiplier); for (int i = 0; i < ticks; ++i) @@ -267,25 +293,31 @@ void GameWorldView::onFrame() } } - // Schematic choice available - if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown) + // Sim-state polls are input sources for the live game; in replay these are + // gated off (the recorded ApplySchematicChoice resolves the choice with no UI, + // and game-over becomes the passive "replay ended" state below). + if (!m_replayPlayer) { - m_schematicChoiceShown = true; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_sim->getPendingSchematicChoices())); - } - if (!m_sim->hasSchematicChoicesPending()) - { - m_schematicChoiceShown = false; - } + // Schematic choice available + if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown) + { + m_schematicChoiceShown = true; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_sim->getPendingSchematicChoices())); + } + if (!m_sim->hasSchematicChoicesPending()) + { + m_schematicChoiceShown = false; + } - // Game over check - if (m_sim->isGameOver() && !m_gameOverShown) - { - m_gameOverShown = true; - m_gameSpeedMultiplier = 0.0; - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); + // Game over check + if (m_sim->isGameOver() && !m_gameOverShown) + { + m_gameOverShown = true; + m_gameSpeedMultiplier = 0.0; + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + } } update(); @@ -311,6 +343,7 @@ void GameWorldView::paintGL() drawBeams(painter); drawOverlays(painter); drawScreenSpace(painter); + drawReplayOverlay(painter); } // --------------------------------------------------------------------------- @@ -1246,6 +1279,42 @@ void GameWorldView::drawScreenSpace(QPainter& /*painter*/) { } +void GameWorldView::drawReplayOverlay(QPainter& painter) +{ + if (!m_replayPlayer) { return; } + + painter.save(); + + QFont tag = painter.font(); + tag.setPixelSize(16); + tag.setBold(true); + painter.setFont(tag); + painter.setPen(QColor(255, 220, 80)); + painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY")); + + if (m_replayPlayer->isFinished()) + { + const std::optional desync = m_replayPlayer->getDesyncTick(); + QString message; + if (desync.has_value()) + { + message = tr("Desync at tick %1").arg(static_cast(*desync)); + painter.setPen(QColor(255, 90, 90)); + } + else + { + message = tr("Replay ended"); + painter.setPen(QColor(255, 255, 255)); + } + QFont big = painter.font(); + big.setPixelSize(28); + painter.setFont(big); + painter.drawText(rect(), Qt::AlignCenter, message); + } + + painter.restore(); +} + // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index e3ebee7..2ecf099 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -41,6 +41,8 @@ #include "VisualsConfig.h" struct Command; +struct ParsedReplay; +class ReplayPlayer; class Simulation; class QPainter; @@ -68,7 +70,7 @@ class GameWorldView : public QOpenGLWidget, public: GameWorldView(Simulation* sim, const GameConfig* config, const VisualsConfig* visuals, const std::string& configDir, - QWidget* parent = nullptr); + const ParsedReplay* replay, QWidget* parent = nullptr); ~GameWorldView() override; double gameSpeed() const; @@ -121,6 +123,7 @@ private: void drawBeams(QPainter& painter); void drawOverlays(QPainter& painter); void drawScreenSpace(QPainter& painter); + void drawReplayOverlay(QPainter& painter); float tilePx() const; float viewportWidthTiles() const; @@ -178,6 +181,9 @@ private: CommandManager m_commandManager; // A Reset command was enqueued; reset the view after the next drain applies it. bool m_viewResetPending = false; + // Non-null => view-only playback: ticks are driven by the recorded stream and + // live input is ignored (the CommandManager is in replay mode). + std::unique_ptr m_replayPlayer; TickDriver m_tickDriver; QElapsedTimer m_frameTimer; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 79a0952..717443c 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -31,18 +31,21 @@ #include "Tick.h" #include "VisualsLoader.h" -MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent) +MainWindow::MainWindow(Simulation* sim, const std::string& configDir, + std::shared_ptr replay, QWidget* parent) : QWidget(parent) , m_configDir(configDir) , m_visuals(VisualsLoader::load(configDir + "/visuals.toml")) , m_sim(sim) + , m_replay(std::move(replay)) { setWindowTitle(tr("Dota Factory")); resize(1280, 768); m_headerBar = new HeaderBar(this); - m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this); + m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, + m_replay.get(), this); m_sidePanel = new QWidget(this); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 9c0996a..3c09cbe 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -17,6 +18,7 @@ #include "Tick.h" #include "VisualsConfig.h" +struct ParsedReplay; class Simulation; class GameWorldView; class HeaderBar; @@ -37,7 +39,8 @@ class MainWindow : public QWidget, Q_OBJECT public: - MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr); + MainWindow(Simulation* sim, const std::string& configDir, + std::shared_ptr replay = nullptr, QWidget* parent = nullptr); ~MainWindow() override; protected: @@ -65,4 +68,5 @@ private: QWidget* m_sidePanel; std::vector m_layoutBlueprints; + std::shared_ptr m_replay; // non-null => view-only playback };