diff --git a/core/src/lib.rs b/core/src/lib.rs index 13f3dcf..fd07e7f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,4 +14,5 @@ pub mod map; pub mod mapgen; pub mod player; pub mod rng; +pub mod snapshot; pub mod world; diff --git a/core/src/snapshot.rs b/core/src/snapshot.rs new file mode 100644 index 0000000..42a4b6b --- /dev/null +++ b/core/src/snapshot.rs @@ -0,0 +1,109 @@ +//! Complete, versioned encoding of a world. Saves, joining players and state +//! checksums all use it. + +use std::fmt; + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::hash::fnv1a64; +use crate::map::Map; +use crate::player::{Player, MAX_KNOWN_PLAYERS}; +use crate::world::World; + +const MAGIC: [u8; 4] = *b"GRWS"; +/// Version of the snapshot format. Raise it whenever the encoded state +/// changes. +pub const SNAPSHOT_VERSION: u32 = 1; + +/// Reason a snapshot could not be restored. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SnapshotError { + /// The bytes do not start with the snapshot marker. + NotASnapshot, + /// The snapshot was written by an incompatible version of the game. + UnsupportedVersion(u32), + /// The snapshot is damaged. + Corrupt(DecodeError), +} + +impl fmt::Display for SnapshotError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SnapshotError::NotASnapshot => write!(f, "not a snapshot"), + SnapshotError::UnsupportedVersion(version) => { + write!(f, "unsupported snapshot version {version}") + } + SnapshotError::Corrupt(error) => write!(f, "damaged snapshot: {error}"), + } + } +} + +impl std::error::Error for SnapshotError {} + +impl From for SnapshotError { + fn from(error: DecodeError) -> Self { + SnapshotError::Corrupt(error) + } +} + +impl World { + fn encode_state(&self, w: &mut Writer) { + w.u64(self.seed); + w.u64(self.tick); + self.map.encode(w); + w.u8(self.players.len() as u8); + for player in &self.players { + player.encode(w); + } + } + + /// Checksum of the complete world state. Two worlds with the same value + /// are in the same state, apart from hash collisions. + pub fn state_hash(&self) -> u64 { + let mut w = Writer::new(); + self.encode_state(&mut w); + fnv1a64(&w.into_bytes()) + } + + /// Encodes the complete world behind a header that names format and + /// version. + pub fn to_snapshot(&self) -> Vec { + let mut w = Writer::new(); + w.raw(&MAGIC); + w.u32(SNAPSHOT_VERSION); + self.encode_state(&mut w); + w.into_bytes() + } + + /// Restores a world from bytes written by [`World::to_snapshot`]. + pub fn from_snapshot(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + let magic = r + .raw(MAGIC.len()) + .map_err(|_| SnapshotError::NotASnapshot)?; + if magic != MAGIC.as_slice() { + return Err(SnapshotError::NotASnapshot); + } + let version = r.u32()?; + if version != SNAPSHOT_VERSION { + return Err(SnapshotError::UnsupportedVersion(version)); + } + let seed = r.u64()?; + let tick = r.u64()?; + let map = Map::decode(&mut r)?; + let count = r.u8()? as usize; + if count > MAX_KNOWN_PLAYERS { + return Err(DecodeError::InvalidValue("player count").into()); + } + let mut players = Vec::with_capacity(count); + for _ in 0..count { + players.push(Player::decode(&mut r)?); + } + r.finish()?; + Ok(World { + seed, + tick, + map, + players, + }) + } +} diff --git a/core/tests/snapshot.rs b/core/tests/snapshot.rs new file mode 100644 index 0000000..204075a --- /dev/null +++ b/core/tests/snapshot.rs @@ -0,0 +1,121 @@ +use grovewright_core::codec::DecodeError; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::PlayerId; +use grovewright_core::snapshot::{SnapshotError, SNAPSHOT_VERSION}; +use grovewright_core::world::World; + +const ADA: PlayerId = PlayerId(0); + +fn rock(world: &World) -> Pos { + outcrop_origin(world.map()) +} + +/// A world at tick 12 with one player who has hit a rock once. +fn played_world() -> World { + let mut world = World::new(7); + world.step(&[Command::Join { + key: [3; 16], + name: "Ada".to_owned(), + }]); + let pos = rock(&world); + world.step(&[Command::Dig { player: ADA, pos }]); + for _ in 0..10 { + world.step(&[]); + } + world +} + +#[test] +fn a_snapshot_restores_an_identical_world() { + let world = played_world(); + let restored = World::from_snapshot(&world.to_snapshot()).unwrap(); + assert_eq!(restored, world); + assert_eq!(restored.state_hash(), world.state_hash()); +} + +#[test] +fn the_state_hash_covers_every_part_of_the_state() { + let base = played_world(); + let pos = rock(&base); + + let mut waited = base.clone(); + waited.step(&[]); + assert_ne!(waited.state_hash(), base.state_hash(), "tick"); + + let mut dug = base.clone(); + dug.step(&[Command::Dig { player: ADA, pos }]); + assert_ne!(dug.state_hash(), waited.state_hash(), "map and cooldown"); + + let mut left = base.clone(); + left.step(&[Command::Leave { player: ADA }]); + assert_ne!(left.state_hash(), waited.state_hash(), "players"); + + assert_ne!( + World::new(1).state_hash(), + World::new(2).state_hash(), + "seed" + ); +} + +#[test] +fn equal_worlds_have_equal_hashes() { + assert_eq!(played_world().state_hash(), played_world().state_hash()); +} + +#[test] +fn a_restored_world_continues_exactly_like_the_original() { + let mut original = played_world(); + let mut restored = World::from_snapshot(&original.to_snapshot()).unwrap(); + let pos = rock(&original); + for tick in 0..40u32 { + let commands = if tick.is_multiple_of(6) { + vec![Command::Dig { player: ADA, pos }] + } else { + vec![] + }; + assert_eq!(original.step(&commands), restored.step(&commands)); + } + assert_eq!(original.state_hash(), restored.state_hash()); + assert_eq!(original, restored); +} + +#[test] +fn bytes_that_are_not_a_snapshot_are_rejected() { + assert_eq!(World::from_snapshot(b""), Err(SnapshotError::NotASnapshot)); + assert_eq!( + World::from_snapshot(b"GR"), + Err(SnapshotError::NotASnapshot) + ); + assert_eq!( + World::from_snapshot(b"nope, not a save"), + Err(SnapshotError::NotASnapshot) + ); +} + +#[test] +fn snapshots_of_another_version_are_rejected() { + let mut bytes = played_world().to_snapshot(); + bytes[4..8].copy_from_slice(&(SNAPSHOT_VERSION + 1).to_le_bytes()); + assert_eq!( + World::from_snapshot(&bytes), + Err(SnapshotError::UnsupportedVersion(SNAPSHOT_VERSION + 1)) + ); +} + +#[test] +fn cut_off_and_padded_snapshots_are_rejected() { + let bytes = played_world().to_snapshot(); + assert_eq!( + World::from_snapshot(&bytes[..bytes.len() - 1]), + Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd)) + ); + + let mut padded = bytes.clone(); + padded.push(0); + assert_eq!( + World::from_snapshot(&padded), + Err(SnapshotError::Corrupt(DecodeError::TrailingBytes)) + ); +} diff --git a/docs/KONZEPT.md b/docs/KONZEPT.md index 344464b..a2f01d2 100644 --- a/docs/KONZEPT.md +++ b/docs/KONZEPT.md @@ -177,7 +177,7 @@ Die eigene Spielfigur bewegt sich sofort und läuft außerhalb des Gleichschritt - **Prüfsummen im Spiel.** Die Rechner vergleichen laufend ihren Zustand und gleichen notfalls neu ab. - **Aufzeichnung.** Das Spiel zeichnet die Befehle einer Sitzung auf. Ein Fehler aus deinem Playtest lässt sich damit exakt nachspielen. - **Generator-Regeln.** Regeln wie „Das erste Vorkommen liegt nah am Start" werden über viele Startzahlen geprüft. -- **Speicherstand.** Er hält Startzahl und Änderungen fest. Ein Gast bekommt ihn beim Beitritt geschickt. +- **Speicherstand.** Er enthält den vollständigen Zustand der Welt samt Startzahl und bleibt deshalb gültig, wenn sich der Kartengenerator später ändert. Ein Gast bekommt ihn beim Beitritt geschickt. ### 7.4 Grafik und Ton