Replay: deterministic record & playback (#4)

Add deterministic record/playback for a run.

Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause.

Reviewed-on: #4
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-07-01 19:20:08 +00:00
committed by mlangkabel
parent cf68ac2862
commit d74ba5bfad
40 changed files with 3385 additions and 129 deletions

View File

@@ -1,4 +1,7 @@
#include <memory>
#include <optional>
#include <random>
#include <string>
#include <QApplication>
#include <QDir>
@@ -8,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[])
@@ -31,10 +36,54 @@ int main(int argc, char *argv[])
QDir().mkdir(dataDir.dirName());
}
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
// 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;
}
}
MainWindow window(sim.get(), std::string(CONFIG_DIR));
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
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), replay);
window.show();
const int ret = application.exec();

View File

@@ -27,6 +27,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
PARENT_SCOPE
)

View File

@@ -0,0 +1,23 @@
#pragma once
#include <memory>
#include "Event.h"
struct Command;
// UI fan-in for the command path: widgets emit this with a built command, and a
// single subscriber (GameWorldView) enqueues it onto the CommandManager. This
// keeps widgets decoupled (consistent with the rest of the UI), while the
// sim-mutating command itself is routed through the dedicated CommandManager
// queue rather than the EventManager bus (see docs/replay_design.md).
class CommandRequestedEvent : public Event
{
public:
explicit CommandRequestedEvent(std::shared_ptr<const Command> command)
: command(std::move(command))
{
}
const std::shared_ptr<const Command> command;
};

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include "StateChecksum.h"
#include "Tick.h"
#include "tracing.h"
@@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
}
}
void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun)
{
hasher.append(slotRun.size());
for (const BeltItemSlot& slot : slotRun)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
void BeltSystem::appendChecksum(Hasher& hasher) const
{
// std::map iterates in sorted key order, so all tile loops are deterministic.
hasher.append(m_belts.size());
for (const std::pair<const std::pair<int, int>, BeltTile>& entry : m_belts)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
appendItemSlots(hasher, entry.second.itemSlots);
}
hasher.append(m_splitters.size());
for (const std::pair<const std::pair<int, int>, SplitterTile>& entry : m_splitters)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
const SplitterTile& s = entry.second;
hasher.append(s.outputA);
hasher.append(s.outputB);
hasher.append(s.filterA.size());
for (const ItemType& type : s.filterA) { hasher.append(type.id); }
hasher.append(s.filterB.size());
for (const ItemType& type : s.filterB) { hasher.append(type.id); }
hasher.append(s.nextOutputIsA);
appendItemSlots(hasher, s.back);
hasher.append(s.backDir.size());
for (Rotation dir : s.backDir) { hasher.append(dir); }
hasher.append(s.frontA.has_value());
if (s.frontA.has_value())
{
hasher.append(s.frontA->item.type.id);
hasher.append(s.frontA->progress);
}
hasher.append(s.frontB.has_value());
if (s.frontB.has_value())
{
hasher.append(s.frontB->item.type.id);
hasher.append(s.frontB->progress);
}
}
hasher.append(m_tunnelEntries.size());
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
hasher.append(entry.second.maxDistance);
appendItemSlots(hasher, entry.second.itemSlots);
}
hasher.append(m_tunnelExits.size());
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
appendItemSlots(hasher, entry.second.itemSlots);
}
// m_tunnelLinks preserves insertion order, which is itself deterministic.
hasher.append(m_tunnelLinks.size());
for (const TunnelLink& link : m_tunnelLinks)
{
hasher.append(link.entryTile);
hasher.append(link.exitTile);
hasher.append(link.length);
hasher.append(link.items.size());
for (const TunnelTransitItem& item : link.items)
{
hasher.append(item.item.type.id);
hasher.append(item.progress);
}
}
}

View File

@@ -15,6 +15,8 @@
#include "Port.h"
#include "Rotation.h"
class Hasher;
// Carries item type and fractional world position for the renderer.
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
struct VisualItem
@@ -92,6 +94,11 @@ public:
void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const;
// -- Determinism ---------------------------------------------------------
// Folds all transport state (belt/splitter/tunnel tiles and their items)
// into the hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const;
private:
void advanceProgress();
void advanceTunnelProgress();
@@ -170,6 +177,9 @@ private:
std::vector<TunnelTransitItem> items; // front (highest progress) to back
};
// Folds a run of item slots (front-to-back order is canonical) into the hasher.
static void appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun);
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
std::map<std::pair<int, int>, BeltTile> m_belts;

View File

