current state
This commit is contained in:
Generated
+4
@@ -854,6 +854,10 @@ dependencies = [
|
|||||||
"jni-sys 0.3.0",
|
"jni-sys 0.3.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "netsim"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = ["shared", "client", "server"]
|
members = ["shared", "client", "server", "netsim"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|||||||
+248
-85
@@ -6,33 +6,36 @@ use std::collections::{HashMap, VecDeque};
|
|||||||
use crate::assets::Image;
|
use crate::assets::Image;
|
||||||
use crate::input::{GameAction, InputState};
|
use crate::input::{GameAction, InputState};
|
||||||
use crate::net::{EntityInfo, NetClient, NetEvent};
|
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 {
|
pub enum GameSignal {
|
||||||
Quit,
|
Quit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cardinal action that moves `from` → `to`. This is the only movement vocabulary
|
/// The server's movement cadence (24 Hz base tick, movement on every 4th tick
|
||||||
/// the server understands; the pathfinder's output is translated through here.
|
/// → 6 Hz ≈ 167 ms/tile). Entity interpolation lerps over this interval.
|
||||||
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).
|
|
||||||
const MOVE_INTERVAL_MS: usize = 167;
|
const MOVE_INTERVAL_MS: usize = 167;
|
||||||
|
|
||||||
/// After this long without movement input, drop any unconfirmed queued path (the server
|
/// Length of one server tick. Steps are not paced by a local timer but scheduled onto
|
||||||
/// is the truth; whatever it hasn't acted on is discarded).
|
/// the server's tick timeline, estimated from the last `StatePacket` plus elapsed time.
|
||||||
const RECONCILE_IDLE_MS: usize = 300;
|
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;
|
const PATH_MAX_LEN: usize = 8;
|
||||||
|
|
||||||
/// Palette index for the path indicator. Pure blue in RGB332 (r=0, g=0, b=3).
|
/// 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,
|
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 {
|
pub struct Game {
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
tileset: Vec<[u8; 64]>,
|
tileset: Vec<[u8; 64]>,
|
||||||
@@ -70,17 +83,30 @@ pub struct Game {
|
|||||||
player_entity_id: u32,
|
player_entity_id: u32,
|
||||||
/// Authoritative position, as last confirmed by the server.
|
/// Authoritative position, as last confirmed by the server.
|
||||||
player_pos: (i32, i32),
|
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.
|
/// planned path; the head is consumed as the server confirms each move.
|
||||||
path: VecDeque<(i32, i32)>,
|
path: VecDeque<PathStep>,
|
||||||
/// Planned route from click-to-move: tiles not yet sent to the server. Fed into
|
/// 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)>,
|
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
|
/// Viewport top-left in world pixels. Follows the player linearly instead of
|
||||||
/// snapping tile-to-tile; only rendering rounds it to whole pixels.
|
/// snapping tile-to-tile; only rendering rounds it to whole pixels.
|
||||||
cam: (f32, f32),
|
cam: (f32, f32),
|
||||||
move_accum_ms: usize,
|
|
||||||
idle_ms: usize,
|
|
||||||
entities: Vec<EntityInfo>,
|
entities: Vec<EntityInfo>,
|
||||||
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
/// Per-entity interpolation state, keyed by entity id. Purely cosmetic — all game
|
||||||
/// logic keeps using the authoritative tile positions.
|
/// logic keeps using the authoritative tile positions.
|
||||||
@@ -88,12 +114,10 @@ pub struct Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 image = Image::from_tga("assets/tilesets/overworld.tga");
|
||||||
let tileset = image.to_tileset();
|
let tileset = image.to_tileset();
|
||||||
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").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);
|
let player_pos = (16, 16);
|
||||||
Game {
|
Game {
|
||||||
tileset,
|
tileset,
|
||||||
@@ -103,12 +127,16 @@ impl Game {
|
|||||||
player_pos,
|
player_pos,
|
||||||
path: VecDeque::new(),
|
path: VecDeque::new(),
|
||||||
route: 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: (
|
cam: (
|
||||||
(player_pos.0 * TILE_PX - VIEW_PX / 2) as f32,
|
(player_pos.0 * TILE_PX - VIEW_PX / 2) as f32,
|
||||||
(player_pos.1 * 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(),
|
entities: Vec::new(),
|
||||||
lerp: HashMap::new(),
|
lerp: HashMap::new(),
|
||||||
}
|
}
|
||||||
@@ -130,11 +158,21 @@ impl Game {
|
|||||||
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
|
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
|
||||||
-> Option<GameSignal>
|
-> Option<GameSignal>
|
||||||
{
|
{
|
||||||
if input.mouse_clicked() {
|
self.tick_age_ms += dt;
|
||||||
self.handle_click(input.mouse_pos());
|
|
||||||
|
// 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) {
|
if input.button_pressed(GameAction::Confirm) {
|
||||||
self.net.send_ping();
|
self.net.send_ping();
|
||||||
@@ -143,18 +181,26 @@ impl Game {
|
|||||||
|
|
||||||
for event in self.net.poll() {
|
for event in self.net.poll() {
|
||||||
match event {
|
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 } => {
|
NetEvent::State { tick, player_entity_id } => {
|
||||||
println!("state tick={tick} player_entity_id={player_entity_id}");
|
println!("state tick={tick} player_entity_id={player_entity_id}");
|
||||||
self.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::Chunk { chunk_id } => println!("chunk cached id={chunk_id}"),
|
||||||
NetEvent::Entity { entities } => {
|
NetEvent::Entity { tick, entities } => {
|
||||||
self.player_pos = entities.iter()
|
self.player_pos = entities.iter()
|
||||||
.find(|e| e.id == self.player_entity_id)
|
.find(|e| e.id == self.player_entity_id)
|
||||||
.map(|e| (e.pos_x as i32, e.pos_y as i32))
|
.map(|e| (e.pos_x as i32, e.pos_y as i32))
|
||||||
.unwrap_or(self.player_pos);
|
.unwrap_or(self.player_pos);
|
||||||
self.reconcile_path();
|
self.reconcile_path(tick);
|
||||||
self.track_lerp(&entities);
|
self.track_lerp(&entities);
|
||||||
self.entities = entities;
|
self.entities = entities;
|
||||||
}
|
}
|
||||||
@@ -173,10 +219,47 @@ impl Game {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The position movement continues from: the last queued step, or where the server
|
/// Replay one pending delta the way the server will: through the shared movement
|
||||||
/// last put us.
|
/// 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) {
|
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.
|
/// 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));
|
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
|
/// Click-to-move: translate a framebuffer click into a world tile and adopt it as
|
||||||
/// there. The route is only a client-side plan — it is executed as ordinary cardinal
|
/// the movement goal. The route is only a client-side plan — executed as ordinary
|
||||||
/// actions in `step_movement`, so the server keeps full authority over every step.
|
/// tick-addressed actions in `step_movement`, so the server keeps full authority.
|
||||||
fn handle_click(&mut self, (mx, my): (i32, i32)) {
|
/// 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 {
|
if mx < 0 || my < 0 || mx >= VIEW_PX || my >= VIEW_PX {
|
||||||
return; // outside the world viewport
|
return; // outside the world viewport
|
||||||
}
|
}
|
||||||
let (cx, cy) = self.cam_px();
|
let (cx, cy) = self.cam_px();
|
||||||
let goal = ((cx + mx).div_euclid(TILE_PX), (cy + my).div_euclid(TILE_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));
|
// While steering, an unchanged goal needs no replan — unless the route was voided
|
||||||
match found {
|
// (blocked step) before reaching it; an empty route with the goal still ahead of
|
||||||
Some(steps) => self.route = steps.into(),
|
// the prediction means exactly that, so plan again.
|
||||||
None => self.route.clear(), // unreachable — cancel any current route
|
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
|
/// Movement scheduling on the server timeline. A held direction key (which cancels
|
||||||
/// next planned route tile appends one step per `MOVE_INTERVAL_MS` to the in-flight
|
/// any goal) or the next planned route tile is addressed to the *next* movement
|
||||||
/// path, checking walkability locally and forwarding the cardinal action to the
|
/// window — at most one step per window, so the send rate follows the server's
|
||||||
/// server. When idle, drop any still-unconfirmed path.
|
/// cadence by construction instead of racing it with a local timer. Steps are
|
||||||
fn step_movement(&mut self, dt: usize, input: &InputState) {
|
/// validated locally with the shared movement rule; a blocked route step triggers
|
||||||
let dir = if input.button_held(GameAction::Up) { Some((0, -1)) }
|
/// a replan toward the goal.
|
||||||
else if input.button_held(GameAction::Down) { Some((0, 1)) }
|
fn step_movement(&mut self, input: &InputState) {
|
||||||
else if input.button_held(GameAction::Left) { Some((-1, 0)) }
|
// Both axes combine, so two held keys walk diagonally (a king move).
|
||||||
else if input.button_held(GameAction::Right) { Some((1, 0)) }
|
let kx = input.button_held(GameAction::Right) as i32
|
||||||
else { None };
|
- 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() {
|
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 target = self.est_window() + self.sched_lead();
|
||||||
let ready = self.move_accum_ms >= MOVE_INTERVAL_MS && self.path.len() < PATH_MAX_LEN;
|
let ready = target > self.last_sched_window && self.path.len() < PATH_MAX_LEN;
|
||||||
let from = self.predicted_pos();
|
let from = self.predicted_pos();
|
||||||
|
|
||||||
// The next step: either from the held key, or the head of the planned route.
|
// 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(),
|
None => self.route.front().copied(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some((nx, ny)) = step {
|
if let Some((nx, ny)) = step
|
||||||
self.idle_ms = 0;
|
&& ready
|
||||||
if ready {
|
{
|
||||||
// Route steps are re-validated at send time: the world may have changed
|
let allowed = step_allowed(from, (nx, ny), |x, y| self.tile_blocked(x, y));
|
||||||
// since planning (or the plan may have desynced). A bad step voids the
|
if allowed {
|
||||||
// whole route rather than walking blindly on.
|
if dir.is_none() {
|
||||||
let adjacent = (nx - from.0).abs() + (ny - from.1).abs() == 1;
|
self.route.pop_front();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
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
|
/// Fold a fresh authoritative snapshot into the prediction. The snapshot's tick is
|
||||||
/// tile up to and including the one the server has now reached.
|
/// an acknowledgment cursor on the shared timeline: every movement window at or
|
||||||
fn reconcile_path(&mut self) {
|
/// before it has been consumed — executed, rejected or lost, it no longer matters
|
||||||
if let Some(idx) = self.path.iter().position(|&p| p == self.player_pos) {
|
/// which — so those steps expire, and the remaining deltas replay on top of the
|
||||||
self.path.drain(0..=idx);
|
/// 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,
|
// Path pass — in-flight steps bright, planned-route tiles dim; over the world,
|
||||||
// under entities.
|
// 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::<Vec<_>>().into_iter()
|
||||||
.chain(self.route.iter().map(|&p| (p, ROUTE_COLOR)));
|
.chain(self.route.iter().map(|&p| (p, ROUTE_COLOR)));
|
||||||
for ((wx, wy), color) in marks {
|
for ((wx, wy), color) in marks {
|
||||||
let px = wx * TILE_PX - cx;
|
let px = wx * TILE_PX - cx;
|
||||||
|
|||||||
+75
-15
@@ -10,9 +10,21 @@ const MAX_EXPANSIONS: usize = 4096;
|
|||||||
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
|
/// Longest route we will plan. Keeps the reply small and bounds replan cost.
|
||||||
const MAX_ROUTE_LEN: usize = 64;
|
const MAX_ROUTE_LEN: usize = 64;
|
||||||
|
|
||||||
/// 4-connected A* from `start` to `goal` (manhattan heuristic — admissible on a
|
/// Step costs. In game terms every step costs the same (Chebyshev geometry) — the +1 on
|
||||||
/// cardinal grid). Returns the tiles to walk, excluding `start`, ending on `goal`,
|
/// diagonals is purely a tie-breaker so that among equally short paths the one with the
|
||||||
/// or `None` if the goal is unreachable within the search budget.
|
/// 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(
|
pub fn find_path(
|
||||||
start: (i32, i32),
|
start: (i32, i32),
|
||||||
goal: (i32, i32),
|
goal: (i32, i32),
|
||||||
@@ -22,7 +34,13 @@ pub fn find_path(
|
|||||||
return None;
|
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.
|
// (f, tile) min-heap; g and parent per visited tile.
|
||||||
let mut open = BinaryHeap::new();
|
let mut open = BinaryHeap::new();
|
||||||
@@ -52,12 +70,15 @@ pub fn find_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let g = best[&cur].0;
|
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);
|
let next = (cur.0 + dx, cur.1 + dy);
|
||||||
if blocked(next.0, next.1) {
|
if !shared::step_allowed(cur, next, &blocked) {
|
||||||
continue;
|
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) {
|
if best.get(&next).is_none_or(|&(og, _)| ng < og) {
|
||||||
best.insert(next, (ng, cur));
|
best.insert(next, (ng, cur));
|
||||||
open.push(Reverse((ng + h(next), next)));
|
open.push(Reverse((ng + h(next), next)));
|
||||||
@@ -71,29 +92,68 @@ pub fn find_path(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::find_path;
|
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]
|
#[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();
|
let path = find_path((0, 0), (3, 0), |_, _| false).unwrap();
|
||||||
assert_eq!(path, vec![(1, 0), (2, 0), (3, 0)]);
|
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]
|
#[test]
|
||||||
fn detours_around_wall() {
|
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 blocked = |x: i32, y: i32| x == 2 && y != 5;
|
||||||
let path = find_path((0, 0), (4, 0), blocked).unwrap();
|
let path = find_path((0, 0), (4, 0), blocked).unwrap();
|
||||||
assert_eq!(path.last(), Some(&(4, 0)));
|
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!(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]
|
#[test]
|
||||||
fn unreachable_is_none() {
|
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;
|
let blocked = |x: i32, y: i32| (x - 10).abs().max((y - 10).abs()) == 1;
|
||||||
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
|
assert_eq!(find_path((0, 0), (10, 10), blocked), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub struct InputState {
|
|||||||
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
|
/// Cursor position in framebuffer pixels (pbio delivers framebuffer coordinates).
|
||||||
mouse_pos: (i32, i32),
|
mouse_pos: (i32, i32),
|
||||||
mouse_clicked: bool,
|
mouse_clicked: bool,
|
||||||
|
mouse_held: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InputState {
|
impl InputState {
|
||||||
@@ -54,6 +55,7 @@ impl InputState {
|
|||||||
released: Vec::new(),
|
released: Vec::new(),
|
||||||
mouse_pos: (-1, -1),
|
mouse_pos: (-1, -1),
|
||||||
mouse_clicked: false,
|
mouse_clicked: false,
|
||||||
|
mouse_held: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +65,11 @@ impl InputState {
|
|||||||
|
|
||||||
pub fn push_click(&mut self) {
|
pub fn push_click(&mut self) {
|
||||||
self.mouse_clicked = true;
|
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) {
|
pub fn mouse_pos(&self) -> (i32, i32) {
|
||||||
@@ -74,6 +81,11 @@ impl InputState {
|
|||||||
self.mouse_clicked
|
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) {
|
pub fn push(&mut self, action: GameAction) {
|
||||||
self.pressed.push(action);
|
self.pressed.push(action);
|
||||||
if !self.held.contains(&action) {
|
if !self.held.contains(&action) {
|
||||||
|
|||||||
+9
-1
@@ -21,7 +21,12 @@ fn main() {
|
|||||||
let input_map = input::InputMap::new();
|
let input_map = input::InputMap::new();
|
||||||
let mut input_state = input::InputState::new();
|
let mut input_state = input::InputState::new();
|
||||||
let mut last_update = Instant::now();
|
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() {
|
while !plat.should_close() {
|
||||||
plat.poll_events(Some(Duration::from_millis(20)));
|
plat.poll_events(Some(Duration::from_millis(20)));
|
||||||
@@ -45,6 +50,9 @@ fn main() {
|
|||||||
Event::MouseBtn { button: MouseButton::Left, pressed: true } => {
|
Event::MouseBtn { button: MouseButton::Left, pressed: true } => {
|
||||||
input_state.push_click();
|
input_state.push_click();
|
||||||
}
|
}
|
||||||
|
Event::MouseBtn { button: MouseButton::Left, pressed: false } => {
|
||||||
|
input_state.release_click();
|
||||||
|
}
|
||||||
Event::CloseRequested => plat.request_close(),
|
Event::CloseRequested => plat.request_close(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-8
@@ -26,7 +26,10 @@ pub enum NetEvent {
|
|||||||
Pong { rtt_ms: u64 },
|
Pong { rtt_ms: u64 },
|
||||||
State { tick: u32, player_entity_id: u32 },
|
State { tick: u32, player_entity_id: u32 },
|
||||||
Chunk { chunk_id: u32 },
|
Chunk { chunk_id: u32 },
|
||||||
Entity { entities: Vec<EntityInfo> },
|
/// 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<EntityInfo> },
|
||||||
Disconnected,
|
Disconnected,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +39,6 @@ const FNV_PRIME: u32 = 16_777_619;
|
|||||||
pub struct NetClient {
|
pub struct NetClient {
|
||||||
socket: UdpSocket,
|
socket: UdpSocket,
|
||||||
ping_sent_at: Option<Instant>,
|
ping_sent_at: Option<Instant>,
|
||||||
action_sequence: u32,
|
|
||||||
last_action_sent: Instant,
|
last_action_sent: Instant,
|
||||||
last_state_received: Option<Instant>,
|
last_state_received: Option<Instant>,
|
||||||
pub chunk_cache: HashMap<u32, CachedChunk>,
|
pub chunk_cache: HashMap<u32, CachedChunk>,
|
||||||
@@ -55,7 +57,6 @@ impl NetClient {
|
|||||||
Self {
|
Self {
|
||||||
socket,
|
socket,
|
||||||
ping_sent_at: None,
|
ping_sent_at: None,
|
||||||
action_sequence: 0,
|
|
||||||
last_action_sent: Instant::now(),
|
last_action_sent: Instant::now(),
|
||||||
last_state_received: None,
|
last_state_received: None,
|
||||||
chunk_cache: HashMap::new(),
|
chunk_cache: HashMap::new(),
|
||||||
@@ -67,12 +68,13 @@ impl NetClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send_action(&mut self, player_action: u16) {
|
/// Send an action addressed to a server tick (see `ActionPacket::target_tick`).
|
||||||
self.action_sequence += 1;
|
/// `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 {
|
let packet = ActionPacket {
|
||||||
header: Header::new(packet_type::ACTION),
|
header: Header::new(packet_type::ACTION),
|
||||||
auth_token: 0,
|
auth_token: 0,
|
||||||
sequence: self.action_sequence,
|
target_tick,
|
||||||
cache: {
|
cache: {
|
||||||
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
|
let mut cache = [ChunkEntry { chunk_id: 0, version: 0 }; 9];
|
||||||
if let Some(slots) = self.last_state_chunks {
|
if let Some(slots) = self.last_state_chunks {
|
||||||
@@ -102,7 +104,7 @@ impl NetClient {
|
|||||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
if self.last_action_sent.elapsed() >= KEEPALIVE {
|
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();
|
let mut events = Vec::new();
|
||||||
@@ -157,9 +159,10 @@ impl NetClient {
|
|||||||
if pkt.packet_flags & 1 == 0 {
|
if pkt.packet_flags & 1 == 0 {
|
||||||
// last datagram in sequence
|
// last datagram in sequence
|
||||||
if self.entity_checksum_accum != self.last_entity_checksum {
|
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 {
|
events.push(NetEvent::Entity {
|
||||||
|
tick: pkt.tick,
|
||||||
entities: std::mem::take(&mut self.pending_entities),
|
entities: std::mem::take(&mut self.pending_entities),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[package]
|
||||||
|
name = "netsim"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
@@ -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<Duration> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<std::cmp::Ordering> { 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::<f32>; 3]; // delay, jitter, loss
|
||||||
|
let mut down_over = [None::<f32>; 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::<f32>().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<f32>; 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<Reverse<Pending>> = BinaryHeap::new();
|
||||||
|
// The one game client, learned from its first datagram (re-learned on address change).
|
||||||
|
let mut client_addr: Option<SocketAddr> = 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -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
|
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,
|
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.
|
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 |
|
| 0 | 6 | Header | `header` | packet_type = 1 |
|
||||||
| 6 | 8 | u64 | `auth_token` | Token of the current session |
|
| 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 |
|
| 18 | 54 | ChunkEntry | `cache[9]` | Versions client currently holds |
|
||||||
| 72 | 2 | PlayerAction | `player_action` | Derived from user input |
|
| 72 | 2 | PlayerAction | `player_action` | Derived from user input |
|
||||||
| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction |
|
| 74 | ? | ActionData | `action_data` | Dependent on PlayerAction |
|
||||||
|
|
||||||
**Minimum: 74 bytes** (no ActionData)
|
**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)**
|
**ChunkEntry (6 bytes)**
|
||||||
|
|
||||||
| Offset | Size | Type | Field |
|
| 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).
|
the client simply waits one tick (~100 ms).
|
||||||
|
|
||||||
**Lost EntityPacket** — detected via the `entity_checksum` in the next `StatePacket`.
|
**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
|
current cache state); the server treats this as a normal diff request and re-sends the
|
||||||
full entity update for the tick.
|
full entity update for the tick.
|
||||||
|
|
||||||
|
|||||||
+92
-9
@@ -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.
|
(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
|
- [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
|
(`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
|
cardinal actions one step per movement interval — the server only ever sees movement
|
||||||
stays authoritative. Keyboard input cancels the route; each step is re-validated at send
|
actions and stays authoritative. Keyboard input cancels the route; each step is
|
||||||
time and a blocked step voids the route. In-flight steps render bright blue, planned
|
re-validated at send time and a blocked step voids the route. In-flight steps render
|
||||||
route dim blue.
|
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:
|
Remaining follow-ups when the need is concrete:
|
||||||
|
|
||||||
- Collision vocabulary: `tile_flags` in `map.rs` is currently empty (all walkable). Fill in
|
- Collision vocabulary: seeded with test values (0, 146) in `shared::tile_collidable`.
|
||||||
collidable / opaque tile ids as the level design requires.
|
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).
|
- Tile flipping: orientation is discarded on load; revisit with the sprite pass (09).
|
||||||
- Multiple / object layers (spawns, triggers) — not yet parsed.
|
- Multiple / object layers (spawns, triggers) — not yet parsed.
|
||||||
|
|
||||||
@@ -119,10 +203,9 @@ Sprite rendering already landed (see Achieved). Camera status:
|
|||||||
|
|
||||||
### Later — Robustness + auth
|
### 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)
|
- 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)
|
- Multi-datagram `EntityPacket`: server currently truncates at 66 entities (`net.rs` TODO)
|
||||||
- Asset embedding: `include_bytes!` for single-binary distribution
|
- Asset embedding: `include_bytes!` for single-binary distribution
|
||||||
|
|
||||||
|
|||||||
+30
-15
@@ -1,4 +1,4 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::mem::size_of;
|
use std::mem::size_of;
|
||||||
use std::net::{SocketAddr, UdpSocket};
|
use std::net::{SocketAddr, UdpSocket};
|
||||||
use std::sync::mpsc::{self, Receiver, SyncSender};
|
use std::sync::mpsc::{self, Receiver, SyncSender};
|
||||||
@@ -15,13 +15,21 @@ use shared::{
|
|||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
|
|
||||||
pub struct NetClient {
|
pub struct NetClient {
|
||||||
pub entity_id: u32,
|
pub entity_id: u32,
|
||||||
pub last_seen: Instant,
|
pub last_seen: Instant,
|
||||||
pub send_phase: u8,
|
pub send_phase: u8,
|
||||||
pub pending_action: Option<u16>,
|
/// Tick-addressed actions received since the last drain, in arrival order —
|
||||||
pub last_cache: [ChunkEntry; 9],
|
/// `(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 {
|
pub struct Net {
|
||||||
clients: HashMap<SocketAddr, NetClient>,
|
clients: HashMap<SocketAddr, NetClient>,
|
||||||
new_addrs: Vec<SocketAddr>,
|
new_addrs: Vec<SocketAddr>,
|
||||||
@@ -70,10 +78,10 @@ impl Net {
|
|||||||
pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) {
|
pub fn add_client(&mut self, addr: SocketAddr, entity_id: u32) {
|
||||||
self.clients.insert(addr, NetClient {
|
self.clients.insert(addr, NetClient {
|
||||||
entity_id,
|
entity_id,
|
||||||
last_seen: Instant::now(),
|
last_seen: Instant::now(),
|
||||||
send_phase: (entity_id % 2) as u8,
|
send_phase: (entity_id % 2) as u8,
|
||||||
pending_action: None,
|
pending_actions: VecDeque::new(),
|
||||||
last_cache: [ChunkEntry { chunk_id: 0, version: 0 }; 9],
|
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::<ActionPacket>() {
|
if header.packet_type == packet_type::ACTION && data.len() == size_of::<ActionPacket>() {
|
||||||
let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data);
|
let pkt: ActionPacket = bytemuck::pod_read_unaligned(&data);
|
||||||
if let Some(client) = self.clients.get_mut(&addr) {
|
if let Some(client) = self.clients.get_mut(&addr) {
|
||||||
client.pending_action = Some(pkt.player_action);
|
|
||||||
client.last_seen = Instant::now();
|
client.last_seen = Instant::now();
|
||||||
client.last_cache = pkt.cache;
|
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) {
|
} else if !self.new_addrs.contains(&addr) {
|
||||||
self.new_addrs.push(addr);
|
self.new_addrs.push(addr);
|
||||||
}
|
}
|
||||||
@@ -122,11 +136,12 @@ impl Net {
|
|||||||
self.clients.retain(|_, c| c.last_seen.elapsed().as_secs() < 10);
|
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()
|
self.clients.values_mut()
|
||||||
.filter_map(|c| {
|
.flat_map(|c| {
|
||||||
let action = c.pending_action.take()?;
|
let entity_id = c.entity_id;
|
||||||
Some((c.entity_id, action))
|
c.pending_actions.drain(..)
|
||||||
|
.map(move |(target_tick, action)| (entity_id, target_tick, action))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-24
@@ -1,10 +1,16 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use shared::player_action;
|
use shared::{ACTION_WINDOW_HORIZON, TICKS_PER_MOVE};
|
||||||
use crate::world::World;
|
use crate::world::World;
|
||||||
|
|
||||||
pub struct Sim {
|
pub struct Sim {
|
||||||
pub world: World,
|
pub world: World,
|
||||||
pending: HashMap<u32, u16>,
|
/// 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 {
|
impl Sim {
|
||||||
@@ -12,35 +18,67 @@ impl Sim {
|
|||||||
Self { world, pending: HashMap::new() }
|
Self { world, pending: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self, tick: u32, actions: &[(u32, u16)]) {
|
pub fn tick(&mut self, tick: u32, actions: &[(u32, u32, u16)]) {
|
||||||
for &(entity_id, action) in actions {
|
// The earliest window still addressable at this tick. On a movement tick that is
|
||||||
self.pending.insert(entity_id, action);
|
// 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);
|
if tick.is_multiple_of(TICKS_PER_MOVE) {
|
||||||
self.entity_tick(&pending);
|
self.entity_tick(tick / TICKS_PER_MOVE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn entity_tick(&mut self, actions: &HashMap<u32, u16>) {
|
fn entity_tick(&mut self, window: u32) {
|
||||||
for (&entity_id, &action) in actions {
|
// Exactly one action per entity per movement window, then the window is gone.
|
||||||
if action == player_action::NOOP {
|
let actions: Vec<(u32, u16)> = self.pending.iter_mut()
|
||||||
continue;
|
.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) {
|
let pos = match self.world.entities.get(&entity_id) {
|
||||||
Some(e) => e.pos,
|
Some(e) => e.pos,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
let (nx, ny) = match action {
|
let from = (pos.0 as i32, pos.1 as i32);
|
||||||
player_action::NORTH => (pos.0, pos.1 - 1),
|
let to = (from.0 + delta.0, from.1 + delta.1);
|
||||||
player_action::EAST => (pos.0 + 1, pos.1),
|
// `step_allowed` is the shared movement rule (chessboard geometry, no corner
|
||||||
player_action::SOUTH => (pos.0, pos.1 + 1),
|
// cutting) — the client predicts with the exact same function.
|
||||||
player_action::WEST => (pos.0 - 1, pos.1),
|
let allowed = shared::step_allowed(from, to, |x, y| {
|
||||||
_ => continue,
|
self.world.tile_flags(x as i16, y as i16).map_or(true, |f| f.collidable())
|
||||||
};
|
});
|
||||||
let blocked = self.world.tile_flags(nx, ny).map_or(true, |f| f.collidable());
|
if allowed {
|
||||||
if !blocked {
|
self.world.move_entity(entity_id, (to.0 as i16, to.1 as i16));
|
||||||
self.world.move_entity(entity_id, (nx, ny));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-9
@@ -1,13 +1,82 @@
|
|||||||
use bytemuck::{Pod, Zeroable};
|
use bytemuck::{Pod, Zeroable};
|
||||||
|
|
||||||
pub mod player_action {
|
pub mod player_action {
|
||||||
pub const NOOP: u16 = 0;
|
pub const NOOP: u16 = 0;
|
||||||
pub const NORTH: u16 = 1;
|
pub const NORTH: u16 = 1;
|
||||||
pub const EAST: u16 = 2;
|
pub const EAST: u16 = 2;
|
||||||
pub const SOUTH: u16 = 3;
|
pub const SOUTH: u16 = 3;
|
||||||
pub const WEST: u16 = 4;
|
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 MAGIC: u16 = 0x524C;
|
||||||
pub const VERSION: u8 = 1;
|
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
|
/// 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`.
|
/// (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 {
|
pub fn tile_collidable(tile_id: u16) -> bool {
|
||||||
match tile_id {
|
match tile_id {
|
||||||
// e.g. 146 => true, // trees / rocks
|
0 => true, // empty / world border (the server pads chunks past the map rim with id 0)
|
||||||
_ => false,
|
146 => true, // trees / rocks
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +142,13 @@ pub struct ChunkEntry {
|
|||||||
pub struct ActionPacket {
|
pub struct ActionPacket {
|
||||||
pub header: Header,
|
pub header: Header,
|
||||||
pub auth_token: u64,
|
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 index = (dy+1)*3 + (dx+1), dx/dy ∈ {-1, 0, 1}.
|
||||||
/// Slot 4 is always the player's current chunk.
|
/// Slot 4 is always the player's current chunk.
|
||||||
pub cache: [ChunkEntry; 9],
|
pub cache: [ChunkEntry; 9],
|
||||||
|
|||||||
Reference in New Issue
Block a user