M1a: deterministischer Rust-Kern #3
@@ -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