M1c: Gleichschritt im Netz #9
@@ -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