@@ -5,6 +5,7 @@
#include <random>
#include <set>
#include "StateChecksum.h"
#include "SurfaceMask.h"
#include "tracing.h"
@@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
m_tileOccupancy.erase({cell.x(), cell.y()});
}
}
namespace
{
void appendItems(Hasher& hasher, const std::vector<Item>& items)
{
hasher.append(items.size());
for (const Item& item : items)
{
hasher.append(item.type.id);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
hasher.append(buffer.counts.size());
for (const std::pair<const ItemType, int>& entry : buffer.counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
hasher.append(buffer.caps.size());
for (const std::pair<const ItemType, int>& entry : buffer.caps)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
} // namespace
void BuildingSystem::appendChecksum(Hasher& hasher) const
{
// m_buildings keeps a stable, deterministic order (append on build, swap-free
// erase aside — both runs perform identical operations, so order matches).
hasher.append(m_buildings.size());
for (const Building& b : m_buildings)
{
hasher.append(b.id);
hasher.append(b.anchor);
hasher.append(b.footprint.width());
hasher.append(b.footprint.height());
hasher.append(b.rotation);
hasher.append(b.type);
hasher.append(b.recipeId);
appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity);
hasher.append(b.production.has_value());
if (b.production.has_value())
{
hasher.append(b.production->recipeId);
hasher.append(b.production->completesAt);
appendItems(hasher, b.production->chosenOutputs);
}
hasher.append(b.shipLayout.has_value());
}
hasher.append(m_constructionQueue.size());
for (const ConstructionSite& s : m_constructionQueue)
{
hasher.append(s.id);
hasher.append(s.anchor);
hasher.append(s.footprint.width());
hasher.append(s.footprint.height());
hasher.append(s.rotation);
hasher.append(s.type);
hasher.append(s.recipeId);
hasher.append(s.completesAt);
hasher.append(s.shipLayout.has_value());
hasher.append(s.splitterFilterA.size());
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
hasher.append(s.splitterFilterB.size());
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
}
// std::map iterates in sorted key order.
hasher.append(m_tileOccupancy.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
}

View File

@@ -23,6 +23,8 @@
#include "ShipsConfig.h"
#include "Tick.h"
class Hasher;
// Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances;
@@ -151,6 +153,11 @@ public:
// Mutable iteration over all operational buildings.
void forEachBuilding(std::function<void(Building&)> fn);
// -- Determinism ---------------------------------------------------------
// Folds all building, construction-site, and tile-occupancy state into the
// hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const;
private:
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;

View File

@@ -1,6 +1,12 @@
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
${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
@@ -9,6 +15,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE
@@ -17,11 +24,17 @@ SET(HDRS
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
${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
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE

141
src/lib/sim/Command.h Normal file
View File

@@ -0,0 +1,141 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "Rotation.h"
#include "ShipLayout.h"
class GameConfig;
// Player intent, resolved to domain ids / tile coordinates and serializable, that
// mutates the simulation. Every sim mutation during play flows through a Command
// applied at the single Simulation::apply chokepoint, so it can be recorded and
// replayed (see docs/replay_design.md).
//
// Commands form a closed set dispatched by kind. They reference stable,
// deterministic ids (BuildingId, tile coordinates, choice indices) — never raw
// entt handles — so a recorded command resolves to the same target on replay.
enum class CommandKind
{
PlaceBuilding,
Demolish,
RotateInPlace,
SetRecipe,
SetShipLayout,
SetSiteSplitterFilters,
SetSplitterFilters,
ClearBeltTiles,
ApplySchematicChoice,
Reset
};
struct Command
{
explicit Command(CommandKind kind) : kind(kind) {}
virtual ~Command() = default;
CommandKind kind;
// Source of the command. Always 0 in single-player; lockstep multiplayer
// later merges commands from multiple sources into one ordered stream.
int playerId = 0;
};
// Places a building and, atomically, configures it. The configuration fields are
// bundled here (rather than as follow-up commands) because commands are applied
// at a deferred tick boundary, so the caller never sees the new BuildingId — the
// place-and-configure must happen as one unit inside apply().
struct PlaceBuildingCommand : Command
{
PlaceBuildingCommand() : Command(CommandKind::PlaceBuilding) {}
BuildingType type = BuildingType::Miner;
QPoint anchor;
Rotation rotation = Rotation::East;
// Optional configuration applied to the freshly placed (still-construction)
// building. The caller (UI) is responsible for unlock/validity pre-filtering;
// only fields that should apply are set.
std::optional<std::string> recipeId;
std::optional<ShipLayoutConfig> shipLayout;
bool hasSplitterFilters = false;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
BuildingId id = kInvalidBuildingId;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
BuildingId id = kInvalidBuildingId;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
BuildingId id = kInvalidBuildingId;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
BuildingId id = kInvalidBuildingId;
ShipLayoutConfig layout;
};
// Splitter filters for a queued / under-construction Splitter site (configured by
// BuildingSystem before the splitter is registered with BeltSystem).
struct SetSiteSplitterFiltersCommand : Command
{
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
BuildingId id = kInvalidBuildingId;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
// Splitter filters for an operational splitter, configured by tile via BeltSystem.
struct SetSplitterFiltersCommand : Command
{
SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {}
QPoint tile;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
struct ClearBeltTilesCommand : Command
{
ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {}
std::vector<QPoint> tiles;
};
struct ApplySchematicChoiceCommand : Command
{
ApplySchematicChoiceCommand() : Command(CommandKind::ApplySchematicChoice) {}
int choiceIndex = 0;
};
// Restart boundary: reinitializes the simulation with a fresh seed and, if
// config is set, a reloaded config (GameConfig is move-only, so it is carried by
// shared_ptr and moved into the sim on apply). A null config keeps the current
// config. One replay file = one run between Reset boundaries.
struct ResetCommand : Command
{
ResetCommand() : Command(CommandKind::Reset) {}
std::shared_ptr<GameConfig> config; // null = keep current config
unsigned int seed = 0;
};

View File

@@ -0,0 +1,92 @@
#include "CommandManager.h"
#include <utility>
#include "Command.h"
#include "ReplayRecorder.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)
: m_simulation(simulation)
{
}
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));
}
}
void CommandManager::drain()
{
// Apply in FIFO order. A queued ResetCommand reinitializes the simulation in
// place; the reference stays valid and any commands after it apply to the
// fresh state.
for (const std::shared_ptr<const Command>& command : m_queue)
{
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();
}
bool CommandManager::hasPending() const
{
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::setReplayMode(bool replayMode)
{
m_replayMode = replayMode;
}
void CommandManager::recordTickCheckpoint()
{
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
{
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
}
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <memory>
#include <vector>
struct Command;
class ReplayRecorder;
class Simulation;
// Ordered queue that funnels every player command into the single
// Simulation::apply chokepoint (see docs/replay_design.md). This is deliberately
// NOT the EventManager pub/sub bus: sim mutations must apply in a strict,
// tick-pinned, recordable order to a single recipient.
//
// Live play pushes commands via enqueue(); they are applied at the next drain(),
// which runs once per frame before the tick batch. Draining before the tick
// (even at 0x game speed) lets a paused player see placed construction sites
// immediately while staying deterministic — replay applies each command at its
// recorded tick regardless of frame cadence.
class CommandManager
{
public:
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).
void enqueue(std::shared_ptr<const Command> command);
// Apply all queued commands in FIFO order through Simulation::apply, then
// clear the queue. If a recorder is attached, each applied command is recorded
// (a Reset rolls the recorder to a new file).
void drain();
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);
// 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();
private:
Simulation& m_simulation;
std::vector<std::shared_ptr<const Command>> m_queue;
std::unique_ptr<ReplayRecorder> m_recorder;
bool m_replayMode = false;
};

View File

@@ -0,0 +1,314 @@
#include "CommandSerializer.h"
#include <sstream>
#include <vector>
#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';
}
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)
{
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();
}
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;
}

View File

@@ -0,0 +1,21 @@
#pragma once
#include <memory>
#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);
// 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);

View 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;
}
}
}

