Skip to main content

pinch_points/app/net/
rounds.rs

1//! The round after this one: who is still owed a seat, who has been
2//! waiting in line for one, and the terms everybody agrees to play the
3//! next board on.
4//!
5//! All of it is the host's business. A joiner keeps no launch plan and
6//! answers nobody, since the star's spokes cannot hear each other, so
7//! every decision here is made once, at the hub, and sent out.
8
9use super::*;
10
11/// What a host's `Start` invites this peer to: the size of the table, the
12/// chair (or none, for a watcher), the terms, who else is at it, where
13/// the series stands, and the host's own beach when it sent one.
14///
15/// Named rather than left the tuple it was, because a tuple is read by
16/// counting: `(seats, seat, ..)` is a count and an index next to each
17/// other, and the only thing keeping them apart was their order. Taken up
18/// by [`OnlineSession::take_up`] whether it arrives in the lobby, mid-round
19/// or on the results card, so there is one way to begin a round.
20pub struct Invitation {
21    pub seats: u8,
22    pub seat: Option<u8>,
23    pub terms: MatchTerms,
24    pub names: [crate::transport::WireName; MAX_PLAYERS],
25    /// Where the series stands, as the host says. A peer that greeted
26    /// mid-series is seated with the table's own tally rather than
27    /// starting a fresh one. `None` for a single round.
28    pub standing: Option<SeriesStanding>,
29    /// The host's own beach, when it sent one. A joiner has never seen the
30    /// file it came from, so it arrives with the invitation or not at all.
31    pub beach: Vec<u8>,
32}
33
34/// The lockstep a peer plays a round over: the `humans` low seats, from
35/// the given chair, or from the rail for a watcher.
36pub(crate) fn lockstep_for(seat: Option<u8>, humans: u8) -> Lockstep {
37    let players: Vec<u8> = (0..humans).collect();
38    match seat {
39        None => Lockstep::observer(players, crate::sim::DEFAULT_DELAY),
40        Some(seat) => Lockstep::new(seat, players, crate::sim::DEFAULT_DELAY),
41    }
42}
43
44impl OnlineSession {
45    /// A session formed on a host's invitation, as a joiner's is from the
46    /// lobby: the socket it greeted over, and everything the `Start` said.
47    pub fn invited(transport: UdpTransport, invitation: Invitation) -> OnlineSession {
48        let Invitation {
49            seats, seat, terms, ..
50        } = &invitation;
51        let mut session = OnlineSession::new(
52            transport,
53            lockstep_for(*seat, terms.humans(*seats)),
54            *seats,
55            *terms,
56        );
57        session.take_up(invitation);
58        session
59    }
60
61    /// Take up an invitation: the host's beach, the table's names, and a
62    /// round begun afresh on its terms (see [`Self::begin_round`] for
63    /// everything that resets). Does not arm `next_round`, which is the
64    /// caller's to say: a session formed on its first invitation is
65    /// walking into the arena, not back into it.
66    pub fn take_up(&mut self, invitation: Invitation) {
67        let Invitation {
68            seats,
69            seat,
70            terms,
71            names,
72            standing,
73            beach,
74        } = invitation;
75        self.beach = beach;
76        self.begin_round(seats, seat, terms, table_from_wire(&names));
77        self.series_standing = standing;
78    }
79
80    /// The answer for a peer the socket picked up *after* the launch, or
81    /// `None` for one that was at the table when the round began.
82    ///
83    /// Membership is the launch plan, not the seat: a peer seated as a
84    /// spectator in the lobby is `Watching` in the plan, while a stranger
85    /// who greeted mid-round is `Queued`. Both answer `None` to `seat_of`,
86    /// so the plan is the only thing that tells them apart. Admitting the
87    /// second as a spectator was how a latecomer used to end up staring at
88    /// frame zero forever.
89    pub(super) fn queue_place(&self, peer: usize) -> Option<NetMsg> {
90        debug_assert!(
91            self.is_host() || self.peers.planned() == 0,
92            "a joiner keeps no launch plan and must never answer with one"
93        );
94        let seated = self.peers.planned();
95        // Peers are registered in arrival order, so everyone between the
96        // plan and this one is already waiting.
97        let ahead = peer.checked_sub(seated)?;
98        Some(NetMsg::Queued {
99            ahead: ahead.min(u8::MAX as usize) as u8,
100        })
101    }
102
103    /// Drain the socket between rounds, when the sim is stopped and `pump`
104    /// is therefore not running either.
105    ///
106    /// The results card is when people turn up wanting in, and when the
107    /// host decides there will be another round, so the two messages that
108    /// matter here are a greeting to queue and the invitation that answers
109    /// it. Inputs and hashes belong to the round that just ended, and are
110    /// dropped with it.
111    ///
112    /// A joiner also keeps greeting, which is not for the host's benefit:
113    /// the answer is what proves the host is still there. Nothing else is
114    /// sent between rounds, so without it the silence clock would grow on
115    /// a table where everybody is present and simply reading the scores,
116    /// and [`Self::host_gone`] would call the round off under them.
117    pub fn poll_between_rounds(&mut self, delta: f32) {
118        let host = self.is_host();
119        self.age_the_silence(delta);
120        if !host && crate::app::lobby::once_a_second(&mut self.home.greet_in, delta) {
121            // The same greeting the lobby sends, wish to watch included: a
122            // watcher on the results card used to greet as a nameless
123            // player, which the host reads the same way between rounds
124            // (a watcher's chair is in the launch plan already) but is
125            // not what the peer means.
126            let me = self
127                .session
128                .seat()
129                .map_or("", |seat| &self.names[usize::from(seat)]);
130            self.transport
131                .send(crate::app::lobby::greeting(self.watching(), me));
132        }
133        for (msg, from) in self.transport.recv_all() {
134            self.mark_heard(from);
135            match msg {
136                NetMsg::Hello { name } => {
137                    if host {
138                        let answer = self.answer_greeting(from, &name_from_wire(&name), false);
139                        self.transport.send_to(from, answer);
140                    }
141                }
142                NetMsg::Watch => {
143                    if host {
144                        let answer = self.answer_greeting(from, "", true);
145                        self.transport.send_to(from, answer);
146                    }
147                }
148                NetMsg::Start {
149                    seats,
150                    seat,
151                    terms,
152                    names,
153                    standing,
154                    beach,
155                } => {
156                    if !host && self.is_next_round(&terms) {
157                        self.take_up(Invitation {
158                            seats,
159                            seat,
160                            terms,
161                            names,
162                            standing,
163                            beach,
164                        });
165                        self.next_round = true;
166                    }
167                }
168                NetMsg::Input(_)
169                | NetMsg::Hash { .. }
170                | NetMsg::Pause { .. }
171                | NetMsg::Resume { .. }
172                | NetMsg::Queued { .. }
173                | NetMsg::Chat { .. }
174                | NetMsg::Roster { .. }
175                | NetMsg::Abandoned { .. }
176                | NetMsg::Incompatible { .. } => {}
177            }
178        }
179        // After the socket is drained, so a greeting that arrived in this
180        // very poll counts as a word before the silence is judged.
181        if host {
182            self.forget_the_silent();
183        }
184    }
185
186    /// Host: the table for the next round, admitting whoever queued while
187    /// this one played.
188    ///
189    /// Seats are handed out in peer order as they were at the launch, so
190    /// those still here keep theirs, and the queue fills what is left,
191    /// pushing the AI back a seat at a time. Returns the new plan, one
192    /// entry per peer, `None` for a peer that watches or that the table
193    /// could not fit.
194    fn next_plan(&self, peers: usize) -> Vec<Option<u8>> {
195        let mut next = 1u8; // the host keeps seat 0
196        (0..peers)
197            .map(|peer| {
198                // A watcher, whether it sat out the launch (at the rail in
199                // the plan) or queued mid-round with W armed (a remembered
200                // wish), keeps no chair.
201                let watches = self.peers.get(peer).is_some_and(Peer::watches);
202                if watches || usize::from(next) >= MAX_PLAYERS {
203                    return None;
204                }
205                let seat = next;
206                next += 1;
207                Some(seat)
208            })
209            .collect()
210    }
211
212    /// Host: what a greeting earns its sender, with what it said kept.
213    ///
214    /// One answer whether the round is running (`pump`) or the table is on
215    /// the results card (`poll_between_rounds`): a peer in line is told
216    /// so, and one in the plan is told its place again, in case the Start
217    /// went astray. The name and the wish to watch are written down either
218    /// way, because they are what the next round's plan is dealt from. The
219    /// card path once answered without writing either, so a player who
220    /// turned up while the scores were being read was seated nameless
221    /// next round, and a would-be watcher was dealt a chair.
222    pub(super) fn answer_greeting(&mut self, from: usize, told: &str, watch: bool) -> NetMsg {
223        debug_assert!(self.is_host(), "only the host answers greetings");
224        if !told.is_empty() {
225            self.remember_peer_name(from, told);
226        }
227        if watch {
228            self.note_watch_wish(from);
229        }
230        if let Some(queued) = self.queue_place(from) {
231            return queued;
232        }
233        // A watcher is told it is watching, so the seat it gets is not one.
234        let seat = if watch { None } else { self.seat_of(from) };
235        if let Some(slot) = seat.and_then(|seat| self.names.get_mut(usize::from(seat)))
236            && !told.is_empty()
237        {
238            *slot = told.to_string();
239        }
240        self.start_msg(seat)
241    }
242
243    /// Host: write down a peer's name against its socket index, growing the
244    /// row to reach it. Kept for peers the seat table does not cover yet.
245    pub(super) fn remember_peer_name(&mut self, peer: usize, name: &str) {
246        self.peers.row(peer).name = name.to_string();
247    }
248
249    /// Host: note that a peer asked to watch rather than play.
250    pub(super) fn note_watch_wish(&mut self, peer: usize) {
251        self.peers.row(peer).watch = true;
252    }
253
254    /// The name a peer index goes by, from its greeting: its seat's name if
255    /// it holds one, else what it greeted with while queued.
256    pub(super) fn peer_name(&self, peer: usize) -> Option<&str> {
257        if let Some(seat) = self.peers.seat_of(peer)
258            && let Some(name) = self.names.get(usize::from(seat))
259            && !name.is_empty()
260        {
261            return Some(name);
262        }
263        self.peers
264            .get(peer)
265            .map(|peer| peer.name.as_str())
266            .filter(|name| !name.is_empty())
267    }
268
269    /// Host: call the next round on `terms`, admitting the queue, and tell
270    /// every peer which seat it now holds. Arms this session too, so host
271    /// and joiners take the same path back into the arena.
272    ///
273    /// `standing` is the series as it is now, by *this* round's seats, or
274    /// `None` outside a series; the return value is the same standing
275    /// re-dealt to the seats the next round hands out, which the caller
276    /// folds back into its `Tournament`. Seats are re-dealt in peer order
277    /// every round (they have to stay the contiguous `0..humans` the sim
278    /// fills the top of with AI), so a peer that leaves shifts everyone
279    /// behind it up a chair; carrying the wins across by hand is what
280    /// keeps a survivor's rounds its own rather than the next player's.
281    pub fn call_next_round(
282        &mut self,
283        mut terms: MatchTerms,
284        standing: Option<SeriesStanding>,
285    ) -> Option<SeriesStanding> {
286        let peers = self.transport.peer_count();
287        let plan = self.next_plan(peers);
288        let standing = standing.map(|SeriesStanding { round, wins }| {
289            // The wins follow the chairs: seat 0 is always the host's, and
290            // each peer's new seat inherits what its old seat had won.
291            let mut new_wins = [0u8; MAX_PLAYERS];
292            new_wins[0] = wins[0];
293            for (peer, slot) in plan.iter().enumerate() {
294                if let Some(new_seat) = slot
295                    && let Some(old_seat) = self.peers.seat_of(peer)
296                {
297                    new_wins[usize::from(*new_seat)] = wins[usize::from(old_seat)];
298                }
299            }
300            // The number of the round about to begin: the caller passes
301            // the one just played, and every peer shows the same next
302            // number without each counting for itself.
303            SeriesStanding {
304                round: round.saturating_add(1),
305                wins: new_wins,
306            }
307        });
308        let humans = 1 + plan.iter().flatten().count() as u8;
309        // A beach needs two castles, and a host whose table has emptied is
310        // still entitled to another round, against the AI, since playing
311        // itself is not a round. The same floor `seat_count` keeps.
312        terms.bots = terms
313            .bots
314            .min(MAX_PLAYERS as u8 - humans)
315            .max(2u8.saturating_sub(humans));
316        let seats = humans + terms.bots;
317        // Names travel with the invitation, as they did at the launch: a
318        // player admitted from the queue is a stranger to every other
319        // screen until this says otherwise.
320        let mut names: [String; MAX_PLAYERS] = Default::default();
321        names[0].clone_from(&self.names[0]);
322        for (peer, slot) in plan.iter().enumerate() {
323            if let Some(seat) = slot
324                && let Some(name) = self.peer_name(peer)
325            {
326                names[usize::from(*seat)] = name.to_string();
327            }
328        }
329        let wire = wire_table(&names);
330        for (peer, slot) in plan.iter().enumerate() {
331            self.transport.send_to(
332                peer,
333                NetMsg::Start {
334                    seats,
335                    seat: *slot,
336                    terms,
337                    names: wire,
338                    standing,
339                    beach: self.beach.clone(),
340                },
341            );
342        }
343        self.peers.deal(&plan);
344        self.begin_round(seats, Some(0), terms, names);
345        self.series_standing = standing;
346        self.next_round = true;
347        standing
348    }
349
350    /// Take up the terms of a new round: a fresh board, a lockstep back at
351    /// frame zero, and whatever the table is called now.
352    ///
353    /// The seed is what marks this a *new* round rather than the stale
354    /// `Start` a host re-answers stray greetings with, so a caller that
355    /// does not change it will find nothing happens.
356    #[cfg_attr(debug_assertions, track_caller)]
357    pub fn begin_round(
358        &mut self,
359        seats: u8,
360        seat: Option<u8>,
361        terms: MatchTerms,
362        names: [String; MAX_PLAYERS],
363    ) {
364        debug_assert!(
365            (2..=MAX_PLAYERS as u8).contains(&seats),
366            "a round for {seats} seats, which no per-seat array can hold"
367        );
368        debug_assert!(
369            seat.is_none_or(|seat| seat < seats),
370            "seated at {seat:?} of {seats}"
371        );
372        self.session = lockstep_for(seat, terms.humans(seats));
373        self.seats = seats;
374        self.terms = terms;
375        self.names = names;
376        self.hashes.reset();
377        self.resume_echo = 0;
378        // And nobody is late for a round that has not begun. The results
379        // card is a place a table sits for a while, and carrying that
380        // silence into the new round would call the host gone on its first
381        // frame.
382        self.stall.reset(self.session.frame());
383        self.peers.hush();
384    }
385
386    /// Whether a `Start` off the wire is the next round rather than the
387    /// stale one a host re-answers a greeting with.
388    pub fn is_next_round(&self, terms: &MatchTerms) -> bool {
389        terms.seed != self.terms.seed
390    }
391
392    /// The `Start` the host answers a stray greeting with: the terms plus
393    /// the current name table, so a joiner that missed the launch still
394    /// learns what everyone is called.
395    pub(super) fn start_msg(&self, seat: Option<u8>) -> NetMsg {
396        // The stale `Start` a greeting is re-answered with carries the same
397        // seed as the round in play, so a joiner reads it for the names and
398        // does not mistake it for a fresh round; the series standing rides
399        // along all the same, in case this is the message that seats a
400        // latecomer next round.
401        NetMsg::Start {
402            seats: self.seats,
403            seat,
404            terms: self.terms,
405            names: wire_table(&self.names),
406            standing: self.series_standing,
407            beach: self.beach.clone(),
408        }
409    }
410
411    /// The seat a peer index holds, or `None` for a watcher, and for a
412    /// peer the plan has never heard of. The host keeps the same list the
413    /// lobby handed out, so a late `Hello` is answered with the seat that
414    /// peer already has.
415    pub(super) fn seat_of(&self, peer: usize) -> Option<u8> {
416        self.peers.seat_of(peer)
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::sim::DEFAULT_DELAY;
424    use crate::transport::UdpTransport;
425
426    /// A session as the host holds it: seat 0, with a launch plan naming
427    /// everyone who was at the table when the round began.
428    fn hosting(plan: Vec<Option<u8>>) -> OnlineSession {
429        let transport = UdpTransport::host(0).expect("game socket");
430        let players = (0..=plan.iter().flatten().count() as u8).collect();
431        let mut session = OnlineSession::new(
432            transport,
433            Lockstep::new(0, players, DEFAULT_DELAY),
434            2,
435            MatchTerms::default(),
436        );
437        session.peers.deal(&plan);
438        session
439    }
440
441    /// The launch plan is what membership means, not the seat number. A
442    /// peer seated as a spectator in the lobby and a stranger who greeted
443    /// mid-round both answer `None` to `seat_of`, and only one of them can
444    /// be served: lockstep replays from frame zero, so the stranger would
445    /// build a board nobody will ever send it inputs for.
446    #[test]
447    fn a_latecomer_is_queued_and_a_lobby_spectator_is_not() {
448        // Two peers at launch: one seated, one watching.
449        let session = hosting(vec![Some(1), None]);
450        assert_eq!(session.queue_place(0), None, "a seated peer plays on");
451        assert_eq!(
452            session.queue_place(1),
453            None,
454            "and so does one that came to watch: it has been in step since \
455             frame zero, which is the whole difference"
456        );
457        // Anyone the socket picked up afterwards is in line, in arrival
458        // order, and told how many are in front of them.
459        assert_eq!(session.queue_place(2), Some(NetMsg::Queued { ahead: 0 }));
460        assert_eq!(session.queue_place(3), Some(NetMsg::Queued { ahead: 1 }));
461        assert_eq!(session.queue_place(9), Some(NetMsg::Queued { ahead: 7 }));
462    }
463
464    /// A joiner holds no plan at all, and answers nobody: the star's spokes
465    /// never field a greeting.
466    #[test]
467    fn a_joiner_queues_nobody() {
468        let session = hosting(Vec::new());
469        assert!(!session.is_host() || session.peers.planned() == 0);
470        // With an empty plan every peer index looks late, which is only ever
471        // consulted on the host; the guard that matters is `if host`.
472        assert_eq!(session.queue_place(0), Some(NetMsg::Queued { ahead: 0 }));
473    }
474}
475
476#[cfg(test)]
477mod next_round_tests {
478    use super::*;
479    use crate::sim::DEFAULT_DELAY;
480    use crate::transport::UdpTransport;
481
482    fn terms(seed: u64) -> MatchTerms {
483        MatchTerms {
484            seed,
485            series: 1,
486            ..MatchTerms::default()
487        }
488    }
489
490    /// A host and a real joiner over loopback, played to the point where
491    /// the host calls another round. The invitation has to reach the joiner
492    /// and rearm it on the same terms, or the two rebuild different beaches
493    /// and the round is a desync from frame zero.
494    #[test]
495    fn a_called_round_rearms_both_ends_on_the_same_terms() {
496        let mut host = OnlineSession::new(
497            UdpTransport::host(0).expect("host socket"),
498            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
499            2,
500            terms(111),
501        );
502        let port = host.transport.local_addr().expect("addr").port();
503        let mut joiner = OnlineSession::new(
504            UdpTransport::join(("127.0.0.1", port)).expect("join"),
505            Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
506            2,
507            terms(111),
508        );
509        // The greeting is what registers the joiner as a peer host-side.
510        joiner.transport.send(NetMsg::hello("Bo"));
511        for _ in 0..40 {
512            std::thread::sleep(std::time::Duration::from_millis(5));
513            host.poll_between_rounds(0.0);
514            if host.transport.peer_count() == 1 {
515                break;
516            }
517        }
518        assert_eq!(host.transport.peer_count(), 1, "the joiner is at the table");
519        host.peers.deal(&[Some(1)]);
520        host.names[0] = "Anna".into();
521        host.names[1] = "Bo".into();
522
523        host.call_next_round(terms(222), None);
524        assert!(host.next_round, "the host arms itself along with the table");
525        assert_eq!(host.terms.seed, 222);
526
527        for _ in 0..40 {
528            std::thread::sleep(std::time::Duration::from_millis(5));
529            joiner.poll_between_rounds(0.0);
530            if joiner.next_round {
531                break;
532            }
533        }
534        assert!(joiner.next_round, "the invitation reached the joiner");
535        assert_eq!(
536            joiner.terms, host.terms,
537            "and both will build the same beach from it"
538        );
539        assert_eq!(joiner.seats, host.seats);
540        assert_eq!(joiner.names[0], "Anna", "the table travels with it");
541        assert_eq!(joiner.session.seat(), Some(1), "keeping its own chair");
542        assert_eq!(joiner.session.frame(), 0, "a new round starts at zero");
543        assert_eq!(host.session.frame(), 0);
544    }
545
546    /// Register `joiners` on the host's socket in order, so their peer
547    /// indices are known, and hand back the sockets they greeted from.
548    fn gather(host: &mut OnlineSession, joiners: &[&str]) -> Vec<UdpTransport> {
549        let port = host.transport.local_addr().expect("addr").port();
550        let mut sockets = Vec::new();
551        for (want, name) in joiners.iter().enumerate() {
552            let socket = UdpTransport::join(("127.0.0.1", port)).expect("join");
553            socket.send(NetMsg::hello(name));
554            for _ in 0..40 {
555                std::thread::sleep(std::time::Duration::from_millis(5));
556                host.poll_between_rounds(0.0);
557                if host.transport.peer_count() > want {
558                    break;
559                }
560            }
561            assert_eq!(host.transport.peer_count(), want + 1, "{name} is aboard");
562            sockets.push(socket);
563        }
564        sockets
565    }
566
567    /// A greeting on the results card carries the same two things it
568    /// carries in the lobby, a name and a wish to watch, and the host
569    /// has to keep both: they are what the next round's table is dealt
570    /// from. The card path once answered without writing either down, so
571    /// Bo was seated nameless and Dee, who asked for the rail, was dealt a
572    /// chair.
573    #[test]
574    fn a_greeting_on_the_results_card_keeps_its_name_and_its_wish() {
575        let mut host = OnlineSession::new(
576            UdpTransport::host(0).expect("socket"),
577            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
578            2,
579            terms(1),
580        );
581        host.names[0] = "Anna".into();
582        let _bo = gather(&mut host, &["Bo"]);
583        let port = host.transport.local_addr().expect("addr").port();
584        let dee = UdpTransport::join(("127.0.0.1", port)).expect("join");
585        dee.send(crate::app::lobby::greeting(true, "Dee"));
586        for _ in 0..40 {
587            std::thread::sleep(std::time::Duration::from_millis(5));
588            host.poll_between_rounds(0.0);
589            if host.transport.peer_count() == 2 {
590                break;
591            }
592        }
593        assert_eq!(host.transport.peer_count(), 2, "Dee is aboard");
594
595        host.call_next_round(terms(2), None);
596        assert_eq!(host.peers.seat_of(0), Some(1), "Bo is dealt a chair");
597        assert_eq!(host.names[1], "Bo", "under its own name");
598        assert_eq!(
599            host.peers.seat_of(1),
600            None,
601            "Dee gets the rail it asked for"
602        );
603        assert!(host.peers.get(1).is_some_and(Peer::watches));
604    }
605
606    /// What a greeting earns: a peer in the plan is told its place again,
607    /// under whatever it now calls itself, and one behind the plan is told
608    /// how many are ahead of it.
609    #[test]
610    fn a_greeting_is_answered_with_the_peers_place_and_keeps_its_name() {
611        let mut host = OnlineSession::new(
612            UdpTransport::host(0).expect("socket"),
613            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
614            2,
615            terms(1),
616        );
617        host.names[0] = "Anna".into();
618        let _sockets = gather(&mut host, &["Bo", "Cy"]);
619        host.peers.deal(&[Some(1)]);
620        host.names[1] = "Bo".into();
621
622        let answer = host.answer_greeting(0, "Bobby", false);
623        assert!(
624            matches!(answer, NetMsg::Start { seat: Some(1), .. }),
625            "seated, and told so: {answer:?}"
626        );
627        assert_eq!(host.names[1], "Bobby", "under the name it greeted with");
628
629        let answer = host.answer_greeting(1, "Cy", false);
630        assert!(
631            matches!(answer, NetMsg::Queued { ahead: 0 }),
632            "behind the plan, first in line: {answer:?}"
633        );
634        assert_eq!(
635            host.peer_name(1),
636            Some("Cy"),
637            "and remembered for the next deal"
638        );
639    }
640
641    /// A seated peer that arms W between rounds is told it is watching,
642    /// and the wish is kept for the next deal.
643    #[test]
644    fn a_watch_from_a_seated_peer_is_told_the_rail() {
645        let mut host = OnlineSession::new(
646            UdpTransport::host(0).expect("socket"),
647            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
648            2,
649            terms(1),
650        );
651        let _sockets = gather(&mut host, &["Bo"]);
652        host.peers.deal(&[Some(1)]);
653        let answer = host.answer_greeting(0, "", true);
654        assert!(
655            matches!(answer, NetMsg::Start { seat: None, .. }),
656            "{answer:?}"
657        );
658        assert!(host.peers.get(0).is_some_and(Peer::watches));
659        host.call_next_round(terms(2), None);
660        assert_eq!(host.peers.seat_of(0), None, "dealt the rail it asked for");
661    }
662
663    /// A peer that leaves while the scores are being read leaves nothing
664    /// stalled, so the round never notices; the host has to. One that has
665    /// said nothing for as long as the round waits before giving a seat
666    /// up is forgotten before the next table is dealt, so the ghost is
667    /// not dealt a chair that every table then freezes on.
668    #[test]
669    fn a_peer_silent_on_the_results_card_is_not_dealt_a_chair() {
670        let mut host = OnlineSession::new(
671            UdpTransport::host(0).expect("socket"),
672            Lockstep::new(0, vec![0, 1, 2], DEFAULT_DELAY),
673            3,
674            terms(1),
675        );
676        host.names[0] = "Anna".into();
677        let sockets = gather(&mut host, &["Bo", "Cy"]);
678        // Cy keeps greeting, as a joiner on the card does; Bo has gone.
679        sockets[1].send(NetMsg::hello("Cy"));
680        std::thread::sleep(std::time::Duration::from_millis(20));
681        host.poll_between_rounds(super::super::presence::ABANDON_AFTER);
682        assert_eq!(host.transport.peer_count(), 1, "Bo is forgotten");
683        assert_eq!(host.peers.len(), 1, "row and all");
684        assert_eq!(
685            host.peer_name(0),
686            Some("Cy"),
687            "and Cy moved down into its index"
688        );
689
690        host.call_next_round(terms(2), None);
691        assert_eq!(host.seats, 2, "Anna and Cy, nobody else");
692        assert_eq!(host.names[1], "Cy");
693    }
694
695    /// The host's call for the next round can land on a joiner that is
696    /// still ticking out the last frames of the round the host has
697    /// finished. The lockstep it takes up is at frame zero and the board
698    /// is still the old one: the pump that rearmed must not tick, or the
699    /// new round's first two frames are played out on the wrong beach and
700    /// the joiner walks into the arena two frames ahead of every table.
701    #[test]
702    fn a_call_that_lands_mid_pump_does_not_tick_the_old_board() {
703        let mut host = OnlineSession::new(
704            UdpTransport::host(0).expect("host socket"),
705            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
706            2,
707            terms(111),
708        );
709        let port = host.transport.local_addr().expect("addr").port();
710        let mut joiner = OnlineSession::new(
711            UdpTransport::join(("127.0.0.1", port)).expect("join"),
712            Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
713            2,
714            terms(111),
715        );
716        joiner.transport.send(NetMsg::hello("Bo"));
717        for _ in 0..40 {
718            std::thread::sleep(std::time::Duration::from_millis(5));
719            host.poll_between_rounds(0.0);
720            if host.transport.peer_count() == 1 {
721                break;
722            }
723        }
724        host.peers.deal(&[Some(1)]);
725        host.call_next_round(terms(222), None);
726
727        let mut ticks = 0;
728        let mut rearmed_in = None;
729        for pump in 0..40 {
730            std::thread::sleep(std::time::Duration::from_millis(5));
731            let before = ticks;
732            joiner.pump(PlayerAction::None, |_| ticks += 1);
733            if joiner.next_round {
734                assert_eq!(ticks, before, "the pump that rearmed did not tick");
735                rearmed_in = Some(pump);
736                break;
737            }
738        }
739        assert!(rearmed_in.is_some(), "the call reached the joiner");
740        assert_eq!(
741            joiner.session.frame(),
742            0,
743            "and the new lockstep is untouched"
744        );
745    }
746
747    /// A series tally is kept by seat, and seats move. A peer that leaves
748    /// between rounds frees its chair and everyone behind it shuffles up
749    /// one, so the host re-deals the wins along with the chairs.
750    ///
751    /// Without that, a tally each peer kept by seat number handed the
752    /// departed player's rounds to whoever moved into their seat: Cy walks
753    /// into the next round holding Bo's two wins and its own three lost,
754    /// and the table crowns the wrong champion.
755    #[test]
756    fn the_wins_follow_the_chairs_when_a_peer_leaves() {
757        let mut host = OnlineSession::new(
758            UdpTransport::host(0).expect("socket"),
759            Lockstep::new(0, vec![0, 1, 2], DEFAULT_DELAY),
760            3,
761            terms(1),
762        );
763        let _sockets = gather(&mut host, &["Bo", "Cy"]);
764        host.peers.deal(&[Some(1), Some(2)]);
765        host.names[0] = "Anna".into();
766        host.names[1] = "Bo".into();
767        host.names[2] = "Cy".into();
768
769        // Three rounds played: Anna one, Bo two, Cy three.
770        let standing = SeriesStanding {
771            round: 3,
772            wins: [1, 2, 3, 0, 0, 0],
773        };
774        // And Bo walks off while the results card is up, which frees seat
775        // one and moves Cy up into it.
776        host.transport.forget(0);
777        host.peers.forget(0);
778
779        let next = host
780            .call_next_round(terms(2), Some(standing))
781            .expect("a series has a standing");
782        assert_eq!(next.round, 4, "the round about to begin");
783        assert_eq!(
784            next.wins[0], 1,
785            "the host keeps its own, seat zero being its own"
786        );
787        assert_eq!(
788            next.wins[1], 3,
789            "Cy took its three rounds up the table with it"
790        );
791        assert_eq!(next.wins[2], 0, "and nothing of Bo's was left for anyone");
792        assert_eq!(host.names[1], "Cy", "the name moved with the chair too");
793        assert_eq!(host.series_standing, Some(next), "and the host holds it");
794    }
795
796    /// The host is the only peer that can tell who sent what, so it is the
797    /// only one that can catch a peer speaking for somebody else's seat.
798    ///
799    /// It matters because of the relay: the spokes of the star hear each
800    /// other only through the hub, so an input the hub believes is an
801    /// input the whole table believes. One unchecked datagram and every
802    /// peer that heard the relay is playing a different round from the one
803    /// that did not - which is a desync lockstep has no way back from.
804    #[test]
805    fn a_peer_speaks_only_for_the_seat_it_holds() {
806        let mut host = OnlineSession::new(
807            UdpTransport::host(0).expect("socket"),
808            Lockstep::new(0, vec![0, 1, 2], DEFAULT_DELAY),
809            3,
810            terms(7),
811        );
812        let mut sockets = gather(&mut host, &["Bo", "Cy"]);
813        host.peers.deal(&[Some(1), Some(2)]);
814
815        // Bo holds seat one. It sends for its own chair, and for Cy's.
816        let own = crate::sim::InputMsg {
817            player: 1,
818            frame: DEFAULT_DELAY,
819            action: PlayerAction::Remove { x: 1, y: 1 },
820        };
821        let forged = crate::sim::InputMsg {
822            player: 2,
823            frame: DEFAULT_DELAY,
824            action: PlayerAction::Remove { x: 2, y: 2 },
825        };
826        // What reaches Cy is the question: the hub is the only way Bo can
827        // reach it at all, so what the hub relays is what the table plays.
828        let mut relayed: Vec<u8> = Vec::new();
829        for _ in 0..40 {
830            sockets[0].send(NetMsg::Input(own));
831            sockets[0].send(NetMsg::Input(forged));
832            std::thread::sleep(std::time::Duration::from_millis(5));
833            host.pump(PlayerAction::None, |_| {});
834            for (msg, _) in sockets[1].recv_all() {
835                if let NetMsg::Input(input) = msg {
836                    relayed.push(input.player);
837                }
838            }
839            if relayed.contains(&1) {
840                break;
841            }
842        }
843        assert!(relayed.contains(&1), "the chair it holds reaches the table");
844        assert!(
845            !relayed.contains(&2),
846            "the chair it does not hold never does: {relayed:?}"
847        );
848    }
849
850    /// The stale `Start` a host re-answers a stray greeting with must not
851    /// read as a new round, or every late hello would restart the match.
852    #[test]
853    fn the_same_seed_is_not_a_new_round() {
854        let session = OnlineSession::new(
855            UdpTransport::host(0).expect("socket"),
856            Lockstep::new(0, vec![0], DEFAULT_DELAY),
857            2,
858            terms(111),
859        );
860        assert!(!session.is_next_round(&terms(111)), "the round it is in");
861        assert!(
862            session.is_next_round(&terms(112)),
863            "a beach it has not seen"
864        );
865    }
866
867    /// Whoever queued while the round played gets a chair in the next one,
868    /// and the AI gives way to them rather than the other way about.
869    #[test]
870    fn the_queue_is_seated_next_round() {
871        let mut host = OnlineSession::new(
872            UdpTransport::host(0).expect("socket"),
873            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
874            2,
875            terms(1),
876        );
877        // One peer played, one watched, two turned up while they played.
878        host.peers.deal(&[Some(1), None]);
879        let plan = host.next_plan(4);
880        assert_eq!(
881            plan,
882            vec![Some(1), None, Some(2), Some(3)],
883            "the player keeps its seat, the watcher keeps watching, and the \
884             two who waited are seated behind them"
885        );
886        // The AI takes what the humans leave, however much the terms asked
887        // for. No peer ever connected to this socket, so the table the call
888        // actually plans is the host alone, five chairs for the AI.
889        let mut greedy = terms(2);
890        greedy.bots = 5;
891        host.call_next_round(greedy, None);
892        assert_eq!(host.seats, MAX_PLAYERS as u8);
893        assert_eq!(host.terms.bots, MAX_PLAYERS as u8 - 1);
894        // And the plan is drawn from who is actually connected, not from
895        // last round's list: a peer that left does not hold a chair.
896        assert!(host.peers.is_empty(), "nobody is connected any more");
897    }
898}