M1a: deterministischer Rust-Kern #3

Merged
steven merged 15 commits from m1a-rust-kern into main 2026-10-01 06:03:36 +00:00
5 changed files with 310 additions and 0 deletions
Showing only changes of commit 7f904b5e1d - Show all commits
+69
View File
@@ -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")),
}
}
}
+2
View File
@@ -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;
+86
View File
@@ -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()
}
+96
View File
@@ -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);
}
+57
View File
@@ -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"))
);
}