View 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;
};

View 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;
}

View 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);

View 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 computeReplayConfigHash(const std::string& configDir)
{
Hasher hasher;
QDir dir(QString::fromStdString(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 " << computeReplayConfigHash(m_configDir) << "\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;
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <fstream>
#include <string>
#include "Tick.h"
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
// 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:
std::string m_configDir;
std::string m_outputDir;
std::string m_filePath;
std::ofstream m_stream;
};

View File

@@ -4,10 +4,13 @@
#include <cassert>
#include "AiSystem.h"
#include "Command.h"
#include "DisplayName.h"
#include "BuildingSystem.h"
#include "CombatSystem.h"
#include "DynamicBodyComponent.h"
#include "DynamicBodySystem.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
#include "EventManager.h"
#include "HealthComponent.h"
@@ -16,9 +19,11 @@
#include "PositionComponent.h"
#include "RepairSystem.h"
#include "SalvagerSystem.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "StateChecksum.h"
#include "StationBodyComponent.h"
#include "SurfaceMask.h"
#include "tracing.h"
@@ -28,6 +33,7 @@
Simulation::Simulation(GameConfig config, unsigned int seed)
: m_config(std::move(config))
, m_rng(seed)
, m_seed(seed)
, m_currentTick(0)
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
, m_nextBuildingId(1)
@@ -129,6 +135,7 @@ void Simulation::reset(unsigned int seed)
{
EventManager::getInstance()->clearEvents();
m_rng.seed(seed);
m_seed = seed;
m_currentTick = 0;
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextBuildingId = 1;
@@ -215,6 +222,92 @@ void Simulation::reset(unsigned int seed)
// tick
// ---------------------------------------------------------------------------
void Simulation::apply(const Command& command)
{
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
{
break;
}
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
}
if (c.shipLayout.has_value())
{
m_buildingSystem->setShipLayout(id, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
{
const SetSplitterFiltersCommand& c =
static_cast<const SetSplitterFiltersCommand&>(command);
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
break;
}
case CommandKind::ClearBeltTiles:
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
break;
case CommandKind::ApplySchematicChoice:
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
break;
case CommandKind::Reset:
{
const ResetCommand& c = static_cast<const ResetCommand&>(command);
if (c.config)
{
// operator* on a const shared_ptr yields a mutable GameConfig&, so
// the move-only config moves into reset without a copy. The command
// is applied once, so leaving its config moved-from is fine.
reset(std::move(*c.config), c.seed);
}
else
{
reset(c.seed);
}
break;
}
}
}
void Simulation::tick()
{
EventManager::getInstance()->processEvents();
@@ -825,6 +918,116 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
return m_unlockedItemIds.count(itemId) > 0;
}
// ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md)
// ---------------------------------------------------------------------------
void Simulation::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
hasher.append(entry.second.level);
}
}
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
unsigned long long Simulation::rngFingerprint() const
{
return fingerprintRng(m_rng);
}
unsigned long long Simulation::computeStateChecksum() const
{
Hasher hasher;
// RNG stream — the most sensitive signal of divergence.
hasher.append(fingerprintRng(m_rng));
// Top-level scalars.
hasher.append(m_currentTick);
hasher.append(m_nextDepartureTick);
hasher.append(m_nextBuildingId);
hasher.append(m_buildingBlocksStock);
hasher.append(m_gameOver);
// WaveSystem scalar state, reached through existing accessors.
hasher.append(threatLevel());
hasher.append(threatAccumulationRate());
hasher.append(bossWaveCounter());
hasher.append(bossCountdownTicks());
hasher.append(normalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
// Subsystems contribute their own state.
m_buildingSystem->appendChecksum(hasher);
m_beltSystem.appendChecksum(hasher);
// ECS component state. View iteration order is a pure function of the
// (identical) operation sequence on a fixed binary; each entity's raw id is
// folded in so the fingerprint is keyed, not merely a sum of fields.
m_admin.forEach<PositionComponent>(
[&hasher](entt::entity entity, const PositionComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.value);
});
m_admin.forEach<HealthComponent>(
[&hasher](entt::entity entity, const HealthComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.hp);
hasher.append(c.maxHp);
});
m_admin.forEach<FacingComponent>(
[&hasher](entt::entity entity, const FacingComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.radians);
});
m_admin.forEach<DynamicBodyComponent>(
[&hasher](entt::entity entity, const DynamicBodyComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.velocity_tpt);
hasher.append(c.angularVelocity_rpt);
hasher.append(c.linearAcceleration_tptt);
hasher.append(c.angularAcceleration_rptt);
});
m_admin.forEach<ScrapDataComponent>(
[&hasher](entt::entity entity, const ScrapDataComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.amount);
});
m_admin.forEach<ShipIdentityComponent>(
[&hasher](entt::entity entity, const ShipIdentityComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.level);
hasher.append(c.schematicId);
});
return hasher.value();
}
// ---------------------------------------------------------------------------
// Drains
// ---------------------------------------------------------------------------
@@ -856,6 +1059,11 @@ Tick Simulation::currentTick() const
return m_currentTick;
}
unsigned int Simulation::getSeed() const
{
return m_seed;
}
int Simulation::buildingBlocksStock() const
{
return m_buildingBlocksStock;
@@ -974,7 +1182,7 @@ void Simulation::demolish(BuildingId id)
m_buildingBlocksStock += m_buildingSystem->demolish(id);
}
BuildingSystem& Simulation::buildings()
BuildingSystem& Simulation::buildingsMutable()
{
return *m_buildingSystem;
}
@@ -984,7 +1192,7 @@ const BuildingSystem& Simulation::buildings() const
return *m_buildingSystem;
}
BeltSystem& Simulation::belts()
BeltSystem& Simulation::beltsMutable()
{
return m_beltSystem;
}

View File

