replay: add determinism foundation and double-run verification (Phase 0)
Introduces the state-checksum machinery the replay feature rests on, and a test that proves the simulation is deterministic for a given seed/binary. - StateChecksum: FNV-1a Hasher (bit-pattern float hashing, -0 normalization, length-tagged strings) plus a portable mt19937 state fingerprint. - BeltSystem/BuildingSystem: appendChecksum(Hasher&) folding transport and building/site/occupancy state in deterministic (sorted/insertion) order. - Simulation: rngFingerprint() (cheap, for the future file checksum) and computeStateChecksum() (full state: RNG, scalars, wave/schematic/unlock state, subsystems, and ECS position/health/facing/body/scrap/identity). - DeterminismTest: Hasher unit tests, RNG-fingerprint tests, and a double-run test asserting identical per-tick full-state checksums from one seed; plus a different-seed divergence guard. Exit criteria met: double-run determinism test passes; full suite green (334 cases / 3346 assertions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
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;
|
||||
|
||||
@@ -9,6 +9,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
|
||||
@@ -22,6 +23,7 @@ SET(SRCS
|
||||
${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
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#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 +18,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"
|
||||
@@ -825,6 +829,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
class AiSystem;
|
||||
class BuildingSystem;
|
||||
class Hasher;
|
||||
class CombatSystem;
|
||||
class DynamicBodySystem;
|
||||
class MovementIntentSystem;
|
||||
@@ -88,6 +89,16 @@ public:
|
||||
bool isRecipeUnlocked(const std::string& recipeId) const;
|
||||
bool isItemUnlocked(const std::string& itemId) const;
|
||||
|
||||
// -- 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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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);
|
||||
@@ -149,6 +160,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);
|
||||
@@ -21,4 +21,5 @@ add_files(
|
||||
ShipModuleTest.cpp
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.cpp
|
||||
DeterminismTest.cpp
|
||||
)
|
||||
|
||||
157
src/test/DeterminismTest.cpp
Normal file
157
src/test/DeterminismTest.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
#include "catch.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "ConfigLoader.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
#include "Simulation.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.
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East);
|
||||
sim.tryPlaceBuilding(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.
|
||||
sim.tryPlaceBuilding(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);
|
||||
}
|
||||
Reference in New Issue
Block a user