Snapshot und Zustandsprüfsumme
Der Snapshot enthält den vollständigen Zustand der Welt hinter einem Kopf mit Format und Version. Er dient als Speicherstand und als Grundlage der Prüfsumme. Das Konzept ist entsprechend korrigiert: Der Speicherstand ist der vollständige Zustand, nicht Startzahl plus Änderungen. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,4 +14,5 @@ pub mod map;
|
|||||||
pub mod mapgen;
|
pub mod mapgen;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
pub mod snapshot;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|||||||
@@ -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<DecodeError> 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<u8> {
|
||||||
|
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<World, SnapshotError> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
### 7.4 Grafik und Ton
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user