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) <noreply@anthropic.com>
This commit is contained in:
parent
9267d056d2
commit
0dd2ab0335
1 changed files with 141 additions and 0 deletions
141
src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs
Normal file
141
src/simulator/crates/mc-replay/tests/recorder_roundtrip.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue