Spieler und Befehle
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Self, DecodeError> {
|
||||||
|
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")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@
|
|||||||
//! world and exchange nothing but commands.
|
//! world and exchange nothing but commands.
|
||||||
|
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
|
pub mod command;
|
||||||
pub mod hash;
|
pub mod hash;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod map;
|
pub mod map;
|
||||||
pub mod mapgen;
|
pub mod mapgen;
|
||||||
|
pub mod player;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|||||||
@@ -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<Self, DecodeError> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user