M1c: Gleichschritt im Netz #9
@@ -15,5 +15,6 @@ pub mod mapgen;
|
||||
pub mod player;
|
||||
pub mod replay;
|
||||
pub mod rng;
|
||||
pub mod rules;
|
||||
pub mod snapshot;
|
||||
pub mod world;
|
||||
|
||||
+29
-2
@@ -1,17 +1,26 @@
|
||||
//! Recording of a session: the starting state and every command, tick by
|
||||
//! 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 crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::rules::rules_id;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::{Event, World};
|
||||
use crate::world::{Event, World, TICKS_PER_SECOND};
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWR";
|
||||
/// Version of the recording format. Raise it whenever the encoded recording,
|
||||
/// 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.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -20,6 +29,9 @@ pub enum RecordingError {
|
||||
NotARecording,
|
||||
/// The recording was written by an incompatible version of the game.
|
||||
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.
|
||||
Corrupt(DecodeError),
|
||||
/// The starting state inside the recording could not be restored.
|
||||
@@ -35,6 +47,10 @@ impl fmt::Display for RecordingError {
|
||||
RecordingError::UnsupportedVersion(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::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
||||
RecordingError::Diverged { expected, actual } => write!(
|
||||
@@ -139,6 +155,7 @@ impl Recording {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(RECORDING_VERSION);
|
||||
w.u64(rules_id());
|
||||
w.u64(end.state_hash());
|
||||
w.bytes(&self.start);
|
||||
w.u32(self.tick_count);
|
||||
@@ -154,6 +171,8 @@ impl Recording {
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
@@ -166,12 +185,20 @@ impl Recording {
|
||||
if version != RECORDING_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 start = r.bytes()?.to_vec();
|
||||
let start_tick = World::from_snapshot(&start)
|
||||
.map_err(RecordingError::BadStart)?
|
||||
.tick();
|
||||
let tick_count = r.u32()?;
|
||||
if tick_count > MAX_RECORDING_TICKS {
|
||||
return Err(DecodeError::InvalidValue("tick count").into());
|
||||
}
|
||||
let busy_count = r.u32()?;
|
||||
let mut busy_ticks = Vec::new();
|
||||
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]
|
||||
fn an_unknown_command_is_rejected() {
|
||||
assert_eq!(
|
||||
|
||||
+25
-146
@@ -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
@@ -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"
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user