Replay: deterministic record & playback (#4)
Add deterministic record/playback for a run. Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause. Reviewed-on: #4 Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com> Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
212
src/test/ReplayRecorderTest.cpp
Normal file
212
src/test/ReplayRecorderTest.cpp
Normal file
@@ -0,0 +1,212 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
|
||||
#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<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(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<PlaceBuildingCommand> place = std::make_shared<PlaceBuildingCommand>();
|
||||
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();
|
||||
}
|
||||
|
||||
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<ReplayRecorder> recorder =
|
||||
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||
ReplayRecorder* recorderPtr = recorder.get();
|
||||
manager.setRecorder(std::move(recorder));
|
||||
const std::string firstPath = recorderPtr->currentFilePath();
|
||||
|
||||
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
|
||||
reset->config = std::make_shared<GameConfig>(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));
|
||||
}
|
||||
Reference in New Issue
Block a user