@@ -24,6 +24,8 @@
class AiSystem;
class BuildingSystem;
struct Command;
class Hasher;
class CombatSystem;
class DynamicBodySystem;
class MovementIntentSystem;
@@ -50,6 +52,12 @@ public:
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
void tick();
// The single command chokepoint: applies one player command by dispatching
// to the underlying mutators. Every sim mutation during play must flow
// through here so it can be recorded and replayed (see docs/replay_design.md
// and CommandManager). Reached via CommandManager::drain.
void apply(const Command& command);
// Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<BeamFiredEvent> drainBeamFiredEvents();
@@ -60,12 +68,9 @@ public:
// Returns true if there are pending schematic choices waiting for player input.
bool hasSchematicChoicesPending() const;
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
Tick currentTick() const;
// The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const;
int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
@@ -88,16 +93,21 @@ public:
bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// -- Determinism (see docs/replay_design.md) -----------------------------
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
// file periodically + after each command for desync detection.
unsigned long long rngFingerprint() const;
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
// belts, and ECS component state). Used by the double-run determinism test;
// a superset of rngFingerprint().
unsigned long long computeStateChecksum() const;
BuildingSystem& buildings();
// Const subsystem accessors (queries only). The mutable counterparts are
// private and reachable only through Simulation::apply (the command
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
// mutate the factory outside the recorded command path (docs/replay_design.md).
const BuildingSystem& buildings() const;
BeltSystem& belts();
const BeltSystem& belts() const;
ShipSystem& ships();
const ShipSystem& ships() const;
@@ -107,6 +117,29 @@ public:
const EntityAdmin& admin() const;
private:
// Grants tests access to the private player-action mutators below without
// opening them to production code (see src/test/SimulationTestAccess.h).
friend struct SimulationTestAccess;
// -- Player-action mutators (command chokepoint only) --------------------
// Reached during play exclusively via apply(); never called by UI/app code.
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable();
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
@@ -126,6 +159,7 @@ private:
GameConfig m_config;
std::mt19937 m_rng;
unsigned int m_seed;
Tick m_currentTick;
Tick m_nextDepartureTick;
@@ -149,6 +183,11 @@ private:
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
// Determinism helpers — fold sub-state into the hasher in deterministic order.
static void appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels);
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;

View File

