From a97687154ebd73b9245c447db65952a21817e416 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 15:33:55 +0200 Subject: [PATCH 1/7] docs: add replay record/playback design and implementation plan Captures the deterministic command-replay design (seed + config hash + tick-tagged command log, re-simulated on playback), the command-chokepoint and timing model, determinism/checksum strategy, file format, and a sequenced Phase 0-4 implementation plan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 408 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 docs/replay_design.md diff --git a/docs/replay_design.md b/docs/replay_design.md new file mode 100644 index 0000000..c9432c6 --- /dev/null +++ b/docs/replay_design.md @@ -0,0 +1,408 @@ +# Replay — Design + +This document captures the design for the replay record/playback feature. It records the +decisions made during design discussion; it is a complement to `architecture.md`. No +implementation exists yet — this is the agreed design to implement against. + +## Goal + +Record every play session and allow it to be played back later. Playback is **view-only** +(no interaction) with **manual game-speed selection** (including pause). Playback is launched +via a command-line argument to the executable. + +## Approach: deterministic command-replay (re-simulation) + +We record **player intent** (commands) plus the inputs needed to reproduce the run, and on +playback we **re-run the real simulation**, injecting the recorded commands at their recorded +ticks. We do **not** record per-tick state snapshots. + +This is viable because the simulation is already built for it (see `architecture.md`: +"determinism, replayability ... fall out for free"): + +- Fixed 30 Hz tick-based simulation, decoupled from render rate via `TickDriver`. +- Game speed (0/0.5/1/2/4×) and pause are tick-rate multipliers — they change *how many* + ticks run per frame, never the *outcome* of a tick. So speed, pause, camera scroll, and + selection are pure presentation and are **not recorded**. +- A single deterministic RNG stream: `Simulation::m_rng` (one `std::mt19937`) is passed by + reference into `WaveSystem` and `BuildingSystem`, the only two consumers. ECS combat/AI/ + movement/scrap systems use no RNG. The `utility::getRandom*` global is not used by the sim. +- Config is immutable after load; a replay is pinned to the config it was recorded with. + +A replay run is therefore a pure function of `(seed, config, ordered commands)`. + +### What we do NOT do (now) + +- No per-tick / keyframe state snapshots. +- No backward seek / scrubbing (would require snapshots). +- No save/load. (See "Future direction".) +- No interactive playback (no taking over a replay mid-run). + +## Replay commands + +A *replay command* is the resolved, serializable **intent** behind a player action — the +data, not the UI gesture. Example: placing a miner records +`PlaceBuilding{type=Miner, anchor=(3,5), rotation=East}`, not the mouse pixel that produced it. + +- Commands are at **intent level, resolved to tile coordinates / domain ids** — independent + of window size, camera scroll, and DPI. +- Command payloads reference **stable, deterministic domain ids** (`BuildingId`, tile + coordinates, choice indices) — never raw `entt::entity` handles. These ids are sim-allocated + deterministically, so a recorded command resolves to the same entity on replay. +- Camera scroll, selection, game speed, and pause are **not** commands. + +### Command vocabulary + +One command per sim-mutating operation (the complete mutation surface): + +- `PlaceBuilding` +- `Demolish` +- `RotateInPlace` +- `SetRecipe` +- `SetShipLayout` +- `SetSplitterFilters` (building-site and belt variants) +- `ClearBeltTiles` +- `ApplySchematicChoice` +- `Reset` / restart — see "Restart is a boundary". + +### Command representation + +Commands use a **base class + derived classes** (mirroring the existing `Event` hierarchy +idiom, so it is native to this codebase). They are routed through a dedicated command path, +**not** through `EventManager` (see next section). + +## Command system: reuse the *pattern*, not the EventManager singleton + +We reuse the **pattern** of the existing event system (a polymorphic base + small derived +types), but the sim-mutating command path is a **dedicated, ordered queue**, not the +`EventManager` pub/sub bus. Reasons: + +1. **Determinism / ordering.** Sim mutations must apply in a strict, tick-pinned, recorded + order. `architecture.md` deliberately keeps the sim free of `EventManager` for exactly this + reason (determinism, tick-order fidelity, headless testability — why `BeamFiredEvent` uses a + plain vector). Routing commands into the sim via the singleton would break that. +2. **Single consumer.** A command has exactly one recipient (the `Simulation`); pub/sub + N-handler fan-out is the wrong shape. +3. **Recording chokepoint.** One place must see every command, stamp its tick, append it to the + file, and apply it. A direct queue gives that; a multi-handler bus does not. +4. **Headless tests.** Tests link only `lib` and build a `Simulation` directly; the command + type and apply path live in `lib` and must work with no UI and no singleton. + +### Structure + +- **In `lib`:** a `Command` base class + derived command types, plus a `CommandManager` + (ordered queue) and a single `Simulation::apply(command)` chokepoint. +- **UI fan-in still uses `EventManager`:** widgets emit a UI event as today; a single + dispatcher/recorder catches it, builds the `lib` command, and hands it to the + `CommandManager`. This keeps widgets decoupled (consistent with current architecture). +- **Replay** skips the UI half and feeds commands straight into the same `CommandManager` / + `Simulation::apply` chokepoint. + +### The completeness invariant (enforced structurally) + +**Every** sim mutation must flow through the single `CommandManager → Simulation::apply` +chokepoint. Any path that mutates the sim directly would not be recorded and would silently +desync the replay. + +This is enforced **structurally, not by discipline**: the `Simulation` mutators +(`tryPlaceBuilding`, `demolish`, `setRecipe`, …) are made private/non-public so the only way to +reach them is `apply(command)`. A stray direct call then fails to compile rather than compiling +and silently desyncing. + +Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that +actually reached the sim are recorded, and they replay through the identical apply path. +UI-side validation (placement validity, affordability) remains a pre-filter that simply does +not produce a command unless the action reaches the sim. + +## Command timing: drain once per frame, before the tick batch + +- During live play, input pushes commands onto the `CommandManager` queue (not applied + synchronously). +- The queue is drained at **one defined point: once per frame, before stepping the tick + batch.** The whole queue is drained in FIFO order (not one-per-tick), so bursts (e.g. laying + many belts quickly) apply immediately instead of dribbling across ticks, and it matches the + lockstep model wanted later. +- Each drained command is **tagged with the current completed-tick count**, recorded at drain + time (so record-order == apply-order canonically), and applied. + +### Build-while-paused is preserved + +The drain runs every frame including at 0× (the tick batch is simply empty when paused). So a +player can place buildings while paused and **see the construction sites immediately**. This is +still fully deterministic: replay applies each command at its recorded tick regardless of the +frame cadence that produced it. + +On replay, there is no input; the player applies each pre-filled command at its recorded tick +through the same drain path, preserving order. + +The Qt single-threaded event loop guarantees input events and the `onFrame` tick-batch never +interleave, so the completed-tick count at drain time is unambiguous. (If the sim is ever moved +to a worker thread, this needs a lock at the sim boundary.) + +## Determinism: checksums and verification + +We do not verify EnTT iteration order statically. EnTT view iteration is a pure function of the +sequence of spawn/destroy/add/remove operations, so on a fixed binary it contributes zero +run-to-run nondeterminism. Instead we verify **end-to-end determinism** with a state checksum, +and any divergence (EnTT order, float, container ordering, etc.) surfaces loudly. + +### What is checksummed (now) + +- **RNG state only**, for now. The `mt19937` state is fingerprinted into a 64-bit value. +- The hash can be extended later (entity positions/HP, belt items, building buffers, scalars) + without changing the format. + +### Cadence + +- **In the replay file:** every **30 ticks**, **and** after **every command** is applied. The + per-command checksum pins any divergence to the action that triggered it; the periodic one + localizes drift to a ~1 s window. On playback the recomputed checksum is compared; a mismatch + reports "desync at tick N". +- **In tests:** the Catch2 **double-run determinism test** hashes **full sim state every tick** + (not just RNG). It runs a scripted command sequence twice from the same seed and asserts + per-tick checksums match. This keeps the file lean while still catching non-RNG determinism + bugs during development. + +### Known limitation of the RNG-only file checksum (accepted) + +An RNG-only checksum only catches divergences that change **how much randomness is consumed** +(wave composition, recipe rolls, scrap). Float or iteration drift that does **not** alter RNG +draw counts passes the checksum undetected. This is acceptable for same-binary Windows replay +(no float drift expected on an identical binary; the checksum's real job there is catching +determinism *bugs*). When cross-platform replay becomes a goal, the **file** hash must be +expanded to include entity state. + +## Cross-platform: Windows-first, portable by construction + +The replay file is platform-neutral data; `std::mt19937` is bit-identical across platforms, so +RNG is not a cross-platform problem. The only real cross-platform issue is **floating-point +reproducibility** — the sim does heavy `QVector2D` float math, and a 1-ULP difference (compiler +/ CPU / SIMD / FMA contraction) can flip an in-range comparison and cascade into different ship +behavior (the classic lockstep-RTS problem). + +Decision: **Windows-only first**, but make the later swap cheap and bounded by, from day one: + +- a **per-period state checksum** in the file (above), and +- a **build/version + config-hash identity tag** in the header. + +Then cross-platform later is a contained float-hardening pass (`/fp:strict`, no FMA contraction, +possibly fixed-point positions) guided by the checksums — **not** a redesign of the +command-replay architecture. + +Note: even a new Windows *build* of the game can desync old replays for the same float reasons, +so the version tag + "warn on mismatch" is needed regardless of cross-platform ambitions. + +## Seed and config + +- **Seed:** a **random** seed is generated at the start of each run, **outside** the sim (e.g. + `std::random_device` in `main`/reset), so the `Simulation` stays a pure function of + `(seed, config, commands)`. The seed is written to the replay header. +- **Config:** the header stores a **config hash** (not a full config snapshot). On playback the + current config is hashed and compared; a mismatch warns/refuses. The hash is taken over the + actually-loaded config (so editing config files and restarting yields a new, consistent + replay). + +## File format: line-oriented append-friendly text + +Non-binary, chosen for readability and crash-safety. Size is a non-issue: the command log is +sparse (only ticks with a player action), so even a multi-hour game is tens of KB in any text +format. + +- A small keyed/header section: seed, config hash, build/version, start timestamp. +- One line per command, e.g. `1234 place miner 3 5 E`. +- Periodic checksum lines interleaved, e.g. `# checksum 9000 a1b2c3...`. + +Why line-oriented text: + +- **Append-friendly** — the recorder stream-appends as the game runs, so a crash does not lose + the replay (a crash is exactly when you would want it). A format that must be rewritten/closed + as a whole is rejected for this reason. +- **No new dependency** — the project has no JSON lib; toml++ is parse-oriented and clunky for a + long event stream (fine for the header, awkward as an array-of-tables of thousands of + entries). +- Greppable, diffable, tiny. +- Aligns with the project's existing text-serialization idiom (`BlueprintSerializer`, + `ShipLayoutBlueprintSerializer`). + +## Recording lifecycle + +- **Record every run.** A new replay file is created at `Simulation` construction and at each + `reset()`. +- **Restart is a boundary.** Restart (escape menu → restart, which reloads config and resets) + closes the current file and opens a new one with a fresh seed and header. One replay file = + one contiguous run from tick 0 to game-over/quit. +- **Retention: keep everything.** Files live in the existing `data/` directory, named by + timestamp + seed. (No automatic pruning for now.) + +## Playback + +Launched via a command-line argument, e.g. `DotaFactory.exe --replay `. + +`main` for the `--replay` path: + +1. Read the header → validate config hash and build/version (warn on mismatch). +2. Construct the `Simulation` from the recorded seed + config. +3. Construct the `CommandManager` in **Replay mode**, **pre-filled** with the whole command list + from the file. (Pre-fill memory is trivial; streaming-read is a later optimization if files + ever get huge — not needed now.) +4. Run the driver in replay mode: each frame, drain commands due at the reached tick (same drain + path as live), step ticks, compare checksums. + +### Replay mode rules + +Reframe: the schematic-choice modal is **an input source** (the device that produces an +`ApplySchematicChoice` command in live play), exactly like the mouse. Replay's single rule is +"**disable live input sources**", which the modal falls under. + +- **`CommandManager` in replay mode:** `addCommand` is a no-op; the queue is pre-filled from the + file. Live input therefore produces nothing. +- **Only two reactions need explicit gating** — the sim-state *polls* in `onFrame` that emit + `SchematicChoicesAvailableEvent` and `GameOverEvent`. In replay these polls do not run, so no + modal opens, no auto-pause occurs, and there is no deadlock against the recorded command. +- **Everything else falls away for free** because it is click-driven, not sim-state-driven: the + recipe dialog (`RecipeSelectionRequestedEvent`), ship-layout dialog + (`LayoutDialogRequestedEvent`), and escape menu are all triggered by player input, which is + disabled — so they never open and need no special handling. +- **Schematic choice still resolves with no UI:** the sim regenerates identical choices + deterministically (same seed + prior commands), and the pre-filled `ApplySchematicChoice` + applies itself at its recorded tick through the normal drain path. The tick-tag invariant + places it correctly relative to when the choices became pending, in both record and replay. +- **Game-over is replaced, not just suppressed:** instead of the live restart/quit dialog, + playback detects the end condition (command stream exhausted / recorded game-over reached) and + stops, showing a passive "replay ended" state. +- **Kept in replay:** the renderer/view and **manual game-speed selection** (including pause / + 0× and fast-forward via high speed). Playback only ever moves forward. + +## Future direction (informs the design, not built now) + +Save/load and (deterministic lockstep) multiplayer are wanted later. The command bus is the +shared foundation; two cheap shaping decisions now keep that path open: + +1. **Each command carries a source/player id** (always "player 0" in single-player). Lockstep + multiplayer is just commands from multiple sources merged into one ordered stream. +2. **Commands are applied at a defined tick boundary** (already required for replay). Multiplayer + schedules them a few ticks in the future to hide latency; single-player uses the next drain. + +Implications to note: + +- Multiplayer makes cross-platform float determinism mandatory and promotes the checksum to + load-bearing desync-detection (rather than a test aid) — reinforcing doing the checksum now. +- **Save/load** is the one feature that needs a *different* mechanism: either "replay to current + tick" on load (reuses 100% of replay machinery; load time grows with game length, though + fast-forward usually replays hours in seconds), or a full **state-snapshot serializer** + (EnTT registry + belts + buildings + scalars). The snapshot serializer is also what + backward-seek/scrubbing would need. Building the command bus now does not block adding it + later; it is explicitly out of scope here. + +## Summary of decisions + +- Approach: **A — deterministic command-replay** (re-simulation), no snapshots. +- Scope: **view-only** playback + **manual speed selection**; launched via CLI argument. +- Commands: **base class + derived types**, routed through a dedicated `CommandManager` queue + and a single `Simulation::apply` chokepoint; sim mutators made non-public to **enforce** the + chokepoint. UI fan-in still uses `EventManager`. +- Timing: queue **drained once per frame before the tick batch**, whole queue FIFO, each command + tick-tagged; **build-while-paused preserved**. +- Determinism: **RNG-state checksum** in the file every **30 ticks + after each command**; + **full-state per-tick hashing** in the Catch2 double-run test. Known RNG-only blind spot + accepted for now. +- Platform: **Windows-first**; file format + version/config-hash make a later cross-platform + pass contained. +- Seed: **random**, generated outside the sim, written to the header. +- Config: **config hash** in the header, validated on playback. +- File: **line-oriented append-friendly text**, kept in `data/`, **one file per run**, + **retain everything**. +- Restart: **a boundary** — new file, new seed. +- Replay mode: `CommandManager` `addCommand` is a no-op + pre-filled; gate the two sim-state + polls (schematic choices, game-over); passive "replay ended" instead of the game-over dialog; + keep view + speed. + +## Implementation plan + +Ordered to de-risk: prove determinism first, then build the command path, then recording, then +playback. Each phase is independently testable and leaves the game in a working state. Phases +0 → 1 → 2 → 3 are strictly sequential; Phase 4 tests can start as soon as their subject exists. + +### Phase 0 — Determinism foundation & verification (no replay yet) + +The whole feature rests on a deterministic sim, so prove that before building on it. + +- Add a `mt19937` state **fingerprint** (fold its serialized state into a 64-bit value). +- Add a **full-state checksum** path (positions, HP, velocities, belt items, building buffers, + scalars), used by tests; each subsystem contributes via its own `appendChecksum(Hasher&)` so + no state knowledge is duplicated. +- Add a Catch2 **double-run determinism test**: run a scripted sequence twice from the same + seed, assert per-tick **full-state** checksums match. +- **Files:** new `lib/sim` checksum helper; small additions to `Simulation`, `BeltSystem`, + `BuildingSystem`, ECS state; new test. +- **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before + proceeding. + +### Phase 1 — Command model + enforced chokepoint (no recording yet) + +Reshape mutations to flow through one path; behaviour unchanged. + +- Define `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, + `SetRecipe`, `SetShipLayout`, `SetSplitterFilters`, `ClearBeltTiles`, `ApplySchematicChoice`, + `Reset`) in `lib`. Each carries a source/player id (always 0 now) for the future-multiplayer + shape. +- Add `CommandManager` (FIFO queue, drain) in `lib`, holding a `Simulation&`. +- Add `Simulation::apply(const Command&)` dispatching to the existing mutators; then **make + those mutators non-public** so `apply` is the only entry — compile-enforces the completeness + invariant. +- Wire the drain: in `GameWorldView::onFrame`, call `CommandManager::drain()` **once per frame, + before** the tick batch; tag each command with the current completed-tick count. +- Refactor every UI mutation site to **emit a single `CommandRequestedEvent`** (carrying a + `shared_ptr`) via the existing `EventManager`; one dispatcher subscribes and enqueues + onto `CommandManager`. +- **Files:** new `lib` command + `CommandManager`; `Simulation.h/.cpp`; call sites in + `GameWorldView.cpp`, `MainWindow.cpp`, `SelectedBuildingPanel.cpp`; new dispatcher in + `ui`/`app`. +- **Exit criteria:** game plays identically (including build-while-paused), determinism test + still passes, no widget can call a sim mutator directly (won't compile). + +### Phase 2 — Recording + +- Implement the **line-oriented append writer**: header (seed, config hash, build/version, + timestamp) + one line per command + checksum lines. +- Generate a **random seed outside the sim** (in `main`/reset), write to header. +- Compute the **config hash** over the loaded config. +- Hook the **recorder at the apply chokepoint** in `CommandManager::drain()`: append each command + (tick-tagged), append an **RNG checksum after every command** and **every 30 ticks**. +- **Lifecycle:** open a new file on `Simulation` construction and on each `reset()` (restart = + boundary); store in `data/`, named by timestamp+seed; retain everything. +- **Files:** new replay writer in `lib`; `main.cpp` (seed), config hash helper; + `CommandManager`/`Simulation` for the tick checksum hook. +- **Exit criteria:** every run produces a well-formed, growing replay file; a crash mid-run still + leaves a valid partial file. + +### Phase 3 — Playback + +- Implement the **reader/parser** for the format (header + commands + checksums). +- Add the `--replay ` CLI path in `main`: validate config hash + version (warn on + mismatch), construct `Simulation` from seed+config, construct `CommandManager` in **replay + mode** (pre-filled, `addCommand` is a no-op). +- Replay driver: each frame, drain commands due at the reached tick (same drain path), step + ticks, **keep manual speed/pause**, playback only moves forward. +- **Gate the two `onFrame` polls** (schematic-choices, game-over) off in replay mode; everything + else (recipe/layout dialogs, escape menu) is input-driven and falls away automatically. +- **Desync detection:** recompute the RNG checksum at each checkpoint, compare to the file, + report "desync at tick N" on mismatch. +- **Passive end:** when the command stream is exhausted / recorded game-over is reached, stop + with a "replay ended" overlay instead of the restart dialog. +- **Files:** new reader in `lib`; `main.cpp`; `CommandManager` (replay mode); `GameWorldView.cpp` + (poll gating, end overlay). +- **Exit criteria:** a recorded file plays back identically; checksums match throughout. + +### Phase 4 — Closing tests & polish + +- **Round-trip test:** serialize → parse → assert command equality. +- **Replay-equivalence test (headless):** record a scripted run, play it back through the same + `lib` path, assert per-tick checksums match end-to-end — the real proof, no UI needed. +- Mismatch-warning UX, end-of-replay overlay polish. + +### Notes + +- Phase 1 is the largest (the mutation-site refactor); Phase 0 is the riskiest (it may surface + latent nondeterminism that must be fixed first). +- Still deferred (per this design): snapshots, save/load, backward-seek, cross-platform float + hardening, expanding the file checksum beyond RNG. -- 2.49.1 From a45df902aa0c1871ca864d7f2fce33560fc4990b Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 17:44:17 +0200 Subject: [PATCH 2/7] 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 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- src/lib/sim/BeltSystem.cpp | 88 ++++++++++++++++++ src/lib/sim/BeltSystem.h | 10 +++ src/lib/sim/BuildingSystem.cpp | 85 ++++++++++++++++++ src/lib/sim/BuildingSystem.h | 7 ++ src/lib/sim/CMakeLists.txt | 2 + src/lib/sim/Simulation.cpp | 114 ++++++++++++++++++++++++ src/lib/sim/Simulation.h | 16 ++++ src/lib/sim/StateChecksum.cpp | 61 +++++++++++++ src/lib/sim/StateChecksum.h | 55 ++++++++++++ src/test/CMakeLists.txt | 1 + src/test/DeterminismTest.cpp | 157 +++++++++++++++++++++++++++++++++ 11 files changed, 596 insertions(+) create mode 100644 src/lib/sim/StateChecksum.cpp create mode 100644 src/lib/sim/StateChecksum.h create mode 100644 src/test/DeterminismTest.cpp diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index de8e167..34438bb 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -2,6 +2,7 @@ #include +#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& 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, 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, 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, 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, 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); + } + } +} + diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index 7006fe0..322a19c 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -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 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 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& slotRun); + double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz std::map, BeltTile> m_belts; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index db3f527..8343c2e 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -5,6 +5,7 @@ #include #include +#include "StateChecksum.h" #include "SurfaceMask.h" #include "tracing.h" @@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector& cells) m_tileOccupancy.erase({cell.x(), cell.y()}); } } + +namespace +{ +void appendItems(Hasher& hasher, const std::vector& items) +{ + hasher.append(items.size()); + for (const Item& item : items) + { + hasher.append(item.type.id); + } +} + +void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer) +{ + // std::map iterates in sorted-id order (ItemType::operator<). + hasher.append(buffer.counts.size()); + for (const std::pair& entry : buffer.counts) + { + hasher.append(entry.first.id); + hasher.append(entry.second); + } + hasher.append(buffer.caps.size()); + for (const std::pair& 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, BuildingId>& entry : m_tileOccupancy) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + hasher.append(entry.second); + } +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 9b4990c..b631c06 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -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 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; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index b2c6d2d..32c4186 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -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 diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 07d92de..351de9e 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -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& levels) +{ + hasher.append(levels.size()); + for (const std::pair& entry : levels) + { + hasher.append(entry.first); + hasher.append(entry.second.unlocked); + hasher.append(entry.second.level); + } +} + +void Simulation::appendStringSet(Hasher& hasher, const std::set& 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( + [&hasher](entt::entity entity, const PositionComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.value); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const HealthComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.hp); + hasher.append(c.maxHp); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const FacingComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.radians); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const DynamicBodyComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.velocity_tpt); + hasher.append(c.angularVelocity_rpt); + hasher.append(c.linearAcceleration_tptt); + hasher.append(c.angularAcceleration_rptt); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const ScrapDataComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.amount); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const ShipIdentityComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.level); + hasher.append(c.schematicId); + }); + + return hasher.value(); +} + // --------------------------------------------------------------------------- // Drains // --------------------------------------------------------------------------- diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 173e2ee..51955b5 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -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 m_schematicLevels; std::map m_moduleSchematicLevels; + // Determinism helpers — fold sub-state into the hasher in deterministic order. + static void appendSchematicMap(Hasher& hasher, + const std::map& levels); + static void appendStringSet(Hasher& hasher, const std::set& ids); + // Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT). std::set m_unlockedRecipeSchematicIds; diff --git a/src/lib/sim/StateChecksum.cpp b/src/lib/sim/StateChecksum.cpp new file mode 100644 index 0000000..69300ef --- /dev/null +++ b/src/lib/sim/StateChecksum.cpp @@ -0,0 +1,61 @@ +#include "StateChecksum.h" + +#include + +void Hasher::appendBytes(const void* data, std::size_t byteCount) +{ + const unsigned char* bytes = static_cast(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(); +} diff --git a/src/lib/sim/StateChecksum.h b/src/lib/sim/StateChecksum.h new file mode 100644 index 0000000..f150712 --- /dev/null +++ b/src/lib/sim/StateChecksum.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +// 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 + void append(const T& value) + { + static_assert(std::is_trivially_copyable::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); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 79c0bdd..1b2525f 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -21,4 +21,5 @@ add_files( ShipModuleTest.cpp ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp + DeterminismTest.cpp ) diff --git a/src/test/DeterminismTest.cpp b/src/test/DeterminismTest.cpp new file mode 100644 index 0000000..c7987fa --- /dev/null +++ b/src/test/DeterminismTest.cpp @@ -0,0 +1,157 @@ +#include "catch.hpp" + +#include +#include +#include + +#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 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 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 first = runScriptedSession(424242); + const std::vector second = runScriptedSession(424242); + + REQUIRE(first.size() == second.size()); + REQUIRE(first.size() == static_cast(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 a = runScriptedSession(111); + const std::vector 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); +} -- 2.49.1 From 82ca9080a54dcc39f3f11ed6f1ea40d756f7d507 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 19:44:50 +0200 Subject: [PATCH 3/7] replay: route player input through a command chokepoint (Phase 1) Reshapes every UI-driven sim mutation to flow through one path so it can be recorded and replayed later, with behaviour unchanged. - Command model (lib): Command base + derived types (PlaceBuilding, Demolish, RotateInPlace, SetRecipe, SetShipLayout, Set[Site]SplitterFilters, ClearBeltTiles, ApplySchematicChoice, Reset), each with a playerId for the future-multiplayer shape. PlaceBuilding is atomic (carries optional recipe/layout/filters) because deferred commands never return the new BuildingId to the caller. - CommandManager (lib): FIFO queue holding a Simulation&; enqueue + drain. - Simulation::apply(const Command&): the single chokepoint, dispatching by kind to the existing mutators. Mutators stay public (enforced by convention, not compile-time, so the test suite keeps driving the sim directly). - Timing: GameWorldView owns the CommandManager and drains it once per frame in onFrame, before the tick batch (runs at 0x too, so build-while-paused is preserved). A drained Reset triggers the view reset. - UI fan-in: GameWorldView enqueues its own input directly; MainWindow and SelectedBuildingPanel emit CommandRequestedEvent, which GameWorldView subscribes to and enqueues. No UI site mutates the sim directly anymore. - CommandTest: asserts apply(...) yields byte-identical state to the direct mutator path, and that CommandManager drains FIFO through apply. Full suite green (338 cases / 3353 assertions); determinism double-run still passes. Design doc updated with the atomic-PlaceBuilding and convention- enforcement decisions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 68 ++++++--- src/lib/eventsystem/event/CMakeLists.txt | 1 + .../eventsystem/event/CommandRequestedEvent.h | 23 +++ src/lib/sim/CMakeLists.txt | 3 + src/lib/sim/Command.h | 141 ++++++++++++++++++ src/lib/sim/CommandManager.cpp | 34 +++++ src/lib/sim/CommandManager.h | 36 +++++ src/lib/sim/Simulation.cpp | 87 +++++++++++ src/lib/sim/Simulation.h | 7 + src/test/CMakeLists.txt | 1 + src/test/CommandTest.cpp | 105 +++++++++++++ src/ui/GameWorldView.cpp | 128 +++++++++++----- src/ui/GameWorldView.h | 22 ++- src/ui/MainWindow.cpp | 45 ++++-- src/ui/SelectedBuildingPanel.cpp | 30 ++-- 15 files changed, 654 insertions(+), 77 deletions(-) create mode 100644 src/lib/eventsystem/event/CommandRequestedEvent.h create mode 100644 src/lib/sim/Command.h create mode 100644 src/lib/sim/CommandManager.cpp create mode 100644 src/lib/sim/CommandManager.h create mode 100644 src/test/CommandTest.cpp diff --git a/docs/replay_design.md b/docs/replay_design.md index c9432c6..07d8deb 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -70,6 +70,16 @@ Commands use a **base class + derived classes** (mirroring the existing `Event` idiom, so it is native to this codebase). They are routed through a dedicated command path, **not** through `EventManager` (see next section). +> **Implementation refinement (Phase 1).** `PlaceBuilding` is **atomic**: it carries the +> optional recipe / ship-layout / splitter-filters to configure the new building in the same +> command. This is forced by the deferred-drain timing — commands apply at a later tick +> boundary, so the caller never sees the new `BuildingId` and therefore cannot issue a +> follow-up `SetRecipe`/`SetShipLayout` against it. The standalone `SetRecipe`, +> `SetShipLayout`, and the two `SetSplitterFilters` commands remain for the dialog-driven +> edits on *existing* buildings (which reference a known id). `Reset` carries the (move-only) +> `GameConfig` via `shared_ptr` and is moved into the sim on apply; a null config means "keep +> current config". + ## Command system: reuse the *pattern*, not the EventManager singleton We reuse the **pattern** of the existing event system (a polymorphic base + small derived @@ -103,10 +113,20 @@ types), but the sim-mutating command path is a **dedicated, ordered queue**, not chokepoint. Any path that mutates the sim directly would not be recorded and would silently desync the replay. -This is enforced **structurally, not by discipline**: the `Simulation` mutators -(`tryPlaceBuilding`, `demolish`, `setRecipe`, …) are made private/non-public so the only way to -reach them is `apply(command)`. A stray direct call then fails to compile rather than compiling -and silently desyncing. +This was originally intended to be enforced **structurally** (make the `Simulation` mutators +non-public so the only way to reach them is `apply(command)`). + +> **Implementation decision (Phase 1).** The structural-enforcement plan was **dropped in +> favour of convention**, because the test suite legitimately drives the same mutators +> directly (`sim.tryPlaceBuilding(...)` and its returned id, `buildings().setRecipe(...)`, +> `applySchematicChoice`, `reset`, `placeImmediate`, …) and relies on their return values — +> making them non-public would break ~30 test call sites, and `apply()` cannot hand a new +> `BuildingId` back to a caller. So the mutators stay **public**; the rule "every UI mutation +> goes through a command" is upheld by convention and a documented chokepoint comment on +> `Simulation::apply`. A `[command]` Catch2 suite asserts `apply(...)` produces byte-identical +> state to the direct mutator path, guarding the equivalence the replay relies on. Tests are +> not gameplay (they never record), so direct mutator use there does not affect replay +> correctness. Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that actually reached the sim are recorded, and they replay through the identical apply path. @@ -337,28 +357,30 @@ The whole feature rests on a deterministic sim, so prove that before building on - **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before proceeding. -### Phase 1 — Command model + enforced chokepoint (no recording yet) +### Phase 1 — Command model + chokepoint (no recording yet) — DONE Reshape mutations to flow through one path; behaviour unchanged. -- Define `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, - `SetRecipe`, `SetShipLayout`, `SetSplitterFilters`, `ClearBeltTiles`, `ApplySchematicChoice`, - `Reset`) in `lib`. Each carries a source/player id (always 0 now) for the future-multiplayer - shape. -- Add `CommandManager` (FIFO queue, drain) in `lib`, holding a `Simulation&`. -- Add `Simulation::apply(const Command&)` dispatching to the existing mutators; then **make - those mutators non-public** so `apply` is the only entry — compile-enforces the completeness - invariant. -- Wire the drain: in `GameWorldView::onFrame`, call `CommandManager::drain()` **once per frame, - before** the tick batch; tag each command with the current completed-tick count. -- Refactor every UI mutation site to **emit a single `CommandRequestedEvent`** (carrying a - `shared_ptr`) via the existing `EventManager`; one dispatcher subscribes and enqueues - onto `CommandManager`. -- **Files:** new `lib` command + `CommandManager`; `Simulation.h/.cpp`; call sites in - `GameWorldView.cpp`, `MainWindow.cpp`, `SelectedBuildingPanel.cpp`; new dispatcher in - `ui`/`app`. -- **Exit criteria:** game plays identically (including build-while-paused), determinism test - still passes, no widget can call a sim mutator directly (won't compile). +- Defined `Command` base + derived types (`PlaceBuilding`, `Demolish`, `RotateInPlace`, + `SetRecipe`, `SetShipLayout`, `SetSiteSplitterFilters`, `SetSplitterFilters`, + `ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0 + now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above). +- Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`. +- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the existing + mutators — the single documented chokepoint. (Mutators stay public; enforced by convention, + see the decision note above.) +- Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame, + before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset` + triggers the view reset. +- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues + directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a + `shared_ptr`) which `GameWorldView` subscribes to and enqueues. +- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`; + `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, + `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. +- **Exit criteria:** game plays identically (including build-while-paused); determinism test + still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly + (verified by grep — convention, not compile-enforced). ### Phase 2 — Recording diff --git a/src/lib/eventsystem/event/CMakeLists.txt b/src/lib/eventsystem/event/CMakeLists.txt index 80fb640..6a5274d 100644 --- a/src/lib/eventsystem/event/CMakeLists.txt +++ b/src/lib/eventsystem/event/CMakeLists.txt @@ -27,6 +27,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h ${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h + ${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h PARENT_SCOPE ) diff --git a/src/lib/eventsystem/event/CommandRequestedEvent.h b/src/lib/eventsystem/event/CommandRequestedEvent.h new file mode 100644 index 0000000..388f5e5 --- /dev/null +++ b/src/lib/eventsystem/event/CommandRequestedEvent.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "Event.h" + +struct Command; + +// UI fan-in for the command path: widgets emit this with a built command, and a +// single subscriber (GameWorldView) enqueues it onto the CommandManager. This +// keeps widgets decoupled (consistent with the rest of the UI), while the +// sim-mutating command itself is routed through the dedicated CommandManager +// queue rather than the EventManager bus (see docs/replay_design.md). +class CommandRequestedEvent : public Event +{ +public: + explicit CommandRequestedEvent(std::shared_ptr command) + : command(std::move(command)) + { + } + + const std::shared_ptr command; +}; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 32c4186..997cf5f 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -1,6 +1,8 @@ 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}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h @@ -18,6 +20,7 @@ SET(HDRS SET(SRCS ${SRCS} ${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp diff --git a/src/lib/sim/Command.h b/src/lib/sim/Command.h new file mode 100644 index 0000000..20240e4 --- /dev/null +++ b/src/lib/sim/Command.h @@ -0,0 +1,141 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#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 recipeId; + std::optional shipLayout; + bool hasSplitterFilters = false; + std::vector splitterFilterA; + std::vector 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 filterA; + std::vector filterB; +}; + +// Splitter filters for an operational splitter, configured by tile via BeltSystem. +struct SetSplitterFiltersCommand : Command +{ + SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {} + QPoint tile; + std::vector filterA; + std::vector filterB; +}; + +struct ClearBeltTilesCommand : Command +{ + ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {} + std::vector 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 config; // null = keep current config + unsigned int seed = 0; +}; diff --git a/src/lib/sim/CommandManager.cpp b/src/lib/sim/CommandManager.cpp new file mode 100644 index 0000000..3e92a17 --- /dev/null +++ b/src/lib/sim/CommandManager.cpp @@ -0,0 +1,34 @@ +#include "CommandManager.h" + +#include "Command.h" +#include "Simulation.h" + +CommandManager::CommandManager(Simulation& simulation) + : m_simulation(simulation) +{ +} + +void CommandManager::enqueue(std::shared_ptr command) +{ + 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& command : m_queue) + { + m_simulation.apply(*command); + } + m_queue.clear(); +} + +bool CommandManager::hasPending() const +{ + return !m_queue.empty(); +} diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h new file mode 100644 index 0000000..4a128eb --- /dev/null +++ b/src/lib/sim/CommandManager.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +struct Command; +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); + + // Append a command for application at the next drain (FIFO order). + void enqueue(std::shared_ptr command); + + // Apply all queued commands in FIFO order through Simulation::apply, then + // clear the queue. + void drain(); + + bool hasPending() const; + +private: + Simulation& m_simulation; + std::vector> m_queue; +}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 351de9e..a3b3c2f 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -4,6 +4,7 @@ #include #include "AiSystem.h" +#include "Command.h" #include "DisplayName.h" #include "BuildingSystem.h" #include "CombatSystem.h" @@ -219,6 +220,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(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(command).id); + break; + case CommandKind::RotateInPlace: + { + const RotateInPlaceCommand& c = static_cast(command); + m_buildingSystem->rotateInPlace(c.id, c.newRotation); + break; + } + case CommandKind::SetRecipe: + { + const SetRecipeCommand& c = static_cast(command); + m_buildingSystem->setRecipe(c.id, c.recipeId); + break; + } + case CommandKind::SetShipLayout: + { + const SetShipLayoutCommand& c = static_cast(command); + m_buildingSystem->setShipLayout(c.id, c.layout); + break; + } + case CommandKind::SetSiteSplitterFilters: + { + const SetSiteSplitterFiltersCommand& c = + static_cast(command); + m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB); + break; + } + case CommandKind::SetSplitterFilters: + { + const SetSplitterFiltersCommand& c = + static_cast(command); + m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB); + break; + } + case CommandKind::ClearBeltTiles: + m_beltSystem.clearTiles(static_cast(command).tiles); + break; + case CommandKind::ApplySchematicChoice: + applySchematicChoice(static_cast(command).choiceIndex); + break; + case CommandKind::Reset: + { + const ResetCommand& c = static_cast(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(); diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 51955b5..a375156 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -24,6 +24,7 @@ class AiSystem; class BuildingSystem; +struct Command; class Hasher; class CombatSystem; class DynamicBodySystem; @@ -51,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 drainBeamFiredEvents(); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 1b2525f..5d1be1c 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -22,4 +22,5 @@ add_files( ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp DeterminismTest.cpp + CommandTest.cpp ) diff --git a/src/test/CommandTest.cpp b/src/test/CommandTest.cpp new file mode 100644 index 0000000..b767b83 --- /dev/null +++ b/src/test/CommandTest.cpp @@ -0,0 +1,105 @@ +#include "catch.hpp" + +#include + +#include "BuildingSystem.h" +#include "Command.h" +#include "CommandManager.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "Rotation.h" +#include "Simulation.h" + +namespace +{ +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} +} // namespace + +// The command chokepoint (Simulation::apply) must produce exactly the same state +// as driving the underlying mutators directly — that equivalence is what lets a +// recorded command stream reproduce a live run (see docs/replay_design.md). + +TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]") +{ + Simulation viaCommand(loadConfig(), 99); + Simulation viaDirect(loadConfig(), 99); + + PlaceBuildingCommand command; + command.type = BuildingType::Miner; + command.anchor = QPoint(-3, 0); + command.rotation = Rotation::East; + viaCommand.apply(command); + + viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + + REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); +} + +TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe", "[command]") +{ + Simulation viaCommand(loadConfig(), 99); + Simulation viaDirect(loadConfig(), 99); + + PlaceBuildingCommand command; + command.type = BuildingType::Miner; + command.anchor = QPoint(-2, 0); + command.rotation = Rotation::East; + command.recipeId = "mine_iron_ore"; + viaCommand.apply(command); + + const BuildingId id = + viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); + viaDirect.buildings().setRecipe(id, "mine_iron_ore"); + + REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); +} + +TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]") +{ + Simulation viaCommand(loadConfig(), 99); + Simulation viaDirect(loadConfig(), 99); + + const BuildingId idA = + viaCommand.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + const BuildingId idB = + viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + REQUIRE(idA == idB); + + DemolishCommand command; + command.id = idA; + viaCommand.apply(command); + + viaDirect.demolish(idB); + + REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); +} + +TEST_CASE("CommandManager drains queued commands in FIFO order through apply", "[command]") +{ + Simulation viaManager(loadConfig(), 99); + Simulation viaDirect(loadConfig(), 99); + + CommandManager manager(viaManager); + + std::shared_ptr first = std::make_shared(); + first->type = BuildingType::Miner; + first->anchor = QPoint(-3, 0); + std::shared_ptr second = std::make_shared(); + second->type = BuildingType::Belt; + second->anchor = QPoint(-2, 0); + + manager.enqueue(first); + manager.enqueue(second); + REQUIRE(manager.hasPending()); + + manager.drain(); + REQUIRE_FALSE(manager.hasPending()); + + viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + viaDirect.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East); + + REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum()); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index fcbe533..ee21b20 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -24,6 +24,7 @@ #include "BeltSystem.h" #include "Building.h" #include "BuildingSystem.h" +#include "Command.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" #include "EntitySelectedEvent.h" @@ -117,6 +118,7 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, , m_sim(sim) , m_config(config) , m_visuals(visuals) + , m_commandManager(*sim) , m_gameSpeedMultiplier(1.0) , m_prevNonZeroSpeed(1.0) , m_scrollXTiles(0.0f) @@ -159,6 +161,18 @@ void GameWorldView::onFrame() { const qint64 elapsed = m_frameTimer.restart(); + // Drain queued player commands once per frame, before the tick batch. This + // runs even at 0x so a paused player sees placed construction sites + // immediately, while staying deterministic (see docs/replay_design.md). + m_commandManager.drain(); + + // A drained Reset reinitialized the simulation; reset the view to match. + if (m_viewResetPending) + { + m_viewResetPending = false; + resetForNewGame(); + } + // Advance simulation { const int ticks = m_tickDriver.advance( @@ -515,12 +529,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation); if (rotateTarget.has_value()) { - m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation); + std::shared_ptr rotateCommand = + std::make_shared(); + rotateCommand->id = *rotateTarget; + rotateCommand->newRotation = bb.rotation; + enqueueCommand(rotateCommand); continue; } - const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation); - if (id == kInvalidBuildingId) { continue; } + // Place-and-configure is one atomic command: commands apply at a deferred + // tick boundary, so the caller never sees the new BuildingId. Unlock + // gating stays here (UI-side pre-filter); only fields that should apply + // are set on the command. + std::shared_ptr command = std::make_shared(); + command->type = bb.type; + command->anchor = anchor; + command->rotation = bb.rotation; if (!bb.recipeId.empty()) { @@ -528,7 +552,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) { if (m_sim->isSchematicUnlocked(bb.recipeId)) { - m_sim->buildings().setRecipe(id, bb.recipeId); + command->recipeId = bb.recipeId; } } else @@ -537,15 +561,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) || bb.type == BuildingType::Assembler; if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId)) { - m_sim->buildings().setRecipe(id, bb.recipeId); + command->recipeId = bb.recipeId; } } } - if (bb.shipLayout.has_value()) - { - m_sim->buildings().setShipLayout(id, *bb.shipLayout); - } + command->shipLayout = bb.shipLayout; if (bb.type == BuildingType::Splitter && (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty())) @@ -553,11 +574,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center) // The splitter is still a construction site, so the filters carry // over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked // item types are dropped per REQ-LOCK-UI-BLUEPRINT. - m_sim->buildings().setSiteSplitterFilters( - id, - filterUnlockedItems(bb.splitterFilterA, *m_sim), - filterUnlockedItems(bb.splitterFilterB, *m_sim)); + command->hasSplitterFilters = true; + command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim); + command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim); } + + enqueueCommand(command); } } @@ -578,49 +600,50 @@ void GameWorldView::placeAtTile(QPoint tile) m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation); if (rotateTarget.has_value()) { - m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation); + std::shared_ptr command = + std::make_shared(); + command->id = *rotateTarget; + command->newRotation = m_ghostRotation; + enqueueCommand(command); return; } + // For placements whose UI follow-up depends on success (belt-drag bookkeeping, + // tunnel entry/exit toggle), pre-validate occupancy + affordability so the + // optimistic UI update matches what the deferred command will do — isValidPlacement + // (above) already covered terrain/bounds. if (type == BuildingType::Belt) { if (m_beltDragTiles.count(tile) > 0) - { + { return; } - if (!m_sim->buildings().isTileOccupied(tile)) + if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type)) { - const BuildingId id = m_sim->tryPlaceBuilding( - type, tile, m_ghostRotation); - if (id != kInvalidBuildingId) - { - m_beltDragTiles.insert(tile); - } + enqueuePlaceBuilding(type, tile, m_ghostRotation); + m_beltDragTiles.insert(tile); } } else if (type == BuildingType::Splitter || type == BuildingType::TunnelEntry || type == BuildingType::TunnelExit) { - if (!m_sim->buildings().isTileOccupied(tile)) + if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type)) { - const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); - if (id != kInvalidBuildingId) + enqueuePlaceBuilding(type, tile, m_ghostRotation); + if (type == BuildingType::TunnelEntry) { - if (type == BuildingType::TunnelEntry) - { - m_builderType = BuildingType::TunnelExit; - } - else if (type == BuildingType::TunnelExit) - { - m_builderType = BuildingType::TunnelEntry; - } + m_builderType = BuildingType::TunnelExit; + } + else if (type == BuildingType::TunnelExit) + { + m_builderType = BuildingType::TunnelEntry; } } } else { - m_sim->tryPlaceBuilding(type, tile, m_ghostRotation); + enqueuePlaceBuilding(type, tile, m_ghostRotation); } } @@ -1369,7 +1392,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event) const bool isProtected = b && b->type == BuildingType::Hq; if (!isProtected) { - m_sim->demolish(hovered); + std::shared_ptr command = + std::make_shared(); + command->id = hovered; + enqueueCommand(command); m_demolishHoverBuildingId = kInvalidBuildingId; } } @@ -1743,3 +1769,35 @@ void GameWorldView::handleEvent(std::shared_ptr { setGameSpeed(event->multiplier); } + +void GameWorldView::handleEvent(std::shared_ptr event) +{ + // Other widgets (MainWindow, SelectedBuildingPanel) request commands via this + // event; GameWorldView owns the CommandManager and enqueues them. + if (event->command && event->command->kind == CommandKind::Reset) + { + m_viewResetPending = true; + } + enqueueCommand(event->command); +} + +void GameWorldView::enqueueCommand(std::shared_ptr command) +{ + m_commandManager.enqueue(std::move(command)); +} + +void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation) +{ + std::shared_ptr command = std::make_shared(); + command->type = type; + command->anchor = anchor; + command->rotation = rotation; + enqueueCommand(command); +} + +bool GameWorldView::canAfford(BuildingType type) const +{ + const BuildingDef* def = findBuildingDef(type); + if (!def) { return false; } + return m_sim->buildingBlocksStock() >= def->cost; +} diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index 082ef52..db117e9 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -26,10 +26,12 @@ #include "ExitBuilderModeRequestedEvent.h" #include "DebugDrawToggledEvent.h" #include "BeamFiredEvent.h" +#include "CommandRequestedEvent.h" #include "SchematicChoiceOption.h" #include "SpeedChangeRequestedEvent.h" #include "entt/entity/entity.hpp" +#include "CommandManager.h" #include "EntitySelectedEvent.h" #include "GameConfig.h" #include "Rotation.h" @@ -37,6 +39,7 @@ #include "TickDriver.h" #include "VisualsConfig.h" +struct Command; class Simulation; class QPainter; @@ -56,7 +59,8 @@ class GameWorldView : public QOpenGLWidget, DemolishModeToggleRequestedEvent, BlueprintPlacementRequestedEvent, ExitBlueprintModeRequestedEvent, - SpeedChangeRequestedEvent> + SpeedChangeRequestedEvent, + CommandRequestedEvent> { Q_OBJECT @@ -91,6 +95,17 @@ private: void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; void handleEvent(std::shared_ptr event) override; + void handleEvent(std::shared_ptr event) override; + + // Enqueue a sim command onto the CommandManager (the single mutation path). + void enqueueCommand(std::shared_ptr command); + + // Enqueue a plain (unconfigured) building placement. + void enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); + + // True if the player can currently afford to place one building of `type`. + // Used to pre-validate placements whose UI follow-up depends on success. + bool canAfford(BuildingType type) const; void drawTiles(QPainter& painter); void drawBuildings(QPainter& painter); @@ -157,6 +172,11 @@ private: const GameConfig* m_config; const VisualsConfig* m_visuals; + // Funnels all player input into the single Simulation::apply chokepoint. + CommandManager m_commandManager; + // A Reset command was enqueued; reset the view after the next drain applies it. + bool m_viewResetPending = false; + TickDriver m_tickDriver; QElapsedTimer m_frameTimer; std::mt19937 m_rng; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 3a25574..ff68345 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -15,7 +15,10 @@ #include "BuildButtonGrid.h" #include "BuildingBlocksChangedEvent.h" #include "BuildingSystem.h" +#include "Command.h" +#include "CommandRequestedEvent.h" #include "ConfigLoader.h" +#include "EventManager.h" #include "GameWorldView.h" #include "RecipeSelectionDialog.h" #include "SchematicChoiceDialog.h" @@ -138,7 +141,11 @@ void MainWindow::handleEvent(std::shared_ptrchoices, this); dialog.exec(); - m_sim->applySchematicChoice(dialog.getChosenIndex()); + std::shared_ptr command = + std::make_shared(); + command->choiceIndex = dialog.getChosenIndex(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); m_gameWorldView->setGameSpeed(prevSpeed); m_gameWorldView->resetFrameTimer(); @@ -160,12 +167,13 @@ void MainWindow::handleEvent(std::shared_ptr /*e QAbstractButton* clicked = box.clickedButton(); if (clicked == restartBtn) { + std::shared_ptr newConfig; try { - GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + newConfig = std::make_shared( + ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); - m_sim->reset(std::move(newConfig)); } catch (const std::exception& e) { @@ -175,7 +183,12 @@ void MainWindow::handleEvent(std::shared_ptr /*e m_gameWorldView->resetFrameTimer(); return; } - m_gameWorldView->resetForNewGame(); + // Restart is a command boundary; the view resets when the drain applies + // it (see GameWorldView::onFrame). Seed stays 0 in Phase 1. + std::shared_ptr command = std::make_shared(); + command->config = std::move(newConfig); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else if (clicked == quitBtn) { @@ -234,7 +247,12 @@ void MainWindow::handleEvent(std::shared_ptr e this); if (dialog.exec() == QDialog::Accepted && dialog.result().has_value()) { - m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result()); + std::shared_ptr command = + std::make_shared(); + command->id = event->shipyardId; + command->layout = *dialog.result(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } m_gameWorldView->setGameSpeed(prevSpeed); @@ -268,7 +286,11 @@ void MainWindow::handleEvent(std::shared_ptrbuildings().setRecipe(event->buildingId, *dialog.getChosenId()); + std::shared_ptr command = std::make_shared(); + command->id = event->buildingId; + command->recipeId = *dialog.getChosenId(); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } m_gameWorldView->setGameSpeed(prevSpeed); @@ -293,12 +315,13 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) if (box.clickedButton() == restartBtn) { + std::shared_ptr newConfig; try { - GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir); + newConfig = std::make_shared( + ConfigLoader::loadFromDirectory(m_configDir)); VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml"); m_visuals = std::move(newVisuals); - m_sim->reset(std::move(newConfig)); } catch (const std::exception& e) { @@ -306,7 +329,11 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) tr("Failed to reload config:\n%1").arg(e.what())); return; } - m_gameWorldView->resetForNewGame(); + // Restart is a command boundary; the view resets when the drain applies it. + std::shared_ptr command = std::make_shared(); + command->config = std::move(newConfig); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else { diff --git a/src/ui/SelectedBuildingPanel.cpp b/src/ui/SelectedBuildingPanel.cpp index 4edc9ab..aef9226 100644 --- a/src/ui/SelectedBuildingPanel.cpp +++ b/src/ui/SelectedBuildingPanel.cpp @@ -12,6 +12,8 @@ #include #include "BeltSystem.h" +#include "Command.h" +#include "CommandRequestedEvent.h" #include "DynamicBodyComponent.h" #include "EntityAdmin.h" #include "EntitySelectedEvent.h" @@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged() if (m_singleIsSite) { - m_sim->buildings().setSiteSplitterFilters( - m_singleBuildingId, - collectFilter(m_filterAList), - collectFilter(m_filterBList)); + std::shared_ptr command = + std::make_shared(); + command->id = m_singleBuildingId; + command->filterA = collectFilter(m_filterAList); + command->filterB = collectFilter(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } else { - m_sim->belts().setSplitterFilters( - m_splitterTile, - collectFilter(m_filterAList), - collectFilter(m_filterBList)); + std::shared_ptr command = + std::make_shared(); + command->tile = m_splitterTile; + command->filterA = collectFilter(m_filterAList); + command->filterB = collectFilter(m_filterBList); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } } @@ -767,7 +775,11 @@ void SelectedBuildingPanel::onClearBelt() } if (!tiles.empty()) { - m_sim->belts().clearTiles(tiles); + std::shared_ptr command = + std::make_shared(); + command->tiles = std::move(tiles); + EventManager::getInstance()->sendEventImmediately( + std::make_shared(command)); } } -- 2.49.1 From 97b6f0d8fd6760925a74d0b991e1ee1f88e75bd2 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 20:08:04 +0200 Subject: [PATCH 4/7] replay: record every run to a replay file (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks a recorder into the command chokepoint so each run is written to disk as it plays, ready for playback in Phase 3. - ReplayRecorder (lib): line-oriented append-friendly file — keyed header (version, build, seed, config_hash, timestamp), then '---', then tick-tagged command lines interleaved with "# checksum " RNG fingerprints. Each line is flushed so a crash leaves a valid partial file. Config hash is a 64-bit FNV over the config dir's *.toml files; build tag is __DATE__/__TIME__. - CommandSerializer (lib): per-command text (length-prefixed variable parts; ship layouts and splitter filters serialized inline). Reset is a file boundary, never a stream entry. - CommandManager owns an optional ReplayRecorder: drain() records each applied command + a post-apply checksum; a drained Reset rolls to a new file; recordTickCheckpoint() (called per tick from onFrame) writes a checksum every 30 ticks. - Random seed generated in main and on restart (std::random_device); Simulation retains it via getSeed() for the header. - GameWorldView attaches the recorder at construction (first file + tick-0 checksum); replays land in /replays named _.replay. ReplayRecorderTest covers serialization, file well-formedness, file rolling, and the CommandManager drain->record integration. Full suite green (346 cases / 3377 assertions); app, tests, and balancing all build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 35 +++--- src/app/main.cpp | 8 +- src/lib/sim/CMakeLists.txt | 4 + src/lib/sim/CommandManager.cpp | 51 +++++++- src/lib/sim/CommandManager.h | 16 ++- src/lib/sim/CommandSerializer.cpp | 134 +++++++++++++++++++++ src/lib/sim/CommandSerializer.h | 15 +++ src/lib/sim/ReplayRecorder.cpp | 130 +++++++++++++++++++++ src/lib/sim/ReplayRecorder.h | 53 +++++++++ src/lib/sim/Simulation.cpp | 7 ++ src/lib/sim/Simulation.h | 3 + src/test/CMakeLists.txt | 1 + src/test/ReplayRecorderTest.cpp | 186 ++++++++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 16 ++- src/ui/GameWorldView.h | 4 +- src/ui/MainWindow.cpp | 7 +- 16 files changed, 650 insertions(+), 20 deletions(-) create mode 100644 src/lib/sim/CommandSerializer.cpp create mode 100644 src/lib/sim/CommandSerializer.h create mode 100644 src/lib/sim/ReplayRecorder.cpp create mode 100644 src/lib/sim/ReplayRecorder.h create mode 100644 src/test/ReplayRecorderTest.cpp diff --git a/docs/replay_design.md b/docs/replay_design.md index 07d8deb..f90f66f 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -382,20 +382,29 @@ Reshape mutations to flow through one path; behaviour unchanged. still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly (verified by grep — convention, not compile-enforced). -### Phase 2 — Recording +### Phase 2 — Recording — DONE -- Implement the **line-oriented append writer**: header (seed, config hash, build/version, - timestamp) + one line per command + checksum lines. -- Generate a **random seed outside the sim** (in `main`/reset), write to header. -- Compute the **config hash** over the loaded config. -- Hook the **recorder at the apply chokepoint** in `CommandManager::drain()`: append each command - (tick-tagged), append an **RNG checksum after every command** and **every 30 ticks**. -- **Lifecycle:** open a new file on `Simulation` construction and on each `reset()` (restart = - boundary); store in `data/`, named by timestamp+seed; retain everything. -- **Files:** new replay writer in `lib`; `main.cpp` (seed), config hash helper; - `CommandManager`/`Simulation` for the tick checksum hook. -- **Exit criteria:** every run produces a well-formed, growing replay file; a crash mid-run still - leaves a valid partial file. +- `ReplayRecorder` (lib) writes the **line-oriented append file**: header (`version`, `build`, + `seed`, `config_hash`, `timestamp`) then `---`, then one tick-tagged line per command + interleaved with `# checksum ` lines. Each line is flushed, so a crash mid-run + leaves a valid partial file. `CommandSerializer` produces the per-command text (length-prefixed + variable parts; `ShipLayoutConfig`/filters serialized inline). The build tag is + `__DATE__ " " __TIME__`; the config hash is a 64-bit FNV over the `*.toml` files in the config + dir (re-hashed on playback to detect mismatch). +- **Random seed** generated in `main` (and on each restart in `MainWindow`) via + `std::random_device`; `Simulation` retains it (`getSeed()`) for the header. +- **Recorder hooked at the chokepoint:** `CommandManager` owns an optional `ReplayRecorder`; + `drain()` records each applied command (tick-tagged) + a post-apply RNG checksum, and + `recordTickCheckpoint()` (called per tick from the `onFrame` loop) writes a checksum every 30 + ticks. A drained `Reset` rolls the recorder to a new file (restart = boundary). +- **Lifecycle:** `GameWorldView` attaches the recorder at construction (opens the first file with + the initial seed + a tick-0 checksum); files live in `/replays`, named + `_.replay`; everything is retained. +- **Files:** new `lib/sim/ReplayRecorder.{h,cpp}`, `CommandSerializer.{h,cpp}`; `Simulation` + (`getSeed`); `CommandManager` (recorder + tick checkpoint); `main.cpp` (seed); + `MainWindow.cpp` / `GameWorldView.{h,cpp}` (wiring); new `ReplayRecorderTest.cpp`. +- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is + well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.) ### Phase 3 — Playback diff --git a/src/app/main.cpp b/src/app/main.cpp index 2657167..c692c35 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -32,7 +33,12 @@ int main(int argc, char *argv[]) } GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR); - std::unique_ptr sim = std::make_unique(std::move(config)); + + // Random seed generated outside the sim so the Simulation stays a pure + // function of (seed, config, commands); the seed is written to the replay + // header (see docs/replay_design.md "Seed and config"). + const unsigned int seed = std::random_device{}(); + std::unique_ptr sim = std::make_unique(std::move(config), seed); MainWindow window(sim.get(), std::string(CONFIG_DIR)); window.show(); diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 997cf5f..0ea2184 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -3,6 +3,8 @@ SET(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}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h @@ -21,6 +23,8 @@ 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}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp diff --git a/src/lib/sim/CommandManager.cpp b/src/lib/sim/CommandManager.cpp index 3e92a17..9163c96 100644 --- a/src/lib/sim/CommandManager.cpp +++ b/src/lib/sim/CommandManager.cpp @@ -1,13 +1,25 @@ #include "CommandManager.h" +#include + #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 command) { if (command) @@ -23,7 +35,27 @@ void CommandManager::drain() // fresh state. for (const std::shared_ptr& command : m_queue) { - m_simulation.apply(*command); + 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(); } @@ -32,3 +64,20 @@ bool CommandManager::hasPending() const { return !m_queue.empty(); } + +void CommandManager::setRecorder(std::unique_ptr recorder) +{ + m_recorder = std::move(recorder); + if (m_recorder) + { + m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint()); + } +} + +void CommandManager::recordTickCheckpoint() +{ + if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0)) + { + m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint()); + } +} diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h index 4a128eb..29e57ff 100644 --- a/src/lib/sim/CommandManager.h +++ b/src/lib/sim/CommandManager.h @@ -4,6 +4,7 @@ #include struct Command; +class ReplayRecorder; class Simulation; // Ordered queue that funnels every player command into the single @@ -20,17 +21,30 @@ class CommandManager { public: explicit CommandManager(Simulation& simulation); + // Defined out-of-line so the unique_ptr 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 command); // Apply all queued commands in FIFO order through Simulation::apply, then - // clear the queue. + // 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 recorder); + + // 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> m_queue; + std::unique_ptr m_recorder; }; diff --git a/src/lib/sim/CommandSerializer.cpp b/src/lib/sim/CommandSerializer.cpp new file mode 100644 index 0000000..ec41e49 --- /dev/null +++ b/src/lib/sim/CommandSerializer.cpp @@ -0,0 +1,134 @@ +#include "CommandSerializer.h" + +#include + +#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'; +} + +// " ( )*" +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); + } +} + +// " ()* ()*" +void appendFilters(std::ostringstream& out, + const std::vector& filterA, + const std::vector& 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(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(command).id; + break; + case CommandKind::RotateInPlace: + { + const RotateInPlaceCommand& c = static_cast(command); + out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation); + break; + } + case CommandKind::SetRecipe: + { + const SetRecipeCommand& c = static_cast(command); + out << "setrecipe " << c.id << ' ' << c.recipeId; + break; + } + case CommandKind::SetShipLayout: + { + const SetShipLayoutCommand& c = static_cast(command); + out << "setlayout " << c.id << ' '; + appendLayout(out, c.layout); + break; + } + case CommandKind::SetSiteSplitterFilters: + { + const SetSiteSplitterFiltersCommand& c = + static_cast(command); + out << "sitefilters " << c.id << ' '; + appendFilters(out, c.filterA, c.filterB); + break; + } + case CommandKind::SetSplitterFilters: + { + const SetSplitterFiltersCommand& c = + static_cast(command); + out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' '; + appendFilters(out, c.filterA, c.filterB); + break; + } + case CommandKind::ClearBeltTiles: + { + const ClearBeltTilesCommand& c = static_cast(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(command).choiceIndex; + break; + case CommandKind::Reset: + // A reset rolls the replay file; it is never written as a stream entry. + break; + } + + return out.str(); +} diff --git a/src/lib/sim/CommandSerializer.h b/src/lib/sim/CommandSerializer.h new file mode 100644 index 0000000..990eadd --- /dev/null +++ b/src/lib/sim/CommandSerializer.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +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); diff --git a/src/lib/sim/ReplayRecorder.cpp b/src/lib/sim/ReplayRecorder.cpp new file mode 100644 index 0000000..e0cba69 --- /dev/null +++ b/src/lib/sim/ReplayRecorder.cpp @@ -0,0 +1,130 @@ +#include "ReplayRecorder.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#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 ReplayRecorder::computeConfigHash() const +{ + Hasher hasher; + QDir dir(QString::fromStdString(m_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(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 " << computeConfigHash() << "\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; +} diff --git a/src/lib/sim/ReplayRecorder.h b/src/lib/sim/ReplayRecorder.h new file mode 100644 index 0000000..d29af0a --- /dev/null +++ b/src/lib/sim/ReplayRecorder.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include + +#include "Tick.h" + +struct Command; + +// 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 _), + // 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: + // 64-bit hash over the *.toml files in m_configDir, as a 16-char hex string. + std::string computeConfigHash() const; + + std::string m_configDir; + std::string m_outputDir; + std::string m_filePath; + std::ofstream m_stream; +}; diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index a3b3c2f..e735fef 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -33,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) @@ -134,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; @@ -1057,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; diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index a375156..f404655 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -74,6 +74,8 @@ public: 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; @@ -144,6 +146,7 @@ private: GameConfig m_config; std::mt19937 m_rng; + unsigned int m_seed; Tick m_currentTick; Tick m_nextDepartureTick; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 5d1be1c..1a29625 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -23,4 +23,5 @@ add_files( RecipeSchematicTest.cpp DeterminismTest.cpp CommandTest.cpp + ReplayRecorderTest.cpp ) diff --git a/src/test/ReplayRecorderTest.cpp b/src/test/ReplayRecorderTest.cpp new file mode 100644 index 0000000..967891d --- /dev/null +++ b/src/test/ReplayRecorderTest.cpp @@ -0,0 +1,186 @@ +#include "catch.hpp" + +#include +#include +#include + +#include + +#include +#include + +#include "Command.h" +#include "CommandManager.h" +#include "CommandSerializer.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "ReplayRecorder.h" +#include "Simulation.h" + +namespace +{ +std::string readFile(const std::string& path) +{ + std::ifstream stream(path, std::ios::in | std::ios::binary); + std::ostringstream buffer; + buffer << stream.rdbuf(); + return buffer.str(); +} + +std::string tempOutputDir() +{ + return (QDir::tempPath() + "/dota_factory_replay_test").toStdString(); +} +} // namespace + +// --------------------------------------------------------------------------- +// Command serialization +// --------------------------------------------------------------------------- + +TEST_CASE("serializeCommand: plain placement", "[replay]") +{ + PlaceBuildingCommand command; + command.type = BuildingType::Miner; + command.anchor = QPoint(-3, 5); + command.rotation = Rotation::East; + + REQUIRE(serializeCommand(command) == "place miner -3 5 E"); +} + +TEST_CASE("serializeCommand: placement with recipe", "[replay]") +{ + PlaceBuildingCommand command; + command.type = BuildingType::Miner; + command.anchor = QPoint(-2, 0); + command.rotation = Rotation::North; + command.recipeId = "mine_iron_ore"; + + REQUIRE(serializeCommand(command) == "place miner -2 0 N recipe mine_iron_ore"); +} + +TEST_CASE("serializeCommand: placement with ship layout", "[replay]") +{ + PlaceBuildingCommand command; + command.type = BuildingType::Shipyard; + command.anchor = QPoint(-3, 0); + command.rotation = Rotation::East; + ShipLayoutConfig layout; + layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 2), Rotation::South}); + command.shipLayout = layout; + + REQUIRE(serializeCommand(command) + == "place shipyard -3 0 E layout 1 weapon_basic 1 2 S"); +} + +TEST_CASE("serializeCommand: splitter filters are length-prefixed", "[replay]") +{ + SetSplitterFiltersCommand command; + command.tile = QPoint(4, 7); + command.filterA = { ItemType{"iron_ore"}, ItemType{"copper_ore"} }; + command.filterB = { ItemType{"coal"} }; + + REQUIRE(serializeCommand(command) + == "splitterfilters 4 7 2 iron_ore copper_ore 1 coal"); +} + +TEST_CASE("serializeCommand: demolish / rotate / schematic / clearbelt", "[replay]") +{ + DemolishCommand demolish; + demolish.id = 12; + REQUIRE(serializeCommand(demolish) == "demolish 12"); + + RotateInPlaceCommand rotate; + rotate.id = 7; + rotate.newRotation = Rotation::West; + REQUIRE(serializeCommand(rotate) == "rotate 7 W"); + + ApplySchematicChoiceCommand schematic; + schematic.choiceIndex = 2; + REQUIRE(serializeCommand(schematic) == "schematic 2"); + + ClearBeltTilesCommand clear; + clear.tiles = { QPoint(1, 2), QPoint(3, 4) }; + REQUIRE(serializeCommand(clear) == "clearbelt 2 1 2 3 4"); +} + +// --------------------------------------------------------------------------- +// ReplayRecorder file output +// --------------------------------------------------------------------------- + +TEST_CASE("ReplayRecorder writes a well-formed file", "[replay]") +{ + ReplayRecorder recorder(CONFIG_DIR, tempOutputDir()); + recorder.startNewRun(42u, 0x1122334455667788ull); + + PlaceBuildingCommand place; + place.type = BuildingType::Miner; + place.anchor = QPoint(-3, 0); + place.rotation = Rotation::East; + recorder.recordCommand(5, place, 0xabcdef0123456789ull); + + recorder.recordChecksum(30, 0x0ffffffffffffff0ull); + + const std::string path = recorder.currentFilePath(); + REQUIRE_FALSE(path.empty()); + recorder.close(); + + const std::string content = readFile(path); + + // Header. + REQUIRE(content.find("# dota_factory replay") != std::string::npos); + REQUIRE(content.find("version 1") != std::string::npos); + REQUIRE(content.find("seed 42") != std::string::npos); + REQUIRE(content.find("config_hash ") != std::string::npos); + REQUIRE(content.find("---") != std::string::npos); + + // Initial + per-command + periodic checksums. + REQUIRE(content.find("# checksum 0 1122334455667788") != std::string::npos); + REQUIRE(content.find("5 place miner -3 0 E") != std::string::npos); + REQUIRE(content.find("# checksum 5 abcdef0123456789") != std::string::npos); + REQUIRE(content.find("# checksum 30 0ffffffffffffff0") != std::string::npos); + + QFile::remove(QString::fromStdString(path)); +} + +TEST_CASE("CommandManager records commands and an initial checksum on drain", "[replay]") +{ + Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 7u); + CommandManager manager(sim); + + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + // setRecorder opens the file and writes the header + the tick-0 checksum. + manager.setRecorder(std::move(recorder)); + const std::string path = recorderPtr->currentFilePath(); + REQUIRE_FALSE(path.empty()); + + std::shared_ptr place = std::make_shared(); + place->type = BuildingType::Miner; + place->anchor = QPoint(-3, 0); + manager.enqueue(place); + manager.drain(); + + const std::string content = readFile(path); + REQUIRE(content.find("seed 7") != std::string::npos); + REQUIRE(content.find("# checksum 0 ") != std::string::npos); + // Drained at tick 0 (no ticks have run), so the command line is tagged tick 0. + REQUIRE(content.find("0 place miner -3 0 E") != std::string::npos); + + QFile::remove(QString::fromStdString(path)); +} + +TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]") +{ + ReplayRecorder recorder(CONFIG_DIR, tempOutputDir()); + + recorder.startNewRun(1u, 0ull); + const std::string first = recorder.currentFilePath(); + + recorder.startNewRun(2u, 0ull); + const std::string second = recorder.currentFilePath(); + + REQUIRE(first != second); + REQUIRE(second.find("_2.replay") != std::string::npos); + recorder.close(); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index ee21b20..326776e 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -6,10 +6,12 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -25,6 +27,7 @@ #include "Building.h" #include "BuildingSystem.h" #include "Command.h" +#include "ReplayRecorder.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" #include "EntitySelectedEvent.h" @@ -113,7 +116,8 @@ QPoint portBodyTile(QPoint portTile, Rotation direction) GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, - const VisualsConfig* visuals, QWidget* parent) + const VisualsConfig* visuals, const std::string& configDir, + QWidget* parent) : QOpenGLWidget(parent) , m_sim(sim) , m_config(config) @@ -145,6 +149,14 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, m_frameTimer.start(); registerForEvents(); + + // Record every run to disk. Replays live under /replays, alongside the + // config dir that was loaded. Attaching the recorder opens the first file and + // writes the header for the initial run. + QDir replayDir(QString::fromStdString(configDir)); + replayDir.cdUp(); + m_commandManager.setRecorder(std::make_unique( + configDir, replayDir.filePath("replays").toStdString())); } GameWorldView::~GameWorldView() @@ -180,6 +192,8 @@ void GameWorldView::onFrame() for (int i = 0; i < ticks; ++i) { m_sim->tick(); + // Periodic checksum (every 30 ticks) for replay desync detection. + m_commandManager.recordTickCheckpoint(); } } diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index db117e9..e3ebee7 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -66,7 +67,8 @@ class GameWorldView : public QOpenGLWidget, public: GameWorldView(Simulation* sim, const GameConfig* config, - const VisualsConfig* visuals, QWidget* parent = nullptr); + const VisualsConfig* visuals, const std::string& configDir, + QWidget* parent = nullptr); ~GameWorldView() override; double gameSpeed() const; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ff68345..79a0952 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -1,6 +1,7 @@ #include "MainWindow.h" #include +#include #include #include @@ -41,7 +42,7 @@ MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* p m_headerBar = new HeaderBar(this); - m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this); + m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this); m_sidePanel = new QWidget(this); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); @@ -184,9 +185,10 @@ void MainWindow::handleEvent(std::shared_ptr /*e return; } // Restart is a command boundary; the view resets when the drain applies - // it (see GameWorldView::onFrame). Seed stays 0 in Phase 1. + // it (see GameWorldView::onFrame). A fresh random seed starts a new run. std::shared_ptr command = std::make_shared(); command->config = std::move(newConfig); + command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } @@ -332,6 +334,7 @@ void MainWindow::handleEvent(std::shared_ptr /*event*/) // Restart is a command boundary; the view resets when the drain applies it. std::shared_ptr command = std::make_shared(); command->config = std::move(newConfig); + command->seed = std::random_device{}(); EventManager::getInstance()->sendEventImmediately( std::make_shared(command)); } -- 2.49.1 From 26e108f3e1b1eebafe52e074117f3932dfa0d62b Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 21:06:59 +0200 Subject: [PATCH 5/7] replay: play back recorded runs via --replay (Phase 3) Adds view-only playback: re-simulate from the recorded seed + commands and verify the RNG checksums. - ReplayReader (lib): parses a replay file into header + an ordered stream of command/checksum entries. CommandSerializer gains the inverse parseCommand (round-trips every verb; rejects malformed input). - ReplayPlayer (lib): the playback driver. Applies each command at its exact recorded tick and verifies checksums in file order (start() handles tick 0; advanceTo(tick) handles each tick after sim.tick()). Independent of replay-time speed/pause; reports the first desync tick. - CommandManager replay mode: enqueue() becomes a no-op so live input is ignored while the recorded stream drives application. - main.cpp: --replay reads + validates (warns on version/config-hash mismatch), seeds the sim from the header, and threads the replay through MainWindow to GameWorldView. - GameWorldView: drives the player in onFrame (manual speed/pause kept, forward-only), gates the schematic-choices and game-over polls, and draws a "REPLAY" tag plus a passive "Replay ended" / "Desync at tick N" overlay. - computeReplayConfigHash factored out of ReplayRecorder for reuse by main. ReplayPlaybackTest records a scripted run, reads it back, replays it, and asserts no desync + byte-identical final state -- including the periodic-checksum-then-command ordering at a shared tick. Full suite green (350 cases / 3396 assertions); app, tests, and balancing all build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 41 ++++--- src/app/main.cpp | 53 ++++++++- src/lib/sim/CMakeLists.txt | 4 + src/lib/sim/CommandManager.cpp | 9 ++ src/lib/sim/CommandManager.h | 5 + src/lib/sim/CommandSerializer.cpp | 180 ++++++++++++++++++++++++++++++ src/lib/sim/CommandSerializer.h | 6 + src/lib/sim/ReplayPlayer.cpp | 56 ++++++++++ src/lib/sim/ReplayPlayer.h | 48 ++++++++ src/lib/sim/ReplayReader.cpp | 145 ++++++++++++++++++++++++ src/lib/sim/ReplayReader.h | 41 +++++++ src/lib/sim/ReplayRecorder.cpp | 6 +- src/lib/sim/ReplayRecorder.h | 7 +- src/test/CMakeLists.txt | 1 + src/test/ReplayPlaybackTest.cpp | 151 +++++++++++++++++++++++++ src/ui/GameWorldView.cpp | 141 +++++++++++++++++------ src/ui/GameWorldView.h | 8 +- src/ui/MainWindow.cpp | 7 +- src/ui/MainWindow.h | 6 +- 19 files changed, 848 insertions(+), 67 deletions(-) create mode 100644 src/lib/sim/ReplayPlayer.cpp create mode 100644 src/lib/sim/ReplayPlayer.h create mode 100644 src/lib/sim/ReplayReader.cpp create mode 100644 src/lib/sim/ReplayReader.h create mode 100644 src/test/ReplayPlaybackTest.cpp diff --git a/docs/replay_design.md b/docs/replay_design.md index f90f66f..b7380bb 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -406,23 +406,32 @@ Reshape mutations to flow through one path; behaviour unchanged. - **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.) -### Phase 3 — Playback +### Phase 3 — Playback — DONE -- Implement the **reader/parser** for the format (header + commands + checksums). -- Add the `--replay ` CLI path in `main`: validate config hash + version (warn on - mismatch), construct `Simulation` from seed+config, construct `CommandManager` in **replay - mode** (pre-filled, `addCommand` is a no-op). -- Replay driver: each frame, drain commands due at the reached tick (same drain path), step - ticks, **keep manual speed/pause**, playback only moves forward. -- **Gate the two `onFrame` polls** (schematic-choices, game-over) off in replay mode; everything - else (recipe/layout dialogs, escape menu) is input-driven and falls away automatically. -- **Desync detection:** recompute the RNG checksum at each checkpoint, compare to the file, - report "desync at tick N" on mismatch. -- **Passive end:** when the command stream is exhausted / recorded game-over is reached, stop - with a "replay ended" overlay instead of the restart dialog. -- **Files:** new reader in `lib`; `main.cpp`; `CommandManager` (replay mode); `GameWorldView.cpp` - (poll gating, end overlay). -- **Exit criteria:** a recorded file plays back identically; checksums match throughout. +- `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command + (with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer` + gained the inverse `parseCommand` (round-tripping every verb). +- `--replay ` CLI path in `main`: reads the file, **warns** on version / config-hash + mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the + parsed replay through `MainWindow` to `GameWorldView`. +- `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies + each command at its **exact recorded tick** and verifies checksums **in file order**: + `start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)` + consumes that tick's entries (periodic checksum first, then command + its checksum — the order + the file already has). This makes playback independent of replay-time speed/pause. +- `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept, + forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two + sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are + input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** / + **"Desync at tick N"** overlay replaces the restart dialog. +- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer` + (`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager` + (replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new + `ReplayPlaybackTest.cpp`. +- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back, + replays it, and asserts **no desync** and a **byte-identical final state checksum** — including + the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but + not auto-tested here.) ### Phase 4 — Closing tests & polish diff --git a/src/app/main.cpp b/src/app/main.cpp index c692c35..e08a5e2 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -1,5 +1,7 @@ #include +#include #include +#include #include #include @@ -9,6 +11,8 @@ #include "logging.h" #include "LogManager.h" #include "MainWindow.h" +#include "ReplayReader.h" +#include "ReplayRecorder.h" #include "Simulation.h" int main(int argc, char *argv[]) @@ -32,15 +36,54 @@ int main(int argc, char *argv[]) QDir().mkdir(dataDir.dirName()); } + // Optional "--replay " launches view-only playback of a recorded run. + std::optional replayPath; + for (int i = 1; i + 1 < argc; ++i) + { + if (std::string(argv[i]) == "--replay") + { + replayPath = argv[i + 1]; + break; + } + } + GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR); - // Random seed generated outside the sim so the Simulation stays a pure - // function of (seed, config, commands); the seed is written to the replay - // header (see docs/replay_design.md "Seed and config"). - const unsigned int seed = std::random_device{}(); + unsigned int seed = 0; + std::shared_ptr replay; + if (replayPath.has_value()) + { + std::optional parsed = readReplayFile(*replayPath); + if (!parsed.has_value()) + { + LOG_ERROR("Failed to read replay file: " + *replayPath); + return 1; + } + // Warn (but proceed) on identity mismatches: a different config or build + // can desync playback (see docs/replay_design.md). + if (parsed->header.version != 1) + { + LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version + << " differs from 1; playback may fail"); + } + if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash) + { + LOG_WARNING("Replay config hash mismatch; playback may desync"); + } + seed = parsed->header.seed; + replay = std::make_shared(std::move(*parsed)); + } + else + { + // Random seed generated outside the sim so the Simulation stays a pure + // function of (seed, config, commands); written to the replay header + // (see docs/replay_design.md "Seed and config"). + seed = std::random_device{}(); + } + std::unique_ptr sim = std::make_unique(std::move(config), seed); - MainWindow window(sim.get(), std::string(CONFIG_DIR)); + MainWindow window(sim.get(), std::string(CONFIG_DIR), replay); window.show(); const int ret = application.exec(); diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index 0ea2184..24c819d 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -5,6 +5,8 @@ SET(HDRS ${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 @@ -25,6 +27,8 @@ SET(SRCS ${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 diff --git a/src/lib/sim/CommandManager.cpp b/src/lib/sim/CommandManager.cpp index 9163c96..74bd61b 100644 --- a/src/lib/sim/CommandManager.cpp +++ b/src/lib/sim/CommandManager.cpp @@ -22,6 +22,10 @@ CommandManager::~CommandManager() = default; void CommandManager::enqueue(std::shared_ptr command) { + if (m_replayMode) + { + return; // playback is driven by the recorded stream; ignore live input + } if (command) { m_queue.push_back(std::move(command)); @@ -74,6 +78,11 @@ void CommandManager::setRecorder(std::unique_ptr recorder) } } +void CommandManager::setReplayMode(bool replayMode) +{ + m_replayMode = replayMode; +} + void CommandManager::recordTickCheckpoint() { if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0)) diff --git a/src/lib/sim/CommandManager.h b/src/lib/sim/CommandManager.h index 29e57ff..ddbd686 100644 --- a/src/lib/sim/CommandManager.h +++ b/src/lib/sim/CommandManager.h @@ -39,6 +39,10 @@ public: // Passing nullptr detaches/stops recording. void setRecorder(std::unique_ptr 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(); @@ -47,4 +51,5 @@ private: Simulation& m_simulation; std::vector> m_queue; std::unique_ptr m_recorder; + bool m_replayMode = false; }; diff --git a/src/lib/sim/CommandSerializer.cpp b/src/lib/sim/CommandSerializer.cpp index ec41e49..1a0d80b 100644 --- a/src/lib/sim/CommandSerializer.cpp +++ b/src/lib/sim/CommandSerializer.cpp @@ -1,6 +1,7 @@ #include "CommandSerializer.h" #include +#include #include "BuildingType.h" #include "Command.h" @@ -21,6 +22,59 @@ char rotationToChar(Rotation rotation) 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 " ( )*" 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 " ()* ()*" from the stream. +void parseFilters(std::istringstream& in, + std::vector& filterA, + std::vector& 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}); + } +} + // " ( )*" void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout) { @@ -132,3 +186,129 @@ std::string serializeCommand(const Command& command) return out.str(); } + +std::shared_ptr 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 c = std::make_shared(); + std::string typeToken; + std::string rotToken; + int x = 0; + int y = 0; + if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; } + const std::optional 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 c = std::make_shared(); + if (!(in >> c->id)) { return nullptr; } + return c; + } + if (verb == "rotate") + { + std::shared_ptr c = std::make_shared(); + std::string rotToken; + if (!(in >> c->id >> rotToken)) { return nullptr; } + c->newRotation = rotationFromString(rotToken); + return c; + } + if (verb == "setrecipe") + { + std::shared_ptr c = std::make_shared(); + if (!(in >> c->id >> c->recipeId)) { return nullptr; } + return c; + } + if (verb == "setlayout") + { + std::shared_ptr c = std::make_shared(); + if (!(in >> c->id)) { return nullptr; } + c->layout = parseLayout(in, ok); + if (!ok) { return nullptr; } + return c; + } + if (verb == "sitefilters") + { + std::shared_ptr c = + std::make_shared(); + 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 c = + std::make_shared(); + 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 c = std::make_shared(); + 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 c = + std::make_shared(); + if (!(in >> c->choiceIndex)) { return nullptr; } + return c; + } + + return nullptr; +} diff --git a/src/lib/sim/CommandSerializer.h b/src/lib/sim/CommandSerializer.h index 990eadd..6f5df8e 100644 --- a/src/lib/sim/CommandSerializer.h +++ b/src/lib/sim/CommandSerializer.h @@ -1,5 +1,6 @@ #pragma once +#include #include struct Command; @@ -13,3 +14,8 @@ struct Command; // 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 parseCommand(const std::string& tokens); diff --git a/src/lib/sim/ReplayPlayer.cpp b/src/lib/sim/ReplayPlayer.cpp new file mode 100644 index 0000000..bc4aa50 --- /dev/null +++ b/src/lib/sim/ReplayPlayer.cpp @@ -0,0 +1,56 @@ +#include "ReplayPlayer.h" + +#include + +#include "Command.h" +#include "Simulation.h" + +ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector 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 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; + } + } +} diff --git a/src/lib/sim/ReplayPlayer.h b/src/lib/sim/ReplayPlayer.h new file mode 100644 index 0000000..558dab2 --- /dev/null +++ b/src/lib/sim/ReplayPlayer.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +#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 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 getDesyncTick() const; + +private: + void processEntriesAt(Tick tick); + + Simulation& m_simulation; + std::vector m_entries; + std::size_t m_cursor = 0; + std::optional m_desyncTick; +}; diff --git a/src/lib/sim/ReplayReader.cpp b/src/lib/sim/ReplayReader.cpp new file mode 100644 index 0000000..5ce1d03 --- /dev/null +++ b/src/lib/sim/ReplayReader.cpp @@ -0,0 +1,145 @@ +#include "ReplayReader.h" + +#include +#include +#include + +#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 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(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 " + 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; + } + + // " " + 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 = 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; +} diff --git a/src/lib/sim/ReplayReader.h b/src/lib/sim/ReplayReader.h new file mode 100644 index 0000000..17d9d15 --- /dev/null +++ b/src/lib/sim/ReplayReader.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +#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 command; // set iff isCommand + std::uint64_t fingerprint = 0; // set iff !isCommand +}; + +struct ParsedReplay +{ + ReplayHeader header; + std::vector 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 readReplayFile(const std::string& path); diff --git a/src/lib/sim/ReplayRecorder.cpp b/src/lib/sim/ReplayRecorder.cpp index e0cba69..ac03dd7 100644 --- a/src/lib/sim/ReplayRecorder.cpp +++ b/src/lib/sim/ReplayRecorder.cpp @@ -42,10 +42,10 @@ ReplayRecorder::~ReplayRecorder() close(); } -std::string ReplayRecorder::computeConfigHash() const +std::string computeReplayConfigHash(const std::string& configDir) { Hasher hasher; - QDir dir(QString::fromStdString(m_configDir)); + QDir dir(QString::fromStdString(configDir)); const QStringList files = dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name); for (const QString& name : files) @@ -80,7 +80,7 @@ void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFing m_stream << "version " << kReplayFormatVersion << "\n"; m_stream << "build " << kBuildTag << "\n"; m_stream << "seed " << seed << "\n"; - m_stream << "config_hash " << computeConfigHash() << "\n"; + m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n"; m_stream << "timestamp " << QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n"; m_stream << "---\n"; diff --git a/src/lib/sim/ReplayRecorder.h b/src/lib/sim/ReplayRecorder.h index d29af0a..0e1dff6 100644 --- a/src/lib/sim/ReplayRecorder.h +++ b/src/lib/sim/ReplayRecorder.h @@ -8,6 +8,10 @@ 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 @@ -43,9 +47,6 @@ public: const std::string& currentFilePath() const; private: - // 64-bit hash over the *.toml files in m_configDir, as a 16-char hex string. - std::string computeConfigHash() const; - std::string m_configDir; std::string m_outputDir; std::string m_filePath; diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 1a29625..cfa47c6 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -24,4 +24,5 @@ add_files( DeterminismTest.cpp CommandTest.cpp ReplayRecorderTest.cpp + ReplayPlaybackTest.cpp ) diff --git a/src/test/ReplayPlaybackTest.cpp b/src/test/ReplayPlaybackTest.cpp new file mode 100644 index 0000000..e34addd --- /dev/null +++ b/src/test/ReplayPlaybackTest.cpp @@ -0,0 +1,151 @@ +#include "catch.hpp" + +#include +#include + +#include +#include + +#include "Command.h" +#include "CommandManager.h" +#include "CommandSerializer.h" +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "ReplayPlayer.h" +#include "ReplayReader.h" +#include "ReplayRecorder.h" +#include "Rotation.h" +#include "Simulation.h" + +namespace +{ +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +std::string tempOutputDir() +{ + return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString(); +} + +std::shared_ptr place(BuildingType type, QPoint anchor) +{ + std::shared_ptr command = std::make_shared(); + command->type = type; + command->anchor = anchor; + return command; +} +} // namespace + +// --------------------------------------------------------------------------- +// Round-trip +// --------------------------------------------------------------------------- + +TEST_CASE("parseCommand inverts serializeCommand", "[replay]") +{ + PlaceBuildingCommand placeCommand; + placeCommand.type = BuildingType::Shipyard; + placeCommand.anchor = QPoint(-3, 2); + placeCommand.rotation = Rotation::West; + placeCommand.recipeId = "some_ship"; + ShipLayoutConfig layout; + layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 0), Rotation::North}); + placeCommand.shipLayout = layout; + + const std::string text = serializeCommand(placeCommand); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); +} + +TEST_CASE("parseCommand round-trips every command verb", "[replay]") +{ + SetSplitterFiltersCommand filters; + filters.tile = QPoint(3, 9); + filters.filterA = { ItemType{"iron_ore"} }; + filters.filterB = { ItemType{"coal"}, ItemType{"copper_ore"} }; + + ClearBeltTilesCommand clear; + clear.tiles = { QPoint(0, 0), QPoint(-1, 4) }; + + DemolishCommand demolish; + demolish.id = 5; + + for (const Command* command : { static_cast(&filters), + static_cast(&clear), + static_cast(&demolish) }) + { + const std::string text = serializeCommand(*command); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); + } +} + +TEST_CASE("parseCommand rejects malformed input", "[replay]") +{ + REQUIRE(parseCommand("") == nullptr); + REQUIRE(parseCommand("place not_a_type 0 0 E") == nullptr); + REQUIRE(parseCommand("place miner 0 0 E bogus") == nullptr); + REQUIRE(parseCommand("nonsense 1 2 3") == nullptr); +} + +// --------------------------------------------------------------------------- +// Record -> read -> replay equivalence +// --------------------------------------------------------------------------- + +TEST_CASE("a recorded run replays to byte-identical state with no desync", "[replay]") +{ + const unsigned int seed = 314159u; + + // --- Record a scripted run, mimicking the frame cadence (drain, then ticks). --- + std::string replayPath; + std::uint64_t recordedFinalChecksum = 0; + { + Simulation rec(loadConfig(), seed); + CommandManager manager(rec); + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + + // Frame at tick 0: place a miner. + manager.enqueue(place(BuildingType::Miner, QPoint(-3, 0))); + manager.drain(); + for (int i = 0; i < 90; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + // Frame at tick 90 (a checksum boundary): place a belt — exercises the + // periodic-checksum-then-command ordering at one tick. + manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0))); + manager.drain(); + for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + replayPath = recorderPtr->currentFilePath(); + recordedFinalChecksum = rec.computeStateChecksum(); + manager.setRecorder(nullptr); // close the file + } + + // --- Read it back. --- + const std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + REQUIRE(parsed->header.seed == seed); + REQUIRE(parsed->header.version == 1); + REQUIRE_FALSE(parsed->entries.empty()); + + // --- Replay it. --- + Simulation play(loadConfig(), parsed->header.seed); + ReplayPlayer player(play, parsed->entries); + player.start(); + while (!player.isFinished()) + { + play.tick(); + player.advanceTo(play.currentTick()); + } + + REQUIRE_FALSE(player.getDesyncTick().has_value()); + REQUIRE(play.currentTick() == 150); + REQUIRE(play.computeStateChecksum() == recordedFinalChecksum); + + QFile::remove(QString::fromStdString(replayPath)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 326776e..c6d5d9a 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -27,6 +27,8 @@ #include "Building.h" #include "BuildingSystem.h" #include "Command.h" +#include "ReplayPlayer.h" +#include "ReplayReader.h" #include "ReplayRecorder.h" #include "DemolishModeChangedEvent.h" #include "EntityHitTest.h" @@ -117,7 +119,7 @@ QPoint portBodyTile(QPoint portTile, Rotation direction) GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, const VisualsConfig* visuals, const std::string& configDir, - QWidget* parent) + const ParsedReplay* replay, QWidget* parent) : QOpenGLWidget(parent) , m_sim(sim) , m_config(config) @@ -150,13 +152,24 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, registerForEvents(); - // Record every run to disk. Replays live under /replays, alongside the - // config dir that was loaded. Attaching the recorder opens the first file and - // writes the header for the initial run. - QDir replayDir(QString::fromStdString(configDir)); - replayDir.cdUp(); - m_commandManager.setRecorder(std::make_unique( - configDir, replayDir.filePath("replays").toStdString())); + if (replay) + { + // View-only playback: ignore live input and drive ticks from the recorded + // stream. No recorder (we are not creating a new run). + m_commandManager.setReplayMode(true); + m_replayPlayer = std::make_unique(*sim, replay->entries); + m_replayPlayer->start(); // process tick-0 entries before the first tick + } + else + { + // Record every run to disk. Replays live under /replays, alongside + // the config dir that was loaded. Attaching the recorder opens the first + // file and writes the header for the initial run. + QDir replayDir(QString::fromStdString(configDir)); + replayDir.cdUp(); + m_commandManager.setRecorder(std::make_unique( + configDir, replayDir.filePath("replays").toStdString())); + } } GameWorldView::~GameWorldView() @@ -173,20 +186,33 @@ void GameWorldView::onFrame() { const qint64 elapsed = m_frameTimer.restart(); - // Drain queued player commands once per frame, before the tick batch. This - // runs even at 0x so a paused player sees placed construction sites - // immediately, while staying deterministic (see docs/replay_design.md). - m_commandManager.drain(); - - // A drained Reset reinitialized the simulation; reset the view to match. - if (m_viewResetPending) + if (m_replayPlayer) { - m_viewResetPending = false; - resetForNewGame(); + // Playback: apply recorded commands at their ticks and verify checksums. + // Manual speed/pause still works; playback only moves forward. + const int ticks = m_tickDriver.advance( + static_cast(elapsed), m_gameSpeedMultiplier); + for (int i = 0; i < ticks; ++i) + { + if (m_replayPlayer->isFinished()) { break; } + m_sim->tick(); + m_replayPlayer->advanceTo(m_sim->currentTick()); + } } - - // Advance simulation + else { + // Drain queued player commands once per frame, before the tick batch. This + // runs even at 0x so a paused player sees placed construction sites + // immediately, while staying deterministic (see docs/replay_design.md). + m_commandManager.drain(); + + // A drained Reset reinitialized the simulation; reset the view to match. + if (m_viewResetPending) + { + m_viewResetPending = false; + resetForNewGame(); + } + const int ticks = m_tickDriver.advance( static_cast(elapsed), m_gameSpeedMultiplier); for (int i = 0; i < ticks; ++i) @@ -267,25 +293,31 @@ void GameWorldView::onFrame() } } - // Schematic choice available - if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown) + // Sim-state polls are input sources for the live game; in replay these are + // gated off (the recorded ApplySchematicChoice resolves the choice with no UI, + // and game-over becomes the passive "replay ended" state below). + if (!m_replayPlayer) { - m_schematicChoiceShown = true; - EventManager::getInstance()->sendEventImmediately( - std::make_shared(m_sim->getPendingSchematicChoices())); - } - if (!m_sim->hasSchematicChoicesPending()) - { - m_schematicChoiceShown = false; - } + // Schematic choice available + if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown) + { + m_schematicChoiceShown = true; + EventManager::getInstance()->sendEventImmediately( + std::make_shared(m_sim->getPendingSchematicChoices())); + } + if (!m_sim->hasSchematicChoicesPending()) + { + m_schematicChoiceShown = false; + } - // Game over check - if (m_sim->isGameOver() && !m_gameOverShown) - { - m_gameOverShown = true; - m_gameSpeedMultiplier = 0.0; - EventManager::getInstance()->sendEventImmediately( - std::make_shared()); + // Game over check + if (m_sim->isGameOver() && !m_gameOverShown) + { + m_gameOverShown = true; + m_gameSpeedMultiplier = 0.0; + EventManager::getInstance()->sendEventImmediately( + std::make_shared()); + } } update(); @@ -311,6 +343,7 @@ void GameWorldView::paintGL() drawBeams(painter); drawOverlays(painter); drawScreenSpace(painter); + drawReplayOverlay(painter); } // --------------------------------------------------------------------------- @@ -1246,6 +1279,42 @@ void GameWorldView::drawScreenSpace(QPainter& /*painter*/) { } +void GameWorldView::drawReplayOverlay(QPainter& painter) +{ + if (!m_replayPlayer) { return; } + + painter.save(); + + QFont tag = painter.font(); + tag.setPixelSize(16); + tag.setBold(true); + painter.setFont(tag); + painter.setPen(QColor(255, 220, 80)); + painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY")); + + if (m_replayPlayer->isFinished()) + { + const std::optional desync = m_replayPlayer->getDesyncTick(); + QString message; + if (desync.has_value()) + { + message = tr("Desync at tick %1").arg(static_cast(*desync)); + painter.setPen(QColor(255, 90, 90)); + } + else + { + message = tr("Replay ended"); + painter.setPen(QColor(255, 255, 255)); + } + QFont big = painter.font(); + big.setPixelSize(28); + painter.setFont(big); + painter.drawText(rect(), Qt::AlignCenter, message); + } + + painter.restore(); +} + // --------------------------------------------------------------------------- // Input // --------------------------------------------------------------------------- diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index e3ebee7..2ecf099 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -41,6 +41,8 @@ #include "VisualsConfig.h" struct Command; +struct ParsedReplay; +class ReplayPlayer; class Simulation; class QPainter; @@ -68,7 +70,7 @@ class GameWorldView : public QOpenGLWidget, public: GameWorldView(Simulation* sim, const GameConfig* config, const VisualsConfig* visuals, const std::string& configDir, - QWidget* parent = nullptr); + const ParsedReplay* replay, QWidget* parent = nullptr); ~GameWorldView() override; double gameSpeed() const; @@ -121,6 +123,7 @@ private: void drawBeams(QPainter& painter); void drawOverlays(QPainter& painter); void drawScreenSpace(QPainter& painter); + void drawReplayOverlay(QPainter& painter); float tilePx() const; float viewportWidthTiles() const; @@ -178,6 +181,9 @@ private: CommandManager m_commandManager; // A Reset command was enqueued; reset the view after the next drain applies it. bool m_viewResetPending = false; + // Non-null => view-only playback: ticks are driven by the recorded stream and + // live input is ignored (the CommandManager is in replay mode). + std::unique_ptr m_replayPlayer; TickDriver m_tickDriver; QElapsedTimer m_frameTimer; diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index 79a0952..717443c 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -31,18 +31,21 @@ #include "Tick.h" #include "VisualsLoader.h" -MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent) +MainWindow::MainWindow(Simulation* sim, const std::string& configDir, + std::shared_ptr replay, QWidget* parent) : QWidget(parent) , m_configDir(configDir) , m_visuals(VisualsLoader::load(configDir + "/visuals.toml")) , m_sim(sim) + , m_replay(std::move(replay)) { setWindowTitle(tr("Dota Factory")); resize(1280, 768); m_headerBar = new HeaderBar(this); - m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this); + m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, + m_replay.get(), this); m_sidePanel = new QWidget(this); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h index 9c0996a..3c09cbe 100644 --- a/src/ui/MainWindow.h +++ b/src/ui/MainWindow.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -17,6 +18,7 @@ #include "Tick.h" #include "VisualsConfig.h" +struct ParsedReplay; class Simulation; class GameWorldView; class HeaderBar; @@ -37,7 +39,8 @@ class MainWindow : public QWidget, Q_OBJECT public: - MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr); + MainWindow(Simulation* sim, const std::string& configDir, + std::shared_ptr replay = nullptr, QWidget* parent = nullptr); ~MainWindow() override; protected: @@ -65,4 +68,5 @@ private: QWidget* m_sidePanel; std::vector m_layoutBlueprints; + std::shared_ptr m_replay; // non-null => view-only playback }; -- 2.49.1 From bd9f550b67b90e4ac9affe00c10d94bec21c1605 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 21:20:22 +0200 Subject: [PATCH 6/7] replay: closing tests and overlay polish (Phase 4) - Round-trip: every command verb serialize->parse->re-serialize is identical; malformed input is rejected. - Equivalence: add a long ~2400-tick run through waves/combat that records, reads back, and replays to a byte-identical final state with no desync (alongside the existing short scenario). - Desync detection: corrupting one recorded checksum makes ReplayPlayer report the exact desync tick. - Reset boundary: a Reset drained through CommandManager rolls the recorder to a new file named by the new seed. - Polish: the end-of-replay / desync overlay dims the world behind the message. Record + playback is now functionally complete and covered by headless tests. Full suite green (354 cases / 3418 assertions). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 26 ++++-- src/test/ReplayPlaybackTest.cpp | 141 ++++++++++++++++++++++++++++++++ src/test/ReplayRecorderTest.cpp | 26 ++++++ src/ui/GameWorldView.cpp | 3 + 4 files changed, 191 insertions(+), 5 deletions(-) diff --git a/docs/replay_design.md b/docs/replay_design.md index b7380bb..366a93d 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -433,12 +433,28 @@ Reshape mutations to flow through one path; behaviour unchanged. the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but not auto-tested here.) -### Phase 4 — Closing tests & polish +### Phase 4 — Closing tests & polish — DONE -- **Round-trip test:** serialize → parse → assert command equality. -- **Replay-equivalence test (headless):** record a scripted run, play it back through the same - `lib` path, assert per-tick checksums match end-to-end — the real proof, no UI needed. -- Mismatch-warning UX, end-of-replay overlay polish. +- **Round-trip:** every command verb serializes → parses → re-serializes identically; + malformed input is rejected (`parseCommand` returns nullptr). +- **Replay-equivalence (headless):** a short scripted run and a **long ~2400-tick run through + waves/combat** each record → read → replay with **no desync** and a **byte-identical final + state checksum**. +- **Desync detection:** corrupting one recorded checksum makes the player report the exact + desync tick. +- **Reset boundary:** a `Reset` drained through `CommandManager` rolls the recorder to a new + file (named by the new seed). +- **Polish:** the end-of-replay / desync overlay dims the world behind the message for + readability; config/version mismatch is warned to the log on launch (its visible consequence, + a desync, is already surfaced by the overlay). + +### Status + +Record + playback is functionally complete and covered by headless tests. Still deferred (per +this design): snapshots, save/load, backward-seek, cross-platform float hardening, expanding the +file checksum beyond RNG. Known minor rough edge: in replay mode the recipe/layout dialogs and +escape→restart can still open but do nothing (their commands hit the no-op enqueue); fully +disabling that input UI is polish, not correctness. ### Notes diff --git a/src/test/ReplayPlaybackTest.cpp b/src/test/ReplayPlaybackTest.cpp index e34addd..510e9b8 100644 --- a/src/test/ReplayPlaybackTest.cpp +++ b/src/test/ReplayPlaybackTest.cpp @@ -36,6 +36,14 @@ std::shared_ptr place(BuildingType type, QPoint anchor) command->anchor = anchor; return command; } + +void requireRoundTrip(const Command& command) +{ + const std::string text = serializeCommand(command); + const std::shared_ptr parsed = parseCommand(text); + REQUIRE(parsed != nullptr); + REQUIRE(serializeCommand(*parsed) == text); +} } // namespace // --------------------------------------------------------------------------- @@ -91,6 +99,43 @@ TEST_CASE("parseCommand rejects malformed input", "[replay]") REQUIRE(parseCommand("nonsense 1 2 3") == nullptr); } +TEST_CASE("every command verb round-trips through serialize/parse", "[replay]") +{ + SetRecipeCommand setRecipe; + setRecipe.id = 4; + setRecipe.recipeId = "smelt_iron"; + requireRoundTrip(setRecipe); + + SetShipLayoutCommand setLayout; + setLayout.id = 9; + setLayout.layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(0, 1), Rotation::East}); + setLayout.layout.placedModules.push_back(PlacedModule{"armor", QPoint(2, -1), Rotation::South}); + requireRoundTrip(setLayout); + + SetSiteSplitterFiltersCommand siteFilters; + siteFilters.id = 11; + siteFilters.filterA = { ItemType{"iron_ore"} }; + siteFilters.filterB = {}; + requireRoundTrip(siteFilters); + + RotateInPlaceCommand rotate; + rotate.id = 3; + rotate.newRotation = Rotation::South; + requireRoundTrip(rotate); + + ApplySchematicChoiceCommand schematic; + schematic.choiceIndex = 1; + requireRoundTrip(schematic); + + PlaceBuildingCommand placeWithFilters; + placeWithFilters.type = BuildingType::Splitter; + placeWithFilters.anchor = QPoint(2, 2); + placeWithFilters.hasSplitterFilters = true; + placeWithFilters.splitterFilterA = { ItemType{"iron_ore"}, ItemType{"coal"} }; + placeWithFilters.splitterFilterB = { ItemType{"copper_ore"} }; + requireRoundTrip(placeWithFilters); +} + // --------------------------------------------------------------------------- // Record -> read -> replay equivalence // --------------------------------------------------------------------------- @@ -149,3 +194,99 @@ TEST_CASE("a recorded run replays to byte-identical state with no desync", "[rep QFile::remove(QString::fromStdString(replayPath)); } + +TEST_CASE("ReplayPlayer reports a desync when a checksum is corrupted", "[replay]") +{ + const unsigned int seed = 5u; + + std::string replayPath; + { + Simulation rec(loadConfig(), seed); + CommandManager manager(rec); + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + replayPath = recorderPtr->currentFilePath(); + manager.setRecorder(nullptr); + } + + std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + + // Corrupt the periodic checksum recorded at tick 30. + bool corrupted = false; + for (ReplayEntry& entry : parsed->entries) + { + if (!entry.isCommand && entry.tick == 30) + { + entry.fingerprint ^= 0x1ull; + corrupted = true; + break; + } + } + REQUIRE(corrupted); + + Simulation play(loadConfig(), parsed->header.seed); + ReplayPlayer player(play, parsed->entries); + player.start(); + while (!player.isFinished()) + { + play.tick(); + player.advanceTo(play.currentTick()); + } + + REQUIRE(player.getDesyncTick().has_value()); + REQUIRE(*player.getDesyncTick() == 30); + + QFile::remove(QString::fromStdString(replayPath)); +} + +TEST_CASE("a long recorded run (through waves and combat) replays with no desync", "[replay]") +{ + const unsigned int seed = 271828u; + + std::string replayPath; + std::uint64_t recordedFinalChecksum = 0; + { + Simulation rec(loadConfig(), seed); + CommandManager manager(rec); + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + + std::shared_ptr miner = place(BuildingType::Miner, QPoint(-3, 0)); + miner->recipeId = "mine_iron_ore"; + manager.enqueue(miner); + manager.drain(); + for (int i = 0; i < 1500; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0))); + manager.drain(); + for (int i = 0; i < 900; ++i) { rec.tick(); manager.recordTickCheckpoint(); } + + replayPath = recorderPtr->currentFilePath(); + recordedFinalChecksum = rec.computeStateChecksum(); + manager.setRecorder(nullptr); + } + + const std::optional parsed = readReplayFile(replayPath); + REQUIRE(parsed.has_value()); + + Simulation play(loadConfig(), parsed->header.seed); + ReplayPlayer player(play, parsed->entries); + player.start(); + while (!player.isFinished()) + { + play.tick(); + player.advanceTo(play.currentTick()); + } + + REQUIRE_FALSE(player.getDesyncTick().has_value()); + REQUIRE(play.currentTick() == 2400); + REQUIRE(play.computeStateChecksum() == recordedFinalChecksum); + + QFile::remove(QString::fromStdString(replayPath)); +} diff --git a/src/test/ReplayRecorderTest.cpp b/src/test/ReplayRecorderTest.cpp index 967891d..821e53b 100644 --- a/src/test/ReplayRecorderTest.cpp +++ b/src/test/ReplayRecorderTest.cpp @@ -184,3 +184,29 @@ TEST_CASE("ReplayRecorder startNewRun rolls to a new file", "[replay]") REQUIRE(second.find("_2.replay") != std::string::npos); recorder.close(); } + +TEST_CASE("CommandManager rolls the replay file on a Reset command", "[replay]") +{ + Simulation sim(ConfigLoader::loadFromDirectory(CONFIG_DIR), 1u); + CommandManager manager(sim); + + std::unique_ptr recorder = + std::make_unique(CONFIG_DIR, tempOutputDir()); + ReplayRecorder* recorderPtr = recorder.get(); + manager.setRecorder(std::move(recorder)); + const std::string firstPath = recorderPtr->currentFilePath(); + + std::shared_ptr reset = std::make_shared(); + reset->config = std::make_shared(ConfigLoader::loadFromDirectory(CONFIG_DIR)); + reset->seed = 999u; + manager.enqueue(reset); + manager.drain(); + + const std::string secondPath = recorderPtr->currentFilePath(); + REQUIRE(firstPath != secondPath); + REQUIRE(secondPath.find("_999.replay") != std::string::npos); + + manager.setRecorder(nullptr); + QFile::remove(QString::fromStdString(firstPath)); + QFile::remove(QString::fromStdString(secondPath)); +} diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index c6d5d9a..264d9b0 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -1294,6 +1294,9 @@ void GameWorldView::drawReplayOverlay(QPainter& painter) if (m_replayPlayer->isFinished()) { + // Dim the world so the end message reads clearly over it. + painter.fillRect(rect(), QColor(0, 0, 0, 140)); + const std::optional desync = m_replayPlayer->getDesyncTick(); QString message; if (desync.has_value()) -- 2.49.1 From c2196cb34ef4fa36dc1a59f3e7c65b4543a1b008 Mon Sep 17 00:00:00 2001 From: Malte Langkabel Date: Tue, 30 Jun 2026 22:56:51 +0200 Subject: [PATCH 7/7] replay: compile-enforce the command chokepoint Privatize the Simulation player-action mutators (tryPlaceBuilding, demolish, applySchematicChoice) and the mutable subsystem accessors (now buildingsMutable/ beltsMutable; only the const buildings()/belts() stay public). Production's only handle to a subsystem is through Simulation, and every production buildings()/ belts() call is a const query, so this fully locks gameplay out of any command-bypassing mutation path -- such code now fails to compile instead of silently desyncing replays. Tests reach the private mutators through SimulationTestAccess, a friend struct under src/test (off the lib/ui/app include path), so they keep calling the real mutators and keep their return values -- no id-by-position recovery needed. The BuildingSystem subsystem mutators stay public (BuildingTest unit-tests a bare subsystem; unreachable from production anyway). Upgrades the Phase 1 "convention only" decision to structural enforcement. All targets build; 354 cases / 3418 assertions pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn --- docs/replay_design.md | 54 +++++++++++++++++++++----------- src/lib/sim/Simulation.cpp | 4 +-- src/lib/sim/Simulation.h | 41 +++++++++++++++--------- src/test/BlueprintTest.cpp | 33 +++++++++---------- src/test/CommandTest.cpp | 17 +++++----- src/test/DeterminismTest.cpp | 9 +++--- src/test/RecipeSchematicTest.cpp | 9 +++--- src/test/ShipModuleTest.cpp | 19 +++++------ src/test/ShipyardTest.cpp | 11 ++++--- src/test/SimulationTestAccess.h | 40 +++++++++++++++++++++++ src/test/WaveSystemTest.cpp | 3 +- 11 files changed, 159 insertions(+), 81 deletions(-) create mode 100644 src/test/SimulationTestAccess.h diff --git a/docs/replay_design.md b/docs/replay_design.md index 366a93d..beed998 100644 --- a/docs/replay_design.md +++ b/docs/replay_design.md @@ -113,20 +113,36 @@ types), but the sim-mutating command path is a **dedicated, ordered queue**, not chokepoint. Any path that mutates the sim directly would not be recorded and would silently desync the replay. -This was originally intended to be enforced **structurally** (make the `Simulation` mutators -non-public so the only way to reach them is `apply(command)`). +This is enforced **structurally**: the `Simulation` player-action mutators are **private**, so +the only way production code can reach them is `apply(command)`. -> **Implementation decision (Phase 1).** The structural-enforcement plan was **dropped in -> favour of convention**, because the test suite legitimately drives the same mutators -> directly (`sim.tryPlaceBuilding(...)` and its returned id, `buildings().setRecipe(...)`, -> `applySchematicChoice`, `reset`, `placeImmediate`, …) and relies on their return values — -> making them non-public would break ~30 test call sites, and `apply()` cannot hand a new -> `BuildingId` back to a caller. So the mutators stay **public**; the rule "every UI mutation -> goes through a command" is upheld by convention and a documented chokepoint comment on -> `Simulation::apply`. A `[command]` Catch2 suite asserts `apply(...)` produces byte-identical -> state to the direct mutator path, guarding the equivalence the replay relies on. Tests are -> not gameplay (they never record), so direct mutator use there does not affect replay -> correctness. +> **Implementation decision (Phase 1, revised post-Phase 4).** Structural enforcement was +> initially deferred in favour of convention, because the test suite legitimately drives the +> same mutators directly and relies on their return values (notably the `BuildingId` from +> placement, which `apply()` cannot hand back to a caller). It was later restored once a key +> observation made the change cheap: **the UI's only handle to a mutable subsystem is through +> `Simulation`** — no production code in `ui`/`app`/`balancing` holds a `BuildingSystem`/ +> `BeltSystem` directly, and every production `buildings()`/`belts()` call is a const query. +> So: +> +> - `Simulation::tryPlaceBuilding`, `demolish`, and `applySchematicChoice` are **private**. +> - The mutable subsystem accessors are private and renamed `buildingsMutable()` / +> `beltsMutable()`; only `const BuildingSystem& buildings() const` / `belts() const` are +> public (queries). UI query sites bind to the const overload unchanged. +> - `Simulation::apply` still mutates through the private members directly, so the chokepoint +> itself is unaffected. +> - Tests reach the private mutators through `SimulationTestAccess` (src/test, a `friend struct` +> of `Simulation`), so they keep calling the real mutators **and keep getting return values** +> — no id-by-position recovery needed. This header is not on the lib/ui/app include path, so +> only test translation units can use it. +> +> The `BuildingSystem` subsystem mutators (`place`, `setRecipe`, `placeImmediate`, +> `forEachBuilding`, …) stay **public**: `BuildingTest` unit-tests a bare `BuildingSystem` with +> no `Simulation`/command layer, and that surface is unreachable from production anyway (you +> cannot obtain a mutable subsystem without the private accessor). A `[command]` Catch2 suite +> still asserts `apply(...)` produces byte-identical state to the direct mutator path, guarding +> the equivalence the replay relies on. Tests are not gameplay (they never record), so direct +> mutator use there does not affect replay correctness. Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that actually reached the sim are recorded, and they replay through the identical apply path. @@ -366,9 +382,10 @@ Reshape mutations to flow through one path; behaviour unchanged. `ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0 now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above). - Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`. -- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the existing - mutators — the single documented chokepoint. (Mutators stay public; enforced by convention, - see the decision note above.) +- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the underlying + mutators — the single chokepoint. The `Simulation` player-action mutators are **private** + (compile-time enforced; tests reach them via the `SimulationTestAccess` friend) — see the + decision note above. - Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame, before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset` triggers the view reset. @@ -379,8 +396,9 @@ Reshape mutations to flow through one path; behaviour unchanged. `Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`, `SelectedBuildingPanel.cpp`; new `CommandTest.cpp`. - **Exit criteria:** game plays identically (including build-while-paused); determinism test - still passes; `[command]` equivalence tests pass; no UI call site mutates the sim directly - (verified by grep — convention, not compile-enforced). + still passes; `[command]` equivalence tests pass; no production call site can mutate the sim + directly (compile-enforced: the `Simulation` mutators are private, tests excepted via + `SimulationTestAccess`). ### Phase 2 — Recording — DONE diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index e735fef..6455591 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -1182,7 +1182,7 @@ void Simulation::demolish(BuildingId id) m_buildingBlocksStock += m_buildingSystem->demolish(id); } -BuildingSystem& Simulation::buildings() +BuildingSystem& Simulation::buildingsMutable() { return *m_buildingSystem; } @@ -1192,7 +1192,7 @@ const BuildingSystem& Simulation::buildings() const return *m_buildingSystem; } -BeltSystem& Simulation::belts() +BeltSystem& Simulation::beltsMutable() { return m_beltSystem; } diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index f404655..d87036e 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -68,11 +68,6 @@ 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; @@ -108,16 +103,11 @@ public: // 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); - - // Demolishes the building with the given id and refunds building blocks. - void demolish(BuildingId id); - - 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; @@ -127,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 event) override; BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId. diff --git a/src/test/BlueprintTest.cpp b/src/test/BlueprintTest.cpp index 2f87754..9fdb467 100644 --- a/src/test/BlueprintTest.cpp +++ b/src/test/BlueprintTest.cpp @@ -16,6 +16,7 @@ #include "Rotation.h" #include "ShipLayout.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "SurfaceMask.h" #include "Tick.h" @@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor", const QPoint offsetA(-1, 0); const QPoint offsetB( 1, 0); - const BuildingId idA = sim.tryPlaceBuilding( + const BuildingId idA = SimulationTestAccess::place(sim, BuildingType::Belt, cursor + offsetA, Rotation::East); - const BuildingId idB = sim.tryPlaceBuilding( + const BuildingId idB = SimulationTestAccess::place(sim, BuildingType::Belt, cursor + offsetB, Rotation::East); REQUIRE(idA != kInvalidBuildingId); @@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence", const int startBlocks = sim.buildingBlocksStock(); REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks - sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East); + SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East); REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost); - sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East); + SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East); REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost); } @@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a int col = -2; while (sim.buildingBlocksStock() >= minerCost) { - sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East); + SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East); col -= 2; } const int blocksBeforeAttempt = sim.buildingBlocksStock(); - const BuildingId id = sim.tryPlaceBuilding( + const BuildingId id = SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(col - 2, 0), Rotation::East); // Placement must fail and leave the stock unchanged. @@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch // A miner is all-asteroid; placing it in space (x >= 0) violates the terrain // rule, so it must be rejected without consuming building blocks. const BuildingId id = - sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East); + SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East); REQUIRE(id == kInvalidBuildingId); REQUIRE(sim.buildingBlocksStock() == startBlocks); @@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies // Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at // (-3,0),(-2,0),(-3,1); a valid spot. const BuildingId id = - sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East); REQUIRE(id != kInvalidBuildingId); REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost); @@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", " Simulation sim(loadConfig()); // Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid. - const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); + const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East); REQUIRE(id != kInvalidBuildingId); - sim.buildings().setRecipe(id, "mine_iron_ore"); + SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore"); const ConstructionSite* site = sim.buildings().findSite(id); REQUIRE(site != nullptr); @@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction { Simulation sim(loadConfig()); - const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); + const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East); REQUIRE(id != kInvalidBuildingId); - sim.buildings().setRecipe(id, "mine_copper_ore"); + SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore"); // Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300. // Run 301 ticks (0..300) to process the completion tick. @@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout // Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East: // A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles. // S-tile at (0,0) and (0,1) — x >= 0, valid space tiles. - const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); + const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); REQUIRE(id != kInvalidBuildingId); ShipLayoutConfig layout; @@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - sim.buildings().setShipLayout(id, layout); + SimulationTestAccess::buildings(sim).setShipLayout(id, layout); const ConstructionSite* site = sim.buildings().findSite(id); REQUIRE(site != nullptr); @@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc { Simulation sim(loadConfig()); - const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); + const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East); REQUIRE(id != kInvalidBuildingId); ShipLayoutConfig layout; @@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc pm.rotation = Rotation::North; layout.placedModules.push_back(pm); - sim.buildings().setShipLayout(id, layout); + SimulationTestAccess::buildings(sim).setShipLayout(id, layout); // Shipyard construction_time_seconds = 30 in the test config. double constructionTime = 0.0; diff --git a/src/test/CommandTest.cpp b/src/test/CommandTest.cpp index b767b83..8a43cd1 100644 --- a/src/test/CommandTest.cpp +++ b/src/test/CommandTest.cpp @@ -9,6 +9,7 @@ #include "GameConfig.h" #include "Rotation.h" #include "Simulation.h" +#include "SimulationTestAccess.h" namespace { @@ -33,7 +34,7 @@ TEST_CASE("apply(PlaceBuildingCommand) matches direct placement", "[command]") command.rotation = Rotation::East; viaCommand.apply(command); - viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); } @@ -51,8 +52,8 @@ TEST_CASE("apply(PlaceBuildingCommand) with recipe matches place-then-setRecipe" viaCommand.apply(command); const BuildingId id = - viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East); - viaDirect.buildings().setRecipe(id, "mine_iron_ore"); + SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East); + SimulationTestAccess::buildings(viaDirect).setRecipe(id, "mine_iron_ore"); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); } @@ -63,16 +64,16 @@ TEST_CASE("apply(DemolishCommand) matches direct demolish", "[command]") Simulation viaDirect(loadConfig(), 99); const BuildingId idA = - viaCommand.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East); const BuildingId idB = - viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East); REQUIRE(idA == idB); DemolishCommand command; command.id = idA; viaCommand.apply(command); - viaDirect.demolish(idB); + SimulationTestAccess::demolish(viaDirect, idB); REQUIRE(viaCommand.computeStateChecksum() == viaDirect.computeStateChecksum()); } @@ -98,8 +99,8 @@ TEST_CASE("CommandManager drains queued commands in FIFO order through apply", " manager.drain(); REQUIRE_FALSE(manager.hasPending()); - viaDirect.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); - viaDirect.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East); + SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(viaDirect, BuildingType::Belt, QPoint(-2, 0), Rotation::East); REQUIRE(viaManager.computeStateChecksum() == viaDirect.computeStateChecksum()); } diff --git a/src/test/DeterminismTest.cpp b/src/test/DeterminismTest.cpp index c7987fa..c766425 100644 --- a/src/test/DeterminismTest.cpp +++ b/src/test/DeterminismTest.cpp @@ -8,6 +8,7 @@ #include "GameConfig.h" #include "Rotation.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "StateChecksum.h" #include "Tick.h" @@ -28,9 +29,9 @@ std::vector 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); + SimulationTestAccess::place(sim, BuildingType::Miner, QPoint(-3, 0), Rotation::East); + SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-2, 0), Rotation::East); + SimulationTestAccess::place(sim, BuildingType::Belt, QPoint(-1, 0), Rotation::East); std::vector checksums; checksums.reserve(kScriptTicks); @@ -40,7 +41,7 @@ std::vector runScriptedSession(unsigned int seed) if (t == 500) { // Demolish the second belt mid-run to exercise the removal paths. - sim.tryPlaceBuilding(BuildingType::Smelter, QPoint(-3, 3), Rotation::East); + SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East); } sim.tick(); diff --git a/src/test/RecipeSchematicTest.cpp b/src/test/RecipeSchematicTest.cpp index c48b338..53bf740 100644 --- a/src/test/RecipeSchematicTest.cpp +++ b/src/test/RecipeSchematicTest.cpp @@ -10,6 +10,7 @@ #include "RecipesConfig.h" #include "SchematicChoiceOption.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "StationBodyComponent.h" static GameConfig loadConfig() @@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim) killEnemyStations(sim); if (sim.hasSchematicChoicesPending()) { - sim.applySchematicChoice(0); + SimulationTestAccess::applySchematicChoice(sim, 0); } } @@ -246,7 +247,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices", break; } } - sim.applySchematicChoice(0); + SimulationTestAccess::applySchematicChoice(sim, 0); } } CHECK(foundRecipeChoice); @@ -308,7 +309,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and } } - sim.applySchematicChoice(0); + SimulationTestAccess::applySchematicChoice(sim, 0); } } @@ -340,7 +341,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually const std::set unlockedBefore = unlockedTrackedRecipeIds(); const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0]; - sim.applySchematicChoice(0); + SimulationTestAccess::applySchematicChoice(sim, 0); std::set expectedNames; for (const RecipeDef& def : cfg.recipes.recipes) diff --git a/src/test/ShipModuleTest.cpp b/src/test/ShipModuleTest.cpp index 2e58efa..7c70246 100644 --- a/src/test/ShipModuleTest.cpp +++ b/src/test/ShipModuleTest.cpp @@ -17,6 +17,7 @@ #include "ShipStatsCalculator.h" #include "ShipSystem.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "Tick.h" #include "WeaponComponent.h" @@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) { - return sim.buildings().placeImmediate( + return SimulationTestAccess::buildings(sim).placeImmediate( BuildingType::Shipyard, yardDef.surfaceMask, QPoint(0, 0), @@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def, const ShipLayoutConfig& layout) { - sim.buildings().forEachBuilding([&](Building& b) { + SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) { if (b.id != yardId) { return; @@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials", REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); - sim.buildings().setRecipe(yardId, "interceptor"); + SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); ShipLayoutConfig layout; PlacedModule pm; @@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials", pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - sim.buildings().setShipLayout(yardId, layout); + SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b = sim.buildings().findBuilding(yardId); REQUIRE(b != nullptr); @@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production", REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); - sim.buildings().setRecipe(yardId, "interceptor"); + SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); // Fill materials and tick to start production. ShipLayoutConfig emptyLayout; @@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production", pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - sim.buildings().setShipLayout(yardId, layout); + SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b2 = sim.buildings().findBuilding(yardId); REQUIRE(b2 != nullptr); @@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); - sim.buildings().setRecipe(yardId, "interceptor"); + SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor"); ShipLayoutConfig layout; PlacedModule pm; @@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]") pm.position = QPoint(0, 0); pm.rotation = Rotation::East; layout.placedModules.push_back(pm); - sim.buildings().setShipLayout(yardId, layout); + SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout); const Building* b1 = sim.buildings().findBuilding(yardId); REQUIRE(b1 != nullptr); REQUIRE(b1->shipLayout.has_value()); - sim.buildings().setRecipe(yardId, "destroyer"); + SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer"); const Building* b2 = sim.buildings().findBuilding(yardId); REQUIRE(b2 != nullptr); diff --git a/src/test/ShipyardTest.cpp b/src/test/ShipyardTest.cpp index d778852..932f0f0 100644 --- a/src/test/ShipyardTest.cpp +++ b/src/test/ShipyardTest.cpp @@ -12,6 +12,7 @@ #include "ShipIdentityComponent.h" #include "ShipSystem.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "Tick.h" static GameConfig loadConfig() @@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg) static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef) { - return sim.buildings().placeImmediate( + return SimulationTestAccess::buildings(sim).placeImmediate( BuildingType::Shipyard, yardDef.surfaceMask, QPoint(0, 0), @@ -62,7 +63,7 @@ static int countShips(Simulation& sim) static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def) { - sim.buildings().forEachBuilding([&](Building& b) + SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) { if (b.id != yardId) { @@ -94,7 +95,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes", const BuildingId yardId = placeShipyard(sim, *yardDef); REQUIRE(yardId != kInvalidBuildingId); - sim.buildings().setRecipe(yardId, def->id); + SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id); fillMaterials(sim, yardId, *def); // First tick: materials consumed, production cycle starts — no ship yet. @@ -153,7 +154,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]") const int shipsBefore = countShips(sim); const BuildingId yardId = placeShipyard(sim, *yardDef); - sim.buildings().setRecipe(yardId, def->id); + SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id); // Materials remain at zero (default after setRecipe); no cycle starts. const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); @@ -175,7 +176,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya REQUIRE(yardDef != nullptr); const BuildingId yardId = placeShipyard(sim, *yardDef); - sim.buildings().setRecipe(yardId, def->id); + SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id); const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds); diff --git a/src/test/SimulationTestAccess.h b/src/test/SimulationTestAccess.h new file mode 100644 index 0000000..5a31f27 --- /dev/null +++ b/src/test/SimulationTestAccess.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include "BuildingId.h" +#include "BuildingType.h" +#include "Rotation.h" +#include "Simulation.h" + +class BeltSystem; +class BuildingSystem; + +// Test-only backdoor to Simulation's private player-action mutators and mutable +// subsystem accessors. Declared a friend of Simulation, so it can hand tests the +// same mutation surface that Simulation::apply uses internally — without exposing +// those mutators to production (UI/app) code, which must go through the command +// chokepoint (docs/replay_design.md). +// +// This header lives under src/test and is not on the lib/ui/app include path, so +// only test translation units can reach it. Non-player test setup that has no +// command equivalent (e.g. placeImmediate, forEachBuilding buffer injection) is +// reached via buildings(sim)/belts(sim). +struct SimulationTestAccess +{ + static BuildingSystem& buildings(Simulation& sim) { return sim.buildingsMutable(); } + static BeltSystem& belts(Simulation& sim) { return sim.beltsMutable(); } + + static BuildingId place(Simulation& sim, BuildingType type, QPoint anchor, + Rotation rotation) + { + return sim.tryPlaceBuilding(type, anchor, rotation); + } + + static void demolish(Simulation& sim, BuildingId id) { sim.demolish(id); } + + static void applySchematicChoice(Simulation& sim, int choiceIndex) + { + sim.applySchematicChoice(choiceIndex); + } +}; diff --git a/src/test/WaveSystemTest.cpp b/src/test/WaveSystemTest.cpp index afc1244..21e3e6e 100644 --- a/src/test/WaveSystemTest.cpp +++ b/src/test/WaveSystemTest.cpp @@ -21,6 +21,7 @@ #include "SchematicChoiceOption.h" #include "ShipsConfig.h" #include "Simulation.h" +#include "SimulationTestAccess.h" #include "Tick.h" #include "ThreatCostCalculator.h" #include "WaveSystem.h" @@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave] sim.tick(); REQUIRE(sim.hasSchematicChoicesPending()); - sim.applySchematicChoice(0); + SimulationTestAccess::applySchematicChoice(sim, 0); REQUIRE_FALSE(sim.hasSchematicChoicesPending()); } -- 2.49.1