diff --git a/core/src/command.rs b/core/src/command.rs new file mode 100644 index 0000000..6042e23 --- /dev/null +++ b/core/src/command.rs @@ -0,0 +1,69 @@ +//! Everything a player can ask the world to do. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::map::Pos; +use crate::player::{clamp_name, PlayerId, PlayerKey, MAX_NAME_BYTES}; + +/// A request that changes the world. Commands are the only way to change it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Command { + /// A player enters the world, new or returning. + Join { key: PlayerKey, name: String }, + /// A player leaves the world. Their inventory stays. + Leave { player: PlayerId }, + /// A player hits the tile at `pos` with their pickaxe. + Dig { player: PlayerId, pos: Pos }, +} + +const TAG_JOIN: u8 = 1; +const TAG_LEAVE: u8 = 2; +const TAG_DIG: u8 = 3; + +impl Command { + pub fn encode(&self, w: &mut Writer) { + match self { + Command::Join { key, name } => { + w.u8(TAG_JOIN); + w.raw(key); + w.str(&clamp_name(name)); + } + Command::Leave { player } => { + w.u8(TAG_LEAVE); + w.u8(player.0); + } + Command::Dig { player, pos } => { + w.u8(TAG_DIG); + w.u8(player.0); + w.i32(pos.x); + w.i32(pos.y); + } + } + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + match r.u8()? { + TAG_JOIN => { + 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")); + } + Ok(Command::Join { key, name }) + } + TAG_LEAVE => Ok(Command::Leave { + player: PlayerId(r.u8()?), + }), + TAG_DIG => { + let player = PlayerId(r.u8()?); + let x = r.i32()?; + let y = r.i32()?; + Ok(Command::Dig { + player, + pos: Pos::new(x, y), + }) + } + _ => Err(DecodeError::InvalidValue("command tag")), + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index 130ead2..9ec41d3 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -7,8 +7,10 @@ //! world and exchange nothing but commands. pub mod codec; +pub mod command; pub mod hash; pub mod inventory; pub mod map; pub mod mapgen; +pub mod player; pub mod rng; diff --git a/core/src/player.rs b/core/src/player.rs new file mode 100644 index 0000000..b28d6d0 --- /dev/null +++ b/core/src/player.rs @@ -0,0 +1,86 @@ +//! Players known to a world. + +use crate::codec::{DecodeError, Reader, Writer}; +use crate::inventory::Inventory; + +/// Stable identity of a player across sessions, chosen by the player's game. +pub type PlayerKey = [u8; 16]; + +/// Index of a player in the world's player list. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PlayerId(pub u8); + +/// Most players that can be online at the same time. +pub const MAX_ONLINE_PLAYERS: usize = 4; +/// Most players a world remembers. +pub const MAX_KNOWN_PLAYERS: usize = 16; +/// Longest stored player name, in bytes of UTF-8. +pub const MAX_NAME_BYTES: usize = 32; +/// Pickaxe tier every player starts with: stone. +pub const STARTING_TOOL_TIER: u8 = 1; + +/// A player and everything that belongs to them personally. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Player { + pub key: PlayerKey, + pub name: String, + pub online: bool, + pub tool_tier: u8, + /// First tick at which the player may act again. + pub next_action_tick: u64, + pub inventory: Inventory, +} + +impl Player { + /// Creates an online player with a stone pickaxe and an empty inventory. + pub fn new(key: PlayerKey, name: &str) -> Self { + Self { + key, + name: clamp_name(name), + online: true, + tool_tier: STARTING_TOOL_TIER, + next_action_tick: 0, + inventory: Inventory::new(), + } + } + + pub fn encode(&self, w: &mut Writer) { + w.raw(&self.key); + w.str(&self.name); + w.bool(self.online); + w.u8(self.tool_tier); + w.u64(self.next_action_tick); + self.inventory.encode(w); + } + + pub fn decode(r: &mut Reader<'_>) -> Result { + 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")); + } + let online = r.bool()?; + let tool_tier = r.u8()?; + let next_action_tick = r.u64()?; + let inventory = Inventory::decode(r)?; + Ok(Self { + key, + name, + online, + tool_tier, + next_action_tick, + inventory, + }) + } +} + +/// Shortens `name` to at most [`MAX_NAME_BYTES`] bytes without splitting a +/// character. +pub fn clamp_name(name: &str) -> String { + let mut end = name.len().min(MAX_NAME_BYTES); + while !name.is_char_boundary(end) { + end -= 1; + } + name[..end].to_owned() +} diff --git a/core/tests/command.rs b/core/tests/command.rs new file mode 100644 index 0000000..c86f27d --- /dev/null +++ b/core/tests/command.rs @@ -0,0 +1,96 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::command::Command; +use grovewright_core::map::Pos; +use grovewright_core::player::{PlayerId, MAX_NAME_BYTES}; + +fn round_trip(command: &Command) -> Command { + let mut w = Writer::new(); + command.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let decoded = Command::decode(&mut r).expect("command decodes"); + r.finish().expect("no bytes are left over"); + decoded +} + +#[test] +fn every_command_survives_encoding() { + let commands = [ + Command::Join { + key: [7; 16], + name: "Ada".to_owned(), + }, + Command::Leave { + player: PlayerId(3), + }, + Command::Dig { + player: PlayerId(1), + pos: Pos::new(-4, 1_000), + }, + ]; + for command in &commands { + assert_eq!(&round_trip(command), command); + } +} + +#[test] +fn an_overlong_name_is_shortened_when_encoded() { + let command = Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES + 10), + }; + assert_eq!( + round_trip(&command), + Command::Join { + key: [1; 16], + name: "x".repeat(MAX_NAME_BYTES), + } + ); +} + +#[test] +fn an_unknown_command_is_rejected() { + assert_eq!( + Command::decode(&mut Reader::new(&[0xEE])), + Err(DecodeError::InvalidValue("command tag")) + ); +} + +#[test] +fn a_cut_off_command_is_rejected() { + let mut w = Writer::new(); + Command::Dig { + player: PlayerId(0), + pos: Pos::new(1, 2), + } + .encode(&mut w); + let bytes = w.into_bytes(); + assert_eq!( + Command::decode(&mut Reader::new(&bytes[..bytes.len() - 1])), + Err(DecodeError::UnexpectedEnd) + ); +} + +#[test] +fn several_commands_can_follow_each_other_in_one_buffer() { + let commands = vec![ + Command::Leave { + player: PlayerId(2), + }, + Command::Dig { + player: PlayerId(0), + pos: Pos::new(5, 6), + }, + ]; + let mut w = Writer::new(); + for command in &commands { + command.encode(&mut w); + } + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + let mut decoded = Vec::new(); + while r.remaining() > 0 { + decoded.push(Command::decode(&mut r).unwrap()); + } + assert_eq!(decoded, commands); +} diff --git a/core/tests/player.rs b/core/tests/player.rs new file mode 100644 index 0000000..3e3c9f6 --- /dev/null +++ b/core/tests/player.rs @@ -0,0 +1,57 @@ +use grovewright_core::codec::{DecodeError, Reader, Writer}; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::player::{clamp_name, Player, MAX_NAME_BYTES, STARTING_TOOL_TIER}; + +#[test] +fn a_new_player_is_online_with_a_stone_pickaxe_and_empty_pockets() { + let player = Player::new([5; 16], "Ada"); + assert_eq!(player.key, [5; 16]); + assert_eq!(player.name, "Ada"); + assert!(player.online); + assert_eq!(player.tool_tier, STARTING_TOOL_TIER); + assert_eq!(player.next_action_tick, 0); + assert!(player.inventory.slots().iter().all(Option::is_none)); +} + +#[test] +fn long_names_are_cut_at_a_character_boundary() { + // "ä" takes two bytes, so twenty of them are forty bytes. + let long = "ä".repeat(20); + let clamped = clamp_name(&long); + assert_eq!(clamped, "ä".repeat(16)); + assert_eq!(clamped.len(), MAX_NAME_BYTES); + + // With one byte in front, the limit falls into the middle of a character. + let odd = format!("a{}", "ä".repeat(20)); + assert_eq!(clamp_name(&odd), format!("a{}", "ä".repeat(15))); + + assert_eq!(clamp_name("Ada"), "Ada"); + assert_eq!(Player::new([0; 16], &long).name, clamped); +} + +#[test] +fn a_player_survives_encoding() { + let mut player = Player::new([9; 16], "Grace"); + player.online = false; + player.next_action_tick = 1234; + player.inventory.add(ITEM_STONE, 17); + + let mut w = Writer::new(); + player.encode(&mut w); + let bytes = w.into_bytes(); + let mut r = Reader::new(&bytes); + assert_eq!(Player::decode(&mut r), Ok(player)); + assert_eq!(r.finish(), Ok(())); +} + +#[test] +fn decoding_rejects_an_overlong_name() { + let mut w = Writer::new(); + w.raw(&[0; 16]); + w.str(&"x".repeat(MAX_NAME_BYTES + 1)); + let bytes = w.into_bytes(); + assert_eq!( + Player::decode(&mut Reader::new(&bytes)), + Err(DecodeError::InvalidValue("player name")) + ); +}