Extract headless sim crate from server

Move chunk, entity, world, map, sim and load_world into a new sim library
crate together with the game rules from shared (player_action, action_delta,
delta_action, step_allowed, tile_collidable, tick constants). shared keeps
only the wire types and re-exports the rules, so server and client compile
unchanged. First step toward a local single-player build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 22:52:10 +02:00
co-authored by Claude Opus 5
parent 5610c2c343
commit 303c587aee
14 changed files with 156 additions and 133 deletions
Executable
+85
View File
@@ -0,0 +1,85 @@
use std::collections::{BTreeMap, HashMap};
use crate::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
use crate::world::World;
pub struct Sim {
pub world: World,
/// Scheduled actions per entity, keyed by movement window (`tick / TICKS_PER_MOVE`).
/// The window slot is the whole ordering model: an action addressed to an occupied
/// window *replaces* it (retraction and rescheduling by the client, dedup of
/// retransmits), a late action only fills the next window if it is empty, and
/// anything past `ACTION_WINDOW_HORIZON` is dropped — so no client can grow server
/// memory or move faster than one action per window.
pending: HashMap<u32, BTreeMap<u32, u16>>,
}
impl Sim {
pub fn new(world: World) -> Self {
Self { world, pending: HashMap::new() }
}
pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) {
// The earliest window still addressable at this tick. On a movement tick that is
// the window executing *this call* — actions arriving the same tick still count.
let min_window = tick.div_ceil(TICKS_PER_MOVE);
for &(entity_id, target_tick, action) in actions {
let slots = self.pending.entry(entity_id).or_default();
let window = target_tick.div_ceil(TICKS_PER_MOVE);
if window < min_window {
// Late. A movement action keeps its *order* instead of its time: it
// fills the first still-empty upcoming window, so bunched late arrivals
// don't collapse onto one slot and eat each other. A late NOOP is
// dropped — once its window has passed, its cancellation intent is
// ambiguous, and as a gap-filler it would block real steps (worst case
// one stale step executes; the client's reconciliation handles that).
if crate::action_delta(action).is_some() {
let gap = (min_window..min_window + ACTION_WINDOW_HORIZON as u32)
.find(|w| !slots.contains_key(w));
if let Some(w) = gap {
slots.insert(w, action);
}
}
} else if window - min_window < ACTION_WINDOW_HORIZON as u32 {
slots.insert(window, action); // newest addressing wins
}
}
if tick.is_multiple_of(TICKS_PER_MOVE) {
self.entity_tick(tick / TICKS_PER_MOVE);
}
}
fn entity_tick(&mut self, window: u32) {
// Exactly one action per entity per movement window, then the window is gone.
let actions: Vec<(u32, u16)> = self.pending.iter_mut()
.filter_map(|(&id, slots)| {
let action = slots.remove(&window);
slots.retain(|&w, _| w > window); // drop anything the timeline passed by
action.map(|a| (id, a))
})
.collect();
self.pending.retain(|_, slots| !slots.is_empty());
for (entity_id, action) in actions {
let delta = match crate::action_delta(action) {
Some(d) => d,
None => continue, // NOOP or garbage
};
let pos = match self.world.entities.get(&entity_id) {
Some(e) => e.pos,
None => continue,
};
let from = (pos.0 as i32, pos.1 as i32);
let to = (from.0 + delta.0, from.1 + delta.1);
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
// cutting) — the client predicts with the exact same function.
let allowed = crate::step_allowed(from, to, |x, y| {
self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable())
});
if allowed {
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
}
}
}
}