From 0dd2ab0335514685e0c2b1e70bb55c1fff386702 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 03:13:00 -0400 Subject: [PATCH] test(mc-replay): p3-31 multi-turn GameHistory round-trip + ladder projection First p3-31 increment: a multi-turn GameHistory built the way the live recorder will (one TurnSnapshot per clan per turn in sorted clan-id order; events flushed through TurnEventCollector) survives write_game -> read_game byte-for-byte, and standings_at projects the recorded ladder ranked by score. Adds a schema-level determinism check (identical recorded inputs -> byte-identical bincode). Satisfies the 'cargo test -p mc-replay round-trip' acceptance bullet. Verified on the DO fleet (worker mc-test-0 booted from golden mc-golden-20260630065154, repo pulled from the migrated forge): cargo test -p mc-replay -> 11 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mc-replay/tests/recorder_roundtrip.rs | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs diff --git a/src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs b/src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs new file mode 100644 index 00000000..a5d8432e --- /dev/null +++ b/src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs @@ -0,0 +1,141 @@ +//! p3-31 acceptance: a multi-turn [`GameHistory`] — built the way the live +//! recorder builds it (one [`TurnSnapshot`] per clan per turn, events flushed +//! through a [`TurnEventCollector`]) — survives a `write_game` → `read_game` +//! round-trip byte-for-byte, and [`GameHistory::standings_at`] projects the +//! recorded ladder. This is the `cargo test -p mc-replay` gate; the recorder +//! itself (mc-player-api) has its own determinism test against the live sim. + +use mc_replay::{ + read_game, write_game, ClanDescriptor, ClanId, GameHistory, GameId, GameOutcome, LeaderId, + MapDescriptor, PackId, PackVersion, TileCoord, TurnEvent, TurnEventCollector, TurnSnapshot, +}; + +const PACK: &str = "age-of-dwarves"; +const TURNS: u32 = 3; + +fn clan(id: u32, name: &str) -> ClanDescriptor { + ClanDescriptor { + id: ClanId(id), + name: name.into(), + sigil_key: format!("sigil_{id}"), + colour_rgba: 0x1020_30FF + id, + starting_leader: LeaderId(format!("leader_{id}")), + } +} + +/// One snapshot row. `score` grows with both clan and turn so the final-turn +/// ranking is deterministic and non-trivially ordered. +fn snap(turn: u32, clan: u32) -> TurnSnapshot { + TurnSnapshot { + turn, + clan_id: ClanId(clan), + population: 100 * clan + turn, + cities: clan, + army_strength: 10.0 * clan as f32 + turn as f32, + gold: 50 * clan as i64, + gold_per_turn: 5 * clan as i64, + culture_per_turn: clan as f32, + tech_count: turn, + land_area: 10 * clan + turn, + buildings_built_total: clan * turn, + culture_total: clan as f32 * turn as f32, + score: 100.0 * clan as f32 + 10.0 * turn as f32, + } +} + +/// Build a complete history exactly as the recorder would: snapshots appended +/// per turn in **sorted clan-id order** (the determinism guard), events pushed +/// into a collector and flushed (sorted by turn) at game-end. +fn build_history(clans: &[ClanDescriptor]) -> GameHistory { + let mut hist = GameHistory::new( + GameId::new_v4(), + PackId(PACK.into()), + PackVersion("0.1.0-test".into()), + 4242, + MapDescriptor { kind: "continents".into(), width: 48, height: 32 }, + clans.to_vec(), + ); + + for turn in 1..=TURNS { + let mut ids: Vec = clans.iter().map(|c| c.id.0).collect(); + ids.sort_unstable(); + for id in ids { + hist.snapshots.push(snap(turn, id)); + } + } + + // A small event stream, intentionally pushed out of turn order to prove the + // collector's by-turn sort. + let mut col = TurnEventCollector::new(); + col.push(TurnEvent::WarDeclared { turn: 2, aggressor: ClanId(1), target: ClanId(3) }); + col.push(TurnEvent::CityFounded { + turn: 1, + clan: ClanId(2), + hex: TileCoord::new(4, 4), + name: mc_replay::CityName("Hold".into()), + }); + col.push(TurnEvent::CityFounded { + turn: 3, + clan: ClanId(3), + hex: TileCoord::new(9, 2), + name: mc_replay::CityName("Deeps".into()), + }); + col.flush_to_history(&mut hist); + + hist.final_turn = TURNS; + hist.outcome = GameOutcome::Victor { clan: 3, reason: "domination".into(), turn: TURNS }; + hist +} + +#[test] +fn multi_turn_history_round_trips_and_projects_ladder() { + let clans = vec![clan(1, "Ironhold"), clan(2, "Goldvein"), clan(3, "Deepforge")]; + let hist = build_history(&clans); + + // Shape sanity before persisting. + assert_eq!(hist.snapshots.len(), (TURNS * clans.len() as u32) as usize); + assert_eq!(hist.events.len(), 3); + let event_turns: Vec = hist.events.iter().map(TurnEvent::turn).collect(); + assert_eq!(event_turns, vec![1, 2, 3], "collector flush sorts events by turn"); + + // write → read is lossless. + let tmp = tempfile::tempdir().unwrap(); + let pack = PackId(PACK.into()); + let game_id = hist.game_id; + write_game(tmp.path(), &hist, "Round Trip".into(), "2026-06-30T00:00:00Z".into()).unwrap(); + let read = read_game(tmp.path(), &pack, game_id).unwrap(); + assert_eq!(read, hist, "GameHistory survives write_game → read_game byte-for-byte"); + + // standings_at(final) ranks every clan by score; clan 3 has the top score. + let ladder = read.standings_at(TURNS); + assert_eq!(ladder.len(), clans.len(), "every clan appears in the final ladder"); + assert_eq!(ladder[0].clan_id, ClanId(3)); + assert_eq!(ladder[0].rank, 1); + let ranks: Vec = ladder.iter().map(|r| r.rank).collect(); + assert_eq!(ranks, vec![1, 2, 3]); + // Ranking is score-descending. + for w in ladder.windows(2) { + assert!(w[0].score >= w[1].score, "ladder is sorted by score descending"); + } + // The projected row carries the latest snapshot's turn for each clan. + assert!(ladder.iter().all(|r| r.turn == TURNS)); +} + +#[test] +fn identical_construction_is_byte_identical() { + // Determinism at the schema level: the same recorded inputs serialize to the + // same bytes (the recorder's full same-seed determinism test lives in + // mc-player-api, but this pins the snapshot/event ordering contract). + let clans = vec![clan(1, "Ironhold"), clan(2, "Goldvein"), clan(3, "Deepforge")]; + let mut a = build_history(&clans); + let mut b = build_history(&clans); + // game_id is a random UUID; equalise it so we compare the recorded payload. + let fixed = GameId::new_v4(); + a.game_id = fixed; + b.game_id = fixed; + + let cfg = bincode::config::standard(); + let bytes_a = bincode::serde::encode_to_vec(&a, cfg).unwrap(); + let bytes_b = bincode::serde::encode_to_vec(&b, cfg).unwrap(); + assert_eq!(bytes_a, bytes_b, "identical recorded inputs are byte-identical"); +}