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 <optional>
|
||||
#include <random>
|
||||
#include <string>
|
||||
|
||||
#include <QApplication>
|
||||
#include <QDir>
|
||||
@@ -8,6 +11,8 @@
|
||||
#include "logging.h"
|
||||
#include "LogManager.h"
|
||||
#include "MainWindow.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "Simulation.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
@@ -31,10 +36,54 @@ int main(int argc, char *argv[])
|
||||
QDir().mkdir(dataDir.dirName());
|
||||
}
|
||||
|
||||
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
|
||||
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
|
||||
// Optional "--replay <file>" launches view-only playback of a recorded run.
|
||||
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();
|
||||
|
||||
const int ret = application.exec();
|
||||
|
||||
@@ -29,6 +29,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
|
||||
PARENT_SCOPE
|
||||
)
|
||||
|
||||
|
||||
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 "StateChecksum.h"
|
||||
#include "Tick.h"
|
||||
#include "tracing.h"
|
||||
|
||||
@@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
|
||||
}
|
||||
}
|
||||
|
||||
void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun)
|
||||
{
|
||||
hasher.append(slotRun.size());
|
||||
for (const BeltItemSlot& slot : slotRun)
|
||||
{
|
||||
hasher.append(slot.item.type.id);
|
||||
hasher.append(slot.progress);
|
||||
}
|
||||
}
|
||||
|
||||
void BeltSystem::appendChecksum(Hasher& hasher) const
|
||||
{
|
||||
// std::map iterates in sorted key order, so all tile loops are deterministic.
|
||||
hasher.append(m_belts.size());
|
||||
for (const std::pair<const std::pair<int, int>, BeltTile>& entry : m_belts)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
hasher.append(m_splitters.size());
|
||||
for (const std::pair<const std::pair<int, int>, SplitterTile>& entry : m_splitters)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
const SplitterTile& s = entry.second;
|
||||
hasher.append(s.outputA);
|
||||
hasher.append(s.outputB);
|
||||
hasher.append(s.filterA.size());
|
||||
for (const ItemType& type : s.filterA) { hasher.append(type.id); }
|
||||
hasher.append(s.filterB.size());
|
||||
for (const ItemType& type : s.filterB) { hasher.append(type.id); }
|
||||
hasher.append(s.nextOutputIsA);
|
||||
appendItemSlots(hasher, s.back);
|
||||
hasher.append(s.backDir.size());
|
||||
for (Rotation dir : s.backDir) { hasher.append(dir); }
|
||||
hasher.append(s.frontA.has_value());
|
||||
if (s.frontA.has_value())
|
||||
{
|
||||
hasher.append(s.frontA->item.type.id);
|
||||
hasher.append(s.frontA->progress);
|
||||
}
|
||||
hasher.append(s.frontB.has_value());
|
||||
if (s.frontB.has_value())
|
||||
{
|
||||
hasher.append(s.frontB->item.type.id);
|
||||
hasher.append(s.frontB->progress);
|
||||
}
|
||||
}
|
||||
|
||||
hasher.append(m_tunnelEntries.size());
|
||||
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
hasher.append(entry.second.maxDistance);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
hasher.append(m_tunnelExits.size());
|
||||
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second.direction);
|
||||
appendItemSlots(hasher, entry.second.itemSlots);
|
||||
}
|
||||
|
||||
// m_tunnelLinks preserves insertion order, which is itself deterministic.
|
||||
hasher.append(m_tunnelLinks.size());
|
||||
for (const TunnelLink& link : m_tunnelLinks)
|
||||
{
|
||||
hasher.append(link.entryTile);
|
||||
hasher.append(link.exitTile);
|
||||
hasher.append(link.length);
|
||||
hasher.append(link.items.size());
|
||||
for (const TunnelTransitItem& item : link.items)
|
||||
{
|
||||
hasher.append(item.item.type.id);
|
||||
hasher.append(item.progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#include "Port.h"
|
||||
#include "Rotation.h"
|
||||
|
||||
class Hasher;
|
||||
|
||||
// Carries item type and fractional world position for the renderer.
|
||||
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
|
||||
struct VisualItem
|
||||
@@ -92,6 +94,11 @@ public:
|
||||
void forEachVisualItem(QRect viewportTiles,
|
||||
std::function<void(VisualItem)> visit) const;
|
||||
|
||||
// -- Determinism ---------------------------------------------------------
|
||||
// Folds all transport state (belt/splitter/tunnel tiles and their items)
|
||||
// into the hasher in deterministic order (see docs/replay_design.md).
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
void advanceProgress();
|
||||
void advanceTunnelProgress();
|
||||
@@ -170,6 +177,9 @@ private:
|
||||
std::vector<TunnelTransitItem> items; // front (highest progress) to back
|
||||
};
|
||||
|
||||
// Folds a run of item slots (front-to-back order is canonical) into the hasher.
|
||||
static void appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun);
|
||||
|
||||
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
|
||||
|
||||
std::map<std::pair<int, int>, BeltTile> m_belts;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
#include "StateChecksum.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "tracing.h"
|
||||
|
||||
@@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
|
||||
m_tileOccupancy.erase({cell.x(), cell.y()});
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
void appendItems(Hasher& hasher, const std::vector<Item>& items)
|
||||
{
|
||||
hasher.append(items.size());
|
||||
for (const Item& item : items)
|
||||
{
|
||||
hasher.append(item.type.id);
|
||||
}
|
||||
}
|
||||
|
||||
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
|
||||
{
|
||||
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
|
||||
hasher.append(buffer.counts.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.counts)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
hasher.append(buffer.caps.size());
|
||||
for (const std::pair<const ItemType, int>& entry : buffer.caps)
|
||||
{
|
||||
hasher.append(entry.first.id);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void BuildingSystem::appendChecksum(Hasher& hasher) const
|
||||
{
|
||||
// m_buildings keeps a stable, deterministic order (append on build, swap-free
|
||||
// erase aside — both runs perform identical operations, so order matches).
|
||||
hasher.append(m_buildings.size());
|
||||
for (const Building& b : m_buildings)
|
||||
{
|
||||
hasher.append(b.id);
|
||||
hasher.append(b.anchor);
|
||||
hasher.append(b.footprint.width());
|
||||
hasher.append(b.footprint.height());
|
||||
hasher.append(b.rotation);
|
||||
hasher.append(b.type);
|
||||
hasher.append(b.recipeId);
|
||||
appendInputBuffer(hasher, b.inputBuffer);
|
||||
appendItems(hasher, b.outputBuffer.items);
|
||||
hasher.append(b.outputBuffer.capacity);
|
||||
hasher.append(b.production.has_value());
|
||||
if (b.production.has_value())
|
||||
{
|
||||
hasher.append(b.production->recipeId);
|
||||
hasher.append(b.production->completesAt);
|
||||
appendItems(hasher, b.production->chosenOutputs);
|
||||
}
|
||||
hasher.append(b.shipLayout.has_value());
|
||||
}
|
||||
|
||||
hasher.append(m_constructionQueue.size());
|
||||
for (const ConstructionSite& s : m_constructionQueue)
|
||||
{
|
||||
hasher.append(s.id);
|
||||
hasher.append(s.anchor);
|
||||
hasher.append(s.footprint.width());
|
||||
hasher.append(s.footprint.height());
|
||||
hasher.append(s.rotation);
|
||||
hasher.append(s.type);
|
||||
hasher.append(s.recipeId);
|
||||
hasher.append(s.completesAt);
|
||||
hasher.append(s.shipLayout.has_value());
|
||||
hasher.append(s.splitterFilterA.size());
|
||||
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
|
||||
hasher.append(s.splitterFilterB.size());
|
||||
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
|
||||
}
|
||||
|
||||
// std::map iterates in sorted key order.
|
||||
hasher.append(m_tileOccupancy.size());
|
||||
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
|
||||
{
|
||||
hasher.append(entry.first.first);
|
||||
hasher.append(entry.first.second);
|
||||
hasher.append(entry.second);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
#include "ShipsConfig.h"
|
||||
#include "Tick.h"
|
||||
|
||||
class Hasher;
|
||||
|
||||
// Manages building placement, construction queuing, and the per-tick
|
||||
// production loop (belt→building pull, production, building→belt push).
|
||||
// All types including Belt and Splitter are stored as Building instances;
|
||||
@@ -151,6 +153,11 @@ public:
|
||||
// Mutable iteration over all operational buildings.
|
||||
void forEachBuilding(std::function<void(Building&)> fn);
|
||||
|
||||
// -- Determinism ---------------------------------------------------------
|
||||
// Folds all building, construction-site, and tile-occupancy state into the
|
||||
// hasher in deterministic order (see docs/replay_design.md).
|
||||
void appendChecksum(Hasher& hasher) const;
|
||||
|
||||
private:
|
||||
const BuildingDef* findBuildingDef(BuildingType type) const;
|
||||
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
SET(HDRS
|
||||
${HDRS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
|
||||
@@ -9,6 +15,7 @@ SET(HDRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
|
||||
PARENT_SCOPE
|
||||
@@ -17,11 +24,17 @@ SET(HDRS
|
||||
SET(SRCS
|
||||
${SRCS}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
|
||||
PARENT_SCOPE
|
||||
|
||||
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 "AiSystem.h"
|
||||
#include "Command.h"
|
||||
#include "DisplayName.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "CombatSystem.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "DynamicBodySystem.h"
|
||||
#include "FacingComponent.h"
|
||||
#include "FactionComponent.h"
|
||||
#include "EventManager.h"
|
||||
#include "HealthComponent.h"
|
||||
@@ -16,9 +19,11 @@
|
||||
#include "PositionComponent.h"
|
||||
#include "RepairSystem.h"
|
||||
#include "SalvagerSystem.h"
|
||||
#include "ScrapDataComponent.h"
|
||||
#include "ScrapSystem.h"
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "StateChecksum.h"
|
||||
#include "StationBodyComponent.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "tracing.h"
|
||||
@@ -28,6 +33,7 @@
|
||||
Simulation::Simulation(GameConfig config, unsigned int seed)
|
||||
: m_config(std::move(config))
|
||||
, m_rng(seed)
|
||||
, m_seed(seed)
|
||||
, m_currentTick(0)
|
||||
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
|
||||
, m_nextBuildingId(1)
|
||||
@@ -129,6 +135,7 @@ void Simulation::reset(unsigned int seed)
|
||||
{
|
||||
EventManager::getInstance()->clearEvents();
|
||||
m_rng.seed(seed);
|
||||
m_seed = seed;
|
||||
m_currentTick = 0;
|
||||
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
|
||||
m_nextBuildingId = 1;
|
||||
@@ -217,6 +224,92 @@ void Simulation::reset(unsigned int seed)
|
||||
// tick
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Simulation::apply(const Command& command)
|
||||
{
|
||||
switch (command.kind)
|
||||
{
|
||||
case CommandKind::PlaceBuilding:
|
||||
{
|
||||
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
|
||||
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
|
||||
if (id == kInvalidBuildingId)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (c.recipeId.has_value())
|
||||
{
|
||||
m_buildingSystem->setRecipe(id, *c.recipeId);
|
||||
}
|
||||
if (c.shipLayout.has_value())
|
||||
{
|
||||
m_buildingSystem->setShipLayout(id, *c.shipLayout);
|
||||
}
|
||||
if (c.hasSplitterFilters)
|
||||
{
|
||||
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CommandKind::Demolish:
|
||||
demolish(static_cast<const DemolishCommand&>(command).id);
|
||||
break;
|
||||
case CommandKind::RotateInPlace:
|
||||
{
|
||||
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
|
||||
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetRecipe:
|
||||
{
|
||||
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
|
||||
m_buildingSystem->setRecipe(c.id, c.recipeId);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetShipLayout:
|
||||
{
|
||||
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
|
||||
m_buildingSystem->setShipLayout(c.id, c.layout);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSiteSplitterFilters:
|
||||
{
|
||||
const SetSiteSplitterFiltersCommand& c =
|
||||
static_cast<const SetSiteSplitterFiltersCommand&>(command);
|
||||
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::SetSplitterFilters:
|
||||
{
|
||||
const SetSplitterFiltersCommand& c =
|
||||
static_cast<const SetSplitterFiltersCommand&>(command);
|
||||
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
|
||||
break;
|
||||
}
|
||||
case CommandKind::ClearBeltTiles:
|
||||
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
|
||||
break;
|
||||
case CommandKind::ApplySchematicChoice:
|
||||
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
|
||||
break;
|
||||
case CommandKind::Reset:
|
||||
{
|
||||
const ResetCommand& c = static_cast<const ResetCommand&>(command);
|
||||
if (c.config)
|
||||
{
|
||||
// operator* on a const shared_ptr yields a mutable GameConfig&, so
|
||||
// the move-only config moves into reset without a copy. The command
|
||||
// is applied once, so leaving its config moved-from is fine.
|
||||
reset(std::move(*c.config), c.seed);
|
||||
}
|
||||
else
|
||||
{
|
||||
reset(c.seed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::tick()
|
||||
{
|
||||
EventManager::getInstance()->processEvents();
|
||||
@@ -856,6 +949,118 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
|
||||
return m_unlockedItemIds.count(itemId) > 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Determinism (see docs/replay_design.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void Simulation::appendSchematicMap(Hasher& hasher,
|
||||
const std::map<std::string, SchematicState>& levels)
|
||||
{
|
||||
hasher.append(levels.size());
|
||||
for (const std::pair<const std::string, SchematicState>& entry : levels)
|
||||
{
|
||||
hasher.append(entry.first);
|
||||
hasher.append(entry.second.unlocked);
|
||||
hasher.append(entry.second.level);
|
||||
}
|
||||
}
|
||||
|
||||
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
|
||||
{
|
||||
hasher.append(ids.size());
|
||||
for (const std::string& id : ids)
|
||||
{
|
||||
hasher.append(id);
|
||||
}
|
||||
}
|
||||
|
||||
unsigned long long Simulation::rngFingerprint() const
|
||||
{
|
||||
return fingerprintRng(m_rng);
|
||||
}
|
||||
|
||||
unsigned long long Simulation::computeStateChecksum() const
|
||||
{
|
||||
Hasher hasher;
|
||||
|
||||
// RNG stream — the most sensitive signal of divergence.
|
||||
hasher.append(fingerprintRng(m_rng));
|
||||
|
||||
// Top-level scalars.
|
||||
hasher.append(m_currentTick);
|
||||
hasher.append(m_nextDepartureTick);
|
||||
hasher.append(m_nextBuildingId);
|
||||
hasher.append(m_buildingBlocksStock);
|
||||
hasher.append(m_gameOver);
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -887,6 +1092,11 @@ Tick Simulation::currentTick() const
|
||||
return m_currentTick;
|
||||
}
|
||||
|
||||
unsigned int Simulation::getSeed() const
|
||||
{
|
||||
return m_seed;
|
||||
}
|
||||
|
||||
int Simulation::buildingBlocksStock() const
|
||||
{
|
||||
return m_buildingBlocksStock;
|
||||
@@ -1015,7 +1225,7 @@ void Simulation::demolish(BuildingId id)
|
||||
m_buildingBlocksStock += m_buildingSystem->demolish(id);
|
||||
}
|
||||
|
||||
BuildingSystem& Simulation::buildings()
|
||||
BuildingSystem& Simulation::buildingsMutable()
|
||||
{
|
||||
return *m_buildingSystem;
|
||||
}
|
||||
@@ -1025,7 +1235,7 @@ const BuildingSystem& Simulation::buildings() const
|
||||
return *m_buildingSystem;
|
||||
}
|
||||
|
||||
BeltSystem& Simulation::belts()
|
||||
BeltSystem& Simulation::beltsMutable()
|
||||
{
|
||||
return m_beltSystem;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
|
||||
class AiSystem;
|
||||
class BuildingSystem;
|
||||
struct Command;
|
||||
class Hasher;
|
||||
class CombatSystem;
|
||||
class DynamicBodySystem;
|
||||
class MovementIntentSystem;
|
||||
@@ -50,6 +52,12 @@ public:
|
||||
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
|
||||
void tick();
|
||||
|
||||
// The single command chokepoint: applies one player command by dispatching
|
||||
// to the underlying mutators. Every sim mutation during play must flow
|
||||
// through here so it can be recorded and replayed (see docs/replay_design.md
|
||||
// and CommandManager). Reached via CommandManager::drain.
|
||||
void apply(const Command& command);
|
||||
|
||||
// Returns all fire events accumulated since the last drain, clearing the
|
||||
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
|
||||
std::vector<BeamFiredEvent> drainBeamFiredEvents();
|
||||
@@ -60,12 +68,9 @@ public:
|
||||
// Returns true if there are pending schematic choices waiting for player input.
|
||||
bool hasSchematicChoicesPending() const;
|
||||
|
||||
// Applies the player's chosen schematic from the pending choices.
|
||||
// choiceIndex must be in [0, pendingChoices.size()).
|
||||
// Clears the pending choices after application.
|
||||
void applySchematicChoice(int choiceIndex);
|
||||
|
||||
Tick currentTick() const;
|
||||
// The seed this run was (re)initialized with; written to the replay header.
|
||||
unsigned int getSeed() const;
|
||||
int buildingBlocksStock() const;
|
||||
bool isGameOver() const;
|
||||
bool isWon() const;
|
||||
@@ -90,16 +95,21 @@ public:
|
||||
bool isRecipeUnlocked(const std::string& recipeId) const;
|
||||
bool isItemUnlocked(const std::string& itemId) const;
|
||||
|
||||
// Checks affordability, deducts building blocks, and places the building.
|
||||
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
||||
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
||||
// -- Determinism (see docs/replay_design.md) -----------------------------
|
||||
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
|
||||
// file periodically + after each command for desync detection.
|
||||
unsigned long long rngFingerprint() const;
|
||||
|
||||
// Demolishes the building with the given id and refunds building blocks.
|
||||
void demolish(BuildingId id);
|
||||
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
|
||||
// belts, and ECS component state). Used by the double-run determinism test;
|
||||
// a superset of rngFingerprint().
|
||||
unsigned long long computeStateChecksum() const;
|
||||
|
||||
BuildingSystem& buildings();
|
||||
// Const subsystem accessors (queries only). The mutable counterparts are
|
||||
// private and reachable only through Simulation::apply (the command
|
||||
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
|
||||
// mutate the factory outside the recorded command path (docs/replay_design.md).
|
||||
const BuildingSystem& buildings() const;
|
||||
BeltSystem& belts();
|
||||
const BeltSystem& belts() const;
|
||||
ShipSystem& ships();
|
||||
const ShipSystem& ships() const;
|
||||
@@ -109,6 +119,29 @@ public:
|
||||
const EntityAdmin& admin() const;
|
||||
|
||||
private:
|
||||
// Grants tests access to the private player-action mutators below without
|
||||
// opening them to production code (see src/test/SimulationTestAccess.h).
|
||||
friend struct SimulationTestAccess;
|
||||
|
||||
// -- Player-action mutators (command chokepoint only) --------------------
|
||||
// Reached during play exclusively via apply(); never called by UI/app code.
|
||||
|
||||
// Checks affordability, deducts building blocks, and places the building.
|
||||
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
|
||||
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
|
||||
|
||||
// Demolishes the building with the given id and refunds building blocks.
|
||||
void demolish(BuildingId id);
|
||||
|
||||
// Applies the player's chosen schematic from the pending choices.
|
||||
// choiceIndex must be in [0, pendingChoices.size()).
|
||||
// Clears the pending choices after application.
|
||||
void applySchematicChoice(int choiceIndex);
|
||||
|
||||
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
|
||||
BuildingSystem& buildingsMutable();
|
||||
BeltSystem& beltsMutable();
|
||||
|
||||
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
|
||||
|
||||
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
|
||||
@@ -128,6 +161,7 @@ private:
|
||||
|
||||
GameConfig m_config;
|
||||
std::mt19937 m_rng;
|
||||
unsigned int m_seed;
|
||||
|
||||
Tick m_currentTick;
|
||||
Tick m_nextDepartureTick;
|
||||
@@ -153,6 +187,11 @@ private:
|
||||
std::map<std::string, SchematicState> m_schematicLevels;
|
||||
std::map<std::string, SchematicState> m_moduleSchematicLevels;
|
||||
|
||||
// Determinism helpers — fold sub-state into the hasher in deterministic order.
|
||||
static void appendSchematicMap(Hasher& hasher,
|
||||
const std::map<std::string, SchematicState>& levels);
|
||||
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
|
||||
|
||||
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
|
||||
std::set<std::string> m_unlockedRecipeSchematicIds;
|
||||
|
||||
|
||||
61
src/lib/sim/StateChecksum.cpp
Normal file
61
src/lib/sim/StateChecksum.cpp
Normal file
@@ -0,0 +1,61 @@
|
||||
#include "StateChecksum.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
void Hasher::appendBytes(const void* data, std::size_t byteCount)
|
||||
{
|
||||
const unsigned char* bytes = static_cast<const unsigned char*>(data);
|
||||
for (std::size_t i = 0; i < byteCount; ++i)
|
||||
{
|
||||
m_state ^= bytes[i];
|
||||
m_state *= 1099511628211ull; // FNV-1a 64-bit prime
|
||||
}
|
||||
}
|
||||
|
||||
void Hasher::append(float value)
|
||||
{
|
||||
// Normalize -0.0f to +0.0f so the two equal values share a fingerprint.
|
||||
if (value == 0.0f) { value = 0.0f; }
|
||||
appendBytes(&value, sizeof(value));
|
||||
}
|
||||
|
||||
void Hasher::append(double value)
|
||||
{
|
||||
if (value == 0.0) { value = 0.0; }
|
||||
appendBytes(&value, sizeof(value));
|
||||
}
|
||||
|
||||
void Hasher::append(const QPoint& point)
|
||||
{
|
||||
const int coords[2] = { point.x(), point.y() };
|
||||
appendBytes(coords, sizeof(coords));
|
||||
}
|
||||
|
||||
void Hasher::append(const QPointF& point)
|
||||
{
|
||||
append(point.x());
|
||||
append(point.y());
|
||||
}
|
||||
|
||||
void Hasher::append(const QVector2D& vector)
|
||||
{
|
||||
append(vector.x());
|
||||
append(vector.y());
|
||||
}
|
||||
|
||||
void Hasher::append(const std::string& text)
|
||||
{
|
||||
appendBytes(text.data(), text.size());
|
||||
// Length terminator so "ab"+"c" and "a"+"bc" do not collide.
|
||||
const std::size_t length = text.size();
|
||||
appendBytes(&length, sizeof(length));
|
||||
}
|
||||
|
||||
std::uint64_t fingerprintRng(const std::mt19937& rng)
|
||||
{
|
||||
std::ostringstream stream;
|
||||
stream << rng; // full internal state as space-separated integers
|
||||
Hasher hasher;
|
||||
hasher.append(stream.str());
|
||||
return hasher.value();
|
||||
}
|
||||
55
src/lib/sim/StateChecksum.h
Normal file
55
src/lib/sim/StateChecksum.h
Normal file
@@ -0,0 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
#include <QPoint>
|
||||
#include <QPointF>
|
||||
#include <QVector2D>
|
||||
|
||||
// FNV-1a 64-bit accumulator used to fingerprint simulation state for
|
||||
// determinism verification (see docs/replay_design.md "Determinism").
|
||||
//
|
||||
// Subsystems contribute their own state through appendChecksum(Hasher&) so the
|
||||
// hash stays close to the data it covers and no state knowledge is duplicated.
|
||||
// The accumulator is order-sensitive; callers fold state in a deterministic
|
||||
// order (sorted containers, fixed view iteration).
|
||||
class Hasher
|
||||
{
|
||||
public:
|
||||
// Folds raw bytes into the running hash.
|
||||
void appendBytes(const void* data, std::size_t byteCount);
|
||||
|
||||
// Trivially-copyable scalars (ints, enums) are hashed by object representation.
|
||||
// Floating-point and Qt types have dedicated overloads below and bypass this.
|
||||
template <typename T>
|
||||
void append(const T& value)
|
||||
{
|
||||
static_assert(std::is_trivially_copyable<T>::value,
|
||||
"Hasher::append requires a trivially copyable type "
|
||||
"(add a dedicated overload otherwise)");
|
||||
appendBytes(&value, sizeof(T));
|
||||
}
|
||||
|
||||
// Floats are hashed by bit pattern so equal values always hash equally;
|
||||
// negative zero is normalized so -0.0 and +0.0 collapse to one value.
|
||||
void append(float value);
|
||||
void append(double value);
|
||||
void append(const QPoint& point);
|
||||
void append(const QPointF& point);
|
||||
void append(const QVector2D& vector);
|
||||
void append(const std::string& text);
|
||||
|
||||
std::uint64_t value() const { return m_state; }
|
||||
|
||||
private:
|
||||
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis
|
||||
};
|
||||
|
||||
// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a
|
||||
// portable, bit-identical text serialization, so this fingerprint is stable
|
||||
// across platforms (see docs/replay_design.md "Cross-platform").
|
||||
std::uint64_t fingerprintRng(const std::mt19937& rng);
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "HealthComponent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -121,7 +122,7 @@ TEST_CASE("ArtifactWinCondition: no artifact option when chance formula returns
|
||||
if (!sim.hasSchematicChoicesPending()) { continue; }
|
||||
|
||||
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);
|
||||
REQUIRE(index >= 0);
|
||||
|
||||
sim.applySchematicChoice(index);
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
|
||||
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; });
|
||||
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);
|
||||
}
|
||||
@@ -187,7 +188,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true when artifact count reaches
|
||||
|
||||
const int index = findArtifactChoiceIndex(sim);
|
||||
REQUIRE(index >= 0);
|
||||
sim.applySchematicChoice(index);
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
|
||||
CHECK(sim.isWon());
|
||||
}
|
||||
@@ -202,7 +203,7 @@ TEST_CASE("ArtifactWinCondition: isWon stays false when artifact count is below
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
sim.applySchematicChoice(findArtifactChoiceIndex(sim));
|
||||
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||
|
||||
CHECK(sim.artifactCount() == 1);
|
||||
CHECK_FALSE(sim.isWon());
|
||||
@@ -222,7 +223,7 @@ TEST_CASE("ArtifactWinCondition: isWon becomes true after collecting required nu
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
const int index = findArtifactChoiceIndex(sim);
|
||||
REQUIRE(index >= 0);
|
||||
sim.applySchematicChoice(index);
|
||||
SimulationTestAccess::applySchematicChoice(sim,index);
|
||||
}
|
||||
|
||||
CHECK(sim.artifactCount() == 2);
|
||||
@@ -243,7 +244,7 @@ TEST_CASE("ArtifactWinCondition: reset clears artifact count and win state",
|
||||
|
||||
killEnemyStations(sim);
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
sim.applySchematicChoice(findArtifactChoiceIndex(sim));
|
||||
SimulationTestAccess::applySchematicChoice(sim,findArtifactChoiceIndex(sim));
|
||||
REQUIRE(sim.isWon());
|
||||
|
||||
sim.reset();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "Rotation.h"
|
||||
#include "ShipLayout.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "SurfaceMask.h"
|
||||
#include "Tick.h"
|
||||
|
||||
@@ -524,9 +525,9 @@ TEST_CASE("Blueprint placement: buildings land at anchor + offset from cursor",
|
||||
const QPoint offsetA(-1, 0);
|
||||
const QPoint offsetB( 1, 0);
|
||||
|
||||
const BuildingId idA = sim.tryPlaceBuilding(
|
||||
const BuildingId idA = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetA, Rotation::East);
|
||||
const BuildingId idB = sim.tryPlaceBuilding(
|
||||
const BuildingId idB = SimulationTestAccess::place(sim,
|
||||
BuildingType::Belt, cursor + offsetB, Rotation::East);
|
||||
|
||||
REQUIRE(idA != kInvalidBuildingId);
|
||||
@@ -551,10 +552,10 @@ TEST_CASE("Blueprint placement: cost is deducted for each building in sequence",
|
||||
const int startBlocks = sim.buildingBlocksStock();
|
||||
REQUIRE(startBlocks >= 2 * beltCost); // test config has enough starting blocks
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-6, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - beltCost);
|
||||
|
||||
sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Belt, QPoint(-4, 0), Rotation::East);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - 2 * beltCost);
|
||||
}
|
||||
|
||||
@@ -576,12 +577,12 @@ TEST_CASE("Blueprint placement: insufficient blocks returns kInvalidBuildingId a
|
||||
int col = -2;
|
||||
while (sim.buildingBlocksStock() >= minerCost)
|
||||
{
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(col, 0), Rotation::East);
|
||||
col -= 2;
|
||||
}
|
||||
|
||||
const int blocksBeforeAttempt = sim.buildingBlocksStock();
|
||||
const BuildingId id = sim.tryPlaceBuilding(
|
||||
const BuildingId id = SimulationTestAccess::place(sim,
|
||||
BuildingType::Miner, QPoint(col - 2, 0), Rotation::East);
|
||||
|
||||
// Placement must fail and leave the stock unchanged.
|
||||
@@ -598,7 +599,7 @@ TEST_CASE("Simulation: tryPlaceBuilding rejects terrain-invalid placement and ch
|
||||
// A miner is all-asteroid; placing it in space (x >= 0) violates the terrain
|
||||
// rule, so it must be rejected without consuming building blocks.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(0, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id == kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks);
|
||||
@@ -621,7 +622,7 @@ TEST_CASE("Simulation: tryPlaceBuilding accepts a valid asteroid spot, occupies
|
||||
// Miner mask ["AA","A>"] East at (-3,0) → all-asteroid body at
|
||||
// (-3,0),(-2,0),(-3,1); a valid spot.
|
||||
const BuildingId id =
|
||||
sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-3, 0), Rotation::East);
|
||||
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
REQUIRE(sim.buildingBlocksStock() == startBlocks - minerCost);
|
||||
@@ -664,10 +665,10 @@ TEST_CASE("Blueprint placement: setRecipe on construction site stores recipe", "
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
// Miner body cells: (0,0),(1,0),(0,1) — all at x < 0, valid for asteroid.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
sim.buildings().setRecipe(id, "mine_iron_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_iron_ore");
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -679,9 +680,9 @@ TEST_CASE("Blueprint placement: recipe transfers to building after construction
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Miner, QPoint(-2, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
sim.buildings().setRecipe(id, "mine_copper_ore");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(id, "mine_copper_ore");
|
||||
|
||||
// Miner construction_time_seconds = 10 → completesAt = secondsToTicks(10) = 300.
|
||||
// Run 301 ticks (0..300) to process the completion tick.
|
||||
@@ -759,7 +760,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
// Shipyard surface_mask ["AAAS>","AAAS "] with Rotation::East:
|
||||
// A-tiles at (-3,0),(-2,0),(-1,0),(-3,1),(-2,1),(-1,1) — all x < 0, valid asteroid tiles.
|
||||
// S-tile at (0,0) and (0,1) — x >= 0, valid space tiles.
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -769,7 +770,7 @@ TEST_CASE("Blueprint placement: setShipLayout on construction site stores layout
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
const ConstructionSite* site = sim.buildings().findSite(id);
|
||||
REQUIRE(site != nullptr);
|
||||
@@ -783,7 +784,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
{
|
||||
Simulation sim(loadConfig());
|
||||
|
||||
const BuildingId id = sim.tryPlaceBuilding(BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
const BuildingId id = SimulationTestAccess::place(sim,BuildingType::Shipyard, QPoint(-3, 0), Rotation::East);
|
||||
REQUIRE(id != kInvalidBuildingId);
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
@@ -793,7 +794,7 @@ TEST_CASE("Blueprint placement: ship layout transfers to building after construc
|
||||
pm.rotation = Rotation::North;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(id, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(id, layout);
|
||||
|
||||
// Shipyard construction_time_seconds = 30 in the test config.
|
||||
double constructionTime = 0.0;
|
||||
|
||||
@@ -22,4 +22,8 @@ add_files(
|
||||
ThreatCostCalculatorTest.cpp
|
||||
RecipeSchematicTest.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 "SchematicChoiceOption.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "StationBodyComponent.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -38,7 +39,7 @@ static void killEnemyStationsAndApply(Simulation& sim)
|
||||
killEnemyStations(sim);
|
||||
if (sim.hasSchematicChoicesPending())
|
||||
{
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +247,7 @@ TEST_CASE("RecipeSchematic: recipe schematic can appear in pending choices",
|
||||
break;
|
||||
}
|
||||
}
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
CHECK(foundRecipeChoice);
|
||||
@@ -308,7 +309,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames is sorted, deduplicated, and
|
||||
}
|
||||
}
|
||||
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +341,7 @@ TEST_CASE("RecipeSchematic: newlyUnlockedItemNames matches recipes that actually
|
||||
const std::set<std::string> unlockedBefore = unlockedTrackedRecipeIds();
|
||||
const SchematicChoiceOption choice = sim.getPendingSchematicChoices()[0];
|
||||
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
|
||||
std::set<std::string> expectedNames;
|
||||
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 "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
#include "WeaponComponent.h"
|
||||
|
||||
@@ -62,7 +63,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
||||
|
||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||
{
|
||||
return sim.buildings().placeImmediate(
|
||||
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||
BuildingType::Shipyard,
|
||||
yardDef.surfaceMask,
|
||||
QPoint(0, 0),
|
||||
@@ -73,7 +74,7 @@ static void fillMaterials(Simulation& sim, BuildingId yardId,
|
||||
const ShipDef& def,
|
||||
const ShipLayoutConfig& layout)
|
||||
{
|
||||
sim.buildings().forEachBuilding([&](Building& b) {
|
||||
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b) {
|
||||
if (b.id != yardId)
|
||||
{
|
||||
return;
|
||||
@@ -216,7 +217,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
@@ -225,7 +226,7 @@ TEST_CASE("Shipyard: setShipLayout reinitializes buffers with module materials",
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b != nullptr);
|
||||
@@ -245,7 +246,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
// Fill materials and tick to start production.
|
||||
ShipLayoutConfig emptyLayout;
|
||||
@@ -264,7 +265,7 @@ TEST_CASE("Shipyard: setShipLayout cancels in-progress production",
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
@@ -278,7 +279,7 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, "interceptor");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"interceptor");
|
||||
|
||||
ShipLayoutConfig layout;
|
||||
PlacedModule pm;
|
||||
@@ -286,13 +287,13 @@ TEST_CASE("Shipyard: setRecipe clears ship layout", "[modules][shipyard]")
|
||||
pm.position = QPoint(0, 0);
|
||||
pm.rotation = Rotation::East;
|
||||
layout.placedModules.push_back(pm);
|
||||
sim.buildings().setShipLayout(yardId, layout);
|
||||
SimulationTestAccess::buildings(sim).setShipLayout(yardId, layout);
|
||||
|
||||
const Building* b1 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b1 != nullptr);
|
||||
REQUIRE(b1->shipLayout.has_value());
|
||||
|
||||
sim.buildings().setRecipe(yardId, "destroyer");
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId,"destroyer");
|
||||
|
||||
const Building* b2 = sim.buildings().findBuilding(yardId);
|
||||
REQUIRE(b2 != nullptr);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "ShipIdentityComponent.h"
|
||||
#include "ShipSystem.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
|
||||
static GameConfig loadConfig()
|
||||
@@ -45,7 +46,7 @@ static const BuildingDef* findShipyardDef(const GameConfig& cfg)
|
||||
|
||||
static BuildingId placeShipyard(Simulation& sim, const BuildingDef& yardDef)
|
||||
{
|
||||
return sim.buildings().placeImmediate(
|
||||
return SimulationTestAccess::buildings(sim).placeImmediate(
|
||||
BuildingType::Shipyard,
|
||||
yardDef.surfaceMask,
|
||||
QPoint(0, 0),
|
||||
@@ -62,7 +63,7 @@ static int countShips(Simulation& sim)
|
||||
|
||||
static void fillMaterials(Simulation& sim, BuildingId yardId, const ShipDef& def)
|
||||
{
|
||||
sim.buildings().forEachBuilding([&](Building& b)
|
||||
SimulationTestAccess::buildings(sim).forEachBuilding([&](Building& b)
|
||||
{
|
||||
if (b.id != yardId)
|
||||
{
|
||||
@@ -94,7 +95,7 @@ TEST_CASE("Shipyard: spawns a player ship after production cycle completes",
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
REQUIRE(yardId != kInvalidBuildingId);
|
||||
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
fillMaterials(sim, yardId, *def);
|
||||
|
||||
// First tick: materials consumed, production cycle starts — no ship yet.
|
||||
@@ -153,7 +154,7 @@ TEST_CASE("Shipyard: does not spawn with insufficient materials", "[shipyard]")
|
||||
const int shipsBefore = countShips(sim);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
// Materials remain at zero (default after setRecipe); no cycle starts.
|
||||
|
||||
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
||||
@@ -175,7 +176,7 @@ TEST_CASE("Shipyard: spawns a second ship after materials replenished", "[shipya
|
||||
REQUIRE(yardDef != nullptr);
|
||||
|
||||
const BuildingId yardId = placeShipyard(sim, *yardDef);
|
||||
sim.buildings().setRecipe(yardId, def->id);
|
||||
SimulationTestAccess::buildings(sim).setRecipe(yardId, def->id);
|
||||
|
||||
const Tick cycleTicks = secondsToTicks(def->schematic.productionTimeSeconds);
|
||||
|
||||
|
||||
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 "ShipsConfig.h"
|
||||
#include "Simulation.h"
|
||||
#include "SimulationTestAccess.h"
|
||||
#include "Tick.h"
|
||||
#include "ThreatCostCalculator.h"
|
||||
#include "WaveSystem.h"
|
||||
@@ -359,7 +360,7 @@ TEST_CASE("WaveSystem: applySchematicChoice clears pending and applies", "[wave]
|
||||
|
||||
sim.tick();
|
||||
REQUIRE(sim.hasSchematicChoicesPending());
|
||||
sim.applySchematicChoice(0);
|
||||
SimulationTestAccess::applySchematicChoice(sim, 0);
|
||||
REQUIRE_FALSE(sim.hasSchematicChoicesPending());
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include <QColor>
|
||||
#include <QCursor>
|
||||
#include <QDir>
|
||||
#include <QFont>
|
||||
#include <QKeyEvent>
|
||||
#include <QMessageBox>
|
||||
@@ -24,6 +26,10 @@
|
||||
#include "BeltSystem.h"
|
||||
#include "Building.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "ReplayPlayer.h"
|
||||
#include "ReplayReader.h"
|
||||
#include "ReplayRecorder.h"
|
||||
#include "DemolishModeChangedEvent.h"
|
||||
#include "EntityHitTest.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
@@ -112,11 +118,13 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
|
||||
|
||||
|
||||
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
const VisualsConfig* visuals, QWidget* parent)
|
||||
const VisualsConfig* visuals, const std::string& configDir,
|
||||
const ParsedReplay* replay, QWidget* parent)
|
||||
: QOpenGLWidget(parent)
|
||||
, m_sim(sim)
|
||||
, m_config(config)
|
||||
, m_visuals(visuals)
|
||||
, m_commandManager(*sim)
|
||||
, m_gameSpeedMultiplier(1.0)
|
||||
, m_prevNonZeroSpeed(1.0)
|
||||
, m_scrollXTiles(0.0f)
|
||||
@@ -143,6 +151,25 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
|
||||
m_frameTimer.start();
|
||||
|
||||
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()
|
||||
@@ -159,13 +186,40 @@ void GameWorldView::onFrame()
|
||||
{
|
||||
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(
|
||||
static_cast<double>(elapsed), m_gameSpeedMultiplier);
|
||||
for (int i = 0; i < ticks; ++i)
|
||||
{
|
||||
m_sim->tick();
|
||||
// Periodic checksum (every 30 ticks) for replay desync detection.
|
||||
m_commandManager.recordTickCheckpoint();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,43 +293,49 @@ void GameWorldView::onFrame()
|
||||
}
|
||||
}
|
||||
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
// Sim-state polls are input sources for the live game; in replay these are
|
||||
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
|
||||
// and game-over becomes the passive "replay ended" state below).
|
||||
if (!m_replayPlayer)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
// Schematic choice available
|
||||
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
|
||||
{
|
||||
m_schematicChoiceShown = true;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
|
||||
}
|
||||
if (!m_sim->hasSchematicChoicesPending())
|
||||
{
|
||||
m_schematicChoiceShown = false;
|
||||
}
|
||||
|
||||
// Artifact count update
|
||||
const int currentArtifactCount = m_sim->artifactCount();
|
||||
if (currentArtifactCount != m_lastArtifactCount)
|
||||
{
|
||||
m_lastArtifactCount = currentArtifactCount;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ArtifactCountChangedEvent>(
|
||||
currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount));
|
||||
}
|
||||
// Artifact count update
|
||||
const int currentArtifactCount = m_sim->artifactCount();
|
||||
if (currentArtifactCount != m_lastArtifactCount)
|
||||
{
|
||||
m_lastArtifactCount = currentArtifactCount;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<ArtifactCountChangedEvent>(
|
||||
currentArtifactCount, m_sim->config().world.artifacts.artifactWinCount));
|
||||
}
|
||||
|
||||
// Win check
|
||||
if (m_sim->isWon() && !m_winShown)
|
||||
{
|
||||
m_winShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(std::make_shared<WinEvent>());
|
||||
}
|
||||
// Win check
|
||||
if (m_sim->isWon() && !m_winShown)
|
||||
{
|
||||
m_winShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(std::make_shared<WinEvent>());
|
||||
}
|
||||
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
// Game over check
|
||||
if (m_sim->isGameOver() && !m_gameOverShown)
|
||||
{
|
||||
m_gameOverShown = true;
|
||||
m_gameSpeedMultiplier = 0.0;
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<GameOverEvent>());
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
@@ -301,6 +361,7 @@ void GameWorldView::paintGL()
|
||||
drawBeams(painter);
|
||||
drawOverlays(painter);
|
||||
drawScreenSpace(painter);
|
||||
drawReplayOverlay(painter);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -533,12 +594,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
|
||||
if (rotateTarget.has_value())
|
||||
{
|
||||
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
|
||||
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
rotateCommand->id = *rotateTarget;
|
||||
rotateCommand->newRotation = bb.rotation;
|
||||
enqueueCommand(rotateCommand);
|
||||
continue;
|
||||
}
|
||||
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
|
||||
if (id == kInvalidBuildingId) { continue; }
|
||||
// Place-and-configure is one atomic command: commands apply at a deferred
|
||||
// tick boundary, so the caller never sees the new BuildingId. Unlock
|
||||
// gating stays here (UI-side pre-filter); only fields that should apply
|
||||
// are set on the command.
|
||||
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
|
||||
command->type = bb.type;
|
||||
command->anchor = anchor;
|
||||
command->rotation = bb.rotation;
|
||||
|
||||
if (!bb.recipeId.empty())
|
||||
{
|
||||
@@ -546,7 +617,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
{
|
||||
if (m_sim->isSchematicUnlocked(bb.recipeId))
|
||||
{
|
||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -555,15 +626,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
|| bb.type == BuildingType::Assembler;
|
||||
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
|
||||
{
|
||||
m_sim->buildings().setRecipe(id, bb.recipeId);
|
||||
command->recipeId = bb.recipeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bb.shipLayout.has_value())
|
||||
{
|
||||
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
|
||||
}
|
||||
command->shipLayout = bb.shipLayout;
|
||||
|
||||
if (bb.type == BuildingType::Splitter
|
||||
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
|
||||
@@ -571,11 +639,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|
||||
// The splitter is still a construction site, so the filters carry
|
||||
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
|
||||
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
|
||||
m_sim->buildings().setSiteSplitterFilters(
|
||||
id,
|
||||
filterUnlockedItems(bb.splitterFilterA, *m_sim),
|
||||
filterUnlockedItems(bb.splitterFilterB, *m_sim));
|
||||
command->hasSplitterFilters = true;
|
||||
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
|
||||
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
|
||||
}
|
||||
|
||||
enqueueCommand(command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,49 +665,50 @@ void GameWorldView::placeAtTile(QPoint tile)
|
||||
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
|
||||
if (rotateTarget.has_value())
|
||||
{
|
||||
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
|
||||
std::shared_ptr<RotateInPlaceCommand> command =
|
||||
std::make_shared<RotateInPlaceCommand>();
|
||||
command->id = *rotateTarget;
|
||||
command->newRotation = m_ghostRotation;
|
||||
enqueueCommand(command);
|
||||
return;
|
||||
}
|
||||
|
||||
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
|
||||
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
|
||||
// optimistic UI update matches what the deferred command will do — isValidPlacement
|
||||
// (above) already covered terrain/bounds.
|
||||
if (type == BuildingType::Belt)
|
||||
{
|
||||
if (m_beltDragTiles.count(tile) > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!m_sim->buildings().isTileOccupied(tile))
|
||||
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
||||
{
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(
|
||||
type, tile, m_ghostRotation);
|
||||
if (id != kInvalidBuildingId)
|
||||
{
|
||||
m_beltDragTiles.insert(tile);
|
||||
}
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
m_beltDragTiles.insert(tile);
|
||||
}
|
||||
}
|
||||
else if (type == BuildingType::Splitter
|
||||
|| type == BuildingType::TunnelEntry
|
||||
|| type == BuildingType::TunnelExit)
|
||||
{
|
||||
if (!m_sim->buildings().isTileOccupied(tile))
|
||||
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
|
||||
{
|
||||
const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
|
||||
if (id != kInvalidBuildingId)
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
if (type == BuildingType::TunnelEntry)
|
||||
{
|
||||
if (type == BuildingType::TunnelEntry)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelExit;
|
||||
}
|
||||
else if (type == BuildingType::TunnelExit)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelEntry;
|
||||
}
|
||||
m_builderType = BuildingType::TunnelExit;
|
||||
}
|
||||
else if (type == BuildingType::TunnelExit)
|
||||
{
|
||||
m_builderType = BuildingType::TunnelEntry;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
|
||||
enqueuePlaceBuilding(type, tile, m_ghostRotation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1387,7 +1496,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
|
||||
const bool isProtected = b && b->type == BuildingType::Hq;
|
||||
if (!isProtected)
|
||||
{
|
||||
m_sim->demolish(hovered);
|
||||
std::shared_ptr<DemolishCommand> command =
|
||||
std::make_shared<DemolishCommand>();
|
||||
command->id = hovered;
|
||||
enqueueCommand(command);
|
||||
m_demolishHoverBuildingId = kInvalidBuildingId;
|
||||
}
|
||||
}
|
||||
@@ -1763,3 +1875,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
|
||||
{
|
||||
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 <random>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <QElapsedTimer>
|
||||
@@ -27,11 +28,13 @@
|
||||
#include "DebugDrawToggledEvent.h"
|
||||
#include "ArtifactCountChangedEvent.h"
|
||||
#include "BeamFiredEvent.h"
|
||||
#include "CommandRequestedEvent.h"
|
||||
#include "SchematicChoiceOption.h"
|
||||
#include "WinEvent.h"
|
||||
#include "SpeedChangeRequestedEvent.h"
|
||||
|
||||
#include "entt/entity/entity.hpp"
|
||||
#include "CommandManager.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
#include "GameConfig.h"
|
||||
#include "Rotation.h"
|
||||
@@ -39,6 +42,9 @@
|
||||
#include "TickDriver.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct Command;
|
||||
struct ParsedReplay;
|
||||
class ReplayPlayer;
|
||||
class Simulation;
|
||||
class QPainter;
|
||||
|
||||
@@ -58,13 +64,15 @@ class GameWorldView : public QOpenGLWidget,
|
||||
DemolishModeToggleRequestedEvent,
|
||||
BlueprintPlacementRequestedEvent,
|
||||
ExitBlueprintModeRequestedEvent,
|
||||
SpeedChangeRequestedEvent>
|
||||
SpeedChangeRequestedEvent,
|
||||
CommandRequestedEvent>
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
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;
|
||||
|
||||
double gameSpeed() const;
|
||||
@@ -93,6 +101,17 @@ private:
|
||||
void handleEvent(std::shared_ptr<const BlueprintPlacementRequestedEvent> 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 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 drawBuildings(QPainter& painter);
|
||||
@@ -106,6 +125,7 @@ private:
|
||||
void drawBeams(QPainter& painter);
|
||||
void drawOverlays(QPainter& painter);
|
||||
void drawScreenSpace(QPainter& painter);
|
||||
void drawReplayOverlay(QPainter& painter);
|
||||
|
||||
float tilePx() const;
|
||||
float viewportWidthTiles() const;
|
||||
@@ -159,6 +179,14 @@ private:
|
||||
const GameConfig* m_config;
|
||||
const VisualsConfig* m_visuals;
|
||||
|
||||
// Funnels all player input into the single Simulation::apply chokepoint.
|
||||
CommandManager m_commandManager;
|
||||
// A Reset command was enqueued; reset the view after the next drain applies it.
|
||||
bool m_viewResetPending = false;
|
||||
// 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;
|
||||
QElapsedTimer m_frameTimer;
|
||||
std::mt19937 m_rng;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "MainWindow.h"
|
||||
|
||||
#include <map>
|
||||
#include <random>
|
||||
#include <set>
|
||||
|
||||
#include <QApplication>
|
||||
@@ -15,7 +16,10 @@
|
||||
#include "BuildButtonGrid.h"
|
||||
#include "BuildingBlocksChangedEvent.h"
|
||||
#include "BuildingSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandRequestedEvent.h"
|
||||
#include "ConfigLoader.h"
|
||||
#include "EventManager.h"
|
||||
#include "GameWorldView.h"
|
||||
#include "RecipeSelectionDialog.h"
|
||||
#include "SchematicChoiceDialog.h"
|
||||
@@ -27,18 +31,21 @@
|
||||
#include "Tick.h"
|
||||
#include "VisualsLoader.h"
|
||||
|
||||
MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent)
|
||||
MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
|
||||
std::shared_ptr<ParsedReplay> replay, QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_configDir(configDir)
|
||||
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
|
||||
, m_sim(sim)
|
||||
, m_replay(std::move(replay))
|
||||
{
|
||||
setWindowTitle(tr("Dota Factory"));
|
||||
resize(1280, 768);
|
||||
|
||||
m_headerBar = new HeaderBar(this);
|
||||
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, this);
|
||||
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
|
||||
m_replay.get(), this);
|
||||
|
||||
m_sidePanel = new QWidget(this);
|
||||
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);
|
||||
@@ -138,7 +145,11 @@ void MainWindow::handleEvent(std::shared_ptr<const SchematicChoicesAvailableEven
|
||||
SchematicChoiceDialog dialog(event->choices, this);
|
||||
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->resetFrameTimer();
|
||||
@@ -160,12 +171,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
||||
QAbstractButton* clicked = box.clickedButton();
|
||||
if (clicked == restartBtn)
|
||||
{
|
||||
std::shared_ptr<GameConfig> newConfig;
|
||||
try
|
||||
{
|
||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||
newConfig = std::make_shared<GameConfig>(
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_sim->reset(std::move(newConfig));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
@@ -175,7 +187,13 @@ void MainWindow::handleEvent(std::shared_ptr<const EscapeMenuRequestedEvent> /*e
|
||||
m_gameWorldView->resetFrameTimer();
|
||||
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)
|
||||
{
|
||||
@@ -234,7 +252,12 @@ void MainWindow::handleEvent(std::shared_ptr<const LayoutDialogRequestedEvent> e
|
||||
this);
|
||||
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);
|
||||
@@ -268,7 +291,11 @@ void MainWindow::handleEvent(std::shared_ptr<const RecipeSelectionRequestedEvent
|
||||
RecipeSelectionDialog dialog(options, title, this);
|
||||
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);
|
||||
@@ -293,12 +320,13 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
|
||||
if (box.clickedButton() == restartBtn)
|
||||
{
|
||||
std::shared_ptr<GameConfig> newConfig;
|
||||
try
|
||||
{
|
||||
GameConfig newConfig = ConfigLoader::loadFromDirectory(m_configDir);
|
||||
newConfig = std::make_shared<GameConfig>(
|
||||
ConfigLoader::loadFromDirectory(m_configDir));
|
||||
VisualsConfig newVisuals = VisualsLoader::load(m_configDir + "/visuals.toml");
|
||||
m_visuals = std::move(newVisuals);
|
||||
m_sim->reset(std::move(newConfig));
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
@@ -306,7 +334,12 @@ void MainWindow::handleEvent(std::shared_ptr<const GameOverEvent> /*event*/)
|
||||
tr("Failed to reload config:\n%1").arg(e.what()));
|
||||
return;
|
||||
}
|
||||
m_gameWorldView->resetForNewGame();
|
||||
// Restart is a command boundary; the view resets when the drain applies it.
|
||||
std::shared_ptr<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
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,7 @@
|
||||
#include "Tick.h"
|
||||
#include "VisualsConfig.h"
|
||||
|
||||
struct ParsedReplay;
|
||||
class Simulation;
|
||||
class GameWorldView;
|
||||
class HeaderBar;
|
||||
@@ -39,7 +41,8 @@ class MainWindow : public QWidget,
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr);
|
||||
MainWindow(Simulation* sim, const std::string& configDir,
|
||||
std::shared_ptr<ParsedReplay> replay = nullptr, QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
protected:
|
||||
@@ -68,4 +71,5 @@ private:
|
||||
QWidget* m_sidePanel;
|
||||
|
||||
std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
|
||||
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "BeltSystem.h"
|
||||
#include "Command.h"
|
||||
#include "CommandRequestedEvent.h"
|
||||
#include "DynamicBodyComponent.h"
|
||||
#include "EntityAdmin.h"
|
||||
#include "EntitySelectedEvent.h"
|
||||
@@ -720,17 +722,23 @@ void SelectedBuildingPanel::onSplitterFilterChanged()
|
||||
|
||||
if (m_singleIsSite)
|
||||
{
|
||||
m_sim->buildings().setSiteSplitterFilters(
|
||||
m_singleBuildingId,
|
||||
collectFilter(m_filterAList),
|
||||
collectFilter(m_filterBList));
|
||||
std::shared_ptr<SetSiteSplitterFiltersCommand> command =
|
||||
std::make_shared<SetSiteSplitterFiltersCommand>();
|
||||
command->id = m_singleBuildingId;
|
||||
command->filterA = collectFilter(m_filterAList);
|
||||
command->filterB = collectFilter(m_filterBList);
|
||||
EventManager::getInstance()->sendEventImmediately(
|
||||
std::make_shared<CommandRequestedEvent>(command));
|
||||
}
|
||||
else
|
||||
{
|
||||
m_sim->belts().setSplitterFilters(
|
||||
m_splitterTile,
|
||||
collectFilter(m_filterAList),
|
||||
collectFilter(m_filterBList));
|
||||
std::shared_ptr<SetSplitterFiltersCommand> command =
|
||||
std::make_shared<SetSplitterFiltersCommand>();
|
||||
command->tile = m_splitterTile;
|
||||
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())
|
||||
{
|
||||
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