Welt und ihre Regeln

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 <noreply@anthropic.com>
This commit is contained in:
Steven Wroblewski
2026-09-30 21:05:46 +02:00
parent 7f904b5e1d
commit afc6d9ca77
3 changed files with 559 additions and 0 deletions
+1
View File
@@ -14,3 +14,4 @@ pub mod map;
pub mod mapgen;
pub mod player;
pub mod rng;
pub mod world;
+266
View File
@@ -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<Player>,
}
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<PlayerId> {
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<Event> {
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<Event>) {
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<Event>) {
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<Event>) {
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
}
]
);
}
}