diff --git a/core/src/lib.rs b/core/src/lib.rs index ad890d3..f1fd938 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -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; diff --git a/core/src/protocol.rs b/core/src/protocol.rs new file mode 100644 index 0000000..f8b855e --- /dev/null +++ b/core/src/protocol.rs @@ -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 { + 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 { + 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 { + 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 { + 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 }, + /// 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, + check: Option, + }, +} + +impl ToGuest { + pub fn encode(&self) -> Vec { + 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 { + 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) + } +} diff --git a/core/tests/golden.rs b/core/tests/golden.rs index a38fc45..f87f09e 100644 --- a/core/tests/golden.rs +++ b/core/tests/golden.rs @@ -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" + ); +} diff --git a/core/tests/protocol.rs b/core/tests/protocol.rs new file mode 100644 index 0000000..373be18 --- /dev/null +++ b/core/tests/protocol.rs @@ -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) + ); +}