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}