feat(@projects/@magic-civilization): add unit-spawn event coverage tests

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-11 20:32:28 -07:00
parent 245167af19
commit 9b8f001a6f
3 changed files with 258 additions and 37 deletions

View file

@ -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<u8> = 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_<player>_<idx>`. 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::<u8>() {
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()
);

View file

@ -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",

View file

@ -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<String, u8> = 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<usize> =
state.players.iter().map(|p| p.units.len()).collect();
// Cumulative event counts per player slot.
let mut created_cum: Vec<u32> = vec![0; state.players.len()];
let mut captured_cum: Vec<u32> = vec![0; state.players.len()];
let mut killed_cum: Vec<u32> = 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::<u32>() > 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_<pi>_<idx>`; got {}",
name.0,
);
}
}