replay: play back recorded runs via --replay (Phase 3)
Adds view-only playback: re-simulate from the recorded seed + commands and verify the RNG checksums. - ReplayReader (lib): parses a replay file into header + an ordered stream of command/checksum entries. CommandSerializer gains the inverse parseCommand (round-trips every verb; rejects malformed input). - ReplayPlayer (lib): the playback driver. Applies each command at its exact recorded tick and verifies checksums in file order (start() handles tick 0; advanceTo(tick) handles each tick after sim.tick()). Independent of replay-time speed/pause; reports the first desync tick. - CommandManager replay mode: enqueue() becomes a no-op so live input is ignored while the recorded stream drives application. - main.cpp: --replay <file> reads + validates (warns on version/config-hash mismatch), seeds the sim from the header, and threads the replay through MainWindow to GameWorldView. - GameWorldView: drives the player in onFrame (manual speed/pause kept, forward-only), gates the schematic-choices and game-over polls, and draws a "REPLAY" tag plus a passive "Replay ended" / "Desync at tick N" overlay. - computeReplayConfigHash factored out of ReplayRecorder for reuse by main. ReplayPlaybackTest records a scripted run, reads it back, replays it, and asserts no desync + byte-identical final state -- including the periodic-checksum-then-command ordering at a shared tick. Full suite green (350 cases / 3396 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:
151
src/test/ReplayPlaybackTest.cpp
Normal file
151
src/test/ReplayPlaybackTest.cpp
Normal file
@@ -0,0 +1,151 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
#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<PlaceBuildingCommand> place(BuildingType type, QPoint anchor)
|
||||
{
|
||||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||
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<Command> 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<const Command*>(&filters),
|
||||
static_cast<const Command*>(&clear),
|
||||
static_cast<const Command*>(&demolish) })
|
||||
{
|
||||
const std::string text = serializeCommand(*command);
|
||||
const std::shared_ptr<Command> 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<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(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<ParsedReplay> 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));
|
||||
}
|
||||
Reference in New Issue
Block a user