diff --git a/core/src/replay.rs b/core/src/replay.rs index c0243d2..c5b8477 100644 --- a/core/src/replay.rs +++ b/core/src/replay.rs @@ -6,7 +6,7 @@ use std::fmt; use crate::codec::{DecodeError, Reader, Writer}; use crate::command::Command; use crate::snapshot::SnapshotError; -use crate::world::World; +use crate::world::{Event, World}; const MAGIC: [u8; 4] = *b"GRWR"; /// Version of the recording format. Raise it whenever the encoded recording, @@ -57,6 +57,7 @@ impl From for RecordingError { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Recording { start: Vec, + start_tick: u64, tick_count: u32, /// Ticks that had commands, as (tick index, commands), in rising order. busy_ticks: Vec<(u32, Vec)>, @@ -67,18 +68,33 @@ impl Recording { pub fn start(world: &World) -> Self { Self { start: world.to_snapshot(), + start_tick: world.tick(), tick_count: 0, busy_ticks: Vec::new(), } } - /// Records the commands of one tick. Call it once for every - /// [`World::step`], with the same commands. - pub fn push_tick(&mut self, commands: &[Command]) { + /// Advances `world` by one tick with `commands` and records them. This is + /// the only way to add to a recording, so it can never hold other + /// commands than the ones the world executed. + /// + /// # Panics + /// Panics if `world` is not at [`Recording::end_tick`], which means it is + /// not the world this recording follows. + pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec { + assert_eq!( + world.tick(), + self.end_tick(), + "the world is not at the tick this recording has reached" + ); if !commands.is_empty() { self.busy_ticks.push((self.tick_count, commands.to_vec())); } - self.tick_count += 1; + self.tick_count = self + .tick_count + .checked_add(1) + .expect("a recording holds fewer than 2^32 ticks"); + world.step(commands) } /// Number of recorded ticks. @@ -86,6 +102,11 @@ impl Recording { self.tick_count } + /// Tick of the recorded world after the last recorded tick. + pub fn end_tick(&self) -> u64 { + self.start_tick + u64::from(self.tick_count) + } + /// Restores the starting state and applies every recorded tick. pub fn replay(&self) -> Result { let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?; @@ -106,7 +127,15 @@ impl Recording { /// Encodes the recording together with the state that replaying it must /// reach. `end` is the world after the last recorded tick. + /// + /// # Panics + /// Panics if `end` is not at [`Recording::end_tick`]. pub fn to_bytes(&self, end: &World) -> Vec { + assert_eq!( + end.tick(), + self.end_tick(), + "the end state is not at the tick this recording has reached" + ); let mut w = Writer::new(); w.raw(&MAGIC); w.u32(RECORDING_VERSION); @@ -139,6 +168,9 @@ impl Recording { } let expected_hash = r.u64()?; let start = r.bytes()?.to_vec(); + let start_tick = World::from_snapshot(&start) + .map_err(RecordingError::BadStart)? + .tick(); let tick_count = r.u32()?; let busy_count = r.u32()?; let mut busy_ticks = Vec::new(); @@ -163,6 +195,7 @@ impl Recording { Ok(( Self { start, + start_tick, tick_count, busy_ticks, }, diff --git a/core/tests/determinism.rs b/core/tests/determinism.rs index 47adfb5..ba0831d 100644 --- a/core/tests/determinism.rs +++ b/core/tests/determinism.rs @@ -123,8 +123,7 @@ fn a_recorded_session_replays_to_the_same_hash() { let mut world = World::new(8); let mut recording = Recording::start(&world); for commands in &script { - recording.push_tick(commands); - world.step(commands); + recording.step(&mut world, commands); } let bytes = recording.to_bytes(&world); let replayed = Recording::verify(&bytes).expect("the recording replays cleanly"); diff --git a/core/tests/golden.rs b/core/tests/golden.rs index 5f272ad..a2a3632 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -194,8 +194,7 @@ fn the_recording_format_is_unchanged() { let mut world = World::new(SEED); let mut recording = Recording::start(&world); for commands in frozen_script(&world) { - recording.push_tick(&commands); - world.step(&commands); + recording.step(&mut world, &commands); } assert_eq!( fnv1a64(&recording.to_bytes(&world)), diff --git a/core/tests/replay.rs b/core/tests/replay.rs index 7df5888..51711cf 100644 --- a/core/tests/replay.rs +++ b/core/tests/replay.rs @@ -4,6 +4,7 @@ use grovewright_core::inventory::ITEM_STONE; use grovewright_core::mapgen::outcrop_origin; use grovewright_core::player::PlayerId; use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION}; +use grovewright_core::snapshot::SnapshotError; use grovewright_core::world::World; const ADA: PlayerId = PlayerId(0); @@ -31,12 +32,28 @@ fn recorded_session() -> (Recording, World) { let mut recording = Recording::start(&world); for tick in 0..200 { let commands = commands_for(&world, tick); - recording.push_tick(&commands); - world.step(&commands); + recording.step(&mut world, &commands); } (recording, world) } +#[test] +fn stepping_through_a_recording_gives_the_events_of_the_world() { + let mut plain = World::new(11); + let mut recorded = World::new(11); + let mut recording = Recording::start(&recorded); + for tick in 0..30 { + let commands = commands_for(&plain, tick); + assert_eq!( + recording.step(&mut recorded, &commands), + plain.step(&commands), + "tick {tick}" + ); + } + assert_eq!(recorded, plain); + assert_eq!(recording.end_tick(), 30); +} + #[test] fn replaying_reaches_the_same_state() { let (recording, world) = recorded_session(); @@ -62,14 +79,14 @@ fn verify_accepts_a_faithful_recording() { #[test] fn verify_reports_a_replay_that_ends_in_another_state() { - let (recording, mut world) = recorded_session(); - // One tick more than was recorded: the claimed end state is unreachable. - world.step(&[]); - let bytes = recording.to_bytes(&world); + let (recording, world) = recorded_session(); + let mut bytes = recording.to_bytes(&world); + // The expected state hash follows the marker and the version. + bytes[8] ^= 0x01; match Recording::verify(&bytes) { Err(RecordingError::Diverged { expected, actual }) => { - assert_eq!(expected, world.state_hash()); - assert_ne!(actual, expected); + assert_eq!(actual, world.state_hash()); + assert_ne!(expected, actual); } other => panic!("expected a divergence, got {other:?}"), } @@ -85,10 +102,10 @@ fn a_recording_can_start_in_the_middle_of_a_session() { let mut recording = Recording::start(&world); for tick in 50..120 { let commands = commands_for(&world, tick); - recording.push_tick(&commands); - world.step(&commands); + recording.step(&mut world, &commands); } assert_eq!(recording.tick_count(), 70); + assert_eq!(recording.end_tick(), 120); assert_eq!(recording.replay(), Ok(world)); } @@ -100,6 +117,23 @@ fn an_empty_recording_replays_to_its_starting_state() { assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world)); } +#[test] +#[should_panic(expected = "the world is not at the tick this recording has reached")] +fn a_recording_refuses_a_world_it_does_not_follow() { + let mut world = World::new(3); + let mut recording = Recording::start(&world); + world.step(&[]); + recording.step(&mut world, &[]); +} + +#[test] +#[should_panic(expected = "the end state is not at the tick this recording has reached")] +fn a_recording_refuses_an_end_state_at_another_tick() { + let (recording, mut world) = recorded_session(); + world.step(&[]); + recording.to_bytes(&world); +} + #[test] fn damaged_recordings_are_rejected() { let (recording, world) = recorded_session(); @@ -133,3 +167,16 @@ fn damaged_recordings_are_rejected() { Err(RecordingError::Corrupt(DecodeError::TrailingBytes)) ); } + +#[test] +fn a_recording_with_a_damaged_start_is_rejected() { + let (recording, world) = recorded_session(); + let mut bytes = recording.to_bytes(&world); + // The snapshot starts after marker, version, expected hash and its own + // length: 4 + 4 + 8 + 4 bytes. + bytes[20 + 10] ^= 0x01; + assert_eq!( + Recording::from_bytes(&bytes), + Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch)) + ); +}