@@ -0,0 +1,61 @@
#include "StateChecksum.h"
#include <sstream>
void Hasher::appendBytes(const void* data, std::size_t byteCount)
{
const unsigned char* bytes = static_cast<const unsigned char*>(data);
for (std::size_t i = 0; i < byteCount; ++i)
{
m_state ^= bytes[i];
m_state *= 1099511628211ull; // FNV-1a 64-bit prime
}
}
void Hasher::append(float value)
{
// Normalize -0.0f to +0.0f so the two equal values share a fingerprint.
if (value == 0.0f) { value = 0.0f; }
appendBytes(&value, sizeof(value));
}
void Hasher::append(double value)
{
if (value == 0.0) { value = 0.0; }
appendBytes(&value, sizeof(value));
}
void Hasher::append(const QPoint& point)
{
const int coords[2] = { point.x(), point.y() };
appendBytes(coords, sizeof(coords));
}
void Hasher::append(const QPointF& point)
{
append(point.x());
append(point.y());
}
void Hasher::append(const QVector2D& vector)
{
append(vector.x());
append(vector.y());
}
void Hasher::append(const std::string& text)
{
appendBytes(text.data(), text.size());
// Length terminator so "ab"+"c" and "a"+"bc" do not collide.
const std::size_t length = text.size();
appendBytes(&length, sizeof(length));
}
std::uint64_t fingerprintRng(const std::mt19937& rng)
{
std::ostringstream stream;
stream << rng; // full internal state as space-separated integers
Hasher hasher;
hasher.append(stream.str());
return hasher.value();
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <random>
#include <string>
#include <type_traits>
#include <QPoint>
#include <QPointF>
#include <QVector2D>
// FNV-1a 64-bit accumulator used to fingerprint simulation state for
// determinism verification (see docs/replay_design.md "Determinism").
//
// Subsystems contribute their own state through appendChecksum(Hasher&) so the
// hash stays close to the data it covers and no state knowledge is duplicated.
// The accumulator is order-sensitive; callers fold state in a deterministic
// order (sorted containers, fixed view iteration).
class Hasher
{
public:
// Folds raw bytes into the running hash.
void appendBytes(const void* data, std::size_t byteCount);
// Trivially-copyable scalars (ints, enums) are hashed by object representation.
// Floating-point and Qt types have dedicated overloads below and bypass this.
template <typename T>
void append(const T& value)
{
static_assert(std::is_trivially_copyable<T>::value,
"Hasher::append requires a trivially copyable type "
"(add a dedicated overload otherwise)");
appendBytes(&value, sizeof(T));
}
// Floats are hashed by bit pattern so equal values always hash equally;
// negative zero is normalized so -0.0 and +0.0 collapse to one value.
void append(float value);
void append(double value);
void append(const QPoint& point);
void append(const QPointF& point);
void append(const QVector2D& vector);
void append(const std::string& text);
std::uint64_t value() const { return m_state; }
private:
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis
};
// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a
// portable, bit-identical text serialization, so this fingerprint is stable
// across platforms (see docs/replay_design.md "Cross-platform").
std::uint64_t fingerprintRng(const std::mt19937& rng);

View File

@@ -16,6 +16,7 @@
#include "Rotation.h"
#include "ShipLayout.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "SurfaceMask.h"
#include "Tick.h"
@@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
const QPoint offsetA(-1, 0);
const QPoint offsetB( 1, 0);
const BuildingId idA = sim.tryPlaceBuilding(
const BuildingId idA = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetA, Rotation::East);
const BuildingId idB = sim.tryPlaceBuilding(
const BuildingId idB = SimulationTestAccess::place(sim,
BuildingType::Belt, cursor + offsetB, Rotation::East);
REQUIRE(idA != kInvalidBuildingId);
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
const int startBlocks = sim.buildingBlocksStock();
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
}
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
int col = -2;
while (sim.buildingBlocksStock() >= minerCost)
{
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
col -= 2;
}
const int blocksBeforeAttempt = sim.buildingBlocksStock();
const BuildingId id = sim.tryPlaceBuilding(
const BuildingId id = SimulationTestAccess::place(sim,
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
// Placement must fail and leave the stock unchanged.
@@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
// rule, so it must be rejected without consuming building blocks.
const BuildingId id =
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
REQUIRE(id == kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks);
@@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
// (-3,0),(-2,0),(-3,1); a valid spot.
const BuildingId id =
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
Simulation sim(loadConfig());
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_iron_ore");
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
{
Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
sim.buildings().setRecipe(id, "mine_copper_ore");
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
// Run 301 ticks (0..300) to process the completion tick.
@@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout);
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
const ConstructionSite* site = sim.buildings().findSite(id);
REQUIRE(site != nullptr);
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
{
Simulation sim(loadConfig());
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
REQUIRE(id != kInvalidBuildingId);
ShipLayoutConfig layout;
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
pm.rotation = Rotation::North;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(id, layout);
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
// Shipyard construction_time_seconds = 30 in the test config.
double constructionTime = 0.0;

View File

@@ -21,4 +21,8 @@ add_files(
ShipModuleTest.cpp
ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp
DeterminismTest.cpp
CommandTest.cpp
ReplayRecorderTest.cpp
ReplayPlaybackTest.cpp
)

106
src/test/CommandTest.cpp Normal file
View File

@@ -0,0 +1,106 @@
#include "catch.hpp"
#include <memory>
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandManager.h"
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
} // namespace
// The command chokepoint (Simulation::apply) must produce exactly the same state
// as driving the underlying mutators directly — that equivalence is what lets a
// recorded command stream reproduce a live run (see docs/replay_design.md).
TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
PlaceBuildingCommand command;
command.type = BuildingType::Miner;
command.anchor = QPoint(-3, 0);
command.rotation = Rotation::East;
viaCommand.apply(command);
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
PlaceBuildingCommand command;
command.type = BuildingType::Miner;
command.anchor = QPoint(-2, 0);
command.rotation = Rotation::East;
command.recipeId = "mine_iron_ore";
viaCommand.apply(command);
const BuildingId id =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore");
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]")
{
Simulation viaCommand(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
const BuildingId idA =
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
const BuildingId idB =
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
REQUIRE(idA == idB);
DemolishCommand command;
command.id = idA;
viaCommand.apply(command);
SimulationTestAccess::demolish(viaDirect, idB);
REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum());
}
TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]")
{
Simulation viaManager(loadConfig(), 99);
Simulation viaDirect(loadConfig(), 99);
CommandManager manager(viaManager);
std::shared_ptr<PlaceBuildingCommand> first = std::make_shared<PlaceBuildingCommand>();
first->type = BuildingType::Miner;
first->anchor = QPoint(-3, 0);
std::shared_ptr<PlaceBuildingCommand> second = std::make_shared<PlaceBuildingCommand>();
second->type = BuildingType::Belt;
second->anchor = QPoint(-2, 0);
manager.enqueue(first);
manager.enqueue(second);
REQUIRE(manager.hasPending());
manager.drain();
REQUIRE_FALSE(manager.hasPending());
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(viaDirect, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum());
}

View File

@@ -0,0 +1,158 @@
#include "catch.hpp"
#include <cstdint>
#include <random>
#include <vector>
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "Rotation.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StateChecksum.h"
#include "Tick.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
constexpr int kScriptTicks = 2000;
// Runs a fixed scripted session and returns the full-state checksum after every
// tick. The script places a small factory, demolishes part of it mid-run, and
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
{
Simulation sim(loadConfig(), seed);
// Tick 0: a miner feeding a short belt line on the asteroid.
SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East);
SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East);
std::vector<std::uint64_t> checksums;
checksums.reserve(kScriptTicks);
for (int t = 0; t < kScriptTicks; ++t)
{
if (t == 500)
{
// Demolish the second belt mid-run to exercise the removal paths.
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
}
sim.tick();
checksums.push_back(sim.computeStateChecksum());
}
return checksums;
}
} // namespace
// ---------------------------------------------------------------------------
// Hasher
// ---------------------------------------------------------------------------
TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
{
Hasher a;
Hasher b;
a.append(42);
a.append(3.5f);
a.append(std::string("ore"));
b.append(42);
b.append(3.5f);
b.append(std::string("ore"));
REQUIRE(a.value() == b.value());
}
TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
{
Hasher a;
Hasher b;
a.append(42);
b.append(43);
REQUIRE(a.value() != b.value());
}
TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
{
Hasher a;
Hasher b;
a.append(std::string("ab"));
a.append(std::string("c"));
b.append(std::string("a"));
b.append(std::string("bc"));
REQUIRE(a.value() != b.value());
}
TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
{
Hasher a;
Hasher b;
a.append(-0.0f);
b.append(0.0f);
REQUIRE(a.value() == b.value());
}
// ---------------------------------------------------------------------------
// RNG fingerprint
// ---------------------------------------------------------------------------
TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]")
{
std::mt19937 a(12345);
std::mt19937 b(12345);
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
a(); // advance one draw
REQUIRE(fingerprintRng(a) != fingerprintRng(b));
b(); // advance b to the same point
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
}
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
{
const Simulation a(loadConfig(), 777);
const Simulation b(loadConfig(), 777);
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
}
// ---------------------------------------------------------------------------
// Double-run determinism
// ---------------------------------------------------------------------------
TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state",
"[determinism]")
{
const std::vector<std::uint64_t> first = runScriptedSession(424242);
const std::vector<std::uint64_t> second = runScriptedSession(424242);
REQUIRE(first.size() == second.size());
REQUIRE(first.size() == static_cast<std::size_t>(kScriptTicks));
for (std::size_t i = 0; i < first.size(); ++i)
{
INFO("divergence at tick " << i);
REQUIRE(first[i] == second[i]);
}
}
TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]")
{
const std::vector<std::uint64_t> a = runScriptedSession(111);
const std::vector<std::uint64_t> b = runScriptedSession(222);
// The two sessions must differ at some point (the checksum is sensitive to
// the RNG-driven divergence; a constant checksum would be a broken hash).
REQUIRE(a != b);
}

View File

