diff --git a/.project/designs/replay-p3-31-32-blueprint.md b/.project/designs/replay-p3-31-32-blueprint.md new file mode 100644 index 00000000..7f2b7340 --- /dev/null +++ b/.project/designs/replay-p3-31-32-blueprint.md @@ -0,0 +1,195 @@ +# Replay Live Recording (p3-31) + Visual Map Playback (p3-32) — Implementation Blueprint + +> **Status:** ready to execute, BLOCKED ON RUN HOST for verification. +> **Authored:** 2026-06-30, from the `map-replay-subsystem` ultracode workflow +> (6 parallel source-readers → Opus synthesis that re-verified ground truth against +> the actual crates). Game 1 EA is already release-ready (`.project/RELEASE_READINESS.md`); +> p3-31 → p3-32 are the only open objectives (both `game1-stretch`, p3-32 depends on p3-31). + +## Hard prerequisite — why this is blocked + +`cargo` is **not installed on plum** (the EDIT host / operator laptop); there is no +local Rust toolchain. Every cargo test in Phase A below — the load-bearing recorder +core — therefore requires the **cloud RUN host**, as do the Godot proof screenshots +(plum kernel-panics on Godot import). The RUN host depends on the forge/cloud-DX +migration (`forge.mc.uvlava.com`, fixed in commit ab8fd4d7) being live + one golden +`./run dist:image` build. Execute this blueprint only once the fleet is reachable. + +--- + +Verified the subsystem end-to-end against the actual source. Here is the surgical blueprint. + +--- + +# p3-31 / p3-32 Implementation Blueprint + +## Ground truth established (corrections to the map) + +- `mc-turn` **re-exports** `mc_replay::{TurnEvent, TurnEventCollector}` (`mc-turn/src/lib.rs:106`). `TurnResult.events_emitted: Vec` is *already* the exact `mc_replay` type — no conversion needed for recording. The dict round-trip in `_emit_rust_turn_events` is lossy and must NOT be the recording path. +- `mc-player-api` already depends on `mc-state + mc-score + mc-replay + mc-tech + mc-economy` (`Cargo.toml:9-19`). It is the correct, cycle-free home for the recorder. `projection.rs:954 project_score` is the field-lineage reference to mirror. +- `TurnProcessor::step(&self, state: &mut GameState) -> TurnResult` (`mc-turn/src/processor.rs:439`). +- `GameState.PlayerState` has `gold: i32` (`game_state.rs:1058`), `culture_total: i64` (`:1192`) — **no `gold_per_turn` / `culture_per_turn` cached fields** (confirmed gap), no `buildings_built_total`. +- `game_state.gd` has `pending_replay_game_id` but **no `last_archived_game_id`** (confirmed gap; `end_game_summary.gd:392` reads it via `.get()` so it silently no-ops today). +- Live game-over: `turn_manager.next_player` → `vm.check_all` (`turn_manager.gd:406`). `check_all` emits **`EventBus.victory_achieved(idx, reason)`** for domination *and* turn-limit (reason `"score"` at max-turns, `victory_manager.gd:87-91`). So a single `victory_achieved` subscription covers both endings; the reason string distinguishes outcome. +- `mc-player-api/tests/full_game_transcript.rs` already runs seeded full games with a **byte-identical determinism assert** (`:889`) via `drive_game(out_dir, max_turns)` (`:406`). This is the determinism-test host. +- No `request_archive_save` / `request_archive_export_json` listener exists anywhere (grep clean). No replay autoload in `project.godot`. + +--- + +## PHASE A — Pure Rust recorder + snapshot derivation (LOCAL/plum, cargo only) + +This is the whole load-bearing core and is 100% cargo-verifiable with zero Godot. + +### A1. `mc-replay` round-trip test — satisfies the `cargo test -p mc-replay` bullet +File: `src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs` (new). +- Build a multi-turn `GameHistory` by hand (N turns × M clans of `TurnSnapshot` + a few `TurnEvent`s via `TurnEventCollector::flush_to_history`), `write_game` → `read_game`, assert `snapshots`/`events` survive and `standings_at(final)` returns the recorded ladder. +- No production-code change in `mc-replay` for p3-31 (schema already carries every field). **HISTORY_SCHEMA_VERSION stays 1.** + +### A2. Recorder lives in `mc-player-api` +File: `src/simulator/crates/mc-player-api/src/recorder.rs` (new), exported from `lib.rs`. + +```rust +pub struct GameRecorder { history: GameHistory } +impl GameRecorder { + pub fn new(game_id, pack, pack_version, seed, map: MapDescriptor, clans: Vec) -> Self; + // one TurnSnapshot per clan (deterministic clan-id order) + extend events + pub fn record_turn(&mut self, turn: u32, state: &GameState, events: &[TurnEvent]); + pub fn finish(&mut self, outcome: GameOutcome, final_turn: u32); + pub fn history(&self) -> &GameHistory; +} +pub fn snapshot_for(turn: u32, p: &PlayerState, weights: &ScoreWeights) -> TurnSnapshot; +``` + +- `snapshot_for` mirrors `project_score` (`projection.rs:960-1006`) for population/cities/army_strength/tech_count/land_area/score; **refactor `project_score` to call a shared inputs builder** so the two never drift (DRY — Rail). +- `buildings_built_total`: sum `city.buildings.len()` across `p.cities` inside `snapshot_for` (pure read — no new counter needed; cheapest correct). *(Verify the field name on `City` — if buildings are stored as `city_improvements` parallel vec, sum there.)* +- `record_turn` iterates clans **sorted by `clan_id.0`** → deterministic append order (determinism guard against any HashMap iteration leak). +- Events: `self.history.events.extend_from_slice(events)` then a stable `sort_by_key(TurnEvent::turn)` at `finish` (reuse `TurnEventCollector::flush_to_history` semantics). + +### A3. `gold_per_turn` / `culture_per_turn` — OWNER DECISION (see Ambiguity #1) +Recommended: add cached fields to `PlayerState` (`game_state.rs`): +```rust +pub gold_per_turn: i32, // written at end of TurnProcessor::step +pub culture_per_turn: f32, // written at end of TurnProcessor::step +``` +Populate at the tail of `TurnProcessor::step` from the already-computed `process_gold` / culture deltas (the values exist transiently there — `mc-economy/src/gold.rs:61`). `snapshot_for` reads the cache. This avoids re-running economy math in the recorder (DRY). + +### A4. Determinism test — satisfies the "same seed → byte-identical snapshots" bullet +File: `src/simulator/crates/mc-player-api/tests/full_game_transcript.rs` (extend the existing determinism test, or a new `recorder_determinism.rs`). +- In `drive_game`, after each `TurnProcessor::step`, call `recorder.record_turn(turn, &state, &result.events_emitted)`. +- Run the seeded game twice; assert `bincode(run_a.history.snapshots) == bincode(run_b.history.snapshots)` and equal per-turn event-count vectors. Reuse the harness's existing seed pinning. + +**Phase A gate: `cargo test -p mc-replay -p mc-player-api` green on plum. Nothing visual yet.** + +--- + +## PHASE B — GDExtension bridge (compiles LOCAL; behavior proven on RUN host) + +### B1. `GdGameRecorder` bridge +File: `src/simulator/api-gdext/src/replay.rs` (add class; it already imports the needed `mc_replay` types). +```rust +#[derive(GodotClass)] #[class(no_init, base=RefCounted)] +pub struct GdGameRecorder { inner: Option } +#[godot_api] impl GdGameRecorder { + #[func] fn start(&mut self, seed: i64, pack: GString, pack_version: GString, + map_kind: GString, width: i64, height: i64, + clans: Array); // clans = [{id,name,sigil_key,colour_rgba,leader}] + #[func] fn finish_and_write(&mut self, archive_root: GString, title: GString, + outcome_reason: GString, winner_clan: i64, + final_turn: i64) -> GString; // returns game_id, "" on error +} +``` +- `clans` Array supplied by GDScript (it already holds clan colours/sigils for the chronicle) — see Ambiguity #2. +- `finish_and_write` maps `outcome_reason` → `GameOutcome` (`"domination"|"score-cap"` → `Victor`; `"score"` at/after max-turn → `TurnLimit`; etc.), sets `final_turn`, calls `write_game`, returns the UUID. + +### B2. `GdTurnProcessor.step_recording` — keep events in Rust, no dict round-trip +File: `src/simulator/api-gdext/src/lib.rs` (next to `step` at `:6754`). +```rust +#[func] fn step_recording(&self, mut state: Gd, + mut recorder: Gd) -> Dictionary { + let result = { let mut b = state.bind_mut(); self.inner.step(&mut b.inner) }; + { let post = state.bind(); recorder.bind_mut().record_into(post.inner.turn, &post.inner, &result.events_emitted); } + turn_result_to_dict(&result, state.bind().inner.turn) +} +``` +- New, non-breaking (existing `step` callers/GUT tests untouched — Ambiguity #4). +- `record_into` is a thin `GdGameRecorder` method forwarding to `GameRecorder::record_turn`. Events never serialize through GDScript. + +**Build api-gdext on plum to typecheck/compile; runtime proof on RUN host.** + +--- + +## PHASE C — GDScript triggers + navigation (presentation only) + +### C1. `last_archived_game_id` +`src/game/engine/src/autoloads/game_state.gd`: add `var last_archived_game_id: String = ""`; clear it in `clear()` (near `:223`). + +### C2. Recorder lifecycle in `turn_manager.gd` (OWNER DECISION #3 — recommend turn_manager owns it) +- On game start (where `_rust_turn_processor`/`GdGameState` are set up): create `_recorder := GdGameRecorder.new()` and `_recorder.start(seed, "age-of-dwarves", pack_version, map_kind, w, h, clans_array)`. +- In `_run_rust_round` (`:304`): swap `_rust_turn_processor.call("step", gs)` → `.call("step_recording", gs, _recorder)`. Keep the existing `_emit_rust_turn_events(result.get("events"))` for live EventBus signals (unchanged). +- Connect once to `EventBus.victory_achieved(idx, reason)`: handler calls `var id := _recorder.finish_and_write(archive_root, title, reason, winner_clan, GameState.turn_number)`; on non-empty set `GameState.last_archived_game_id = id`. This single hook covers victory **and** turn-limit (reason `"score"`). +- `OBSERVER`/`AI_ARENA` runs: same path (recorder created at start regardless of cast). + +### C3. Archive save/export listener autoload (the missing piece for the footer buttons) +File: `src/game/engine/src/autoloads/replay_archive_listener.gd` (new) + register in `src/game/project.godot` `[autoload]`. +- Connects `EventBus.request_archive_save` → re-write current `_recorder` (rename) and `EventBus.request_archive_export_json(path)` → `GdReplayArchive.export_game(...)`. (Pulls the live recorder reference from `turn_manager`, or turn_manager passes it on game-over.) + +### C4. Replay navigation stubs → real +- `past_games.gd:146-153 _on_watch_replay_pressed`: replace the `push_warning` with `GameState.pending_replay_game_id = game_id` + `main.change_scene("res://engine/scenes/menus/replay_viewer.tscn")` (mirror `end_game_summary.gd:392-403`, which is already correct once `last_archived_game_id` exists). +- `end_game_summary.gd` `_on_watch_replay` already works once C1+C2 set the id. + +**Phase C is pure presentation — no sim/IO/stats math in GDScript (Rail-3 honored).** + +--- + +## PHASE D — p3-31 visual proof (CLOUD/RUN host) + +- Build GDExtension for the RUN platform, run a headless N-turn `OBSERVER` (or `AI_ARENA`) game to completion → assert an archive dir appears under `/age-of-dwarves//` with `meta.json`+`history.bin`, `GdReplayArchive.list()` returns it. +- Drive `end_game_summary` "Watch Replay" → `replay_viewer` chronicle (standings ladder + event feed) renders the **real** match. `tools/screenshot.sh` → `$SCREENSHOT_HOST`; review in conversation (phase-gate ritual). **p3-31 done.** + +--- + +## p3-32 — Visual map playback (after p3-31 lands) + +### Rust (LOCAL/plum, cargo) — do first, fully testable +File: `src/simulator/crates/mc-replay/src/history.rs` — two pure projections mirroring `standings_at`: +```rust +pub struct CityMarker { pub hex: TileCoord, pub clan_id: ClanId, pub name: String } +pub fn cities_at(&self, turn: u32) -> Vec; // fold CityFounded/CityCaptured ≤ turn +pub struct UnitPing { pub hex: TileCoord, pub kind: PingKind } // Spawn/Death/Capture +pub fn unit_pings_at(&self, turn: u32) -> Vec; // UnitCreated/Killed/Captured == turn +``` +Unit tests in `history.rs` (`cargo test -p mc-replay`). + +Bridge: `GdReplayPlayer` (`replay.rs:756`) — add `#[func] cities_at(turn) -> Array`, `unit_pings_at(turn) -> Array`, and `seed()/map_kind()/map_size()` accessors for terrain regen. + +### GDScript (RUN host for proof) +`replay_viewer.gd`: in `_ready`/`_goto_turn`, regenerate terrain from `_player.seed()` via the **existing worldgen bridge new-game uses** (deterministic — Rail-1, no GDScript worldgen), mount `hex_renderer` god-view (fog off), and paint `_player.cities_at(t)` via `city_renderer` + `unit_pings_at(t)` as transient pings. Extend `scenes/tests/proof_replay_viewer.tscn/.gd`. Proof screenshot → review. + +### Stretch — `TurnEvent::UnitMoved` (schema bump) +`event.rs`: add `UnitMoved { turn, unit_id, from: TileCoord, to: TileCoord }`; **bump `HISTORY_SCHEMA_VERSION` 1→2** (`archive.rs:39`); emit at every `mc-turn` move site; record via the existing recorder; add `GameHistory::unit_positions_at(turn)`. Gated — ship marker tiers first (old fixtures become `SchemaMismatch`; acceptable, archives are advisory). + +--- + +## Cheapest correct ordering + +1. **A1 + A2 + A3 + A4** (all cargo, plum) — the recorder, snapshot derivation, both required Rust tests. Highest value, zero Godot risk, satisfies two acceptance bullets outright. +2. **B1 + B2** (bridge; compiles on plum). +3. **C1–C4** (thin GDScript triggers + navigation). +4. **D** (RUN host p3-31 proof) → close p3-31. +5. **p3-32 Rust projections + tests** (plum) → bridge → GDScript → RUN proof. UnitMoved stretch absolutely last. + +Rationale: every line of stats/IO/projection logic is cargo-verified on plum before a single Godot run; the RUN host is touched only for the two visual proofs. + +--- + +## Ambiguities needing an owner decision + +1. **`gold_per_turn` / `culture_per_turn` source.** Recommend caching both on `PlayerState`, written at the tail of `TurnProcessor::step` (values already computed there). Alternative (recompute in recorder) duplicates economy math. Also: snapshot's `culture_per_turn` is `f32` but `culture_total` is `i64` on `PlayerState` — confirm the per-turn delta is captured before it's folded into the total. +2. **`ClanDescriptor` display metadata (name/sigil/colour) origin.** Recommend GDScript supplies a `clans` Array at `GdGameRecorder.start` (it already owns clan colours for the chronicle). Alternative: Rust derives from `GameState`+pack (heavier, needs pack access in the bridge). +3. **Recorder lifecycle owner.** Recommend `turn_manager` owns the `GdGameRecorder` (sole caller of `step`); a tiny `replay_archive_listener` autoload only services the save/export buttons. Alternative: a full `GameRecorder` autoload owning everything. +4. **`step` API.** Recommend new non-breaking `step_recording` over changing `step`'s signature (avoids touching all GUT/sim callers). +5. **Determinism assertion surface.** Snapshots carry `f32` (army_strength/score/culture_per_turn). Same-seed/same-controller is bit-identical only if the sim is float-deterministic (PCG64 pin per `WORLDGEN_RNG.md`) **and** the recorder appends clans in sorted `clan_id` order (specified in A2). Confirm no `HashMap` iteration order reaches snapshot ordering. +6. **Archive is god-view.** Recommend the recorder records **all** clans every turn (no contact filter); `snapshots_visible_to`/met-set filtering stays a read-time projection. Confirm this is desired for human-cast games (OBSERVER/ARENA are god-view anyway). +7. **p3-32 schema bump.** `UnitMoved` forces `HISTORY_SCHEMA_VERSION` 1→2 → all pre-existing fixtures/archives read as `SchemaMismatch`. Acceptable per the "archives are advisory" doctrine, but it invalidates `write_fixture` output until regenerated — confirm. + +Key files: recorder `src/simulator/crates/mc-player-api/src/recorder.rs` (new); `mc-replay` tests `.../mc-replay/tests/recorder_roundtrip.rs` (new) + projections in `.../mc-replay/src/history.rs`; bridge `src/simulator/api-gdext/src/replay.rs` + `.../lib.rs:6754`; GDScript `src/game/engine/src/autoloads/turn_manager.gd:304/406`, `.../game_state.gd`, `.../scenes/menus/past_games.gd:146`, new `.../autoloads/replay_archive_listener.gd`.