Merge master (replay feature) into feature/win-condition-artifacts
Resolves conflicts from the merged replay/command-chokepoint work: - src/test/CMakeLists.txt: keep both new test sets (artifact + replay). - GameWorldView::onFrame: keep the replay gate (if (!m_replayPlayer)) and place the artifact-count / win / game-over polls inside it, de-duplicating the game-over check. - ArtifactWinConditionTest.cpp: route applySchematicChoice through SimulationTestAccess (the mutator is now private post-lockdown). - Simulation::computeStateChecksum: fold in m_isWon and m_artifactCount so the new artifact state is covered by determinism/replay desync detection. Simulation.h/.cpp auto-merged cleanly (applySchematicChoice landed private; artifact accessors public; reset() clears artifact state; the artifact roll draws from m_rng so it stays deterministic under replay). All targets build; 365 cases / 3469 assertions pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
This commit is contained in:
482
docs/replay_design.md
Normal file
482
docs/replay_design.md
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
> **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
|
||||||
|
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**: the `Simulation` player-action mutators are **private**, so
|
||||||
|
the only way production code can reach them is `apply(command)`.
|
||||||
|
|
||||||
|
> **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.
|
||||||
|
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 <file>`.
|
||||||
|
|
||||||
|
`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 + chokepoint (no recording yet) — DONE
|
||||||
|
|
||||||
|
Reshape mutations to flow through one path; behaviour unchanged.
|
||||||
|
|
||||||
|
- 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 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.
|
||||||
|
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
|
||||||
|
directly; `MainWindow` and `SelectedBuildingPanel` emit `CommandRequestedEvent` (carrying a
|
||||||
|
`shared_ptr<const Command>`) 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 production call site can mutate the sim
|
||||||
|
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
|
||||||
|
`SimulationTestAccess`).
|
||||||
|
|
||||||
|
### Phase 2 — Recording — DONE
|
||||||
|
|
||||||
|
- `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 <tick> <hex>` 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 `<data>/replays`, named
|
||||||
|
`<timestamp>_<seed>.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 — DONE
|
||||||
|
|
||||||
|
- `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 <file>` 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 — DONE
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
- 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.
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
@@ -8,6 +11,8 @@
|
|||||||
#include "logging.h"
|
#include "logging.h"
|
||||||
#include "LogManager.h"
|
#include "LogManager.h"
|
||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
#include "ReplayReader.h"
|
||||||
|
#include "ReplayRecorder.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
int main(int argc, char *argv[])
|
||||||
@@ -31,10 +36,54 @@ int main(int argc, char *argv[])
|
|||||||
QDir().mkdir(dataDir.dirName());
|
QDir().mkdir(dataDir.dirName());
|
||||||
}
|
}
|
||||||
|
|
||||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
// Optional "--replay <file>" launches view-only playback of a recorded run.
|
||||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
|
std::optional<std::string> replayPath;
|
||||||
|
for (int i = 1; i + 1 < argc; ++i)
|
||||||
|
{
|
||||||
|
if (std::string(argv[i]) == "--replay")
|
||||||
|
{
|
||||||
|
replayPath = argv[i + 1];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MainWindow window(sim.get(), std::string(CONFIG_DIR));
|
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||||
|
|
||||||
|
unsigned int seed = 0;
|
||||||
|
std::shared_ptr<ParsedReplay> replay;
|
||||||
|
if (replayPath.has_value())
|
||||||
|
{
|
||||||
|
std::optional<ParsedReplay> 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<ParsedReplay>(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<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
|
||||||
|
|
||||||
|
MainWindow window(sim.get(), std::string(CONFIG_DIR), replay);
|
||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
const int ret = application.exec();
|
const int ret = application.exec();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal file
23
src/lib/eventsystem/event/CommandRequestedEvent.h
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#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<const Command> command)
|
||||||
|
: command(std::move(command))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::shared_ptr<const Command> command;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
||||||
|
#include "StateChecksum.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
@@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun)
|
||||||
|
{
|
||||||
|
hasher.append(slotRun.size());
|
||||||
|
for (const BeltItemSlot& slot : slotRun)
|
||||||
|
{
|
||||||
|
hasher.append(slot.item.type.id);
|
||||||
|
hasher.append(slot.progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void BeltSystem::appendChecksum(Hasher& hasher) const
|
||||||
|
{
|
||||||
|
// std::map iterates in sorted key order, so all tile loops are deterministic.
|
||||||
|
hasher.append(m_belts.size());
|
||||||
|
for (const std::pair<const std::pair<int, int>, BeltTile>& entry : m_belts)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.first);
|
||||||
|
hasher.append(entry.first.second);
|
||||||
|
hasher.append(entry.second.direction);
|
||||||
|
appendItemSlots(hasher, entry.second.itemSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.append(m_splitters.size());
|
||||||
|
for (const std::pair<const std::pair<int, int>, SplitterTile>& entry : m_splitters)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.first);
|
||||||
|
hasher.append(entry.first.second);
|
||||||
|
const SplitterTile& s = entry.second;
|
||||||
|
hasher.append(s.outputA);
|
||||||
|
hasher.append(s.outputB);
|
||||||
|
hasher.append(s.filterA.size());
|
||||||
|
for (const ItemType& type : s.filterA) { hasher.append(type.id); }
|
||||||
|
hasher.append(s.filterB.size());
|
||||||
|
for (const ItemType& type : s.filterB) { hasher.append(type.id); }
|
||||||
|
hasher.append(s.nextOutputIsA);
|
||||||
|
appendItemSlots(hasher, s.back);
|
||||||
|
hasher.append(s.backDir.size());
|
||||||
|
for (Rotation dir : s.backDir) { hasher.append(dir); }
|
||||||
|
hasher.append(s.frontA.has_value());
|
||||||
|
if (s.frontA.has_value())
|
||||||
|
{
|
||||||
|
hasher.append(s.frontA->item.type.id);
|
||||||
|
hasher.append(s.frontA->progress);
|
||||||
|
}
|
||||||
|
hasher.append(s.frontB.has_value());
|
||||||
|
if (s.frontB.has_value())
|
||||||
|
{
|
||||||
|
hasher.append(s.frontB->item.type.id);
|
||||||
|
hasher.append(s.frontB->progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.append(m_tunnelEntries.size());
|
||||||
|
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.first);
|
||||||
|
hasher.append(entry.first.second);
|
||||||
|
hasher.append(entry.second.direction);
|
||||||
|
hasher.append(entry.second.maxDistance);
|
||||||
|
appendItemSlots(hasher, entry.second.itemSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.append(m_tunnelExits.size());
|
||||||
|
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.first);
|
||||||
|
hasher.append(entry.first.second);
|
||||||
|
hasher.append(entry.second.direction);
|
||||||
|
appendItemSlots(hasher, entry.second.itemSlots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// m_tunnelLinks preserves insertion order, which is itself deterministic.
|
||||||
|
hasher.append(m_tunnelLinks.size());
|
||||||
|
for (const TunnelLink& link : m_tunnelLinks)
|
||||||
|
{
|
||||||
|
hasher.append(link.entryTile);
|
||||||
|
hasher.append(link.exitTile);
|
||||||
|
hasher.append(link.length);
|
||||||
|
hasher.append(link.items.size());
|
||||||
|
for (const TunnelTransitItem& item : link.items)
|
||||||
|
{
|
||||||
|
hasher.append(item.item.type.id);
|
||||||
|
hasher.append(item.progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
#include "Port.h"
|
#include "Port.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
|
|
||||||
|
class Hasher;
|
||||||
|
|
||||||
// Carries item type and fractional world position for the renderer.
|
// Carries item type and fractional world position for the renderer.
|
||||||
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
|
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
|
||||||
struct VisualItem
|
struct VisualItem
|
||||||
@@ -92,6 +94,11 @@ public:
|
|||||||
void forEachVisualItem(QRect viewportTiles,
|
void forEachVisualItem(QRect viewportTiles,
|
||||||
std::function<void(VisualItem)> visit) const;
|
std::function<void(VisualItem)> visit) const;
|
||||||
|
|
||||||
|
// -- Determinism ---------------------------------------------------------
|
||||||
|
// Folds all transport state (belt/splitter/tunnel tiles and their items)
|
||||||
|
// into the hasher in deterministic order (see docs/replay_design.md).
|
||||||
|
void appendChecksum(Hasher& hasher) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void advanceProgress();
|
void advanceProgress();
|
||||||
void advanceTunnelProgress();
|
void advanceTunnelProgress();
|
||||||
@@ -170,6 +177,9 @@ private:
|
|||||||
std::vector<TunnelTransitItem> items; // front (highest progress) to back
|
std::vector<TunnelTransitItem> items; // front (highest progress) to back
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Folds a run of item slots (front-to-back order is canonical) into the hasher.
|
||||||
|
static void appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun);
|
||||||
|
|
||||||
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
|
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
|
||||||
|
|
||||||
std::map<std::pair<int, int>, BeltTile> m_belts;
|
std::map<std::pair<int, int>, BeltTile> m_belts;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <random>
|
#include <random>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
|
||||||
|
#include "StateChecksum.h"
|
||||||
#include "SurfaceMask.h"
|
#include "SurfaceMask.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
|
|
||||||
@@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
|||||||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
||||||
|
{
|
||||||
|
hasher.append(items.size());
|
||||||
|
for (const Item& item : items)
|
||||||
|
{
|
||||||
|
hasher.append(item.type.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||||||
|
{
|
||||||
|
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
||||||
|
hasher.append(buffer.counts.size());
|
||||||
|
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.id);
|
||||||
|
hasher.append(entry.second);
|
||||||
|
}
|
||||||
|
hasher.append(buffer.caps.size());
|
||||||
|
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.id);
|
||||||
|
hasher.append(entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void BuildingSystem::appendChecksum(Hasher& hasher) const
|
||||||
|
{
|
||||||
|
// m_buildings keeps a stable, deterministic order (append on build, swap-free
|
||||||
|
// erase aside — both runs perform identical operations, so order matches).
|
||||||
|
hasher.append(m_buildings.size());
|
||||||
|
for (const Building& b : m_buildings)
|
||||||
|
{
|
||||||
|
hasher.append(b.id);
|
||||||
|
hasher.append(b.anchor);
|
||||||
|
hasher.append(b.footprint.width());
|
||||||
|
hasher.append(b.footprint.height());
|
||||||
|
hasher.append(b.rotation);
|
||||||
|
hasher.append(b.type);
|
||||||
|
hasher.append(b.recipeId);
|
||||||
|
appendInputBuffer(hasher, b.inputBuffer);
|
||||||
|
appendItems(hasher, b.outputBuffer.items);
|
||||||
|
hasher.append(b.outputBuffer.capacity);
|
||||||
|
hasher.append(b.production.has_value());
|
||||||
|
if (b.production.has_value())
|
||||||
|
{
|
||||||
|
hasher.append(b.production->recipeId);
|
||||||
|
hasher.append(b.production->completesAt);
|
||||||
|
appendItems(hasher, b.production->chosenOutputs);
|
||||||
|
}
|
||||||
|
hasher.append(b.shipLayout.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.append(m_constructionQueue.size());
|
||||||
|
for (const ConstructionSite& s : m_constructionQueue)
|
||||||
|
{
|
||||||
|
hasher.append(s.id);
|
||||||
|
hasher.append(s.anchor);
|
||||||
|
hasher.append(s.footprint.width());
|
||||||
|
hasher.append(s.footprint.height());
|
||||||
|
hasher.append(s.rotation);
|
||||||
|
hasher.append(s.type);
|
||||||
|
hasher.append(s.recipeId);
|
||||||
|
hasher.append(s.completesAt);
|
||||||
|
hasher.append(s.shipLayout.has_value());
|
||||||
|
hasher.append(s.splitterFilterA.size());
|
||||||
|
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
|
||||||
|
hasher.append(s.splitterFilterB.size());
|
||||||
|
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// std::map iterates in sorted key order.
|
||||||
|
hasher.append(m_tileOccupancy.size());
|
||||||
|
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first.first);
|
||||||
|
hasher.append(entry.first.second);
|
||||||
|
hasher.append(entry.second);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
#include "ShipsConfig.h"
|
#include "ShipsConfig.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
|
|
||||||
|
class Hasher;
|
||||||
|
|
||||||
// Manages building placement, construction queuing, and the per-tick
|
// Manages building placement, construction queuing, and the per-tick
|
||||||
// production loop (belt→building pull, production, building→belt push).
|
// production loop (belt→building pull, production, building→belt push).
|
||||||
// All types including Belt and Splitter are stored as Building instances;
|
// All types including Belt and Splitter are stored as Building instances;
|
||||||
@@ -151,6 +153,11 @@ public:
|
|||||||
// Mutable iteration over all operational buildings.
|
// Mutable iteration over all operational buildings.
|
||||||
void forEachBuilding(std::function<void(Building&)> fn);
|
void forEachBuilding(std::function<void(Building&)> fn);
|
||||||
|
|
||||||
|
// -- Determinism ---------------------------------------------------------
|
||||||
|
// Folds all building, construction-site, and tile-occupancy state into the
|
||||||
|
// hasher in deterministic order (see docs/replay_design.md).
|
||||||
|
void appendChecksum(Hasher& hasher) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
SET(HDRS
|
SET(HDRS
|
||||||
${HDRS}
|
${HDRS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||||
@@ -9,6 +15,7 @@ SET(HDRS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
@@ -17,11 +24,17 @@ SET(HDRS
|
|||||||
SET(SRCS
|
SET(SRCS
|
||||||
${SRCS}
|
${SRCS}
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||||
PARENT_SCOPE
|
PARENT_SCOPE
|
||||||
|
|||||||
141
src/lib/sim/Command.h
Normal file
141
src/lib/sim/Command.h
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#include "BuildingId.h"
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "ItemType.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
|
class GameConfig;
|
||||||
|
|
||||||
|
// Player intent, resolved to domain ids / tile coordinates and serializable, that
|
||||||
|
// mutates the simulation. Every sim mutation during play flows through a Command
|
||||||
|
// applied at the single Simulation::apply chokepoint, so it can be recorded and
|
||||||
|
// replayed (see docs/replay_design.md).
|
||||||
|
//
|
||||||
|
// Commands form a closed set dispatched by kind. They reference stable,
|
||||||
|
// deterministic ids (BuildingId, tile coordinates, choice indices) — never raw
|
||||||
|
// entt handles — so a recorded command resolves to the same target on replay.
|
||||||
|
|
||||||
|
enum class CommandKind
|
||||||
|
{
|
||||||
|
PlaceBuilding,
|
||||||
|
Demolish,
|
||||||
|
RotateInPlace,
|
||||||
|
SetRecipe,
|
||||||
|
SetShipLayout,
|
||||||
|
SetSiteSplitterFilters,
|
||||||
|
SetSplitterFilters,
|
||||||
|
ClearBeltTiles,
|
||||||
|
ApplySchematicChoice,
|
||||||
|
Reset
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Command
|
||||||
|
{
|
||||||
|
explicit Command(CommandKind kind) : kind(kind) {}
|
||||||
|
virtual ~Command() = default;
|
||||||
|
|
||||||
|
CommandKind kind;
|
||||||
|
// Source of the command. Always 0 in single-player; lockstep multiplayer
|
||||||
|
// later merges commands from multiple sources into one ordered stream.
|
||||||
|
int playerId = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Places a building and, atomically, configures it. The configuration fields are
|
||||||
|
// bundled here (rather than as follow-up commands) because commands are applied
|
||||||
|
// at a deferred tick boundary, so the caller never sees the new BuildingId — the
|
||||||
|
// place-and-configure must happen as one unit inside apply().
|
||||||
|
struct PlaceBuildingCommand : Command
|
||||||
|
{
|
||||||
|
PlaceBuildingCommand() : Command(CommandKind::PlaceBuilding) {}
|
||||||
|
|
||||||
|
BuildingType type = BuildingType::Miner;
|
||||||
|
QPoint anchor;
|
||||||
|
Rotation rotation = Rotation::East;
|
||||||
|
|
||||||
|
// Optional configuration applied to the freshly placed (still-construction)
|
||||||
|
// building. The caller (UI) is responsible for unlock/validity pre-filtering;
|
||||||
|
// only fields that should apply are set.
|
||||||
|
std::optional<std::string> recipeId;
|
||||||
|
std::optional<ShipLayoutConfig> shipLayout;
|
||||||
|
bool hasSplitterFilters = false;
|
||||||
|
std::vector<ItemType> splitterFilterA;
|
||||||
|
std::vector<ItemType> splitterFilterB;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct DemolishCommand : Command
|
||||||
|
{
|
||||||
|
DemolishCommand() : Command(CommandKind::Demolish) {}
|
||||||
|
BuildingId id = kInvalidBuildingId;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct RotateInPlaceCommand : Command
|
||||||
|
{
|
||||||
|
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
|
||||||
|
BuildingId id = kInvalidBuildingId;
|
||||||
|
Rotation newRotation = Rotation::East;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SetRecipeCommand : Command
|
||||||
|
{
|
||||||
|
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
|
||||||
|
BuildingId id = kInvalidBuildingId;
|
||||||
|
std::string recipeId;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct SetShipLayoutCommand : Command
|
||||||
|
{
|
||||||
|
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
|
||||||
|
BuildingId id = kInvalidBuildingId;
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Splitter filters for a queued / under-construction Splitter site (configured by
|
||||||
|
// BuildingSystem before the splitter is registered with BeltSystem).
|
||||||
|
struct SetSiteSplitterFiltersCommand : Command
|
||||||
|
{
|
||||||
|
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
|
||||||
|
BuildingId id = kInvalidBuildingId;
|
||||||
|
std::vector<ItemType> filterA;
|
||||||
|
std::vector<ItemType> filterB;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Splitter filters for an operational splitter, configured by tile via BeltSystem.
|
||||||
|
struct SetSplitterFiltersCommand : Command
|
||||||
|
{
|
||||||
|
SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {}
|
||||||
|
QPoint tile;
|
||||||
|
std::vector<ItemType> filterA;
|
||||||
|
std::vector<ItemType> filterB;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ClearBeltTilesCommand : Command
|
||||||
|
{
|
||||||
|
ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {}
|
||||||
|
std::vector<QPoint> tiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ApplySchematicChoiceCommand : Command
|
||||||
|
{
|
||||||
|
ApplySchematicChoiceCommand() : Command(CommandKind::ApplySchematicChoice) {}
|
||||||
|
int choiceIndex = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Restart boundary: reinitializes the simulation with a fresh seed and, if
|
||||||
|
// config is set, a reloaded config (GameConfig is move-only, so it is carried by
|
||||||
|
// shared_ptr and moved into the sim on apply). A null config keeps the current
|
||||||
|
// config. One replay file = one run between Reset boundaries.
|
||||||
|
struct ResetCommand : Command
|
||||||
|
{
|
||||||
|
ResetCommand() : Command(CommandKind::Reset) {}
|
||||||
|
std::shared_ptr<GameConfig> config; // null = keep current config
|
||||||
|
unsigned int seed = 0;
|
||||||
|
};
|
||||||
92
src/lib/sim/CommandManager.cpp
Normal file
92
src/lib/sim/CommandManager.cpp
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#include "CommandManager.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "ReplayRecorder.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
// Periodic RNG checksum cadence (see docs/replay_design.md "Cadence").
|
||||||
|
constexpr Tick kChecksumIntervalTicks = 30;
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CommandManager::CommandManager(Simulation& simulation)
|
||||||
|
: m_simulation(simulation)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
CommandManager::~CommandManager() = default;
|
||||||
|
|
||||||
|
void CommandManager::enqueue(std::shared_ptr<const Command> command)
|
||||||
|
{
|
||||||
|
if (m_replayMode)
|
||||||
|
{
|
||||||
|
return; // playback is driven by the recorded stream; ignore live input
|
||||||
|
}
|
||||||
|
if (command)
|
||||||
|
{
|
||||||
|
m_queue.push_back(std::move(command));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandManager::drain()
|
||||||
|
{
|
||||||
|
// Apply in FIFO order. A queued ResetCommand reinitializes the simulation in
|
||||||
|
// place; the reference stays valid and any commands after it apply to the
|
||||||
|
// fresh state.
|
||||||
|
for (const std::shared_ptr<const Command>& command : m_queue)
|
||||||
|
{
|
||||||
|
if (command->kind == CommandKind::Reset)
|
||||||
|
{
|
||||||
|
m_simulation.apply(*command);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
// Restart is a file boundary: a fresh file with the new seed.
|
||||||
|
m_recorder->startNewRun(m_simulation.getSeed(),
|
||||||
|
m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Commands drain before the tick batch, so currentTick is the count of
|
||||||
|
// completed ticks the command is pinned to.
|
||||||
|
const Tick tick = m_simulation.currentTick();
|
||||||
|
m_simulation.apply(*command);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m_queue.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CommandManager::hasPending() const
|
||||||
|
{
|
||||||
|
return !m_queue.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
|
||||||
|
{
|
||||||
|
m_recorder = std::move(recorder);
|
||||||
|
if (m_recorder)
|
||||||
|
{
|
||||||
|
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandManager::setReplayMode(bool replayMode)
|
||||||
|
{
|
||||||
|
m_replayMode = replayMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommandManager::recordTickCheckpoint()
|
||||||
|
{
|
||||||
|
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
|
||||||
|
{
|
||||||
|
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/lib/sim/CommandManager.h
Normal file
55
src/lib/sim/CommandManager.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
class ReplayRecorder;
|
||||||
|
class Simulation;
|
||||||
|
|
||||||
|
// Ordered queue that funnels every player command into the single
|
||||||
|
// Simulation::apply chokepoint (see docs/replay_design.md). This is deliberately
|
||||||
|
// NOT the EventManager pub/sub bus: sim mutations must apply in a strict,
|
||||||
|
// tick-pinned, recordable order to a single recipient.
|
||||||
|
//
|
||||||
|
// Live play pushes commands via enqueue(); they are applied at the next drain(),
|
||||||
|
// which runs once per frame before the tick batch. Draining before the tick
|
||||||
|
// (even at 0x game speed) lets a paused player see placed construction sites
|
||||||
|
// immediately while staying deterministic — replay applies each command at its
|
||||||
|
// recorded tick regardless of frame cadence.
|
||||||
|
class CommandManager
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit CommandManager(Simulation& simulation);
|
||||||
|
// Defined out-of-line so the unique_ptr<ReplayRecorder> member can be
|
||||||
|
// destroyed where ReplayRecorder is a complete type.
|
||||||
|
~CommandManager();
|
||||||
|
|
||||||
|
// Append a command for application at the next drain (FIFO order).
|
||||||
|
void enqueue(std::shared_ptr<const Command> command);
|
||||||
|
|
||||||
|
// Apply all queued commands in FIFO order through Simulation::apply, then
|
||||||
|
// clear the queue. If a recorder is attached, each applied command is recorded
|
||||||
|
// (a Reset rolls the recorder to a new file).
|
||||||
|
void drain();
|
||||||
|
|
||||||
|
bool hasPending() const;
|
||||||
|
|
||||||
|
// Attach a recorder and start recording the current run. Ownership is taken.
|
||||||
|
// Passing nullptr detaches/stops recording.
|
||||||
|
void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
|
||||||
|
|
||||||
|
// In replay mode, enqueue() is a no-op: the queue is driven by the recorded
|
||||||
|
// stream (ReplayPlayer), so stray live input produces nothing.
|
||||||
|
void setReplayMode(bool replayMode);
|
||||||
|
|
||||||
|
// Record a periodic RNG checksum if the current tick is on the checksum
|
||||||
|
// cadence. Call once per simulated tick (from the tick loop).
|
||||||
|
void recordTickCheckpoint();
|
||||||
|
|
||||||
|
private:
|
||||||
|
Simulation& m_simulation;
|
||||||
|
std::vector<std::shared_ptr<const Command>> m_queue;
|
||||||
|
std::unique_ptr<ReplayRecorder> m_recorder;
|
||||||
|
bool m_replayMode = false;
|
||||||
|
};
|
||||||
314
src/lib/sim/CommandSerializer.cpp
Normal file
314
src/lib/sim/CommandSerializer.cpp
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
#include "CommandSerializer.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "BuildingType.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "ShipLayout.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
char rotationToChar(Rotation rotation)
|
||||||
|
{
|
||||||
|
switch (rotation)
|
||||||
|
{
|
||||||
|
case Rotation::North: return 'N';
|
||||||
|
case Rotation::East: return 'E';
|
||||||
|
case Rotation::South: return 'S';
|
||||||
|
case Rotation::West: return 'W';
|
||||||
|
}
|
||||||
|
return 'E';
|
||||||
|
}
|
||||||
|
|
||||||
|
Rotation rotationFromString(const std::string& token)
|
||||||
|
{
|
||||||
|
if (token == "N") { return Rotation::North; }
|
||||||
|
if (token == "S") { return Rotation::South; }
|
||||||
|
if (token == "W") { return Rotation::West; }
|
||||||
|
return Rotation::East;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads "<count> (<moduleId> <x> <y> <rot>)*" from the stream. Sets ok=false on
|
||||||
|
// a stream failure.
|
||||||
|
ShipLayoutConfig parseLayout(std::istringstream& in, bool& ok)
|
||||||
|
{
|
||||||
|
ShipLayoutConfig layout;
|
||||||
|
int count = 0;
|
||||||
|
if (!(in >> count) || count < 0) { ok = false; return layout; }
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
PlacedModule placed;
|
||||||
|
std::string rotToken;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
if (!(in >> placed.moduleId >> x >> y >> rotToken)) { ok = false; return layout; }
|
||||||
|
placed.position = QPoint(x, y);
|
||||||
|
placed.rotation = rotationFromString(rotToken);
|
||||||
|
layout.placedModules.push_back(placed);
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads "<countA> (<itemId>)* <countB> (<itemId>)*" from the stream.
|
||||||
|
void parseFilters(std::istringstream& in,
|
||||||
|
std::vector<ItemType>& filterA,
|
||||||
|
std::vector<ItemType>& filterB,
|
||||||
|
bool& ok)
|
||||||
|
{
|
||||||
|
int countA = 0;
|
||||||
|
if (!(in >> countA) || countA < 0) { ok = false; return; }
|
||||||
|
for (int i = 0; i < countA; ++i)
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
if (!(in >> id)) { ok = false; return; }
|
||||||
|
filterA.push_back(ItemType{id});
|
||||||
|
}
|
||||||
|
int countB = 0;
|
||||||
|
if (!(in >> countB) || countB < 0) { ok = false; return; }
|
||||||
|
for (int i = 0; i < countB; ++i)
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
if (!(in >> id)) { ok = false; return; }
|
||||||
|
filterB.push_back(ItemType{id});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<count> (<moduleId> <x> <y> <rot>)*"
|
||||||
|
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
|
||||||
|
{
|
||||||
|
out << layout.placedModules.size();
|
||||||
|
for (const PlacedModule& placed : layout.placedModules)
|
||||||
|
{
|
||||||
|
out << ' ' << placed.moduleId
|
||||||
|
<< ' ' << placed.position.x()
|
||||||
|
<< ' ' << placed.position.y()
|
||||||
|
<< ' ' << rotationToChar(placed.rotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<countA> (<itemId>)* <countB> (<itemId>)*"
|
||||||
|
void appendFilters(std::ostringstream& out,
|
||||||
|
const std::vector<ItemType>& filterA,
|
||||||
|
const std::vector<ItemType>& filterB)
|
||||||
|
{
|
||||||
|
out << filterA.size();
|
||||||
|
for (const ItemType& type : filterA) { out << ' ' << type.id; }
|
||||||
|
out << ' ' << filterB.size();
|
||||||
|
for (const ItemType& type : filterB) { out << ' ' << type.id; }
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string serializeCommand(const Command& command)
|
||||||
|
{
|
||||||
|
std::ostringstream out;
|
||||||
|
|
||||||
|
switch (command.kind)
|
||||||
|
{
|
||||||
|
case CommandKind::PlaceBuilding:
|
||||||
|
{
|
||||||
|
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||||
|
out << "place " << buildingTypeId(c.type)
|
||||||
|
<< ' ' << c.anchor.x() << ' ' << c.anchor.y()
|
||||||
|
<< ' ' << rotationToChar(c.rotation);
|
||||||
|
if (c.recipeId.has_value())
|
||||||
|
{
|
||||||
|
out << " recipe " << *c.recipeId;
|
||||||
|
}
|
||||||
|
if (c.shipLayout.has_value())
|
||||||
|
{
|
||||||
|
out << " layout ";
|
||||||
|
appendLayout(out, *c.shipLayout);
|
||||||
|
}
|
||||||
|
if (c.hasSplitterFilters)
|
||||||
|
{
|
||||||
|
out << " filters ";
|
||||||
|
appendFilters(out, c.splitterFilterA, c.splitterFilterB);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::Demolish:
|
||||||
|
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
|
||||||
|
break;
|
||||||
|
case CommandKind::RotateInPlace:
|
||||||
|
{
|
||||||
|
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||||
|
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetRecipe:
|
||||||
|
{
|
||||||
|
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||||
|
out << "setrecipe " << c.id << ' ' << c.recipeId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetShipLayout:
|
||||||
|
{
|
||||||
|
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||||
|
out << "setlayout " << c.id << ' ';
|
||||||
|
appendLayout(out, c.layout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSiteSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSiteSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||||
|
out << "sitefilters " << c.id << ' ';
|
||||||
|
appendFilters(out, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||||
|
out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' ';
|
||||||
|
appendFilters(out, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::ClearBeltTiles:
|
||||||
|
{
|
||||||
|
const ClearBeltTilesCommand& c = static_cast<const ClearBeltTilesCommand&>(command);
|
||||||
|
out << "clearbelt " << c.tiles.size();
|
||||||
|
for (const QPoint& tile : c.tiles)
|
||||||
|
{
|
||||||
|
out << ' ' << tile.x() << ' ' << tile.y();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::ApplySchematicChoice:
|
||||||
|
out << "schematic " << static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex;
|
||||||
|
break;
|
||||||
|
case CommandKind::Reset:
|
||||||
|
// A reset rolls the replay file; it is never written as a stream entry.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::shared_ptr<Command> parseCommand(const std::string& tokens)
|
||||||
|
{
|
||||||
|
std::istringstream in(tokens);
|
||||||
|
std::string verb;
|
||||||
|
if (!(in >> verb))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = true;
|
||||||
|
|
||||||
|
if (verb == "place")
|
||||||
|
{
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> c = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
std::string typeToken;
|
||||||
|
std::string rotToken;
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; }
|
||||||
|
const std::optional<BuildingType> type = parseBuildingType(typeToken);
|
||||||
|
if (!type.has_value()) { return nullptr; }
|
||||||
|
c->type = *type;
|
||||||
|
c->anchor = QPoint(x, y);
|
||||||
|
c->rotation = rotationFromString(rotToken);
|
||||||
|
|
||||||
|
std::string segment;
|
||||||
|
while (in >> segment)
|
||||||
|
{
|
||||||
|
if (segment == "recipe")
|
||||||
|
{
|
||||||
|
std::string id;
|
||||||
|
if (!(in >> id)) { return nullptr; }
|
||||||
|
c->recipeId = id;
|
||||||
|
}
|
||||||
|
else if (segment == "layout")
|
||||||
|
{
|
||||||
|
c->shipLayout = parseLayout(in, ok);
|
||||||
|
if (!ok) { return nullptr; }
|
||||||
|
}
|
||||||
|
else if (segment == "filters")
|
||||||
|
{
|
||||||
|
parseFilters(in, c->splitterFilterA, c->splitterFilterB, ok);
|
||||||
|
if (!ok) { return nullptr; }
|
||||||
|
c->hasSplitterFilters = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "demolish")
|
||||||
|
{
|
||||||
|
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
|
||||||
|
if (!(in >> c->id)) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "rotate")
|
||||||
|
{
|
||||||
|
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
|
||||||
|
std::string rotToken;
|
||||||
|
if (!(in >> c->id >> rotToken)) { return nullptr; }
|
||||||
|
c->newRotation = rotationFromString(rotToken);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "setrecipe")
|
||||||
|
{
|
||||||
|
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
|
||||||
|
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "setlayout")
|
||||||
|
{
|
||||||
|
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
|
||||||
|
if (!(in >> c->id)) { return nullptr; }
|
||||||
|
c->layout = parseLayout(in, ok);
|
||||||
|
if (!ok) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "sitefilters")
|
||||||
|
{
|
||||||
|
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
|
||||||
|
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||||
|
if (!(in >> c->id)) { return nullptr; }
|
||||||
|
parseFilters(in, c->filterA, c->filterB, ok);
|
||||||
|
if (!ok) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "splitterfilters")
|
||||||
|
{
|
||||||
|
std::shared_ptr<SetSplitterFiltersCommand> c =
|
||||||
|
std::make_shared<SetSplitterFiltersCommand>();
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
if (!(in >> x >> y)) { return nullptr; }
|
||||||
|
c->tile = QPoint(x, y);
|
||||||
|
parseFilters(in, c->filterA, c->filterB, ok);
|
||||||
|
if (!ok) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "clearbelt")
|
||||||
|
{
|
||||||
|
std::shared_ptr<ClearBeltTilesCommand> c = std::make_shared<ClearBeltTilesCommand>();
|
||||||
|
int count = 0;
|
||||||
|
if (!(in >> count) || count < 0) { return nullptr; }
|
||||||
|
for (int i = 0; i < count; ++i)
|
||||||
|
{
|
||||||
|
int x = 0;
|
||||||
|
int y = 0;
|
||||||
|
if (!(in >> x >> y)) { return nullptr; }
|
||||||
|
c->tiles.push_back(QPoint(x, y));
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
if (verb == "schematic")
|
||||||
|
{
|
||||||
|
std::shared_ptr<ApplySchematicChoiceCommand> c =
|
||||||
|
std::make_shared<ApplySchematicChoiceCommand>();
|
||||||
|
if (!(in >> c->choiceIndex)) { return nullptr; }
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
21
src/lib/sim/CommandSerializer.h
Normal file
21
src/lib/sim/CommandSerializer.h
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
|
||||||
|
// Serializes a command to a single-line, space-delimited token sequence for the
|
||||||
|
// replay file (see docs/replay_design.md "File format: line-oriented ...").
|
||||||
|
// Config ids (building types, recipes, items, modules) are whitespace-free
|
||||||
|
// identifiers, so space delimiting is unambiguous; variable-length parts are
|
||||||
|
// length-prefixed so the matching parser (added in Phase 3) is unambiguous.
|
||||||
|
//
|
||||||
|
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it
|
||||||
|
// is never serialized here.
|
||||||
|
std::string serializeCommand(const Command& command);
|
||||||
|
|
||||||
|
// Inverse of serializeCommand: parses the command token sequence (the part of a
|
||||||
|
// replay line after the leading tick) back into a Command. Returns nullptr if the
|
||||||
|
// tokens are malformed or reference an unknown building type.
|
||||||
|
std::shared_ptr<Command> parseCommand(const std::string& tokens);
|
||||||
56
src/lib/sim/ReplayPlayer.cpp
Normal file
56
src/lib/sim/ReplayPlayer.cpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#include "ReplayPlayer.h"
|
||||||
|
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
|
||||||
|
ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries)
|
||||||
|
: m_simulation(simulation)
|
||||||
|
, m_entries(std::move(entries))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayPlayer::start()
|
||||||
|
{
|
||||||
|
processEntriesAt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayPlayer::advanceTo(Tick tick)
|
||||||
|
{
|
||||||
|
processEntriesAt(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReplayPlayer::isFinished() const
|
||||||
|
{
|
||||||
|
return m_desyncTick.has_value() || m_cursor >= m_entries.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<Tick> ReplayPlayer::getDesyncTick() const
|
||||||
|
{
|
||||||
|
return m_desyncTick;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayPlayer::processEntriesAt(Tick tick)
|
||||||
|
{
|
||||||
|
// Consume entries for this tick in file order. The recording writes, at a
|
||||||
|
// given tick: the periodic checksum (if any) first, then command lines each
|
||||||
|
// followed by their post-apply checksum — so applying/verifying in file order
|
||||||
|
// reproduces the original sequence exactly.
|
||||||
|
while (!m_desyncTick.has_value()
|
||||||
|
&& m_cursor < m_entries.size()
|
||||||
|
&& m_entries[m_cursor].tick == tick)
|
||||||
|
{
|
||||||
|
const ReplayEntry& entry = m_entries[m_cursor];
|
||||||
|
++m_cursor;
|
||||||
|
|
||||||
|
if (entry.isCommand)
|
||||||
|
{
|
||||||
|
m_simulation.apply(*entry.command);
|
||||||
|
}
|
||||||
|
else if (m_simulation.rngFingerprint() != entry.fingerprint)
|
||||||
|
{
|
||||||
|
m_desyncTick = tick;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/lib/sim/ReplayPlayer.h
Normal file
48
src/lib/sim/ReplayPlayer.h
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <optional>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "ReplayReader.h"
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
class Simulation;
|
||||||
|
|
||||||
|
// Drives playback of a parsed replay against a Simulation: applies each recorded
|
||||||
|
// command at its recorded tick (through Simulation::apply) and verifies the RNG
|
||||||
|
// checksums, reporting the first desync. Entries are consumed in file order,
|
||||||
|
// which reproduces the exact command/tick interleaving of the original run.
|
||||||
|
//
|
||||||
|
// Cadence (mirrors how the run was recorded so checksums line up):
|
||||||
|
// player.start(); // process tick-0 entries before the first tick
|
||||||
|
// each frame, for each tick to run:
|
||||||
|
// if (player.isFinished()) break;
|
||||||
|
// sim.tick();
|
||||||
|
// player.advanceTo(sim.currentTick());
|
||||||
|
class ReplayPlayer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries);
|
||||||
|
|
||||||
|
// Process all entries recorded at tick 0 (the initial checksum and any
|
||||||
|
// commands issued before the first tick). Call once, before the first tick.
|
||||||
|
void start();
|
||||||
|
|
||||||
|
// Process all entries recorded at `tick`. Call once after each sim.tick().
|
||||||
|
void advanceTo(Tick tick);
|
||||||
|
|
||||||
|
// True once all entries are consumed or a desync was detected.
|
||||||
|
bool isFinished() const;
|
||||||
|
|
||||||
|
// The tick at which the recomputed checksum first diverged, if any.
|
||||||
|
std::optional<Tick> getDesyncTick() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void processEntriesAt(Tick tick);
|
||||||
|
|
||||||
|
Simulation& m_simulation;
|
||||||
|
std::vector<ReplayEntry> m_entries;
|
||||||
|
std::size_t m_cursor = 0;
|
||||||
|
std::optional<Tick> m_desyncTick;
|
||||||
|
};
|
||||||
145
src/lib/sim/ReplayReader.cpp
Normal file
145
src/lib/sim/ReplayReader.cpp
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#include "ReplayReader.h"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandSerializer.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
void stripCarriageReturn(std::string& line)
|
||||||
|
{
|
||||||
|
if (!line.empty() && line.back() == '\r')
|
||||||
|
{
|
||||||
|
line.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits "key value with spaces" into (key, remainder). Remainder may be empty.
|
||||||
|
void splitKeyValue(const std::string& line, std::string& key, std::string& value)
|
||||||
|
{
|
||||||
|
const std::string::size_type space = line.find(' ');
|
||||||
|
if (space == std::string::npos)
|
||||||
|
{
|
||||||
|
key = line;
|
||||||
|
value.clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
key = line.substr(0, space);
|
||||||
|
value = line.substr(space + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::optional<ParsedReplay> readReplayFile(const std::string& path)
|
||||||
|
{
|
||||||
|
std::ifstream stream(path, std::ios::in);
|
||||||
|
if (!stream.is_open())
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedReplay replay;
|
||||||
|
std::string line;
|
||||||
|
|
||||||
|
// --- Header (up to the "---" separator) ---
|
||||||
|
bool sawSeparator = false;
|
||||||
|
while (std::getline(stream, line))
|
||||||
|
{
|
||||||
|
stripCarriageReturn(line);
|
||||||
|
if (line == "---")
|
||||||
|
{
|
||||||
|
sawSeparator = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (line.empty() || line[0] == '#')
|
||||||
|
{
|
||||||
|
continue; // banner / blank
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string key;
|
||||||
|
std::string value;
|
||||||
|
splitKeyValue(line, key, value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (key == "version") { replay.header.version = std::stoi(value); }
|
||||||
|
else if (key == "build") { replay.header.build = value; }
|
||||||
|
else if (key == "seed") { replay.header.seed = static_cast<unsigned int>(std::stoul(value)); }
|
||||||
|
else if (key == "config_hash") { replay.header.configHash = value; }
|
||||||
|
else if (key == "timestamp") { replay.header.timestamp = value; }
|
||||||
|
}
|
||||||
|
catch (const std::exception&)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawSeparator)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Command / checksum stream ---
|
||||||
|
while (std::getline(stream, line))
|
||||||
|
{
|
||||||
|
stripCarriageReturn(line);
|
||||||
|
if (line.empty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line[0] == '#')
|
||||||
|
{
|
||||||
|
// "# checksum <tick> <hex>"
|
||||||
|
std::istringstream in(line);
|
||||||
|
std::string hash;
|
||||||
|
std::string keyword;
|
||||||
|
ReplayEntry entry;
|
||||||
|
in >> hash >> keyword >> entry.tick >> hash;
|
||||||
|
if (keyword != "checksum")
|
||||||
|
{
|
||||||
|
continue; // unknown comment line — ignore
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry.fingerprint = std::stoull(hash, nullptr, 16);
|
||||||
|
}
|
||||||
|
catch (const std::exception&)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
entry.isCommand = false;
|
||||||
|
replay.entries.push_back(std::move(entry));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<tick> <serialized command>"
|
||||||
|
const std::string::size_type space = line.find(' ');
|
||||||
|
if (space == std::string::npos)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
ReplayEntry entry;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
entry.tick = std::stoll(line.substr(0, space));
|
||||||
|
}
|
||||||
|
catch (const std::exception&)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
std::shared_ptr<Command> command = parseCommand(line.substr(space + 1));
|
||||||
|
if (!command)
|
||||||
|
{
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
entry.isCommand = true;
|
||||||
|
entry.command = std::move(command);
|
||||||
|
replay.entries.push_back(std::move(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
return replay;
|
||||||
|
}
|
||||||
41
src/lib/sim/ReplayReader.h
Normal file
41
src/lib/sim/ReplayReader.h
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
|
||||||
|
struct ReplayHeader
|
||||||
|
{
|
||||||
|
int version = 0;
|
||||||
|
std::string build;
|
||||||
|
unsigned int seed = 0;
|
||||||
|
std::string configHash;
|
||||||
|
std::string timestamp;
|
||||||
|
};
|
||||||
|
|
||||||
|
// One entry of the replay stream: either a command (applied at its tick) or an
|
||||||
|
// RNG checksum (verified at its tick). Entries are kept in file order, which is
|
||||||
|
// the canonical order the playback driver reproduces.
|
||||||
|
struct ReplayEntry
|
||||||
|
{
|
||||||
|
Tick tick = 0;
|
||||||
|
bool isCommand = false;
|
||||||
|
std::shared_ptr<const Command> command; // set iff isCommand
|
||||||
|
std::uint64_t fingerprint = 0; // set iff !isCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ParsedReplay
|
||||||
|
{
|
||||||
|
ReplayHeader header;
|
||||||
|
std::vector<ReplayEntry> entries;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parses a replay file (header + command/checksum stream). Returns nullopt on an
|
||||||
|
// I/O failure or malformed content (e.g. an unparseable command line).
|
||||||
|
std::optional<ParsedReplay> readReplayFile(const std::string& path);
|
||||||
130
src/lib/sim/ReplayRecorder.cpp
Normal file
130
src/lib/sim/ReplayRecorder.cpp
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
#include "ReplayRecorder.h"
|
||||||
|
|
||||||
|
#include <iomanip>
|
||||||
|
#include <sstream>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandSerializer.h"
|
||||||
|
#include "StateChecksum.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr const char* kReplayFormatVersion = "1";
|
||||||
|
|
||||||
|
// Build fingerprint: even a new local build can desync old replays (float
|
||||||
|
// reasons), so the header carries a per-build tag to warn on mismatch.
|
||||||
|
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
|
||||||
|
|
||||||
|
std::string toHex(std::uint64_t value)
|
||||||
|
{
|
||||||
|
std::ostringstream out;
|
||||||
|
out << std::hex << std::setw(16) << std::setfill('0') << value;
|
||||||
|
return out.str();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
|
||||||
|
: m_configDir(std::move(configDir))
|
||||||
|
, m_outputDir(std::move(outputDir))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
ReplayRecorder::~ReplayRecorder()
|
||||||
|
{
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string computeReplayConfigHash(const std::string& configDir)
|
||||||
|
{
|
||||||
|
Hasher hasher;
|
||||||
|
QDir dir(QString::fromStdString(configDir));
|
||||||
|
const QStringList files =
|
||||||
|
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
|
||||||
|
for (const QString& name : files)
|
||||||
|
{
|
||||||
|
hasher.append(name.toStdString());
|
||||||
|
QFile file(dir.filePath(name));
|
||||||
|
if (file.open(QIODevice::ReadOnly))
|
||||||
|
{
|
||||||
|
const QByteArray bytes = file.readAll();
|
||||||
|
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toHex(hasher.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
|
||||||
|
{
|
||||||
|
close();
|
||||||
|
|
||||||
|
QDir().mkpath(QString::fromStdString(m_outputDir));
|
||||||
|
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
|
||||||
|
const QString fileName = timestamp + "_" + QString::number(seed) + ".replay";
|
||||||
|
m_filePath = QDir(QString::fromStdString(m_outputDir)).filePath(fileName).toStdString();
|
||||||
|
|
||||||
|
m_stream.open(m_filePath, std::ios::out | std::ios::trunc);
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_stream << "# dota_factory replay\n";
|
||||||
|
m_stream << "version " << kReplayFormatVersion << "\n";
|
||||||
|
m_stream << "build " << kBuildTag << "\n";
|
||||||
|
m_stream << "seed " << seed << "\n";
|
||||||
|
m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n";
|
||||||
|
m_stream << "timestamp "
|
||||||
|
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
|
||||||
|
m_stream << "---\n";
|
||||||
|
m_stream << "# checksum 0 " << toHex(initialRngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::recordCommand(Tick tick, const Command& command,
|
||||||
|
std::uint64_t rngFingerprint)
|
||||||
|
{
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_stream << tick << ' ' << serializeCommand(command) << "\n";
|
||||||
|
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::recordChecksum(Tick tick, std::uint64_t rngFingerprint)
|
||||||
|
{
|
||||||
|
if (!m_stream.is_open())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
|
||||||
|
m_stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ReplayRecorder::close()
|
||||||
|
{
|
||||||
|
if (m_stream.is_open())
|
||||||
|
{
|
||||||
|
m_stream.flush();
|
||||||
|
m_stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ReplayRecorder::isOpen() const
|
||||||
|
{
|
||||||
|
return m_stream.is_open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& ReplayRecorder::currentFilePath() const
|
||||||
|
{
|
||||||
|
return m_filePath;
|
||||||
|
}
|
||||||
54
src/lib/sim/ReplayRecorder.h
Normal file
54
src/lib/sim/ReplayRecorder.h
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <fstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
|
||||||
|
// 64-bit hash (16-char hex) over the *.toml files in configDir. Stored in the
|
||||||
|
// replay header and recomputed on playback to detect a config mismatch.
|
||||||
|
std::string computeReplayConfigHash(const std::string& configDir);
|
||||||
|
|
||||||
|
// Writes a replay file as the game runs (see docs/replay_design.md). The format
|
||||||
|
// is line-oriented append-friendly text: a small keyed header, then one line per
|
||||||
|
// command (tick-tagged) interleaved with RNG-state checksum lines for desync
|
||||||
|
// detection. Each line is flushed so a crash mid-run still leaves a valid partial
|
||||||
|
// file.
|
||||||
|
//
|
||||||
|
// One file = one run between Reset boundaries; startNewRun() closes the current
|
||||||
|
// file and opens a fresh one.
|
||||||
|
class ReplayRecorder
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// configDir: hashed (its *.toml files) into the header for config-mismatch
|
||||||
|
// detection on playback. outputDir: where .replay files are written.
|
||||||
|
ReplayRecorder(std::string configDir, std::string outputDir);
|
||||||
|
~ReplayRecorder();
|
||||||
|
|
||||||
|
ReplayRecorder(const ReplayRecorder&) = delete;
|
||||||
|
ReplayRecorder& operator=(const ReplayRecorder&) = delete;
|
||||||
|
|
||||||
|
// Close any current file, then open a fresh one (named <timestamp>_<seed>),
|
||||||
|
// write the header, and record an initial tick-0 checksum. A run boundary.
|
||||||
|
void startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint);
|
||||||
|
|
||||||
|
// Append one command line tagged with the tick it was applied at, followed by
|
||||||
|
// the post-apply RNG checksum.
|
||||||
|
void recordCommand(Tick tick, const Command& command, std::uint64_t rngFingerprint);
|
||||||
|
|
||||||
|
// Append a periodic RNG checksum line.
|
||||||
|
void recordChecksum(Tick tick, std::uint64_t rngFingerprint);
|
||||||
|
|
||||||
|
void close();
|
||||||
|
bool isOpen() const;
|
||||||
|
const std::string& currentFilePath() const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string m_configDir;
|
||||||
|
std::string m_outputDir;
|
||||||
|
std::string m_filePath;
|
||||||
|
std::ofstream m_stream;
|
||||||
|
};
|
||||||
@@ -4,10 +4,13 @@
|
|||||||
#include <cassert>
|
#include <cassert>
|
||||||
|
|
||||||
#include "AiSystem.h"
|
#include "AiSystem.h"
|
||||||
|
#include "Command.h"
|
||||||
#include "DisplayName.h"
|
#include "DisplayName.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
#include "CombatSystem.h"
|
#include "CombatSystem.h"
|
||||||
|
#include "DynamicBodyComponent.h"
|
||||||
#include "DynamicBodySystem.h"
|
#include "DynamicBodySystem.h"
|
||||||
|
#include "FacingComponent.h"
|
||||||
#include "FactionComponent.h"
|
#include "FactionComponent.h"
|
||||||
#include "EventManager.h"
|
#include "EventManager.h"
|
||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
@@ -16,9 +19,11 @@
|
|||||||
#include "PositionComponent.h"
|
#include "PositionComponent.h"
|
||||||
#include "RepairSystem.h"
|
#include "RepairSystem.h"
|
||||||
#include "SalvagerSystem.h"
|
#include "SalvagerSystem.h"
|
||||||
|
#include "ScrapDataComponent.h"
|
||||||
#include "ScrapSystem.h"
|
#include "ScrapSystem.h"
|
||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
|
#include "StateChecksum.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
#include "SurfaceMask.h"
|
#include "SurfaceMask.h"
|
||||||
#include "tracing.h"
|
#include "tracing.h"
|
||||||
@@ -28,6 +33,7 @@
|
|||||||
Simulation::Simulation(GameConfig config, unsigned int seed)
|
Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||||
: m_config(std::move(config))
|
: m_config(std::move(config))
|
||||||
, m_rng(seed)
|
, m_rng(seed)
|
||||||
|
, m_seed(seed)
|
||||||
, m_currentTick(0)
|
, m_currentTick(0)
|
||||||
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
||||||
, m_nextBuildingId(1)
|
, m_nextBuildingId(1)
|
||||||
@@ -129,6 +135,7 @@ void Simulation::reset(unsigned int seed)
|
|||||||
{
|
{
|
||||||
EventManager::getInstance()->clearEvents();
|
EventManager::getInstance()->clearEvents();
|
||||||
m_rng.seed(seed);
|
m_rng.seed(seed);
|
||||||
|
m_seed = seed;
|
||||||
m_currentTick = 0;
|
m_currentTick = 0;
|
||||||
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
||||||
m_nextBuildingId = 1;
|
m_nextBuildingId = 1;
|
||||||
@@ -217,6 +224,92 @@ void Simulation::reset(unsigned int seed)
|
|||||||
// tick
|
// tick
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void Simulation::apply(const Command& command)
|
||||||
|
{
|
||||||
|
switch (command.kind)
|
||||||
|
{
|
||||||
|
case CommandKind::PlaceBuilding:
|
||||||
|
{
|
||||||
|
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||||
|
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
|
||||||
|
if (id == kInvalidBuildingId)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (c.recipeId.has_value())
|
||||||
|
{
|
||||||
|
m_buildingSystem->setRecipe(id, *c.recipeId);
|
||||||
|
}
|
||||||
|
if (c.shipLayout.has_value())
|
||||||
|
{
|
||||||
|
m_buildingSystem->setShipLayout(id, *c.shipLayout);
|
||||||
|
}
|
||||||
|
if (c.hasSplitterFilters)
|
||||||
|
{
|
||||||
|
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::Demolish:
|
||||||
|
demolish(static_cast<const DemolishCommand&>(command).id);
|
||||||
|
break;
|
||||||
|
case CommandKind::RotateInPlace:
|
||||||
|
{
|
||||||
|
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||||
|
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetRecipe:
|
||||||
|
{
|
||||||
|
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||||
|
m_buildingSystem->setRecipe(c.id, c.recipeId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetShipLayout:
|
||||||
|
{
|
||||||
|
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||||
|
m_buildingSystem->setShipLayout(c.id, c.layout);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSiteSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSiteSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||||
|
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::SetSplitterFilters:
|
||||||
|
{
|
||||||
|
const SetSplitterFiltersCommand& c =
|
||||||
|
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||||
|
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CommandKind::ClearBeltTiles:
|
||||||
|
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
|
||||||
|
break;
|
||||||
|
case CommandKind::ApplySchematicChoice:
|
||||||
|
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
|
||||||
|
break;
|
||||||
|
case CommandKind::Reset:
|
||||||
|
{
|
||||||
|
const ResetCommand& c = static_cast<const ResetCommand&>(command);
|
||||||
|
if (c.config)
|
||||||
|
{
|
||||||
|
// operator* on a const shared_ptr yields a mutable GameConfig&, so
|
||||||
|
// the move-only config moves into reset without a copy. The command
|
||||||
|
// is applied once, so leaving its config moved-from is fine.
|
||||||
|
reset(std::move(*c.config), c.seed);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reset(c.seed);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void Simulation::tick()
|
void Simulation::tick()
|
||||||
{
|
{
|
||||||
EventManager::getInstance()->processEvents();
|
EventManager::getInstance()->processEvents();
|
||||||
@@ -856,6 +949,118 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
|
|||||||
return m_unlockedItemIds.count(itemId) > 0;
|
return m_unlockedItemIds.count(itemId) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Determinism (see docs/replay_design.md)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void Simulation::appendSchematicMap(Hasher& hasher,
|
||||||
|
const std::map<std::string, SchematicState>& levels)
|
||||||
|
{
|
||||||
|
hasher.append(levels.size());
|
||||||
|
for (const std::pair<const std::string, SchematicState>& entry : levels)
|
||||||
|
{
|
||||||
|
hasher.append(entry.first);
|
||||||
|
hasher.append(entry.second.unlocked);
|
||||||
|
hasher.append(entry.second.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
|
||||||
|
{
|
||||||
|
hasher.append(ids.size());
|
||||||
|
for (const std::string& id : ids)
|
||||||
|
{
|
||||||
|
hasher.append(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long long Simulation::rngFingerprint() const
|
||||||
|
{
|
||||||
|
return fingerprintRng(m_rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned long long Simulation::computeStateChecksum() const
|
||||||
|
{
|
||||||
|
Hasher hasher;
|
||||||
|
|
||||||
|
// RNG stream — the most sensitive signal of divergence.
|
||||||
|
hasher.append(fingerprintRng(m_rng));
|
||||||
|
|
||||||
|
// Top-level scalars.
|
||||||
|
hasher.append(m_currentTick);
|
||||||
|
hasher.append(m_nextDepartureTick);
|
||||||
|
hasher.append(m_nextBuildingId);
|
||||||
|
hasher.append(m_buildingBlocksStock);
|
||||||
|
hasher.append(m_gameOver);
|
||||||
|
hasher.append(m_isWon);
|
||||||
|
hasher.append(m_artifactCount);
|
||||||
|
|
||||||
|
// WaveSystem scalar state, reached through existing accessors.
|
||||||
|
hasher.append(threatLevel());
|
||||||
|
hasher.append(threatAccumulationRate());
|
||||||
|
hasher.append(bossWaveCounter());
|
||||||
|
hasher.append(bossCountdownTicks());
|
||||||
|
hasher.append(normalGapRemainingTicks());
|
||||||
|
|
||||||
|
// Schematic / unlock state (std::map and std::set iterate in sorted order).
|
||||||
|
appendSchematicMap(hasher, m_schematicLevels);
|
||||||
|
appendSchematicMap(hasher, m_moduleSchematicLevels);
|
||||||
|
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
|
||||||
|
appendStringSet(hasher, m_unlockedRecipeIds);
|
||||||
|
appendStringSet(hasher, m_unlockedItemIds);
|
||||||
|
|
||||||
|
// Subsystems contribute their own state.
|
||||||
|
m_buildingSystem->appendChecksum(hasher);
|
||||||
|
m_beltSystem.appendChecksum(hasher);
|
||||||
|
|
||||||
|
// ECS component state. View iteration order is a pure function of the
|
||||||
|
// (identical) operation sequence on a fixed binary; each entity's raw id is
|
||||||
|
// folded in so the fingerprint is keyed, not merely a sum of fields.
|
||||||
|
m_admin.forEach<PositionComponent>(
|
||||||
|
[&hasher](entt::entity entity, const PositionComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.value);
|
||||||
|
});
|
||||||
|
m_admin.forEach<HealthComponent>(
|
||||||
|
[&hasher](entt::entity entity, const HealthComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.hp);
|
||||||
|
hasher.append(c.maxHp);
|
||||||
|
});
|
||||||
|
m_admin.forEach<FacingComponent>(
|
||||||
|
[&hasher](entt::entity entity, const FacingComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.radians);
|
||||||
|
});
|
||||||
|
m_admin.forEach<DynamicBodyComponent>(
|
||||||
|
[&hasher](entt::entity entity, const DynamicBodyComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.velocity_tpt);
|
||||||
|
hasher.append(c.angularVelocity_rpt);
|
||||||
|
hasher.append(c.linearAcceleration_tptt);
|
||||||
|
hasher.append(c.angularAcceleration_rptt);
|
||||||
|
});
|
||||||
|
m_admin.forEach<ScrapDataComponent>(
|
||||||
|
[&hasher](entt::entity entity, const ScrapDataComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.amount);
|
||||||
|
});
|
||||||
|
m_admin.forEach<ShipIdentityComponent>(
|
||||||
|
[&hasher](entt::entity entity, const ShipIdentityComponent& c)
|
||||||
|
{
|
||||||
|
hasher.append(static_cast<std::uint32_t>(entity));
|
||||||
|
hasher.append(c.level);
|
||||||
|
hasher.append(c.schematicId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasher.value();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Drains
|
// Drains
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -887,6 +1092,11 @@ Tick Simulation::currentTick() const
|
|||||||
return m_currentTick;
|
return m_currentTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unsigned int Simulation::getSeed() const
|
||||||
|
{
|
||||||
|
return m_seed;
|
||||||
|
}
|
||||||
|
|
||||||
int Simulation::buildingBlocksStock() const
|
int Simulation::buildingBlocksStock() const
|
||||||
{
|
{
|
||||||
return m_buildingBlocksStock;
|
return m_buildingBlocksStock;
|
||||||
@@ -1015,7 +1225,7 @@ void Simulation::demolish(BuildingId id)
|
|||||||
m_buildingBlocksStock += m_buildingSystem->demolish(id);
|
m_buildingBlocksStock += m_buildingSystem->demolish(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
BuildingSystem& Simulation::buildings()
|
BuildingSystem& Simulation::buildingsMutable()
|
||||||
{
|
{
|
||||||
return *m_buildingSystem;
|
return *m_buildingSystem;
|
||||||
}
|
}
|
||||||
@@ -1025,7 +1235,7 @@ const BuildingSystem& Simulation::buildings() const
|
|||||||
return *m_buildingSystem;
|
return *m_buildingSystem;
|
||||||
}
|
}
|
||||||
|
|
||||||
BeltSystem& Simulation::belts()
|
BeltSystem& Simulation::beltsMutable()
|
||||||
{
|
{
|
||||||
return m_beltSystem;
|
return m_beltSystem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
class AiSystem;
|
class AiSystem;
|
||||||
class BuildingSystem;
|
class BuildingSystem;
|
||||||
|
struct Command;
|
||||||
|
class Hasher;
|
||||||
class CombatSystem;
|
class CombatSystem;
|
||||||
class DynamicBodySystem;
|
class DynamicBodySystem;
|
||||||
class MovementIntentSystem;
|
class MovementIntentSystem;
|
||||||
@@ -50,6 +52,12 @@ public:
|
|||||||
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
|
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
|
||||||
void tick();
|
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
|
// Returns all fire events accumulated since the last drain, clearing the
|
||||||
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
|
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
|
||||||
std::vector<BeamFiredEvent> drainBeamFiredEvents();
|
std::vector<BeamFiredEvent> drainBeamFiredEvents();
|
||||||
@@ -60,12 +68,9 @@ public:
|
|||||||
// Returns true if there are pending schematic choices waiting for player input.
|
// Returns true if there are pending schematic choices waiting for player input.
|
||||||
bool hasSchematicChoicesPending() const;
|
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;
|
Tick currentTick() const;
|
||||||
|
// The seed this run was (re)initialized with; written to the replay header.
|
||||||
|
unsigned int getSeed() const;
|
||||||
int buildingBlocksStock() const;
|
int buildingBlocksStock() const;
|
||||||
bool isGameOver() const;
|
bool isGameOver() const;
|
||||||
bool isWon() const;
|
bool isWon() const;
|
||||||
@@ -90,16 +95,21 @@ public:
|
|||||||
bool isRecipeUnlocked(const std::string& recipeId) const;
|
bool isRecipeUnlocked(const std::string& recipeId) const;
|
||||||
bool isItemUnlocked(const std::string& itemId) const;
|
bool isItemUnlocked(const std::string& itemId) const;
|
||||||
|
|
||||||
// Checks affordability, deducts building blocks, and places the building.
|
// -- Determinism (see docs/replay_design.md) -----------------------------
|
||||||
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
|
||||||
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
// file periodically + after each command for desync detection.
|
||||||
|
unsigned long long rngFingerprint() const;
|
||||||
|
|
||||||
// Demolishes the building with the given id and refunds building blocks.
|
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
|
||||||
void demolish(BuildingId id);
|
// belts, and ECS component state). Used by the double-run determinism test;
|
||||||
|
// a superset of rngFingerprint().
|
||||||
|
unsigned long long computeStateChecksum() const;
|
||||||
|
|
||||||
BuildingSystem& buildings();
|
// Const subsystem accessors (queries only). The mutable counterparts are
|
||||||
|
// private and reachable only through Simulation::apply (the command
|
||||||
|
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
|
||||||
|
// mutate the factory outside the recorded command path (docs/replay_design.md).
|
||||||
const BuildingSystem& buildings() const;
|
const BuildingSystem& buildings() const;
|
||||||
BeltSystem& belts();
|
|
||||||
const BeltSystem& belts() const;
|
const BeltSystem& belts() const;
|
||||||
ShipSystem& ships();
|
ShipSystem& ships();
|
||||||
const ShipSystem& ships() const;
|
const ShipSystem& ships() const;
|
||||||
@@ -109,6 +119,29 @@ public:
|
|||||||
const EntityAdmin& admin() const;
|
const EntityAdmin& admin() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Grants tests access to the private player-action mutators below without
|
||||||
|
// opening them to production code (see src/test/SimulationTestAccess.h).
|
||||||
|
friend struct SimulationTestAccess;
|
||||||
|
|
||||||
|
// -- Player-action mutators (command chokepoint only) --------------------
|
||||||
|
// Reached during play exclusively via apply(); never called by UI/app code.
|
||||||
|
|
||||||
|
// Checks affordability, deducts building blocks, and places the building.
|
||||||
|
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
||||||
|
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
||||||
|
|
||||||
|
// Demolishes the building with the given id and refunds building blocks.
|
||||||
|
void demolish(BuildingId id);
|
||||||
|
|
||||||
|
// Applies the player's chosen schematic from the pending choices.
|
||||||
|
// choiceIndex must be in [0, pendingChoices.size()).
|
||||||
|
// Clears the pending choices after application.
|
||||||
|
void applySchematicChoice(int choiceIndex);
|
||||||
|
|
||||||
|
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
|
||||||
|
BuildingSystem& buildingsMutable();
|
||||||
|
BeltSystem& beltsMutable();
|
||||||
|
|
||||||
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
|
||||||
|
|
||||||
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
|
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
|
||||||
@@ -128,6 +161,7 @@ private:
|
|||||||
|
|
||||||
GameConfig m_config;
|
GameConfig m_config;
|
||||||
std::mt19937 m_rng;
|
std::mt19937 m_rng;
|
||||||
|
unsigned int m_seed;
|
||||||
|
|
||||||
Tick m_currentTick;
|
Tick m_currentTick;
|
||||||
Tick m_nextDepartureTick;
|
Tick m_nextDepartureTick;
|
||||||
@@ -153,6 +187,11 @@ private:
|
|||||||
std::map<std::string, SchematicState> m_schematicLevels;
|
std::map<std::string, SchematicState> m_schematicLevels;
|
||||||
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
||||||
|
|
||||||
|
// Determinism helpers — fold sub-state into the hasher in deterministic order.
|
||||||
|
static void appendSchematicMap(Hasher& hasher,
|
||||||
|
const std::map<std::string, SchematicState>& levels);
|
||||||
|
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
|
||||||
|
|
||||||
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
|
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
|
||||||
std::set<std::string> m_unlockedRecipeSchematicIds;
|
std::set<std::string> m_unlockedRecipeSchematicIds;
|
||||||
|
|
||||||
|
|||||||
61
src/lib/sim/StateChecksum.cpp
Normal file
61
src/lib/sim/StateChecksum.cpp
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
#include "StateChecksum.h"
|
||||||
|
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
|
void Hasher::appendBytes(const void* data, std::size_t byteCount)
|
||||||
|
{
|
||||||
|
const unsigned char* bytes = static_cast<const unsigned char*>(data);
|
||||||
|
for (std::size_t i = 0; i < byteCount; ++i)
|
||||||
|
{
|
||||||
|
m_state ^= bytes[i];
|
||||||
|
m_state *= 1099511628211ull; // FNV-1a 64-bit prime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(float value)
|
||||||
|
{
|
||||||
|
// Normalize -0.0f to +0.0f so the two equal values share a fingerprint.
|
||||||
|
if (value == 0.0f) { value = 0.0f; }
|
||||||
|
appendBytes(&value, sizeof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(double value)
|
||||||
|
{
|
||||||
|
if (value == 0.0) { value = 0.0; }
|
||||||
|
appendBytes(&value, sizeof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(const QPoint& point)
|
||||||
|
{
|
||||||
|
const int coords[2] = { point.x(), point.y() };
|
||||||
|
appendBytes(coords, sizeof(coords));
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(const QPointF& point)
|
||||||
|
{
|
||||||
|
append(point.x());
|
||||||
|
append(point.y());
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(const QVector2D& vector)
|
||||||
|
{
|
||||||
|
append(vector.x());
|
||||||
|
append(vector.y());
|
||||||
|
}
|
||||||
|
|
||||||
|
void Hasher::append(const std::string& text)
|
||||||
|
{
|
||||||
|
appendBytes(text.data(), text.size());
|
||||||
|
// Length terminator so "ab"+"c" and "a"+"bc" do not collide.
|
||||||
|
const std::size_t length = text.size();
|
||||||
|
appendBytes(&length, sizeof(length));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t fingerprintRng(const std::mt19937& rng)
|
||||||
|
{
|
||||||
|
std::ostringstream stream;
|
||||||
|
stream << rng; // full internal state as space-separated integers
|
||||||
|
Hasher hasher;
|
||||||
|
hasher.append(stream.str());
|
||||||
|
return hasher.value();
|
||||||
|
}
|
||||||
55
src/lib/sim/StateChecksum.h
Normal file
55
src/lib/sim/StateChecksum.h
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
#include <type_traits>
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
// FNV-1a 64-bit accumulator used to fingerprint simulation state for
|
||||||
|
// determinism verification (see docs/replay_design.md "Determinism").
|
||||||
|
//
|
||||||
|
// Subsystems contribute their own state through appendChecksum(Hasher&) so the
|
||||||
|
// hash stays close to the data it covers and no state knowledge is duplicated.
|
||||||
|
// The accumulator is order-sensitive; callers fold state in a deterministic
|
||||||
|
// order (sorted containers, fixed view iteration).
|
||||||
|
class Hasher
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
// Folds raw bytes into the running hash.
|
||||||
|
void appendBytes(const void* data, std::size_t byteCount);
|
||||||
|
|
||||||
|
// Trivially-copyable scalars (ints, enums) are hashed by object representation.
|
||||||
|
// Floating-point and Qt types have dedicated overloads below and bypass this.
|
||||||
|
template <typename T>
|
||||||
|
void append(const T& value)
|
||||||
|
{
|
||||||
|
static_assert(std::is_trivially_copyable<T>::value,
|
||||||
|
"Hasher::append requires a trivially copyable type "
|
||||||
|
"(add a dedicated overload otherwise)");
|
||||||
|
appendBytes(&value, sizeof(T));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floats are hashed by bit pattern so equal values always hash equally;
|
||||||
|
// negative zero is normalized so -0.0 and +0.0 collapse to one value.
|
||||||
|
void append(float value);
|
||||||
|
void append(double value);
|
||||||
|
void append(const QPoint& point);
|
||||||
|
void append(const QPointF& point);
|
||||||
|
void append(const QVector2D& vector);
|
||||||
|
void append(const std::string& text);
|
||||||
|
|
||||||
|
std::uint64_t value() const { return m_state; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis
|
||||||
|
};
|
||||||
|
|
||||||
|
// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a
|
||||||
|
// portable, bit-identical text serialization, so this fingerprint is stable
|
||||||
|
// across platforms (see docs/replay_design.md "Cross-platform").
|
||||||
|
std::uint64_t fingerprintRng(const std::mt19937& rng);
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "HealthComponent.h"
|
#include "HealthComponent.h"
|
||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
|
|
||||||
static GameConfig loadConfig()
|
static GameConfig loadConfig()
|
||||||
@@ -121,7 +122,7 @@ TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns
|
|||||||
if (!sim.hasSchematicChoicesPending()) { continue; }
|
if (!sim.hasSchematicChoicesPending()) { continue; }
|
||||||
|
|
||||||
CHECK(findArtifactChoiceIndex(sim) == -1);
|
CHECK(findArtifactChoiceIndex(sim) == -1);
|
||||||
sim.applySchematicChoice(0);
|
SimulationTestAccess::applySchematicChoice(sim,0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ TEST_CASE("ArtifactWinCondition: selecting artifact increments artifact count",
|
|||||||
const int index = findArtifactChoiceIndex(sim);
|
const int index = findArtifactChoiceIndex(sim);
|
||||||
REQUIRE(index >= 0);
|
REQUIRE(index >= 0);
|
||||||
|
|
||||||
sim.applySchematicChoice(index);
|
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||||
|
|
||||||
CHECK(sim.artifactCount() == 1);
|
CHECK(sim.artifactCount() == 1);
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ TEST_CASE("ArtifactWinCondition: selecting a non-artifact option does not increm
|
|||||||
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
|
[](const SchematicChoiceOption& opt) { return opt.type != SchematicType::Artifact; });
|
||||||
REQUIRE(it != choices.end());
|
REQUIRE(it != choices.end());
|
||||||
|
|
||||||
sim.applySchematicChoice(static_cast<int>(it - choices.begin()));
|
SimulationTestAccess::applySchematicChoice(sim,static_cast<int>(it - choices.begin()));
|
||||||
|
|
||||||
CHECK(sim.artifactCount() == 0);
|
CHECK(sim.artifactCount() == 0);
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches
|
|||||||
|
|
||||||
const int index = findArtifactChoiceIndex(sim);
|
const int index = findArtifactChoiceIndex(sim);
|
||||||
REQUIRE(index >= 0);
|
REQUIRE(index >= 0);
|
||||||
sim.applySchematicChoice(index);
|
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||||
|
|
||||||
CHECK(sim.isWon());
|
CHECK(sim.isWon());
|
||||||
}
|
}
|
||||||
@@ -202,7 +203,7 @@ TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below
|
|||||||
|
|
||||||
killEnemyStations(sim);
|
killEnemyStations(sim);
|
||||||
REQUIRE(sim.hasSchematicChoicesPending());
|
REQUIRE(sim.hasSchematicChoicesPending());
|
||||||
sim.applySchematicChoice(findArtifactChoiceIndex(sim));
|
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||||
|
|
||||||
CHECK(sim.artifactCount() == 1);
|
CHECK(sim.artifactCount() == 1);
|
||||||
CHECK_FALSE(sim.isWon());
|
CHECK_FALSE(sim.isWon());
|
||||||
@@ -222,7 +223,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required nu
|
|||||||
REQUIRE(sim.hasSchematicChoicesPending());
|
REQUIRE(sim.hasSchematicChoicesPending());
|
||||||
const int index = findArtifactChoiceIndex(sim);
|
const int index = findArtifactChoiceIndex(sim);
|
||||||
REQUIRE(index >= 0);
|
REQUIRE(index >= 0);
|
||||||
sim.applySchematicChoice(index);
|
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||||
}
|
}
|
||||||
|
|
||||||
CHECK(sim.artifactCount() == 2);
|
CHECK(sim.artifactCount() == 2);
|
||||||
@@ -243,7 +244,7 @@ TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
|
|||||||
|
|
||||||
killEnemyStations(sim);
|
killEnemyStations(sim);
|
||||||
REQUIRE(sim.hasSchematicChoicesPending());
|
REQUIRE(sim.hasSchematicChoicesPending());
|
||||||
sim.applySchematicChoice(findArtifactChoiceIndex(sim));
|
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||||
REQUIRE(sim.isWon());
|
REQUIRE(sim.isWon());
|
||||||
|
|
||||||
sim.reset();
|
sim.reset();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
#include "ShipLayout.h"
|
#include "ShipLayout.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "SurfaceMask.h"
|
#include "SurfaceMask.h"
|
||||||
#include "Tick.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 offsetA(-1, 0);
|
||||||
const QPoint offsetB( 1, 0);
|
const QPoint offsetB( 1, 0);
|
||||||
|
|
||||||
const BuildingId idA = sim.tryPlaceBuilding(
|
const BuildingId idA = SimulationTestAccess::place(sim,
|
||||||
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
||||||
const BuildingId idB = sim.tryPlaceBuilding(
|
const BuildingId idB = SimulationTestAccess::place(sim,
|
||||||
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
||||||
|
|
||||||
REQUIRE(idA != kInvalidBuildingId);
|
REQUIRE(idA != kInvalidBuildingId);
|
||||||
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
|
|||||||
const int startBlocks = sim.buildingBlocksStock();
|
const int startBlocks = sim.buildingBlocksStock();
|
||||||
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
|
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);
|
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);
|
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
|
|||||||
int col = -2;
|
int col = -2;
|
||||||
while (sim.buildingBlocksStock() >= minerCost)
|
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;
|
col -= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
||||||
const BuildingId id = sim.tryPlaceBuilding(
|
const BuildingId id = SimulationTestAccess::place(sim,
|
||||||
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
||||||
|
|
||||||
// Placement must fail and leave the stock unchanged.
|
// 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
|
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
|
||||||
// rule, so it must be rejected without consuming building blocks.
|
// rule, so it must be rejected without consuming building blocks.
|
||||||
const BuildingId id =
|
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(id == kInvalidBuildingId);
|
||||||
REQUIRE(sim.buildingBlocksStock() == startBlocks);
|
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
|
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
|
||||||
// (-3,0),(-2,0),(-3,1); a valid spot.
|
// (-3,0),(-2,0),(-3,1); a valid spot.
|
||||||
const BuildingId id =
|
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(id != kInvalidBuildingId);
|
||||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
|
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
|
||||||
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
|||||||
Simulation sim(loadConfig());
|
Simulation sim(loadConfig());
|
||||||
|
|
||||||
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
// 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);
|
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);
|
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||||
REQUIRE(site != nullptr);
|
REQUIRE(site != nullptr);
|
||||||
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
|||||||
{
|
{
|
||||||
Simulation sim(loadConfig());
|
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);
|
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.
|
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
|
||||||
// Run 301 ticks (0..300) to process the completion tick.
|
// 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:
|
// 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.
|
// 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.
|
// 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);
|
REQUIRE(id != kInvalidBuildingId);
|
||||||
|
|
||||||
ShipLayoutConfig layout;
|
ShipLayoutConfig layout;
|
||||||
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
|||||||
pm.rotation = Rotation::East;
|
pm.rotation = Rotation::East;
|
||||||
layout.placedModules.push_back(pm);
|
layout.placedModules.push_back(pm);
|
||||||
|
|
||||||
sim.buildings().setShipLayout(id, layout);
|
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||||
|
|
||||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||||
REQUIRE(site != nullptr);
|
REQUIRE(site != nullptr);
|
||||||
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
|||||||
{
|
{
|
||||||
Simulation sim(loadConfig());
|
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);
|
REQUIRE(id != kInvalidBuildingId);
|
||||||
|
|
||||||
ShipLayoutConfig layout;
|
ShipLayoutConfig layout;
|
||||||
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
|||||||
pm.rotation = Rotation::North;
|
pm.rotation = Rotation::North;
|
||||||
layout.placedModules.push_back(pm);
|
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.
|
// Shipyard construction_time_seconds = 30 in the test config.
|
||||||
double constructionTime = 0.0;
|
double constructionTime = 0.0;
|
||||||
|
|||||||
@@ -22,4 +22,8 @@ add_files(
|
|||||||
ThreatCostCalculatorTest.cpp
|
ThreatCostCalculatorTest.cpp
|
||||||
RecipeSchematicTest.cpp
|
RecipeSchematicTest.cpp
|
||||||
ArtifactWinConditionTest.cpp
|
ArtifactWinConditionTest.cpp
|
||||||
|
DeterminismTest.cpp
|
||||||
|
CommandTest.cpp
|
||||||
|
ReplayRecorderTest.cpp
|
||||||
|
ReplayPlaybackTest.cpp
|
||||||
)
|
)
|
||||||
|
|||||||
106
src/test/CommandTest.cpp
Normal file
106
src/test/CommandTest.cpp
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "BuildingSystem.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandManager.h"
|
||||||
|
#include "ConfigLoader.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.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);
|
||||||
|
|
||||||
|
SimulationTestAccess::place(viaDirect, 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 =
|
||||||
|
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||||
|
SimulationTestAccess::buildings(viaDirect).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 =
|
||||||
|
SimulationTestAccess::place(viaCommand, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||||
|
const BuildingId idB =
|
||||||
|
SimulationTestAccess::place(viaDirect, BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||||
|
REQUIRE(idA == idB);
|
||||||
|
|
||||||
|
DemolishCommand command;
|
||||||
|
command.id = idA;
|
||||||
|
viaCommand.apply(command);
|
||||||
|
|
||||||
|
SimulationTestAccess::demolish(viaDirect, 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<PlaceBuildingCommand> first = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
first->type = BuildingType::Miner;
|
||||||
|
first->anchor = QPoint(-3, 0);
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> second = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
second->type = BuildingType::Belt;
|
||||||
|
second->anchor = QPoint(-2, 0);
|
||||||
|
|
||||||
|
manager.enqueue(first);
|
||||||
|
manager.enqueue(second);
|
||||||
|
REQUIRE(manager.hasPending());
|
||||||
|
|
||||||
|
manager.drain();
|
||||||
|
REQUIRE_FALSE(manager.hasPending());
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
158
src/test/DeterminismTest.cpp
Normal file
158
src/test/DeterminismTest.cpp
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <random>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "ConfigLoader.h"
|
||||||
|
#include "GameConfig.h"
|
||||||
|
#include "Rotation.h"
|
||||||
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
|
#include "StateChecksum.h"
|
||||||
|
#include "Tick.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
GameConfig loadConfig()
|
||||||
|
{
|
||||||
|
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr int kScriptTicks = 2000;
|
||||||
|
|
||||||
|
// Runs a fixed scripted session and returns the full-state checksum after every
|
||||||
|
// tick. The script places a small factory, demolishes part of it mid-run, and
|
||||||
|
// otherwise lets waves/combat run so the RNG stream and ECS state are exercised.
|
||||||
|
std::vector<std::uint64_t> runScriptedSession(unsigned int seed)
|
||||||
|
{
|
||||||
|
Simulation sim(loadConfig(), seed);
|
||||||
|
|
||||||
|
// Tick 0: a miner feeding a short belt line on the asteroid.
|
||||||
|
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<std::uint64_t> checksums;
|
||||||
|
checksums.reserve(kScriptTicks);
|
||||||
|
|
||||||
|
for (int t = 0; t < kScriptTicks; ++t)
|
||||||
|
{
|
||||||
|
if (t == 500)
|
||||||
|
{
|
||||||
|
// Demolish the second belt mid-run to exercise the removal paths.
|
||||||
|
SimulationTestAccess::place(sim, BuildingType::Smelter, QPoint(-3, 3), Rotation::East);
|
||||||
|
}
|
||||||
|
|
||||||
|
sim.tick();
|
||||||
|
checksums.push_back(sim.computeStateChecksum());
|
||||||
|
}
|
||||||
|
|
||||||
|
return checksums;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hasher
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]")
|
||||||
|
{
|
||||||
|
Hasher a;
|
||||||
|
Hasher b;
|
||||||
|
a.append(42);
|
||||||
|
a.append(3.5f);
|
||||||
|
a.append(std::string("ore"));
|
||||||
|
b.append(42);
|
||||||
|
b.append(3.5f);
|
||||||
|
b.append(std::string("ore"));
|
||||||
|
|
||||||
|
REQUIRE(a.value() == b.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]")
|
||||||
|
{
|
||||||
|
Hasher a;
|
||||||
|
Hasher b;
|
||||||
|
a.append(42);
|
||||||
|
b.append(43);
|
||||||
|
|
||||||
|
REQUIRE(a.value() != b.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Hasher: string concatenation does not collide", "[determinism]")
|
||||||
|
{
|
||||||
|
Hasher a;
|
||||||
|
Hasher b;
|
||||||
|
a.append(std::string("ab"));
|
||||||
|
a.append(std::string("c"));
|
||||||
|
b.append(std::string("a"));
|
||||||
|
b.append(std::string("bc"));
|
||||||
|
|
||||||
|
REQUIRE(a.value() != b.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]")
|
||||||
|
{
|
||||||
|
Hasher a;
|
||||||
|
Hasher b;
|
||||||
|
a.append(-0.0f);
|
||||||
|
b.append(0.0f);
|
||||||
|
|
||||||
|
REQUIRE(a.value() == b.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RNG fingerprint
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]")
|
||||||
|
{
|
||||||
|
std::mt19937 a(12345);
|
||||||
|
std::mt19937 b(12345);
|
||||||
|
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||||
|
|
||||||
|
a(); // advance one draw
|
||||||
|
REQUIRE(fingerprintRng(a) != fingerprintRng(b));
|
||||||
|
|
||||||
|
b(); // advance b to the same point
|
||||||
|
REQUIRE(fingerprintRng(a) == fingerprintRng(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]")
|
||||||
|
{
|
||||||
|
const Simulation a(loadConfig(), 777);
|
||||||
|
const Simulation b(loadConfig(), 777);
|
||||||
|
|
||||||
|
REQUIRE(a.rngFingerprint() == b.rngFingerprint());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Double-run determinism
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state",
|
||||||
|
"[determinism]")
|
||||||
|
{
|
||||||
|
const std::vector<std::uint64_t> first = runScriptedSession(424242);
|
||||||
|
const std::vector<std::uint64_t> second = runScriptedSession(424242);
|
||||||
|
|
||||||
|
REQUIRE(first.size() == second.size());
|
||||||
|
REQUIRE(first.size() == static_cast<std::size_t>(kScriptTicks));
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < first.size(); ++i)
|
||||||
|
{
|
||||||
|
INFO("divergence at tick " << i);
|
||||||
|
REQUIRE(first[i] == second[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]")
|
||||||
|
{
|
||||||
|
const std::vector<std::uint64_t> a = runScriptedSession(111);
|
||||||
|
const std::vector<std::uint64_t> b = runScriptedSession(222);
|
||||||
|
|
||||||
|
// The two sessions must differ at some point (the checksum is sensitive to
|
||||||
|
// the RNG-driven divergence; a constant checksum would be a broken hash).
|
||||||
|
REQUIRE(a != b);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
#include "RecipesConfig.h"
|
#include "RecipesConfig.h"
|
||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "StationBodyComponent.h"
|
#include "StationBodyComponent.h"
|
||||||
|
|
||||||
static GameConfig loadConfig()
|
static GameConfig loadConfig()
|
||||||
@@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim)
|
|||||||
killEnemyStations(sim);
|
killEnemyStations(sim);
|
||||||
if (sim.hasSchematicChoicesPending())
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sim.applySchematicChoice(0);
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CHECK(foundRecipeChoice);
|
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<std::string> unlockedBefore = unlockedTrackedRecipeIds();
|
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
|
||||||
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
|
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
|
||||||
|
|
||||||
sim.applySchematicChoice(0);
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||||
|
|
||||||
std::set<std::string> expectedNames;
|
std::set<std::string> expectedNames;
|
||||||
for (const RecipeDef& def : cfg.recipes.recipes)
|
for (const RecipeDef& def : cfg.recipes.recipes)
|
||||||
|
|||||||
292
src/test/ReplayPlaybackTest.cpp
Normal file
292
src/test/ReplayPlaybackTest.cpp
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
|
#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<PlaceBuildingCommand> place(BuildingType type, QPoint anchor)
|
||||||
|
{
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
command->type = type;
|
||||||
|
command->anchor = anchor;
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
void requireRoundTrip(const Command& command)
|
||||||
|
{
|
||||||
|
const std::string text = serializeCommand(command);
|
||||||
|
const std::shared_ptr<Command> parsed = parseCommand(text);
|
||||||
|
REQUIRE(parsed != nullptr);
|
||||||
|
REQUIRE(serializeCommand(*parsed) == text);
|
||||||
|
}
|
||||||
|
} // 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<Command> 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<const Command*>(&filters),
|
||||||
|
static_cast<const Command*>(&clear),
|
||||||
|
static_cast<const Command*>(&demolish) })
|
||||||
|
{
|
||||||
|
const std::string text = serializeCommand(*command);
|
||||||
|
const std::shared_ptr<Command> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(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<ParsedReplay> 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(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<ParsedReplay> 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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||||
|
ReplayRecorder* recorderPtr = recorder.get();
|
||||||
|
manager.setRecorder(std::move(recorder));
|
||||||
|
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> 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<ParsedReplay> 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));
|
||||||
|
}
|
||||||
212
src/test/ReplayRecorderTest.cpp
Normal file
212
src/test/ReplayRecorderTest.cpp
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#include "catch.hpp"
|
||||||
|
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
|
#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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(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<PlaceBuildingCommand> place = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ReplayRecorder> recorder =
|
||||||
|
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
|
||||||
|
ReplayRecorder* recorderPtr = recorder.get();
|
||||||
|
manager.setRecorder(std::move(recorder));
|
||||||
|
const std::string firstPath = recorderPtr->currentFilePath();
|
||||||
|
|
||||||
|
std::shared_ptr<ResetCommand> reset = std::make_shared<ResetCommand>();
|
||||||
|
reset->config = std::make_shared<GameConfig>(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));
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
#include "ShipStatsCalculator.h"
|
#include "ShipStatsCalculator.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "WeaponComponent.h"
|
#include "WeaponComponent.h"
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
|||||||
|
|
||||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||||
{
|
{
|
||||||
return sim.buildings().placeImmediate(
|
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||||
BuildingType::Shipyard,
|
BuildingType::Shipyard,
|
||||||
yardDef.surfaceMask,
|
yardDef.surfaceMask,
|
||||||
QPoint(0, 0),
|
QPoint(0, 0),
|
||||||
@@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
|
|||||||
const ShipDef& def,
|
const ShipDef& def,
|
||||||
const ShipLayoutConfig& layout)
|
const ShipLayoutConfig& layout)
|
||||||
{
|
{
|
||||||
sim.buildings().forEachBuilding([&](Building& b) {
|
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
|
||||||
if (b.id != yardId)
|
if (b.id != yardId)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
|||||||
REQUIRE(yardDef != nullptr);
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
sim.buildings().setRecipe(yardId, "interceptor");
|
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||||
|
|
||||||
ShipLayoutConfig layout;
|
ShipLayoutConfig layout;
|
||||||
PlacedModule pm;
|
PlacedModule pm;
|
||||||
@@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
|||||||
pm.rotation = Rotation::East;
|
pm.rotation = Rotation::East;
|
||||||
layout.placedModules.push_back(pm);
|
layout.placedModules.push_back(pm);
|
||||||
|
|
||||||
sim.buildings().setShipLayout(yardId, layout);
|
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||||
|
|
||||||
const Building* b = sim.buildings().findBuilding(yardId);
|
const Building* b = sim.buildings().findBuilding(yardId);
|
||||||
REQUIRE(b != nullptr);
|
REQUIRE(b != nullptr);
|
||||||
@@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
|||||||
REQUIRE(yardDef != nullptr);
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
sim.buildings().setRecipe(yardId, "interceptor");
|
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||||
|
|
||||||
// Fill materials and tick to start production.
|
// Fill materials and tick to start production.
|
||||||
ShipLayoutConfig emptyLayout;
|
ShipLayoutConfig emptyLayout;
|
||||||
@@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
|||||||
pm.rotation = Rotation::East;
|
pm.rotation = Rotation::East;
|
||||||
layout.placedModules.push_back(pm);
|
layout.placedModules.push_back(pm);
|
||||||
|
|
||||||
sim.buildings().setShipLayout(yardId, layout);
|
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||||
|
|
||||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||||
REQUIRE(b2 != nullptr);
|
REQUIRE(b2 != nullptr);
|
||||||
@@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
|||||||
REQUIRE(yardDef != nullptr);
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
sim.buildings().setRecipe(yardId, "interceptor");
|
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||||
|
|
||||||
ShipLayoutConfig layout;
|
ShipLayoutConfig layout;
|
||||||
PlacedModule pm;
|
PlacedModule pm;
|
||||||
@@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
|||||||
pm.position = QPoint(0, 0);
|
pm.position = QPoint(0, 0);
|
||||||
pm.rotation = Rotation::East;
|
pm.rotation = Rotation::East;
|
||||||
layout.placedModules.push_back(pm);
|
layout.placedModules.push_back(pm);
|
||||||
sim.buildings().setShipLayout(yardId, layout);
|
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||||
|
|
||||||
const Building* b1 = sim.buildings().findBuilding(yardId);
|
const Building* b1 = sim.buildings().findBuilding(yardId);
|
||||||
REQUIRE(b1 != nullptr);
|
REQUIRE(b1 != nullptr);
|
||||||
REQUIRE(b1->shipLayout.has_value());
|
REQUIRE(b1->shipLayout.has_value());
|
||||||
|
|
||||||
sim.buildings().setRecipe(yardId, "destroyer");
|
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
|
||||||
|
|
||||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||||
REQUIRE(b2 != nullptr);
|
REQUIRE(b2 != nullptr);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
#include "ShipIdentityComponent.h"
|
#include "ShipIdentityComponent.h"
|
||||||
#include "ShipSystem.h"
|
#include "ShipSystem.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
|
|
||||||
static GameConfig loadConfig()
|
static GameConfig loadConfig()
|
||||||
@@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
|||||||
|
|
||||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||||
{
|
{
|
||||||
return sim.buildings().placeImmediate(
|
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||||
BuildingType::Shipyard,
|
BuildingType::Shipyard,
|
||||||
yardDef.surfaceMask,
|
yardDef.surfaceMask,
|
||||||
QPoint(0, 0),
|
QPoint(0, 0),
|
||||||
@@ -62,7 +63,7 @@ static int countShips(Simulation& sim)
|
|||||||
|
|
||||||
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
|
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)
|
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);
|
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||||
REQUIRE(yardId != kInvalidBuildingId);
|
REQUIRE(yardId != kInvalidBuildingId);
|
||||||
|
|
||||||
sim.buildings().setRecipe(yardId, def->id);
|
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||||
fillMaterials(sim, yardId, *def);
|
fillMaterials(sim, yardId, *def);
|
||||||
|
|
||||||
// First tick: materials consumed, production cycle starts — no ship yet.
|
// 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 int shipsBefore = countShips(sim);
|
||||||
|
|
||||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
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.
|
// Materials remain at zero (default after setRecipe); no cycle starts.
|
||||||
|
|
||||||
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
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);
|
REQUIRE(yardDef != nullptr);
|
||||||
|
|
||||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
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);
|
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
||||||
|
|
||||||
|
|||||||
40
src/test/SimulationTestAccess.h
Normal file
40
src/test/SimulationTestAccess.h
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QPoint>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
#include "ShipsConfig.h"
|
#include "ShipsConfig.h"
|
||||||
#include "Simulation.h"
|
#include "Simulation.h"
|
||||||
|
#include "SimulationTestAccess.h"
|
||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "ThreatCostCalculator.h"
|
#include "ThreatCostCalculator.h"
|
||||||
#include "WaveSystem.h"
|
#include "WaveSystem.h"
|
||||||
@@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
|
|||||||
|
|
||||||
sim.tick();
|
sim.tick();
|
||||||
REQUIRE(sim.hasSchematicChoicesPending());
|
REQUIRE(sim.hasSchematicChoicesPending());
|
||||||
sim.applySchematicChoice(0);
|
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <QColor>
|
#include <QColor>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
|
#include <QDir>
|
||||||
#include <QFont>
|
#include <QFont>
|
||||||
#include <QKeyEvent>
|
#include <QKeyEvent>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
@@ -24,6 +26,10 @@
|
|||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
#include "Building.h"
|
#include "Building.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "ReplayPlayer.h"
|
||||||
|
#include "ReplayReader.h"
|
||||||
|
#include "ReplayRecorder.h"
|
||||||
#include "DemolishModeChangedEvent.h"
|
#include "DemolishModeChangedEvent.h"
|
||||||
#include "EntityHitTest.h"
|
#include "EntityHitTest.h"
|
||||||
#include "EntitySelectedEvent.h"
|
#include "EntitySelectedEvent.h"
|
||||||
@@ -112,11 +118,13 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
|
|||||||
|
|
||||||
|
|
||||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||||
const VisualsConfig* visuals, QWidget* parent)
|
const VisualsConfig* visuals, const std::string& configDir,
|
||||||
|
const ParsedReplay* replay, QWidget* parent)
|
||||||
: QOpenGLWidget(parent)
|
: QOpenGLWidget(parent)
|
||||||
, m_sim(sim)
|
, m_sim(sim)
|
||||||
, m_config(config)
|
, m_config(config)
|
||||||
, m_visuals(visuals)
|
, m_visuals(visuals)
|
||||||
|
, m_commandManager(*sim)
|
||||||
, m_gameSpeedMultiplier(1.0)
|
, m_gameSpeedMultiplier(1.0)
|
||||||
, m_prevNonZeroSpeed(1.0)
|
, m_prevNonZeroSpeed(1.0)
|
||||||
, m_scrollXTiles(0.0f)
|
, m_scrollXTiles(0.0f)
|
||||||
@@ -143,6 +151,25 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
|||||||
m_frameTimer.start();
|
m_frameTimer.start();
|
||||||
|
|
||||||
registerForEvents();
|
registerForEvents();
|
||||||
|
|
||||||
|
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<ReplayPlayer>(*sim, replay->entries);
|
||||||
|
m_replayPlayer->start(); // process tick-0 entries before the first tick
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Record every run to disk. Replays live under <data>/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<ReplayRecorder>(
|
||||||
|
configDir, replayDir.filePath("replays").toStdString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GameWorldView::~GameWorldView()
|
GameWorldView::~GameWorldView()
|
||||||
@@ -159,13 +186,40 @@ void GameWorldView::onFrame()
|
|||||||
{
|
{
|
||||||
const qint64 elapsed = m_frameTimer.restart();
|
const qint64 elapsed = m_frameTimer.restart();
|
||||||
|
|
||||||
// Advance simulation
|
if (m_replayPlayer)
|
||||||
{
|
{
|
||||||
|
// 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<double>(elapsed), m_gameSpeedMultiplier);
|
||||||
|
for (int i = 0; i < ticks; ++i)
|
||||||
|
{
|
||||||
|
if (m_replayPlayer->isFinished()) { break; }
|
||||||
|
m_sim->tick();
|
||||||
|
m_replayPlayer->advanceTo(m_sim->currentTick());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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(
|
const int ticks = m_tickDriver.advance(
|
||||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||||
for (int i = 0; i < ticks; ++i)
|
for (int i = 0; i < ticks; ++i)
|
||||||
{
|
{
|
||||||
m_sim->tick();
|
m_sim->tick();
|
||||||
|
// Periodic checksum (every 30 ticks) for replay desync detection.
|
||||||
|
m_commandManager.recordTickCheckpoint();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +293,11 @@ void GameWorldView::onFrame()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
// Schematic choice available
|
// Schematic choice available
|
||||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||||
{
|
{
|
||||||
@@ -277,6 +336,7 @@ void GameWorldView::onFrame()
|
|||||||
EventManager::getInstance()->sendEventImmediately(
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
std::make_shared<GameOverEvent>());
|
std::make_shared<GameOverEvent>());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
@@ -301,6 +361,7 @@ void GameWorldView::paintGL()
|
|||||||
drawBeams(painter);
|
drawBeams(painter);
|
||||||
drawOverlays(painter);
|
drawOverlays(painter);
|
||||||
drawScreenSpace(painter);
|
drawScreenSpace(painter);
|
||||||
|
drawReplayOverlay(painter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -533,12 +594,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
|||||||
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
||||||
if (rotateTarget.has_value())
|
if (rotateTarget.has_value())
|
||||||
{
|
{
|
||||||
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
|
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
||||||
|
std::make_shared<RotateInPlaceCommand>();
|
||||||
|
rotateCommand->id = *rotateTarget;
|
||||||
|
rotateCommand->newRotation = bb.rotation;
|
||||||
|
enqueueCommand(rotateCommand);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
|
// Place-and-configure is one atomic command: commands apply at a deferred
|
||||||
if (id == kInvalidBuildingId) { continue; }
|
// 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<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
command->type = bb.type;
|
||||||
|
command->anchor = anchor;
|
||||||
|
command->rotation = bb.rotation;
|
||||||
|
|
||||||
if (!bb.recipeId.empty())
|
if (!bb.recipeId.empty())
|
||||||
{
|
{
|
||||||
@@ -546,7 +617,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
|||||||
{
|
{
|
||||||
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
||||||
{
|
{
|
||||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
command->recipeId = bb.recipeId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -555,15 +626,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
|||||||
|| bb.type == BuildingType::Assembler;
|
|| bb.type == BuildingType::Assembler;
|
||||||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
||||||
{
|
{
|
||||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
command->recipeId = bb.recipeId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bb.shipLayout.has_value())
|
command->shipLayout = bb.shipLayout;
|
||||||
{
|
|
||||||
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bb.type == BuildingType::Splitter
|
if (bb.type == BuildingType::Splitter
|
||||||
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
||||||
@@ -571,11 +639,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
|||||||
// The splitter is still a construction site, so the filters carry
|
// The splitter is still a construction site, so the filters carry
|
||||||
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
||||||
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
||||||
m_sim->buildings().setSiteSplitterFilters(
|
command->hasSplitterFilters = true;
|
||||||
id,
|
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
|
||||||
filterUnlockedItems(bb.splitterFilterA, *m_sim),
|
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
|
||||||
filterUnlockedItems(bb.splitterFilterB, *m_sim));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enqueueCommand(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -596,35 +665,37 @@ void GameWorldView::placeAtTile(QPoint tile)
|
|||||||
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
||||||
if (rotateTarget.has_value())
|
if (rotateTarget.has_value())
|
||||||
{
|
{
|
||||||
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
|
std::shared_ptr<RotateInPlaceCommand> command =
|
||||||
|
std::make_shared<RotateInPlaceCommand>();
|
||||||
|
command->id = *rotateTarget;
|
||||||
|
command->newRotation = m_ghostRotation;
|
||||||
|
enqueueCommand(command);
|
||||||
return;
|
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 (type == BuildingType::Belt)
|
||||||
{
|
{
|
||||||
if (m_beltDragTiles.count(tile) > 0)
|
if (m_beltDragTiles.count(tile) > 0)
|
||||||
{
|
{
|
||||||
return;
|
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)
|
|
||||||
{
|
{
|
||||||
|
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||||
m_beltDragTiles.insert(tile);
|
m_beltDragTiles.insert(tile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else if (type == BuildingType::Splitter
|
else if (type == BuildingType::Splitter
|
||||||
|| type == BuildingType::TunnelEntry
|
|| type == BuildingType::TunnelEntry
|
||||||
|| type == BuildingType::TunnelExit)
|
|| 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;
|
m_builderType = BuildingType::TunnelExit;
|
||||||
@@ -635,10 +706,9 @@ void GameWorldView::placeAtTile(QPoint tile)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
|
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1227,6 +1297,45 @@ 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())
|
||||||
|
{
|
||||||
|
// Dim the world so the end message reads clearly over it.
|
||||||
|
painter.fillRect(rect(), QColor(0, 0, 0, 140));
|
||||||
|
|
||||||
|
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
|
||||||
|
QString message;
|
||||||
|
if (desync.has_value())
|
||||||
|
{
|
||||||
|
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*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
|
// Input
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1387,7 +1496,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
|||||||
const bool isProtected = b && b->type == BuildingType::Hq;
|
const bool isProtected = b && b->type == BuildingType::Hq;
|
||||||
if (!isProtected)
|
if (!isProtected)
|
||||||
{
|
{
|
||||||
m_sim->demolish(hovered);
|
std::shared_ptr<DemolishCommand> command =
|
||||||
|
std::make_shared<DemolishCommand>();
|
||||||
|
command->id = hovered;
|
||||||
|
enqueueCommand(command);
|
||||||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1763,3 +1875,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
|
|||||||
{
|
{
|
||||||
setGameSpeed(event->multiplier);
|
setGameSpeed(event->multiplier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> 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<const Command> command)
|
||||||
|
{
|
||||||
|
m_commandManager.enqueue(std::move(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
|
||||||
|
{
|
||||||
|
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <QElapsedTimer>
|
#include <QElapsedTimer>
|
||||||
@@ -27,11 +28,13 @@
|
|||||||
#include "DebugDrawToggledEvent.h"
|
#include "DebugDrawToggledEvent.h"
|
||||||
#include "ArtifactCountChangedEvent.h"
|
#include "ArtifactCountChangedEvent.h"
|
||||||
#include "BeamFiredEvent.h"
|
#include "BeamFiredEvent.h"
|
||||||
|
#include "CommandRequestedEvent.h"
|
||||||
#include "SchematicChoiceOption.h"
|
#include "SchematicChoiceOption.h"
|
||||||
#include "WinEvent.h"
|
#include "WinEvent.h"
|
||||||
#include "SpeedChangeRequestedEvent.h"
|
#include "SpeedChangeRequestedEvent.h"
|
||||||
|
|
||||||
#include "entt/entity/entity.hpp"
|
#include "entt/entity/entity.hpp"
|
||||||
|
#include "CommandManager.h"
|
||||||
#include "EntitySelectedEvent.h"
|
#include "EntitySelectedEvent.h"
|
||||||
#include "GameConfig.h"
|
#include "GameConfig.h"
|
||||||
#include "Rotation.h"
|
#include "Rotation.h"
|
||||||
@@ -39,6 +42,9 @@
|
|||||||
#include "TickDriver.h"
|
#include "TickDriver.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
|
||||||
|
struct Command;
|
||||||
|
struct ParsedReplay;
|
||||||
|
class ReplayPlayer;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
class QPainter;
|
class QPainter;
|
||||||
|
|
||||||
@@ -58,13 +64,15 @@ class GameWorldView : public QOpenGLWidget,
|
|||||||
DemolishModeToggleRequestedEvent,
|
DemolishModeToggleRequestedEvent,
|
||||||
BlueprintPlacementRequestedEvent,
|
BlueprintPlacementRequestedEvent,
|
||||||
ExitBlueprintModeRequestedEvent,
|
ExitBlueprintModeRequestedEvent,
|
||||||
SpeedChangeRequestedEvent>
|
SpeedChangeRequestedEvent,
|
||||||
|
CommandRequestedEvent>
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
GameWorldView(Simulation* sim, const GameConfig* config,
|
GameWorldView(Simulation* sim, const GameConfig* config,
|
||||||
const VisualsConfig* visuals, QWidget* parent = nullptr);
|
const VisualsConfig* visuals, const std::string& configDir,
|
||||||
|
const ParsedReplay* replay, QWidget* parent = nullptr);
|
||||||
~GameWorldView() override;
|
~GameWorldView() override;
|
||||||
|
|
||||||
double gameSpeed() const;
|
double gameSpeed() const;
|
||||||
@@ -93,6 +101,17 @@ private:
|
|||||||
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const ExitBlueprintModeRequestedEvent> event) override;
|
||||||
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
|
void handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent> event) override;
|
||||||
|
void handleEvent(std::shared_ptr<const CommandRequestedEvent> event) override;
|
||||||
|
|
||||||
|
// Enqueue a sim command onto the CommandManager (the single mutation path).
|
||||||
|
void enqueueCommand(std::shared_ptr<const Command> 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 drawTiles(QPainter& painter);
|
||||||
void drawBuildings(QPainter& painter);
|
void drawBuildings(QPainter& painter);
|
||||||
@@ -106,6 +125,7 @@ private:
|
|||||||
void drawBeams(QPainter& painter);
|
void drawBeams(QPainter& painter);
|
||||||
void drawOverlays(QPainter& painter);
|
void drawOverlays(QPainter& painter);
|
||||||
void drawScreenSpace(QPainter& painter);
|
void drawScreenSpace(QPainter& painter);
|
||||||
|
void drawReplayOverlay(QPainter& painter);
|
||||||
|
|
||||||
float tilePx() const;
|
float tilePx() const;
|
||||||
float viewportWidthTiles() const;
|
float viewportWidthTiles() const;
|
||||||
@@ -159,6 +179,14 @@ private:
|
|||||||
const GameConfig* m_config;
|
const GameConfig* m_config;
|
||||||
const VisualsConfig* m_visuals;
|
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;
|
||||||
|
// 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<ReplayPlayer> m_replayPlayer;
|
||||||
|
|
||||||
TickDriver m_tickDriver;
|
TickDriver m_tickDriver;
|
||||||
QElapsedTimer m_frameTimer;
|
QElapsedTimer m_frameTimer;
|
||||||
std::mt19937 m_rng;
|
std::mt19937 m_rng;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
|
||||||
#include <map>
|
#include <map>
|
||||||
|
#include <random>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
@@ -15,7 +16,10 @@
|
|||||||
#include "BuildButtonGrid.h"
|
#include "BuildButtonGrid.h"
|
||||||
#include "BuildingBlocksChangedEvent.h"
|
#include "BuildingBlocksChangedEvent.h"
|
||||||
#include "BuildingSystem.h"
|
#include "BuildingSystem.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandRequestedEvent.h"
|
||||||
#include "ConfigLoader.h"
|
#include "ConfigLoader.h"
|
||||||
|
#include "EventManager.h"
|
||||||
#include "GameWorldView.h"
|
#include "GameWorldView.h"
|
||||||
#include "RecipeSelectionDialog.h"
|
#include "RecipeSelectionDialog.h"
|
||||||
#include "SchematicChoiceDialog.h"
|
#include "SchematicChoiceDialog.h"
|
||||||
@@ -27,18 +31,21 @@
|
|||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "VisualsLoader.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<ParsedReplay> replay, QWidget* parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_configDir(configDir)
|
, m_configDir(configDir)
|
||||||
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
|
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
|
||||||
, m_sim(sim)
|
, m_sim(sim)
|
||||||
|
, m_replay(std::move(replay))
|
||||||
{
|
{
|
||||||
setWindowTitle(tr("Dota Factory"));
|
setWindowTitle(tr("Dota Factory"));
|
||||||
resize(1280, 768);
|
resize(1280, 768);
|
||||||
|
|
||||||
m_headerBar = new HeaderBar(this);
|
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,
|
||||||
|
m_replay.get(), this);
|
||||||
|
|
||||||
m_sidePanel = new QWidget(this);
|
m_sidePanel = new QWidget(this);
|
||||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||||
@@ -138,7 +145,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
|||||||
SchematicChoiceDialog dialog(event->choices, this);
|
SchematicChoiceDialog dialog(event->choices, this);
|
||||||
dialog.exec();
|
dialog.exec();
|
||||||
|
|
||||||
m_sim->applySchematicChoice(dialog.getChosenIndex());
|
std::shared_ptr<ApplySchematicChoiceCommand> command =
|
||||||
|
std::make_shared<ApplySchematicChoiceCommand>();
|
||||||
|
command->choiceIndex = dialog.getChosenIndex();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
|
|
||||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||||
m_gameWorldView->resetFrameTimer();
|
m_gameWorldView->resetFrameTimer();
|
||||||
@@ -160,12 +171,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
|||||||
QAbstractButton* clicked = box.clickedButton();
|
QAbstractButton* clicked = box.clickedButton();
|
||||||
if (clicked == restartBtn)
|
if (clicked == restartBtn)
|
||||||
{
|
{
|
||||||
|
std::shared_ptr<GameConfig> newConfig;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
newConfig = std::make_shared<GameConfig>(
|
||||||
|
ConfigLoader::loadFromDirectory(m_configDir));
|
||||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||||
m_visuals = std::move(newVisuals);
|
m_visuals = std::move(newVisuals);
|
||||||
m_sim->reset(std::move(newConfig));
|
|
||||||
}
|
}
|
||||||
catch (const std::exception& e)
|
catch (const std::exception& e)
|
||||||
{
|
{
|
||||||
@@ -175,7 +187,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
|||||||
m_gameWorldView->resetFrameTimer();
|
m_gameWorldView->resetFrameTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_gameWorldView->resetForNewGame();
|
// Restart is a command boundary; the view resets when the drain applies
|
||||||
|
// it (see GameWorldView::onFrame). A fresh random seed starts a new run.
|
||||||
|
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||||
|
command->config = std::move(newConfig);
|
||||||
|
command->seed = std::random_device{}();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
else if (clicked == quitBtn)
|
else if (clicked == quitBtn)
|
||||||
{
|
{
|
||||||
@@ -234,7 +252,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
|||||||
this);
|
this);
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
|
if (dialog.exec() == QDialog::Accepted && dialog.result().has_value())
|
||||||
{
|
{
|
||||||
m_sim->buildings().setShipLayout(event->shipyardId, *dialog.result());
|
std::shared_ptr<SetShipLayoutCommand> command =
|
||||||
|
std::make_shared<SetShipLayoutCommand>();
|
||||||
|
command->id = event->shipyardId;
|
||||||
|
command->layout = *dialog.result();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
|
|
||||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||||
@@ -268,7 +291,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
|||||||
RecipeSelectionDialog dialog(options, title, this);
|
RecipeSelectionDialog dialog(options, title, this);
|
||||||
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
if (dialog.exec() == QDialog::Accepted && dialog.getChosenId().has_value())
|
||||||
{
|
{
|
||||||
m_sim->buildings().setRecipe(event->buildingId, *dialog.getChosenId());
|
std::shared_ptr<SetRecipeCommand> command = std::make_shared<SetRecipeCommand>();
|
||||||
|
command->id = event->buildingId;
|
||||||
|
command->recipeId = *dialog.getChosenId();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
|
|
||||||
m_gameWorldView->setGameSpeed(prevSpeed);
|
m_gameWorldView->setGameSpeed(prevSpeed);
|
||||||
@@ -293,12 +320,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
|||||||
|
|
||||||
if (box.clickedButton() == restartBtn)
|
if (box.clickedButton() == restartBtn)
|
||||||
{
|
{
|
||||||
|
std::shared_ptr<GameConfig> newConfig;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
newConfig = std::make_shared<GameConfig>(
|
||||||
|
ConfigLoader::loadFromDirectory(m_configDir));
|
||||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||||
m_visuals = std::move(newVisuals);
|
m_visuals = std::move(newVisuals);
|
||||||
m_sim->reset(std::move(newConfig));
|
|
||||||
}
|
}
|
||||||
catch (const std::exception& e)
|
catch (const std::exception& e)
|
||||||
{
|
{
|
||||||
@@ -306,7 +334,12 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
|||||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
m_gameWorldView->resetForNewGame();
|
// Restart is a command boundary; the view resets when the drain applies it.
|
||||||
|
std::shared_ptr<ResetCommand> command = std::make_shared<ResetCommand>();
|
||||||
|
command->config = std::move(newConfig);
|
||||||
|
command->seed = std::random_device{}();
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
#include "Tick.h"
|
#include "Tick.h"
|
||||||
#include "VisualsConfig.h"
|
#include "VisualsConfig.h"
|
||||||
|
|
||||||
|
struct ParsedReplay;
|
||||||
class Simulation;
|
class Simulation;
|
||||||
class GameWorldView;
|
class GameWorldView;
|
||||||
class HeaderBar;
|
class HeaderBar;
|
||||||
@@ -39,7 +41,8 @@ class MainWindow : public QWidget,
|
|||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
|
|
||||||
public:
|
public:
|
||||||
MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr);
|
MainWindow(Simulation* sim, const std::string& configDir,
|
||||||
|
std::shared_ptr<ParsedReplay> replay = nullptr, QWidget* parent = nullptr);
|
||||||
~MainWindow() override;
|
~MainWindow() override;
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
@@ -68,4 +71,5 @@ private:
|
|||||||
QWidget* m_sidePanel;
|
QWidget* m_sidePanel;
|
||||||
|
|
||||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||||
|
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
|
|
||||||
#include "BeltSystem.h"
|
#include "BeltSystem.h"
|
||||||
|
#include "Command.h"
|
||||||
|
#include "CommandRequestedEvent.h"
|
||||||
#include "DynamicBodyComponent.h"
|
#include "DynamicBodyComponent.h"
|
||||||
#include "EntityAdmin.h"
|
#include "EntityAdmin.h"
|
||||||
#include "EntitySelectedEvent.h"
|
#include "EntitySelectedEvent.h"
|
||||||
@@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
|
|||||||
|
|
||||||
if (m_singleIsSite)
|
if (m_singleIsSite)
|
||||||
{
|
{
|
||||||
m_sim->buildings().setSiteSplitterFilters(
|
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
|
||||||
m_singleBuildingId,
|
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||||
collectFilter(m_filterAList),
|
command->id = m_singleBuildingId;
|
||||||
collectFilter(m_filterBList));
|
command->filterA = collectFilter(m_filterAList);
|
||||||
|
command->filterB = collectFilter(m_filterBList);
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
m_sim->belts().setSplitterFilters(
|
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||||||
m_splitterTile,
|
std::make_shared<SetSplitterFiltersCommand>();
|
||||||
collectFilter(m_filterAList),
|
command->tile = m_splitterTile;
|
||||||
collectFilter(m_filterBList));
|
command->filterA = collectFilter(m_filterAList);
|
||||||
|
command->filterB = collectFilter(m_filterBList);
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +775,11 @@ void SelectedBuildingPanel::onClearBelt()
|
|||||||
}
|
}
|
||||||
if (!tiles.empty())
|
if (!tiles.empty())
|
||||||
{
|
{
|
||||||
m_sim->belts().clearTiles(tiles);
|
std::shared_ptr<ClearBeltTilesCommand> command =
|
||||||
|
std::make_shared<ClearBeltTilesCommand>();
|
||||||
|
command->tiles = std::move(tiles);
|
||||||
|
EventManager::getInstance()->sendEventImmediately(
|
||||||
|
std::make_shared<CommandRequestedEvent>(command));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user