#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; } 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 // --------------------------------------------------------------------------- // 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); } 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 // --------------------------------------------------------------------------- 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)); } 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)); }