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_KNOWN_PLAYERS, 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 { 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 = (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 = (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()); } /// A world that knows `MAX_KNOWN_PLAYERS` players, built through commands. /// They join in groups of `MAX_ONLINE_PLAYERS`, and each group but the last /// leaves before the next one joins, so only the last group is online. fn world_with_a_full_roster() -> World { let mut world = World::new(7); let groups = MAX_KNOWN_PLAYERS / MAX_ONLINE_PLAYERS; for group in 0..groups { let first = group * MAX_ONLINE_PLAYERS; for index in first..first + MAX_ONLINE_PLAYERS { world.step(&[Command::Join { key: [index as u8; 16], name: format!("P{index}"), }]); } if group + 1 < groups { for index in first..first + MAX_ONLINE_PLAYERS { world.step(&[Command::Leave { player: PlayerId(index as u8), }]); } } } world } #[test] fn a_full_roster_with_most_players_offline_is_accepted() { let world = world_with_a_full_roster(); assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); let online = world.players().iter().filter(|player| player.online); assert_eq!(online.count(), MAX_ONLINE_PLAYERS); let restored = World::from_snapshot(&world.to_snapshot()).unwrap(); assert_eq!(restored, world); let loaded = World::load(&world.to_snapshot()).unwrap(); assert_eq!(loaded.players().len(), MAX_KNOWN_PLAYERS); assert!(loaded.players().iter().all(|player| !player.online)); }