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:
@@ -382,20 +382,29 @@ Reshape mutations to flow through one path; behaviour unchanged.
|
|||||||
still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly
|
still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly
|
||||||
(verified by grep — convention, not compile-enforced).
|
(verified by grep — convention, not compile-enforced).
|
||||||
|
|
||||||
### Phase 2 — Recording
|
### Phase 2 — Recording — DONE
|
||||||
|
|
||||||
- Implement the **line-oriented append writer**: header (seed, config hash, build/version,
|
- `ReplayRecorder` (lib) writes the **line-oriented append file**: header (`version`, `build`,
|
||||||
timestamp) + one line per command + checksum lines.
|
`seed`, `config_hash`, `timestamp`) then `---`, then one tick-tagged line per command
|
||||||
- Generate a **random seed outside the sim** (in `main`/reset), write to header.
|
interleaved with `# checksum <tick> <hex>` lines. Each line is flushed, so a crash mid-run
|
||||||
- Compute the **config hash** over the loaded config.
|
leaves a valid partial file. `CommandSerializer` produces the per-command text (length-prefixed
|
||||||
- Hook the **recorder at the apply chokepoint** in `CommandManager::drain()`: append each command
|
variable parts; `ShipLayoutConfig`/filters serialized inline). The build tag is
|
||||||
(tick-tagged), append an **RNG checksum after every command** and **every 30 ticks**.
|
`__DATE__ " " __TIME__`; the config hash is a 64-bit FNV over the `*.toml` files in the config
|
||||||
- **Lifecycle:** open a new file on `Simulation` construction and on each `reset()` (restart =
|
dir (re-hashed on playback to detect mismatch).
|
||||||
boundary); store in `data/`, named by timestamp+seed; retain everything.
|
- **Random seed** generated in `main` (and on each restart in `MainWindow`) via
|
||||||
- **Files:** new replay writer in `lib`; `main.cpp` (seed), config hash helper;
|
`std::random_device`; `Simulation` retains it (`getSeed()`) for the header.
|
||||||
`CommandManager`/`Simulation` for the tick checksum hook.
|
- **Recorder hooked at the chokepoint:** `CommandManager` owns an optional `ReplayRecorder`;
|
||||||
- **Exit criteria:** every run produces a well-formed, growing replay file; a crash mid-run still
|
`drain()` records each applied command (tick-tagged) + a post-apply RNG checksum, and
|
||||||
leaves a valid partial file.
|
`recordTickCheckpoint()` (called per tick from the `onFrame` loop) writes a checksum every 30
|
||||||
|
ticks. A drained `Reset` rolls the recorder to a new file (restart = boundary).
|
||||||
|
- **Lifecycle:** `GameWorldView` attaches the recorder at construction (opens the first file with
|
||||||
|
the initial seed + a tick-0 checksum); files live in `<data>/replays`, named
|
||||||
|
`<timestamp>_<seed>.replay`; everything is retained.
|
||||||
|
- **Files:** new `lib/sim/ReplayRecorder.{h,cpp}`, `CommandSerializer.{h,cpp}`; `Simulation`
|
||||||
|
(`getSeed`); `CommandManager` (recorder + tick checkpoint); `main.cpp` (seed);
|
||||||
|
`MainWindow.cpp` / `GameWorldView.{h,cpp}` (wiring); new `ReplayRecorderTest.cpp`.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <random>
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
@@ -32,7 +33,12 @@ int main(int argc, char *argv[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
|
|
||||||
|
// 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{}();
|
||||||
|
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));
|
||||||
window.show();
|
window.show();
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||||
@@ -21,6 +23,8 @@ SET(SRCS
|
|||||||
${SRCS}
|
${SRCS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
#include "CommandManager.h"
|
#include "CommandManager.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
|
#include "ReplayRecorder.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Periodic RNG checksum cadence (see docs/replay_design.md "Cadence").
|
||||||
|
constexpr Tick kChecksumIntervalTicks = 30;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
CommandManager::CommandManager(Simulation& simulation)
|
CommandManager::CommandManager(Simulation& simulation)
|
||||||
: m_simulation(simulation)
|
: m_simulation(simulation)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CommandManager::~CommandManager() = default;
|
||||||
|
|
||||||
void CommandManager::enqueue(std::shared_ptr<const Command> command)
|
void CommandManager::enqueue(std::shared_ptr<const Command> command)
|
||||||
{
|
{
|
||||||
if (command)
|
if (command)
|
||||||
@@ -23,7 +35,27 @@ void CommandManager::drain()
|
|||||||
// fresh state.
|
// fresh state.
|
||||||
for (const std::shared_ptr<const Command>& command : m_queue)
|
for (const std::shared_ptr<const Command>& command : m_queue)
|
||||||
{
|
{
|
||||||
m_simulation.apply(*command);
|
if (command->kind == CommandKind::Reset)
|
||||||
|
{
|
||||||
|
m_simulation.apply(*command);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
// Restart is a file boundary: a fresh file with the new seed.
|
||||||
|
m_recorder->startNewRun(m_simulation.getSeed(),
|
||||||
|
m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Commands drain before the tick batch, so currentTick is the count of
|
||||||
|
// completed ticks the command is pinned to.
|
||||||
|
const Tick tick = m_simulation.currentTick();
|
||||||
|
m_simulation.apply(*command);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m_queue.clear();
|
m_queue.clear();
|
||||||
}
|
}
|
||||||
@@ -32,3 +64,20 @@ bool CommandManager::hasPending() const
|
|||||||
{
|
{
|
||||||
return !m_queue.empty();
|
return !m_queue.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
|
||||||
|
{
|
||||||
|
m_recorder = std::move(recorder);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandManager::recordTickCheckpoint()
|
||||||
|
{
|
||||||
|
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
|
||||||
|
{
|
||||||
|
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct Command;
|
struct Command;
|
||||||
|
class ReplayRecorder;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
|
|
||||||
// Ordered queue that funnels every player command into the single
|
// Ordered queue that funnels every player command into the single
|
||||||
@@ -20,17 +21,30 @@ class CommandManager
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit CommandManager(Simulation& simulation);
|
explicit CommandManager(Simulation& simulation);
|
||||||
|
// Defined out-of-line so the unique_ptr<ReplayRecorder> member can be
|
||||||
|
// destroyed where ReplayRecorder is a complete type.
|
||||||
|
~CommandManager();
|
||||||
|
|
||||||
// Append a command for application at the next drain (FIFO order).
|
// Append a command for application at the next drain (FIFO order).
|
||||||
void enqueue(std::shared_ptr<const Command> command);
|
void enqueue(std::shared_ptr<const Command> command);
|
||||||
|
|
||||||
// Apply all queued commands in FIFO order through Simulation::apply, then
|
// Apply all queued commands in FIFO order through Simulation::apply, then
|
||||||
// clear the queue.
|
// clear the queue. If a recorder is attached, each applied command is recorded
|
||||||
|
// (a Reset rolls the recorder to a new file).
|
||||||
void drain();
|
void drain();
|
||||||
|
|
||||||
bool hasPending() const;
|
bool hasPending() const;
|
||||||
|
|
||||||
|
// Attach a recorder and start recording the current run. Ownership is taken.
|
||||||
|
// Passing nullptr detaches/stops recording.
|
||||||
|
void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Simulation& m_simulation;
|
Simulation& m_simulation;
|
||||||
std::vector<std::shared_ptr<const Command>> m_queue;
|
std::vector<std::shared_ptr<const Command>> m_queue;
|
||||||
|
std::unique_ptr<ReplayRecorder> m_recorder;
|
||||||
};
|
};
|
||||||
|
|||||||
134
src/lib/sim/CommandSerializer.cpp
Normal file
134
src/lib/sim/CommandSerializer.cpp
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
#include "CommandSerializer.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
char rotationToChar(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return 'N';
|
||||||
|
case Rotation::East: return 'E';
|
||||||
|
case Rotation::South: return 'S';
|
||||||
|
case Rotation::West: return 'W';
|
||||||
|
}
|
||||||
|
return 'E';
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<count> (<moduleId> <x> <y> <rot>)*"
|
||||||
|
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
|
||||||
|
{
|
||||||
|
out << layout.placedModules.size();
|
||||||
|
for (const PlacedModule& placed : layout.placedModules)
|
||||||
|
{
|
||||||
|
out << ' ' << placed.moduleId
|
||||||
|
<< ' ' << placed.position.x()
|
||||||
|
<< ' ' << placed.position.y()
|
||||||
|
<< ' ' << rotationToChar(placed.rotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<countA> (<itemId>)* <countB> (<itemId>)*"
|
||||||
|
void appendFilters(std::ostringstream& out,
|
||||||
|
const std::vector<ItemType>& filterA,
|
||||||
|
const std::vector<ItemType>& filterB)
|
||||||
|
{
|
||||||
|
out << filterA.size();
|
||||||
|
for (const ItemType& type : filterA) { out << ' ' << type.id; }
|
||||||
|
out << ' ' << filterB.size();
|
||||||
|
for (const ItemType& type : filterB) { out << ' ' << type.id; }
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string serializeCommand(const Command& command)
|
||||||
|
{
|
||||||
|
std::ostringstream out;
|
||||||
|
|
||||||
|
switch (command.kind)
|
||||||
|
{
|
||||||
|
case CommandKind::PlaceBuilding:
|
||||||
|
{
|
||||||
|
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||||
|
out << "place " << buildingTypeId(c.type)
|
||||||
|
<< ' ' << c.anchor.x() << ' ' << c.anchor.y()
|
||||||
|
<< ' ' << rotationToChar(c.rotation);
|
||||||
|
if (c.recipeId.has_value())
|
||||||
|
{
|
||||||
|
out << " recipe " << *c.recipeId;
|
||||||
|
}
|
||||||
|
if (c.shipLayout.has_value())
|
||||||
|
{
|
||||||
|
out << " layout ";
|
||||||
|
appendLayout(out, *c.shipLayout);
|
||||||
|
}
|
||||||
|
if (c.hasSplitterFilters)
|
||||||
|
{
|
||||||
|
out << " filters ";
|
||||||
|
appendFilters(out, c.splitterFilterA, c.splitterFilterB);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::Demolish:
|
||||||
|
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
|
||||||
|
break;
|
||||||
|
case CommandKind::RotateInPlace:
|
||||||
|
{
|
||||||
|
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||||
|
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetRecipe:
|
||||||
|
{
|
||||||
|
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||||
|
out << "setrecipe " << c.id << ' ' << c.recipeId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetShipLayout:
|
||||||
|
{
|
||||||
|
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||||
|
out << "setlayout " << c.id << ' ';
|
||||||
|
appendLayout(out, c.layout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSiteSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSiteSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||||
|
out << "sitefilters " << c.id << ' ';
|
||||||
|
appendFilters(out, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||||
|
out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' ';
|
||||||
|
appendFilters(out, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::ClearBeltTiles:
|
||||||
|
{
|
||||||
|
const ClearBeltTilesCommand& c = static_cast<const ClearBeltTilesCommand&>(command);
|
||||||
|
out << "clearbelt " << c.tiles.size();
|
||||||
|
for (const QPoint& tile : c.tiles)
|
||||||
|
{
|
||||||
|
out << ' ' << tile.x() << ' ' << tile.y();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::ApplySchematicChoice:
|
||||||
|
out << "schematic " << static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex;
|
||||||
|
break;
|
||||||
|
case CommandKind::Reset:
|
||||||
|
// A reset rolls the replay file; it is never written as a stream entry.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
15
src/lib/sim/CommandSerializer.h
Normal file
15
src/lib/sim/CommandSerializer.h
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
|
||||||
|
// Serializes a command to a single-line, space-delimited token sequence for the
|
||||||
|
// replay file (see docs/replay_design.md "File format: line-oriented ...").
|
||||||
|
// Config ids (building types, recipes, items, modules) are whitespace-free
|
||||||
|
// identifiers, so space delimiting is unambiguous; variable-length parts are
|
||||||
|
// length-prefixed so the matching parser (added in Phase 3) is unambiguous.
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
130
src/lib/sim/ReplayRecorder.cpp
Normal file
130
src/lib/sim/ReplayRecorder.cpp
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#include "ReplayRecorder.h"
|
||||||
|
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandSerializer.h"
|
||||||
|
#include "StateChecksum.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr const char* kReplayFormatVersion = "1";
|
||||||
|
|
||||||
|
// Build fingerprint: even a new local build can desync old replays (float
|
||||||
|
// reasons), so the header carries a per-build tag to warn on mismatch.
|
||||||
|
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
|
||||||
|
|
||||||
|
std::string toHex(std::uint64_t value)
|
||||||
|
{
|
||||||
|
std::ostringstream out;
|
||||||
|
out << std::hex << std::setw(16) << std::setfill('0') << value;
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
|
||||||
|
: m_configDir(std::move(configDir))
|
||||||
|
, m_outputDir(std::move(outputDir))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplayRecorder::~ReplayRecorder()
|
||||||
|
{
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ReplayRecorder::computeConfigHash() const
|
||||||
|
{
|
||||||
|
Hasher hasher;
|
||||||
|
QDir dir(QString::fromStdString(m_configDir));
|
||||||
|
const QStringList files =
|
||||||
|
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
|
||||||
|
for (const QString& name : files)
|
||||||
|
{
|
||||||
|
hasher.append(name.toStdString());
|
||||||
|
QFile file(dir.filePath(name));
|
||||||
|
if (file.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
const QByteArray bytes = file.readAll();
|
||||||
|
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toHex(hasher.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
|
||||||
|
{
|
||||||
|
close();
|
||||||
|
|
||||||
|
QDir().mkpath(QString::fromStdString(m_outputDir));
|
||||||
|
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||||
|
const QString fileName = timestamp + "_" + QString::number(seed) + ".replay";
|
||||||
|
m_filePath = QDir(QString::fromStdString(m_outputDir)).filePath(fileName).toStdString();
|
||||||
|
|
||||||
|
m_stream.open(m_filePath, std::ios::out | std::ios::trunc);
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_stream << "# dota_factory replay\n";
|
||||||
|
m_stream << "version " << kReplayFormatVersion << "\n";
|
||||||
|
m_stream << "build " << kBuildTag << "\n";
|
||||||
|
m_stream << "seed " << seed << "\n";
|
||||||
|
m_stream << "config_hash " << computeConfigHash() << "\n";
|
||||||
|
m_stream << "timestamp "
|
||||||
|
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
|
||||||
|
m_stream << "---\n";
|
||||||
|
m_stream << "# checksum 0 " << toHex(initialRngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::recordCommand(Tick tick, const Command& command,
|
||||||
|
std::uint64_t rngFingerprint)
|
||||||
|
{
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_stream << tick << ' ' << serializeCommand(command) << "\n";
|
||||||
|
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::recordChecksum(Tick tick, std::uint64_t rngFingerprint)
|
||||||
|
{
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::close()
|
||||||
|
{
|
||||||
|
if (m_stream.is_open())
|
||||||
|
{
|
||||||
|
m_stream.flush();
|
||||||
|
m_stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReplayRecorder::isOpen() const
|
||||||
|
{
|
||||||
|
return m_stream.is_open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& ReplayRecorder::currentFilePath() const
|
||||||
|
{
|
||||||
|
return m_filePath;
|
||||||
|
}
|
||||||
53
src/lib/sim/ReplayRecorder.h
Normal file
53
src/lib/sim/ReplayRecorder.h
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// detection. Each line is flushed so a crash mid-run still leaves a valid partial
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// One file = one run between Reset boundaries; startNewRun() closes the current
|
||||||
|
// file and opens a fresh one.
|
||||||
|
class ReplayRecorder
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// configDir: hashed (its *.toml files) into the header for config-mismatch
|
||||||
|
// detection on playback. outputDir: where .replay files are written.
|
||||||
|
ReplayRecorder(std::string configDir, std::string outputDir);
|
||||||
|
~ReplayRecorder();
|
||||||
|
|
||||||
|
ReplayRecorder(const ReplayRecorder&) = delete;
|
||||||
|
ReplayRecorder& operator=(const ReplayRecorder&) = delete;
|
||||||
|
|
||||||
|
// Close any current file, then open a fresh one (named <timestamp>_<seed>),
|
||||||
|
// write the header, and record an initial tick-0 checksum. A run boundary.
|
||||||
|
void startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint);
|
||||||
|
|
||||||
|
// Append one command line tagged with the tick it was applied at, followed by
|
||||||
|
// the post-apply RNG checksum.
|
||||||
|
void recordCommand(Tick tick, const Command& command, std::uint64_t rngFingerprint);
|
||||||
|
|
||||||
|
// Append a periodic RNG checksum line.
|
||||||
|
void recordChecksum(Tick tick, std::uint64_t rngFingerprint);
|
||||||
|
|
||||||
|
void close();
|
||||||
|
bool isOpen() const;
|
||||||
|
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;
|
||||||
|
std::ofstream m_stream;
|
||||||
|
};
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
Simulation::Simulation(GameConfig config, unsigned int seed)
|
Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||||
: m_config(std::move(config))
|
: m_config(std::move(config))
|
||||||
, m_rng(seed)
|
, m_rng(seed)
|
||||||
|
, m_seed(seed)
|
||||||
, m_currentTick(0)
|
, m_currentTick(0)
|
||||||
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
||||||
, m_nextBuildingId(1)
|
, m_nextBuildingId(1)
|
||||||
@@ -134,6 +135,7 @@ void Simulation::reset(unsigned int seed)
|
|||||||
{
|
{
|
||||||
EventManager::getInstance()->clearEvents();
|
EventManager::getInstance()->clearEvents();
|
||||||
m_rng.seed(seed);
|
m_rng.seed(seed);
|
||||||
|
m_seed = seed;
|
||||||
m_currentTick = 0;
|
m_currentTick = 0;
|
||||||
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
||||||
m_nextBuildingId = 1;
|
m_nextBuildingId = 1;
|
||||||
@@ -1057,6 +1059,11 @@ Tick Simulation::currentTick() const
|
|||||||
return m_currentTick;
|
return m_currentTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsigned int Simulation::getSeed() const
|
||||||
|
{
|
||||||
|
return m_seed;
|
||||||
|
}
|
||||||
|
|
||||||
int Simulation::buildingBlocksStock() const
|
int Simulation::buildingBlocksStock() const
|
||||||
{
|
{
|
||||||
return m_buildingBlocksStock;
|
return m_buildingBlocksStock;
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ public:
|
|||||||
void applySchematicChoice(int choiceIndex);
|
void applySchematicChoice(int choiceIndex);
|
||||||
|
|
||||||
Tick currentTick() const;
|
Tick currentTick() const;
|
||||||
|
// The seed this run was (re)initialized with; written to the replay header.
|
||||||
|
unsigned int getSeed() const;
|
||||||
int buildingBlocksStock() const;
|
int buildingBlocksStock() const;
|
||||||
bool isGameOver() const;
|
bool isGameOver() const;
|
||||||
double threatLevel() const;
|
double threatLevel() const;
|
||||||
@@ -144,6 +146,7 @@ private:
|
|||||||
|
|
||||||
GameConfig m_config;
|
GameConfig m_config;
|
||||||
std::mt19937 m_rng;
|
std::mt19937 m_rng;
|
||||||
|
unsigned int m_seed;
|
||||||
|
|
||||||
Tick m_currentTick;
|
Tick m_currentTick;
|
||||||
Tick m_nextDepartureTick;
|
Tick m_nextDepartureTick;
|
||||||
|
|||||||
@@ -23,4 +23,5 @@ add_files(
|
|||||||
RecipeSchematicTest.cpp
|
RecipeSchematicTest.cpp
|
||||||
DeterminismTest.cpp
|
DeterminismTest.cpp
|
||||||
CommandTest.cpp
|
CommandTest.cpp
|
||||||
|
ReplayRecorderTest.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
186
src/test/ReplayRecorderTest.cpp
Normal file
186
src/test/ReplayRecorderTest.cpp
Normal 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();
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
|
#include <QDir>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
@@ -25,6 +27,7 @@
|
|||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "Command.h"
|
#include "Command.h"
|
||||||
|
#include "ReplayRecorder.h"
|
||||||
#include "DemolishModeChangedEvent.h"
|
#include "DemolishModeChangedEvent.h"
|
||||||
#include "EntityHitTest.h"
|
#include "EntityHitTest.h"
|
||||||
#include "EntitySelectedEvent.h"
|
#include "EntitySelectedEvent.h"
|
||||||
@@ -113,7 +116,8 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
|
|||||||
|
|
||||||
|
|
||||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||||
const VisualsConfig* visuals, QWidget* parent)
|
const VisualsConfig* visuals, const std::string& configDir,
|
||||||
|
QWidget* parent)
|
||||||
: QOpenGLWidget(parent)
|
: QOpenGLWidget(parent)
|
||||||
, m_sim(sim)
|
, m_sim(sim)
|
||||||
, m_config(config)
|
, m_config(config)
|
||||||
@@ -145,6 +149,14 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
|||||||
m_frameTimer.start();
|
m_frameTimer.start();
|
||||||
|
|
||||||
registerForEvents();
|
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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
GameWorldView::~GameWorldView()
|
GameWorldView::~GameWorldView()
|
||||||
@@ -180,6 +192,8 @@ void GameWorldView::onFrame()
|
|||||||
for (int i = 0; i < ticks; ++i)
|
for (int i = 0; i < ticks; ++i)
|
||||||
{
|
{
|
||||||
m_sim->tick();
|
m_sim->tick();
|
||||||
|
// Periodic checksum (every 30 ticks) for replay desync detection.
|
||||||
|
m_commandManager.recordTickCheckpoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
@@ -66,7 +67,8 @@ class GameWorldView : public QOpenGLWidget,
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
GameWorldView(Simulation* sim, const GameConfig* config,
|
GameWorldView(Simulation* sim, const GameConfig* config,
|
||||||
const VisualsConfig* visuals, QWidget* parent = nullptr);
|
const VisualsConfig* visuals, const std::string& configDir,
|
||||||
|
QWidget* parent = nullptr);
|
||||||
~GameWorldView() override;
|
~GameWorldView() override;
|
||||||
|
|
||||||
double gameSpeed() const;
|
double gameSpeed() const;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <random>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
@@ -41,7 +42,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p
|
|||||||
|
|
||||||
m_headerBar = new HeaderBar(this);
|
m_headerBar = new HeaderBar(this);
|
||||||
|
|
||||||
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this);
|
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this);
|
||||||
|
|
||||||
m_sidePanel = new QWidget(this);
|
m_sidePanel = new QWidget(this);
|
||||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||||
@@ -184,9 +185,10 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Restart is a command boundary; the view resets when the drain applies
|
// Restart is a command boundary; the view resets when the drain applies
|
||||||
// it (see GameWorldView::onFrame). Seed stays 0 in Phase 1.
|
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
|
||||||
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||||
command->config = std::move(newConfig);
|
command->config = std::move(newConfig);
|
||||||
|
command->seed = std::random_device{}();
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<CommandRequestedEvent>(command));
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
@@ -332,6 +334,7 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
|||||||
// Restart is a command boundary; the view resets when the drain applies it.
|
// Restart is a command boundary; the view resets when the drain applies it.
|
||||||
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||||
command->config = std::move(newConfig);
|
command->config = std::move(newConfig);
|
||||||
|
command->seed = std::random_device{}();
|
||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<CommandRequestedEvent>(command));
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user