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:
Steven Wroblewski
2026-09-30 21:18:09 +02:00
parent afc6d9ca77
commit 311516dd0a
4 changed files with 232 additions and 1 deletions
+121
View File
@@ -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))
);
}