ef728cd100
Snapshots tragen jetzt eine Prüfsumme (Format 2). Beim Wiederherstellen werden Zustände abgelehnt, die die Regeln nie erzeugen: zu große Takte, mehr als vier Spieler online, doppelte Schlüssel, unmögliche Wartezeiten. World::load ersetzt den öffentlichen Aufruf von set_all_offline. Die festgeschriebenen Werte sind bewusst angepasst und um einen Offline-Spieler und das Kartenformat ergänzt. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
259 lines
7.7 KiB
Rust
259 lines
7.7 KiB
Rust
use grovewright_core::codec::{DecodeError, Writer};
|
|
use grovewright_core::command::Command;
|
|
use grovewright_core::hash::fnv1a64;
|
|
use grovewright_core::map::{Map, Pos, Terrain};
|
|
use grovewright_core::mapgen::outcrop_origin;
|
|
use grovewright_core::player::{Player, PlayerId, MAX_ONLINE_PLAYERS};
|
|
use grovewright_core::snapshot::{SnapshotError, MAX_RESTORABLE_TICK, SNAPSHOT_VERSION};
|
|
use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS};
|
|
|
|
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
|
|
}
|
|
|
|
/// Builds snapshot bytes by hand: header, state and checksum. This lets tests
|
|
/// describe states that the rules would never produce.
|
|
fn snapshot_of(seed: u64, tick: u64, map: &Map, players: &[Player]) -> Vec<u8> {
|
|
let mut w = Writer::new();
|
|
w.raw(b"GRWS");
|
|
w.u32(SNAPSHOT_VERSION);
|
|
w.u64(seed);
|
|
w.u64(tick);
|
|
map.encode(&mut w);
|
|
w.u8(players.len() as u8);
|
|
for player in players {
|
|
player.encode(&mut w);
|
|
}
|
|
let mut bytes = w.into_bytes();
|
|
let checksum = fnv1a64(&bytes);
|
|
bytes.extend_from_slice(&checksum.to_le_bytes());
|
|
bytes
|
|
}
|
|
|
|
fn player(key: u8) -> Player {
|
|
Player::new([key; 16], "P")
|
|
}
|
|
|
|
#[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 a_snapshot_is_header_then_state_then_checksum() {
|
|
let world = played_world();
|
|
let by_hand = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
|
assert_eq!(world.to_snapshot(), by_hand);
|
|
}
|
|
|
|
#[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 from_snapshot_keeps_online_players_online() {
|
|
let restored = World::from_snapshot(&played_world().to_snapshot()).unwrap();
|
|
assert!(restored.player(ADA).unwrap().online);
|
|
}
|
|
|
|
#[test]
|
|
fn loading_a_save_starts_with_every_player_offline() {
|
|
let world = played_world();
|
|
let loaded = World::load(&world.to_snapshot()).unwrap();
|
|
assert!(loaded.players().iter().all(|player| !player.online));
|
|
assert_eq!(loaded.tick(), world.tick());
|
|
assert_eq!(loaded.map(), world.map());
|
|
assert_eq!(
|
|
loaded.player(ADA).unwrap().inventory,
|
|
world.player(ADA).unwrap().inventory
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_returning_player_can_join_a_loaded_world() {
|
|
let mut loaded = World::load(&played_world().to_snapshot()).unwrap();
|
|
let events = loaded.step(&[Command::Join {
|
|
key: [3; 16],
|
|
name: "Ada".to_owned(),
|
|
}]);
|
|
assert_eq!(events, vec![Event::PlayerJoined { player: ADA }]);
|
|
}
|
|
|
|
#[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 any_changed_byte_is_detected() {
|
|
let bytes = played_world().to_snapshot();
|
|
for index in [8, bytes.len() / 2, bytes.len() - 9, bytes.len() - 1] {
|
|
let mut changed = bytes.clone();
|
|
changed[index] ^= 0x01;
|
|
assert_eq!(
|
|
World::from_snapshot(&changed),
|
|
Err(SnapshotError::ChecksumMismatch),
|
|
"byte {index}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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::ChecksumMismatch)
|
|
);
|
|
|
|
let mut padded = bytes.clone();
|
|
padded.push(0);
|
|
assert_eq!(
|
|
World::from_snapshot(&padded),
|
|
Err(SnapshotError::ChecksumMismatch)
|
|
);
|
|
|
|
assert_eq!(
|
|
World::from_snapshot(&bytes[..10]),
|
|
Err(SnapshotError::Corrupt(DecodeError::UnexpectedEnd))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_content_behind_a_valid_checksum_is_rejected() {
|
|
let world = played_world();
|
|
let mut bytes = snapshot_of(world.seed(), world.tick(), world.map(), world.players());
|
|
bytes.truncate(bytes.len() - 8);
|
|
bytes.push(0);
|
|
let checksum = fnv1a64(&bytes);
|
|
bytes.extend_from_slice(&checksum.to_le_bytes());
|
|
assert_eq!(
|
|
World::from_snapshot(&bytes),
|
|
Err(SnapshotError::Corrupt(DecodeError::TrailingBytes))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn states_the_rules_never_produce_are_rejected() {
|
|
let map = Map::filled(4, 3, Terrain::Ground);
|
|
let too_many_online: Vec<Player> = (0..=MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
|
let mut offline_twin = player(1);
|
|
offline_twin.online = false;
|
|
let mut waiting_too_long = player(1);
|
|
waiting_too_long.next_action_tick = 5 + DIG_COOLDOWN_TICKS + 1;
|
|
|
|
let cases = [
|
|
(snapshot_of(1, MAX_RESTORABLE_TICK + 1, &map, &[]), "tick"),
|
|
(snapshot_of(1, 5, &map, &too_many_online), "online players"),
|
|
(
|
|
snapshot_of(1, 5, &map, &[player(1), offline_twin]),
|
|
"duplicate player key",
|
|
),
|
|
(
|
|
snapshot_of(1, 5, &map, &[waiting_too_long]),
|
|
"next action tick",
|
|
),
|
|
];
|
|
for (bytes, what) in cases {
|
|
assert_eq!(
|
|
World::from_snapshot(&bytes),
|
|
Err(SnapshotError::Invalid(what)),
|
|
"{what}"
|
|
);
|
|
assert_eq!(
|
|
World::load(&bytes),
|
|
Err(SnapshotError::Invalid(what)),
|
|
"{what}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn states_right_at_the_limits_are_accepted() {
|
|
let map = Map::filled(4, 3, Terrain::Ground);
|
|
let mut players: Vec<Player> = (0..MAX_ONLINE_PLAYERS as u8).map(player).collect();
|
|
players[0].next_action_tick = MAX_RESTORABLE_TICK + DIG_COOLDOWN_TICKS;
|
|
let bytes = snapshot_of(1, MAX_RESTORABLE_TICK, &map, &players);
|
|
assert!(World::from_snapshot(&bytes).is_ok());
|
|
}
|