M1c: Gleichschritt im Netz #9

Merged
steven merged 20 commits from m1c-gleichschritt-im-netz into main 2026-10-02 06:41:02 +00:00
4 changed files with 505 additions and 0 deletions
Showing only changes of commit a0cb6b12b7 - Show all commits
+1
View File
@@ -13,6 +13,7 @@ pub mod inventory;
pub mod map;
pub mod mapgen;
pub mod player;
pub mod protocol;
pub mod replay;
pub mod rng;
pub mod rules;
+276
View File
@@ -0,0 +1,276 @@
//! Messages between the host of a shared world and its guests.
//!
//! Every machine simulates the whole world. The host decides which commands
//! run in which tick and sends every tick to every guest; a guest sends the
//! host only what its player wants to do. The shell carries these messages
//! as they are, and it must deliver the host's messages to a guest reliably
//! and in the order they were sent.
//!
//! The tag and the version at the start of a greeting, and the whole refusal
//! message, never change. That way games of every version can still tell
//! each other that they do not fit together.
use crate::codec::{DecodeError, Reader, Writer};
use crate::command::Command;
use crate::map::Pos;
use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES};
use crate::world::TICKS_PER_SECOND;
/// Version of the messages. Raise it whenever a message changes.
pub const PROTOCOL_VERSION: u32 = 1;
/// Longest message a guest may send, in bytes. The longest one is a greeting
/// with a name of full length, which needs 65 bytes.
pub const MAX_GUEST_MESSAGE_BYTES: usize = 128;
/// Ticks between two state checksums in the stream of ticks: one second.
pub const CHECK_INTERVAL_TICKS: u64 = TICKS_PER_SECOND as u64;
const TO_HOST_HELLO: u8 = 1;
const TO_HOST_ACT: u8 = 2;
const TO_HOST_DIVERGED: u8 = 3;
const TO_GUEST_WORLD: u8 = 1;
const TO_GUEST_REFUSED: u8 = 2;
const TO_GUEST_TICK: u8 = 3;
const ACTION_DIG: u8 = 1;
/// Something a player wants to do. The host turns it into a command for the
/// player of the connection it came from, so that no guest can act for
/// another player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Hit the tile at `pos` with the pickaxe.
Dig { pos: Pos },
}
impl Action {
/// The command that carries out this action for `player`.
pub fn command(self, player: PlayerId) -> Command {
match self {
Action::Dig { pos } => Command::Dig { player, pos },
}
}
fn encode(&self, w: &mut Writer) {
match self {
Action::Dig { pos } => {
w.u8(ACTION_DIG);
w.i32(pos.x);
w.i32(pos.y);
}
}
}
fn decode(r: &mut Reader<'_>) -> Result<Self, DecodeError> {
match r.u8()? {
ACTION_DIG => {
let x = r.i32()?;
let y = r.i32()?;
Ok(Action::Dig {
pos: Pos::new(x, y),
})
}
_ => Err(DecodeError::InvalidValue("action tag")),
}
}
}
/// Why a host turned a guest away.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Refusal {
/// The guest's game speaks another version of these messages.
OtherProtocol = 1,
/// The guest's game has other rules than the host's.
OtherRules = 2,
/// Four players are online already.
WorldFull = 3,
/// The world remembers as many players as it can and does not know this
/// one.
TooManyPlayers = 4,
/// A player with the same key is in the world already.
AlreadyPlaying = 5,
}
impl Refusal {
/// The number that stands for this refusal in a message.
pub fn code(self) -> u8 {
self as u8
}
pub fn from_code(code: u8) -> Option<Self> {
match code {
1 => Some(Refusal::OtherProtocol),
2 => Some(Refusal::OtherRules),
3 => Some(Refusal::WorldFull),
4 => Some(Refusal::TooManyPlayers),
5 => Some(Refusal::AlreadyPlaying),
_ => None,
}
}
}
/// A message from a guest to the host.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToHost {
/// The first message of every guest.
Hello {
rules: u64,
key: PlayerKey,
name: String,
},
/// A greeting in another version of the messages. Nothing after the
/// version can be read.
ForeignHello { protocol: u32 },
/// The guest's player wants to do something.
Act(Action),
/// After `tick` ticks the guest's state had the hash `hash`, which
/// differs from the host's checksum. The host answers with its world.
Diverged { tick: u64, hash: u64 },
}
impl ToHost {
pub fn encode(&self) -> Vec<u8> {
let mut w = Writer::new();
match self {
ToHost::Hello { rules, key, name } => {
w.u8(TO_HOST_HELLO);
w.u32(PROTOCOL_VERSION);
w.u64(*rules);
w.raw(key);
w.str(&clamp_name(name));
}
ToHost::ForeignHello { protocol } => {
w.u8(TO_HOST_HELLO);
w.u32(*protocol);
}
ToHost::Act(action) => {
w.u8(TO_HOST_ACT);
action.encode(&mut w);
}
ToHost::Diverged { tick, hash } => {
w.u8(TO_HOST_DIVERGED);
w.u64(*tick);
w.u64(*hash);
}
}
w.into_bytes()
}
/// Decodes a whole message. Messages longer than
/// [`MAX_GUEST_MESSAGE_BYTES`] are rejected unread.
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
if bytes.len() > MAX_GUEST_MESSAGE_BYTES {
return Err(DecodeError::InvalidValue("message length"));
}
let mut r = Reader::new(bytes);
let message = match r.u8()? {
TO_HOST_HELLO => {
let protocol = r.u32()?;
if protocol != PROTOCOL_VERSION {
return Ok(ToHost::ForeignHello { protocol });
}
let rules = r.u64()?;
let mut key = [0u8; 16];
key.copy_from_slice(r.raw(16)?);
let name = r.string()?;
if name.len() > MAX_NAME_BYTES {
return Err(DecodeError::InvalidValue("player name"));
}
ToHost::Hello { rules, key, name }
}
TO_HOST_ACT => ToHost::Act(Action::decode(&mut r)?),
TO_HOST_DIVERGED => ToHost::Diverged {
tick: r.u64()?,
hash: r.u64()?,
},
_ => return Err(DecodeError::InvalidValue("message tag")),
};
r.finish()?;
Ok(message)
}
}
/// A message from the host to a guest.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ToGuest {
/// The world as it is now. The host sends it when it accepts a guest and
/// again after the guest reported a divergence. The ticks that follow
/// start at this world's tick.
World { snapshot: Vec<u8> },
/// The host turned the guest away and closes the connection.
Refused(Refusal),
/// The commands of tick `tick` in the order the host ran them. Every
/// [`CHECK_INTERVAL_TICKS`] ticks, `check` holds the hash of the host's
/// state after the tick.
Tick {
tick: u64,
commands: Vec<Command>,
check: Option<u64>,
},
}
impl ToGuest {
pub fn encode(&self) -> Vec<u8> {
let mut w = Writer::new();
match self {
ToGuest::World { snapshot } => {
w.u8(TO_GUEST_WORLD);
w.bytes(snapshot);
}
ToGuest::Refused(refusal) => {
w.u8(TO_GUEST_REFUSED);
w.u8(refusal.code());
}
ToGuest::Tick {
tick,
commands,
check,
} => {
w.u8(TO_GUEST_TICK);
w.u64(*tick);
w.u16(u16::try_from(commands.len()).expect("command count fits in u16"));
for command in commands {
command.encode(&mut w);
}
match check {
Some(hash) => {
w.bool(true);
w.u64(*hash);
}
None => w.bool(false),
}
}
}
w.into_bytes()
}
/// Decodes a whole message.
pub fn decode(bytes: &[u8]) -> Result<Self, DecodeError> {
let mut r = Reader::new(bytes);
let message = match r.u8()? {
TO_GUEST_WORLD => ToGuest::World {
snapshot: r.bytes()?.to_vec(),
},
TO_GUEST_REFUSED => ToGuest::Refused(
Refusal::from_code(r.u8()?).ok_or(DecodeError::InvalidValue("refusal"))?,
),
TO_GUEST_TICK => {
let tick = r.u64()?;
let count = r.u16()?;
let mut commands = Vec::new();
for _ in 0..count {
commands.push(Command::decode(&mut r)?);
}
let check = if r.bool()? { Some(r.u64()?) } else { None };
ToGuest::Tick {
tick,
commands,
check,
}
}
_ => return Err(DecodeError::InvalidValue("message tag")),
};
r.finish()?;
Ok(message)
}
}
+51
View File
@@ -8,8 +8,11 @@
//! value here deliberately.
use grovewright_core::codec::Writer;
use grovewright_core::command::Command;
use grovewright_core::hash::fnv1a64;
use grovewright_core::map::{Map, Pos, Terrain, Tile};
use grovewright_core::player::PlayerId;
use grovewright_core::protocol::{Action, Refusal, ToGuest, ToHost};
use grovewright_core::replay::Recording;
use grovewright_core::rules::{
end_hash, frozen_offline_script, frozen_script, rules_id, RULES_SEED,
@@ -114,3 +117,51 @@ fn the_map_encoding_is_unchanged() {
core/src/snapshot.rs; then update these bytes"
);
}
/// Covers every message between host and guests, built from literal values.
#[test]
fn the_protocol_format_is_unchanged() {
let to_host = [
ToHost::Hello {
rules: 0x0102_0304_0506_0708,
key: [3; 16],
name: "Ada".to_owned(),
},
ToHost::Act(Action::Dig {
pos: Pos::new(-2, 9),
}),
ToHost::Diverged { tick: 40, hash: 77 },
];
let to_guest = [
ToGuest::World {
snapshot: vec![5, 6, 7],
},
ToGuest::Refused(Refusal::WorldFull),
ToGuest::Tick {
tick: 12,
commands: vec![Command::Dig {
player: PlayerId(1),
pos: Pos::new(3, 4),
}],
check: Some(99),
},
ToGuest::Tick {
tick: 13,
commands: vec![],
check: None,
},
];
let mut bytes = Vec::new();
for message in &to_host {
bytes.extend(message.encode());
}
for message in &to_guest {
bytes.extend(message.encode());
}
assert_eq!(
fnv1a64(&bytes),
0xc263_69c0_f892_247b,
"the messages between host and guests changed; raise \
PROTOCOL_VERSION in core/src/protocol.rs; then update this value"
);
}
+177
View File
@@ -0,0 +1,177 @@
use grovewright_core::codec::{DecodeError, Writer};
use grovewright_core::command::Command;
use grovewright_core::map::Pos;
use grovewright_core::player::{PlayerId, MAX_NAME_BYTES};
use grovewright_core::protocol::{
Action, Refusal, ToGuest, ToHost, MAX_GUEST_MESSAGE_BYTES, PROTOCOL_VERSION,
};
#[test]
fn every_message_to_the_host_survives_encoding() {
let messages = [
ToHost::Hello {
rules: 0x0123_4567_89ab_cdef,
key: [9; 16],
name: "Ada".to_owned(),
},
ToHost::Act(Action::Dig {
pos: Pos::new(-3, 70),
}),
ToHost::Diverged {
tick: 1_234,
hash: u64::MAX,
},
];
for message in &messages {
assert_eq!(&ToHost::decode(&message.encode()).unwrap(), message);
}
}
#[test]
fn every_message_to_a_guest_survives_encoding() {
let messages = [
ToGuest::World {
snapshot: vec![1, 2, 3],
},
ToGuest::Refused(Refusal::TooManyPlayers),
ToGuest::Tick {
tick: 99,
commands: vec![
Command::Leave {
player: PlayerId(2),
},
Command::Dig {
player: PlayerId(0),
pos: Pos::new(4, 5),
},
],
check: Some(7),
},
ToGuest::Tick {
tick: 100,
commands: vec![],
check: None,
},
];
for message in &messages {
assert_eq!(&ToGuest::decode(&message.encode()).unwrap(), message);
}
}
#[test]
fn an_action_becomes_a_command_for_the_given_player() {
let action = Action::Dig {
pos: Pos::new(1, 2),
};
assert_eq!(
action.command(PlayerId(3)),
Command::Dig {
player: PlayerId(3),
pos: Pos::new(1, 2)
}
);
}
#[test]
fn a_greeting_in_another_protocol_version_can_still_be_recognised() {
let mut w = Writer::new();
w.u8(1); // the tag of a greeting, which never changes
w.u32(PROTOCOL_VERSION + 1);
w.raw(b"whatever the other version sends after its version");
assert_eq!(
ToHost::decode(&w.into_bytes()),
Ok(ToHost::ForeignHello {
protocol: PROTOCOL_VERSION + 1
})
);
}
#[test]
fn every_refusal_keeps_its_code() {
let codes = [
(Refusal::OtherProtocol, 1),
(Refusal::OtherRules, 2),
(Refusal::WorldFull, 3),
(Refusal::TooManyPlayers, 4),
(Refusal::AlreadyPlaying, 5),
];
for (refusal, code) in codes {
assert_eq!(refusal.code(), code);
assert_eq!(Refusal::from_code(code), Some(refusal));
}
assert_eq!(Refusal::from_code(0), None);
assert_eq!(Refusal::from_code(6), None);
}
#[test]
fn a_greeting_shortens_an_overlong_name() {
let hello = ToHost::Hello {
rules: 1,
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES + 5),
};
assert_eq!(
ToHost::decode(&hello.encode()),
Ok(ToHost::Hello {
rules: 1,
key: [1; 16],
name: "x".repeat(MAX_NAME_BYTES),
})
);
}
#[test]
fn a_greeting_with_an_overlong_name_written_by_hand_is_rejected() {
let mut w = Writer::new();
w.u8(1);
w.u32(PROTOCOL_VERSION);
w.u64(1);
w.raw(&[1; 16]);
w.str(&"x".repeat(MAX_NAME_BYTES + 1));
assert_eq!(
ToHost::decode(&w.into_bytes()),
Err(DecodeError::InvalidValue("player name"))
);
}
#[test]
fn broken_messages_are_rejected() {
assert_eq!(ToHost::decode(&[]), Err(DecodeError::UnexpectedEnd));
assert_eq!(
ToHost::decode(&[0xEE]),
Err(DecodeError::InvalidValue("message tag"))
);
assert_eq!(
ToHost::decode(&[2, 0xEE]),
Err(DecodeError::InvalidValue("action tag"))
);
assert_eq!(
ToHost::decode(&[2; MAX_GUEST_MESSAGE_BYTES + 1]),
Err(DecodeError::InvalidValue("message length"))
);
let mut act = ToHost::Act(Action::Dig {
pos: Pos::new(0, 0),
})
.encode();
act.push(0);
assert_eq!(ToHost::decode(&act), Err(DecodeError::TrailingBytes));
assert_eq!(
ToGuest::decode(&[0xEE]),
Err(DecodeError::InvalidValue("message tag"))
);
assert_eq!(
ToGuest::decode(&[2, 99]),
Err(DecodeError::InvalidValue("refusal"))
);
let tick = ToGuest::Tick {
tick: 1,
commands: vec![],
check: Some(5),
}
.encode();
assert_eq!(
ToGuest::decode(&tick[..tick.len() - 1]),
Err(DecodeError::UnexpectedEnd)
);
}