Aufzeichnung und Wiederholung von Sitzungen
Eine Aufzeichnung hält den Startzustand und die Befehle jedes Takts fest. Wiederholen ergibt exakt dieselbe Welt; verify meldet, wenn das Ende von der aufgezeichneten Prüfsumme abweicht. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ pub mod inventory;
|
||||
pub mod map;
|
||||
pub mod mapgen;
|
||||
pub mod player;
|
||||
pub mod replay;
|
||||
pub mod rng;
|
||||
pub mod snapshot;
|
||||
pub mod world;
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Recording of a session: the starting state and every command, tick by
|
||||
//! tick. Replaying a recording reproduces the session exactly.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::codec::{DecodeError, Reader, Writer};
|
||||
use crate::command::Command;
|
||||
use crate::snapshot::SnapshotError;
|
||||
use crate::world::World;
|
||||
|
||||
const MAGIC: [u8; 4] = *b"GRWR";
|
||||
/// Version of the recording format.
|
||||
pub const RECORDING_VERSION: u32 = 1;
|
||||
|
||||
/// Reason a recording could not be read or replayed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RecordingError {
|
||||
/// The bytes do not start with the recording marker.
|
||||
NotARecording,
|
||||
/// The recording was written by an incompatible version of the game.
|
||||
UnsupportedVersion(u32),
|
||||
/// The recording is damaged.
|
||||
Corrupt(DecodeError),
|
||||
/// The starting state inside the recording could not be restored.
|
||||
BadStart(SnapshotError),
|
||||
/// Replaying did not end in the recorded state.
|
||||
Diverged { expected: u64, actual: u64 },
|
||||
}
|
||||
|
||||
impl fmt::Display for RecordingError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RecordingError::NotARecording => write!(f, "not a recording"),
|
||||
RecordingError::UnsupportedVersion(version) => {
|
||||
write!(f, "unsupported recording version {version}")
|
||||
}
|
||||
RecordingError::Corrupt(error) => write!(f, "damaged recording: {error}"),
|
||||
RecordingError::BadStart(error) => write!(f, "unusable starting state: {error}"),
|
||||
RecordingError::Diverged { expected, actual } => write!(
|
||||
f,
|
||||
"replay ended in state {actual:016x} instead of {expected:016x}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RecordingError {}
|
||||
|
||||
impl From<DecodeError> for RecordingError {
|
||||
fn from(error: DecodeError) -> Self {
|
||||
RecordingError::Corrupt(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The starting state of a session and the commands of every tick after it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Recording {
|
||||
start: Vec<u8>,
|
||||
tick_count: u32,
|
||||
/// Ticks that had commands, as (tick index, commands), in rising order.
|
||||
busy_ticks: Vec<(u32, Vec<Command>)>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
/// Starts a recording at the current state of `world`.
|
||||
pub fn start(world: &World) -> Self {
|
||||
Self {
|
||||
start: world.to_snapshot(),
|
||||
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]) {
|
||||
if !commands.is_empty() {
|
||||
self.busy_ticks.push((self.tick_count, commands.to_vec()));
|
||||
}
|
||||
self.tick_count += 1;
|
||||
}
|
||||
|
||||
/// Number of recorded ticks.
|
||||
pub fn tick_count(&self) -> u32 {
|
||||
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)?;
|
||||
let mut busy = self.busy_ticks.iter().peekable();
|
||||
for index in 0..self.tick_count {
|
||||
match busy.peek() {
|
||||
Some((tick, commands)) if *tick == index => {
|
||||
world.step(commands);
|
||||
busy.next();
|
||||
}
|
||||
_ => {
|
||||
world.step(&[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
/// Encodes the recording together with the state that replaying it must
|
||||
/// reach. `end` is the world after the last recorded tick.
|
||||
pub fn to_bytes(&self, end: &World) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.raw(&MAGIC);
|
||||
w.u32(RECORDING_VERSION);
|
||||
w.u64(end.state_hash());
|
||||
w.bytes(&self.start);
|
||||
w.u32(self.tick_count);
|
||||
w.u32(u32::try_from(self.busy_ticks.len()).expect("busy tick count fits in u32"));
|
||||
for (tick, commands) in &self.busy_ticks {
|
||||
w.u32(*tick);
|
||||
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
|
||||
for command in commands {
|
||||
command.encode(&mut w);
|
||||
}
|
||||
}
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
/// Decodes a recording and the state hash that replaying it must reach.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<(Self, u64), RecordingError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let magic = r
|
||||
.raw(MAGIC.len())
|
||||
.map_err(|_| RecordingError::NotARecording)?;
|
||||
if magic != MAGIC.as_slice() {
|
||||
return Err(RecordingError::NotARecording);
|
||||
}
|
||||
let version = r.u32()?;
|
||||
if version != RECORDING_VERSION {
|
||||
return Err(RecordingError::UnsupportedVersion(version));
|
||||
}
|
||||
let expected_hash = r.u64()?;
|
||||
let start = r.bytes()?.to_vec();
|
||||
let tick_count = r.u32()?;
|
||||
let busy_count = r.u32()?;
|
||||
let mut busy_ticks = Vec::new();
|
||||
let mut first_free_tick = 0;
|
||||
for _ in 0..busy_count {
|
||||
let tick = r.u32()?;
|
||||
if tick < first_free_tick || tick >= tick_count {
|
||||
return Err(DecodeError::InvalidValue("tick index").into());
|
||||
}
|
||||
first_free_tick = tick + 1;
|
||||
let command_count = r.u16()?;
|
||||
if command_count == 0 {
|
||||
return Err(DecodeError::InvalidValue("command count").into());
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for _ in 0..command_count {
|
||||
commands.push(Command::decode(&mut r)?);
|
||||
}
|
||||
busy_ticks.push((tick, commands));
|
||||
}
|
||||
r.finish()?;
|
||||
Ok((
|
||||
Self {
|
||||
start,
|
||||
tick_count,
|
||||
busy_ticks,
|
||||
},
|
||||
expected_hash,
|
||||
))
|
||||
}
|
||||
|
||||
/// Replays `bytes` and checks that the recorded final state is reached.
|
||||
pub fn verify(bytes: &[u8]) -> Result<World, RecordingError> {
|
||||
let (recording, expected) = Self::from_bytes(bytes)?;
|
||||
let world = recording.replay()?;
|
||||
let actual = world.state_hash();
|
||||
if actual == expected {
|
||||
Ok(world)
|
||||
} else {
|
||||
Err(RecordingError::Diverged { expected, actual })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use grovewright_core::codec::DecodeError;
|
||||
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::world::World;
|
||||
|
||||
const ADA: PlayerId = PlayerId(0);
|
||||
|
||||
/// Commands of tick `tick` in a short scripted session.
|
||||
fn commands_for(world: &World, tick: u32) -> Vec<Command> {
|
||||
if tick == 0 {
|
||||
vec![Command::Join {
|
||||
key: [1; 16],
|
||||
name: "Ada".to_owned(),
|
||||
}]
|
||||
} else if tick.is_multiple_of(7) {
|
||||
vec![Command::Dig {
|
||||
player: ADA,
|
||||
pos: outcrop_origin(world.map()),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
/// Plays 200 ticks from a fresh world and records them.
|
||||
fn recorded_session() -> (Recording, World) {
|
||||
let mut world = World::new(11);
|
||||
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, world)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replaying_reaches_the_same_state() {
|
||||
let (recording, world) = recorded_session();
|
||||
assert_eq!(recording.tick_count(), 200);
|
||||
assert_eq!(world.player(ADA).unwrap().inventory.count(ITEM_STONE), 1);
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_survives_encoding() {
|
||||
let (recording, world) = recorded_session();
|
||||
let bytes = recording.to_bytes(&world);
|
||||
let (decoded, expected_hash) = Recording::from_bytes(&bytes).unwrap();
|
||||
assert_eq!(decoded, recording);
|
||||
assert_eq!(expected_hash, world.state_hash());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_accepts_a_faithful_recording() {
|
||||
let (recording, world) = recorded_session();
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[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);
|
||||
match Recording::verify(&bytes) {
|
||||
Err(RecordingError::Diverged { expected, actual }) => {
|
||||
assert_eq!(expected, world.state_hash());
|
||||
assert_ne!(actual, expected);
|
||||
}
|
||||
other => panic!("expected a divergence, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recording_can_start_in_the_middle_of_a_session() {
|
||||
let mut world = World::new(11);
|
||||
for tick in 0..50 {
|
||||
let commands = commands_for(&world, tick);
|
||||
world.step(&commands);
|
||||
}
|
||||
let mut recording = Recording::start(&world);
|
||||
for tick in 50..120 {
|
||||
let commands = commands_for(&world, tick);
|
||||
recording.push_tick(&commands);
|
||||
world.step(&commands);
|
||||
}
|
||||
assert_eq!(recording.tick_count(), 70);
|
||||
assert_eq!(recording.replay(), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_recording_replays_to_its_starting_state() {
|
||||
let world = World::new(3);
|
||||
let recording = Recording::start(&world);
|
||||
assert_eq!(recording.tick_count(), 0);
|
||||
assert_eq!(Recording::verify(&recording.to_bytes(&world)), Ok(world));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_recordings_are_rejected() {
|
||||
let (recording, world) = recorded_session();
|
||||
let bytes = recording.to_bytes(&world);
|
||||
|
||||
assert_eq!(
|
||||
Recording::from_bytes(b""),
|
||||
Err(RecordingError::NotARecording)
|
||||
);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&world.to_snapshot()),
|
||||
Err(RecordingError::NotARecording)
|
||||
);
|
||||
|
||||
let mut other_version = bytes.clone();
|
||||
other_version[4..8].copy_from_slice(&(RECORDING_VERSION + 1).to_le_bytes());
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&other_version),
|
||||
Err(RecordingError::UnsupportedVersion(RECORDING_VERSION + 1))
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&bytes[..bytes.len() - 1]),
|
||||
Err(RecordingError::Corrupt(DecodeError::UnexpectedEnd))
|
||||
);
|
||||
|
||||
let mut padded = bytes.clone();
|
||||
padded.push(0);
|
||||
assert_eq!(
|
||||
Recording::from_bytes(&padded),
|
||||
Err(RecordingError::Corrupt(DecodeError::TrailingBytes))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user