Skip to main content

pinch_points/app/net/
presence.rs

1//! Who is still at the table, and who has gone.
2//!
3//! UDP will not say. What it gives is silence, and the whole of this file
4//! is the difference between a machine that has stopped talking and a
5//! player who is merely behind, because those two look identical from a
6//! held-up frame and only one of them should cost somebody their castle.
7//!
8//! Both halves of that live here: the session's, which decides a seat has
9//! gone, and the shell's, which puts an AI in the chair and tells the
10//! table. They were a file apart, and the second read as an odd guest in
11//! the middle of level loading.
12
13use super::*;
14use crate::app::cycle::Cycle;
15use crate::app::i18n::fill;
16use crate::app::settings::GameSettings;
17use crate::app::side_panels::EventLog;
18use crate::app::{Bots, Paused, RoundNotice, Screen, SeatNames, palette};
19use crate::sim::BotLevel;
20
21/// How long a round waits on a silent player before handing their seat to
22/// an AI. Long enough that a burst of loss cannot be mistaken for somebody
23/// leaving, since the resend tail repeats every tick, and short enough that a
24/// table is not left staring at a still beach.
25const ABANDON_AFTER: f32 = 5.0;
26
27/// How long a joiner waits on a host that has stopped speaking before it
28/// calls the round off.
29///
30/// Nothing can save a round whose host has gone: it alone relays every
31/// input, decides who has left, and calls the next one. So this is not a
32/// patience that buys anything. It is only long enough that a machine
33/// which stumbles (a big alt-tab, a sleeping laptop lid, a hotel wifi
34/// hiccup) is given a fair chance to come back before its friends are sent
35/// home. Four times the patience the host shows an ordinary player, which
36/// is the most a table will sit and stare anyway.
37const HOST_GONE_AFTER: f32 = 20.0;
38
39/// How long the picture has to have been still before the status line
40/// names whoever it is waiting for. Under a second: long enough that the
41/// ordinary rhythm of a three-frame input delay never trips it, short
42/// enough that nobody has time to ask whether the game has crashed.
43const SAY_WAITING_AFTER: f32 = 0.6;
44
45/// How long that line stays up once it has been said, counted only while
46/// the round is moving again, so a wait that keeps coming back holds one
47/// steady line rather than blinking once per stumble.
48///
49/// It has to outlast the gap between two stalls in a round that is merely
50/// limping, and that gap is at most [`SAY_WAITING_AFTER`] of fresh waiting
51/// plus whatever play sits between them. A second and a half covers the
52/// former twice over; longer than that and a round that has genuinely
53/// recovered goes on being talked about.
54const SAY_WAITING_FOR: f32 = 1.5;
55
56/// The line has to be able to survive the gap it is there to cover, or it
57/// is a strobe with extra steps.
58const _: () = assert!(SAY_WAITING_FOR > SAY_WAITING_AFTER);
59
60impl OnlineSession {
61    /// A peer said something, which is all this needs to know: whatever it
62    /// said, it is still there.
63    pub(super) fn mark_heard(&mut self, peer: usize) {
64        while self.peer_silence.len() <= peer {
65            self.peer_silence.push(0.0);
66        }
67        self.peer_silence[peer] = 0.0;
68    }
69
70    /// Age every peer's silence by a frame, taking in any the socket has
71    /// picked up since the last one.
72    pub(super) fn age_the_silence(&mut self, delta: f32) {
73        // As long as the longer of the two lists it sits beside. A peer the
74        // socket has registered has a silence from its first datagram, and
75        // a seat in the launch plan has one whether or not its peer is
76        // still on the socket. A plan entry with no silence to read is a
77        // seat that could never be given up on, and a round that stalls on
78        // a ghost forever.
79        let peers = self.transport.peer_count().max(self.peer_seats.len());
80        while self.peer_silence.len() < peers {
81            self.peer_silence.push(0.0);
82        }
83        for silence in self.peer_silence.iter_mut() {
84            *silence += delta;
85        }
86    }
87
88    /// How long the peer holding `seat` has said nothing at all. `None`
89    /// for a seat no peer holds: the host's own, and the AI's.
90    fn seat_silence(&self, seat: u8) -> Option<f32> {
91        let peer = self
92            .peer_seats
93            .iter()
94            .position(|held| *held == Some(seat))?;
95        self.peer_silence.get(peer).copied()
96    }
97
98    /// Whether the host has gone: a joiner's own verdict on the one peer it
99    /// has, and the only thing it may decide by itself.
100    ///
101    /// Deciding *this* alone is safe where deciding a seat is not. An
102    /// abandoned seat keeps playing under an AI and every peer must agree
103    /// on the frame that happened; a joiner leaving takes nothing with it
104    /// but itself.
105    ///
106    /// A pause makes no difference: the pump runs through one (that is
107    /// what carries the resume), so a host that is there keeps talking
108    /// however still the picture is, and one that has quit under the pause
109    /// card should not leave the table sitting on it for good.
110    pub fn host_gone(&self) -> bool {
111        !self.is_host()
112            && self
113                .peer_silence
114                .first()
115                .is_some_and(|since| *since >= HOST_GONE_AFTER)
116    }
117
118    /// Watch for a player who has stopped sending, and give up on them.
119    ///
120    /// The host alone decides, and says so; a peer that ran its own timer
121    /// would fill the seat on whichever frame its own patience ran out, and
122    /// two peers filling it on different frames is a desync.
123    ///
124    /// Two things have to be true, and the second is the one that matters:
125    /// the round is held up by that seat, *and* the machine holding it has
126    /// not said a word for [`ABANDON_AFTER`]. A held-up frame on its own is
127    /// weak evidence: it is also what a burst of loss looks like, and what
128    /// a peer that is a second behind looks like. A table where the slowest
129    /// laptop loses its castle every few minutes is worse than one that
130    /// waits. Silence is the strong evidence: every peer sends on
131    /// every tick it runs, resending every commit a peer could still be
132    /// missing, so a machine still in the room is a machine still talking.
133    ///
134    /// Returns what was given up on this call, which the tests read. The lasting record is `abandoned`, because a joiner is *told*
135    /// rather than deciding and its seats never pass through here.
136    pub fn abandon_stalled(&mut self, delta: f32, paused: bool) -> Vec<u8> {
137        self.age_the_silence(delta);
138        // A paused round is stalled on purpose, and the pause is agreed:
139        // holding still is what every peer was asked to do.
140        //
141        // Both flags, because they are different pauses and only one of
142        // them is ever set here: `paused` is the shell's ticker, which an
143        // online round deliberately leaves running (see `pause_input`: a
144        // frozen ticker would stop the pump that carries the resume), and
145        // the session's own is the frame the table agreed to stop on. With
146        // only the first, opening the pause card cost every rival their
147        // castle five seconds later.
148        if paused || self.session.paused() {
149            self.stalled_for = 0.0;
150            self.stalled_on = self.session.frame();
151            // And a round holding still on purpose is not waiting on
152            // anybody, so the line goes at once rather than lingering over
153            // the pause card.
154            self.waiting_hold = 0.0;
155            self.waiting_on = None;
156            return Vec::new();
157        }
158        // Never itself. The local slot is empty whenever this peer has not
159        // committed the frame yet, which is an ordinary moment and not a
160        // departure. A host that gave its own castle to an AI would be
161        // playing against itself.
162        let mine = self.session.seat();
163        let waiting: Vec<u8> = self
164            .session
165            .awaiting()
166            .into_iter()
167            .filter(|seat| Some(*seat) != mine)
168            .collect();
169        // The picture moved since this was last looked at, so nothing is
170        // stuck, whatever the next frame's slots look like at this instant.
171        // That is the question that cannot be asked from here.
172        let at = self.session.frame();
173        if at != self.stalled_on || waiting.is_empty() {
174            self.stalled_on = at;
175            self.stalled_for = 0.0;
176            // The line is let down gently. One frame getting through is
177            // not the round recovering: a limping round gets one through
178            // every other tick. The clock only runs while the picture is
179            // actually moving, so a wait that keeps coming back reads as one
180            // steady line rather than a dozen.
181            self.waiting_hold = (self.waiting_hold - delta).max(0.0);
182            if self.waiting_hold == 0.0 {
183                self.waiting_on = None;
184            }
185            return Vec::new();
186        }
187        // Every peer keeps the clock, host or not: a joiner does not decide
188        // anything with it, but it is what puts "waiting for Anna" on a
189        // screen that has otherwise simply stopped moving.
190        self.stalled_for += delta;
191        if self.stalled_for > SAY_WAITING_AFTER {
192            self.waiting_hold = SAY_WAITING_FOR;
193            // The first seat holding the frame up, and never this one: a
194            // player is not told the round is waiting for themselves.
195            self.waiting_on = waiting.first().copied();
196        }
197        if !self.is_host() || self.stalled_for < ABANDON_AFTER {
198            return Vec::new();
199        }
200        let gone: Vec<u8> = waiting
201            .into_iter()
202            .filter(|seat| match self.seat_silence(*seat) {
203                // Held up by them *and* not a word from them.
204                Some(since) => since >= ABANDON_AFTER,
205                // A seat this session cannot point at on the socket: the
206                // direct `PINCH_HOST` pair keeps no launch plan, having
207                // never been through a lobby. Nothing better to go on than
208                // the held-up frame, as it was before.
209                None => true,
210            })
211            .collect();
212        if gone.is_empty() {
213            // Still stuck, but on somebody who is plainly still there. Keep
214            // the clock running rather than resetting it: the moment they
215            // do go quiet, the wait is already served.
216            return Vec::new();
217        }
218        self.stalled_for = 0.0;
219        // Emptied from the frame the round is held up on, and that frame
220        // travels with the word so every peer empties from the same one
221        // (see `Lockstep::abandon`). The notice is repeated every tick from
222        // here on by `pump`, so a lost one costs a tick, not the round.
223        let frame = self.session.frame();
224        for seat in &gone {
225            self.session.abandon(*seat, frame);
226            self.transport
227                .send(NetMsg::Abandoned { seat: *seat, frame });
228            if !self.abandoned.iter().any(|(held, _)| held == seat) {
229                self.abandoned.push((*seat, frame));
230            }
231            self.forget_seat(*seat);
232        }
233        gone
234    }
235
236    /// Drop the peer holding `seat` from the socket as well as from the
237    /// round.
238    ///
239    /// Abandoning only unsticks the play; without this the departed peer is
240    /// still counted, still holds its place in the launch plan, and is
241    /// still dealt a seat in the round after. That round stalls on it for
242    /// five seconds and gives up on the same ghost all over again, every
243    /// round, until somebody goes back to the lobby.
244    fn forget_seat(&mut self, seat: u8) {
245        debug_assert!(usize::from(seat) < MAX_PLAYERS, "no such seat: {seat}");
246        let Some(peer) = self.peer_seats.iter().position(|held| *held == Some(seat)) else {
247            return;
248        };
249        self.transport.forget(peer);
250        self.peer_seats.remove(peer);
251        // Everything indexed by peer shifts together or not at all: a
252        // silence left behind would be read against whoever moved up into
253        // that index, and the next player to fall quiet would be the one
254        // after them.
255        if peer < self.peer_silence.len() {
256            self.peer_silence.remove(peer);
257        }
258        if peer < self.peer_names.len() {
259            self.peer_names.remove(peer);
260        }
261        if peer < self.peer_watch.len() {
262            self.peer_watch.remove(peer);
263        }
264        debug_assert!(
265            self.peer_seats.len() <= self.transport.peer_count(),
266            "the launch plan outlived the peers it names"
267        );
268        debug_assert!(
269            self.peer_silence.len() <= self.transport.peer_count(),
270            "a silence outlived the peer it was kept for"
271        );
272    }
273
274    /// The seat the status line should name, if any.
275    ///
276    /// A lockstep frame runs only when every seat's input is in, so a
277    /// still picture is the ordinary shape of somebody else's trouble.
278    /// This lets the screen say whose, instead of leaving a table
279    /// of people asking each other whether it has crashed.
280    pub fn waiting_on(&self) -> Option<u8> {
281        self.waiting_on.filter(|_| self.waiting_hold > 0.0)
282    }
283}
284
285// --- the shell's half ------------------------------------------------
286
287/// Hand an abandoned seat to the AI, and say so where it cannot be missed.
288///
289/// The sim already fills AI seats deterministically on every peer, so the
290/// round simply carries on with a bot in the chair: no rollback, no
291/// re-agreement, nothing to go out of step. What the shell adds is the
292/// bot's level (the one the table agreed on) and telling the players, who
293/// would otherwise watch a rival turn strange without explanation.
294pub(crate) fn abandon_the_departed(
295    time: Res<Time>,
296    paused: Res<Paused>,
297    settings: Res<GameSettings>,
298    names: Res<SeatNames>,
299    mut online: ResMut<Online>,
300    mut bots: ResMut<Bots>,
301    mut log: ResMut<EventLog>,
302) {
303    let Some(session) = &mut online.0 else {
304        return;
305    };
306    session.abandon_stalled(time.delta_secs(), paused.0);
307    let level = BotLevel::from_index(usize::from(session.terms.bot_level));
308    let tr = settings.tr();
309    // Both roads end in the same list (the host decides, a joiner is told),
310    // so reading it is how this stays one piece of code rather than two.
311    // A seat that already has a bot in it is a seat already announced.
312    for (seat, _) in &session.abandoned {
313        let Some(slot) = bots.0.get_mut(usize::from(*seat)) else {
314            continue;
315        };
316        if slot.is_some() {
317            continue;
318        }
319        *slot = Some(level);
320        log.push(
321            fill(tr.online_seat_abandoned, &[("p", &names.label(tr, *seat))]),
322            palette::player_color(*seat),
323        );
324    }
325}
326
327/// Walk a joiner out of a round whose host has gone, and say why.
328///
329/// Every other kind of departure leaves a round that can go on: a rival's
330/// castle is handed to an AI and the beach plays out. A host's cannot. It
331/// is the hub of the star: it relays every input, decides who has left,
332/// and calls the next round. A table whose host has vanished is not
333/// waiting for anything. Before this, it waited anyway: a still beach,
334/// forever, with no word about why and nothing on screen to suggest that
335/// Escape was the way out.
336pub(crate) fn leave_a_hostless_round(
337    settings: Res<GameSettings>,
338    online: Res<Online>,
339    mut notice: ResMut<RoundNotice>,
340    mut next_screen: ResMut<NextState<Screen>>,
341) {
342    if online.0.as_ref().is_some_and(OnlineSession::host_gone) {
343        // The menu drops the session on its way in, and reads the notice
344        // once it is there.
345        notice.0 = settings.tr().online_host_gone.to_string();
346        next_screen.set(Screen::Menu);
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::app::session::fill_bot_actions;
354    use crate::sim::{DEFAULT_DELAY, PlayerAction, classic_arena};
355    use crate::transport::UdpTransport;
356
357    /// A paused round is a stalled round on purpose, and the host must not
358    /// give up on the table for holding still.
359    ///
360    /// The pause *is* the stall: every peer stops committing at the agreed
361    /// frame, so nobody completes it and `awaiting` names everybody. The
362    /// shell's own `Paused` flag is never set in an online round, because
363    /// the pause card leaves the ticker running on purpose so the network
364    /// pump keeps carrying the resume. So the flag alone said "nothing is
365    /// paused", and five seconds later the host handed every rival's castle
366    /// to an AI and went on simulating a round its peers were no longer part
367    /// of. Pausing for a moment cost you the game.
368    #[test]
369    fn a_paused_round_is_not_an_abandoned_one() {
370        // Delay zero, so frame zero is waiting on both seats from the off:
371        // the state a pause holds the round in.
372        let transport = UdpTransport::host(0).expect("game socket");
373        let mut session = OnlineSession::new(
374            transport,
375            Lockstep::new(0, vec![0, 1], 0),
376            2,
377            MatchTerms::default(),
378        );
379        session.peer_seats = vec![Some(1)];
380
381        // Somebody hit Escape. Every peer stops on one frame, and stays
382        // there for as long as the card is up.
383        session.request_pause();
384        let gone = session.abandon_stalled(ABANDON_AFTER * 3.0, false);
385        assert!(gone.is_empty(), "gave up on {gone:?} for pausing");
386        assert!(session.abandoned.is_empty());
387
388        // Play on, and the patience starts again from zero rather than
389        // firing on the first tick after the resume.
390        session.session.resume();
391        let gone = session.abandon_stalled(ABANDON_AFTER - 0.1, false);
392        assert!(gone.is_empty(), "the timer restarts with the round");
393        let gone = session.abandon_stalled(0.2, false);
394        assert_eq!(gone, vec![1], "and a seat that really is silent still goes");
395    }
396
397    /// A peer that is still talking keeps its castle, however far behind
398    /// its inputs are.
399    ///
400    /// A held-up frame is weak evidence: it is equally what a burst of loss
401    /// looks like, and what a laptop half a second behind looks like. The
402    /// strong evidence is silence on the socket, since every peer sends on
403    /// every tick it runs. The slowest machine at the table losing its
404    /// castle to an AI every few minutes is the failure that makes people
405    /// stop playing.
406    #[test]
407    fn a_peer_that_is_still_talking_keeps_its_castle() {
408        let transport = UdpTransport::host(0).expect("game socket");
409        let mut session = OnlineSession::new(
410            transport,
411            Lockstep::new(0, vec![0, 1], 0),
412            2,
413            MatchTerms::default(),
414        );
415        session.peer_seats = vec![Some(1)];
416
417        // Held up on seat 1 for four times the patience, a frame at a time,
418        // but its machine keeps talking through every one of them.
419        let frame = 0.1;
420        for _ in 0..(ABANDON_AFTER * 4.0 / frame) as usize {
421            session.mark_heard(0);
422            assert!(
423                session.abandon_stalled(frame, false).is_empty(),
424                "gave up on somebody who is right there"
425            );
426        }
427        assert!(session.abandoned.is_empty());
428
429        // And then the machine goes quiet, which is how leaving looks from
430        // out here. The frame was already overdue, so the seat goes as
431        // soon as the silence adds up.
432        let mut gone = Vec::new();
433        for _ in 0..(ABANDON_AFTER * 2.0 / frame) as usize {
434            gone = session.abandon_stalled(frame, false);
435            if !gone.is_empty() {
436                break;
437            }
438        }
439        assert_eq!(gone, vec![1], "a seat that really is silent still goes");
440        assert_eq!(session.abandoned.len(), 1);
441        assert_eq!(session.abandoned[0].0, 1);
442    }
443
444    /// A joiner whose host has gone quiet calls the round off itself.
445    ///
446    /// Nothing else can: the host relays every input, decides who has left,
447    /// and calls the next round, so a table whose host has vanished is not
448    /// waiting for anything. Deciding this alone is safe where deciding a
449    /// seat is not: leaving takes nothing with it but yourself.
450    #[test]
451    fn a_joiner_gives_up_on_a_host_that_has_gone() {
452        let transport = UdpTransport::host(0).expect("game socket");
453        let mut joiner = OnlineSession::new(
454            transport,
455            Lockstep::new(1, vec![0, 1], 0),
456            2,
457            MatchTerms::default(),
458        );
459        assert!(!joiner.host_gone(), "nothing has had time to go wrong");
460
461        joiner.mark_heard(0);
462        joiner.abandon_stalled(HOST_GONE_AFTER - 1.0, false);
463        assert!(!joiner.host_gone(), "still within a fair stumble");
464        joiner.abandon_stalled(2.0, false);
465        assert!(joiner.host_gone(), "and then the host is gone");
466
467        // A host never decides this about itself, however quiet the table.
468        let mut host = OnlineSession::new(
469            UdpTransport::host(0).expect("game socket"),
470            Lockstep::new(0, vec![0, 1], 0),
471            2,
472            MatchTerms::default(),
473        );
474        host.peer_seats = vec![Some(1)];
475        host.abandon_stalled(HOST_GONE_AFTER * 2.0, false);
476        assert!(!host.host_gone(), "the host is the host");
477    }
478
479    /// A table reading the scores together is not a table whose host has
480    /// gone, however quiet it is.
481    ///
482    /// The trap under this fix, and the worse bug of the two. Between
483    /// rounds an established session says nothing at all: inputs and hashes
484    /// belong to the round that ended, and the host has nothing to send
485    /// until somebody calls the next one. So the moment silence is read as
486    /// evidence there, every joiner walks out of a perfectly good table
487    /// twenty seconds into a results card.
488    ///
489    /// The greeting is what makes the silence mean something. Over real
490    /// sockets, because the point is that a reply actually comes back.
491    #[test]
492    fn a_quiet_results_card_is_not_a_host_that_has_gone() {
493        let mut host = OnlineSession::new(
494            UdpTransport::host(0).expect("game socket"),
495            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
496            2,
497            MatchTerms::default(),
498        );
499        let port = host.transport.local_addr().expect("addr").port();
500        let mut joiner = OnlineSession::new(
501            UdpTransport::join(("127.0.0.1", port)).expect("join"),
502            Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
503            2,
504            MatchTerms::default(),
505        );
506
507        // Twice the patience, spent sitting on the card. Nobody presses
508        // anything; the only traffic is the greeting and its answer.
509        let step = 0.5;
510        for _ in 0..(HOST_GONE_AFTER * 2.0 / step) as usize {
511            joiner.poll_between_rounds(step);
512            std::thread::sleep(std::time::Duration::from_millis(2));
513            host.poll_between_rounds(step);
514            std::thread::sleep(std::time::Duration::from_millis(2));
515            joiner.poll_between_rounds(0.0);
516            assert!(
517                !joiner.host_gone(),
518                "walked out on a host that is answering"
519            );
520        }
521        assert_eq!(host.transport.peer_count(), 1, "and the host heard it");
522
523        // Now the host stops answering, which is the case this is all for.
524        drop(host);
525        for _ in 0..(HOST_GONE_AFTER * 2.0 / step) as usize {
526            joiner.poll_between_rounds(step);
527        }
528        assert!(joiner.host_gone(), "and a card over a dead host says so");
529    }
530
531    /// A round that is running says nothing at all, and gives nobody's
532    /// castle away.
533    ///
534    /// This is the bug under the reported one. A lockstep sim advances
535    /// until it *cannot*, and it does that on the fixed step; the tick that
536    /// watches for stalls runs afterwards, in `Update`. So "are the next
537    /// frame's inputs in?" is asked at the one moment in the frame where
538    /// the answer is always no, and every healthy online round was read as
539    /// permanently stalled: both screens said "waiting for Bob" through a
540    /// match that was running perfectly, and the host was five seconds from
541    /// handing every rival's seat to an AI at all times, held back by
542    /// nothing but the socket-silence rule.
543    ///
544    /// The picture moving is the only thing either reader wanted to know,
545    /// and it is not a matter of timing within a frame.
546    #[test]
547    fn a_round_that_is_running_says_nothing() {
548        use crate::sim::PlayerAction;
549
550        let transport = UdpTransport::host(0).expect("game socket");
551        let mut session = OnlineSession::new(
552            transport,
553            Lockstep::new(0, vec![0, 1], 0),
554            2,
555            MatchTerms::default(),
556        );
557        session.peer_seats = vec![Some(1)];
558        let frame = 1.0 / 60.0;
559
560        // Ten seconds of an ordinary round: every input arrives, every
561        // frame runs. Twice the patience the host shows a silent player.
562        for _ in 0..(10.0 / frame) as usize {
563            let at = session.session.frame();
564            session.session.commit_local(PlayerAction::None);
565            session.session.receive(crate::sim::InputMsg {
566                player: 1,
567                frame: at,
568                action: PlayerAction::None,
569            });
570            assert!(session.session.advance().is_some(), "the frame ran");
571            session.mark_heard(0);
572            let gone = session.abandon_stalled(frame, false);
573            assert!(gone.is_empty(), "gave {gone:?} away mid-round");
574            assert_eq!(
575                session.waiting_on(),
576                None,
577                "said so about a round that is running"
578            );
579        }
580        assert!(session.abandoned.is_empty());
581    }
582
583    /// A round that keeps stopping and starting says so once, not once per
584    /// stumble.
585    ///
586    /// The bug this was reported as: "constant flickering in the corner".
587    /// The line was read straight off the stall clock, and that clock snaps
588    /// back to zero the instant one frame gets through, and a limping round
589    /// does that over and over. So the line came up, a
590    /// frame landed, it went, the wait built back past the threshold, it
591    /// came up again: a strobe in the corner of the eye of somebody trying
592    /// to play.
593    #[test]
594    fn a_round_that_keeps_stumbling_says_so_once() {
595        use crate::sim::PlayerAction;
596
597        let transport = UdpTransport::host(0).expect("game socket");
598        let mut session = OnlineSession::new(
599            transport,
600            Lockstep::new(0, vec![0, 1], 0),
601            2,
602            MatchTerms::default(),
603        );
604        session.peer_seats = vec![Some(1)];
605        let frame = 1.0 / 60.0;
606
607        /// A frame of a round that is moving, in the order the app runs it:
608        /// the sim advances on the fixed step, as far as it can, and only
609        /// then does the tick that watches for stalls get a look. It
610        /// therefore always finds the *next* frame's slots empty. That is
611        /// why this cannot be judged by looking at them.
612        fn moving(session: &mut OnlineSession, delta: f32) {
613            let at = session.session.frame();
614            session.session.commit_local(PlayerAction::None);
615            session.session.receive(crate::sim::InputMsg {
616                player: 1,
617                frame: at,
618                action: PlayerAction::None,
619            });
620            assert!(session.session.advance().is_some(), "the frame went");
621            assert!(
622                !session.session.awaiting().is_empty(),
623                "a healthy round looks stalled from here, every single time"
624            );
625            session.mark_heard(0);
626            session.abandon_stalled(delta, false);
627        }
628
629        /// A frame of a round that is not: seat 1's input has not arrived,
630        /// so nothing runs.
631        fn stalling(session: &mut OnlineSession, delta: f32) {
632            session.mark_heard(0);
633            session.abandon_stalled(delta, false);
634        }
635
636        // Seat 1 is late. Nothing is said at first: a moment's wait is the
637        // ordinary rhythm of a delayed lockstep, not news.
638        stalling(&mut session, frame);
639        assert_eq!(session.waiting_on(), None, "not for an ordinary moment");
640        for _ in 0..(SAY_WAITING_AFTER / frame) as usize + 1 {
641            stalling(&mut session, frame);
642        }
643        assert_eq!(session.waiting_on(), Some(1), "and then it says whose");
644
645        // The round limps: a stretch of play, a stall, a stretch of play.
646        // Every one of those stretches used to blink the line out and every
647        // stall used to bring it back, which is the strobe as reported.
648        for _ in 0..20 {
649            for _ in 0..(0.3 / frame) as usize {
650                moving(&mut session, frame);
651                assert_eq!(session.waiting_on(), Some(1), "the line blinked out");
652            }
653            for _ in 0..(SAY_WAITING_AFTER / frame) as usize + 1 {
654                stalling(&mut session, frame);
655                assert_eq!(session.waiting_on(), Some(1), "nor back on again");
656            }
657        }
658
659        // A round that really does recover drops the line after a beat, not
660        // on the first frame through. The same rule, read the other way
661        // round.
662        moving(&mut session, frame);
663        assert_eq!(
664            session.waiting_on(),
665            Some(1),
666            "gone on the very first frame is the strobe again"
667        );
668        for _ in 0..(SAY_WAITING_FOR / frame) as usize + 6 {
669            moving(&mut session, frame);
670        }
671        assert_eq!(
672            session.waiting_on(),
673            None,
674            "and a healthy round says nothing"
675        );
676    }
677
678    /// Play until the round can go no further. A fresh session is not
679    /// stalled: the delay window is pre-filled, so the first few frames
680    /// simulate whether anyone has sent anything or not, and only after
681    /// them does a silent seat start holding the table up.
682    fn run_into_the_stall(session: &mut OnlineSession) {
683        for _ in 0..40 {
684            session.pump(
685                PlayerAction::None,
686                |net| {
687                    while net.session.advance().is_some() {}
688                },
689            );
690        }
691        assert!(
692            !session.session.awaiting().is_empty(),
693            "expected the round to be held up by somebody"
694        );
695        // And one look at the round while no time passes, so the stall clock
696        // knows which frame it stopped on. The real caller looks sixty times
697        // a second; a test that leaps five seconds in a single call would
698        // otherwise be timing a frame the round had only just arrived at.
699        session.abandon_stalled(0.0, false);
700    }
701
702    fn hosting(players: Vec<u8>) -> OnlineSession {
703        OnlineSession::new(
704            UdpTransport::host(0).expect("socket"),
705            Lockstep::new(0, players, DEFAULT_DELAY),
706            2,
707            MatchTerms::default(),
708        )
709    }
710
711    /// The measured behaviour that started this: a player walks away and
712    /// the round advances three frames (the input-delay window) and then
713    /// stops dead, forever, with nothing said. It has to come back.
714    #[test]
715    fn a_round_left_hanging_starts_again_without_the_player() {
716        let mut session = hosting(vec![0, 1]);
717        let mut frames = 0;
718        let step = |session: &mut OnlineSession, frames: &mut u32| {
719            session.pump(PlayerAction::None, |net| {
720                while net.session.advance().is_some() {
721                    *frames += 1;
722                }
723            });
724        };
725        // Seat 1 never sends anything at all.
726        for _ in 0..40 {
727            step(&mut session, &mut frames);
728        }
729        let stuck_at = frames;
730        assert!(
731            stuck_at <= DEFAULT_DELAY,
732            "it got no further than the delay"
733        );
734
735        // Patience runs out only after the wait, not before: a burst of
736        // lost packets must not be mistaken for somebody leaving.
737        assert!(
738            session
739                .abandon_stalled(ABANDON_AFTER / 2.0, false)
740                .is_empty()
741        );
742        assert_eq!(frames, stuck_at, "and nothing has moved yet");
743
744        let given_up = session.abandon_stalled(ABANDON_AFTER, false);
745        assert_eq!(given_up, vec![1], "the seat that went quiet");
746        for _ in 0..40 {
747            step(&mut session, &mut frames);
748        }
749        assert!(
750            frames > stuck_at,
751            "the round is still frozen at frame {stuck_at}"
752        );
753    }
754
755    /// A paused round is stalled on purpose and by agreement. Giving up on
756    /// everybody who is politely waiting would be a rout.
757    #[test]
758    fn a_pause_is_not_a_departure() {
759        let mut session = hosting(vec![0, 1]);
760        run_into_the_stall(&mut session);
761        for _ in 0..20 {
762            assert!(session.abandon_stalled(ABANDON_AFTER, true).is_empty());
763        }
764    }
765
766    /// Only the host decides. A joiner that ran its own timer would fill
767    /// the seat on whichever frame its own patience ran out, and two peers
768    /// filling it on different frames is a desync, which lockstep cannot
769    /// recover from.
770    #[test]
771    fn a_joiner_never_decides_for_itself() {
772        let mut joiner = OnlineSession::new(
773            UdpTransport::host(0).expect("socket"),
774            Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
775            2,
776            MatchTerms::default(),
777        );
778        assert!(!joiner.is_host());
779        run_into_the_stall(&mut joiner);
780        for _ in 0..20 {
781            assert!(joiner.abandon_stalled(ABANDON_AFTER, false).is_empty());
782        }
783        assert!(joiner.abandoned.is_empty(), "and nothing was given up");
784    }
785
786    /// Abandoning has to empty the chair for good. Unsticking the play is
787    /// only half of it: a peer that is still counted still holds its place
788    /// in the launch plan, is dealt a seat in the round after, and stalls
789    /// that one too: five seconds of nothing at the start of every round
790    /// from then on, for a player who left once.
791    #[test]
792    fn an_abandoned_player_does_not_haunt_the_next_round() {
793        let mut host = hosting(vec![0, 1]);
794        let port = host.transport.local_addr().expect("addr").port();
795        let joiner = UdpTransport::join(("127.0.0.1", port)).expect("join");
796        joiner.send(NetMsg::hello("Bo"));
797        for _ in 0..40 {
798            std::thread::sleep(std::time::Duration::from_millis(5));
799            host.poll_between_rounds(0.0);
800            if host.transport.peer_count() == 1 {
801                break;
802            }
803        }
804        assert_eq!(host.transport.peer_count(), 1);
805        host.peer_seats = vec![Some(1)];
806        drop(joiner);
807
808        run_into_the_stall(&mut host);
809        assert_eq!(host.abandon_stalled(ABANDON_AFTER * 2.0, false), vec![1]);
810        assert_eq!(host.transport.peer_count(), 0, "and gone from the socket");
811        assert!(host.peer_seats.is_empty(), "and from the launch plan");
812
813        // The round after is the host's alone, and waits on nobody. It is
814        // still a two-castle beach: a host left by itself plays the AI
815        // rather than plays itself.
816        host.call_next_round(
817            MatchTerms {
818                seed: 42,
819                ..MatchTerms::default()
820            },
821            0,
822            [0; MAX_PLAYERS],
823        );
824        assert_eq!(host.session.player_count(), 1, "still waiting on a ghost");
825        assert_eq!(host.seats, 2, "a beach needs two castles");
826        assert_eq!(host.terms.bots, 1, "and somebody in the other one");
827    }
828
829    /// A host never gives up on itself. Its own slot is empty whenever it
830    /// has not committed this frame yet, which is an ordinary moment. A
831    /// host that handed its own castle to an AI would be sitting there
832    /// watching a bot play its round.
833    #[test]
834    fn a_host_never_abandons_its_own_castle() {
835        let mut session = hosting(vec![0]);
836        // Alone at the table, and the only slot ever outstanding is its own.
837        for _ in 0..40 {
838            session.pump(
839                PlayerAction::None,
840                |net| {
841                    while net.session.advance().is_some() {}
842                },
843            );
844        }
845        for _ in 0..20 {
846            assert!(
847                session
848                    .abandon_stalled(ABANDON_AFTER * 2.0, false)
849                    .is_empty()
850            );
851        }
852        assert!(session.abandoned.is_empty());
853    }
854
855    /// Giving up is remembered, so the seat is filled and announced once
856    /// rather than every frame the round keeps running.
857    #[test]
858    fn a_seat_is_given_up_once() {
859        let mut session = hosting(vec![0, 1]);
860        run_into_the_stall(&mut session);
861        assert_eq!(session.abandon_stalled(ABANDON_AFTER * 2.0, false), vec![1]);
862        assert_eq!(session.abandoned.len(), 1);
863        assert_eq!(session.abandoned[0].0, 1);
864        // The round moves now, so there is nothing left to be waited on.
865        for _ in 0..10 {
866            assert!(
867                session
868                    .abandon_stalled(ABANDON_AFTER * 2.0, false)
869                    .is_empty()
870            );
871        }
872        assert_eq!(session.abandoned.len(), 1, "still just the one");
873    }
874
875    /// The whole thing, driven by the system that really runs it: a player
876    /// stops sending, the round comes back, an AI is holding their castle,
877    /// and the feed says so. Built as the app builds it, because the parts
878    /// were each right the last time something like this broke and it was
879    /// the wiring between them that was not.
880    #[test]
881    fn a_departed_player_is_replaced_by_an_ai_and_the_feed_says_so() {
882        let mut app = App::new();
883        app.insert_resource(Time::<()>::default());
884        app.insert_resource(Paused(false));
885        app.insert_resource(GameSettings::default());
886        app.init_resource::<SeatNames>();
887        app.init_resource::<Bots>();
888        app.init_resource::<EventLog>();
889
890        let mut session = OnlineSession::new(
891            UdpTransport::host(0).expect("socket"),
892            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
893            2,
894            MatchTerms::default(),
895        );
896        // Run the round into the stall the missing player causes.
897        for _ in 0..40 {
898            session.pump(crate::sim::PlayerAction::None, |net| {
899                while net.session.advance().is_some() {}
900            });
901        }
902        app.insert_resource(Online(Some(session)));
903        app.add_systems(Update, abandon_the_departed);
904
905        // Not yet: a burst of loss is not somebody leaving.
906        app.world_mut()
907            .resource_mut::<Time>()
908            .advance_by(std::time::Duration::from_millis(500));
909        app.update();
910        assert_eq!(
911            app.world().resource::<Bots>().0[1],
912            None,
913            "gave up on them after half a second"
914        );
915        assert!(app.world().resource::<EventLog>().0.is_empty());
916
917        // And then it is.
918        app.world_mut()
919            .resource_mut::<Time>()
920            .advance_by(std::time::Duration::from_secs(6));
921        app.update();
922        assert!(
923            app.world().resource::<Bots>().0[1].is_some(),
924            "the empty castle has nobody in it"
925        );
926        let log = app.world().resource::<EventLog>();
927        assert_eq!(log.0.len(), 1, "the players are told, once");
928
929        // The round really does move again, with the AI supplying the seat.
930        let mut board = classic_arena(false, 2);
931        let bots = Bots(app.world().resource::<Bots>().0);
932        let bots = &bots;
933        let mut online = app.world_mut().resource_mut::<Online>();
934        let session = online.0.as_mut().expect("a session");
935        let mut moved = 0;
936        for _ in 0..40 {
937            session.pump(crate::sim::PlayerAction::None, |net| {
938                while let Some(mut actions) = net.session.advance() {
939                    fill_bot_actions(&board, bots, &mut actions);
940                    board.tick(&actions);
941                    moved += 1;
942                }
943            });
944        }
945        assert!(moved > 0, "the beach is still frozen");
946
947        // Said once and not once a frame, however long it plays on.
948        app.update();
949        assert_eq!(app.world().resource::<EventLog>().0.len(), 1);
950    }
951}