//! 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 } ] ); } }