replay: closing tests and overlay polish (Phase 4)
- Round-trip: every command verb serialize->parse->re-serialize is identical; malformed input is rejected. - Equivalence: add a long ~2400-tick run through waves/combat that records, reads back, and replays to a byte-identical final state with no desync (alongside the existing short scenario). - Desync detection: corrupting one recorded checksum makes ReplayPlayer 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. Record + playback is now functionally complete and covered by headless tests. Full suite green (354 cases / 3418 assertions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
This commit is contained in:
@@ -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
|
the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but
|
||||||
not auto-tested here.)
|
not auto-tested here.)
|
||||||
|
|
||||||
### Phase 4 — Closing tests & polish
|
### Phase 4 — Closing tests & polish — DONE
|
||||||
|
|
||||||
- **Round-trip test:** serialize → parse → assert command equality.
|
- **Round-trip:** every command verb serializes → parses → re-serializes identically;
|
||||||
- **Replay-equivalence test (headless):** record a scripted run, play it back through the same
|
malformed input is rejected (`parseCommand` returns nullptr).
|
||||||
`lib` path, assert per-tick checksums match end-to-end — the real proof, no UI needed.
|
- **Replay-equivalence (headless):** a short scripted run and a **long ~2400-tick run through
|
||||||
- Mismatch-warning UX, end-of-replay overlay polish.
|
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
|
### Notes
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ std::shared_ptr<PlaceBuildingCommand> place(BuildingType type, QPoint anchor)
|
|||||||
command->anchor = anchor;
|
command->anchor = anchor;
|
||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void requireRoundTrip(const Command& command)
|
||||||
|
{
|
||||||
|
const std::string text = serializeCommand(command);
|
||||||
|
const std::shared_ptr<Command> parsed = parseCommand(text);
|
||||||
|
REQUIRE(parsed != nullptr);
|
||||||
|
REQUIRE(serializeCommand(*parsed) == text);
|
||||||
|
}
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -91,6 +99,43 @@ TEST_CASE("parseCommand rejects malformed input", "[replay]")
|
|||||||
REQUIRE(parseCommand("nonsense 1 2 3") == 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
|
// 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));
|
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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(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<ParsedReplay> 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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||||
|
ReplayRecorder* recorderPtr = recorder.get();
|
||||||
|
manager.setRecorder(std::move(recorder));
|
||||||
|
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> 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<ParsedReplay> 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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,3 +184,29 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]")
|
|||||||
REQUIRE(second.find("_2.replay") != std::string::npos);
|
REQUIRE(second.find("_2.replay") != std::string::npos);
|
||||||
recorder.close();
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -1294,6 +1294,9 @@ void GameWorldView::drawReplayOverlay(QPainter& painter)
|
|||||||
|
|
||||||
if (m_replayPlayer->isFinished())
|
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<Tick> desync = m_replayPlayer->getDesyncTick();
|
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
|
||||||
QString message;
|
QString message;
|
||||||
if (desync.has_value())
|
if (desync.has_value())
|
||||||
|
|||||||
Reference in New Issue
Block a user