@@ -10,6 +10,7 @@
#include "RecipesConfig.h"
#include "SchematicChoiceOption.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "StationBodyComponent.h"
static GameConfig loadConfig()
@@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim)
killEnemyStations(sim);
if (sim.hasSchematicChoicesPending())
{
sim.applySchematicChoice(0);
SimulationTestAccess::applySchematicChoice(sim, 0);
}
}
@@ -246,7 +247,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
break;
}
}
sim.applySchematicChoice(0);
SimulationTestAccess::applySchematicChoice(sim, 0);
}
}
CHECK(foundRecipeChoice);
@@ -308,7 +309,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
}
}
sim.applySchematicChoice(0);
SimulationTestAccess::applySchematicChoice(sim, 0);
}
}
@@ -340,7 +341,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
sim.applySchematicChoice(0);
SimulationTestAccess::applySchematicChoice(sim, 0);
std::set<std::string> expectedNames;
for (const RecipeDef& def : cfg.recipes.recipes)

View File

@@ -0,0 +1,292 @@
#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) };
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);
}
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->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));
}
TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay]")
{
const unsigned int seed = 5u;
std::string replayPath;
{
Simulation rec(loadConfig(), seed);
CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
manager.setRecorder(nullptr);
}
std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
REQUIRE(parsed.has_value());
// Corrupt the periodic checksum recorded at tick 30.
bool corrupted = false;
for (ReplayEntry& entry : parsed->entries)
{
if (!entry.isCommand && entry.tick == 30)
{
entry.fingerprint ^= 0x1ull;
corrupted = true;
break;
}
}
REQUIRE(corrupted);
Simulation play(loadConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries);
player.start();
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
}
REQUIRE(player.getDesyncTick().has_value());
REQUIRE(*player.getDesyncTick() == 30);
QFile::remove(QString::fromStdString(replayPath));
}
TEST_CASE("a long recorded run (through waves and combat) replays with no desync", "[replay]")
{
const unsigned int seed = 271828u;
std::string replayPath;
std::uint64_t recordedFinalChecksum = 0;
{
Simulation rec(loadConfig(), seed);
CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
std::shared_ptr<PlaceBuildingCommand> miner = place(BuildingType::Miner, QPoint(-3, 0));
miner->recipeId = "mine_iron_ore";
manager.enqueue(miner);
manager.drain();
for (int i = 0; i < 1500; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0)));
manager.drain();
for (int i = 0; i < 900; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
recordedFinalChecksum = rec.computeStateChecksum();
manager.setRecorder(nullptr);
}
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
REQUIRE(parsed.has_value());
Simulation play(loadConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries);
player.start();
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.currentTick() == 2400);
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
QFile::remove(QString::fromStdString(replayPath));
}

View File

@@ -0,0 +1,212 @@
#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();
}
TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]")
{
Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u);
CommandManager manager(sim);
std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
const std::string firstPath = recorderPtr->currentFilePath();
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
reset->config = std::make_shared<GameConfig>(ConfigLoader::loadFromDirectory(CONFIG_DIR));
reset->seed = 999u;
manager.enqueue(reset);
manager.drain();
const std::string secondPath = recorderPtr->currentFilePath();
REQUIRE(firstPath != secondPath);
REQUIRE(secondPath.find("_999.replay") != std::string::npos);
manager.setRecorder(nullptr);
QFile::remove(QString::fromStdString(firstPath));
QFile::remove(QString::fromStdString(secondPath));
}

View File

