From 7f904b5e1d319841984e41862ad5e6c17e1a5c90 Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 20:56:51 +0200 Subject: [PATCH] Spieler und Befehle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ein Spieler hat eine dauerhafte Kennung, einen Namen, eine Werkzeugstufe und ein Inventar. Befehle (Beitreten, Verlassen, Graben) sind der einzige Weg, die Welt zu ändern, und lassen sich kodieren. Co-Authored-By: Claude Haiku 4.5 --- core/src/command.rs | 69 +++++++++++++++++++++++++++++++ core/src/lib.rs | 2 + core/src/player.rs | 86 ++++++++++++++++++++++++++++++++++++++ core/tests/command.rs | 96 +++++++++++++++++++++++++++++++++++++++++++ core/tests/player.rs | 57 +++++++++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 core/src/command.rs create mode 100644 core/src/player.rs create mode 100644 core/tests/command.rs create mode 100644 core/tests/player.rs 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")) + ); +}