Skip to main content

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}