@@ -17,6 +17,7 @@
#include "ShipStatsCalculator.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h"
#include "WeaponComponent.h"
@@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
return sim.buildings().placeImmediate(
return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard,
yardDef.surfaceMask,
QPoint(0, 0),
@@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
const ShipDef& def,
const ShipLayoutConfig& layout)
{
sim.buildings().forEachBuilding([&](Building& b) {
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
if (b.id != yardId)
{
return;
@@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
@@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b = sim.buildings().findBuilding(yardId);
REQUIRE(b != nullptr);
@@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
// Fill materials and tick to start production.
ShipLayoutConfig emptyLayout;
@@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);
@@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, "interceptor");
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
ShipLayoutConfig layout;
PlacedModule pm;
@@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
pm.position = QPoint(0, 0);
pm.rotation = Rotation::East;
layout.placedModules.push_back(pm);
sim.buildings().setShipLayout(yardId, layout);
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
const Building* b1 = sim.buildings().findBuilding(yardId);
REQUIRE(b1 != nullptr);
REQUIRE(b1->shipLayout.has_value());
sim.buildings().setRecipe(yardId, "destroyer");
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
const Building* b2 = sim.buildings().findBuilding(yardId);
REQUIRE(b2 != nullptr);

View File

@@ -12,6 +12,7 @@
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h"
static GameConfig loadConfig()
@@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
{
return sim.buildings().placeImmediate(
return SimulationTestAccess::buildings(sim).placeImmediate(
BuildingType::Shipyard,
yardDef.surfaceMask,
QPoint(0, 0),
@@ -62,7 +63,7 @@ static int countShips(Simulation& sim)
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
{
sim.buildings().forEachBuilding([&](Building& b)
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
{
if (b.id != yardId)
{
@@ -94,7 +95,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
const BuildingId yardId = placeShipyard(sim, *yardDef);
REQUIRE(yardId != kInvalidBuildingId);
sim.buildings().setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
fillMaterials(sim, yardId, *def);
// First tick: materials consumed, production cycle starts — no ship yet.
@@ -153,7 +154,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
const int shipsBefore = countShips(sim);
const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
// Materials remain at zero (default after setRecipe); no cycle starts.
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
@@ -175,7 +176,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
REQUIRE(yardDef != nullptr);
const BuildingId yardId = placeShipyard(sim, *yardDef);
sim.buildings().setRecipe(yardId, def->id);
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);

View File

@@ -0,0 +1,40 @@
#pragma once
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "Rotation.h"
#include "Simulation.h"
class BeltSystem;
class BuildingSystem;
// Test-only backdoor to Simulation's private player-action mutators and mutable
// subsystem accessors. Declared a friend of Simulation, so it can hand tests the
// same mutation surface that Simulation::apply uses internally — without exposing
// those mutators to production (UI/app) code, which must go through the command
// chokepoint (docs/replay_design.md).
//
// This header lives under src/test and is not on the lib/ui/app include path, so
// only test translation units can reach it. Non-player test setup that has no
// command equivalent (e.g. placeImmediate, forEachBuilding buffer injection) is
// reached via buildings(sim)/belts(sim).
struct SimulationTestAccess
{
static BuildingSystem& buildings(Simulation& sim) { return sim.buildingsMutable(); }
static BeltSystem& belts(Simulation& sim) { return sim.beltsMutable(); }
static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor,
Rotation rotation)
{
return sim.tryPlaceBuilding(type, anchor, rotation);
}
static void demolish(Simulation& sim, BuildingId id) { sim.demolish(id); }
static void applySchematicChoice(Simulation& sim, int choiceIndex)
{
sim.applySchematicChoice(choiceIndex);
}
};

View File

@@ -21,6 +21,7 @@
#include "SchematicChoiceOption.h"
#include "ShipsConfig.h"
#include "Simulation.h"
#include "SimulationTestAccess.h"
#include "Tick.h"
#include "ThreatCostCalculator.h"
#include "WaveSystem.h"
@@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
sim.tick();
REQUIRE(sim.hasSchematicChoicesPending());
sim.applySchematicChoice(0);
SimulationTestAccess::applySchematicChoice(sim, 0);
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
}

View File

@@ -6,10 +6,12 @@
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <QColor>
#include <QCursor>
#include <QDir>
#include <QFont>
#include <QKeyEvent>
#include <QMessageBox>
@@ -24,6 +26,10 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
@@ -112,11 +118,13 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent)
const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent)
: QOpenGLWidget(parent)
, m_sim(sim)
, m_config(config)
, m_visuals(visuals)
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0)
, m_scrollXTiles(0.0f)
@@ -143,6 +151,25 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
m_frameTimer.start();
registerForEvents();
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()
@@ -159,13 +186,40 @@ void GameWorldView::onFrame()
{
const qint64 elapsed = m_frameTimer.restart();
// Advance simulation
if (m_replayPlayer)
{
// 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());
}
}
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)
{
m_sim->tick();
// Periodic checksum (every 30 ticks) for replay desync detection.
m_commandManager.recordTickCheckpoint();
}
}
@@ -239,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();
@@ -283,6 +343,7 @@ void GameWorldView::paintGL()
drawBeams(painter);
drawOverlays(painter);
drawScreenSpace(painter);
drawReplayOverlay(painter);
}
// ---------------------------------------------------------------------------
@@ -515,12 +576,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
std::make_shared<RotateInPlaceCommand>();
rotateCommand->id = *rotateTarget;
rotateCommand->newRotation = bb.rotation;
enqueueCommand(rotateCommand);
continue;
}
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidBuildingId) { continue; }
// Place-and-configure is one atomic command: commands apply at a deferred
// tick boundary, so the caller never sees the new BuildingId. Unlock
// gating stays here (UI-side pre-filter); only fields that should apply
// are set on the command.
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = bb.type;
command->anchor = anchor;
command->rotation = bb.rotation;
if (!bb.recipeId.empty())
{
@@ -528,7 +599,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
command->recipeId = bb.recipeId;
}
}
else
@@ -537,15 +608,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
command->recipeId = bb.recipeId;
}
}
}
if (bb.shipLayout.has_value())
{
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
}
command->shipLayout = bb.shipLayout;
if (bb.type == BuildingType::Splitter
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
@@ -553,11 +621,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
// The splitter is still a construction site, so the filters carry
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
m_sim->buildings().setSiteSplitterFilters(
id,
filterUnlockedItems(bb.splitterFilterA, *m_sim),
filterUnlockedItems(bb.splitterFilterB, *m_sim));
command->hasSplitterFilters = true;
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
}
enqueueCommand(command);
}
}
@@ -578,49 +647,50 @@ void GameWorldView::placeAtTile(QPoint tile)
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
std::shared_ptr<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
command->id = *rotateTarget;
command->newRotation = m_ghostRotation;
enqueueCommand(command);
return;
}
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
// optimistic UI update matches what the deferred command will do — isValidPlacement
// (above) already covered terrain/bounds.
if (type == BuildingType::Belt)
{
if (m_beltDragTiles.count(tile) > 0)
{
{
return;
}
if (!m_sim->buildings().isTileOccupied(tile))
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{
const BuildingId id = m_sim->tryPlaceBuilding(
type, tile, m_ghostRotation);
if (id != kInvalidBuildingId)
{
m_beltDragTiles.insert(tile);
}
enqueuePlaceBuilding(type, tile, m_ghostRotation);
m_beltDragTiles.insert(tile);
}
}
else if (type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit)
{
if (!m_sim->buildings().isTileOccupied(tile))
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{
const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
if (id != kInvalidBuildingId)
enqueuePlaceBuilding(type, tile, m_ghostRotation);
if (type == BuildingType::TunnelEntry)
{
if (type == BuildingType::TunnelEntry)
{
m_builderType = BuildingType::TunnelExit;
}
else if (type == BuildingType::TunnelExit)
{
m_builderType = BuildingType::TunnelEntry;
}
m_builderType = BuildingType::TunnelExit;
}
else if (type == BuildingType::TunnelExit)
{
m_builderType = BuildingType::TunnelEntry;
}
}
}
else
{
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
enqueuePlaceBuilding(type, tile, m_ghostRotation);
}
}
@@ -1209,6 +1279,45 @@ 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())
{
// Dim the world so the end message reads clearly over it.
painter.fillRect(rect(), QColor(0, 0, 0, 140));
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
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
// ---------------------------------------------------------------------------
@@ -1369,7 +1478,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
const bool isProtected = b && b->type == BuildingType::Hq;
if (!isProtected)
{
m_sim->demolish(hovered);
std::shared_ptr<DemolishCommand> command =
std::make_shared<DemolishCommand>();
command->id = hovered;
enqueueCommand(command);
m_demolishHoverBuildingId = kInvalidBuildingId;
}
}
@@ -1743,3 +1855,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
{
setGameSpeed(event->multiplier);
}
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
// event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset)
{
m_viewResetPending = true;
}
enqueueCommand(event->command);
}
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
{
m_commandManager.enqueue(std::move(command));
}
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = type;
command->anchor = anchor;
command->rotation = rotation;
enqueueCommand(command);
}
bool GameWorldView::canAfford(BuildingType type) const
{
const BuildingDef* def = findBuildingDef(type);
if (!def) { return false; }
return m_sim->buildingBlocksStock() >= def->cost;
}

