replay: play back recorded runs via --replay (Phase 3)
Adds view-only playback: re-simulate from the recorded seed + commands and verify the RNG checksums. - ReplayReader (lib): parses a replay file into header + an ordered stream of command/checksum entries. CommandSerializer gains the inverse parseCommand (round-trips every verb; rejects malformed input). - ReplayPlayer (lib): the playback driver. Applies each command at its exact recorded tick and verifies checksums in file order (start() handles tick 0; advanceTo(tick) handles each tick after sim.tick()). Independent of replay-time speed/pause; reports the first desync tick. - CommandManager replay mode: enqueue() becomes a no-op so live input is ignored while the recorded stream drives application. - main.cpp: --replay <file> reads + validates (warns on version/config-hash mismatch), seeds the sim from the header, and threads the replay through MainWindow to GameWorldView. - GameWorldView: drives the player in onFrame (manual speed/pause kept, forward-only), gates the schematic-choices and game-over polls, and draws a "REPLAY" tag plus a passive "Replay ended" / "Desync at tick N" overlay. - computeReplayConfigHash factored out of ReplayRecorder for reuse by main. ReplayPlaybackTest records a scripted run, reads it back, replays it, and asserts no desync + byte-identical final state -- including the periodic-checksum-then-command ordering at a shared tick. Full suite green (350 cases / 3396 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:
@@ -406,23 +406,32 @@ Reshape mutations to flow through one path; behaviour unchanged.
|
||||
- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is
|
||||
well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.)
|
||||
|
||||
### Phase 3 — Playback
|
||||
### Phase 3 — Playback — DONE
|
||||
|
||||
- Implement the **reader/parser** for the format (header + commands + checksums).
|
||||
- Add the `--replay <file>` CLI path in `main`: validate config hash + version (warn on
|
||||
mismatch), construct `Simulation` from seed+config, construct `CommandManager` in **replay
|
||||
mode** (pre-filled, `addCommand` is a no-op).
|
||||
- Replay driver: each frame, drain commands due at the reached tick (same drain path), step
|
||||
ticks, **keep manual speed/pause**, playback only moves forward.
|
||||
- **Gate the two `onFrame` polls** (schematic-choices, game-over) off in replay mode; everything
|
||||
else (recipe/layout dialogs, escape menu) is input-driven and falls away automatically.
|
||||
- **Desync detection:** recompute the RNG checksum at each checkpoint, compare to the file,
|
||||
report "desync at tick N" on mismatch.
|
||||
- **Passive end:** when the command stream is exhausted / recorded game-over is reached, stop
|
||||
with a "replay ended" overlay instead of the restart dialog.
|
||||
- **Files:** new reader in `lib`; `main.cpp`; `CommandManager` (replay mode); `GameWorldView.cpp`
|
||||
(poll gating, end overlay).
|
||||
- **Exit criteria:** a recorded file plays back identically; checksums match throughout.
|
||||
- `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command
|
||||
(with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer`
|
||||
gained the inverse `parseCommand` (round-tripping every verb).
|
||||
- `--replay <file>` CLI path in `main`: reads the file, **warns** on version / config-hash
|
||||
mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the
|
||||
parsed replay through `MainWindow` to `GameWorldView`.
|
||||
- `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies
|
||||
each command at its **exact recorded tick** and verifies checksums **in file order**:
|
||||
`start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)`
|
||||
consumes that tick's entries (periodic checksum first, then command + its checksum — the order
|
||||
the file already has). This makes playback independent of replay-time speed/pause.
|
||||
- `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept,
|
||||
forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two
|
||||
sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are
|
||||
input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** /
|
||||
**"Desync at tick N"** overlay replaces the restart dialog.
|
||||
- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer`
|
||||
(`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager`
|
||||
(replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new
|
||||
`ReplayPlaybackTest.cpp`.
|
||||
- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back,
|
||||
replays it, and asserts **no desync** and a **byte-identical final state checksum** — including
|
||||
the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but
|
||||
not auto-tested here.)
|
||||
|
||||
### Phase 4 — Closing tests & polish
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
@@ -9,6 +11,8 @@
|
||||
#include "logging.h"
|
||||
#include "LogManager.h"
|
||||
#include "MainWindow.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
@@ -32,15 +36,54 @@ int main(int argc, char *argv[])
|
||||
QDir().mkdir(dataDir.dirName());
|
||||
}
|
||||
|
||||
// Optional "--replay <file>" launches view-only playback of a recorded run.
|
||||
std::optional<std::string> replayPath;
|
||||
for (int i = 1; i + 1 < argc; ++i)
|
||||
{
|
||||
if (std::string(argv[i]) == "--replay")
|
||||
{
|
||||
replayPath = argv[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
|
||||
// Random seed generated outside the sim so the Simulation stays a pure
|
||||
// function of (seed, config, commands); the seed is written to the replay
|
||||
// header (see docs/replay_design.md "Seed and config").
|
||||
const unsigned int seed = std::random_device{}();
|
||||
unsigned int seed = 0;
|
||||
std::shared_ptr<ParsedReplay> replay;
|
||||
if (replayPath.has_value())
|
||||
{
|
||||
std::optional<ParsedReplay> parsed = readReplayFile(*replayPath);
|
||||
if (!parsed.has_value())
|
||||
{
|
||||
LOG_ERROR("Failed to read replay file: " + *replayPath);
|
||||
return 1;
|
||||
}
|
||||
// Warn (but proceed) on identity mismatches: a different config or build
|
||||
// can desync playback (see docs/replay_design.md).
|
||||
if (parsed->header.version != 1)
|
||||
{
|
||||
LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version
|
||||
<< " differs from 1; playback may fail");
|
||||
}
|
||||
if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash)
|
||||
{
|
||||
LOG_WARNING("Replay config hash mismatch; playback may desync");
|
||||
}
|
||||
seed = parsed->header.seed;
|
||||
replay = std::make_shared<ParsedReplay>(std::move(*parsed));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Random seed generated outside the sim so the Simulation stays a pure
|
||||
// function of (seed, config, commands); written to the replay header
|
||||
// (see docs/replay_design.md "Seed and config").
|
||||
seed = std::random_device{}();
|
||||
}
|
||||
|
||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
|
||||
|
||||
MainWindow window(sim.get(), std::string(CONFIG_DIR));
|
||||
MainWindow window(sim.get(), std::string(CONFIG_DIR), replay);
|
||||
window.show();
|
||||
|
||||
const int ret = application.exec();
|
||||
|
||||
@@ -5,6 +5,8 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||
@@ -25,6 +27,8 @@ SET(SRCS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
|
||||
@@ -22,6 +22,10 @@ CommandManager::~CommandManager() = default;
|
||||
|
||||
void CommandManager::enqueue(std::shared_ptr<const Command> command)
|
||||
{
|
||||
if (m_replayMode)
|
||||
{
|
||||
return; // playback is driven by the recorded stream; ignore live input
|
||||
}
|
||||
if (command)
|
||||
{
|
||||
m_queue.push_back(std::move(command));
|
||||
@@ -74,6 +78,11 @@ void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
|
||||
}
|
||||
}
|
||||
|
||||
void CommandManager::setReplayMode(bool replayMode)
|
||||
{
|
||||
m_replayMode = replayMode;
|
||||
}
|
||||
|
||||
void CommandManager::recordTickCheckpoint()
|
||||
{
|
||||
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
|
||||
|
||||
@@ -39,6 +39,10 @@ public:
|
||||
// Passing nullptr detaches/stops recording.
|
||||
void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
|
||||
|
||||
// In replay mode, enqueue() is a no-op: the queue is driven by the recorded
|
||||
// stream (ReplayPlayer), so stray live input produces nothing.
|
||||
void setReplayMode(bool replayMode);
|
||||
|
||||
// Record a periodic RNG checksum if the current tick is on the checksum
|
||||
// cadence. Call once per simulated tick (from the tick loop).
|
||||
void recordTickCheckpoint();
|
||||
@@ -47,4 +51,5 @@ private:
|
||||
Simulation& m_simulation;
|
||||
std::vector<std::shared_ptr<const Command>> m_queue;
|
||||
std::unique_ptr<ReplayRecorder> m_recorder;
|
||||
bool m_replayMode = false;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "CommandSerializer.h"
|
||||
|
||||
#include <sstream>
|
||||
#include <vector>
|
||||
|
||||
#include "BuildingType.h"
|
||||
#include "Command.h"
|
||||
@@ -21,6 +22,59 @@ char rotationToChar(Rotation rotation)
|
||||
return 'E';
|
||||
}
|
||||
|
||||
Rotation rotationFromString(const std::string& token)
|
||||
{
|
||||
if (token == "N") { return Rotation::North; }
|
||||
if (token == "S") { return Rotation::South; }
|
||||
if (token == "W") { return Rotation::West; }
|
||||
return Rotation::East;
|
||||
}
|
||||
|
||||
// Reads "<count> (<moduleId> <x> <y> <rot>)*" from the stream. Sets ok=false on
|
||||
// a stream failure.
|
||||
ShipLayoutConfig parseLayout(std::istringstream& in, bool& ok)
|
||||
{
|
||||
ShipLayoutConfig layout;
|
||||
int count = 0;
|
||||
if (!(in >> count) || count < 0) { ok = false; return layout; }
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
PlacedModule placed;
|
||||
std::string rotToken;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> placed.moduleId >> x >> y >> rotToken)) { ok = false; return layout; }
|
||||
placed.position = QPoint(x, y);
|
||||
placed.rotation = rotationFromString(rotToken);
|
||||
layout.placedModules.push_back(placed);
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
// Reads "<countA> (<itemId>)* <countB> (<itemId>)*" from the stream.
|
||||
void parseFilters(std::istringstream& in,
|
||||
std::vector<ItemType>& filterA,
|
||||
std::vector<ItemType>& filterB,
|
||||
bool& ok)
|
||||
{
|
||||
int countA = 0;
|
||||
if (!(in >> countA) || countA < 0) { ok = false; return; }
|
||||
for (int i = 0; i < countA; ++i)
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { ok = false; return; }
|
||||
filterA.push_back(ItemType{id});
|
||||
}
|
||||
int countB = 0;
|
||||
if (!(in >> countB) || countB < 0) { ok = false; return; }
|
||||
for (int i = 0; i < countB; ++i)
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { ok = false; return; }
|
||||
filterB.push_back(ItemType{id});
|
||||
}
|
||||
}
|
||||
|
||||
// "<count> (<moduleId> <x> <y> <rot>)*"
|
||||
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
|
||||
{
|
||||
@@ -132,3 +186,129 @@ std::string serializeCommand(const Command& command)
|
||||
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::shared_ptr<Command> parseCommand(const std::string& tokens)
|
||||
{
|
||||
std::istringstream in(tokens);
|
||||
std::string verb;
|
||||
if (!(in >> verb))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ok = true;
|
||||
|
||||
if (verb == "place")
|
||||
{
|
||||
std::shared_ptr<PlaceBuildingCommand> c = std::make_shared<PlaceBuildingCommand>();
|
||||
std::string typeToken;
|
||||
std::string rotToken;
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; }
|
||||
const std::optional<BuildingType> type = parseBuildingType(typeToken);
|
||||
if (!type.has_value()) { return nullptr; }
|
||||
c->type = *type;
|
||||
c->anchor = QPoint(x, y);
|
||||
c->rotation = rotationFromString(rotToken);
|
||||
|
||||
std::string segment;
|
||||
while (in >> segment)
|
||||
{
|
||||
if (segment == "recipe")
|
||||
{
|
||||
std::string id;
|
||||
if (!(in >> id)) { return nullptr; }
|
||||
c->recipeId = id;
|
||||
}
|
||||
else if (segment == "layout")
|
||||
{
|
||||
c->shipLayout = parseLayout(in, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
}
|
||||
else if (segment == "filters")
|
||||
{
|
||||
parseFilters(in, c->splitterFilterA, c->splitterFilterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
c->hasSplitterFilters = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
return c;
|
||||
}
|
||||
if (verb == "demolish")
|
||||
{
|
||||
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "rotate")
|
||||
{
|
||||
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
|
||||
std::string rotToken;
|
||||
if (!(in >> c->id >> rotToken)) { return nullptr; }
|
||||
c->newRotation = rotationFromString(rotToken);
|
||||
return c;
|
||||
}
|
||||
if (verb == "setrecipe")
|
||||
{
|
||||
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
|
||||
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "setlayout")
|
||||
{
|
||||
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
c->layout = parseLayout(in, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "sitefilters")
|
||||
{
|
||||
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
|
||||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||
if (!(in >> c->id)) { return nullptr; }
|
||||
parseFilters(in, c->filterA, c->filterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "splitterfilters")
|
||||
{
|
||||
std::shared_ptr<SetSplitterFiltersCommand> c =
|
||||
std::make_shared<SetSplitterFiltersCommand>();
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> x >> y)) { return nullptr; }
|
||||
c->tile = QPoint(x, y);
|
||||
parseFilters(in, c->filterA, c->filterB, ok);
|
||||
if (!ok) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
if (verb == "clearbelt")
|
||||
{
|
||||
std::shared_ptr<ClearBeltTilesCommand> c = std::make_shared<ClearBeltTilesCommand>();
|
||||
int count = 0;
|
||||
if (!(in >> count) || count < 0) { return nullptr; }
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
if (!(in >> x >> y)) { return nullptr; }
|
||||
c->tiles.push_back(QPoint(x, y));
|
||||
}
|
||||
return c;
|
||||
}
|
||||
if (verb == "schematic")
|
||||
{
|
||||
std::shared_ptr<ApplySchematicChoiceCommand> c =
|
||||
std::make_shared<ApplySchematicChoiceCommand>();
|
||||
if (!(in >> c->choiceIndex)) { return nullptr; }
|
||||
return c;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
struct Command;
|
||||
@@ -13,3 +14,8 @@ struct Command;
|
||||
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it
|
||||
// is never serialized here.
|
||||
std::string serializeCommand(const Command& command);
|
||||
|
||||
// Inverse of serializeCommand: parses the command token sequence (the part of a
|
||||
// replay line after the leading tick) back into a Command. Returns nullptr if the
|
||||
// tokens are malformed or reference an unknown building type.
|
||||
std::shared_ptr<Command> parseCommand(const std::string& tokens);
|
||||
|
||||
56
src/lib/sim/ReplayPlayer.cpp
Normal file
56
src/lib/sim/ReplayPlayer.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#include "ReplayPlayer.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "Command.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries)
|
||||
: m_simulation(simulation)
|
||||
, m_entries(std::move(entries))
|
||||
{
|
||||
}
|
||||
|
||||
void ReplayPlayer::start()
|
||||
{
|
||||
processEntriesAt(0);
|
||||
}
|
||||
|
||||
void ReplayPlayer::advanceTo(Tick tick)
|
||||
{
|
||||
processEntriesAt(tick);
|
||||
}
|
||||
|
||||
bool ReplayPlayer::isFinished() const
|
||||
{
|
||||
return m_desyncTick.has_value() || m_cursor >= m_entries.size();
|
||||
}
|
||||
|
||||
std::optional<Tick> ReplayPlayer::getDesyncTick() const
|
||||
{
|
||||
return m_desyncTick;
|
||||
}
|
||||
|
||||
void ReplayPlayer::processEntriesAt(Tick tick)
|
||||
{
|
||||
// Consume entries for this tick in file order. The recording writes, at a
|
||||
// given tick: the periodic checksum (if any) first, then command lines each
|
||||
// followed by their post-apply checksum — so applying/verifying in file order
|
||||
// reproduces the original sequence exactly.
|
||||
while (!m_desyncTick.has_value()
|
||||
&& m_cursor < m_entries.size()
|
||||
&& m_entries[m_cursor].tick == tick)
|
||||
{
|
||||
const ReplayEntry& entry = m_entries[m_cursor];
|
||||
++m_cursor;
|
||||
|
||||
if (entry.isCommand)
|
||||
{
|
||||
m_simulation.apply(*entry.command);
|
||||
}
|
||||
else if (m_simulation.rngFingerprint() != entry.fingerprint)
|
||||
{
|
||||
m_desyncTick = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/lib/sim/ReplayPlayer.h
Normal file
48
src/lib/sim/ReplayPlayer.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "ReplayReader.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Simulation;
|
||||
|
||||
// Drives playback of a parsed replay against a Simulation: applies each recorded
|
||||
// command at its recorded tick (through Simulation::apply) and verifies the RNG
|
||||
// checksums, reporting the first desync. Entries are consumed in file order,
|
||||
// which reproduces the exact command/tick interleaving of the original run.
|
||||
//
|
||||
// Cadence (mirrors how the run was recorded so checksums line up):
|
||||
// player.start(); // process tick-0 entries before the first tick
|
||||
// each frame, for each tick to run:
|
||||
// if (player.isFinished()) break;
|
||||
// sim.tick();
|
||||
// player.advanceTo(sim.currentTick());
|
||||
class ReplayPlayer
|
||||
{
|
||||
public:
|
||||
ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries);
|
||||
|
||||
// Process all entries recorded at tick 0 (the initial checksum and any
|
||||
// commands issued before the first tick). Call once, before the first tick.
|
||||
void start();
|
||||
|
||||
// Process all entries recorded at `tick`. Call once after each sim.tick().
|
||||
void advanceTo(Tick tick);
|
||||
|
||||
// True once all entries are consumed or a desync was detected.
|
||||
bool isFinished() const;
|
||||
|
||||
// The tick at which the recomputed checksum first diverged, if any.
|
||||
std::optional<Tick> getDesyncTick() const;
|
||||
|
||||
private:
|
||||
void processEntriesAt(Tick tick);
|
||||
|
||||
Simulation& m_simulation;
|
||||
std::vector<ReplayEntry> m_entries;
|
||||
std::size_t m_cursor = 0;
|
||||
std::optional<Tick> m_desyncTick;
|
||||
};
|
||||
145
src/lib/sim/ReplayReader.cpp
Normal file
145
src/lib/sim/ReplayReader.cpp
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "ReplayReader.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandSerializer.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
void stripCarriageReturn(std::string& line)
|
||||
{
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
{
|
||||
line.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
// Splits "key value with spaces" into (key, remainder). Remainder may be empty.
|
||||
void splitKeyValue(const std::string& line, std::string& key, std::string& value)
|
||||
{
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
key = line;
|
||||
value.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
key = line.substr(0, space);
|
||||
value = line.substr(space + 1);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path)
|
||||
{
|
||||
std::ifstream stream(path, std::ios::in);
|
||||
if (!stream.is_open())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ParsedReplay replay;
|
||||
std::string line;
|
||||
|
||||
// --- Header (up to the "---" separator) ---
|
||||
bool sawSeparator = false;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line == "---")
|
||||
{
|
||||
sawSeparator = true;
|
||||
break;
|
||||
}
|
||||
if (line.empty() || line[0] == '#')
|
||||
{
|
||||
continue; // banner / blank
|
||||
}
|
||||
|
||||
std::string key;
|
||||
std::string value;
|
||||
splitKeyValue(line, key, value);
|
||||
try
|
||||
{
|
||||
if (key == "version") { replay.header.version = std::stoi(value); }
|
||||
else if (key == "build") { replay.header.build = value; }
|
||||
else if (key == "seed") { replay.header.seed = static_cast<unsigned int>(std::stoul(value)); }
|
||||
else if (key == "config_hash") { replay.header.configHash = value; }
|
||||
else if (key == "timestamp") { replay.header.timestamp = value; }
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawSeparator)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// --- Command / checksum stream ---
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '#')
|
||||
{
|
||||
// "# checksum <tick> <hex>"
|
||||
std::istringstream in(line);
|
||||
std::string hash;
|
||||
std::string keyword;
|
||||
ReplayEntry entry;
|
||||
in >> hash >> keyword >> entry.tick >> hash;
|
||||
if (keyword != "checksum")
|
||||
{
|
||||
continue; // unknown comment line — ignore
|
||||
}
|
||||
try
|
||||
{
|
||||
entry.fingerprint = std::stoull(hash, nullptr, 16);
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = false;
|
||||
replay.entries.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "<tick> <serialized command>"
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
ReplayEntry entry;
|
||||
try
|
||||
{
|
||||
entry.tick = std::stoll(line.substr(0, space));
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
std::shared_ptr<Command> command = parseCommand(line.substr(space + 1));
|
||||
if (!command)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = true;
|
||||
entry.command = std::move(command);
|
||||
replay.entries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return replay;
|
||||
}
|
||||
41
src/lib/sim/ReplayReader.h
Normal file
41
src/lib/sim/ReplayReader.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Tick.h"
|
||||
|
||||
struct Command;
|
||||
|
||||
struct ReplayHeader
|
||||
{
|
||||
int version = 0;
|
||||
std::string build;
|
||||
unsigned int seed = 0;
|
||||
std::string configHash;
|
||||
std::string timestamp;
|
||||
};
|
||||
|
||||
// One entry of the replay stream: either a command (applied at its tick) or an
|
||||
// RNG checksum (verified at its tick). Entries are kept in file order, which is
|
||||
// the canonical order the playback driver reproduces.
|
||||
struct ReplayEntry
|
||||
{
|
||||
Tick tick = 0;
|
||||
bool isCommand = false;
|
||||
std::shared_ptr<const Command> command; // set iff isCommand
|
||||
std::uint64_t fingerprint = 0; // set iff !isCommand
|
||||
};
|
||||
|
||||
struct ParsedReplay
|
||||
{
|
||||
ReplayHeader header;
|
||||
std::vector<ReplayEntry> entries;
|
||||
};
|
||||
|
||||
// Parses a replay file (header + command/checksum stream). Returns nullopt on an
|
||||
// I/O failure or malformed content (e.g. an unparseable command line).
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path);
|
||||
@@ -42,10 +42,10 @@ ReplayRecorder::~ReplayRecorder()
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ReplayRecorder::computeConfigHash() const
|
||||
std::string computeReplayConfigHash(const std::string& configDir)
|
||||
{
|
||||
Hasher hasher;
|
||||
QDir dir(QString::fromStdString(m_configDir));
|
||||
QDir dir(QString::fromStdString(configDir));
|
||||
const QStringList files =
|
||||
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
|
||||
for (const QString& name : files)
|
||||
@@ -80,7 +80,7 @@ void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFing
|
||||
m_stream << "version " << kReplayFormatVersion << "\n";
|
||||
m_stream << "build " << kBuildTag << "\n";
|
||||
m_stream << "seed " << seed << "\n";
|
||||
m_stream << "config_hash " << computeConfigHash() << "\n";
|
||||
m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n";
|
||||
m_stream << "timestamp "
|
||||
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
|
||||
m_stream << "---\n";
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
struct Command;
|
||||
|
||||
// 64-bit hash (16-char hex) over the *.toml files in configDir. Stored in the
|
||||
// replay header and recomputed on playback to detect a config mismatch.
|
||||
std::string computeReplayConfigHash(const std::string& configDir);
|
||||
|
||||
// Writes a replay file as the game runs (see docs/replay_design.md). The format
|
||||
// is line-oriented append-friendly text: a small keyed header, then one line per
|
||||
// command (tick-tagged) interleaved with RNG-state checksum lines for desync
|
||||
@@ -43,9 +47,6 @@ public:
|
||||
const std::string& currentFilePath() const;
|
||||
|
||||
private:
|
||||
// 64-bit hash over the *.toml files in m_configDir, as a 16-char hex string.
|
||||
std::string computeConfigHash() const;
|
||||
|
||||
std::string m_configDir;
|
||||
std::string m_outputDir;
|
||||
std::string m_filePath;
|
||||
|
||||
@@ -24,4 +24,5 @@ add_files(
|
||||
DeterminismTest.cpp
|
||||
CommandTest.cpp
|
||||
ReplayRecorderTest.cpp
|
||||
ReplayPlaybackTest.cpp
|
||||
)
|
||||
|
||||
151
src/test/ReplayPlaybackTest.cpp
Normal file
151
src/test/ReplayPlaybackTest.cpp
Normal file
@@ -0,0 +1,151 @@
|
||||
#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;
|
||||
}
|
||||
} // 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) };
|
||||
|
||||
DemolishCommand demolish;
|
||||
demolish.id = 5;
|
||||
|
||||
for (const Command* command : { static_cast<const Command*>(&filters),
|
||||
static_cast<const Command*>(&clear),
|
||||
static_cast<const Command*>(&demolish) })
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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->currentFilePath();
|
||||
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.currentTick());
|
||||
}
|
||||
|
||||
REQUIRE_FALSE(player.getDesyncTick().has_value());
|
||||
REQUIRE(play.currentTick() == 150);
|
||||
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
|
||||
|
||||
QFile::remove(QString::fromStdString(replayPath));
|
||||
}
|
||||
@@ -27,6 +27,8 @@
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "ReplayPlayer.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "DemolishModeChangedEvent.h"
|
||||
#include "EntityHitTest.h"
|
||||
@@ -117,7 +119,7 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||||
|
||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
QWidget* parent)
|
||||
const ParsedReplay* replay, QWidget* parent)
|
||||
: QOpenGLWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
@@ -150,13 +152,24 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
|
||||
registerForEvents();
|
||||
|
||||
// Record every run to disk. Replays live under <data>/replays, alongside the
|
||||
// config dir that was loaded. Attaching the recorder opens the first file and
|
||||
// writes the header for the initial run.
|
||||
QDir replayDir(QString::fromStdString(configDir));
|
||||
replayDir.cdUp();
|
||||
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
|
||||
configDir, replayDir.filePath("replays").toStdString()));
|
||||
if (replay)
|
||||
{
|
||||
// View-only playback: ignore live input and drive ticks from the recorded
|
||||
// stream. No recorder (we are not creating a new run).
|
||||
m_commandManager.setReplayMode(true);
|
||||
m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
|
||||
m_replayPlayer->start(); // process tick-0 entries before the first tick
|
||||
}
|
||||
else
|
||||
{
|
||||
// Record every run to disk. Replays live under <data>/replays, alongside
|
||||
// the config dir that was loaded. Attaching the recorder opens the first
|
||||
// file and writes the header for the initial run.
|
||||
QDir replayDir(QString::fromStdString(configDir));
|
||||
replayDir.cdUp();
|
||||
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
|
||||
configDir, replayDir.filePath("replays").toStdString()));
|
||||
}
|
||||
}
|
||||
|
||||
GameWorldView::~GameWorldView()
|
||||
@@ -173,20 +186,33 @@ void GameWorldView::onFrame()
|
||||
{
|
||||
const qint64 elapsed = m_frameTimer.restart();
|
||||
|
||||
// Drain queued player commands once per frame, before the tick batch. This
|
||||
// runs even at 0x so a paused player sees placed construction sites
|
||||
// immediately, while staying deterministic (see docs/replay_design.md).
|
||||
m_commandManager.drain();
|
||||
|
||||
// A drained Reset reinitialized the simulation; reset the view to match.
|
||||
if (m_viewResetPending)
|
||||
if (m_replayPlayer)
|
||||
{
|
||||
m_viewResetPending = false;
|
||||
resetForNewGame();
|
||||
// Playback: apply recorded commands at their ticks and verify checksums.
|
||||
// Manual speed/pause still works; playback only moves forward.
|
||||
const int ticks = m_tickDriver.advance(
|
||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||
for (int i = 0; i < ticks; ++i)
|
||||
{
|
||||
if (m_replayPlayer->isFinished()) { break; }
|
||||
m_sim->tick();
|
||||
m_replayPlayer->advanceTo(m_sim->currentTick());
|
||||
}
|
||||
}
|
||||
|
||||
// Advance simulation
|
||||
else
|
||||
{
|
||||
// Drain queued player commands once per frame, before the tick batch. This
|
||||
// runs even at 0x so a paused player sees placed construction sites
|
||||
// immediately, while staying deterministic (see docs/replay_design.md).
|
||||
m_commandManager.drain();
|
||||
|
||||
// A drained Reset reinitialized the simulation; reset the view to match.
|
||||
if (m_viewResetPending)
|
||||
{
|
||||
m_viewResetPending = false;
|
||||
resetForNewGame();
|
||||
}
|
||||
|
||||
const int ticks = m_tickDriver.advance(
|
||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||
for (int i = 0; i < ticks; ++i)
|
||||
@@ -267,25 +293,31 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
// Sim-state polls are input sources for the live game; in replay these are
|
||||
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
|
||||
// and game-over becomes the passive "replay ended" state below).
|
||||
if (!m_replayPlayer)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -311,6 +343,7 @@ void GameWorldView::paintGL()
|
||||
drawBeams(painter);
|
||||
drawOverlays(painter);
|
||||
drawScreenSpace(painter);
|
||||
drawReplayOverlay(painter);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1246,6 +1279,42 @@ void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
|
||||
{
|
||||
}
|
||||
|
||||
void GameWorldView::drawReplayOverlay(QPainter& painter)
|
||||
{
|
||||
if (!m_replayPlayer) { return; }
|
||||
|
||||
painter.save();
|
||||
|
||||
QFont tag = painter.font();
|
||||
tag.setPixelSize(16);
|
||||
tag.setBold(true);
|
||||
painter.setFont(tag);
|
||||
painter.setPen(QColor(255, 220, 80));
|
||||
painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));
|
||||
|
||||
if (m_replayPlayer->isFinished())
|
||||
{
|
||||
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
|
||||
QString message;
|
||||
if (desync.has_value())
|
||||
{
|
||||
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
|
||||
painter.setPen(QColor(255, 90, 90));
|
||||
}
|
||||
else
|
||||
{
|
||||
message = tr("Replay ended");
|
||||
painter.setPen(QColor(255, 255, 255));
|
||||
}
|
||||
QFont big = painter.font();
|
||||
big.setPixelSize(28);
|
||||
painter.setFont(big);
|
||||
painter.drawText(rect(), Qt::AlignCenter, message);
|
||||
}
|
||||
|
||||
painter.restore();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct Command;
|
||||
struct ParsedReplay;
|
||||
class ReplayPlayer;
|
||||
class Simulation;
|
||||
class QPainter;
|
||||
|
||||
@@ -68,7 +70,7 @@ class GameWorldView : public QOpenGLWidget,
|
||||
public:
|
||||
GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
QWidget* parent = nullptr);
|
||||
const ParsedReplay* replay, QWidget* parent = nullptr);
|
||||
~GameWorldView() override;
|
||||
|
||||
double gameSpeed() const;
|
||||
@@ -121,6 +123,7 @@ private:
|
||||
void drawBeams(QPainter& painter);
|
||||
void drawOverlays(QPainter& painter);
|
||||
void drawScreenSpace(QPainter& painter);
|
||||
void drawReplayOverlay(QPainter& painter);
|
||||
|
||||
float tilePx() const;
|
||||
float viewportWidthTiles() const;
|
||||
@@ -178,6 +181,9 @@ private:
|
||||
CommandManager m_commandManager;
|
||||
// A Reset command was enqueued; reset the view after the next drain applies it.
|
||||
bool m_viewResetPending = false;
|
||||
// Non-null => view-only playback: ticks are driven by the recorded stream and
|
||||
// live input is ignored (the CommandManager is in replay mode).
|
||||
std::unique_ptr<ReplayPlayer> m_replayPlayer;
|
||||
|
||||
TickDriver m_tickDriver;
|
||||
QElapsedTimer m_frameTimer;
|
||||
|
||||
@@ -31,18 +31,21 @@
|
||||
#include "Tick.h"
|
||||
#include "VisualsLoader.h"
|
||||
|
||||
MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent)
|
||||
MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
std::shared_ptr<ParsedReplay> replay, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_configDir(configDir)
|
||||
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
|
||||
, m_sim(sim)
|
||||
, m_replay(std::move(replay))
|
||||
{
|
||||
setWindowTitle(tr("Dota Factory"));
|
||||
resize(1280, 768);
|
||||
|
||||
m_headerBar = new HeaderBar(this);
|
||||
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this);
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
|
||||
m_replay.get(), this);
|
||||
|
||||
m_sidePanel = new QWidget(this);
|
||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -17,6 +18,7 @@
|
||||
#include "Tick.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct ParsedReplay;
|
||||
class Simulation;
|
||||
class GameWorldView;
|
||||
class HeaderBar;
|
||||
@@ -37,7 +39,8 @@ class MainWindow : public QWidget,
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr);
|
||||
MainWindow(Simulation* sim, const std::string& configDir,
|
||||
std::shared_ptr<ParsedReplay> replay = nullptr, QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
@@ -65,4 +68,5 @@ private:
|
||||
QWidget* m_sidePanel;
|
||||
|
||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user