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.
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}