Files
forgotten_caves/sim/src/sim.rs
T
irrlichtandClaude Opus 5 f594f12f35 Embed the sim in the game binary; drop networking
The client becomes the game: it owns a Sim instance and advances it with a
fixed-timestep accumulator (24 Hz ticks, rendering at frame rate). Rendering,
pathfinding and collision read the World directly instead of a chunk cache.

Sim::pending (window slot maps, late rule, horizon) is replaced by one intent
per entity for the next movement window — set_action replaces, executing the
window consumes. The whole client prediction/reconciliation machinery
(tick estimation, RTT lead, retraction, replay) is gone with the latency it
was built for; click-to-move now syncs the route against the sim position
after every window.

Removed: server, netsim, shared (wire types), client/net.rs. client renamed
to game. Headless tests cover the intent model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:56:30 +02:00

129 lines
4.4 KiB
Rust
Executable File

use std::collections::HashMap;
use crate::TICKS_PER_MOVE;
use crate::world::World;
pub struct Sim {
pub world: World,
/// Base tick counter (24 Hz). Movement resolves every `TICKS_PER_MOVE`-th tick.
pub tick: u32,
/// Each entity's intent for the next movement window. Setting it again replaces
/// it, executing the window consumes it — so an actor moves at most one tile per
/// window no matter how often it changes its mind in between.
intents: HashMap<u32, u16>,
}
impl Sim {
pub fn new(world: World) -> Self {
Self { world, tick: 0, intents: HashMap::new() }
}
/// Schedule `action` for `entity_id`'s next movement window. NOOP clears the intent.
pub fn set_action(&mut self, entity_id: u32, action: u16) {
if crate::action_delta(action).is_some() {
self.intents.insert(entity_id, action);
} else {
self.intents.remove(&entity_id);
}
}
pub fn clear_action(&mut self, entity_id: u32) {
self.intents.remove(&entity_id);
}
/// Advance the world by one base tick. Returns `true` when this tick executed a
/// movement window, i.e. entity positions may have changed.
pub fn step(&mut self) -> bool {
self.tick = self.tick.wrapping_add(1);
if self.tick.is_multiple_of(TICKS_PER_MOVE) {
self.entity_tick();
true
} else {
false
}
}
fn entity_tick(&mut self) {
for (entity_id, action) in self.intents.drain() {
let delta = match crate::action_delta(action) {
Some(d) => d,
None => continue,
};
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 pathfinder plans 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));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{Chunk, TileDef, TileFlags};
use crate::player_action;
fn open_world() -> World {
let mut w = World::new();
w.set_chunk(0, 0, Chunk::generate(|lx, ly| {
// A single wall tile at (5, 4).
let flags = if (lx, ly) == (5, 4) { TileFlags::COLLIDABLE } else { 0 };
TileDef { tile_id: 1, flags: TileFlags(flags) }
}).unwrap());
w
}
fn step_window(sim: &mut Sim) {
for _ in 0..TICKS_PER_MOVE {
sim.step();
}
}
#[test]
fn one_step_per_window_and_replacement() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(0, (4, 4), 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NORTH); // replaces
sim.step(); // no window yet
assert_eq!(sim.world.entities[&id].pos, (4, 4));
step_window(&mut sim);
assert_eq!(sim.world.entities[&id].pos, (4, 3));
step_window(&mut sim); // intent consumed — no second step
assert_eq!(sim.world.entities[&id].pos, (4, 3));
}
#[test]
fn blocked_and_out_of_world_steps_are_rejected() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(0, (4, 4), 1);
sim.set_action(id, player_action::EAST); // into the wall at (5, 4)
step_window(&mut sim);
assert_eq!(sim.world.entities[&id].pos, (4, 4));
let edge = sim.world.spawn_entity(0, (0, 0), 1);
sim.set_action(edge, player_action::WEST); // no chunk there
step_window(&mut sim);
assert_eq!(sim.world.entities[&edge].pos, (0, 0));
}
#[test]
fn noop_clears_intent() {
let mut sim = Sim::new(open_world());
let id = sim.world.spawn_entity(0, (4, 4), 1);
sim.set_action(id, player_action::SOUTH);
sim.set_action(id, player_action::NOOP);
step_window(&mut sim);
assert_eq!(sim.world.entities[&id].pos, (4, 4));
}
}