Replay: deterministic record & playback (#4)

Add deterministic record/playback for a run.

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

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

View File

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