Rename the Demolish* terminology to Deconstruct* across the simulation, events, UI, tests, and docs (DemolishCommand and the DemolishMode* events become their Deconstruct* equivalents), aligning with the deconstruction queue at HEAD. Add per-building SVG icons for the build button grid (REQ-UI-BUILD-GRID): one color-chip icon per placeable building under data/icons/buildings, loaded from disk at runtime and rendered with QtSvg onto each build button. An unaffordable building's button shows a grey-recolored variant via a QIcon Disabled-mode pixmap (REQ-UI-BUILD-DISABLED). Adds the Qt5::Svg dependency and deploys Qt5Svg.dll. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7N59FsLA5e2kuVdqe4Uhc
293 lines
9.7 KiB
C++
293 lines
9.7 KiB
C++
#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;
|
|
}
|
|
|
|
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
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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) };
|
|
|
|
DeconstructCommand deconstruct;
|
|
deconstruct.id = 5;
|
|
|
|
for (const Command* command : { static_cast<const Command*>(&filters),
|
|
static_cast<const Command*>(&clear),
|
|
static_cast<const Command*>(&deconstruct) })
|
|
{
|
|
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);
|
|
}
|
|
|
|
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<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->getCurrentFilePath();
|
|
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.getCurrentTick());
|
|
}
|
|
|
|
REQUIRE_FALSE(player.getDesyncTick().has_value());
|
|
REQUIRE(play.getCurrentTick() == 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<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->getCurrentFilePath();
|
|
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.getCurrentTick());
|
|
}
|
|
|
|
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->getCurrentFilePath();
|
|
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.getCurrentTick());
|
|
}
|
|
|
|
REQUIRE_FALSE(player.getDesyncTick().has_value());
|
|
REQUIRE(play.getCurrentTick() == 2400);
|
|
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
|
|
|
|
QFile::remove(QString::fromStdString(replayPath));
|
|
}
|