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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
141
src/lib/sim/Command.h
Normal 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;
|
||||
};
|
||||
92
src/lib/sim/CommandManager.cpp
Normal file
92
src/lib/sim/CommandManager.cpp
Normal 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());
|
||||
}
|
||||
}
|
||||
55
src/lib/sim/CommandManager.h
Normal file
55
src/lib/sim/CommandManager.h
Normal 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;
|
||||
};
|
||||
314
src/lib/sim/CommandSerializer.cpp
Normal file
314
src/lib/sim/CommandSerializer.cpp
Normal 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;
|
||||
}
|
||||
21
src/lib/sim/CommandSerializer.h
Normal file
21
src/lib/sim/CommandSerializer.h
Normal 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);
|
||||
56
src/lib/sim/ReplayPlayer.cpp
Normal file
56
src/lib/sim/ReplayPlayer.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
#include "ReplayPlayer.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
#include "Command.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries)
|
||||
: m_simulation(simulation)
|
||||
, m_entries(std::move(entries))
|
||||
{
|
||||
}
|
||||
|
||||
void ReplayPlayer::start()
|
||||
{
|
||||
processEntriesAt(0);
|
||||
}
|
||||
|
||||
void ReplayPlayer::advanceTo(Tick tick)
|
||||
{
|
||||
processEntriesAt(tick);
|
||||
}
|
||||
|
||||
bool ReplayPlayer::isFinished() const
|
||||
{
|
||||
return m_desyncTick.has_value() || m_cursor >= m_entries.size();
|
||||
}
|
||||
|
||||
std::optional<Tick> ReplayPlayer::getDesyncTick() const
|
||||
{
|
||||
return m_desyncTick;
|
||||
}
|
||||
|
||||
void ReplayPlayer::processEntriesAt(Tick tick)
|
||||
{
|
||||
// Consume entries for this tick in file order. The recording writes, at a
|
||||
// given tick: the periodic checksum (if any) first, then command lines each
|
||||
// followed by their post-apply checksum — so applying/verifying in file order
|
||||
// reproduces the original sequence exactly.
|
||||
while (!m_desyncTick.has_value()
|
||||
&& m_cursor < m_entries.size()
|
||||
&& m_entries[m_cursor].tick == tick)
|
||||
{
|
||||
const ReplayEntry& entry = m_entries[m_cursor];
|
||||
++m_cursor;
|
||||
|
||||
if (entry.isCommand)
|
||||
{
|
||||
m_simulation.apply(*entry.command);
|
||||
}
|
||||
else if (m_simulation.rngFingerprint() != entry.fingerprint)
|
||||
{
|
||||
m_desyncTick = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/lib/sim/ReplayPlayer.h
Normal file
48
src/lib/sim/ReplayPlayer.h
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include "ReplayReader.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Simulation;
|
||||
|
||||
// Drives playback of a parsed replay against a Simulation: applies each recorded
|
||||
// command at its recorded tick (through Simulation::apply) and verifies the RNG
|
||||
// checksums, reporting the first desync. Entries are consumed in file order,
|
||||
// which reproduces the exact command/tick interleaving of the original run.
|
||||
//
|
||||
// Cadence (mirrors how the run was recorded so checksums line up):
|
||||
// player.start(); // process tick-0 entries before the first tick
|
||||
// each frame, for each tick to run:
|
||||
// if (player.isFinished()) break;
|
||||
// sim.tick();
|
||||
// player.advanceTo(sim.currentTick());
|
||||
class ReplayPlayer
|
||||
{
|
||||
public:
|
||||
ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries);
|
||||
|
||||
// Process all entries recorded at tick 0 (the initial checksum and any
|
||||
// commands issued before the first tick). Call once, before the first tick.
|
||||
void start();
|
||||
|
||||
// Process all entries recorded at `tick`. Call once after each sim.tick().
|
||||
void advanceTo(Tick tick);
|
||||
|
||||
// True once all entries are consumed or a desync was detected.
|
||||
bool isFinished() const;
|
||||
|
||||
// The tick at which the recomputed checksum first diverged, if any.
|
||||
std::optional<Tick> getDesyncTick() const;
|
||||
|
||||
private:
|
||||
void processEntriesAt(Tick tick);
|
||||
|
||||
Simulation& m_simulation;
|
||||
std::vector<ReplayEntry> m_entries;
|
||||
std::size_t m_cursor = 0;
|
||||
std::optional<Tick> m_desyncTick;
|
||||
};
|
||||
145
src/lib/sim/ReplayReader.cpp
Normal file
145
src/lib/sim/ReplayReader.cpp
Normal file
@@ -0,0 +1,145 @@
|
||||
#include "ReplayReader.h"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandSerializer.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
void stripCarriageReturn(std::string& line)
|
||||
{
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
{
|
||||
line.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
// Splits "key value with spaces" into (key, remainder). Remainder may be empty.
|
||||
void splitKeyValue(const std::string& line, std::string& key, std::string& value)
|
||||
{
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
key = line;
|
||||
value.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
key = line.substr(0, space);
|
||||
value = line.substr(space + 1);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path)
|
||||
{
|
||||
std::ifstream stream(path, std::ios::in);
|
||||
if (!stream.is_open())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
ParsedReplay replay;
|
||||
std::string line;
|
||||
|
||||
// --- Header (up to the "---" separator) ---
|
||||
bool sawSeparator = false;
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line == "---")
|
||||
{
|
||||
sawSeparator = true;
|
||||
break;
|
||||
}
|
||||
if (line.empty() || line[0] == '#')
|
||||
{
|
||||
continue; // banner / blank
|
||||
}
|
||||
|
||||
std::string key;
|
||||
std::string value;
|
||||
splitKeyValue(line, key, value);
|
||||
try
|
||||
{
|
||||
if (key == "version") { replay.header.version = std::stoi(value); }
|
||||
else if (key == "build") { replay.header.build = value; }
|
||||
else if (key == "seed") { replay.header.seed = static_cast<unsigned int>(std::stoul(value)); }
|
||||
else if (key == "config_hash") { replay.header.configHash = value; }
|
||||
else if (key == "timestamp") { replay.header.timestamp = value; }
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawSeparator)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// --- Command / checksum stream ---
|
||||
while (std::getline(stream, line))
|
||||
{
|
||||
stripCarriageReturn(line);
|
||||
if (line.empty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line[0] == '#')
|
||||
{
|
||||
// "# checksum <tick> <hex>"
|
||||
std::istringstream in(line);
|
||||
std::string hash;
|
||||
std::string keyword;
|
||||
ReplayEntry entry;
|
||||
in >> hash >> keyword >> entry.tick >> hash;
|
||||
if (keyword != "checksum")
|
||||
{
|
||||
continue; // unknown comment line — ignore
|
||||
}
|
||||
try
|
||||
{
|
||||
entry.fingerprint = std::stoull(hash, nullptr, 16);
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = false;
|
||||
replay.entries.push_back(std::move(entry));
|
||||
continue;
|
||||
}
|
||||
|
||||
// "<tick> <serialized command>"
|
||||
const std::string::size_type space = line.find(' ');
|
||||
if (space == std::string::npos)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
ReplayEntry entry;
|
||||
try
|
||||
{
|
||||
entry.tick = std::stoll(line.substr(0, space));
|
||||
}
|
||||
catch (const std::exception&)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
std::shared_ptr<Command> command = parseCommand(line.substr(space + 1));
|
||||
if (!command)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
entry.isCommand = true;
|
||||
entry.command = std::move(command);
|
||||
replay.entries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
return replay;
|
||||
}
|
||||
41
src/lib/sim/ReplayReader.h
Normal file
41
src/lib/sim/ReplayReader.h
Normal file
@@ -0,0 +1,41 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Tick.h"
|
||||
|
||||
struct Command;
|
||||
|
||||
struct ReplayHeader
|
||||
{
|
||||
int version = 0;
|
||||
std::string build;
|
||||
unsigned int seed = 0;
|
||||
std::string configHash;
|
||||
std::string timestamp;
|
||||
};
|
||||
|
||||
// One entry of the replay stream: either a command (applied at its tick) or an
|
||||
// RNG checksum (verified at its tick). Entries are kept in file order, which is
|
||||
// the canonical order the playback driver reproduces.
|
||||
struct ReplayEntry
|
||||
{
|
||||
Tick tick = 0;
|
||||
bool isCommand = false;
|
||||
std::shared_ptr<const Command> command; // set iff isCommand
|
||||
std::uint64_t fingerprint = 0; // set iff !isCommand
|
||||
};
|
||||
|
||||
struct ParsedReplay
|
||||
{
|
||||
ReplayHeader header;
|
||||
std::vector<ReplayEntry> entries;
|
||||
};
|
||||
|
||||
// Parses a replay file (header + command/checksum stream). Returns nullopt on an
|
||||
// I/O failure or malformed content (e.g. an unparseable command line).
|
||||
std::optional<ParsedReplay> readReplayFile(const std::string& path);
|
||||
130
src/lib/sim/ReplayRecorder.cpp
Normal file
130
src/lib/sim/ReplayRecorder.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include "ReplayRecorder.h"
|
||||
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "Command.h"
|
||||
#include "CommandSerializer.h"
|
||||
#include "StateChecksum.h"
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr const char* kReplayFormatVersion = "1";
|
||||
|
||||
// Build fingerprint: even a new local build can desync old replays (float
|
||||
// reasons), so the header carries a per-build tag to warn on mismatch.
|
||||
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
|
||||
|
||||
std::string toHex(std::uint64_t value)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setw(16) << std::setfill('0') << value;
|
||||
return out.str();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
|
||||
: m_configDir(std::move(configDir))
|
||||
, m_outputDir(std::move(outputDir))
|
||||
{
|
||||
}
|
||||
|
||||
ReplayRecorder::~ReplayRecorder()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string 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;
|
||||
}
|
||||
54
src/lib/sim/ReplayRecorder.h
Normal file
54
src/lib/sim/ReplayRecorder.h
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
61
src/lib/sim/StateChecksum.cpp
Normal file
61
src/lib/sim/StateChecksum.cpp
Normal 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();
|
||||
}
|
||||
55
src/lib/sim/StateChecksum.h
Normal file
55
src/lib/sim/StateChecksum.h
Normal 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);
|
||||
Reference in New Issue
Block a user