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:
2026-06-30 17:44:17 +02:00
parent a97687154e
commit a45df902aa
11 changed files with 596 additions and 0 deletions

View File

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

View File

@@ -15,6 +15,8 @@
#include "Port.h" #include "Port.h"
#include "Rotation.h" #include "Rotation.h"
class Hasher;
// Carries item type and fractional world position for the renderer. // Carries item type and fractional world position for the renderer.
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords. // worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
struct VisualItem struct VisualItem
@@ -92,6 +94,11 @@ public:
void forEachVisualItem(QRect viewportTiles, void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const; 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: private:
void advanceProgress(); void advanceProgress();
void advanceTunnelProgress(); void advanceTunnelProgress();
@@ -170,6 +177,9 @@ private:
std::vector<TunnelTransitItem> items; // front (highest progress) to back 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 double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
std::map<std::pair<int, int>, BeltTile> m_belts; std::map<std::pair<int, int>, BeltTile> m_belts;

View File

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

View File

@@ -23,6 +23,8 @@
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Tick.h" #include "Tick.h"
class Hasher;
// Manages building placement, construction queuing, and the per-tick // Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push). // production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances; // All types including Belt and Splitter are stored as Building instances;
@@ -151,6 +153,11 @@ public:
// Mutable iteration over all operational buildings. // Mutable iteration over all operational buildings.
void forEachBuilding(std::function<void(Building&)> fn); 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: private:
const BuildingDef* findBuildingDef(BuildingType type) const; const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;

View File

@@ -9,6 +9,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE PARENT_SCOPE
@@ -22,6 +23,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE PARENT_SCOPE

View File

@@ -7,7 +7,9 @@
#include "DisplayName.h" #include "DisplayName.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "CombatSystem.h" #include "CombatSystem.h"
#include "DynamicBodyComponent.h"
#include "DynamicBodySystem.h" #include "DynamicBodySystem.h"
#include "FacingComponent.h"
#include "FactionComponent.h" #include "FactionComponent.h"
#include "EventManager.h" #include "EventManager.h"
#include "HealthComponent.h" #include "HealthComponent.h"
@@ -16,9 +18,11 @@
#include "PositionComponent.h" #include "PositionComponent.h"
#include "RepairSystem.h" #include "RepairSystem.h"
#include "SalvagerSystem.h" #include "SalvagerSystem.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h" #include "ScrapSystem.h"
#include "ShipIdentityComponent.h" #include "ShipIdentityComponent.h"
#include "ShipSystem.h" #include "ShipSystem.h"
#include "StateChecksum.h"
#include "StationBodyComponent.h" #include "StationBodyComponent.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "tracing.h" #include "tracing.h"
@@ -825,6 +829,116 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
return m_unlockedItemIds.count(itemId) > 0; 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 // Drains
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -24,6 +24,7 @@
class AiSystem; class AiSystem;
class BuildingSystem; class BuildingSystem;
class Hasher;
class CombatSystem; class CombatSystem;
class DynamicBodySystem; class DynamicBodySystem;
class MovementIntentSystem; class MovementIntentSystem;
@@ -88,6 +89,16 @@ public:
bool isRecipeUnlocked(const std::string& recipeId) const; bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) 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. // Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient. // Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); 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_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels; 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). // Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds; std::set<std::string> m_unlockedRecipeSchematicIds;

View File

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

View File

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

View File

@@ -21,4 +21,5 @@ add_files(
ShipModuleTest.cpp ShipModuleTest.cpp
ThreatCostCalculatorTest.cpp ThreatCostCalculatorTest.cpp
RecipeSchematicTest.cpp RecipeSchematicTest.cpp
DeterminismTest.cpp
) )

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