M1a: deterministischer Rust-Kern #3
@@ -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