diff --git a/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs b/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs index f21a5388..70fe25d0 100644 --- a/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs +++ b/src/simulator/crates/mc-player-api/tests/full_game_transcript.rs @@ -505,17 +505,18 @@ fn write_recap(out_dir: &Path, summaries: &[TurnSummary], outcome: &DriveOutcome .iter() .any(|(slot, _, cities, _)| *slot == 0 && *cities >= 2) }); - let ai_unit_by_10 = summaries - .iter() - .take(11) - .any(|s| { - s.endturn_events - .iter() - .any(|e| matches!(e, Event::CityUnitCompleted { .. } | Event::UnitCreated { .. })) - || s.score_snapshot - .iter() - .any(|(slot, _, _, units)| *slot != 0 && *units > 4) - }); + // mc-replay-followup-unit-spawn-events: every PlayerState.units.push + // in TurnProcessor::step now emits Event::UnitCreated (+ for city + // production, Event::CityUnitCompleted). Recap reads the event stream + // directly — no observational fallback. + let ai_unit_by_10 = summaries.iter().take(11).any(|s| { + s.endturn_events.iter().any(|e| { + matches!( + e, + Event::CityUnitCompleted { .. } | Event::UnitCreated { .. } + ) + }) + }); let movement_by_25 = summaries.iter().any(|s| { s.endturn_events.iter().any(|e| { matches!( @@ -755,25 +756,24 @@ fn claude_vs_ai_full_game_transcript() { ); // ── Hard constraint 3: AI builds ≥ 1 unit by turn 10 ──────────────── - // Two detection paths: - // (a) `Event::CityUnitCompleted` / `Event::UnitCreated` for an AI - // owner in an EndTurn batch ≤ turn 10. This is the - // semantically-correct check, but mc-turn's unit-spawn paths - // do not always translate into `TurnEvent::UnitCreated` / - // `TurnEvent::CityUnitCompleted` (some paths mutate - // `PlayerState.units` directly without emitting a replay - // event the dispatch layer can pick up). Tracked as a - // follow-up — the events-coverage gap is residual. - // (b) AI slot's `units` count increases above its initial value - // across the score_snapshot history. This is the - // observational check: if `PlayerState.units` grew, the AI - // built units regardless of whether the wire event fired. + // mc-replay-followup-unit-spawn-events: every `PlayerState.units.push` + // in `TurnProcessor::step` now emits a chronicle entry that the + // dispatch layer translates into `Event::UnitCreated` (and, when a + // city was the originating queue/production source, also + // `Event::CityUnitCompleted`). The observational `PlayerState.units` + // growth fallback that lived here is gone — the event stream alone + // is now contract. let mut ai_owners_with_units: HashSet = HashSet::new(); for s in summaries.iter().take(11) { for ev in &s.endturn_events { match ev { Event::CityUnitCompleted { city_id, .. } => { - if let Some(slot_str) = city_id.split('_').next() { + // City name format: `city__`. Split on + // `_` and take the SECOND field (skip the literal + // "city" prefix) to recover the slot. + let mut parts = city_id.split('_'); + let _ = parts.next(); + if let Some(slot_str) = parts.next() { if let Ok(slot) = slot_str.parse::() { if slot != 0 { ai_owners_with_units.insert(slot); @@ -788,20 +788,10 @@ fn claude_vs_ai_full_game_transcript() { } } } - // Observational fallback — compare unit count growth on each AI slot. - // Initial count is 4 (3 warriors + 1 founder per harness build). - const HARNESS_INITIAL_UNITS: u32 = 4; - for s in summaries.iter().take(11) { - for (slot, _gold, _cities, units) in &s.score_snapshot { - if *slot != 0 && *units > HARNESS_INITIAL_UNITS { - ai_owners_with_units.insert(*slot); - } - } - } assert!( !ai_owners_with_units.is_empty(), - "no AI slot built a unit by turn 10 (neither via Event nor unit-count growth); \ - transcript at {}", + "no AI slot built a unit by turn 10 via Event::UnitCreated / \ + Event::CityUnitCompleted in the wire event stream; transcript at {}", out_dir.join("transcript.jsonl").display() ); diff --git a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs index 5603d40e..4ad71da4 100644 --- a/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs +++ b/src/simulator/crates/mc-turn/tests/event_collector_wiring.rs @@ -207,6 +207,7 @@ fn ten_turn_run_emits_each_wired_variant() { TurnEvent::WonderBuilt { .. } => "WonderBuilt", TurnEvent::CityCaptured { .. } => "CityCaptured", TurnEvent::UnitKilled { .. } => "UnitKilled", + TurnEvent::UnitCreated { .. } => "UnitCreated", TurnEvent::TechResearched { .. } => "TechResearched", TurnEvent::WarDeclared { .. } => "WarDeclared", TurnEvent::PeaceSigned { .. } => "PeaceSigned", diff --git a/src/simulator/crates/mc-turn/tests/unit_spawn_event_coverage.rs b/src/simulator/crates/mc-turn/tests/unit_spawn_event_coverage.rs new file mode 100644 index 00000000..43967955 --- /dev/null +++ b/src/simulator/crates/mc-turn/tests/unit_spawn_event_coverage.rs @@ -0,0 +1,230 @@ +//! `mc-replay-followup-unit-spawn-events`: every `PlayerState.units.push` +//! inside `TurnProcessor::step` must emit a chronicle entry on +//! `TurnResult.events_emitted` so replay reconstructors can rebuild the +//! unit ledger from the event stream alone — no observational fallbacks. +//! +//! Coverage contract: +//! For each player slot, for each turn: +//! `PlayerState.units.len()` cumulative growth +//! ≡ cumulative count of `TurnEvent::UnitCreated { clan == slot, .. }` +//! plus `TurnEvent::UnitCaptured { captor == slot, .. }` +//! minus `TurnEvent::UnitKilled { defender == slot, .. }`. +//! +//! The test drives ~12 turns on a fixture that exercises: +//! (a) bench `try_spawn_unit` (production_stored >= cost → emits +//! `UnitCreated` with `city: Some(...)`), +//! (b) `process_ransom_expiry` → `UnitCaptured` (ownership transfer), +//! (c) `transfer_captured_unit` via PvP capture posture → `UnitCaptured`. +//! +//! Determinism: two back-to-back runs of the same fixture produce +//! byte-identical event-count vectors. + +use mc_ai::evaluator::ScoringWeights; +use mc_city::CityState; +use mc_replay::TurnEvent; +use mc_turn::{GameState, PlayerState, TurnProcessor}; +use std::collections::BTreeMap; + +/// Two-player fixture rigged so bench `try_spawn_unit` fires every turn +/// for both slots: production_stored seeded above the spawn cost, and +/// production yield set high enough to refill between turns. +fn spawn_coverage_fixture() -> GameState { + let mut state = GameState::default(); + for pi in 0..2u8 { + let mut axes: BTreeMap = BTreeMap::new(); + axes.insert("wealth".into(), 3); + axes.insert("production".into(), 9); + axes.insert("expansion".into(), 1); + axes.insert("culture".into(), 1); + + let mut city = CityState::starter(); + // Seed enough stored production for ≥1 spawn on T1 immediately, + // and high prod_yield so subsequent turns refill in lock-step. + city.production_stored = 500; + city.prod_yield = 200; + + let pos = (pi as i32 * 16, 0); + let ps = PlayerState { + player_index: pi, + gold: 100, + cities: vec![city], + unit_upkeep: vec![], + strategic_axes: axes, + scoring_weights: ScoringWeights::default(), + expansion_points: 0, + city_buildings: vec![vec![]], + city_improvements: Default::default(), + city_ecology: vec![Default::default()], + tech_state: None, + science_yield: 0, + science_pool: 0, + player_tech: None, + units: vec![], + city_positions: vec![pos], + capital_position: Some(pos), + culture_total: 0, + culture_pool: mc_culture::CulturePool::default(), + arcane_lore_pop_deducted: false, + traded_luxuries: Default::default(), + relations: Default::default(), + strategic_ledger: Default::default(), + wonders_built: Default::default(), + explored_deposits: Default::default(), + ..Default::default() + }; + state.players.push(ps); + } + state.next_unit_id = 1; + state +} + +/// Walk a step-by-step run, asserting per-player per-turn that the +/// cumulative `units.len()` growth equals +/// (cumulative `UnitCreated{clan=pi}` + cumulative `UnitCaptured{captor=pi}`) +/// − (cumulative `UnitKilled{defender=pi}`). +#[test] +fn every_units_push_in_step_emits_unit_created_event() { + let processor = TurnProcessor::new(200); + let mut state = spawn_coverage_fixture(); + let starting_counts: Vec = + state.players.iter().map(|p| p.units.len()).collect(); + + // Cumulative event counts per player slot. + let mut created_cum: Vec = vec![0; state.players.len()]; + let mut captured_cum: Vec = vec![0; state.players.len()]; + let mut killed_cum: Vec = vec![0; state.players.len()]; + + const TURNS: u32 = 12; + for _ in 0..TURNS { + let result = processor.step(&mut state); + + // Accumulate event-side counts. + for ev in &result.events_emitted { + match ev { + TurnEvent::UnitCreated { clan, .. } => { + let pi = clan.0 as usize; + if pi < created_cum.len() { + created_cum[pi] += 1; + } + } + TurnEvent::UnitCaptured { captor, .. } => { + let pi = captor.0 as usize; + if pi < captured_cum.len() { + captured_cum[pi] += 1; + } + } + TurnEvent::UnitKilled { defender, .. } => { + let pi = defender.0 as usize; + if pi < killed_cum.len() { + killed_cum[pi] += 1; + } + } + _ => {} + } + } + + // Per-turn invariant: growth ≡ created + captured − killed. + for (pi, ps) in state.players.iter().enumerate() { + let observed_delta = ps.units.len() as i64 - starting_counts[pi] as i64; + let event_delta = + created_cum[pi] as i64 + captured_cum[pi] as i64 - killed_cum[pi] as i64; + assert_eq!( + observed_delta, event_delta, + "slot {pi}: observed units delta = {observed_delta}, \ + event-stream delta = {event_delta} \ + (created={}, captured={}, killed={}) — \ + every `players[{pi}].units.push` in TurnProcessor::step \ + must emit a UnitCreated/UnitCaptured chronicle entry.", + created_cum[pi], captured_cum[pi], killed_cum[pi], + ); + } + } + + // Liveness: bench production must actually fire UnitCreated emissions + // (the contract above is trivially satisfied by zero-zero too). + assert!( + created_cum.iter().sum::() > 0, + "fixture rigged for ≥1 UnitCreated per turn must produce >0 \ + events across {TURNS} turns; got cum={created_cum:?}", + ); +} + +/// Determinism — running the same fixture twice produces byte-identical +/// event-count vectors. Guards against RNG / hash-iteration leaks in the +/// emit sites. +#[test] +fn unit_created_event_counts_are_deterministic() { + fn run() -> Vec<(u32, u32, u32)> { + let processor = TurnProcessor::new(200); + let mut state = spawn_coverage_fixture(); + let n = state.players.len(); + let mut acc: Vec<(u32, u32, u32)> = vec![(0, 0, 0); n]; + for _ in 0..12 { + let result = processor.step(&mut state); + for ev in &result.events_emitted { + match ev { + TurnEvent::UnitCreated { clan, .. } => { + if let Some(slot) = acc.get_mut(clan.0 as usize) { + slot.0 += 1; + } + } + TurnEvent::UnitCaptured { captor, .. } => { + if let Some(slot) = acc.get_mut(captor.0 as usize) { + slot.1 += 1; + } + } + TurnEvent::UnitKilled { defender, .. } => { + if let Some(slot) = acc.get_mut(defender.0 as usize) { + slot.2 += 1; + } + } + _ => {} + } + } + } + acc + } + let a = run(); + let b = run(); + assert_eq!(a, b, "unit-spawn event counts must be deterministic across runs"); +} + +/// `try_spawn_unit` attributes the spawn to a city via the chronicle +/// entry's `city: Some(...)` field. The dispatch layer relies on that +/// to emit `Event::CityUnitCompleted` alongside `Event::UnitCreated`. +#[test] +fn try_spawn_unit_attaches_city_attribution() { + let processor = TurnProcessor::new(200); + let mut state = spawn_coverage_fixture(); + let result = processor.step(&mut state); + + let attributed: Vec<&TurnEvent> = result + .events_emitted + .iter() + .filter(|e| { + matches!( + e, + TurnEvent::UnitCreated { + city: Some(_), + .. + } + ) + }) + .collect(); + assert!( + !attributed.is_empty(), + "bench `try_spawn_unit` must attribute every spawn to a city; \ + got events={:?}", + result.events_emitted, + ); + for ev in attributed { + let TurnEvent::UnitCreated { city: Some(name), .. } = ev else { + unreachable!(); + }; + assert!( + name.0.starts_with("city_"), + "city attribution must follow `city__`; got {}", + name.0, + ); + } +}