test(mc-replay): p3-31 multi-turn GameHistory round-trip + ladder projection
Some checks are pending
ci / regression gate (push) Waiting to run
deploy-next / deploy dev guide to mc.next.black.lan (push) Waiting to run

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) <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-30 03:13:00 -04:00
parent 9267d056d2
commit 0dd2ab0335

View file

@ -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<u32> = 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<u32> = 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<u32> = 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");
}