Regel-Kennung und Aufzeichnung Version 3

Die eingefrorenen Skripte ziehen von den festgeschriebenen Tests in den
Kern. Aus ihren Endzuständen leitet sich die Regel-Kennung ab, die sich
mit jeder Regel ändert, die ein Skript berührt. Aufzeichnungen tragen
sie und werden mit anderen Regeln abgelehnt, statt eine scheinbare
Abweichung zu melden. Fremde Aufzeichnungen dürfen höchstens 30 Tage
lang sein. Ein Test hält fest, dass zu lange Namen beim Dekodieren
abgelehnt werden.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 18:00:00 +02:00
parent 760bea36fd
commit f2e4c91082
6 changed files with 296 additions and 154 deletions
+15
View File
@@ -48,6 +48,21 @@ fn an_overlong_name_is_shortened_when_encoded() {
);
}
#[test]
fn a_name_longer_than_allowed_is_rejected_when_decoded() {
// A join written by hand: the encoder would shorten the name. The tag of
// a join is 1; the recording format pins the wire format of commands.
let mut w = Writer::new();
w.u8(1);
w.raw(&[1; 16]);
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
let bytes = w.into_bytes();
assert_eq!(
Command::decode(&mut Reader::new(&bytes)),
Err(DecodeError::InvalidValue("player name"))
);
}
#[test]
fn an_unknown_command_is_rejected() {
assert_eq!(
+25 -146
View File
@@ -8,145 +8,19 @@
//! value here deliberately.
use grovewright_core::codec::Writer;
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::Recording;
use grovewright_core::rules::{
end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
};
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<Vec<Command>> {
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
}
/// The commands of a second session of 30 ticks that ends with one player
/// offline, so that the encoding of offline players is pinned as well.
///
/// Never change or extend this script either.
fn frozen_offline_script(world: &World) -> Vec<Vec<Command>> {
let origin = outcrop_origin(world.map());
let scripted = [
(
0,
Command::Join {
key: [7; 16],
name: "Linus".to_owned(),
},
),
(
1,
Command::Join {
key: [8; 16],
name: "Margaret".to_owned(),
},
),
(
3,
Command::Dig {
player: PlayerId(1),
pos: origin,
},
),
(
10,
Command::Leave {
player: PlayerId(0),
},
),
];
let mut ticks = vec![Vec::new(); 30];
for (tick, command) in scripted {
ticks[tick].push(command);
}
ticks
}
/// Covers the map generator, the map encoding and the snapshot header and
/// checksum.
#[test]
fn the_starting_snapshot_is_unchanged() {
let actual = fnv1a64(&World::new(SEED).to_snapshot());
let actual = fnv1a64(&World::new(RULES_SEED).to_snapshot());
assert_eq!(
actual, 0x6812_937b_f2b2_19ae,
"the starting world or the snapshot format changed; if saves are \
@@ -159,15 +33,11 @@ fn the_starting_snapshot_is_unchanged() {
/// 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(),
end_hash(frozen_script),
0xc304_43df_fc74_dcfc,
"the rules or the snapshot format changed; old recordings no longer \
verify; if saves are affected, raise SNAPSHOT_VERSION in \
"the rules or the snapshot format changed, and with them the rules \
id; if saves are affected, raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update this value"
);
}
@@ -175,30 +45,39 @@ fn the_frozen_session_ends_in_the_same_state() {
/// Covers the encoding of a player who is offline.
#[test]
fn the_frozen_offline_session_ends_in_the_same_state() {
let mut world = World::new(SEED);
for commands in frozen_offline_script(&world) {
world.step(&commands);
}
assert_eq!(
world.state_hash(),
end_hash(frozen_offline_script),
0x904f_10e1_7c52_8be4,
"the rules or the snapshot format changed; old recordings no longer \
verify; if saves are affected, raise SNAPSHOT_VERSION in \
"the rules or the snapshot format changed, and with them the rules \
id; if saves are affected, raise SNAPSHOT_VERSION in \
core/src/snapshot.rs; then update this value"
);
}
/// Covers how the identity of the rules is combined from the end states of
/// the frozen scripts.
#[test]
fn the_rules_id_is_unchanged() {
assert_eq!(
rules_id(),
0x04f1_1b1e_16c5_feff,
"the rules id changed: builds with other rules can no longer play \
together, and older recordings report other rules; if the tests \
above fail as well, deal with those first; 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 world = World::new(RULES_SEED);
let mut recording = Recording::start(&world);
for commands in frozen_script(&world) {
recording.step(&mut world, &commands);
}
assert_eq!(
fnv1a64(&recording.to_bytes(&world)),
0x1c24_8cc7_6bc8_846a,
0xf203_b229_8dad_7c53,
"the recording or command format changed; if other tests in this \
file fail as well, deal with those first, because a recording \
contains a snapshot and an end state; otherwise raise \
+55 -6
View File
@@ -3,11 +3,15 @@ use grovewright_core::command::Command;
use grovewright_core::inventory::ITEM_STONE;
use grovewright_core::mapgen::outcrop_origin;
use grovewright_core::player::PlayerId;
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
use grovewright_core::replay::{Recording, RecordingError, MAX_RECORDING_TICKS, RECORDING_VERSION};
use grovewright_core::rules::rules_id;
use grovewright_core::snapshot::SnapshotError;
use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0);
/// Offset of the starting snapshot in an encoded recording: marker, version,
/// rules identity, expected hash and the snapshot's length come first.
const START: usize = 4 + 4 + 8 + 8 + 4;
/// Commands of tick `tick` in a short scripted session.
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
@@ -81,8 +85,9 @@ fn verify_accepts_a_faithful_recording() {
fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world);
// The expected state hash follows the marker and the version.
bytes[8] ^= 0x01;
// The expected state hash follows the marker, the version and the rules
// identity.
bytes[16] ^= 0x01;
match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(actual, world.state_hash());
@@ -179,11 +184,55 @@ fn damaged_recordings_are_rejected() {
fn a_recording_with_a_damaged_start_is_rejected() {
let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world);
// The snapshot starts after marker, version, expected hash and its own
// length: 4 + 4 + 8 + 4 bytes.
bytes[20 + 10] ^= 0x01;
// The snapshot starts after marker, version, rules identity, expected
// hash and its own length: 4 + 4 + 8 + 8 + 4 bytes.
bytes[START + 10] ^= 0x01;
assert_eq!(
Recording::from_bytes(&bytes),
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
);
}
#[test]
fn a_recording_of_other_rules_is_refused_before_replaying() {
let (recording, world) = recorded_session();
let mut bytes = recording.to_bytes(&world);
// The rules identity follows the marker and the version.
bytes[8] ^= 0x01;
assert_eq!(
Recording::from_bytes(&bytes),
Err(RecordingError::OtherRules {
recorded: rules_id() ^ 0x01,
current: rules_id()
})
);
}
/// The encoding of `recording` with its tick count replaced by `ticks`. The
/// tick count follows the starting snapshot.
fn with_tick_count(recording: &Recording, world: &World, ticks: u32) -> Vec<u8> {
let mut bytes = recording.to_bytes(world);
let length = u32::from_le_bytes(bytes[START - 4..START].try_into().unwrap()) as usize;
let at = START + length;
bytes[at..at + 4].copy_from_slice(&ticks.to_le_bytes());
bytes
}
#[test]
fn a_recording_longer_than_thirty_days_is_refused() {
let world = World::new(3);
let recording = Recording::start(&world);
let (longest, _) =
Recording::from_bytes(&with_tick_count(&recording, &world, MAX_RECORDING_TICKS)).unwrap();
assert_eq!(longest.tick_count(), MAX_RECORDING_TICKS);
assert_eq!(
Recording::from_bytes(&with_tick_count(
&recording,
&world,
MAX_RECORDING_TICKS + 1
)),
Err(RecordingError::Corrupt(DecodeError::InvalidValue(
"tick count"
)))
);
}