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.
214 pub fn request_pause(&mut self) -> u32 {
215 if self.watching() {
216 // A spectator does not get to stop everyone else's match.
217 return self.pause_at.unwrap_or(u32::MAX);
218 }
219 // Past every pause already lifted, or the proposal would be read as
220 // an echo of one of them (see `lifted`) by every peer, this one
221 // included. That only bites when a pause is lifted before the sim
222 // reached it and this peer's commits sit more than a lead behind
223 // the frame it named; a frame the others may already have committed
224 // past is still a sound pause frame, they simply stop committing
225 // and the sim comes to rest a beat later than usual.
226 let mut frame = self.next_commit + PAUSE_LEAD;
227 if let Some(lifted) = self.lifted {
228 frame = frame.max(lifted + 1);
229 }
230 self.receive_pause(frame);
231 self.pause_at.unwrap_or(frame)
232 }
233
234 /// A peer called a pause at `frame`. The earliest proposal wins, so
235 /// every peer converges on one frame however the messages interleave.
236 ///
237 /// Two kinds of `Pause` are not proposals and are dropped: one naming
238 /// a frame this peer has already simulated (every player committed
239 /// past it, so nobody is stopping there), and one naming a pause that
240 /// has since been lifted (see `lifted`). Both are the per-tick echoes
241 /// of a pause that is over, arriving after the resume.
242 pub fn receive_pause(&mut self, frame: u32) {
243 if frame < self.frame || self.lifted.is_some_and(|lifted| frame <= lifted) {
244 return;
245 }
246 self.pause_at = Some(match self.pause_at {
247 Some(existing) => existing.min(frame),
248 None => frame,
249 });
250 }
251
252 /// Lift the pause and let commits flow again. Safe to call unpaused.
253 /// Returns the frame the lifted pause was to freeze on (the highest
254 /// ever lifted here, if there was no pause to lift), for the peers.
255 pub fn resume(&mut self) -> u32 {
256 self.receive_resume(self.pause_at.unwrap_or(0))
257 }
258
259 /// A peer lifted the pause that was to freeze on `frame`. Whatever
260 /// pause is in flight here is lifted with it: the peers agree on one
261 /// frame, and a peer that had not yet heard the earliest proposal is
262 /// resuming from the same pause under a later number.
263 pub fn receive_resume(&mut self, frame: u32) -> u32 {
264 let lifted = [self.lifted, self.pause_at, Some(frame)]
265 .into_iter()
266 .flatten()
267 .max()
268 .unwrap_or(0);
269 self.lifted = Some(lifted);
270 self.pause_at = None;
271 lifted
272 }
273
274 /// The frame this session is frozen on, if paused.
275 pub fn pause_frame(&self) -> Option<u32> {
276 self.pause_at
277 }
278
279 /// The frame of the pause most recently lifted, if any ever was: what
280 /// a repeated `Resume` names.
281 pub fn lifted_pause(&self) -> Option<u32> {
282 self.lifted
283 }
284
285 /// Whether a pause has been called (the freeze itself lands a beat
286 /// later, when the simulated frame reaches [`Lockstep::pause_frame`]).
287 pub fn paused(&self) -> bool {
288 self.pause_at.is_some()
289 }
290
291 /// Whether the sim has actually come to rest on the pause frame: the
292 /// moment the picture on screen stops moving.
293 pub fn frozen(&self) -> bool {
294 self.pause_at.is_some_and(|at| self.frame >= at)
295 }
296
297 /// Commit the local action for the next input frame; returns the message
298 /// to send to every peer, or `None` if the sim has fallen too far behind
299 /// (a stalled peer) or the session is paused. The caller should retry
300 /// the action next frame rather than let commits run unboundedly ahead.
301 pub fn commit_local(&mut self, action: PlayerAction) -> Option<InputMsg> {
302 if self.watching() {
303 return None; // nothing to commit, and nobody waiting for it
304 }
305 if self.pause_at.is_some_and(|at| self.next_commit >= at) {
306 return None;
307 }
308 if self.next_commit >= self.frame + self.delay + MAX_COMMIT_LEAD {
309 return None;
310 }
311 let frame = self.next_commit;
312 self.next_commit += 1;
313 let local = self.local?;
314 // Normalize through the wire encoding so the local sim executes
315 // exactly what remote sims will decode. Lossless for any board whose
316 // tiles fit a byte per axis, which is every board there is.
317 let action = decode_action(encode_action(action));
318 debug_assert!(
319 usize::from(local) < MAX_PLAYERS,
320 "committing for seat {local}, which is not at the table"
321 );
322 self.slot(frame)[local as usize] = Some(action);
323 let msg = InputMsg {
324 player: local,
325 frame,
326 action,
327 };
328 self.history.push(msg);
329 self.trim_history();
330 Some(msg)
331 }
332
333 /// Drop the commits no peer can still be missing (see [`resend_span`]).
334 fn trim_history(&mut self) {
335 let oldest_wanted = self.frame.saturating_sub(resend_span(self.delay));
336 let keep = self
337 .history
338 .iter()
339 .position(|msg| msg.frame >= oldest_wanted)
340 .unwrap_or(self.history.len());
341 if keep > 0 {
342 self.history.drain(..keep);
343 }
344 }
345
346 /// The recent local commits, oldest first: every one a peer still in
347 /// step with us could be missing (see [`resend_span`]). Resend these
348 /// every step so packet loss (or a not-yet-completed handshake) cannot
349 /// stall the peer.
350 pub fn recent_commits(&self) -> &[InputMsg] {
351 &self.history
352 }
353
354 /// Which players the next frame is still waiting on. Empty means it is
355 /// ready to simulate; anything else is who everybody is held up by.
356 ///
357 /// Read-only, because the shell asks this to put a name on screen while
358 /// the picture is still, and a question the HUD asks every frame must
359 /// not be one that writes. Only seated players can hold a frame up; an
360 /// absent seat is filled at the moment its frame is made.
361 pub fn awaiting(&self) -> Vec<PlayerId> {
362 let Some(slot) = self.pending.get(&self.frame) else {
363 // No frame made yet means nothing has arrived for it, so
364 // everybody still seated is being waited on.
365 return self.players.clone();
366 };
367 self.players
368 .iter()
369 .copied()
370 .filter(|player| slot[*player as usize].is_none())
371 .collect()
372 }
373
374 /// Give up on a player who has stopped sending, from `frame` on.
375 ///
376 /// Every frame from there fills their slot the way an absent seat is
377 /// filled, including the frames already waiting on it, which unsticks
378 /// the round in the same breath. What moves into the empty
379 /// chair is not this layer's business: the shell puts an AI there, and
380 /// every peer derives the same moves for it from the same board.
381 ///
382 /// The frame is the one the decider was held up on, and it travels
383 /// with the decision, because the peers do not all hold the same
384 /// inputs from a player who has gone quiet: the host relays each input
385 /// as it arrives, and a peer that missed the relay of frame `n` may
386 /// well hold `n + 2`. Filling only the empty slots had that peer play
387 /// `n + 2` as sent while the host, which never got `n` and stopped
388 /// there, played it empty. Every slot from `frame` on is emptied
389 /// instead, whatever it held, and every peer applies the same frame.
390 /// A peer cannot have simulated past `frame` already: the decider had
391 /// no input for it, and no peer hears from a player except through
392 /// the decider.
393 pub fn abandon(&mut self, player: PlayerId, frame: u32) {
394 debug_assert!(
395 usize::from(player) < MAX_PLAYERS,
396 "no such seat to abandon: {player}"
397 );
398 self.players.retain(|seated| *seated != player);
399 for (&at, slot) in self.pending.iter_mut() {
400 if at >= frame || slot[player as usize].is_none() {
401 slot[player as usize] = Some(PlayerAction::None);
402 }
403 }
404 }
405
406 /// Feed a peer's message (duplicates and already-simulated frames are
407 /// ignored, so resends are harmless).
408 ///
409 /// A frame further ahead than any peer still in step could have
410 /// committed is ignored too: their commits run at most a lead past
411 /// their frame, and their frame at most a lead past ours (see
412 /// [`resend_span`]). Every frame accepted here makes a slot, and a peer
413 /// naming frames up to `u32::MAX` would otherwise grow the table
414 /// without bound.
415 pub fn receive(&mut self, msg: InputMsg) {
416 let horizon = self.frame + 2 * resend_span(self.delay);
417 if msg.frame < self.frame || msg.frame > horizon || !self.players.contains(&msg.player) {
418 return;
419 }
420 // Past the guard, the sender is a seated player, so the index below
421 // is in range, which is the only reason it is written as one.
422 debug_assert!(usize::from(msg.player) < MAX_PLAYERS);
423 let slot = self.slot(msg.frame);
424 if slot[msg.player as usize].is_none() {
425 slot[msg.player as usize] = Some(msg.action);
426 }
427 }
428
429 /// If every player's action for the next frame is known, pop it for
430 /// simulation. `None` means "stall this render frame": never guess.
431 pub fn advance(&mut self) -> Option<[PlayerAction; MAX_PLAYERS]> {
432 let slot = self.slot(self.frame);
433 if slot.iter().any(|a| a.is_none()) {
434 return None;
435 }
436 let actions = std::array::from_fn(|i| slot[i].unwrap_or(PlayerAction::None));
437 self.pending.remove(&self.frame);
438 self.frame += 1;
439 Some(actions)
440 }
441
442 pub fn frame(&self) -> u32 {
443 self.frame
444 }
445
446 /// Whether this peer is watching rather than playing.
447 pub fn watching(&self) -> bool {
448 self.local.is_none()
449 }
450
451 /// The seat this peer plays, if it plays one.
452 pub fn seat(&self) -> Option<PlayerId> {
453 self.local
454 }
455
456 pub fn player_count(&self) -> usize {
457 self.players.len()
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::sim::board::Board;
465 use crate::sim::{CrabKind, Handedness, Spawner, TileKind};
466
467 /// A stalled peer pushes back on local commits at the lead cap, and the
468 /// queue reopens as soon as frames advance.
469 #[test]
470 fn commit_lead_is_bounded_and_recovers() {
471 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
472 let mut accepted = 0;
473 for _ in 0..100 {
474 if session.commit_local(PlayerAction::None).is_some() {
475 accepted += 1;
476 }
477 }
478 assert_eq!(
479 accepted,
480 (MAX_COMMIT_LEAD) as usize,
481 "commits stop at the lead cap while the sim is stalled"
482 );
483 // The peer's inputs arrive; frames advance; commits reopen.
484 for frame in DEFAULT_DELAY..DEFAULT_DELAY + 5 {
485 session.receive(InputMsg {
486 player: 1,
487 frame,
488 action: PlayerAction::None,
489 });
490 }
491 let mut advanced = 0;
492 while session.advance().is_some() {
493 advanced += 1;
494 }
495 assert_eq!(advanced, DEFAULT_DELAY as usize + 5);
496 assert!(session.commit_local(PlayerAction::None).is_some());
497 }
498
499 /// The pause is an agreement about a frame, not a local freeze: both
500 /// peers stop committing at it, so neither can complete it, so the sim
501 /// halts on the same frame on both, and resuming picks straight back up
502 /// with no gap and no repeated frame.
503 #[test]
504 fn a_pause_halts_both_peers_on_one_frame() {
505 let players = vec![0u8, 1];
506 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
507 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
508 let mut board_a = test_board(3);
509 let mut board_b = test_board(3);
510 let mut pause_at = None;
511
512 let run = |a: &mut Lockstep, b: &mut Lockstep, ba: &mut Board, bb: &mut Board| {
513 let msgs: Vec<_> = [
514 a.commit_local(PlayerAction::None),
515 b.commit_local(PlayerAction::None),
516 ]
517 .into_iter()
518 .flatten()
519 .collect();
520 for msg in msgs {
521 if msg.player == 0 {
522 b.receive(msg);
523 } else {
524 a.receive(msg);
525 }
526 }
527 while let Some(actions) = a.advance() {
528 ba.tick(&actions);
529 }
530 while let Some(actions) = b.advance() {
531 bb.tick(&actions);
532 }
533 };
534
535 for step in 0..80 {
536 if step == 20 {
537 // Peer A hits Escape; the pause frame rides the wire.
538 let frame = a.request_pause();
539 b.receive_pause(frame);
540 pause_at = Some(frame);
541 }
542 run(&mut a, &mut b, &mut board_a, &mut board_b);
543 }
544 let pause_at = pause_at.expect("a pause was called");
545 assert!(a.frozen() && b.frozen(), "both peers came to rest");
546 assert_eq!(a.frame(), pause_at, "stopped on the agreed frame");
547 assert_eq!(b.frame(), pause_at, "and so did the peer");
548 assert_eq!(board_a.state_hash(), board_b.state_hash());
549
550 // Peer B presses Continue; both play on from where they stopped.
551 b.resume();
552 a.resume();
553 for _ in 0..40 {
554 run(&mut a, &mut b, &mut board_a, &mut board_b);
555 }
556 assert!(a.frame() > pause_at + 20, "the match ran on");
557 assert_eq!(a.frame(), b.frame());
558 assert_eq!(
559 board_a.state_hash(),
560 board_b.state_hash(),
561 "pausing desynced the peers"
562 );
563 }
564
565 /// A watcher simulates the same frames from the players' inputs, commits
566 /// nothing, and (the part that matters) is never waited for: the two
567 /// players advance whether or not it keeps up.
568 #[test]
569 fn a_watcher_follows_without_being_waited_for() {
570 let players = vec![0u8, 1];
571 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
572 let mut b = Lockstep::new(1, players.clone(), DEFAULT_DELAY);
573 let mut watcher = Lockstep::observer(players, DEFAULT_DELAY);
574 assert!(watcher.watching());
575 assert_eq!(watcher.seat(), None);
576 assert_eq!(a.seat(), Some(0));
577
578 // Whatever it is handed, it commits nothing and sends nothing.
579 assert!(
580 watcher
581 .commit_local(PlayerAction::Remove { x: 1, y: 1 })
582 .is_none()
583 );
584 assert!(watcher.recent_commits().is_empty());
585
586 let place = PlayerAction::Place {
587 x: 2,
588 y: 3,
589 dir: Direction::Up,
590 };
591 for frame in 0..20u32 {
592 let from_a = a.commit_local(if frame == 4 {
593 place
594 } else {
595 PlayerAction::None
596 });
597 let from_b = b.commit_local(PlayerAction::None);
598 for msg in [from_a, from_b].into_iter().flatten() {
599 a.receive(msg);
600 b.receive(msg);
601 watcher.receive(msg);
602 }
603 // The players never stall on the watcher.
604 assert!(a.advance().is_some(), "player a stalled at {frame}");
605 assert!(b.advance().is_some(), "player b stalled at {frame}");
606 }
607 // The watcher replays exactly what they played, in order.
608 let mut seen = Vec::new();
609 while let Some(actions) = watcher.advance() {
610 seen.push(actions);
611 }
612 // The first `delay` frames are agreed-empty by construction, so a
613 // fresh session can always run those before any input arrives.
614 assert_eq!(
615 seen.len(),
616 20 + DEFAULT_DELAY as usize,
617 "the watcher saw every frame"
618 );
619 assert!(
620 seen.iter().any(|frame| frame[0] == place),
621 "and the placement among them"
622 );
623 // It cannot stop the match, either.
624 watcher.request_pause();
625 assert!(watcher.pause_frame().is_none(), "a watcher cannot pause");
626 }
627
628 /// Two players hitting Escape in the same breath must not each freeze on
629 /// their own frame: the earlier proposal wins on every peer.
630 #[test]
631 fn simultaneous_pauses_settle_on_the_earlier_frame() {
632 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
633 let mine = session.request_pause();
634 session.receive_pause(mine + 4);
635 assert_eq!(session.pause_frame(), Some(mine), "later proposal ignored");
636 session.receive_pause(mine - 4);
637 assert_eq!(session.pause_frame(), Some(mine - 4), "earlier one wins");
638 // Repeats of the same pause (they are resent every tick) are inert.
639 session.receive_pause(mine - 4);
640 assert_eq!(session.pause_frame(), Some(mine - 4));
641 session.resume();
642 assert!(!session.paused() && !session.frozen());
643 }
644
645 /// Three peers exchanging inputs (as the host relay would deliver them)
646 /// must stay bit-identical: the protocol itself is seat-count agnostic.
647 #[test]
648 fn three_player_lockstep_stays_bit_identical() {
649 let players = vec![0u8, 1, 2];
650 let mut sessions: Vec<Lockstep> = (0..3u8)
651 .map(|p| Lockstep::new(p, players.clone(), DEFAULT_DELAY))
652 .collect();
653 let mut boards: Vec<Board> = (0..3).map(|_| test_board(4)).collect();
654 for step in 0u32..300 {
655 let mut outgoing = Vec::new();
656 for (i, session) in sessions.iter_mut().enumerate() {
657 let action = if step % (20 + i as u32 * 7) == 3 {
658 PlayerAction::Place {
659 x: (step % 12) as u8,
660 y: (i as u8 * 2 + 1) % 9,
661 dir: Direction::Down,
662 }
663 } else {
664 PlayerAction::None
665 };
666 outgoing.extend(session.commit_local(action));
667 }
668 for msg in outgoing {
669 for (i, session) in sessions.iter_mut().enumerate() {
670 if i as u8 != msg.player {
671 session.receive(msg);
672 }
673 }
674 }
675 for (session, board) in sessions.iter_mut().zip(&mut boards) {
676 while let Some(actions) = session.advance() {
677 board.tick(&actions);
678 }
679 }
680 }
681 assert!(sessions[0].frame() > 250, "made progress");
682 assert_eq!(boards[0].state_hash(), boards[1].state_hash());
683 assert_eq!(boards[1].state_hash(), boards[2].state_hash());
684 }
685
686 fn roundtrip(action: PlayerAction) {
687 let decoded = decode_action(encode_action(action));
688 assert_eq!(format!("{action:?}"), format!("{decoded:?}"));
689 }
690
691 #[test]
692 fn packed_input_round_trips() {
693 roundtrip(PlayerAction::None);
694 for dir in [
695 Direction::Up,
696 Direction::Right,
697 Direction::Down,
698 Direction::Left,
699 ] {
700 roundtrip(PlayerAction::Place { x: 11, y: 8, dir });
701 }
702 roundtrip(PlayerAction::Remove { x: 0, y: 15 });
703 // Past 16 wide, where a nibble per axis would have wrapped: the XL
704 // beach is 20.
705 for x in 16..20u8 {
706 roundtrip(PlayerAction::Place {
707 x,
708 y: 11,
709 dir: Direction::Right,
710 });
711 roundtrip(PlayerAction::Remove { x, y: 12 });
712 }
713 let msg = InputMsg {
714 player: 3,
715 frame: 123_456,
716 action: PlayerAction::Place {
717 x: 7,
718 y: 2,
719 dir: Direction::Left,
720 },
721 };
722 assert_eq!(InputMsg::decode(msg.encode()), msg);
723 }
724
725 fn test_board(seed: u64) -> Board {
726 let mut board = Board::new(12, 9, seed);
727 board.set_tile(11, 4, TileKind::Castle(0));
728 board.set_tile(0, 4, TileKind::Castle(1));
729 board.set_tile(
730 5,
731 0,
732 TileKind::Spawner(Spawner {
733 dir: Direction::Down,
734 period: 25,
735 }),
736 );
737 board.spawn_crab(2, 2, Direction::Right, Handedness::Left, CrabKind::Common);
738 board.spawn_gull(9, 7, Direction::Left);
739 board
740 }
741
742 /// Two peers, out-of-order delivery with lag spikes: both must simulate
743 /// identical frames and end bit-identical.
744 #[test]
745 fn lockstep_peers_stay_bit_identical() {
746 let players = vec![0u8, 1u8];
747 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
748 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
749 let mut board_a = test_board(9);
750 let mut board_b = test_board(9);
751 let (mut queue_ab, mut queue_ba): (Vec<InputMsg>, Vec<InputMsg>) = (vec![], vec![]);
752
753 for step in 0u32..600 {
754 // Local "input sampling": scripted, different per peer.
755 let act_a = if step % 50 == 7 {
756 PlayerAction::Place {
757 x: (step % 12) as u8,
758 y: 3,
759 dir: Direction::Down,
760 }
761 } else {
762 PlayerAction::None
763 };
764 let act_b = if step % 70 == 11 {
765 PlayerAction::Place {
766 x: 4,
767 y: (step % 9) as u8,
768 dir: Direction::Left,
769 }
770 } else {
771 PlayerAction::None
772 };
773 queue_ab.extend(a.commit_local(act_a));
774 queue_ba.extend(b.commit_local(act_b));
775
776 // Artificial network: hold messages back during "lag spikes",
777 // deliver newest-first (reordering) otherwise.
778 if !(step % 90 < 8) {
779 while let Some(msg) = queue_ab.pop() {
780 b.receive(msg);
781 }
782 while let Some(msg) = queue_ba.pop() {
783 a.receive(msg);
784 }
785 }
786
787 // Each peer simulates as many frames as it can.
788 while let Some(actions) = a.advance() {
789 board_a.tick(&actions);
790 }
791 while let Some(actions) = b.advance() {
792 board_b.tick(&actions);
793 }
794 }
795 // Flush and drain: both converge on the same final frame.
796 while let Some(msg) = queue_ab.pop() {
797 b.receive(msg);
798 }
799 while let Some(msg) = queue_ba.pop() {
800 a.receive(msg);
801 }
802 while let Some(actions) = a.advance() {
803 board_a.tick(&actions);
804 }
805 while let Some(actions) = b.advance() {
806 board_b.tick(&actions);
807 }
808 let min_frame = a.frame().min(b.frame());
809 assert!(min_frame > 500, "sessions made progress ({min_frame})");
810 // Winding the faster board back is impossible, so instead both
811 // drained all available frames; with every message delivered, the
812 // frames are equal.
813 assert_eq!(a.frame(), b.frame());
814 assert_eq!(
815 board_a.state_hash(),
816 board_b.state_hash(),
817 "lockstep peers diverged"
818 );
819 }
820}
821
822#[cfg(test)]
823mod abandon_tests {
824 use super::*;
825
826 /// A player that stops sending holds up everybody, because a frame
827 /// simulates only when every seat's action is known. Giving up on them
828 /// has to unstick the frame already waiting, not merely the ones after
829 /// it. Otherwise the round stays frozen on the very frame that proved
830 /// they were gone.
831 #[test]
832 fn abandoning_a_player_unsticks_the_frame_they_were_holding() {
833 let mut session = Lockstep::new(0, vec![0, 1], 0);
834 session.commit_local(PlayerAction::None);
835 assert_eq!(session.awaiting(), vec![1], "held up by the one who left");
836 assert!(session.advance().is_none(), "and going nowhere");
837
838 session.abandon(1, 0);
839 assert!(session.awaiting().is_empty(), "nobody left to wait for");
840 assert!(session.advance().is_some(), "the round moves again");
841 }
842
843 /// And it keeps moving: the seat is filled as an absent one from then
844 /// on, so the next frame does not stall all over again.
845 #[test]
846 fn an_abandoned_seat_stays_abandoned() {
847 let mut session = Lockstep::new(0, vec![0, 1], 0);
848 session.abandon(1, 0);
849 for frame in 0..30 {
850 session.commit_local(PlayerAction::None);
851 assert!(
852 session.advance().is_some(),
853 "stalled again at frame {frame}"
854 );
855 }
856 }
857
858 /// The seat is emptied, not filled: what the sim gets for it is a plain
859 /// no-op, so the shell can drop an AI in on top without the two of them
860 /// fighting over the same slot.
861 #[test]
862 fn an_abandoned_seat_is_handed_over_empty() {
863 let mut session = Lockstep::new(0, vec![0, 1], 0);
864 session.abandon(1, 0);
865 session.commit_local(PlayerAction::None);
866 let actions = session.advance().expect("moves");
867 assert_eq!(actions[1], PlayerAction::None);
868 }
869
870 /// Everyone else is untouched. Giving up on one player must not drop
871 /// the round for the rest, which would be a rout rather than a rescue.
872 #[test]
873 fn the_others_are_still_waited_for() {
874 let mut session = Lockstep::new(0, vec![0, 1, 2], 0);
875 session.commit_local(PlayerAction::None);
876 session.abandon(1, 0);
877 assert_eq!(session.awaiting(), vec![2], "still owed seat two");
878 assert!(session.advance().is_none());
879 session.receive(InputMsg {
880 player: 2,
881 frame: 0,
882 action: PlayerAction::None,
883 });
884 assert!(session.advance().is_some());
885 }
886}
887
888#[cfg(test)]
889mod loss_tests {
890 use super::*;
891
892 /// Two peers over a lossy link, driven by a delivery schedule: each
893 /// step both commit, then whatever `deliver` allows crosses, then both
894 /// simulate what they can. Returns the frames they reached.
895 fn run(steps: usize, mut deliver: impl FnMut(usize, PlayerId) -> bool) -> (Lockstep, Lockstep) {
896 let players = vec![0u8, 1];
897 let mut a = Lockstep::new(0, players.clone(), DEFAULT_DELAY);
898 let mut b = Lockstep::new(1, players, DEFAULT_DELAY);
899 for step in 0..steps {
900 a.commit_local(PlayerAction::None);
901 b.commit_local(PlayerAction::None);
902 // Every tick resends the whole tail, as the shell does.
903 let from_a: Vec<InputMsg> = a.recent_commits().to_vec();
904 let from_b: Vec<InputMsg> = b.recent_commits().to_vec();
905 if deliver(step, 0) {
906 for msg in from_a {
907 b.receive(msg);
908 }
909 }
910 if deliver(step, 1) {
911 for msg in from_b {
912 a.receive(msg);
913 }
914 }
915 while a.advance().is_some() {}
916 while b.advance().is_some() {}
917 }
918 (a, b)
919 }
920
921 /// One direction of the link goes dark for longer than a lead, while
922 /// the other keeps flowing. The peer that kept hearing runs a whole
923 /// lead past the one that did not, which itself ran a lead past its
924 /// stalled frame; the commit the quiet peer is stuck on is two leads
925 /// old on the loud one, and a fixed forty-deep tail had let it go. The
926 /// session then sat frozen for good, both peers "still talking".
927 #[test]
928 fn a_one_way_loss_burst_does_not_deadlock_the_session() {
929 let blackout = 20..90;
930 let (a, b) = run(300, |step, from| from != 0 || !blackout.contains(&step));
931 assert_eq!(a.frame(), b.frame(), "back in step");
932 assert!(a.frame() > 250, "and the round ran on ({})", a.frame());
933 }
934
935 /// The tail is not unbounded either: it holds what a peer could be
936 /// missing, and no more.
937 #[test]
938 fn the_resend_tail_stays_within_two_leads() {
939 let (a, _) = run(200, |_, _| true);
940 let span = resend_span(DEFAULT_DELAY) as usize;
941 assert!(
942 a.recent_commits().len() <= 2 * span,
943 "{}",
944 a.recent_commits().len()
945 );
946 }
947}
948
949#[cfg(test)]
950mod pause_echo_tests {
951 use super::*;
952
953 /// A peer that hears the resume, then a `Pause` echo still in flight
954 /// from before it, must not pause again: peers repeat their pause frame
955 /// every tick, so those echoes always cross the resume, and taking
956 /// them at face value re-paused whoever had just resumed, who repeated
957 /// it back, and the two flapped between paused and playing for good.
958 #[test]
959 fn a_stale_pause_echo_after_the_resume_is_ignored() {
960 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
961 let at = a.request_pause();
962 // Frozen on it, as a peer that heard the pause in time would be.
963 while a.frame() < at {
964 a.commit_local(PlayerAction::None);
965 a.receive(InputMsg {
966 player: 1,
967 frame: a.frame(),
968 action: PlayerAction::None,
969 });
970 assert!(a.advance().is_some());
971 }
972 assert!(a.frozen());
973 // The peer resumes; its last echo of the pause is still on the wire.
974 a.receive_resume(at);
975 assert!(!a.paused());
976 a.receive_pause(at);
977 assert!(!a.paused(), "an echo of a lifted pause is not a pause");
978 // Nor is one from a pause that never reached us until after the
979 // resume was heard: our own frame is already past it.
980 a.receive_pause(at.saturating_sub(1));
981 assert!(!a.paused());
982 // A fresh pause is a fresh pause.
983 let again = a.request_pause();
984 assert!(again > at);
985 assert!(a.paused());
986 }
987
988 /// The same, when the resume comes first and the peer never heard the
989 /// pause at all: the frame the resume names is enough to know the
990 /// echo is stale.
991 #[test]
992 fn a_resume_names_the_pause_it_lifts() {
993 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
994 a.receive_resume(40);
995 assert_eq!(a.lifted_pause(), Some(40));
996 a.receive_pause(40);
997 assert!(!a.paused());
998 a.receive_pause(41);
999 assert!(a.paused(), "a later frame is a new pause");
1000 // Resuming locally lifts through the highest frame known.
1001 assert_eq!(a.resume(), 41);
1002 }
1003
1004 /// A pause proposal that would be read as an echo of a lifted one is
1005 /// pushed past it, so a second Escape shortly after a resume still
1006 /// pauses, everywhere.
1007 #[test]
1008 fn a_new_pause_is_always_past_the_lifted_one() {
1009 let mut a = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1010 a.receive_resume(1000);
1011 let at = a.request_pause();
1012 assert!(at > 1000, "{at}");
1013 assert_eq!(a.pause_frame(), Some(at));
1014 }
1015}
1016
1017#[cfg(test)]
1018mod abandon_frame_tests {
1019 use super::*;
1020
1021 /// Two peers give up on the same seat from the same frame and end up
1022 /// with the same inputs for it, whatever each of them happened to hold:
1023 /// the one that held a later input from the departed player plays it
1024 /// empty like everyone else, rather than as sent.
1025 #[test]
1026 fn peers_holding_different_inputs_agree_after_the_abandonment() {
1027 let mut host = Lockstep::new(0, vec![0, 1, 2], 0);
1028 let mut peer = Lockstep::new(2, vec![0, 1, 2], 0);
1029 let place = PlayerAction::Place {
1030 x: 1,
1031 y: 1,
1032 dir: Direction::Up,
1033 };
1034 // Seat 1's frame 0 never reached the host (nor, therefore, the
1035 // peer), but its frame 2 reached the host, and the relay of it
1036 // reached the peer; the relay of frame 1 was lost on the way.
1037 for frame in [1, 2] {
1038 host.receive(InputMsg {
1039 player: 1,
1040 frame,
1041 action: place,
1042 });
1043 }
1044 peer.receive(InputMsg {
1045 player: 1,
1046 frame: 2,
1047 action: place,
1048 });
1049 // The host is held up on frame 0 and gives up there.
1050 let at = host.frame();
1051 host.abandon(1, at);
1052 peer.abandon(1, at);
1053 for frame in 0..3 {
1054 for session in [&mut host, &mut peer] {
1055 session.commit_local(PlayerAction::None);
1056 let other = if session.seat() == Some(0) { 2 } else { 0 };
1057 session.receive(InputMsg {
1058 player: other,
1059 frame,
1060 action: PlayerAction::None,
1061 });
1062 }
1063 let from_host = host.advance().expect("host moves");
1064 let from_peer = peer.advance().expect("peer moves");
1065 assert_eq!(from_host[1], PlayerAction::None, "frame {frame}");
1066 assert_eq!(from_host, from_peer, "frame {frame}");
1067 }
1068 }
1069
1070 /// Inputs from the departed player that arrive after the abandonment
1071 /// are ignored, however far ahead they name.
1072 #[test]
1073 fn a_departed_seat_is_no_longer_heard() {
1074 let mut session = Lockstep::new(0, vec![0, 1], 0);
1075 session.abandon(1, 0);
1076 session.receive(InputMsg {
1077 player: 1,
1078 frame: 5,
1079 action: PlayerAction::Remove { x: 0, y: 0 },
1080 });
1081 for _ in 0..6 {
1082 session.commit_local(PlayerAction::None);
1083 let actions = session.advance().expect("moves");
1084 assert_eq!(actions[1], PlayerAction::None);
1085 }
1086 }
1087
1088 /// A frame further ahead than any peer in step could have committed
1089 /// makes no slot: a stranger naming frames to the horizon would
1090 /// otherwise grow the table without bound.
1091 #[test]
1092 fn inputs_beyond_the_horizon_are_dropped() {
1093 let mut session = Lockstep::new(0, vec![0, 1], DEFAULT_DELAY);
1094 let before = session.pending.len();
1095 session.receive(InputMsg {
1096 player: 1,
1097 frame: u32::MAX,
1098 action: PlayerAction::None,
1099 });
1100 session.receive(InputMsg {
1101 player: 1,
1102 frame: 1000,
1103 action: PlayerAction::None,
1104 });
1105 assert_eq!(session.pending.len(), before);
1106 session.receive(InputMsg {
1107 player: 1,
1108 frame: 2 * resend_span(DEFAULT_DELAY),
1109 action: PlayerAction::None,
1110 });
1111 assert_eq!(
1112 session.pending.len(),
1113 before + 1,
1114 "the horizon itself is in"
1115 );
1116 }
1117}