diff --git a/core/tests/golden.rs b/core/tests/golden.rs new file mode 100644 index 0000000..7c462a3 --- /dev/null +++ b/core/tests/golden.rs @@ -0,0 +1,147 @@ +//! Pinned values for everything that must not change unnoticed. +//! +//! Every other test compares two computations inside the same build, so a +//! change that affects both sides alike stays green. This file compares +//! against values recorded once. If one of them fails, rules or encodings +//! changed, on purpose or by accident. If saves or recordings are affected, +//! raise the version constant named in the failure message, then update the +//! value here deliberately. + +use grovewright_core::command::Command; +use grovewright_core::hash::fnv1a64; +use grovewright_core::map::Pos; +use grovewright_core::mapgen::outcrop_origin; +use grovewright_core::player::PlayerId; +use grovewright_core::replay::Recording; +use grovewright_core::world::World; + +const SEED: u64 = 2026; + +/// The commands of a session of 60 ticks. It is built from literal values +/// and the outcrop origin of the map, not from constants such as +/// `DIG_COOLDOWN_TICKS`, so that changing a rule changes the result and not +/// the script. +/// +/// Never change or extend this script. To pin more, add a new script and a +/// new pinned value instead. +fn frozen_script(world: &World) -> Vec> { + let origin = outcrop_origin(world.map()); + let right_of_origin = Pos::new(origin.x + 1, origin.y); + // Pairs of (tick, command). Commands of the same tick are applied in the + // order listed here. + let scripted = [ + // Two players join in the same tick. + ( + 0, + Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }, + ), + ( + 0, + Command::Join { + key: [2; 16], + name: "Grace".to_owned(), + }, + ), + // Player 0 hits the outcrop origin three times, five ticks apart. + // The third hit breaks the rock and gives a stone. + ( + 2, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + ( + 7, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + ( + 12, + Command::Dig { + player: PlayerId(0), + pos: origin, + }, + ), + // Player 1 hits the tile to the right of the origin once. + ( + 20, + Command::Dig { + player: PlayerId(1), + pos: right_of_origin, + }, + ), + // Player 1 leaves and returns with a name of 40 bytes, which the + // world shortens to 32. + ( + 30, + Command::Leave { + player: PlayerId(1), + }, + ), + ( + 40, + Command::Join { + key: [2; 16], + name: "ä".repeat(20), + }, + ), + ]; + let mut ticks = vec![Vec::new(); 60]; + for (tick, command) in scripted { + ticks[tick].push(command); + } + ticks +} + +/// Covers the map generator, the map encoding (terrain numbers, byte order of +/// width and height) and the snapshot header. +#[test] +fn the_starting_snapshot_is_unchanged() { + let actual = fnv1a64(&World::new(SEED).to_snapshot()); + assert_eq!( + actual, 0x1b78_d996_1586_fbf1, + "the starting world or the snapshot format changed; if saves are \ + affected, raise SNAPSHOT_VERSION in core/src/snapshot.rs; then \ + update this value" + ); +} + +/// Covers the rules of joining, leaving and digging, the shortening of names +/// and the encoding of players. +#[test] +fn the_frozen_session_ends_in_the_same_state() { + let mut world = World::new(SEED); + for commands in frozen_script(&world) { + world.step(&commands); + } + assert_eq!( + world.state_hash(), + 0xc304_43df_fc74_dcfc, + "the rules or the snapshot format changed; old recordings no longer \ + verify; if saves are affected, raise SNAPSHOT_VERSION in \ + core/src/snapshot.rs; then update this value" + ); +} + +/// Covers the recording header and layout and the wire format of commands. +#[test] +fn the_recording_format_is_unchanged() { + let mut world = World::new(SEED); + let mut recording = Recording::start(&world); + for commands in frozen_script(&world) { + recording.push_tick(&commands); + world.step(&commands); + } + assert_eq!( + fnv1a64(&recording.to_bytes(&world)), + 0x2312_5a7d_d8ef_5ae0, + "the recording or command format changed; raise RECORDING_VERSION in \ + core/src/replay.rs; then update this value" + ); +} diff --git a/core/tests/world.rs b/core/tests/world.rs index ae70c04..b839f5d 100644 --- a/core/tests/world.rs +++ b/core/tests/world.rs @@ -2,7 +2,9 @@ use grovewright_core::command::Command; use grovewright_core::inventory::ITEM_STONE; use grovewright_core::map::{Pos, Terrain}; use grovewright_core::mapgen::{outcrop_origin, spawn_pos}; -use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}; +use grovewright_core::player::{ + PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_NAME_BYTES, MAX_ONLINE_PLAYERS, +}; use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND}; fn key(n: u8) -> PlayerKey { @@ -112,6 +114,28 @@ fn a_returning_player_can_change_their_name() { assert_eq!(world.player(id).unwrap().name, "Neuer Name"); } +#[test] +fn overlong_names_are_stored_shortened_on_both_join_paths() { + let mut world = World::new(1); + world.step(&[Command::Join { + key: key(1), + name: "ä".repeat(20), + }]); + assert_eq!(world.player(PlayerId(0)).unwrap().name, "ä".repeat(16)); + + world.step(&[Command::Leave { + player: PlayerId(0), + }]); + world.step(&[Command::Join { + key: key(1), + name: "x".repeat(MAX_NAME_BYTES + 10), + }]); + assert_eq!( + world.player(PlayerId(0)).unwrap().name, + "x".repeat(MAX_NAME_BYTES) + ); +} + #[test] fn a_fifth_online_player_is_refused() { let mut world = World::new(1);