From 5dacbf22225890075a30df20a22a616f4ddd9b9b Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Thu, 1 Oct 2026 10:34:30 +0200 Subject: [PATCH] Aufzeichnung an den Weltschritt koppeln MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording::step führt einen Takt aus und zeichnet ihn auf. Andere Befehle als die ausgeführten können so nicht in eine Aufzeichnung geraten. Aufzeichnung und Welt prüfen gegenseitig ihren Takt, und ein beschädigter Startzustand fällt schon beim Einlesen auf. Co-Authored-By: Claude Haiku 4.5 --- core/src/replay.rs | 43 ++++++++++++++++++++++--- core/tests/determinism.rs | 3 +- core/tests/golden.rs | 3 +- core/tests/replay.rs | 67 +++++++++++++++++++++++++++++++++------ 4 files changed, 97 insertions(+), 19 deletions(-) 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)) + ); +}