M1c: Gleichschritt im Netz #9
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user