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::command::Command;
use crate::snapshot::SnapshotError;
use crate::world::World;
use crate::world::{Event, World};
const MAGIC: [u8; 4] = *b"GRWR";
/// 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)]
pub struct Recording {
start: Vec<u8>,
start_tick: u64,
tick_count: u32,
/// Ticks that had commands, as (tick index, commands), in rising order.
busy_ticks: Vec<(u32, Vec<Command>)>,
@@ -67,18 +68,33 @@ impl Recording {
pub fn start(world: &World) -> Self {
Self {
start: world.to_snapshot(),
start_tick: world.tick(),
tick_count: 0,
busy_ticks: Vec::new(),
}
}
/// Records the commands of one tick. Call it once for every
/// [`World::step`], with the same commands.
pub fn push_tick(&mut self, commands: &[Command]) {
/// Advances `world` by one tick with `commands` and records them. This is
/// the only way to add to a recording, so it can never hold other
/// 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() {
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.
@@ -86,6 +102,11 @@ impl Recording {
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.
pub fn replay(&self) -> Result<World, RecordingError> {
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
/// 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> {
assert_eq!(
end.tick(),
self.end_tick(),
"the end state is not at the tick this recording has reached"
);
let mut w = Writer::new();
w.raw(&MAGIC);
w.u32(RECORDING_VERSION);
@@ -139,6 +168,9 @@ impl Recording {
}
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()?;
let busy_count = r.u32()?;
let mut busy_ticks = Vec::new();
@@ -163,6 +195,7 @@ impl Recording {
Ok((
Self {
start,
start_tick,
tick_count,
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 recording = Recording::start(&world);
for commands in &script {
recording.push_tick(commands);
world.step(commands);
recording.step(&mut world, commands);
}
let bytes = recording.to_bytes(&world);
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 recording = Recording::start(&world);
for commands in frozen_script(&world) {
recording.push_tick(&commands);
world.step(&commands);
recording.step(&mut world, &commands);
}
assert_eq!(
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::player::PlayerId;
use grovewright_core::replay::{Recording, RecordingError, RECORDING_VERSION};
use grovewright_core::snapshot::SnapshotError;
use grovewright_core::world::World;
const ADA: PlayerId = PlayerId(0);
@@ -31,12 +32,28 @@ fn recorded_session() -> (Recording, World) {
let mut recording = Recording::start(&world);
for tick in 0..200 {
let commands = commands_for(&world, tick);
recording.push_tick(&commands);
world.step(&commands);
recording.step(&mut world, &commands);
}
(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]
fn replaying_reaches_the_same_state() {
let (recording, world) = recorded_session();
@@ -62,14 +79,14 @@ fn verify_accepts_a_faithful_recording() {
#[test]
fn verify_reports_a_replay_that_ends_in_another_state() {
let (recording, mut world) = recorded_session();
// One tick more than was recorded: the claimed end state is unreachable.
world.step(&[]);
let bytes = recording.to_bytes(&world);
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;
match Recording::verify(&bytes) {
Err(RecordingError::Diverged { expected, actual }) => {
assert_eq!(expected, world.state_hash());
assert_ne!(actual, expected);
assert_eq!(actual, world.state_hash());
assert_ne!(expected, actual);
}
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);
for tick in 50..120 {
let commands = commands_for(&world, tick);
recording.push_tick(&commands);
world.step(&commands);
recording.step(&mut world, &commands);
}
assert_eq!(recording.tick_count(), 70);
assert_eq!(recording.end_tick(), 120);
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));
}
#[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]
fn damaged_recordings_are_rejected() {
let (recording, world) = recorded_session();
@@ -133,3 +167,16 @@ fn damaged_recordings_are_rejected() {
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))
);
}