View File

@@ -3,6 +3,7 @@
#include <optional>
#include <random>
#include <set>
#include <string>
#include <vector>
#include <QElapsedTimer>
@@ -26,10 +27,12 @@
#include "ExitBuilderModeRequestedEvent.h"
#include "DebugDrawToggledEvent.h"
#include "BeamFiredEvent.h"
#include "CommandRequestedEvent.h"
#include "SchematicChoiceOption.h"
#include "SpeedChangeRequestedEvent.h"
#include "entt/entity/entity.hpp"
#include "CommandManager.h"
#include "EntitySelectedEvent.h"
#include "GameConfig.h"
#include "Rotation.h"
@@ -37,6 +40,9 @@
#include "TickDriver.h"
#include "VisualsConfig.h"
struct Command;
struct ParsedReplay;
class ReplayPlayer;
class Simulation;
class QPainter;
@@ -56,13 +62,15 @@ class GameWorldView : public QOpenGLWidget,
DemolishModeToggleRequestedEvent,
BlueprintPlacementRequestedEvent,
ExitBlueprintModeRequestedEvent,
SpeedChangeRequestedEvent>
SpeedChangeRequestedEvent,
CommandRequestedEvent>
{
Q_OBJECT
public:
GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent = nullptr);
const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent = nullptr);
~GameWorldView() override;
double gameSpeed() const;
@@ -91,6 +99,17 @@ private:
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
// Enqueue a sim command onto the CommandManager (the single mutation path).
void enqueueCommand(std::shared_ptr<const Command> command);
// Enqueue a plain (unconfigured) building placement.
void enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// True if the player can currently afford to place one building of `type`.
// Used to pre-validate placements whose UI follow-up depends on success.
bool canAfford(BuildingType type) const;
void drawTiles(QPainter& painter);
void drawBuildings(QPainter& painter);
@@ -104,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;
@@ -157,6 +177,14 @@ private:
const GameConfig* m_config;
const VisualsConfig* m_visuals;
// Funnels all player input into the single Simulation::apply chokepoint.
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;
std::mt19937 m_rng;

View File

@@ -1,6 +1,7 @@
#include "MainWindow.h"
#include <map>
#include <random>
#include <set>
#include <QApplication>
@@ -15,7 +16,10 @@
#include "BuildButtonGrid.h"
#include "BuildingBlocksChangedEvent.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "ConfigLoader.h"
#include "EventManager.h"
#include "GameWorldView.h"
#include "RecipeSelectionDialog.h"
#include "SchematicChoiceDialog.h"
@@ -27,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, 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);
@@ -138,7 +145,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
SchematicChoiceDialog dialog(event->choices, this);
dialog.exec();
m_sim->applySchematicChoice(dialog.getChosenIndex());
std::shared_ptr<ApplySchematicChoiceCommand> command =
std::make_shared<ApplySchematicChoiceCommand>();
command->choiceIndex = dialog.getChosenIndex();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
m_gameWorldView->setGameSpeed(prevSpeed);
m_gameWorldView->resetFrameTimer();
@@ -160,12 +171,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
QAbstractButton* clicked = box.clickedButton();
if (clicked == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
}
catch (const std::exception& e)
{
@@ -175,7 +187,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
m_gameWorldView->resetFrameTimer();
return;
}
m_gameWorldView->resetForNewGame();
// Restart is a command boundary; the view resets when the drain applies
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else if (clicked == quitBtn)
{
@@ -234,7 +252,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
this);
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
{
m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result());
std::shared_ptr<SetShipLayoutCommand> command =
std::make_shared<SetShipLayoutCommand>();
command->id = event->shipyardId;
command->layout = *dialog.result();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
@@ -268,7 +291,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
RecipeSelectionDialog dialog(options, title, this);
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
{
m_sim->buildings().setRecipe(event->buildingId, *dialog.getChosenId());
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
command->id = event->buildingId;
command->recipeId = *dialog.getChosenId();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
m_gameWorldView->setGameSpeed(prevSpeed);
@@ -293,12 +320,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
if (box.clickedButton() == restartBtn)
{
std::shared_ptr<GameConfig> newConfig;
try
{
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
newConfig = std::make_shared<GameConfig>(
ConfigLoader::loadFromDirectory(m_configDir));
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
m_visuals = std::move(newVisuals);
m_sim->reset(std::move(newConfig));
}
catch (const std::exception& e)
{
@@ -306,7 +334,12 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
tr("Failed to reload config:\n%1").arg(e.what()));
return;
}
m_gameWorldView->resetForNewGame();
// Restart is a command boundary; the view resets when the drain applies it.
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
command->config = std::move(newConfig);
command->seed = std::random_device{}();
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{

View File

@@ -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
};

View File

@@ -12,6 +12,8 @@
#include <QVBoxLayout>
#include "BeltSystem.h"
#include "Command.h"
#include "CommandRequestedEvent.h"
#include "DynamicBodyComponent.h"
#include "EntityAdmin.h"
#include "EntitySelectedEvent.h"
@@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
if (m_singleIsSite)
{
m_sim->buildings().setSiteSplitterFilters(
m_singleBuildingId,
collectFilter(m_filterAList),
collectFilter(m_filterBList));
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
std::make_shared<SetSiteSplitterFiltersCommand>();
command->id = m_singleBuildingId;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
else
{
m_sim->belts().setSplitterFilters(
m_splitterTile,
collectFilter(m_filterAList),
collectFilter(m_filterBList));
std::shared_ptr<SetSplitterFiltersCommand> command =
std::make_shared<SetSplitterFiltersCommand>();
command->tile = m_splitterTile;
command->filterA = collectFilter(m_filterAList);
command->filterB = collectFilter(m_filterBList);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
}
@@ -767,7 +775,11 @@ void SelectedBuildingPanel::onClearBelt()
}
if (!tiles.empty())
{
m_sim->belts().clearTiles(tiles);
std::shared_ptr<ClearBeltTilesCommand> command =
std::make_shared<ClearBeltTilesCommand>();
command->tiles = std::move(tiles);
EventManager::getInstance()->sendEventImmediately(
std::make_shared<CommandRequestedEvent>(command));
}
}