7.8 KiB
| id | title | priority | status | scope | category | owner | created | updated_at | completed_at | blocked_by | follow_ups | related | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| mc-replay-followup-unit-spawn-events | mc-turn unit-spawn event coverage — every PlayerState.units.push emits UnitCreated / CityUnitCompleted | p2 | done | game1 | tooling | simulator-infra | 2026-05-11 | 2026-05-11 | 2026-05-11 |
|
Context
Event::UnitCreated and Event::CityUnitCompleted are the canonical
wire markers for "a unit just entered the world." Replay and transcript
reconstructors (Claude Player API, headless test drivers, future debug
tooling) consume those events to rebuild the unit ledger without
re-running the simulator.
Today coverage is lossy. The 25-turn full_game_transcript.rs
mock run observed (recap.md):
- Slot 1 grew from 4 → 27 units across turns 0-16.
- Slot 2 grew from 4 → 5 units in the same window.
- Total
Event::CityUnitCompleted/Event::UnitCreatedlines in the transcript: a small fraction of the actual spawn count.
The transcript's hard-constraint check (constraint 3 — "AI builds ≥ 1
unit by turn 10") had to fall back to an observational check
(PlayerState.units.len() growth across score snapshots) because the
event-based check missed multiple known spawns. The check passes today
only because the fallback exists; remove it and constraint 3 fails
on a healthy run.
Replay reconstructors cannot rely on observational fallbacks — they
have no PlayerState to compare against, only the event stream.
Missing events → ghost units in the rebuilt ledger.
Source-of-truth rails
- Rust crate:
mc-turn— audit everystate.players[pi].units .push(...)site (and every helper that does so transitively). Each must emit the appropriate event intoTurnResult.events. - JSON path: no content changes.
- GDScript: no changes — GDScript already consumes
Event::UnitCreated/Event::CityUnitCompletedvia the wire decoder.
Acceptance bullets
- Audit grep — every
units.pushsite inmc-turn/src/**categorised (see## Audit resultsbelow). - Every (a) / (c) / (d) site emits the matching chronicle entry
(
TurnEvent::UnitCreatedfor net-new spawns,TurnEvent::UnitCapturedfor capture / ransom-rollover ownership transfers) with the monotonicMapUnit.id, owning clan asClanId(pi as u32), and hex(col, row)packed intoTileCoord. The dispatch layer translates each entry intoEvent::UnitCreatedand (whencity: Some(...)) alsoEvent::CityUnitCompleted. - Regression test
mc-turn/tests/unit_spawn_event_coverage.rsruns a 12-turn bench fixture and asserts the per-slot invariantgrowth ≡ created + captured − killedafter every turn. Equality of cumulative counts is the contract. 3 tests, 3 pass. full_game_transcript.rsconstraint 3 drops theHARNESS_INITIAL_UNITSobservational fallback (and the pre-recapscore_snapshotfallback in the recap-summary path) and relies solely on the event stream. The constraint still passes (claude_vs_ai_full_game_transcript ... ok).- Determinism —
unit_created_event_counts_are_deterministicruns the fixture twice and asserts byte-identical event-count vectors. Test passes. - No regression in mc-turn / mc-player-api existing tests
(
cargo test -p mc-turn --lib: 208 pass;cargo test -p mc-player-api: 87 lib + 1 integration pass).
Audit results
Every units.push site found across mc-turn, mc-combat,
mc-city, mc-player-api:
| File:line | Category | Action |
|---|---|---|
mc-turn/src/processor.rs:1266 (try_spawn_unit) |
(a) city production | Added TurnEvent::UnitCreated { city: Some(...) } |
mc-turn/src/processor.rs:1327 (spawn_unit_typed) |
(a) city production | Added TurnEvent::UnitCreated { city: Some(...) } |
mc-turn/src/processor.rs:2162 (transfer_captured_unit) |
(d) PvP capture | Already emits via pending_capture_events.drain_into |
mc-turn/src/processor.rs:2238 (process_ransom_expiry) |
(d) ransom rollover | Added TurnEvent::UnitCaptured emit (bypassed drain path) |
mc-turn/src/processor.rs:3715,4355,5312,5402,5497 |
(e) test fixtures | No event (cfg(test) / bench harness) |
mc-turn/src/building_action_handlers.rs:507 |
(e) test fixture | No event (cfg(test)) |
mc-player-api/src/dispatch.rs:1262 |
(e) test fixture | No event (cfg(test)) |
mc-player-api/src/projection.rs:785,796,1085,1091 |
(e) test fixtures | No event (cfg(test)) |
mc-player-api/tests/common/mod.rs:82 |
(e) test fixture | No event (test helper) |
mc-vision/src/lib.rs:583,598,619,640,666,685,719,752,773,776 |
(e) test fixtures | No event (cfg(test)) |
mc-turn/tests/ambient_encounter_integration.rs:62 |
(e) test fixture | No event (test setup) |
mc-turn/tests/event_collector_wiring.rs:102,116,128 |
(e) test fixture | No event (test setup) |
mc-turn/tests/abstract_projection.rs:152 |
(e) test fixture | No event (test setup) |
mc-city/src/production.rs:165 |
n/a (catalog insert) | def: UnitDef, not a MapUnit push — unrelated |
mc-turn/src/gpu/unit_movement.rs:426,427 |
n/a (scratch buffers) | gpu_units / cpu_units are local scratch, not state |
Production audit: 2 sites added UnitCreated emit; 1 site added
UnitCaptured emit; 1 site already correctly emitting; 0 missing.
Changes shipped
src/simulator/crates/mc-replay/src/event.rs— newTurnEvent::UnitCreated { turn, clan, unit_id, unit_kind, hex, city: Option<CityName> }variant;turn()accessor updated.src/simulator/crates/mc-turn/src/processor.rs— emit attry_spawn_unit(1266) andspawn_unit_typed(1327); emitTurnEvent::UnitCapturedatprocess_ransom_expiry(2238).src/simulator/crates/mc-turn/tests/unit_spawn_event_coverage.rs— new 3-test regression suite (growth-vs-events invariant, determinism, city-attribution).src/simulator/crates/mc-turn/tests/event_collector_wiring.rs— extend summary match withUnitCreatedarm (compile-time exhaustiveness preserved).src/simulator/crates/mc-player-api/src/dispatch.rs—translate_processor_eventsadds branches forTurnEvent ::UnitCreated(→Event::UnitCreated+ optionalEvent::CityUnitCompleted) andTurnEvent::UnitCaptured(→Event::UnitCreated { owner: captor }).src/simulator/crates/mc-player-api/tests/full_game_transcript.rs— dropHARNESS_INITIAL_UNITSfallback; recap and constraint-3 detection both read the event stream directly.src/simulator/api-gdext/src/replay.rs— exhaustive-match arm forTurnEvent::UnitCreated(GDExt replay viewer dictionary).
Effort
~half a dev-day (audit + per-site event emission patches + one coverage test + transcript-driver fallback removal).
Out of scope
Event::UnitDestroyedcoverage — the kill paths in mc-turn are separately tracked; the swap_remove sites have their own audit.- Wire-event serialisation changes — the event shapes are stable per p2-67; this objective only ensures the emit sites are complete.