pinch_points/sim/net.rs
1//! Online play, sim side (spec §7.6).
2//!
3//! `bevy_ggrs`/`bevy_matchbox` still target Bevy 0.18 (verified 2026-07;
4//! the §9 risk-6 ecosystem lag is real), so this ships the spec's designated
5//! fallback: **deterministic lockstep with a small input delay**. The
6//! protocol lives here, transport-agnostic and engine-free, so it is
7//! unit-testable and reusable; the shell owns sockets. The 2-byte packed
8//! input and the `Board`'s clone + `state_hash` snapshots are exactly the
9//! groundwork GGRS-style rollback needs, so upgrading later is contained.
10//!
11//! Wire model: every tick, each peer sends its local action scheduled
12//! `delay` frames ahead. A frame simulates only when every player's action
13//! for it is known. On a LAN with delay 3 (100 ms at 30 Hz) the sim never
14//! stalls; on jitter it waits rather than desyncs. Peers exchange state
15//! hashes every [`HASH_INTERVAL`] frames to detect desync loudly.
16
17use crate::sim::board::{MAX_PLAYERS, PlayerAction, PlayerId};
18use crate::sim::direction::Direction;
19use std::collections::BTreeMap;
20
21/// Input delay in frames: 3 at 30 Hz = 100 ms, the spec's 2–3 frame range.
22pub const DEFAULT_DELAY: u32 = 3;
23/// How far ahead of its own next commit a peer schedules a pause. Far
24/// enough that the other peers, whose commit counters run a frame or two
25/// apart, have almost always not passed it yet, and near enough (a third
26/// of a second) that pressing Escape feels like it stopped the game.
27pub const PAUSE_LEAD: u32 = 10;
28/// How far local commits may run ahead of the simulated frame before the
29/// session pushes back on the caller (a peer is stalled or unreachable).
30pub const MAX_COMMIT_LEAD: u32 = 30;
31/// How often peers exchange state hashes for desync detection.
32pub const HASH_INTERVAL: u32 = 30;
33
34// --- 3-byte packed input (spec §7.6) ---------------------------------------
35
36/// Pack an action for the wire: byte 0 is the cursor column, byte 1 the row,
37/// byte 2 is op (bits 0-1: 0 none, 1 place, 2 remove) and direction
38/// (bits 2-3, as `Direction::id`).
39///
40/// A byte per axis rather than the spec's nibble, which capped online boards
41/// at 16 wide. The XL beach is 20.
42pub fn encode_action(action: PlayerAction) -> [u8; 3] {
43 match action {
44 PlayerAction::None => [0, 0, 0],
45 PlayerAction::Place { x, y, dir } => [x, y, 1 | (dir.id() << 2)],
46 PlayerAction::Remove { x, y } => [x, y, 2],
47 }
48}
49
50pub fn decode_action(bytes: [u8; 3]) -> PlayerAction {
51 let (x, y) = (bytes[0], bytes[1]);
52 match bytes[2] & 0b11 {
53 1 => PlayerAction::Place {
54 x,
55 y,
56 dir: Direction::from_id((bytes[2] >> 2) & 0b11),
57 },
58 2 => PlayerAction::Remove { x, y },
59 _ => PlayerAction::None,
60 }
61}
62
63// --- lockstep session ------------------------------------------------------
64
65/// A message to put on the wire: this player's action for a future frame.
66/// 8 bytes packed via [`InputMsg::encode`].
67#[derive(Clone, Copy, PartialEq, Eq, Debug)]
68pub struct InputMsg {
69 pub player: PlayerId,
70 pub frame: u32,
71 pub action: PlayerAction,
72}
73
74/// Bytes an [`InputMsg`] occupies on the wire.
75pub const INPUT_BYTES: usize = 8;
76
77impl InputMsg {
78 pub fn encode(self) -> [u8; INPUT_BYTES] {
79 let f = self.frame.to_le_bytes();
80 let a = encode_action(self.action);
81 [self.player, f[0], f[1], f[2], f[3], a[0], a[1], a[2]]
82 }
83
84 pub fn decode(bytes: [u8; INPUT_BYTES]) -> InputMsg {
85 InputMsg {
86 player: bytes[0],
87 frame: u32::from_le_bytes([bytes[1], bytes[2], bytes[3], bytes[4]]),
88 action: decode_action([bytes[5], bytes[6], bytes[7]]),
89 }
90 }
91}
92
93/// Transport-agnostic lockstep state machine for one peer.
94pub struct Lockstep {
95 /// The seat this peer plays, or `None` for one that only watches.
96 local: Option<PlayerId>,
97 /// Every player in the session, local included.
98 players: Vec<PlayerId>,
99 delay: u32,
100 /// Next frame to simulate.
101 frame: u32,
102 /// Next frame the local player commits an input for. Commits are strictly
103 /// consecutive: every frame gets one local input, or the session would
104 /// stall forever waiting for the skipped frame.
105 next_commit: u32,
106 /// Known actions per future frame.
107 pending: BTreeMap<u32, [Option<PlayerAction>; MAX_PLAYERS]>,
108 /// Recent local commits, kept for redundant resends: UDP drops packets,
109 /// and the handshake itself can eat the first few, so every outgoing
110 /// batch repeats this tail. Receivers ignore duplicates.
111 history: Vec<InputMsg>,
112 /// The agreed frame the session freezes on, if a peer has called a
113 /// pause. See [`Lockstep::pause_at`].
114 pause_at: Option<u32>,
115 /// The highest pause frame ever lifted here, by our own resume or a
116 /// peer's. A `Pause` naming that frame or an earlier one is an echo of
117 /// a pause that is over, still in flight from before the resume, and
118 /// is ignored. Without this the pause flapped: peers repeat `Pause`
119 /// every tick, so the last echoes always cross the `Resume`, re-paused
120 /// whoever had just resumed, who then echoed it back, for good.
121 lifted: Option<u32>,
122}
123
124/// How far behind the local simulated frame a peer that is still talking
125/// to us can be, in frames: `delay + MAX_COMMIT_LEAD`.
126///
127/// We only advance a frame once that peer's input for it is in, and it
128/// only commits that far past its own stalled frame; so our frame is at
129/// most this far past theirs, and everything we committed from that far
130/// back is something they may still be missing. [`Lockstep::recent_commits`]
131/// therefore keeps every commit from `frame - span` on, which with commits
132/// running up to `frame + span - 1` is `2 * span` messages at most. A fixed
133/// window of forty was once used, on the reasoning that only `span` (33)
134/// commits can be outstanding, and it deadlocked under a one-way loss
135/// burst: the peer's frame had fallen `span` behind ours, ours had run
136/// `span` ahead of that, and the commit it needed had scrolled out.
137fn resend_span(delay: u32) -> u32 {
138 delay + MAX_COMMIT_LEAD
139}
140
141impl Lockstep {
142 pub fn new(local: PlayerId, players: Vec<PlayerId>, delay: u32) -> Lockstep {
143 assert!(players.contains(&local));
144 Lockstep::seated(Some(local), players, delay)
145 }
146
147 /// A session that watches: it receives every player's input and
148 /// simulates the same frames, but commits nothing and is waited for by
149 /// nobody. A spectator falling behind is a spectator's problem.
150 pub fn observer(players: Vec<PlayerId>, delay: u32) -> Lockstep {
151 Lockstep::seated(None, players, delay)
152 }
153
154 fn seated(local: Option<PlayerId>, players: Vec<PlayerId>, delay: u32) -> Lockstep {
155 assert!(
156 players
157 .iter()
158 .all(|&p| crate::sim::board::seat(p).is_some()),
159 "player id out of range"
160 );
161 let mut session = Lockstep {
162 local,
163 players,
164 delay,
165 frame: 0,
166 next_commit: delay,
167 pending: BTreeMap::new(),
168 history: Vec::new(),
169 pause_at: None,
170 lifted: None,
171 };
172 // The first `delay` frames have no committed inputs by construction;
173 // both sides agree they are all None.
174 for frame in 0..delay {
175 let slot = session.slot(frame);
176 for entry in slot.iter_mut() {
177 *entry = Some(PlayerAction::None);
178 }
179 }
180 session
181 }
182
183 fn slot(&mut self, frame: u32) -> &mut [Option<PlayerAction>; MAX_PLAYERS] {
184 let players = &self.players;
185 self.pending.entry(frame).or_insert_with(|| {
186 let mut slot = [None; MAX_PLAYERS];
187 for p in 0..MAX_PLAYERS as u8 {
188 if !players.contains(&p) {
189 slot[p as usize] = Some(PlayerAction::None); // absent seats
190 }
191 }
192 slot
193 })
194 }
195
196 // --- pause protocol ---------------------------------------------------
197 //
198 // Lockstep cannot simply stop simulating on one peer: a frame runs only
199 // when *every* player's input for it is known, so a peer that silently
200 // held its inputs back would stall the others without explanation and
201 // then flood them on resume. The pause is therefore agreed as a frame
202 // number. Every peer stops committing at that frame, so no peer can
203 // complete it, so the sim halts on the same frame everywhere: the
204 // natural stall, used on purpose. Resuming just reopens commits.
205 //
206 // Nothing here can desync: a peer whose commits already ran past the
207 // pause frame keeps those commits (they are already sent and recorded)
208 // and simply stops making new ones. The frames still simulate in the
209 // same order with the same inputs on every peer.
210
211 /// Call a pause. Returns the frame the session will freeze on, to be
212 /// broadcast to the peers; a pause already in flight wins if it lands
213 /// earlier, so two players hitting Escape together agree. `None` for a
214 /// spectator, who does not get to stop everyone else's match and has
215 /// nothing to broadcast: it used to answer `u32::MAX` there, a frame
216 /// number a caller could mistake for one to send.
217 pub fn request_pause(&mut self) -> Option<u32> {
218 if self.watching() {
219 return None;
220 }
221 // Past every pause already lifted, or the proposal would be read as
222 // an echo of one of them (see `lifted`) by every peer, this one
223 // included. That only bites when a pause is lifted before the sim
224 // reached it and this peer's commits sit more than a lead behind
225 // the frame it named; a frame the others may already have committed
226 // past is still a sound pause frame, they simply stop committing
227 // and the sim comes to rest a beat later than usual.
228 let mut frame = self.next_commit + PAUSE_LEAD;
229 if let Some(lifted) = self.lifted {
230 frame = frame.max(lifted + 1);
231 }
232 self.receive_pause(frame);
233 Some(self.pause_at.unwrap_or(frame))
234 }
235
236 /// A peer called a pause at `frame`. The earliest proposal wins, so
237 /// every peer converges on one frame however the messages interleave.
238 ///
239 /// Two kinds of `Pause` are not proposals and are dropped: one naming
240 /// a frame this peer has already simulated (every player committed
241 /// past it, so nobody is stopping there), and one naming a pause that
242 /// has since been lifted (see `lifted`). Both are the per-tick echoes
243 /// of a pause that is over, arriving after the resume.
244 pub fn receive_pause(&mut self, frame: u32) {
245 if frame < self.frame
246 || frame > self.horizon()
247 || self.lifted.is_some_and(|lifted| frame <= lifted)
248 {
249 return;
250 }
251 self.pause_at = Some(match self.pause_at {
252 Some(existing) => existing.min(frame),
253 None => frame,
254 });
255 }
256
257 /// Lift the pause and let commits flow again. Safe to call unpaused.
258 /// Returns the frame the lifted pause was to freeze on (the highest
259 /// ever lifted here, if there was no pause to lift), for the peers.
260 pub fn resume(&mut self) -> u32 {
261 self.receive_resume(self.pause_at.unwrap_or(0))
262 }
263
264 /// A peer lifted the pause that was to freeze on `frame`. Whatever
265 /// pause is in flight here is lifted with it: the peers agree on one
266 /// frame, and a peer that had not yet heard the earliest proposal is
267 /// resuming from the same pause under a later number.
268 pub fn receive_resume(&mut self, frame: u32) -> u32 {
269 // Off the wire, so bounded like every other frame number that
270 // arrives that way: `lifted` is added to when the next pause is
271 // proposed, and a peer naming the last frame there is would have
272 // had that addition overflow. One short of the horizon, so that
273 // next pause, one past this, is still a frame this peer accepts.
274 let frame = frame.min(self.horizon().saturating_sub(1));
275 let lifted = [self.lifted, self.pause_at, Some(frame)]
276 .into_iter()
277 .flatten()
278 .max()
279 .unwrap_or(0);
280 self.lifted = Some(lifted);
281 self.pause_at = None;
282 lifted
283 }
284
285 /// The frame this session is frozen on, if paused.
286 pub fn pause_frame(&self) -> Option<u32> {
287 self.pause_at
288 }
289
290 /// The frame of the pause most recently lifted, if any ever was: what
291 /// a repeated `Resume` names.
292 pub fn lifted_pause(&self) -> Option<u32> {
293 self.lifted
294 }
295
296 /// Whether a pause has been called (the freeze itself lands a beat
297 /// later, when the simulated frame reaches [`Lockstep::pause_frame`]).
298 pub fn paused(&self) -> bool {
299 self.pause_at.is_some()
300 }
301
302 /// Whether the sim has actually come to rest on the pause frame: the
303 /// moment the picture on screen stops moving.
304 pub fn frozen(&self) -> bool {
305 self.pause_at.is_some_and(|at| self.frame >= at)
306 }
307
308 /// Commit the local action for the next input frame; returns the message
309 /// to send to every peer, or `None` if the sim has fallen too far behind
310 /// (a stalled peer) or the session is paused. The caller should retry
311 /// the action next frame rather than let commits run unboundedly ahead.
312 pub fn commit_local(&mut self, action: PlayerAction) -> Option<InputMsg> {
313 if self.watching() {
314 return None; // nothing to commit, and nobody waiting for it
315 }
316 if self.pause_at.is_some_and(|at| self.next_commit >= at) {
317 return None;
318 }
319 if self.next_commit >= self.frame + self.delay + MAX_COMMIT_LEAD {
320 return None;
321 }
322 let frame = self.next_commit;
323 self.next_commit += 1;
324 let local = self.local?;
325 // Normalize through the wire encoding so the local sim executes
326 // exactly what remote sims will decode. Lossless for any board whose
327 // tiles fit a byte per axis, which is every board there is.
328 let action = decode_action(encode_action(action));
329 debug_assert!(
330 usize::from(local) < MAX_PLAYERS,
331 "committing for seat {local}, which is not at the table"
332 );
333 self.slot(frame)[local as usize] = Some(action);
334 let msg = InputMsg {
335 player: local,
336 frame,
337 action,
338 };
339 self.history.push(msg);
340 self.trim_history();
341 Some(msg)
342 }
343
344 /// Drop the commits no peer can still be missing (see [`resend_span`]).
345 fn trim_history(&mut self) {
346 let oldest_wanted = self.frame.saturating_sub(resend_span(self.delay));
347 let keep = self
348 .history
349 .iter()
350 .position(|msg| msg.frame >= oldest_wanted)
351 .unwrap_or(self.history.len());
352 if keep > 0 {
353 self.history.drain(..keep);
354 }
355 }
356
357 /// The recent local commits, oldest first: every one a peer still in
358 /// step with us could be missing (see [`resend_span`]). Resend these
359 /// every step so packet loss (or a not-yet-completed handshake) cannot
360 /// stall the peer.
361 pub fn recent_commits(&self) -> &[InputMsg] {
362 &self.history
363 }
364
365 /// Which players the next frame is still waiting on. Empty means it is
366 /// ready to simulate; anything else is who everybody is held up by.
367 ///
368 /// Read-only, because the shell asks this to put a name on screen while
369 /// the picture is still, and a question the HUD asks every frame must
370 /// not be one that writes. Only seated players can hold a frame up; an
371 /// absent seat is filled at the moment its frame is made.
372 pub fn awaiting(&self) -> Vec<PlayerId> {
373 let Some(slot) = self.pending.get(&self.frame) else {
374 // No frame made yet means nothing has arrived for it, so
375 // everybody still seated is being waited on.
376 return self.players.clone();
377 };
378 self.players
379 .iter()
380 .copied()
381 .filter(|player| slot[*player as usize].is_none())
382 .collect()
383 }
384
385 /// Give up on a player who has stopped sending, from `frame` on.
386 ///
387 /// Every frame from there fills their slot the way an absent seat is
388 /// filled, including the frames already waiting on it, which unsticks
389 /// the round in the same breath. What moves into the empty
390 /// chair is not this layer's business: the shell puts an AI there, and
391 /// every peer derives the same moves for it from the same board.
392 ///
393 /// The frame is the one the decider was held up on, and it travels
394 /// with the decision, because the peers do not all hold the same
395 /// inputs from a player who has gone quiet: the host relays each input
396 /// as it arrives, and a peer that missed the relay of frame `n` may
397 /// well hold `n + 2`. Filling only the empty slots had that peer play
398 /// `n + 2` as sent while the host, which never got `n` and stopped
399 /// there, played it empty. Every slot from `frame` on is emptied
400 /// instead, whatever it held, and every peer applies the same frame.
401 /// A peer cannot have simulated past `frame` already: the decider had
402 /// no input for it, and no peer hears from a player except through
403 /// the decider.
404 pub fn abandon(&mut self, player: PlayerId, frame: u32) {
405 debug_assert!(
406 usize::from(player) < MAX_PLAYERS,
407 "no such seat to abandon: {player}"
408 );
409 self.players.retain(|seated| *seated != player);
410 for (&at, slot) in self.pending.iter_mut() {
411 if at >= frame || slot[player as usize].is_none() {
412 slot[player as usize] = Some(PlayerAction::None);
413 }
414 }
415 }
416
417 /// Feed a peer's message (duplicates and already-simulated frames are
418 /// ignored, so resends are harmless).
419 ///
420 /// A frame further ahead than any peer still in step could have
421 /// committed is ignored too: their commits run at most a lead past
422 /// their frame, and their frame at most a lead past ours (see
423 /// [`resend_span`]). Every frame accepted here makes a slot, and a peer
424 /// naming frames up to `u32::MAX` would otherwise grow the table
425 /// without bound.
426 /// The furthest frame a peer may name: as far ahead of the frame being
427 /// simulated as a resend span twice over. Frame numbers arrive off the
428 /// wire, and one past this is either a peer that has run away from the
429 /// table or one that is not playing fair; both are dropped.
430 fn horizon(&self) -> u32 {
431 self.frame + 2 * resend_span(self.delay)
432 }
433
434 pub fn receive(&mut self, msg: InputMsg) {
435 let horizon = self.horizon();
436 if msg.frame < self.frame || msg.frame > horizon || !self.players.contains(&msg.player) {
437 return;
438 }
439 // Past the guard, the sender is a seated player, so the index below
440 // is in range, which is the only reason it is written as one.
441 debug_assert!(usize::from(msg.player) < MAX_PLAYERS);
442 let slot = self.slot(msg.frame);
443 if slot[msg.player as usize].is_none() {
444 slot[msg.player as usize] = Some(msg.action);
445 }
446 }
447
448 /// If every player's action for the next frame is known, pop it for
449 /// simulation. `None` means "stall this render frame": never guess.
450 pub fn advance(&mut self) -> Option<[PlayerAction; MAX_PLAYERS]> {
451 let slot = self.slot(self.frame);
452 if slot.iter().any(|a| a.is_none()) {
453 return None;
454 }
455 let actions = std::array::from_fn(|i| slot[i].unwrap_or(PlayerAction::None));
456 self.pending.remove(&self.frame);
457 self.frame += 1;
458 Some(actions)
459 }
460
461 pub fn frame(&self) -> u32 {
462 self.frame
463 }
464
465 /// Whether this peer is watching rather than playing.
466 pub fn watching(&self) -> bool {
467 self.local.is_none()
468 }
469
470 /// The seat this peer plays, if it plays one.
471 pub fn seat(&self) -> Option<PlayerId> {
472 self.local
473 }
474
475 pub fn player_count(&self) -> usize {
476 self.players.len()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use crate::sim::board::Board;
484 use crate::sim::{CrabKind, Handedness, Spawner, TileKind};
485
486 /// A stalled peer pushes back on local commits at the lead cap, and the
487 /// queue reopens as soon as frames advance.
488 #[test]
489 fn commit_lead_is_bounded_and_recovers() {
490 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
491 let mut accepted = 0;
492 for _ in 0..100 {
493 if session.commit_local(PlayerAction::None).is_some() {
494 accepted += 1;
495 }
496 }
497 assert_eq!(
498 accepted,
499 (MAX_COMMIT_LEAD) as usize,
500 "commits stop at the lead cap while the sim is stalled"
501 );
502 // The peer's inputs arrive; frames advance; commits reopen.
503 for frame in DEFAULT_DELAY..DEFAULT_DELAY + 5 {
504 session.receive(InputMsg {
505 player: 1,
506 frame,
507 action: PlayerAction::None,
508 });
509 }
510 let mut advanced = 0;
511 while session.advance().is_some() {
512 advanced += 1;
513 }
514 assert_eq!(advanced, DEFAULT_DELAY as usize + 5);
515 assert!(session.commit_local(PlayerAction::None).is_some());
516 }
517
518 /// The pause is an agreement about a frame, not a local freeze: both
519 /// peers stop committing at it, so neither can complete it, so the sim
520 /// halts on the same frame on both, and resuming picks straight back up
521 /// with no gap and no repeated frame.
522 #[test]
523 fn a_pause_halts_both_peers_on_one_frame() {
524 let players = vec![0u8, 1];
525 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
526 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
527 let mut board_a = test_board(3);
528 let mut board_b = test_board(3);
529 let mut pause_at = None;
530
531 let run = |a: &mut Lockstep, b: &mut Lockstep, ba: &mut Board, bb: &mut Board| {
532 let msgs: Vec<_> = [
533 a.commit_local(PlayerAction::None),
534 b.commit_local(PlayerAction::None),
535 ]
536 .into_iter()
537 .flatten()
538 .collect();
539 for msg in msgs {
540 if msg.player == 0 {
541 b.receive(msg);
542 } else {
543 a.receive(msg);
544 }
545 }
546 while let Some(actions) = a.advance() {
547 ba.tick(&actions);
548 }
549 while let Some(actions) = b.advance() {
550 bb.tick(&actions);
551 }
552 };
553
554 for step in 0..80 {
555 if step == 20 {
556 // Peer A hits Escape; the pause frame rides the wire.
557 let frame = a.request_pause().expect("a player may pause");
558 b.receive_pause(frame);
559 pause_at = Some(frame);
560 }
561 run(&mut a, &mut b, &mut board_a, &mut board_b);
562 }
563 let pause_at = pause_at.expect("a pause was called");
564 assert!(a.frozen() && b.frozen(), "both peers came to rest");
565 assert_eq!(a.frame(), pause_at, "stopped on the agreed frame");
566 assert_eq!(b.frame(), pause_at, "and so did the peer");
567 assert_eq!(board_a.state_hash(), board_b.state_hash());
568
569 // Peer B presses Continue; both play on from where they stopped.
570 b.resume();
571 a.resume();
572 for _ in 0..40 {
573 run(&mut a, &mut b, &mut board_a, &mut board_b);
574 }
575 assert!(a.frame() > pause_at + 20, "the match ran on");
576 assert_eq!(a.frame(), b.frame());
577 assert_eq!(
578 board_a.state_hash(),
579 board_b.state_hash(),
580 "pausing desynced the peers"
581 );
582 }
583
584 /// A watcher simulates the same frames from the players' inputs, commits
585 /// nothing, and (the part that matters) is never waited for: the two
586 /// players advance whether or not it keeps up.
587 #[test]
588 fn a_watcher_follows_without_being_waited_for() {
589 let players = vec![0u8, 1];
590 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
591 let mut b = Lockstep::new(1, players.clone(), DEFAULT_DELAY);
592 let mut watcher = Lockstep::observer(players, DEFAULT_DELAY);
593 assert!(watcher.watching());
594 assert_eq!(watcher.seat(), None);
595 assert_eq!(a.seat(), Some(0));
596
597 // Whatever it is handed, it commits nothing and sends nothing.
598 assert!(
599 watcher
600 .commit_local(PlayerAction::Remove { x: 1, y: 1 })
601 .is_none()
602 );
603 assert!(watcher.recent_commits().is_empty());
604
605 let place = PlayerAction::Place {
606 x: 2,
607 y: 3,
608 dir: Direction::Up,
609 };
610 for frame in 0..20u32 {
611 let from_a = a.commit_local(if frame == 4 {
612 place
613 } else {
614 PlayerAction::None
615 });
616 let from_b = b.commit_local(PlayerAction::None);
617 for msg in [from_a, from_b].into_iter().flatten() {
618 a.receive(msg);
619 b.receive(msg);
620 watcher.receive(msg);
621 }
622 // The players never stall on the watcher.
623 assert!(a.advance().is_some(), "player a stalled at {frame}");
624 assert!(b.advance().is_some(), "player b stalled at {frame}");
625 }
626 // The watcher replays exactly what they played, in order.
627 let mut seen = Vec::new();
628 while let Some(actions) = watcher.advance() {
629 seen.push(actions);
630 }
631 // The first `delay` frames are agreed-empty by construction, so a
632 // fresh session can always run those before any input arrives.
633 assert_eq!(
634 seen.len(),
635 20 + DEFAULT_DELAY as usize,
636 "the watcher saw every frame"
637 );
638 assert!(
639 seen.iter().any(|frame| frame[0] == place),
640 "and the placement among them"
641 );
642 // It cannot stop the match, either.
643 assert_eq!(watcher.request_pause(), None, "nothing to broadcast");
644 assert!(watcher.pause_frame().is_none(), "a watcher cannot pause");
645 }
646
647 /// Two players hitting Escape in the same breath must not each freeze on
648 /// their own frame: the earlier proposal wins on every peer.
649 #[test]
650 fn simultaneous_pauses_settle_on_the_earlier_frame() {
651 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
652 let mine = session.request_pause().expect("a player may pause");
653 session.receive_pause(mine + 4);
654 assert_eq!(session.pause_frame(), Some(mine), "later proposal ignored");
655 session.receive_pause(mine - 4);
656 assert_eq!(session.pause_frame(), Some(mine - 4), "earlier one wins");
657 // Repeats of the same pause (they are resent every tick) are inert.
658 session.receive_pause(mine - 4);
659 assert_eq!(session.pause_frame(), Some(mine - 4));
660 session.resume();
661 assert!(!session.paused() && !session.frozen());
662 }
663
664 /// Three peers exchanging inputs (as the host relay would deliver them)
665 /// must stay bit-identical: the protocol itself is seat-count agnostic.
666 #[test]
667 fn three_player_lockstep_stays_bit_identical() {
668 let players = vec![0u8, 1, 2];
669 let mut sessions: Vec<Lockstep> = (0..3u8)
670 .map(|p| Lockstep::new(p, players.clone(), DEFAULT_DELAY))
671 .collect();
672 let mut boards: Vec<Board> = (0..3).map(|_| test_board(4)).collect();
673 for step in 0u32..300 {
674 let mut outgoing = Vec::new();
675 for (i, session) in sessions.iter_mut().enumerate() {
676 let action = if step % (20 + i as u32 * 7) == 3 {
677 PlayerAction::Place {
678 x: (step % 12) as u8,
679 y: (i as u8 * 2 + 1) % 9,
680 dir: Direction::Down,
681 }
682 } else {
683 PlayerAction::None
684 };
685 outgoing.extend(session.commit_local(action));
686 }
687 for msg in outgoing {
688 for (i, session) in sessions.iter_mut().enumerate() {
689 if i as u8 != msg.player {
690 session.receive(msg);
691 }
692 }
693 }
694 for (session, board) in sessions.iter_mut().zip(&mut boards) {
695 while let Some(actions) = session.advance() {
696 board.tick(&actions);
697 }
698 }
699 }
700 assert!(sessions[0].frame() > 250, "made progress");
701 assert_eq!(boards[0].state_hash(), boards[1].state_hash());
702 assert_eq!(boards[1].state_hash(), boards[2].state_hash());
703 }
704
705 fn roundtrip(action: PlayerAction) {
706 let decoded = decode_action(encode_action(action));
707 assert_eq!(format!("{action:?}"), format!("{decoded:?}"));
708 }
709
710 #[test]
711 fn packed_input_round_trips() {
712 roundtrip(PlayerAction::None);
713 for dir in [
714 Direction::Up,
715 Direction::Right,
716 Direction::Down,
717 Direction::Left,
718 ] {
719 roundtrip(PlayerAction::Place { x: 11, y: 8, dir });
720 }
721 roundtrip(PlayerAction::Remove { x: 0, y: 15 });
722 // Past 16 wide, where a nibble per axis would have wrapped: the XL
723 // beach is 20.
724 for x in 16..20u8 {
725 roundtrip(PlayerAction::Place {
726 x,
727 y: 11,
728 dir: Direction::Right,
729 });
730 roundtrip(PlayerAction::Remove { x, y: 12 });
731 }
732 let msg = InputMsg {
733 player: 3,
734 frame: 123_456,
735 action: PlayerAction::Place {
736 x: 7,
737 y: 2,
738 dir: Direction::Left,
739 },
740 };
741 assert_eq!(InputMsg::decode(msg.encode()), msg);
742 }
743
744 fn test_board(seed: u64) -> Board {
745 let mut board = Board::new(12, 9, seed);
746 board.set_tile(11, 4, TileKind::Castle(0));
747 board.set_tile(0, 4, TileKind::Castle(1));
748 board.set_tile(
749 5,
750 0,
751 TileKind::Spawner(Spawner {
752 dir: Direction::Down,
753 period: 25,
754 }),
755 );
756 board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
757 board.spawn_gull(9, 7, Direction::Left);
758 board
759 }
760
761 /// Two peers, out-of-order delivery with lag spikes: both must simulate
762 /// identical frames and end bit-identical.
763 #[test]
764 fn lockstep_peers_stay_bit_identical() {
765 let players = vec![0u8, 1u8];
766 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
767 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
768 let mut board_a = test_board(9);
769 let mut board_b = test_board(9);
770 let (mut queue_ab, mut queue_ba): (Vec<InputMsg>, Vec<InputMsg>) = (vec![], vec![]);
771
772 for step in 0u32..600 {
773 // Local "input sampling": scripted, different per peer.
774 let act_a = if step % 50 == 7 {
775 PlayerAction::Place {
776 x: (step % 12) as u8,
777 y: 3,
778 dir: Direction::Down,
779 }
780 } else {
781 PlayerAction::None
782 };
783 let act_b = if step % 70 == 11 {
784 PlayerAction::Place {
785 x: 4,
786 y: (step % 9) as u8,
787 dir: Direction::Left,
788 }
789 } else {
790 PlayerAction::None
791 };
792 queue_ab.extend(a.commit_local(act_a));
793 queue_ba.extend(b.commit_local(act_b));
794
795 // Artificial network: hold messages back during "lag spikes",
796 // deliver newest-first (reordering) otherwise.
797 if !(step % 90 < 8) {
798 while let Some(msg) = queue_ab.pop() {
799 b.receive(msg);
800 }
801 while let Some(msg) = queue_ba.pop() {
802 a.receive(msg);
803 }
804 }
805
806 // Each peer simulates as many frames as it can.
807 while let Some(actions) = a.advance() {
808 board_a.tick(&actions);
809 }
810 while let Some(actions) = b.advance() {
811 board_b.tick(&actions);
812 }
813 }
814 // Flush and drain: both converge on the same final frame.
815 while let Some(msg) = queue_ab.pop() {
816 b.receive(msg);
817 }
818 while let Some(msg) = queue_ba.pop() {
819 a.receive(msg);
820 }
821 while let Some(actions) = a.advance() {
822 board_a.tick(&actions);
823 }
824 while let Some(actions) = b.advance() {
825 board_b.tick(&actions);
826 }
827 let min_frame = a.frame().min(b.frame());
828 assert!(min_frame > 500, "sessions made progress ({min_frame})");
829 // Winding the faster board back is impossible, so instead both
830 // drained all available frames; with every message delivered, the
831 // frames are equal.
832 assert_eq!(a.frame(), b.frame());
833 assert_eq!(
834 board_a.state_hash(),
835 board_b.state_hash(),
836 "lockstep peers diverged"
837 );
838 }
839}
840
841#[cfg(test)]
842mod abandon_tests {
843 use super::*;
844
845 /// A player that stops sending holds up everybody, because a frame
846 /// simulates only when every seat's action is known. Giving up on them
847 /// has to unstick the frame already waiting, not merely the ones after
848 /// it. Otherwise the round stays frozen on the very frame that proved
849 /// they were gone.
850 #[test]
851 fn abandoning_a_player_unsticks_the_frame_they_were_holding() {
852 let mut session = Lockstep::new(0, vec![0, 1], 0);
853 session.commit_local(PlayerAction::None);
854 assert_eq!(session.awaiting(), vec![1], "held up by the one who left");
855 assert!(session.advance().is_none(), "and going nowhere");
856
857 session.abandon(1, 0);
858 assert!(session.awaiting().is_empty(), "nobody left to wait for");
859 assert!(session.advance().is_some(), "the round moves again");
860 }
861
862 /// And it keeps moving: the seat is filled as an absent one from then
863 /// on, so the next frame does not stall all over again.
864 #[test]
865 fn an_abandoned_seat_stays_abandoned() {
866 let mut session = Lockstep::new(0, vec![0, 1], 0);
867 session.abandon(1, 0);
868 for frame in 0..30 {
869 session.commit_local(PlayerAction::None);
870 assert!(
871 session.advance().is_some(),
872 "stalled again at frame {frame}"
873 );
874 }
875 }
876
877 /// The seat is emptied, not filled: what the sim gets for it is a plain
878 /// no-op, so the shell can drop an AI in on top without the two of them
879 /// fighting over the same slot.
880 #[test]
881 fn an_abandoned_seat_is_handed_over_empty() {
882 let mut session = Lockstep::new(0, vec![0, 1], 0);
883 session.abandon(1, 0);
884 session.commit_local(PlayerAction::None);
885 let actions = session.advance().expect("moves");
886 assert_eq!(actions[1], PlayerAction::None);
887 }
888
889 /// Everyone else is untouched. Giving up on one player must not drop
890 /// the round for the rest, which would be a rout rather than a rescue.
891 #[test]
892 fn the_others_are_still_waited_for() {
893 let mut session = Lockstep::new(0, vec![0, 1, 2], 0);
894 session.commit_local(PlayerAction::None);
895 session.abandon(1, 0);
896 assert_eq!(session.awaiting(), vec![2], "still owed seat two");
897 assert!(session.advance().is_none());
898 session.receive(InputMsg {
899 player: 2,
900 frame: 0,
901 action: PlayerAction::None,
902 });
903 assert!(session.advance().is_some());
904 }
905}
906
907#[cfg(test)]
908mod loss_tests {
909 use super::*;
910
911 /// Two peers over a lossy link, driven by a delivery schedule: each
912 /// step both commit, then whatever `deliver` allows crosses, then both
913 /// simulate what they can. Returns the frames they reached.
914 fn run(steps: usize, mut deliver: impl FnMut(usize, PlayerId) -> bool) -> (Lockstep, Lockstep) {
915 let players = vec![0u8, 1];
916 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
917 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
918 for step in 0..steps {
919 a.commit_local(PlayerAction::None);
920 b.commit_local(PlayerAction::None);
921 // Every tick resends the whole tail, as the shell does.
922 let from_a: Vec<InputMsg> = a.recent_commits().to_vec();
923 let from_b: Vec<InputMsg> = b.recent_commits().to_vec();
924 if deliver(step, 0) {
925 for msg in from_a {
926 b.receive(msg);
927 }
928 }
929 if deliver(step, 1) {
930 for msg in from_b {
931 a.receive(msg);
932 }
933 }
934 while a.advance().is_some() {}
935 while b.advance().is_some() {}
936 }
937 (a, b)
938 }
939
940 /// One direction of the link goes dark for longer than a lead, while
941 /// the other keeps flowing. The peer that kept hearing runs a whole
942 /// lead past the one that did not, which itself ran a lead past its
943 /// stalled frame; the commit the quiet peer is stuck on is two leads
944 /// old on the loud one, and a fixed forty-deep tail had let it go. The
945 /// session then sat frozen for good, both peers "still talking".
946 #[test]
947 fn a_one_way_loss_burst_does_not_deadlock_the_session() {
948 let blackout = 20..90;
949 let (a, b) = run(300, |step, from| from != 0 || !blackout.contains(&step));
950 assert_eq!(a.frame(), b.frame(), "back in step");
951 assert!(a.frame() > 250, "and the round ran on ({})", a.frame());
952 }
953
954 /// The tail is not unbounded either: it holds what a peer could be
955 /// missing, and no more.
956 #[test]
957 fn the_resend_tail_stays_within_two_leads() {
958 let (a, _) = run(200, |_, _| true);
959 let span = resend_span(DEFAULT_DELAY) as usize;
960 assert!(
961 a.recent_commits().len() <= 2 * span,
962 "{}",
963 a.recent_commits().len()
964 );
965 }
966}
967
968#[cfg(test)]
969mod pause_echo_tests {
970 use super::*;
971
972 /// A peer that hears the resume, then a `Pause` echo still in flight
973 /// from before it, must not pause again: peers repeat their pause frame
974 /// every tick, so those echoes always cross the resume, and taking
975 /// them at face value re-paused whoever had just resumed, who repeated
976 /// it back, and the two flapped between paused and playing for good.
977 #[test]
978 fn a_stale_pause_echo_after_the_resume_is_ignored() {
979 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
980 let at = a.request_pause().expect("a player may pause");
981 // Frozen on it, as a peer that heard the pause in time would be.
982 while a.frame() < at {
983 a.commit_local(PlayerAction::None);
984 a.receive(InputMsg {
985 player: 1,
986 frame: a.frame(),
987 action: PlayerAction::None,
988 });
989 assert!(a.advance().is_some());
990 }
991 assert!(a.frozen());
992 // The peer resumes; its last echo of the pause is still on the wire.
993 a.receive_resume(at);
994 assert!(!a.paused());
995 a.receive_pause(at);
996 assert!(!a.paused(), "an echo of a lifted pause is not a pause");
997 // Nor is one from a pause that never reached us until after the
998 // resume was heard: our own frame is already past it.
999 a.receive_pause(at.saturating_sub(1));
1000 assert!(!a.paused());
1001 // A fresh pause is a fresh pause.
1002 let again = a.request_pause().expect("a player may pause");
1003 assert!(again > at);
1004 assert!(a.paused());
1005 }
1006
1007 /// The same, when the resume comes first and the peer never heard the
1008 /// pause at all: the frame the resume names is enough to know the
1009 /// echo is stale.
1010 #[test]
1011 fn a_resume_names_the_pause_it_lifts() {
1012 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1013 a.receive_resume(40);
1014 assert_eq!(a.lifted_pause(), Some(40));
1015 a.receive_pause(40);
1016 assert!(!a.paused());
1017 a.receive_pause(41);
1018 assert!(a.paused(), "a later frame is a new pause");
1019 // Resuming locally lifts through the highest frame known.
1020 assert_eq!(a.resume(), 41);
1021 }
1022
1023 /// A pause proposal that would be read as an echo of a lifted one is
1024 /// pushed past it, so a second Escape shortly after a resume still
1025 /// pauses, everywhere.
1026 #[test]
1027 fn a_new_pause_is_always_past_the_lifted_one() {
1028 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1029 a.receive_resume(50);
1030 let at = a.request_pause().expect("a player may pause");
1031 assert!(at > 50, "{at}");
1032 assert_eq!(a.pause_frame(), Some(at));
1033 }
1034
1035 /// A resume names a frame off the wire, and the next local pause is
1036 /// proposed one past it. A peer naming the last frame there is made
1037 /// that addition overflow: a panic in debug, and in release a
1038 /// proposal that wrapped to zero, was dropped as an echo, and was
1039 /// broadcast anyway, stopping every other table at a frame this one
1040 /// never honoured. Bounded to the horizon like every input frame.
1041 #[test]
1042 fn a_resume_from_the_end_of_time_cannot_break_the_next_pause() {
1043 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1044 let lifted = a.receive_resume(u32::MAX);
1045 assert!(lifted <= 2 * resend_span(DEFAULT_DELAY), "{lifted}");
1046 let at = a.request_pause().expect("a player may pause");
1047 assert!(at > lifted, "{at}");
1048 assert_eq!(a.pause_frame(), Some(at), "and the pause is really held");
1049 // A pause proposal from past the horizon is dropped the same way.
1050 let mut b = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1051 b.receive_pause(u32::MAX);
1052 assert_eq!(b.pause_frame(), None);
1053 }
1054}
1055
1056#[cfg(test)]
1057mod abandon_frame_tests {
1058 use super::*;
1059
1060 /// Two peers give up on the same seat from the same frame and end up
1061 /// with the same inputs for it, whatever each of them happened to hold:
1062 /// the one that held a later input from the departed player plays it
1063 /// empty like everyone else, rather than as sent.
1064 #[test]
1065 fn peers_holding_different_inputs_agree_after_the_abandonment() {
1066 let mut host = Lockstep::new(0, vec![0, 1, 2], 0);
1067 let mut peer = Lockstep::new(2, vec![0, 1, 2], 0);
1068 let place = PlayerAction::Place {
1069 x: 1,
1070 y: 1,
1071 dir: Direction::Up,
1072 };
1073 // Seat 1's frame 0 never reached the host (nor, therefore, the
1074 // peer), but its frame 2 reached the host, and the relay of it
1075 // reached the peer; the relay of frame 1 was lost on the way.
1076 for frame in [1, 2] {
1077 host.receive(InputMsg {
1078 player: 1,
1079 frame,
1080 action: place,
1081 });
1082 }
1083 peer.receive(InputMsg {
1084 player: 1,
1085 frame: 2,
1086 action: place,
1087 });
1088 // The host is held up on frame 0 and gives up there.
1089 let at = host.frame();
1090 host.abandon(1, at);
1091 peer.abandon(1, at);
1092 for frame in 0..3 {
1093 for session in [&mut host, &mut peer] {
1094 session.commit_local(PlayerAction::None);
1095 let other = if session.seat() == Some(0) { 2 } else { 0 };
1096 session.receive(InputMsg {
1097 player: other,
1098 frame,
1099 action: PlayerAction::None,
1100 });
1101 }
1102 let from_host = host.advance().expect("host moves");
1103 let from_peer = peer.advance().expect("peer moves");
1104 assert_eq!(from_host[1], PlayerAction::None, "frame {frame}");
1105 assert_eq!(from_host, from_peer, "frame {frame}");
1106 }
1107 }
1108
1109 /// Inputs from the departed player that arrive after the abandonment
1110 /// are ignored, however far ahead they name.
1111 #[test]
1112 fn a_departed_seat_is_no_longer_heard() {
1113 let mut session = Lockstep::new(0, vec![0, 1], 0);
1114 session.abandon(1, 0);
1115 session.receive(InputMsg {
1116 player: 1,
1117 frame: 5,
1118 action: PlayerAction::Remove { x: 0, y: 0 },
1119 });
1120 for _ in 0..6 {
1121 session.commit_local(PlayerAction::None);
1122 let actions = session.advance().expect("moves");
1123 assert_eq!(actions[1], PlayerAction::None);
1124 }
1125 }
1126
1127 /// A frame further ahead than any peer in step could have committed
1128 /// makes no slot: a stranger naming frames to the horizon would
1129 /// otherwise grow the table without bound.
1130 #[test]
1131 fn inputs_beyond_the_horizon_are_dropped() {
1132 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1133 let before = session.pending.len();
1134 session.receive(InputMsg {
1135 player: 1,
1136 frame: u32::MAX,
1137 action: PlayerAction::None,
1138 });
1139 session.receive(InputMsg {
1140 player: 1,
1141 frame: 1000,
1142 action: PlayerAction::None,
1143 });
1144 assert_eq!(session.pending.len(), before);
1145 session.receive(InputMsg {
1146 player: 1,
1147 frame: 2 * resend_span(DEFAULT_DELAY),
1148 action: PlayerAction::None,
1149 });
1150 assert_eq!(
1151 session.pending.len(),
1152 before + 1,
1153 "the horizon itself is in"
1154 );
1155 }
1156}