docs(replay): source-verified blueprint for p3-31 + p3-32 (blocked on RUN host)
Some checks are pending
ci / regression gate (push) Waiting to run
Some checks are pending
ci / regression gate (push) Waiting to run
Game 1 EA is already release-ready; the only open objectives are the two
game1-stretch replay items. This blueprint (from the map-replay-subsystem ultracode
workflow: 6 parallel source-readers + Opus synthesis re-verified against the crates)
gives the surgical, ordered implementation plan — recorder in mc-player-api, round-trip
+ determinism cargo tests, GdGameRecorder bridge, GDScript triggers, then p3-32 map
projections — plus the 7 owner decisions to settle first.
Blocked: plum has no cargo toolchain, so all Rust verification + Godot proofs need
the cloud RUN host, which depends on the forge migration (ab8fd4d7) + a live golden
build. Execute when the fleet is reachable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab8fd4d707
commit
9267d056d2
1 changed files with 195 additions and 0 deletions
195
.project/designs/replay-p3-31-32-blueprint.md
Normal file
195
.project/designs/replay-p3-31-32-blueprint.md
Normal file
|
|
@ -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<TurnEvent>` 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<ClanDescriptor>) -> 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<GameRecorder> }
|
||||
#[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<Dictionary>); // 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<Dictionary> 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<GdGameState>,
|
||||
mut recorder: Gd<GdGameRecorder>) -> 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 `<archive_root>/age-of-dwarves/<uuid>/` 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<CityMarker>; // 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<UnitPing>; // 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<Dictionary>`, `unit_pings_at(turn) -> Array<Dictionary>`, 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<Dictionary> 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`.
|
||||
Loading…
Add table
Reference in a new issue