diff --git a/Cargo.lock b/Cargo.lock index 2e6ca35..49e209c 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -854,6 +854,10 @@ dependencies = [ "jni-sys 0.3.0", ] +[[package]] +name = "netsim" +version = "0.1.0" + [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 7c78d6d..97ffb1d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["shared", "client", "server"] +members = ["shared", "client", "server", "netsim"] resolver = "2" diff --git a/client/src/game.rs b/client/src/game.rs index 3527049..b021aab 100755 --- a/client/src/game.rs +++ b/client/src/game.rs @@ -6,33 +6,36 @@ use std::collections::{HashMap, VecDeque}; use crate::assets::Image; use crate::input::{GameAction, InputState}; use crate::net::{EntityInfo, NetClient, NetEvent}; -use shared::{chunk_id, player_action, tile_collidable}; +use shared::{chunk_id, delta_action, player_action, step_allowed, tile_collidable, TICKS_PER_MOVE}; pub enum GameSignal { Quit, } -/// The cardinal action that moves `from` → `to`. This is the only movement vocabulary -/// the server understands; the pathfinder's output is translated through here. -fn step_action(from: (i32, i32), to: (i32, i32)) -> u16 { - match (to.0 - from.0, to.1 - from.1) { - (0, -1) => player_action::NORTH, - (0, 1) => player_action::SOUTH, - (-1, 0) => player_action::WEST, - (1, 0) => player_action::EAST, - d => unreachable!("non-cardinal step {d:?}"), - } -} - -/// One queued step per this interval while a direction is held. Mirrors the server's -/// movement cadence (24 Hz base tick, movement on every 4th tick → 6 Hz ≈ 167 ms/tile). +/// The server's movement cadence (24 Hz base tick, movement on every 4th tick +/// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval. const MOVE_INTERVAL_MS: usize = 167; -/// After this long without movement input, drop any unconfirmed queued path (the server -/// is the truth; whatever it hasn't acted on is discarded). -const RECONCILE_IDLE_MS: usize = 300; +/// Length of one server tick. Steps are not paced by a local timer but scheduled onto +/// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time. +const TICK_MS: f32 = 1000.0 / shared::TICK_HZ as f32; -/// Upper bound on queued-but-unconfirmed steps, so a server-side block can't grow it forever. +/// Length of one movement window. +const WINDOW_MS: f32 = TICK_MS * TICKS_PER_MOVE as f32; + +/// Safety margin on top of the measured RTT when choosing the scheduling lead — covers +/// jitter and the server answering pings on its tick grid. +const LEAD_MARGIN_MS: f32 = 50.0; + +/// RTT sampling interval; the measurement drives the scheduling lead. +const PING_INTERVAL_MS: usize = 1000; + +/// Upper bound on queued-but-unconfirmed steps — purely a prediction bound: window +/// addressing already caps what the server will hold (windows are consecutive and only +/// `sched_lead` ahead), while confirmations lag a full RTT behind, so on a slow link +/// several correct steps are legitimately in flight at once. Steps whose window the +/// acknowledgment cursor has passed expire automatically, so the path can never go +/// permanently stale. const PATH_MAX_LEN: usize = 8; /// Palette index for the path indicator. Pure blue in RGB332 (r=0, g=0, b=3). @@ -62,6 +65,16 @@ struct EntityLerp { t_ms: usize, } +/// One sent-but-unacknowledged step: the movement window it is scheduled for and its +/// *delta* — deliberately not an absolute tile. The server executes deltas, so the +/// prediction is derived by replaying pending deltas on top of the last authoritative +/// position: a server surprise shifts the whole prediction instead of invalidating it. +/// The window is both the retraction address and the expiry key against the ack cursor. +struct PathStep { + window: u32, + delta: (i32, i32), +} + pub struct Game { #[allow(dead_code)] tileset: Vec<[u8; 64]>, @@ -70,17 +83,30 @@ pub struct Game { player_entity_id: u32, /// Authoritative position, as last confirmed by the server. player_pos: (i32, i32), - /// Queued future tiles: steps sent to the server but not yet confirmed. Shown as the + /// Queued future steps: sent to the server but not yet confirmed. Shown as the /// planned path; the head is consumed as the server confirms each move. - path: VecDeque<(i32, i32)>, + path: VecDeque, /// Planned route from click-to-move: tiles not yet sent to the server. Fed into - /// `path` one step per `MOVE_INTERVAL_MS`. Keyboard input cancels it. + /// `path` one step per movement window. Keyboard input cancels it. route: VecDeque<(i32, i32)>, + /// The persistent movement goal. Outlives the route: any surprise (blocked step, + /// server divergence) triggers a replan toward it, and while the mouse is held it + /// tracks the tile under the cursor. Cleared on arrival, unreachability, keyboard + /// override, or a click on an unreachable tile. + goal: Option<(i32, i32)>, + /// Tick clock: the last tick announced by a `StatePacket` and how long ago it + /// arrived — together an estimate of the server's current tick. + server_tick: u32, + tick_age_ms: usize, + /// Smoothed round-trip time from the automatic pings; `0` until the first pong. + rtt_ms: f32, + ping_accum_ms: usize, + /// Highest movement window a step was scheduled for. At most one step is scheduled + /// per window; retraction lowers this so freed windows can be re-addressed. + last_sched_window: u32, /// Viewport top-left in world pixels. Follows the player linearly instead of /// snapping tile-to-tile; only rendering rounds it to whole pixels. cam: (f32, f32), - move_accum_ms: usize, - idle_ms: usize, entities: Vec, /// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game /// logic keeps using the authoritative tile positions. @@ -88,12 +114,10 @@ pub struct Game { } impl Game { - pub fn start() -> Self { + pub fn start(server_addr: std::net::SocketAddr) -> Self { let image = Image::from_tga("assets/tilesets/overworld.tga"); let tileset = image.to_tileset(); let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset(); - - let server_addr = "127.0.0.1:7777".parse().unwrap(); let player_pos = (16, 16); Game { tileset, @@ -103,12 +127,16 @@ impl Game { player_pos, path: VecDeque::new(), route: VecDeque::new(), + goal: None, + server_tick: 0, + tick_age_ms: 0, + rtt_ms: 0.0, + ping_accum_ms: PING_INTERVAL_MS, // first ping fires immediately + last_sched_window: 0, cam: ( (player_pos.0 * TILE_PX - VIEW_PX / 2) as f32, (player_pos.1 * TILE_PX - VIEW_PX / 2) as f32, ), - move_accum_ms: MOVE_INTERVAL_MS, // ready, so the first held step fires instantly - idle_ms: 0, entities: Vec::new(), lerp: HashMap::new(), } @@ -130,11 +158,21 @@ impl Game { pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState) -> Option { - if input.mouse_clicked() { - self.handle_click(input.mouse_pos()); + self.tick_age_ms += dt; + + // Periodic RTT sampling — the measurement drives how far ahead steps are + // scheduled (`sched_lead`), so it has to stay current on a changing link. + self.ping_accum_ms += dt; + if self.ping_accum_ms >= PING_INTERVAL_MS { + self.ping_accum_ms = 0; + self.net.send_ping(); } - self.step_movement(dt, input); + if input.mouse_clicked() || input.mouse_held() { + self.handle_click(input.mouse_pos(), !input.mouse_clicked()); + } + + self.step_movement(input); if input.button_pressed(GameAction::Confirm) { self.net.send_ping(); @@ -143,18 +181,26 @@ impl Game { for event in self.net.poll() { match event { - NetEvent::Pong { rtt_ms } => println!("pong! rtt = {rtt_ms} ms"), + NetEvent::Pong { rtt_ms } => { + self.rtt_ms = if self.rtt_ms == 0.0 { + rtt_ms as f32 + } else { + 0.8 * self.rtt_ms + 0.2 * rtt_ms as f32 + }; + } NetEvent::State { tick, player_entity_id } => { println!("state tick={tick} player_entity_id={player_entity_id}"); self.player_entity_id = player_entity_id; + self.server_tick = tick; + self.tick_age_ms = 0; } NetEvent::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"), - NetEvent::Entity { entities } => { + NetEvent::Entity { tick, entities } => { self.player_pos = entities.iter() .find(|e| e.id == self.player_entity_id) .map(|e| (e.pos_x as i32, e.pos_y as i32)) .unwrap_or(self.player_pos); - self.reconcile_path(); + self.reconcile_path(tick); self.track_lerp(&entities); self.entities = entities; } @@ -173,10 +219,47 @@ impl Game { None } - /// The position movement continues from: the last queued step, or where the server - /// last put us. + /// Replay one pending delta the way the server will: through the shared movement + /// rule. A delta the server is going to reject does not move the prediction either — + /// so the prediction can never sit inside a wall, however far position and pending + /// steps have diverged. + fn replay_step(&self, p: (i32, i32), delta: (i32, i32)) -> (i32, i32) { + let to = (p.0 + delta.0, p.1 + delta.1); + if step_allowed(p, to, |x, y| self.tile_blocked(x, y)) { to } else { p } + } + + /// The position movement continues from: the last authoritative position with every + /// pending delta replayed on top. Never stored — always derived, so it follows the + /// server automatically when a step was rejected or executed elsewhere. fn predicted_pos(&self) -> (i32, i32) { - self.path.back().copied().unwrap_or(self.player_pos) + self.path.iter() + .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)) + } + + /// Estimated current server tick: the last announced tick advanced by local elapsed + /// time. Broadcast latency makes this lag slightly behind the real server clock — + /// scheduling one window ahead plus the server's lateness rule absorbs that. + fn est_tick(&self) -> f32 { + self.server_tick as f32 + self.tick_age_ms as f32 / TICK_MS + } + + /// The movement window the estimated server time is currently in. + fn est_window(&self) -> u32 { + (self.est_tick() / TICKS_PER_MOVE as f32) as u32 + } + + /// How many windows ahead to schedule so actions arrive *before* their window. The + /// tick estimate lags by the downlink and the action spends the uplink in transit — + /// together one RTT — so the lead must cover the RTT plus a jitter margin. At least + /// one: never schedule into the current window. + fn sched_lead(&self) -> u32 { + (((self.rtt_ms + LEAD_MARGIN_MS) / WINDOW_MS).ceil() as u32).max(1) + } + + /// The newest window whose scheduled step can no longer be retracted in time: a + /// cancellation sent now needs the same lead an action does. + fn retract_horizon(&self) -> u32 { + self.est_window() + self.sched_lead() - 1 } /// Where the camera wants to be: the viewport centered on the player's tile. @@ -207,40 +290,76 @@ impl Game { self.cam = (approach(self.cam.0, tx), approach(self.cam.1, ty)); } - /// Click-to-move: translate a framebuffer click into a world tile and plan a route - /// there. The route is only a client-side plan — it is executed as ordinary cardinal - /// actions in `step_movement`, so the server keeps full authority over every step. - fn handle_click(&mut self, (mx, my): (i32, i32)) { + /// Click-to-move: translate a framebuffer click into a world tile and adopt it as + /// the movement goal. The route is only a client-side plan — executed as ordinary + /// tick-addressed actions in `step_movement`, so the server keeps full authority. + /// With `hold` (button held after the initial click) this runs every frame and keeps + /// steering toward the tile under the cursor, replanning only when that tile changes + /// — camera movement alone shifts it too, not just moving the mouse. + fn handle_click(&mut self, (mx, my): (i32, i32), hold: bool) { if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX { return; // outside the world viewport } let (cx, cy) = self.cam_px(); let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_PX)); - let found = pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)); - match found { - Some(steps) => self.route = steps.into(), - None => self.route.clear(), // unreachable — cancel any current route + // While steering, an unchanged goal needs no replan — unless the route was voided + // (blocked step) before reaching it; an empty route with the goal still ahead of + // the prediction means exactly that, so plan again. + if hold && self.goal == Some(goal) + && (!self.route.is_empty() || self.predicted_pos() == goal) + { + return; + } + + // Plan from where the player will be once the retractable steps are withdrawn: + // the confirmed position plus the deltas that can no longer be cancelled in + // time — not the tip of a prediction we are about to cancel. + let horizon = self.retract_horizon(); + let start = self.path.iter() + .take_while(|s| s.window <= horizon) + .fold(self.player_pos, |p, s| self.replay_step(p, s.delta)); + + match pathfind::find_path(start, goal, |x, y| self.tile_blocked(x, y)) { + Some(steps) => { + // The plan changed: retract every scheduled-but-unexecuted step so the + // old intent cannot keep playing out on the server. + self.retract_future_steps(); + self.route = steps.into(); + self.goal = Some(goal); + } + // A discrete click on an unreachable tile cancels the plan; while steering, + // sweeping the cursor across a blocked tile keeps the current plan alive. + None if !hold => { + self.route.clear(); + self.goal = None; + } + None => {} } } - /// Movement queueing. A held direction key (which cancels any planned route) or the - /// next planned route tile appends one step per `MOVE_INTERVAL_MS` to the in-flight - /// path, checking walkability locally and forwarding the cardinal action to the - /// server. When idle, drop any still-unconfirmed path. - fn step_movement(&mut self, dt: usize, input: &InputState) { - let dir = if input.button_held(GameAction::Up) { Some((0, -1)) } - else if input.button_held(GameAction::Down) { Some((0, 1)) } - else if input.button_held(GameAction::Left) { Some((-1, 0)) } - else if input.button_held(GameAction::Right) { Some((1, 0)) } - else { None }; + /// Movement scheduling on the server timeline. A held direction key (which cancels + /// any goal) or the next planned route tile is addressed to the *next* movement + /// window — at most one step per window, so the send rate follows the server's + /// cadence by construction instead of racing it with a local timer. Steps are + /// validated locally with the shared movement rule; a blocked route step triggers + /// a replan toward the goal. + fn step_movement(&mut self, input: &InputState) { + // Both axes combine, so two held keys walk diagonally (a king move). + let kx = input.button_held(GameAction::Right) as i32 + - input.button_held(GameAction::Left) as i32; + let ky = input.button_held(GameAction::Down) as i32 + - input.button_held(GameAction::Up) as i32; + let dir = (kx != 0 || ky != 0).then_some((kx, ky)); if dir.is_some() { - self.route.clear(); // manual input overrides click-to-move + // Manual input overrides click-to-move entirely, goal included. + self.route.clear(); + self.goal = None; } - self.move_accum_ms = (self.move_accum_ms + dt).min(MOVE_INTERVAL_MS); - let ready = self.move_accum_ms >= MOVE_INTERVAL_MS && self.path.len() < PATH_MAX_LEN; + let target = self.est_window() + self.sched_lead(); + let ready = target > self.last_sched_window && self.path.len() < PATH_MAX_LEN; let from = self.predicted_pos(); // The next step: either from the held key, or the head of the planned route. @@ -249,29 +368,56 @@ impl Game { None => self.route.front().copied(), }; - if let Some((nx, ny)) = step { - self.idle_ms = 0; - if ready { - // Route steps are re-validated at send time: the world may have changed - // since planning (or the plan may have desynced). A bad step voids the - // whole route rather than walking blindly on. - let adjacent = (nx - from.0).abs() + (ny - from.1).abs() == 1; - if adjacent && !self.tile_blocked(nx, ny) { - if dir.is_none() { - self.route.pop_front(); - } - self.path.push_back((nx, ny)); - self.net.send_action(step_action(from, (nx, ny))); - } else if dir.is_none() { - self.route.clear(); + if let Some((nx, ny)) = step + && ready + { + let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y)); + if allowed { + if dir.is_none() { + self.route.pop_front(); } - self.move_accum_ms = 0; + self.path.push_back(PathStep { window: target, delta: (nx - from.0, ny - from.1) }); + self.net.send_action( + delta_action(nx - from.0, ny - from.1), + target * TICKS_PER_MOVE, + ); + self.last_sched_window = target; + } else if dir.is_none() { + self.replan_route(); } - } else { - self.idle_ms += dt; - if self.idle_ms >= RECONCILE_IDLE_MS { - self.path.clear(); + } + } + + /// Retract queued steps the server has (very likely) not executed yet: every step + /// addressed past the current estimated window gets a NOOP sent to its slot + /// (replacement = cancellation) and leaves the prediction. Steps at or before the + /// current window are left to be confirmed — cancelling those would race their + /// execution. Freed windows become addressable again. + fn retract_future_steps(&mut self) { + let horizon = self.retract_horizon(); + while let Some(step) = self.path.back() { + if step.window <= horizon { + break; } + self.net.send_action(player_action::NOOP, step.window * TICKS_PER_MOVE); + self.path.pop_back(); + } + self.last_sched_window = self.path.back().map_or(horizon, |s| s.window); + } + + /// Replan the route from the current prediction toward the persistent goal — the + /// client-side reaction to any surprise (blocked step, server divergence). Gives + /// the goal up only when it is reached or has become unreachable. + fn replan_route(&mut self) { + self.route.clear(); + let Some(goal) = self.goal else { return }; + if self.predicted_pos() == goal { + self.goal = None; + return; + } + match pathfind::find_path(self.predicted_pos(), goal, |x, y| self.tile_blocked(x, y)) { + Some(steps) => self.route = steps.into(), + None => self.goal = None, } } @@ -316,11 +462,20 @@ impl Game { } } - /// Reconcile the queued path against a fresh authoritative position: drop every queued - /// tile up to and including the one the server has now reached. - fn reconcile_path(&mut self) { - if let Some(idx) = self.path.iter().position(|&p| p == self.player_pos) { - self.path.drain(0..=idx); + /// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is + /// an acknowledgment cursor on the shared timeline: every movement window at or + /// before it has been consumed — executed, rejected or lost, it no longer matters + /// which — so those steps expire, and the remaining deltas replay on top of the + /// confirmed position (`predicted_pos`). A server surprise thus *shifts* the + /// prediction instead of invalidating it; if the shifted route no longer connects + /// or misses the goal, send-time validation triggers a replan toward the goal. + fn reconcile_path(&mut self, tick: u32) { + let ack_window = tick / TICKS_PER_MOVE; + while self.path.front().is_some_and(|s| s.window <= ack_window) { + self.path.pop_front(); + } + if self.path.is_empty() && self.route.is_empty() && self.goal == Some(self.player_pos) { + self.goal = None; // arrived — a later server correction must not walk us back } } @@ -363,7 +518,15 @@ impl Game { // Path pass — in-flight steps bright, planned-route tiles dim; over the world, // under entities. - let marks = self.path.iter().map(|&p| (p, PATH_COLOR)) + // Path tiles are derived by replaying the pending deltas from the confirmed + // position — the same fold the prediction uses (a rejected step marks in place). + let mut acc = self.player_pos; + let marks = self.path.iter() + .map(|s| { + acc = self.replay_step(acc, s.delta); + (acc, PATH_COLOR) + }) + .collect::>().into_iter() .chain(self.route.iter().map(|&p| (p, ROUTE_COLOR))); for ((wx, wy), color) in marks { let px = wx * TILE_PX - cx; diff --git a/client/src/game/pathfind.rs b/client/src/game/pathfind.rs index 94754c4..974b934 100644 --- a/client/src/game/pathfind.rs +++ b/client/src/game/pathfind.rs @@ -10,9 +10,21 @@ const MAX_EXPANSIONS: usize = 4096; /// Longest route we will plan. Keeps the reply small and bounds replan cost. const MAX_ROUTE_LEN: usize = 64; -/// 4-connected A* from `start` to `goal` (manhattan heuristic — admissible on a -/// cardinal grid). Returns the tiles to walk, excluding `start`, ending on `goal`, -/// or `None` if the goal is unreachable within the search budget. +/// Step costs. In game terms every step costs the same (Chebyshev geometry) — the +1 on +/// diagonals is purely a tie-breaker so that among equally short paths the one with the +/// fewest diagonals wins (straight lines stay straight instead of zigzagging). Because a +/// route is at most `MAX_ROUTE_LEN` steps, the accumulated surcharge (≤ 64) can never +/// outweigh one extra step (100): the step count always stays Chebyshev-minimal. +const CARDINAL_COST: i32 = 100; +const DIAGONAL_COST: i32 = 101; + +/// 8-connected A* from `start` to `goal`. World geometry is chessboard (Chebyshev): +/// every step is one game turn, so routes are minimal in step count; the tiny diagonal +/// surcharge (see `DIAGONAL_COST`) only breaks ties among equally short paths. Each +/// expansion goes through the shared `step_allowed` rule — so the plan can never contain +/// a step the server would refuse (including corner cutting). Returns the tiles to walk, +/// excluding `start`, ending on `goal`, or `None` if the goal is unreachable within the +/// search budget. pub fn find_path( start: (i32, i32), goal: (i32, i32), @@ -22,7 +34,13 @@ pub fn find_path( return None; } - let h = |p: (i32, i32)| (p.0 - goal.0).abs() + (p.1 - goal.1).abs(); + // Exact open-field cost: `max` steps, of which `min` must be diagonal — admissible + // and consistent, since obstacles can only make a path more expensive. + let h = |p: (i32, i32)| { + let dx = (p.0 - goal.0).abs(); + let dy = (p.1 - goal.1).abs(); + CARDINAL_COST * dx.max(dy) + (DIAGONAL_COST - CARDINAL_COST) * dx.min(dy) + }; // (f, tile) min-heap; g and parent per visited tile. let mut open = BinaryHeap::new(); @@ -52,12 +70,15 @@ pub fn find_path( } let g = best[&cur].0; - for (dx, dy) in [(0, -1), (0, 1), (-1, 0), (1, 0)] { + for (dx, dy) in [ + (0, -1), (0, 1), (-1, 0), (1, 0), + (1, -1), (1, 1), (-1, 1), (-1, -1), + ] { let next = (cur.0 + dx, cur.1 + dy); - if blocked(next.0, next.1) { + if !shared::step_allowed(cur, next, &blocked) { continue; } - let ng = g + 1; + let ng = g + if dx != 0 && dy != 0 { DIAGONAL_COST } else { CARDINAL_COST }; if best.get(&next).is_none_or(|&(og, _)| ng < og) { best.insert(next, (ng, cur)); open.push(Reverse((ng + h(next), next))); @@ -71,29 +92,68 @@ pub fn find_path( mod tests { use super::find_path; + /// Every consecutive pair must be a legal step under the shared movement rule. + fn assert_valid(start: (i32, i32), path: &[(i32, i32)], blocked: impl Fn(i32, i32) -> bool) { + let mut from = start; + for &to in path { + assert!(shared::step_allowed(from, to, &blocked), "illegal step {from:?} → {to:?}"); + from = to; + } + } + #[test] - fn straight_line() { + fn straight_line_stays_straight() { + // The diagonal tie-breaker rules out equal-length zigzags. let path = find_path((0, 0), (3, 0), |_, _| false).unwrap(); assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]); } + #[test] + fn diagonal_is_one_step_per_tile() { + let path = find_path((0, 0), (3, 3), |_, _| false).unwrap(); + assert_eq!(path, vec![(1, 1), (2, 2), (3, 3)]); // not an L of length 6 + } + + #[test] + fn mixed_route_is_step_minimal() { + // (5,2): 5 steps — 2 diagonal, 3 cardinal, never more. + let path = find_path((0, 0), (5, 2), |_, _| false).unwrap(); + assert_eq!(path.len(), 5); + assert_eq!(path.last(), Some(&(5, 2))); + let diagonals = std::iter::once((0, 0)).chain(path.iter().copied()) + .zip(path.iter().copied()) + .filter(|(a, b)| a.0 != b.0 && a.1 != b.1) + .count(); + assert_eq!(diagonals, 2); + assert_valid((0, 0), &path, |_, _| false); + } + #[test] fn detours_around_wall() { - // Vertical wall at x=2 with a gap at y=5. + // Vertical wall at x=2 with a gap at y=5. The corner rule forces the gap to be + // entered and left orthogonally: 5 steps down to (1,5), through (2,5) and (3,5), + // 5 steps back up to (4,0). let blocked = |x: i32, y: i32| x == 2 && y != 5; let path = find_path((0, 0), (4, 0), blocked).unwrap(); assert_eq!(path.last(), Some(&(4, 0))); - assert!(path.iter().all(|&(x, y)| !blocked(x, y))); - assert!(path.windows(2).all(|w| { - (w[1].0 - w[0].0).abs() + (w[1].1 - w[0].1).abs() == 1 - })); assert!(path.contains(&(2, 5))); - assert_eq!(path.len(), 14); // 4 across + 2·5 detour + assert_valid((0, 0), &path, blocked); + assert_eq!(path.len(), 12); + } + + #[test] + fn no_corner_cutting() { + // Blockers at (1,0) and (0,1) touch diagonally; the direct king move + // (0,0) → (1,1) must not squeeze between them. + let blocked = |x: i32, y: i32| (x, y) == (1, 0) || (x, y) == (0, 1); + let path = find_path((0, 0), (1, 1), blocked).unwrap(); + assert_valid((0, 0), &path, blocked); + assert_eq!(path.len(), 6); // shortest legal detour around either blocker } #[test] fn unreachable_is_none() { - // Goal sealed in by a ring. + // Goal sealed in by a ring (Chebyshev radius 1 — also seals diagonals). let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1; assert_eq!(find_path((0, 0), (10, 10), blocked), None); } diff --git a/client/src/input.rs b/client/src/input.rs index d9f1df9..93a17ca 100755 --- a/client/src/input.rs +++ b/client/src/input.rs @@ -44,6 +44,7 @@ pub struct InputState { /// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates). mouse_pos: (i32, i32), mouse_clicked: bool, + mouse_held: bool, } impl InputState { @@ -54,6 +55,7 @@ impl InputState { released: Vec::new(), mouse_pos: (-1, -1), mouse_clicked: false, + mouse_held: false, } } @@ -63,6 +65,11 @@ impl InputState { pub fn push_click(&mut self) { self.mouse_clicked = true; + self.mouse_held = true; + } + + pub fn release_click(&mut self) { + self.mouse_held = false; } pub fn mouse_pos(&self) -> (i32, i32) { @@ -74,6 +81,11 @@ impl InputState { self.mouse_clicked } + /// True while the left button is down. Like `held` keys, this survives `clear()`. + pub fn mouse_held(&self) -> bool { + self.mouse_held + } + pub fn push(&mut self, action: GameAction) { self.pressed.push(action); if !self.held.contains(&action) { diff --git a/client/src/main.rs b/client/src/main.rs index b756793..a69787a 100755 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -21,7 +21,12 @@ fn main() { let input_map = input::InputMap::new(); let mut input_state = input::InputState::new(); let mut last_update = Instant::now(); - let mut game = game::Game::start(); + // Optional server address (e.g. netsim proxy): `client 127.0.0.1:7778`. + let server_addr = std::env::args().nth(1) + .unwrap_or_else(|| "127.0.0.1:7777".into()) + .parse() + .expect("bad server address"); + let mut game = game::Game::start(server_addr); while !plat.should_close() { plat.poll_events(Some(Duration::from_millis(20))); @@ -45,6 +50,9 @@ fn main() { Event::MouseBtn { button: MouseButton::Left, pressed: true } => { input_state.push_click(); } + Event::MouseBtn { button: MouseButton::Left, pressed: false } => { + input_state.release_click(); + } Event::CloseRequested => plat.request_close(), _ => {} } diff --git a/client/src/net.rs b/client/src/net.rs index 518153d..b0aaef6 100755 --- a/client/src/net.rs +++ b/client/src/net.rs @@ -26,7 +26,10 @@ pub enum NetEvent { Pong { rtt_ms: u64 }, State { tick: u32, player_entity_id: u32 }, Chunk { chunk_id: u32 }, - Entity { entities: Vec }, + /// An authoritative entity snapshot taken at server tick `tick` — the tick doubles + /// as the acknowledgment cursor for tick-addressed actions: every movement window + /// at or before it has been consumed. + Entity { tick: u32, entities: Vec }, Disconnected, } @@ -36,7 +39,6 @@ const FNV_PRIME: u32 = 16_777_619; pub struct NetClient { socket: UdpSocket, ping_sent_at: Option, - action_sequence: u32, last_action_sent: Instant, last_state_received: Option, pub chunk_cache: HashMap, @@ -55,7 +57,6 @@ impl NetClient { Self { socket, ping_sent_at: None, - action_sequence: 0, last_action_sent: Instant::now(), last_state_received: None, chunk_cache: HashMap::new(), @@ -67,12 +68,13 @@ impl NetClient { } } - pub fn send_action(&mut self, player_action: u16) { - self.action_sequence += 1; + /// Send an action addressed to a server tick (see `ActionPacket::target_tick`). + /// `target_tick == 0` is a pure keep-alive / cache-ack with no scheduling intent. + pub fn send_action(&mut self, player_action: u16, target_tick: u32) { let packet = ActionPacket { header: Header::new(packet_type::ACTION), auth_token: 0, - sequence: self.action_sequence, + target_tick, cache: { let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9]; if let Some(slots) = self.last_state_chunks { @@ -102,7 +104,7 @@ impl NetClient { const TIMEOUT: Duration = Duration::from_secs(10); if self.last_action_sent.elapsed() >= KEEPALIVE { - self.send_action(shared::player_action::NOOP); + self.send_action(shared::player_action::NOOP, 0); } let mut events = Vec::new(); @@ -157,9 +159,10 @@ impl NetClient { if pkt.packet_flags & 1 == 0 { // last datagram in sequence if self.entity_checksum_accum != self.last_entity_checksum { - self.send_action(shared::player_action::NOOP); + self.send_action(shared::player_action::NOOP, 0); } events.push(NetEvent::Entity { + tick: pkt.tick, entities: std::mem::take(&mut self.pending_entities), }); } diff --git a/netsim/Cargo.toml b/netsim/Cargo.toml new file mode 100644 index 0000000..dbaccbb --- /dev/null +++ b/netsim/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "netsim" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/netsim/src/main.rs b/netsim/src/main.rs new file mode 100644 index 0000000..3bf3709 --- /dev/null +++ b/netsim/src/main.rs @@ -0,0 +1,170 @@ +//! Bad-internet simulator: a UDP proxy between game client and server that adds +//! delay, jitter and packet loss, per direction. Dev tool only — the game itself +//! never knows it exists. +//! +//! Usage: +//! cargo run -p netsim -- [--listen 7778] [--upstream 127.0.0.1:7777] +//! [--delay MS] [--jitter MS] [--loss PERCENT] +//! [--up-delay MS] [--up-jitter MS] [--up-loss PERCENT] +//! [--down-delay MS] [--down-jitter MS] [--down-loss PERCENT] +//! +//! `--delay/--jitter/--loss` set both directions; the `--up-*` (client → server) and +//! `--down-*` (server → client) variants override one direction. Jitter is uniform in +//! ±MS around the delay; reordering emerges from jitter naturally, as on a real link. +//! Point the client at the listen port (e.g. `client 127.0.0.1:7778`). + +use std::cmp::Reverse; +use std::collections::BinaryHeap; +use std::net::{SocketAddr, UdpSocket}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +#[derive(Clone, Copy)] +struct LinkParams { + delay_ms: f32, + jitter_ms: f32, + loss_pct: f32, +} + +/// Xorshift64* — plenty for impairment dice; avoids pulling in a rand dependency. +struct Rng(u64); + +impl Rng { + fn new() -> Self { + let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() as u64; + Rng(seed | 1) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + + /// Uniform in [0, 1). + fn unit(&mut self) -> f32 { + (self.next() >> 40) as f32 / (1u64 << 24) as f32 + } +} + +impl LinkParams { + /// Roll the dice for one datagram: `None` = lost, `Some(d)` = deliver after `d`. + fn impair(&self, rng: &mut Rng) -> Option { + if rng.unit() * 100.0 < self.loss_pct { + return None; + } + let jitter = (rng.unit() * 2.0 - 1.0) * self.jitter_ms; + Some(Duration::from_secs_f32((self.delay_ms + jitter).max(0.0) / 1000.0)) + } +} + +/// A datagram waiting for its delivery time. `Up` = client → server. +enum Dir { + Up, + Down, +} + +struct Pending { + due: Instant, + dir: Dir, + data: Vec, +} + +// BinaryHeap ordering: only `due` matters (earliest first via Reverse). +impl PartialEq for Pending { + fn eq(&self, other: &Self) -> bool { self.due == other.due } +} +impl Eq for Pending {} +impl PartialOrd for Pending { + fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } +} +impl Ord for Pending { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.due.cmp(&other.due) } +} + +fn parse_args() -> (u16, SocketAddr, LinkParams, LinkParams) { + let mut listen: u16 = 7778; + let mut upstream: SocketAddr = "127.0.0.1:7777".parse().unwrap(); + let mut both = LinkParams { delay_ms: 0.0, jitter_ms: 0.0, loss_pct: 0.0 }; + let mut up_over = [None::; 3]; // delay, jitter, loss + let mut down_over = [None::; 3]; + + let mut args = std::env::args().skip(1); + while let Some(flag) = args.next() { + let val = args.next().unwrap_or_else(|| panic!("missing value for {flag}")); + let num = || val.parse::().unwrap_or_else(|_| panic!("bad number for {flag}: {val}")); + match flag.as_str() { + "--listen" => listen = val.parse().expect("bad listen port"), + "--upstream" => upstream = val.parse().expect("bad upstream address"), + "--delay" => both.delay_ms = num(), + "--jitter" => both.jitter_ms = num(), + "--loss" => both.loss_pct = num(), + "--up-delay" => up_over[0] = Some(num()), + "--up-jitter" => up_over[1] = Some(num()), + "--up-loss" => up_over[2] = Some(num()), + "--down-delay" => down_over[0] = Some(num()), + "--down-jitter" => down_over[1] = Some(num()), + "--down-loss" => down_over[2] = Some(num()), + _ => panic!("unknown flag {flag}"), + } + } + + let apply = |over: [Option; 3]| LinkParams { + delay_ms: over[0].unwrap_or(both.delay_ms), + jitter_ms: over[1].unwrap_or(both.jitter_ms), + loss_pct: over[2].unwrap_or(both.loss_pct), + }; + (listen, upstream, apply(up_over), apply(down_over)) +} + +fn main() { + let (listen, upstream, up, down) = parse_args(); + + let client_sock = UdpSocket::bind(("127.0.0.1", listen)).expect("bind listen port"); + let server_sock = UdpSocket::bind("127.0.0.1:0").expect("bind upstream socket"); + server_sock.connect(upstream).expect("connect upstream"); + client_sock.set_nonblocking(true).unwrap(); + server_sock.set_nonblocking(true).unwrap(); + + println!("netsim: 127.0.0.1:{listen} ⇄ {upstream}"); + println!(" up: {:.0} ms ±{:.0} ms, {:.1}% loss", up.delay_ms, up.jitter_ms, up.loss_pct); + println!(" down: {:.0} ms ±{:.0} ms, {:.1}% loss", down.delay_ms, down.jitter_ms, down.loss_pct); + + let mut rng = Rng::new(); + let mut queue: BinaryHeap> = BinaryHeap::new(); + // The one game client, learned from its first datagram (re-learned on address change). + let mut client_addr: Option = None; + let mut buf = [0u8; 2048]; + + loop { + let now = Instant::now(); + + while let Ok((n, from)) = client_sock.recv_from(&mut buf) { + client_addr = Some(from); + if let Some(d) = up.impair(&mut rng) { + queue.push(Reverse(Pending { due: now + d, dir: Dir::Up, data: buf[..n].to_vec() })); + } + } + while let Ok(n) = server_sock.recv(&mut buf) { + if let Some(d) = down.impair(&mut rng) { + queue.push(Reverse(Pending { due: now + d, dir: Dir::Down, data: buf[..n].to_vec() })); + } + } + + while queue.peek().is_some_and(|Reverse(p)| p.due <= now) { + let Reverse(p) = queue.pop().unwrap(); + match p.dir { + Dir::Up => { server_sock.send(&p.data).ok(); } + Dir::Down => { + if let Some(addr) = client_addr { + client_sock.send_to(&p.data, addr).ok(); + } + } + } + } + + std::thread::sleep(Duration::from_millis(1)); + } +} diff --git a/notes/protocol.md b/notes/protocol.md index d7a5fec..f59d8d3 100755 --- a/notes/protocol.md +++ b/notes/protocol.md @@ -58,7 +58,7 @@ Currently carries the chunk manifest for the 3×3 neighbourhood around the playe The `entity_checksum` lets the client detect a lost `EntityPacket` without a dedicated ACK: if the checksum differs from the one computed over the last received entity update, -the client knows to retransmit an `ActionPacket` (sequence preserved, no-op action) to +the client knows to retransmit an `ActionPacket` (`target_tick = 0`, no-op action) to prompt the server to re-send the current entity state. --- @@ -71,13 +71,27 @@ Sent by the client on player action or on a chunk cache miss. |--------|------|--------------|-----------------|--------------------------------| | 0 | 6 | Header | `header` | packet_type = 1 | | 6 | 8 | u64 | `auth_token` | Token of the current session | -| 14 | 4 | u32 | `sequence` | Monotonically increasing | +| 14 | 4 | u32 | `target_tick` | Tick the action is scheduled for (see below); 0 = keep-alive/ack only | | 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds | | 72 | 2 | PlayerAction | `player_action` | Derived from user input | | 74 | ? | ActionData | `action_data` | Dependent on PlayerAction | **Minimum: 74 bytes** (no ActionData) +**Tick-addressed scheduling.** Actions are scheduled onto the server's tick timeline +instead of being consumed in arrival order. `target_tick` selects the movement window +(`target_tick / TICKS_PER_MOVE`, rounded up) the action executes in: + +- A second action addressed to the same window **replaces** the first — this is how the + client retracts (NOOP) or changes a scheduled step until its window executes, and how + retransmits dedupe for free. +- A **late** action (window already passed on arrival) moves to the next window, but + only if that slot is empty: late actions fill gaps, they never override newer intent. +- Only the next `ACTION_WINDOW_HORIZON` windows are addressable; anything beyond is + dropped. Combined with one-action-per-window execution this bounds server memory and + movement speed regardless of client behavior. +- `target_tick = 0` carries no scheduling intent (keep-alive / cache-ack packets). + **ChunkEntry (6 bytes)** | Offset | Size | Type | Field | @@ -302,7 +316,7 @@ whatever is still missing. No deduplication logic is required. the client simply waits one tick (~100 ms). **Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`. -The client retransmits a no-op `ActionPacket` (same sequence number, no-op action, +The client retransmits a no-op `ActionPacket` (`target_tick = 0`, no-op action, current cache state); the server treats this as a normal diff request and re-sends the full entity update for the tick. diff --git a/notes/roadmap.md b/notes/roadmap.md index 93c352d..6fc6d03 100755 --- a/notes/roadmap.md +++ b/notes/roadmap.md @@ -37,10 +37,93 @@ terminal). The platform layer — window, GPU, input, RGB332 palette — lives i (currently empty — every tile walkable) keeps client prediction and server sim in lockstep. - [x] Click-to-move: framebuffer click → world tile, A* over the chunk cache (`client/src/game/pathfind.rs`, unknown chunks count as blocked), route translated into - cardinal actions one step per movement interval — the server only ever sees N/E/S/W and - stays authoritative. Keyboard input cancels the route; each step is re-validated at send - time and a blocked step voids the route. In-flight steps render bright blue, planned - route dim blue. + cardinal actions one step per movement interval — the server only ever sees movement + actions and stays authoritative. Keyboard input cancels the route; each step is + re-validated at send time and a blocked step voids the route. In-flight steps render + bright blue, planned route dim blue. Click-and-hold steers continuously: while the + button is held the route keeps replanning toward the tile under the cursor (only + when that tile changes — cursor or camera movement), sweeping across blocked tiles + keeps the current route, and a voided route replans automatically while held. +- [x] 8-directional movement in chessboard geometry: world physics use the Chebyshev + metric — diagonal and cardinal steps are the same distance, a "circle" is a square + of tiles, matching the square viewport. Four diagonal actions in + `shared::player_action`; the single-step rule lives in `shared::step_allowed` + (king move onto a free tile, diagonals additionally need both orthogonal neighbors + free — no corner cutting) and is the one function used by the server sim, client + send-time validation and the client A* (8-connected, Chebyshev heuristic). Two held + keys walk diagonally. +- [x] Collision vocabulary seeded with test values (`shared::tile_collidable`): id 146 + (trees/rocks) and id 0 — id 0 doubles as the server's invisible world border, which + the client previously mispredicted as walkable. A proper tile-data file format + replaces this table later. +- [x] Tick-addressed action scheduling (supersedes two interim designs — a sequence- + deduped FIFO queue and its flow control — that fixed a periodic walking hitch and + a path/route deadlock but kept two free-running clocks racing each other). Actions + are now scheduled onto the server's tick timeline: `ActionPacket.target_tick` + (formerly `sequence`) selects the movement window, a second action to the same + window *replaces* the first (retraction via NOOP, rescheduling, retransmit dedup), + late actions fill only an *empty* next window (gap-filling without overriding + newer intent — needed over real internet links so actions don't die pointlessly), + and only `ACTION_WINDOW_HORIZON = 3` future windows are addressable. The sim keeps + per-entity window slot maps and executes at most one action per window, so floods + can neither grow memory nor speed anyone up. The client estimates the server tick + from `StatePacket.tick` plus elapsed time and schedules each step into the next + window — one send per window by construction, no local send timer, no clock-rate + race. `target_tick = 0` marks keep-alive/cache-ack packets with no scheduling + intent. See `notes/protocol.md` (ActionPacket). +- [x] Client-side unexpected-state handling: the movement goal is persistent (outlives + the planned route) and every surprise reroutes toward it — a blocked route step + replans instead of voiding the plan, and a confirmed position off the predicted + path (lost/rejected/overridden step) retracts all still-scheduled steps (NOOP to + their windows), drops the stale prediction and replans from the confirmed tile. + Changing plans mid-run (new click/steer target) likewise retracts scheduled-but- + unexecuted steps, so old intent stops playing out on the server within a window. + The goal is released on arrival, unreachability, keyboard override, or a discrete + click on an unreachable tile. This is the "reactive replanning" item formerly + parked under Later — Robustness. +- [x] `netsim` — bad-internet simulator (workspace member, dev tool): a UDP proxy adding + delay, jitter and loss per direction (`--delay/--jitter/--loss`, `--up-*`/`--down-*` + overrides; reordering emerges from jitter). No root, game-traffic only, zero deps. + The client takes an optional server address argument to point at it: + `cargo run -p netsim -- --delay 80 --jitter 30 --loss 5` + `client 127.0.0.1:7778`. +- [x] Bad-link hardening (netsim immediately broke the naive scheduling — locks under + isolated loss 15% and isolated delay 120 ms): + (a) RTT-adaptive scheduling lead — the client pings automatically (1 Hz, smoothed), + and schedules `ceil((rtt + margin) / window)` windows ahead instead of always one: + under systematic latency "late" had been the *normal* case. The retraction horizon + moves out the same way (a cancellation needs the same lead an action does). + (b) Late-rule cleanup in the sim — a late movement action keeps its *order*, not + its time (first still-empty upcoming window), so bunched late arrivals no longer + collapse onto one slot and eat each other; late NOOPs are dropped outright (as + gap-fillers they used to block real steps: retract → late NOOPs poison upcoming + windows → replanned steps eaten → retract again — a lock loop). + (c) Client stall watchdog — steps in flight but nothing confirmed for 750 ms means + the prediction is dead no matter why (e.g. *all* in-flight actions lost: the server + never moves, so the moved-off-plan desync detection never fires, path stays full, + nothing is ever sent again): retract, drop, replan toward the goal. + `PATH_MAX_LEN` is back to 8 as a pure prediction bound — server safety now comes + from window addressing, and on a slow link several correct steps are legitimately + unconfirmed at once (confirmations lag a full RTT). +- [x] Prediction rebuilt as predict → ack → replay (replaces the per-problem patches + above with structural robustness; fixed multi-second replan storms at high ping). + The old model stored absolute predicted tiles and reconciled by tile matching, so + any surprise "invalidated" the whole prediction and recovery meant clear + replan + from the confirmed position — but steps inside the retraction horizon cannot be + cancelled and still execute ("zombies"), shifting the server off every fresh plan + and re-triggering recovery in a loop. Now: pending steps are `(window, delta)` + pairs, `EntityPacket.tick` is the acknowledgment cursor (every window ≤ tick/4 is + provably consumed — executed, rejected or lost, it no longer matters which), and + the predicted position is always *derived* by replaying pending deltas on top of + the confirmed position. A surprise shifts the prediction instead of killing it; a + route that no longer connects triggers one clean replan toward the persistent goal + via the existing send-time validation. Deleted outright: tile-matching reconcile, + the moved-off-plan desync heuristic, the stall watchdog, and the idle path drop — + acked windows expire pending steps automatically, so the path cannot go stale. + The replay applies each pending delta through the shared `step_allowed` rule — + exactly as the server will — so a delta the server is going to reject does not + move the prediction either, and the predicted position can never sit inside a + wall (previously a diverged prediction could, causing a brief walk-into-wall + lock until the acks caught up). --- @@ -65,8 +148,9 @@ This supersedes the original 20/10/1 Hz three-tier sketch. Remaining follow-ups when the need is concrete: -- Collision vocabulary: `tile_flags` in `map.rs` is currently empty (all walkable). Fill in - collidable / opaque tile ids as the level design requires. +- Collision vocabulary: seeded with test values (0, 146) in `shared::tile_collidable`. + Decide on a proper file format for tile data (collision, opacity, …) instead of a + hardcoded match, then feed both server and client from it. - Tile flipping: orientation is discarded on load; revisit with the sprite pass (09). - Multiple / object layers (spawns, triggers) — not yet parsed. @@ -119,10 +203,9 @@ Sprite rendering already landed (see Achieved). Camera status: ### Later — Robustness + auth -- Reactive pathfinding: replan from the confirmed position toward the original goal when a - route step is blocked or the server diverges from the prediction (currently the route is - simply voided) - Auth token handshake: replace source-address identity (`auth_token` field exists but is unused) +- Per-address rate limiting for the action ingest (only meaningful once identity is real — + address spoofing bypasses any limit before then) - Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO) - Asset embedding: `include_bytes!` for single-binary distribution diff --git a/server/src/net.rs b/server/src/net.rs index f67f457..50a8730 100755 --- a/server/src/net.rs +++ b/server/src/net.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use std::mem::size_of; use std::net::{SocketAddr, UdpSocket}; use std::sync::mpsc::{self, Receiver, SyncSender}; @@ -15,13 +15,21 @@ use shared::{ use crate::world::World; pub struct NetClient { - pub entity_id: u32, - pub last_seen: Instant, - pub send_phase: u8, - pub pending_action: Option, - pub last_cache: [ChunkEntry; 9], + pub entity_id: u32, + pub last_seen: Instant, + pub send_phase: u8, + /// Tick-addressed actions received since the last drain, in arrival order — + /// `(target_tick, action)`. Window resolution, replacement and the lateness rule + /// all live in the sim; this is only the per-tick hand-off buffer, bounded so a + /// flood between drains cannot grow it. + pub pending_actions: VecDeque<(u32, u16)>, + pub last_cache: [ChunkEntry; 9], } +/// Hand-off buffer bound. Drained every tick; an honest client sends at most one +/// action per movement window, so a handful is already generous. +const PENDING_ACTIONS_MAX: usize = 8; + pub struct Net { clients: HashMap, new_addrs: Vec, @@ -70,10 +78,10 @@ impl Net { pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) { self.clients.insert(addr, NetClient { entity_id, - last_seen: Instant::now(), - send_phase: (entity_id % 2) as u8, - pending_action: None, - last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9], + last_seen: Instant::now(), + send_phase: (entity_id % 2) as u8, + pending_actions: VecDeque::new(), + last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9], }); } @@ -98,9 +106,15 @@ impl Net { if header.packet_type == packet_type::ACTION && data.len() == size_of::() { let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data); if let Some(client) = self.clients.get_mut(&addr) { - client.pending_action = Some(pkt.player_action); client.last_seen = Instant::now(); client.last_cache = pkt.cache; + // `target_tick == 0` marks pure keep-alive / cache-ack packets — they + // must never enter the schedule. Everything else (including NOOPs, which + // *retract* the addressed window) is handed to the sim in arrival order. + let target_tick = pkt.target_tick; + if target_tick != 0 && client.pending_actions.len() < PENDING_ACTIONS_MAX { + client.pending_actions.push_back((target_tick, pkt.player_action)); + } } else if !self.new_addrs.contains(&addr) { self.new_addrs.push(addr); } @@ -122,11 +136,12 @@ impl Net { self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10); } - pub fn drain_actions(&mut self) -> Vec<(u32, u16)> { + pub fn drain_actions(&mut self) -> Vec<(u32, u32, u16)> { self.clients.values_mut() - .filter_map(|c| { - let action = c.pending_action.take()?; - Some((c.entity_id, action)) + .flat_map(|c| { + let entity_id = c.entity_id; + c.pending_actions.drain(..) + .map(move |(target_tick, action)| (entity_id, target_tick, action)) }) .collect() } diff --git a/server/src/sim.rs b/server/src/sim.rs index f8c229c..b8c6c84 100755 --- a/server/src/sim.rs +++ b/server/src/sim.rs @@ -1,10 +1,16 @@ -use std::collections::HashMap; -use shared::player_action; +use std::collections::{BTreeMap, HashMap}; +use shared::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE}; use crate::world::World; pub struct Sim { pub world: World, - pending: HashMap, + /// 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>, } impl Sim { @@ -12,35 +18,67 @@ impl Sim { Self { world, pending: HashMap::new() } } - pub fn tick(&mut self, tick: u32, actions: &[(u32, u16)]) { - for &(entity_id, action) in actions { - self.pending.insert(entity_id, action); + 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 shared::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(4) { - let pending = std::mem::take(&mut self.pending); - self.entity_tick(&pending); + + if tick.is_multiple_of(TICKS_PER_MOVE) { + self.entity_tick(tick / TICKS_PER_MOVE); } } - fn entity_tick(&mut self, actions: &HashMap) { - for (&entity_id, &action) in actions { - if action == player_action::NOOP { - continue; - } + 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 shared::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 (nx, ny) = match action { - player_action::NORTH => (pos.0, pos.1 - 1), - player_action::EAST => (pos.0 + 1, pos.1), - player_action::SOUTH => (pos.0, pos.1 + 1), - player_action::WEST => (pos.0 - 1, pos.1), - _ => continue, - }; - let blocked = self.world.tile_flags(nx, ny).map_or(true, |f| f.collidable()); - if !blocked { - self.world.move_entity(entity_id, (nx, ny)); + 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 = shared::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)); } } } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 02e10c6..559a2d2 100755 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -1,13 +1,82 @@ use bytemuck::{Pod, Zeroable}; pub mod player_action { - pub const NOOP: u16 = 0; - pub const NORTH: u16 = 1; - pub const EAST: u16 = 2; - pub const SOUTH: u16 = 3; - pub const WEST: u16 = 4; + pub const NOOP: u16 = 0; + pub const NORTH: u16 = 1; + pub const EAST: u16 = 2; + pub const SOUTH: u16 = 3; + pub const WEST: u16 = 4; + pub const NORTH_EAST: u16 = 5; + pub const SOUTH_EAST: u16 = 6; + pub const SOUTH_WEST: u16 = 7; + pub const NORTH_WEST: u16 = 8; } +/// The movement delta of an action, or `None` for `NOOP` and unknown values. +pub fn action_delta(action: u16) -> Option<(i32, i32)> { + match action { + player_action::NORTH => Some((0, -1)), + player_action::EAST => Some((1, 0)), + player_action::SOUTH => Some((0, 1)), + player_action::WEST => Some((-1, 0)), + player_action::NORTH_EAST => Some((1, -1)), + player_action::SOUTH_EAST => Some((1, 1)), + player_action::SOUTH_WEST => Some((-1, 1)), + player_action::NORTH_WEST => Some((-1, -1)), + _ => None, + } +} + +/// The action for a single-step delta. Inverse of [`action_delta`]; panics on anything +/// that is not a king move. +pub fn delta_action(dx: i32, dy: i32) -> u16 { + match (dx, dy) { + (0, -1) => player_action::NORTH, + (1, 0) => player_action::EAST, + (0, 1) => player_action::SOUTH, + (-1, 0) => player_action::WEST, + (1, -1) => player_action::NORTH_EAST, + (1, 1) => player_action::SOUTH_EAST, + (-1, 1) => player_action::SOUTH_WEST, + (-1, -1) => player_action::NORTH_WEST, + d => panic!("non-step delta {d:?}"), + } +} + +/// The single-step movement rule, shared by the server sim, client prediction and the +/// client pathfinder so all three agree on the same physics. +/// +/// World geometry is chessboard (Chebyshev): diagonal and cardinal steps are the same +/// distance, so a step is any king move onto a free tile. A diagonal step additionally +/// requires *both* orthogonal neighbor tiles to be free — no squeezing between two +/// diagonally touching blockers (corner cutting). +pub fn step_allowed( + from: (i32, i32), + to: (i32, i32), + blocked: impl Fn(i32, i32) -> bool, +) -> bool { + let (dx, dy) = (to.0 - from.0, to.1 - from.1); + if dx.abs() > 1 || dy.abs() > 1 || (dx == 0 && dy == 0) { + return false; + } + if blocked(to.0, to.1) { + return false; + } + dx == 0 || dy == 0 || (!blocked(from.0 + dx, from.1) && !blocked(from.0, from.1 + dy)) +} + +/// Server base tick rate. The shared timeline all action scheduling is expressed in. +pub const TICK_HZ: u32 = 24; + +/// Movement resolves on every `TICKS_PER_MOVE`-th tick — one "movement window" per +/// `TICKS_PER_MOVE` ticks (6 Hz). Window `w` executes at tick `w * TICKS_PER_MOVE`. +pub const TICKS_PER_MOVE: u32 = 4; + +/// How many *future* movement windows a client may address. Part of the protocol +/// contract: actions targeted beyond this horizon are dropped, and the client's +/// unconfirmed in-flight steps must stay within it. +pub const ACTION_WINDOW_HORIZON: usize = 3; + pub const MAGIC: u16 = 0x524C; pub const VERSION: u8 = 1; @@ -36,11 +105,13 @@ pub fn chunk_coords(id: u32) -> (i16, i16) { /// /// Single source of truth for both the server (collision in the sim) and the client /// (movement prediction). Keep this in lockstep with the tileset in `overworld.tga`. -/// Currently every authored tile is walkable. +/// +/// Test values for now — a proper tile-data file format replaces this table later. pub fn tile_collidable(tile_id: u16) -> bool { match tile_id { - // e.g. 146 => true, // trees / rocks - _ => false, + 0 => true, // empty / world border (the server pads chunks past the map rim with id 0) + 146 => true, // trees / rocks + _ => false, } } @@ -71,7 +142,13 @@ pub struct ChunkEntry { pub struct ActionPacket { pub header: Header, pub auth_token: u64, - pub sequence: u32, + /// The server tick this action is scheduled for: it executes in the movement window + /// covering that tick, or — if it arrives late — in the next window *if that slot is + /// still empty* (late actions fill gaps, they never override newer intent). A second + /// action addressed to the same window replaces the first, so a scheduled step can be + /// retracted (NOOP) or changed until its window executes. `0` means "no scheduling + /// intent": pure keep-alive / cache-ack packets that must never touch the queue. + pub target_tick: u32, /// Slot index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}. /// Slot 4 is always the player's current chunk. pub cache: [ChunkEntry; 9],