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:
@@ -15,5 +15,6 @@ pub mod mapgen;
|
|||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
pub mod rules;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
pub mod world;
|
pub mod world;
|
||||||
|
|||||||
+29
-2
@@ -1,17 +1,26 @@
|
|||||||
//! Recording of a session: the starting state and every command, tick by
|
//! Recording of a session: the starting state and every command, tick by
|
||||||
//! tick. Replaying a recording reproduces the session exactly.
|
//! tick. Replaying a recording reproduces the session exactly.
|
||||||
|
//!
|
||||||
|
//! Layout: the marker `GRWR`, the format version, the identity of the rules
|
||||||
|
//! that made the recording, the hash of the state the session ended in, the
|
||||||
|
//! starting snapshot, and the commands of every tick that had any.
|
||||||
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use crate::codec::{DecodeError, Reader, Writer};
|
use crate::codec::{DecodeError, Reader, Writer};
|
||||||
use crate::command::Command;
|
use crate::command::Command;
|
||||||
|
use crate::rules::rules_id;
|
||||||
use crate::snapshot::SnapshotError;
|
use crate::snapshot::SnapshotError;
|
||||||
use crate::world::{Event, World};
|
use crate::world::{Event, World, TICKS_PER_SECOND};
|
||||||
|
|
||||||
const MAGIC: [u8; 4] = *b"GRWR";
|
const MAGIC: [u8; 4] = *b"GRWR";
|
||||||
/// Version of the recording format. Raise it whenever the encoded recording,
|
/// Version of the recording format. Raise it whenever the encoded recording,
|
||||||
/// including the snapshot inside it, changes.
|
/// including the snapshot inside it, changes.
|
||||||
pub const RECORDING_VERSION: u32 = 2;
|
pub const RECORDING_VERSION: u32 = 3;
|
||||||
|
/// Most ticks a recording may hold when it is read: thirty days of play.
|
||||||
|
/// Longer recordings are refused, so that a damaged or crafted file cannot
|
||||||
|
/// keep [`Recording::verify`] busy for hours.
|
||||||
|
pub const MAX_RECORDING_TICKS: u32 = 30 * 24 * 60 * 60 * TICKS_PER_SECOND;
|
||||||
|
|
||||||
/// Reason a recording could not be read or replayed.
|
/// Reason a recording could not be read or replayed.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -20,6 +29,9 @@ pub enum RecordingError {
|
|||||||
NotARecording,
|
NotARecording,
|
||||||
/// The recording was written by an incompatible version of the game.
|
/// The recording was written by an incompatible version of the game.
|
||||||
UnsupportedVersion(u32),
|
UnsupportedVersion(u32),
|
||||||
|
/// The recording was made with other rules than this build has, so
|
||||||
|
/// replaying it would not show what happened.
|
||||||
|
OtherRules { recorded: u64, current: u64 },
|
||||||
/// The recording is damaged.
|
/// The recording is damaged.
|
||||||
Corrupt(DecodeError),
|
Corrupt(DecodeError),
|
||||||
/// The starting state inside the recording could not be restored.
|
/// The starting state inside the recording could not be restored.
|
||||||
@@ -35,6 +47,10 @@ impl fmt::Display for RecordingError {
|
|||||||
RecordingError::UnsupportedVersion(version) => {
|
RecordingError::UnsupportedVersion(version) => {
|
||||||
write!(f, "unsupported recording version {version}")
|
write!(f, "unsupported recording version {version}")
|
||||||
}
|
}
|
||||||
|
RecordingError::OtherRules { recorded, current } => write!(
|
||||||
|
f,
|
||||||
|
"recorded with rules {recorded:016x}, this build has rules {current:016x}"
|
||||||
|
),
|
||||||
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
|
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
|
||||||
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
||||||
RecordingError::Diverged { expected, actual } => write!(
|
RecordingError::Diverged { expected, actual } => write!(
|
||||||
@@ -139,6 +155,7 @@ impl Recording {
|
|||||||
let mut w = Writer::new();
|
let mut w = Writer::new();
|
||||||
w.raw(&MAGIC);
|
w.raw(&MAGIC);
|
||||||
w.u32(RECORDING_VERSION);
|
w.u32(RECORDING_VERSION);
|
||||||
|
w.u64(rules_id());
|
||||||
w.u64(end.state_hash());
|
w.u64(end.state_hash());
|
||||||
w.bytes(&self.start);
|
w.bytes(&self.start);
|
||||||
w.u32(self.tick_count);
|
w.u32(self.tick_count);
|
||||||
@@ -154,6 +171,8 @@ impl Recording {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decodes a recording and the state hash that replaying it must reach.
|
/// Decodes a recording and the state hash that replaying it must reach.
|
||||||
|
/// Fails with [`RecordingError::OtherRules`] before anything else is
|
||||||
|
/// decoded if the recording was made with other rules.
|
||||||
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
|
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
|
||||||
let mut r = Reader::new(bytes);
|
let mut r = Reader::new(bytes);
|
||||||
let magic = r
|
let magic = r
|
||||||
@@ -166,12 +185,20 @@ impl Recording {
|
|||||||
if version != RECORDING_VERSION {
|
if version != RECORDING_VERSION {
|
||||||
return Err(RecordingError::UnsupportedVersion(version));
|
return Err(RecordingError::UnsupportedVersion(version));
|
||||||
}
|
}
|
||||||
|
let recorded = r.u64()?;
|
||||||
|
let current = rules_id();
|
||||||
|
if recorded != current {
|
||||||
|
return Err(RecordingError::OtherRules { recorded, current });
|
||||||
|
}
|
||||||
let expected_hash = r.u64()?;
|
let expected_hash = r.u64()?;
|
||||||
let start = r.bytes()?.to_vec();
|
let start = r.bytes()?.to_vec();
|
||||||
let start_tick = World::from_snapshot(&start)
|
let start_tick = World::from_snapshot(&start)
|
||||||
.map_err(RecordingError::BadStart)?
|
.map_err(RecordingError::BadStart)?
|
||||||
.tick();
|
.tick();
|
||||||
let tick_count = r.u32()?;
|
let tick_count = r.u32()?;
|
||||||
|
if tick_count > MAX_RECORDING_TICKS {
|
||||||
|
return Err(DecodeError::InvalidValue("tick count").into());
|
||||||
|
}
|
||||||
let busy_count = r.u32()?;
|
let busy_count = r.u32()?;
|
||||||
let mut busy_ticks = Vec::new();
|
let mut busy_ticks = Vec::new();
|
||||||
let mut first_free_tick = 0;
|
let mut first_free_tick = 0;
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! Identity of the rules. Machines can share a world only if their rules
|
||||||
|
//! produce the same results, and a recording can only be replayed by the
|
||||||
|
//! rules it was made with.
|
||||||
|
//!
|
||||||
|
//! The identity is derived from fixed sessions: each runs a frozen script of
|
||||||
|
//! commands on a fresh world, and the identity combines the state hashes they
|
||||||
|
//! end in. A change to a rule that one of the scripts touches changes the
|
||||||
|
//! identity, without anybody having to raise a number by hand. A new rule
|
||||||
|
//! needs a new script in [`SCRIPTS`], so that the identity covers it too.
|
||||||
|
|
||||||
|
use crate::codec::Writer;
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::hash::fnv1a64;
|
||||||
|
use crate::map::Pos;
|
||||||
|
use crate::mapgen::outcrop_origin;
|
||||||
|
use crate::player::PlayerId;
|
||||||
|
use crate::world::World;
|
||||||
|
|
||||||
|
/// Seed of the world every frozen script runs on.
|
||||||
|
pub const RULES_SEED: u64 = 2026;
|
||||||
|
|
||||||
|
/// A frozen script: the commands of every tick of a fixed session.
|
||||||
|
pub type Script = fn(&World) -> Vec<Vec<Command>>;
|
||||||
|
|
||||||
|
/// Every frozen script the rules identity covers, in a fixed order.
|
||||||
|
pub const SCRIPTS: [Script; 2] = [frozen_script, frozen_offline_script];
|
||||||
|
|
||||||
|
/// 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 cover more, add a new script to
|
||||||
|
/// [`SCRIPTS`] instead.
|
||||||
|
pub 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 covered as well.
|
||||||
|
///
|
||||||
|
/// Never change or extend this script either.
|
||||||
|
pub 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `script` on a fresh world from [`RULES_SEED`] and returns the hash
|
||||||
|
/// of the state it ends in.
|
||||||
|
pub fn end_hash(script: Script) -> u64 {
|
||||||
|
let mut world = World::new(RULES_SEED);
|
||||||
|
for commands in script(&world) {
|
||||||
|
world.step(&commands);
|
||||||
|
}
|
||||||
|
world.state_hash()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identity of the rules of this build: one hash over the end states of
|
||||||
|
/// all [`SCRIPTS`]. Computing it runs every script, which takes about a
|
||||||
|
/// millisecond, so callers that need it often keep the value.
|
||||||
|
pub fn rules_id() -> u64 {
|
||||||
|
let mut w = Writer::new();
|
||||||
|
for script in SCRIPTS {
|
||||||
|
w.u64(end_hash(script));
|
||||||
|
}
|
||||||
|
fnv1a64(&w.into_bytes())
|
||||||
|
}
|
||||||
@@ -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]
|
#[test]
|
||||||
fn an_unknown_command_is_rejected() {
|
fn an_unknown_command_is_rejected() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
+25
-146
@@ -8,145 +8,19 @@
|
|||||||
//! value here deliberately.
|
//! value here deliberately.
|
||||||
|
|
||||||
use grovewright_core::codec::Writer;
|
use grovewright_core::codec::Writer;
|
||||||
use grovewright_core::command::Command;
|
|
||||||
use grovewright_core::hash::fnv1a64;
|
use grovewright_core::hash::fnv1a64;
|
||||||
use grovewright_core::map::{Map, Pos, Terrain, Tile};
|
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::replay::Recording;
|
||||||
|
use grovewright_core::rules::{
|
||||||
|
end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
|
||||||
|
};
|
||||||
use grovewright_core::world::World;
|
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
|
/// Covers the map generator, the map encoding and the snapshot header and
|
||||||
/// checksum.
|
/// checksum.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_starting_snapshot_is_unchanged() {
|
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!(
|
assert_eq!(
|
||||||
actual, 0x6812_937b_f2b2_19ae,
|
actual, 0x6812_937b_f2b2_19ae,
|
||||||
"the starting world or the snapshot format changed; if saves are \
|
"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.
|
/// and the encoding of players.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_frozen_session_ends_in_the_same_state() {
|
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!(
|
assert_eq!(
|
||||||
world.state_hash(),
|
end_hash(frozen_script),
|
||||||
0xc304_43df_fc74_dcfc,
|
0xc304_43df_fc74_dcfc,
|
||||||
"the rules or the snapshot format changed; old recordings no longer \
|
"the rules or the snapshot format changed, and with them the rules \
|
||||||
verify; if saves are affected, raise SNAPSHOT_VERSION in \
|
id; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||||
core/src/snapshot.rs; then update this value"
|
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.
|
/// Covers the encoding of a player who is offline.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_frozen_offline_session_ends_in_the_same_state() {
|
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!(
|
assert_eq!(
|
||||||
world.state_hash(),
|
end_hash(frozen_offline_script),
|
||||||
0x904f_10e1_7c52_8be4,
|
0x904f_10e1_7c52_8be4,
|
||||||
"the rules or the snapshot format changed; old recordings no longer \
|
"the rules or the snapshot format changed, and with them the rules \
|
||||||
verify; if saves are affected, raise SNAPSHOT_VERSION in \
|
id; if saves are affected, raise SNAPSHOT_VERSION in \
|
||||||
core/src/snapshot.rs; then update this value"
|
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.
|
/// Covers the recording header and layout and the wire format of commands.
|
||||||
#[test]
|
#[test]
|
||||||
fn the_recording_format_is_unchanged() {
|
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);
|
let mut recording = Recording::start(&world);
|
||||||
for commands in frozen_script(&world) {
|
for commands in frozen_script(&world) {
|
||||||
recording.step(&mut world, &commands);
|
recording.step(&mut world, &commands);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
fnv1a64(&recording.to_bytes(&world)),
|
fnv1a64(&recording.to_bytes(&world)),
|
||||||
0x1c24_8cc7_6bc8_846a,
|
0xf203_b229_8dad_7c53,
|
||||||
"the recording or command format changed; if other tests in this \
|
"the recording or command format changed; if other tests in this \
|
||||||
file fail as well, deal with those first, because a recording \
|
file fail as well, deal with those first, because a recording \
|
||||||
contains a snapshot and an end state; otherwise raise \
|
contains a snapshot and an end state; otherwise raise \
|
||||||
|
|||||||
+55
-6
@@ -3,11 +3,15 @@ use grovewright_core::command::Command;
|
|||||||
use grovewright_core::inventory::ITEM_STONE;
|
use grovewright_core::inventory::ITEM_STONE;
|
||||||
use grovewright_core::mapgen::outcrop_origin;
|
use grovewright_core::mapgen::outcrop_origin;
|
||||||
use grovewright_core::player::PlayerId;
|
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::snapshot::SnapshotError;
|
||||||
use grovewright_core::world::World;
|
use grovewright_core::world::World;
|
||||||
|
|
||||||
const ADA: PlayerId = PlayerId(0);
|
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.
|
/// Commands of tick `tick` in a short scripted session.
|
||||||
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
|
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() {
|
fn verify_reports_a_replay_that_ends_in_another_state() {
|
||||||
let (recording, world) = recorded_session();
|
let (recording, world) = recorded_session();
|
||||||
let mut bytes = recording.to_bytes(&world);
|
let mut bytes = recording.to_bytes(&world);
|
||||||
// The expected state hash follows the marker and the version.
|
// The expected state hash follows the marker, the version and the rules
|
||||||
bytes[8] ^= 0x01;
|
// identity.
|
||||||
|
bytes[16] ^= 0x01;
|
||||||
match Recording::verify(&bytes) {
|
match Recording::verify(&bytes) {
|
||||||
Err(RecordingError::Diverged { expected, actual }) => {
|
Err(RecordingError::Diverged { expected, actual }) => {
|
||||||
assert_eq!(actual, world.state_hash());
|
assert_eq!(actual, world.state_hash());
|
||||||
@@ -179,11 +184,55 @@ fn damaged_recordings_are_rejected() {
|
|||||||
fn a_recording_with_a_damaged_start_is_rejected() {
|
fn a_recording_with_a_damaged_start_is_rejected() {
|
||||||
let (recording, world) = recorded_session();
|
let (recording, world) = recorded_session();
|
||||||
let mut bytes = recording.to_bytes(&world);
|
let mut bytes = recording.to_bytes(&world);
|
||||||
// The snapshot starts after marker, version, expected hash and its own
|
// The snapshot starts after marker, version, rules identity, expected
|
||||||
// length: 4 + 4 + 8 + 4 bytes.
|
// hash and its own length: 4 + 4 + 8 + 8 + 4 bytes.
|
||||||
bytes[20 + 10] ^= 0x01;
|
bytes[START + 10] ^= 0x01;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Recording::from_bytes(&bytes),
|
Recording::from_bytes(&bytes),
|
||||||
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
|
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"
|
||||||
|
)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user