From afc6d9ca7732c5a26cd519aeb6ccfa6ba5f5c32d Mon Sep 17 00:00:00 2001 From: Steven Wroblewski Date: Wed, 30 Sep 2026 21:05:46 +0200 Subject: [PATCH] Welt und ihre Regeln MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit World::step wendet die Befehle eines Takts der Reihe nach an: Spieler treten bei und verlassen die Welt, drei Schläge brechen weichen Fels und bringen einen Stein. Abgelehnte Befehle ändern nichts. Co-Authored-By: Claude Haiku 4.5 --- core/src/lib.rs | 1 + core/src/world.rs | 266 ++++++++++++++++++++++++++++++++++++++++ core/tests/world.rs | 292 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 559 insertions(+) create mode 100644 core/src/world.rs create mode 100644 core/tests/world.rs diff --git a/core/src/lib.rs b/core/src/lib.rs index 9ec41d3..13f3dcf 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -14,3 +14,4 @@ pub mod map; pub mod mapgen; pub mod player; pub mod rng; +pub mod world; diff --git a/core/src/world.rs b/core/src/world.rs new file mode 100644 index 0000000..2df6b2e --- /dev/null +++ b/core/src/world.rs @@ -0,0 +1,266 @@ +//! The shared world and the rules that change it. + +use crate::command::Command; +use crate::inventory::{ItemId, ITEM_STONE}; +use crate::map::{Map, Pos, Terrain, Tile}; +use crate::mapgen::generate_test_map; +use crate::player::{ + clamp_name, Player, PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS, +}; + +/// Simulation steps per second of play. +pub const TICKS_PER_SECOND: u32 = 20; +/// Ticks a player has to wait between two pickaxe hits. +pub const DIG_COOLDOWN_TICKS: u64 = 5; + +/// Something that happened during a tick. Events tell the presentation what +/// to show. They are not part of the world state. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + PlayerJoined { + player: PlayerId, + }, + PlayerLeft { + player: PlayerId, + }, + /// A player could not join because the world is full. + JoinRefused { + key: PlayerKey, + }, + /// A pickaxe hit landed without breaking the tile. + TileHit { + player: PlayerId, + pos: Pos, + }, + /// A tile changed its terrain. + TileChanged { + pos: Pos, + }, + ItemGained { + player: PlayerId, + item: ItemId, + count: u32, + }, + /// A hit was refused because the inventory has no room for the result. + InventoryFull { + player: PlayerId, + }, +} + +/// Complete state of the shared world. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct World { + pub(crate) seed: u64, + pub(crate) tick: u64, + pub(crate) map: Map, + pub(crate) players: Vec, +} + +impl World { + /// Creates a fresh world with the test map for `seed` and no players. + pub fn new(seed: u64) -> Self { + Self { + seed, + tick: 0, + map: generate_test_map(seed), + players: Vec::new(), + } + } + + pub fn seed(&self) -> u64 { + self.seed + } + + /// Number of ticks simulated so far. + pub fn tick(&self) -> u64 { + self.tick + } + + pub fn map(&self) -> &Map { + &self.map + } + + pub fn players(&self) -> &[Player] { + &self.players + } + + pub fn player(&self, id: PlayerId) -> Option<&Player> { + self.players.get(id.0 as usize) + } + + pub fn find_player(&self, key: &PlayerKey) -> Option { + self.players + .iter() + .position(|player| &player.key == key) + .map(|index| PlayerId(index as u8)) + } + + /// Marks every player as offline. Used after loading a save from disk, + /// when nobody has joined yet. + pub fn set_all_offline(&mut self) { + for player in &mut self.players { + player.online = false; + } + } + + /// Applies `commands` in order and advances the world by one tick. + pub fn step(&mut self, commands: &[Command]) -> Vec { + let mut events = Vec::new(); + for command in commands { + match command { + Command::Join { key, name } => self.join(key, name, &mut events), + Command::Leave { player } => self.leave(*player, &mut events), + Command::Dig { player, pos } => self.dig(*player, *pos, &mut events), + } + } + self.tick += 1; + events + } + + fn join(&mut self, key: &PlayerKey, name: &str, events: &mut Vec) { + let online = self.players.iter().filter(|player| player.online).count(); + match self.find_player(key) { + Some(id) => { + let player = &mut self.players[id.0 as usize]; + if player.online { + return; + } + if online >= MAX_ONLINE_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + player.online = true; + player.name = clamp_name(name); + events.push(Event::PlayerJoined { player: id }); + } + None => { + if online >= MAX_ONLINE_PLAYERS || self.players.len() >= MAX_KNOWN_PLAYERS { + events.push(Event::JoinRefused { key: *key }); + return; + } + self.players.push(Player::new(*key, name)); + let id = PlayerId((self.players.len() - 1) as u8); + events.push(Event::PlayerJoined { player: id }); + } + } + } + + fn leave(&mut self, id: PlayerId, events: &mut Vec) { + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online { + return; + } + player.online = false; + events.push(Event::PlayerLeft { player: id }); + } + + fn dig(&mut self, id: PlayerId, pos: Pos, events: &mut Vec) { + let tick = self.tick; + let Some(player) = self.players.get_mut(id.0 as usize) else { + return; + }; + if !player.online || tick < player.next_action_tick { + return; + } + let Some(tile) = self.map.get(pos) else { + return; + }; + let (Some(hits), Some(tier)) = ( + tile.terrain.hits_to_break(), + tile.terrain.required_tool_tier(), + ) else { + return; + }; + if player.tool_tier < tier { + return; + } + + let damage = tile.damage.saturating_add(1); + let breaks = damage >= hits; + if breaks && player.inventory.free_space(ITEM_STONE) == 0 { + events.push(Event::InventoryFull { player: id }); + return; + } + + player.next_action_tick = tick + DIG_COOLDOWN_TICKS; + if breaks { + self.map.set(pos, Tile::new(Terrain::Ground)); + player.inventory.add(ITEM_STONE, 1); + events.push(Event::TileChanged { pos }); + events.push(Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1, + }); + } else { + self.map.set( + pos, + Tile { + terrain: tile.terrain, + damage, + }, + ); + events.push(Event::TileHit { player: id, pos }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inventory::{INVENTORY_SLOTS, STACK_LIMIT}; + use crate::mapgen::outcrop_origin; + + // Lives here instead of in `tests/world.rs` because filling an inventory + // needs direct access to the player, which the public API does not give. + #[test] + fn a_full_inventory_refuses_the_breaking_hit() { + let mut world = World::new(42); + world.step(&[Command::Join { + key: [1; 16], + name: "Ada".to_owned(), + }]); + let id = PlayerId(0); + let rock = outcrop_origin(world.map()); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let capacity = STACK_LIMIT * INVENTORY_SLOTS as u32; + assert_eq!(world.players[0].inventory.add(ITEM_STONE, capacity), 0); + + for _ in 0..2 { + assert_eq!( + world.step(&dig), + vec![Event::TileHit { + player: id, + pos: rock + }] + ); + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } + } + + assert_eq!(world.step(&dig), vec![Event::InventoryFull { player: id }]); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::SoftRock, 2)); + + // The refused hit starts no cooldown: once there is room, the very + // next tick can break the tile. + assert!(world.players[0].inventory.remove(ITEM_STONE, 1)); + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + } +} diff --git a/core/tests/world.rs b/core/tests/world.rs new file mode 100644 index 0000000..ae70c04 --- /dev/null +++ b/core/tests/world.rs @@ -0,0 +1,292 @@ +use grovewright_core::command::Command; +use grovewright_core::inventory::ITEM_STONE; +use grovewright_core::map::{Pos, Terrain}; +use grovewright_core::mapgen::{outcrop_origin, spawn_pos}; +use grovewright_core::player::{PlayerId, PlayerKey, MAX_KNOWN_PLAYERS, MAX_ONLINE_PLAYERS}; +use grovewright_core::world::{Event, World, DIG_COOLDOWN_TICKS, TICKS_PER_SECOND}; + +fn key(n: u8) -> PlayerKey { + [n; 16] +} + +fn join(n: u8) -> Command { + Command::Join { + key: key(n), + name: format!("Spieler {n}"), + } +} + +/// A world with one online player, and a soft rock tile to dig at. +fn world_with_player() -> (World, PlayerId, Pos) { + let mut world = World::new(42); + world.step(&[join(1)]); + let rock = outcrop_origin(world.map()); + (world, PlayerId(0), rock) +} + +/// Steps without commands until a player who just hit may hit again. +fn wait_for_cooldown(world: &mut World) { + for _ in 0..DIG_COOLDOWN_TICKS { + world.step(&[]); + } +} + +#[test] +fn a_new_world_starts_at_tick_zero_without_players() { + let world = World::new(42); + assert_eq!(world.seed(), 42); + assert_eq!(world.tick(), 0); + assert!(world.players().is_empty()); + assert_eq!(TICKS_PER_SECOND, 20); +} + +#[test] +fn step_advances_the_tick() { + let mut world = World::new(1); + assert!(world.step(&[]).is_empty()); + assert_eq!(world.tick(), 1); +} + +#[test] +fn joining_adds_an_online_player() { + let mut world = World::new(1); + let events = world.step(&[join(7)]); + assert_eq!( + events, + vec![Event::PlayerJoined { + player: PlayerId(0) + }] + ); + let player = world.player(PlayerId(0)).unwrap(); + assert!(player.online); + assert_eq!(player.name, "Spieler 7"); + assert_eq!(world.find_player(&key(7)), Some(PlayerId(0))); + assert_eq!(world.find_player(&key(8)), None); + assert!(world.player(PlayerId(1)).is_none()); +} + +#[test] +fn joining_twice_changes_nothing() { + let mut world = World::new(1); + world.step(&[join(7)]); + assert!(world.step(&[join(7)]).is_empty()); + assert_eq!(world.players().len(), 1); +} + +#[test] +fn leaving_and_returning_keeps_the_inventory() { + let (mut world, id, rock) = world_with_player(); + for _ in 0..3 { + world.step(&[Command::Dig { + player: id, + pos: rock, + }]); + wait_for_cooldown(&mut world); + } + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); + + assert_eq!( + world.step(&[Command::Leave { player: id }]), + vec![Event::PlayerLeft { player: id }] + ); + assert!(!world.player(id).unwrap().online); + assert!(world.step(&[Command::Leave { player: id }]).is_empty()); + + assert_eq!( + world.step(&[join(1)]), + vec![Event::PlayerJoined { player: id }] + ); + assert_eq!(world.players().len(), 1); + assert!(world.player(id).unwrap().online); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn a_returning_player_can_change_their_name() { + let (mut world, id, _) = world_with_player(); + world.step(&[Command::Leave { player: id }]); + world.step(&[Command::Join { + key: key(1), + name: "Neuer Name".to_owned(), + }]); + assert_eq!(world.player(id).unwrap().name, "Neuer Name"); +} + +#[test] +fn a_fifth_online_player_is_refused() { + let mut world = World::new(1); + for n in 0..MAX_ONLINE_PLAYERS as u8 { + world.step(&[join(n)]); + } + assert_eq!( + world.step(&[join(99)]), + vec![Event::JoinRefused { key: key(99) }] + ); + assert_eq!(world.players().len(), MAX_ONLINE_PLAYERS); + + // A known player who is offline is refused as well while the world is full. + world.step(&[Command::Leave { + player: PlayerId(0), + }]); + world.step(&[join(50)]); + assert_eq!( + world.step(&[join(0)]), + vec![Event::JoinRefused { key: key(0) }] + ); +} + +#[test] +fn the_world_remembers_a_limited_number_of_players() { + let mut world = World::new(1); + for n in 0..MAX_KNOWN_PLAYERS as u8 { + world.step(&[join(n)]); + world.step(&[Command::Leave { + player: PlayerId(n), + }]); + } + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); + assert_eq!( + world.step(&[join(200)]), + vec![Event::JoinRefused { key: key(200) }] + ); + assert_eq!(world.players().len(), MAX_KNOWN_PLAYERS); +} + +#[test] +fn set_all_offline_marks_every_player_offline() { + let mut world = World::new(1); + world.step(&[join(1), join(2)]); + world.set_all_offline(); + assert!(world.players().iter().all(|player| !player.online)); +} + +#[test] +fn three_hits_break_soft_rock_and_give_one_stone() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + let hit = vec![Event::TileHit { + player: id, + pos: rock, + }]; + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 1); + wait_for_cooldown(&mut world); + + assert_eq!(world.step(&dig), hit); + assert_eq!(world.map().get(rock).unwrap().damage, 2); + wait_for_cooldown(&mut world); + + assert_eq!( + world.step(&dig), + vec![ + Event::TileChanged { pos: rock }, + Event::ItemGained { + player: id, + item: ITEM_STONE, + count: 1 + } + ] + ); + let tile = world.map().get(rock).unwrap(); + assert_eq!((tile.terrain, tile.damage), (Terrain::Ground, 0)); + assert_eq!(world.player(id).unwrap().inventory.count(ITEM_STONE), 1); +} + +#[test] +fn hits_during_the_cooldown_are_ignored() { + let (mut world, id, rock) = world_with_player(); + let dig = [Command::Dig { + player: id, + pos: rock, + }]; + world.step(&dig); + for _ in 1..DIG_COOLDOWN_TICKS { + assert!(world.step(&dig).is_empty()); + } + assert_eq!(world.map().get(rock).unwrap().damage, 1); + assert_eq!(world.step(&dig).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 2); +} + +#[test] +fn two_hits_in_one_tick_count_once() { + let (mut world, id, rock) = world_with_player(); + let dig = Command::Dig { + player: id, + pos: rock, + }; + let twice = [dig.clone(), dig]; + assert_eq!(world.step(&twice).len(), 1); + assert_eq!(world.map().get(rock).unwrap().damage, 1); +} + +#[test] +fn ground_bedrock_and_positions_off_the_map_cannot_be_dug() { + let (mut world, id, _) = world_with_player(); + let spawn = spawn_pos(world.map()); + for pos in [ + spawn, + Pos::new(0, 0), + Pos::new(-5, 3), + Pos::new(10_000, 10_000), + ] { + let before = world.map().clone(); + let events = world.step(&[Command::Dig { player: id, pos }]); + assert!(events.is_empty(), "{pos:?}"); + assert_eq!(world.map(), &before, "{pos:?}"); + } +} + +#[test] +fn hard_rock_resists_the_stone_pickaxe() { + let (mut world, id, _) = world_with_player(); + let width = world.map().width() as usize; + let index = world + .map() + .tiles() + .iter() + .position(|tile| tile.terrain == Terrain::HardRock) + .expect("the test map has hard rock"); + let pos = Pos::new((index % width) as i32, (index / width) as i32); + + assert!(world.step(&[Command::Dig { player: id, pos }]).is_empty()); + assert_eq!(world.map().get(pos).unwrap().damage, 0); +} + +#[test] +fn unknown_and_offline_players_cannot_dig() { + let (mut world, id, rock) = world_with_player(); + assert!(world + .step(&[Command::Dig { + player: PlayerId(9), + pos: rock + }]) + .is_empty()); + world.step(&[Command::Leave { player: id }]); + assert!(world + .step(&[Command::Dig { + player: id, + pos: rock + }]) + .is_empty()); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +} + +#[test] +fn commands_of_one_tick_are_applied_in_order() { + let (mut world, id, rock) = world_with_player(); + // Leaving first makes the hit in the same tick invalid. + let events = world.step(&[ + Command::Leave { player: id }, + Command::Dig { + player: id, + pos: rock, + }, + ]); + assert_eq!(events, vec![Event::PlayerLeft { player: id }]); + assert_eq!(world.map().get(rock).unwrap().damage, 0); +}