From 40bef858f353b2085fee820cb0d4b20abb86fcc4 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:27:36 +0200 Subject: [PATCH] Aufzeichnung und Wiederholung von Sitzungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eine Aufzeichnung hält den Startzustand und die Befehle jedes Takts fest. Wiederholen ergibt exakt dieselbe Welt; verify meldet, wenn das Ende von der aufgezeichneten Prüfsumme abweicht. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/replay.rs | 183 +++++++++++++++++++++++++++++++++++++++++++ core/tests/replay.rs | 135 +++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 core/src/replay.rs create mode 100644 core/tests/replay.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index fd07e7f..d168edc 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod inventory; pub mod map; pub mod mapgen; pub mod player; +pub mod replay; pub mod rng; pub mod snapshot; pub mod world; diff --git a/core/src/replay.rs b/core/src/replay.rs new file mode 100644 index 0000000..96f8f8a --- /dev/null +++ b/core/src/replay.rs @@ -0,0 +1,183 @@ +//! Recording of a session: the starting state and every command, tick by +//! tick. Replaying a recording reproduces the session exactly. + +use std::fmt; + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::command::Command; +use crate::snapshot::SnapshotError; +use crate::world::World; + +const MAGIC: [u8; 4] = *b"GRWR"; +/// Version of the recording format. +pub const RECORDING_VERSION: u32 = 1; + +/// Reason a recording could not be read or replayed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RecordingError { + /// The bytes do not start with the recording marker. + NotARecording, + /// The recording was written by an incompatible version of the game. + UnsupportedVersion(u32), + /// The recording is damaged. + Corrupt(DecodeError), + /// The starting state inside the recording could not be restored. + BadStart(SnapshotError), + /// Replaying did not end in the recorded state. + Diverged { expected: u64, actual: u64 }, +} + +impl fmt::Display for RecordingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RecordingError::NotARecording => write!(f, "not a recording"), + RecordingError::UnsupportedVersion(version) => { + write!(f, "unsupported recording version {version}") + } + RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"), + RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"), + RecordingError::Diverged { expected, actual } => write!( + f, + "replay ended in state {actual:016x} instead of {expected:016x}" + ), + } + } +} + +impl std::error::Error for RecordingError {} + +impl From for RecordingError { + fn from(error: DecodeError) -> Self { + RecordingError::Corrupt(error) + } +} + +/// The starting state of a session and the commands of every tick after it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Recording { + start: Vec, + tick_count: u32, + /// Ticks that had commands, as (tick index, commands), in rising order. + busy_ticks: Vec<(u32, Vec)>, +} + +impl Recording { + /// Starts a recording at the current state of `world`. + pub fn start(world: &World) -> Self { + Self { + start: world.to_snapshot(), + 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]) { + if !commands.is_empty() { + self.busy_ticks.push((self.tick_count, commands.to_vec())); + } + self.tick_count += 1; + } + + /// Number of recorded ticks. + pub fn tick_count(&self) -> u32 { + 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)?; + let mut busy = self.busy_ticks.iter().peekable(); + for index in 0..self.tick_count { + match busy.peek() { + Some((tick, commands)) if *tick == index => { + world.step(commands); + busy.next(); + } + _ => { + world.step(&[]); + } + } + } + Ok(world) + } + + /// Encodes the recording together with the state that replaying it must + /// reach. `end` is the world after the last recorded tick. + pub fn to_bytes(&self, end: &World) -> Vec { + let mut w = Writer::new(); + w.raw(&MAGIC); + w.u32(RECORDING_VERSION); + w.u64(end.state_hash()); + w.bytes(&self.start); + w.u32(self.tick_count); + w.u32(u32::try_from(self.busy_ticks.len()).expect("busy tick count fits in u32")); + for (tick, commands) in &self.busy_ticks { + w.u32(*tick); + w.u16(u16::try_from(commands.len()).expect("command count fits in u16")); + for command in commands { + command.encode(&mut w); + } + } + w.into_bytes() + } + + /// Decodes a recording and the state hash that replaying it must reach. + pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> { + let mut r = Reader::new(bytes); + let magic = r + .raw(MAGIC.len()) + .map_err(|_| RecordingError::NotARecording)?; + if magic != MAGIC.as_slice() { + return Err(RecordingError::NotARecording); + } + let version = r.u32()?; + if version != RECORDING_VERSION { + return Err(RecordingError::UnsupportedVersion(version)); + } + let expected_hash = r.u64()?; + let start = r.bytes()?.to_vec(); + let tick_count = r.u32()?; + let busy_count = r.u32()?; + let mut busy_ticks = Vec::new(); + let mut first_free_tick = 0; + for _ in 0..busy_count { + let tick = r.u32()?; + if tick < first_free_tick || tick >= tick_count { + return Err(DecodeError::InvalidValue("tick index").into()); + } + first_free_tick = tick + 1; + let command_count = r.u16()?; + if command_count == 0 { + return Err(DecodeError::InvalidValue("command count").into()); + } + let mut commands = Vec::new(); + for _ in 0..command_count { + commands.push(Command::decode(&mut r)?); + } + busy_ticks.push((tick, commands)); + } + r.finish()?; + Ok(( + Self { + start, + tick_count, + busy_ticks, + }, + expected_hash, + )) + } + + /// Replays `bytes` and checks that the recorded final state is reached. + pub fn verify(bytes: &[u8]) -> Result { + let (recording, expected) = Self::from_bytes(bytes)?; + let world = recording.replay()?; + let actual = world.state_hash(); + if actual == expected { + Ok(world) + } else { + Err(RecordingError::Diverged { expected, actual }) + } + } +} diff --git a/core/tests/replay.rs b/core/tests/replay.rs new file mode 100644 index 0000000..7df5888 --- /dev/null +++ b/core/tests/replay.rs @@ -0,0 +1,135 @@ +use grovewright_core::codec::DecodeError; +use grovewright_core::command::Command; +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::world::World; + +const ADA: PlayerId = PlayerId(0); + +/// Commands of tick `tick` in a short scripted session. +fn commands_for(world: &World, tick: u32) -> Vec { + if tick == 0 { + vec![Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }] + } else if tick.is_multiple_of(7) { + vec![Command::Dig { + player: ADA, + pos: outcrop_origin(world.map()), + }] + } else { + vec![] + } +} + +/// Plays 200 ticks from a fresh world and records them. +fn recorded_session() -> (Recording, World) { + let mut world = World::new(11); + 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, world) +} + +#[test] +fn replaying_reaches_the_same_state() { + let (recording, world) = recorded_session(); + assert_eq!(recording.tick_count(), 200); + assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1); + assert_eq!(recording.replay(), Ok(world)); +} + +#[test] +fn a_recording_survives_encoding() { + let (recording, world) = recorded_session(); + let bytes = recording.to_bytes(&world); + let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap(); + assert_eq!(decoded, recording); + assert_eq!(expected_hash, world.state_hash()); +} + +#[test] +fn verify_accepts_a_faithful_recording() { + let (recording, world) = recorded_session(); + assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world)); +} + +#[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); + match Recording::verify(&bytes) { + Err(RecordingError::Diverged { expected, actual }) => { + assert_eq!(expected, world.state_hash()); + assert_ne!(actual, expected); + } + other => panic!("expected a divergence, got {other:?}"), + } +} + +#[test] +fn a_recording_can_start_in_the_middle_of_a_session() { + let mut world = World::new(11); + for tick in 0..50 { + let commands = commands_for(&world, tick); + world.step(&commands); + } + let mut recording = Recording::start(&world); + for tick in 50..120 { + let commands = commands_for(&world, tick); + recording.push_tick(&commands); + world.step(&commands); + } + assert_eq!(recording.tick_count(), 70); + assert_eq!(recording.replay(), Ok(world)); +} + +#[test] +fn an_empty_recording_replays_to_its_starting_state() { + let world = World::new(3); + let recording = Recording::start(&world); + assert_eq!(recording.tick_count(), 0); + assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world)); +} + +#[test] +fn damaged_recordings_are_rejected() { + let (recording, world) = recorded_session(); + let bytes = recording.to_bytes(&world); + + assert_eq!( + Recording::from_bytes(b""), + Err(RecordingError::NotARecording) + ); + assert_eq!( + Recording::from_bytes(&world.to_snapshot()), + Err(RecordingError::NotARecording) + ); + + let mut other_version = bytes.clone(); + other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes()); + assert_eq!( + Recording::from_bytes(&other_version), + Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1)) + ); + + assert_eq!( + Recording::from_bytes(&bytes[..bytes.len() - 1]), + Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd)) + ); + + let mut padded = bytes.clone(); + padded.push(0); + assert_eq!( + Recording::from_bytes(&padded), + Err(RecordingError::Corrupt(DecodeError::TrailingBytes)) + ); +}