diff --git a/docs/replay_design.md b/docs/replay_design.md index b7380bb..366a93d 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -433,12 +433,28 @@ Reshape mutations to flow through one path; behaviour unchanged. 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 +### Phase 4 — Closing tests & polish — DONE -- **Round-trip test:** serialize → parse → assert command equality. -- **Replay-equivalence test (headless):** record a scripted run, play it back through the same - `lib` path, assert per-tick checksums match end-to-end — the real proof, no UI needed. -- Mismatch-warning UX, end-of-replay overlay polish. +- **Round-trip:** every command verb serializes → parses → re-serializes identically; + malformed input is rejected (`parseCommand` returns nullptr). +- **Replay-equivalence (headless):** a short scripted run and a **long ~2400-tick run through + waves/combat** each record → read → replay with **no desync** and a **byte-identical final + state checksum**. +- **Desync detection:** corrupting one recorded checksum makes the player report the exact + desync tick. +- **Reset boundary:** a `Reset` drained through `CommandManager` rolls the recorder to a new + file (named by the new seed). +- **Polish:** the end-of-replay / desync overlay dims the world behind the message for + readability; config/version mismatch is warned to the log on launch (its visible consequence, + a desync, is already surfaced by the overlay). + +### Status + +Record + playback is functionally complete and covered by headless tests. Still deferred (per +this design): snapshots, save/load, backward-seek, cross-platform float hardening, expanding the +file checksum beyond RNG. Known minor rough edge: in replay mode the recipe/layout dialogs and +escape→restart can still open but do nothing (their commands hit the no-op enqueue); fully +disabling that input UI is polish, not correctness. ### Notes diff --git a/src/test/ReplayPlaybackTest.cpp b/src/test/ReplayPlaybackTest.cpp index e34addd..510e9b8 100644 --- a/src/test/ReplayPlaybackTest.cpp +++ b/src/test/ReplayPlaybackTest.cpp @@ -36,6 +36,14 @@ std::shared_ptr place(BuildingType type, QPoint anchor) command->anchor = anchor; return command; } + +void requireRoundTrip(const Command& command) +{ + const std::string text = serializeCommand(command); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); +} } // namespace // --------------------------------------------------------------------------- @@ -91,6 +99,43 @@ TEST_CASE("parseCommand rejects malformed input", "[replay]") REQUIRE(parseCommand("nonsense 1 2 3") == nullptr); } +TEST_CASE("every command verb round-trips through serialize/parse", "[replay]") +{ + SetRecipeCommand setRecipe; + setRecipe.id = 4; + setRecipe.recipeId = "smelt_iron"; + requireRoundTrip(setRecipe); + + SetShipLayoutCommand setLayout; + setLayout.id = 9; + setLayout.layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(0, 1), Rotation::East}); + setLayout.layout.placedModules.push_back(PlacedModule{"armor", QPoint(2, -1), Rotation::South}); + requireRoundTrip(setLayout); + + SetSiteSplitterFiltersCommand siteFilters; + siteFilters.id = 11; + siteFilters.filterA = { ItemType{"iron_ore"} }; + siteFilters.filterB = {}; + requireRoundTrip(siteFilters); + + RotateInPlaceCommand rotate; + rotate.id = 3; + rotate.newRotation = Rotation::South; + requireRoundTrip(rotate); + + ApplySchematicChoiceCommand schematic; + schematic.choiceIndex = 1; + requireRoundTrip(schematic); + + PlaceBuildingCommand placeWithFilters; + placeWithFilters.type = BuildingType::Splitter; + placeWithFilters.anchor = QPoint(2, 2); + placeWithFilters.hasSplitterFilters = true; + placeWithFilters.splitterFilterA = { ItemType{"iron_ore"}, ItemType{"coal"} }; + placeWithFilters.splitterFilterB = { ItemType{"copper_ore"} }; + requireRoundTrip(placeWithFilters); +} + // --------------------------------------------------------------------------- // Record -> read -> replay equivalence // --------------------------------------------------------------------------- @@ -149,3 +194,99 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep QFile::remove(QString::fromStdString(replayPath)); } + +TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay]") +{ + const unsigned int seed = 5u; + + std::string replayPath; + { + 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)); + for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + replayPath = recorderPtr->currentFilePath(); + manager.setRecorder(nullptr); + } + + std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + + // Corrupt the periodic checksum recorded at tick 30. + bool corrupted = false; + for (ReplayEntry& entry : parsed->entries) + { + if (!entry.isCommand && entry.tick == 30) + { + entry.fingerprint ^= 0x1ull; + corrupted = true; + break; + } + } + REQUIRE(corrupted); + + Simulation play(loadConfig(), parsed->header.seed); + ReplayPlayer player(play, parsed->entries); + player.start(); + while (!player.isFinished()) + { + play.tick(); + player.advanceTo(play.currentTick()); + } + + REQUIRE(player.getDesyncTick().has_value()); + REQUIRE(*player.getDesyncTick() == 30); + + QFile::remove(QString::fromStdString(replayPath)); +} + +TEST_CASE("a long recorded run (through waves and combat) replays with no desync", "[replay]") +{ + const unsigned int seed = 271828u; + + 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)); + + std::shared_ptr miner = place(BuildingType::Miner, QPoint(-3, 0)); + miner->recipeId = "mine_iron_ore"; + manager.enqueue(miner); + manager.drain(); + for (int i = 0; i < 1500; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0))); + manager.drain(); + for (int i = 0; i < 900; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + replayPath = recorderPtr->currentFilePath(); + recordedFinalChecksum = rec.computeStateChecksum(); + manager.setRecorder(nullptr); + } + + const std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + + 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() == 2400); + REQUIRE(play.computeStateChecksum() == recordedFinalChecksum); + + QFile::remove(QString::fromStdString(replayPath)); +} diff --git a/src/test/ReplayRecorderTest.cpp b/src/test/ReplayRecorderTest.cpp index 967891d..821e53b 100644 --- a/src/test/ReplayRecorderTest.cpp +++ b/src/test/ReplayRecorderTest.cpp @@ -184,3 +184,29 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]") REQUIRE(second.find("_2.replay") != std::string::npos); recorder.close(); } + +TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]") +{ + Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u); + CommandManager manager(sim); + + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + const std::string firstPath = recorderPtr->currentFilePath(); + + std::shared_ptr reset = std::make_shared(); + reset->config = std::make_shared(ConfigLoader::loadFromDirectory(CONFIG_DIR)); + reset->seed = 999u; + manager.enqueue(reset); + manager.drain(); + + const std::string secondPath = recorderPtr->currentFilePath(); + REQUIRE(firstPath != secondPath); + REQUIRE(secondPath.find("_999.replay") != std::string::npos); + + manager.setRecorder(nullptr); + QFile::remove(QString::fromStdString(firstPath)); + QFile::remove(QString::fromStdString(secondPath)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index c6d5d9a..264d9b0 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1294,6 +1294,9 @@ void GameWorldView::drawReplayOverlay(QPainter& painter) if (m_replayPlayer->isFinished()) { + // Dim the world so the end message reads clearly over it. + painter.fillRect(rect(), QColor(0, 0, 0, 140)); + const std::optional desync = m_replayPlayer->getDesyncTick(); QString message; if (desync.has_value())