Regel-Kennung und Aufzeichnung Version 3
Die eingefrorenen Skripte ziehen von den festgeschriebenen Tests in den Kern. Aus ihren Endzuständen leitet sich die Regel-Kennung ab, die sich mit jeder Regel ändert, die ein Skript berührt. Aufzeichnungen tragen sie und werden mit anderen Regeln abgelehnt, statt eine scheinbare Abweichung zu melden. Fremde Aufzeichnungen dürfen höchstens 30 Tage lang sein. Ein Test hält fest, dass zu lange Namen beim Dekodieren abgelehnt werden. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user