Aufzeichnung an den Weltschritt koppeln

Recording::step führt einen Takt aus und zeichnet ihn auf. Andere
Befehle als die ausgeführten können so nicht in eine Aufzeichnung
geraten. Aufzeichnung und Welt prüfen gegenseitig ihren Takt, und ein
beschädigter Startzustand fällt schon beim Einlesen auf.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-10-01 10:34:30 +02:00
parent ef728cd100
commit 5dacbf2222
4 changed files with 97 additions and 19 deletions
+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))
);
}