M1b: Brücke und Hülle #6

Merged
steven merged 21 commits from m1b-bruecke-und-huelle into main 2026-10-01 11:46:02 +00:00
4 changed files with 97 additions and 19 deletions
Showing only changes of commit 5dacbf2222 - Show all commits
+38 -5
View File
@@ -6,7 +6,7 @@ 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::snapshot::SnapshotError; use crate::snapshot::SnapshotError;
use crate::world::World; use crate::world::{Event, World};
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,
@@ -57,6 +57,7 @@ impl From<DecodeError> for RecordingError {
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct Recording { pub struct Recording {
start: Vec<u8>, start: Vec<u8>,
start_tick: u64,
tick_count: u32, tick_count: u32,
/// Ticks that had commands, as (tick index, commands), in rising order. /// Ticks that had commands, as (tick index, commands), in rising order.
busy_ticks: Vec<(u32, Vec<Command>)>, busy_ticks: Vec<(u32, Vec<Command>)>,
@@ -67,18 +68,33 @@ impl Recording {
pub fn start(world: &World) -> Self { pub fn start(world: &World) -> Self {
Self { Self {
start: world.to_snapshot(), start: world.to_snapshot(),
start_tick: world.tick(),
tick_count: 0, tick_count: 0,
busy_ticks: Vec::new(), busy_ticks: Vec::new(),
} }
} }
/// Records the commands of one tick. Call it once for every /// Advances `world` by one tick with `commands` and records them. This is
/// [`World::step`], with the same commands. /// the only way to add to a recording, so it can never hold other
pub fn push_tick(&mut self, commands: &[Command]) { /// commands than the ones the world executed.
///
/// # Panics
/// Panics if `world` is not at [`Recording::end_tick`], which means it is
/// not the world this recording follows.
pub fn step(&mut self, world: &mut World, commands: &[Command]) -> Vec<Event> {
assert_eq!(
world.tick(),
self.end_tick(),
"the world is not at the tick this recording has reached"
);
if !commands.is_empty() { if !commands.is_empty() {
self.busy_ticks.push((self.tick_count, commands.to_vec())); self.busy_ticks.push((self.tick_count, commands.to_vec()));
} }
self.tick_count += 1; self.tick_count = self
.tick_count
.checked_add(1)
.expect("a recording holds fewer than 2^32 ticks");
world.step(commands)
} }
/// Number of recorded ticks. /// Number of recorded ticks.
@@ -86,6 +102,11 @@ impl Recording {
self.tick_count self.tick_count
} }
/// Tick of the recorded world after the last recorded tick.
pub fn end_tick(&self) -> u64 {
self.start_tick + u64::from(self.tick_count)
}
/// Restores the starting state and applies every recorded tick. /// Restores the starting state and applies every recorded tick.
pub fn replay(&self) -> Result<World, RecordingError> { pub fn replay(&self) -> Result<World, RecordingError> {
let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?; let mut world = World::from_snapshot(&self.start).map_err(RecordingError::BadStart)?;
@@ -106,7 +127,15 @@ impl Recording {
/// Encodes the recording together with the state that replaying it must /// Encodes the recording together with the state that replaying it must
/// reach. `end` is the world after the last recorded tick. /// reach. `end` is the world after the last recorded tick.
///
/// # Panics
/// Panics if `end` is not at [`Recording::end_tick`].
pub fn to_bytes(&self, end: &World) -> Vec<u8> { pub fn to_bytes(&self, end: &World) -> Vec<u8> {
assert_eq!(
end.tick(),
self.end_tick(),
"the end state is not at the tick this recording has reached"
);
let mut w = Writer::new(); let mut w = Writer::new();
w.raw(&MAGIC); w.raw(&MAGIC);
w.u32(RECORDING_VERSION); w.u32(RECORDING_VERSION);
@@ -139,6 +168,9 @@ impl Recording {
} }
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)
.map_err(RecordingError::BadStart)?
.tick();
let tick_count = r.u32()?; let tick_count = r.u32()?;
let busy_count = r.u32()?; let busy_count = r.u32()?;
let mut busy_ticks = Vec::new(); let mut busy_ticks = Vec::new();
@@ -163,6 +195,7 @@ impl Recording {
Ok(( Ok((
Self { Self {
start, start,
start_tick,
tick_count, tick_count,
busy_ticks, busy_ticks,
}, },
+1 -2
View File
@@ -123,8 +123,7 @@ fn a_recorded_session_replays_to_the_same_hash() {
let mut world = World::new(8); let mut world = World::new(8);
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for commands in &script { for commands in &script {
recording.push_tick(commands); recording.step(&mut world, commands);
world.step(commands);
} }
let bytes = recording.to_bytes(&world); let bytes = recording.to_bytes(&world);
let replayed = Recording::verify(&bytes).expect("the recording replays cleanly"); let replayed = Recording::verify(&bytes).expect("the recording replays cleanly");
+1 -2
View File
@@ -194,8 +194,7 @@ fn the_recording_format_is_unchanged() {
let mut world = World::new(SEED); let mut world = World::new(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.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
assert_eq!( assert_eq!(
fnv1a64(&recording.to_bytes(&world)), fnv1a64(&recording.to_bytes(&world)),
+57 -10
View File
@@ -4,6 +4,7 @@ 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, RECORDING_VERSION};
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);
@@ -31,12 +32,28 @@ fn recorded_session() -> (Recording, World) {
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for tick in 0..200 { for tick in 0..200 {
let commands = commands_for(&world, tick); let commands = commands_for(&world, tick);
recording.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
(recording, world) (recording, world)
} }
#[test]
fn stepping_through_a_recording_gives_the_events_of_the_world() {
let mut plain = World::new(11);
let mut recorded = World::new(11);
let mut recording = Recording::start(&recorded);
for tick in 0..30 {
let commands = commands_for(&plain, tick);
assert_eq!(
recording.step(&mut recorded, &commands),
plain.step(&commands),
"tick {tick}"
);
}
assert_eq!(recorded, plain);
assert_eq!(recording.end_tick(), 30);
}
#[test] #[test]
fn replaying_reaches_the_same_state() { fn replaying_reaches_the_same_state() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
@@ -62,14 +79,14 @@ fn verify_accepts_a_faithful_recording() {
#[test] #[test]
fn verify_reports_a_replay_that_ends_in_another_state() { fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, mut world) = recorded_session(); let (recording, world) = recorded_session();
// One tick more than was recorded: the claimed end state is unreachable. let mut bytes = recording.to_bytes(&world);
world.step(&[]); // The expected state hash follows the marker and the version.
let bytes = recording.to_bytes(&world); bytes[8] ^= 0x01;
match Recording::verify(&bytes) { match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => { Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(expected, world.state_hash()); assert_eq!(actual, world.state_hash());
assert_ne!(actual, expected); assert_ne!(expected, actual);
} }
other => panic!("expected a divergence, got {other:?}"), other => panic!("expected a divergence, got {other:?}"),
} }
@@ -85,10 +102,10 @@ fn a_recording_can_start_in_the_middle_of_a_session() {
let mut recording = Recording::start(&world); let mut recording = Recording::start(&world);
for tick in 50..120 { for tick in 50..120 {
let commands = commands_for(&world, tick); let commands = commands_for(&world, tick);
recording.push_tick(&commands); recording.step(&mut world, &commands);
world.step(&commands);
} }
assert_eq!(recording.tick_count(), 70); assert_eq!(recording.tick_count(), 70);
assert_eq!(recording.end_tick(), 120);
assert_eq!(recording.replay(), Ok(world)); assert_eq!(recording.replay(), Ok(world));
} }
@@ -100,6 +117,23 @@ fn an_empty_recording_replays_to_its_starting_state() {
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world)); assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
} }
#[test]
#[should_panic(expected = "the world is not at the tick this recording has reached")]
fn a_recording_refuses_a_world_it_does_not_follow() {
let mut world = World::new(3);
let mut recording = Recording::start(&world);
world.step(&[]);
recording.step(&mut world, &[]);
}
#[test]
#[should_panic(expected = "the end state is not at the tick this recording has reached")]
fn a_recording_refuses_an_end_state_at_another_tick() {
let (recording, mut world) = recorded_session();
world.step(&[]);
recording.to_bytes(&world);
}
#[test] #[test]
fn damaged_recordings_are_rejected() { fn damaged_recordings_are_rejected() {
let (recording, world) = recorded_session(); let (recording, world) = recorded_session();
@@ -133,3 +167,16 @@ fn damaged_recordings_are_rejected() {
Err(RecordingError::Corrupt(DecodeError::TrailingBytes)) Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
); );
} }
#[test]
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;
assert_eq!(
Recording::from_bytes(&bytes),
Err(RecordingError::BadStart(SnapshotError::ChecksumMismatch))
);
}