replay: record every run to a replay file (Phase 2)

Hooks a recorder into the command chokepoint so each run is written to disk as
it plays, ready for playback in Phase 3.

- ReplayRecorder (lib): line-oriented append-friendly file — keyed header
  (version, build, seed, config_hash, timestamp), then '---', then tick-tagged
  command lines interleaved with "# checksum <tick> <hex>" RNG fingerprints.
  Each line is flushed so a crash leaves a valid partial file. Config hash is a
  64-bit FNV over the config dir's *.toml files; build tag is __DATE__/__TIME__.
- CommandSerializer (lib): per-command text (length-prefixed variable parts;
  ship layouts and splitter filters serialized inline). Reset is a file
  boundary, never a stream entry.
- CommandManager owns an optional ReplayRecorder: drain() records each applied
  command + a post-apply checksum; a drained Reset rolls to a new file;
  recordTickCheckpoint() (called per tick from onFrame) writes a checksum every
  30 ticks.
- Random seed generated in main and on restart (std::random_device); Simulation
  retains it via getSeed() for the header.
- GameWorldView attaches the recorder at construction (first file + tick-0
  checksum); replays land in <data>/replays named <timestamp>_<seed>.replay.

ReplayRecorderTest covers serialization, file well-formedness, file rolling,
and the CommandManager drain->record integration. Full suite green
(346 cases / 3377 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:
2026-06-30 20:08:04 +02:00
parent 82ca9080a5
commit 97b6f0d8fd
16 changed files with 650 additions and 20 deletions

View File

@@ -0,0 +1,186 @@
#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();
}