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
11impl OnlineSession {
12    /// The answer for a peer the socket picked up *after* the launch, or
13    /// `None` for one that was at the table when the round began.
14    ///
15    /// Membership is the launch plan, not the seat: a peer seated as a
16    /// spectator in the lobby has an entry in `peer_seats` (a `None` one),
17    /// while a stranger who greeted mid-round has no entry at all. Both
18    /// answer `None` to `seat_of`, so the plan is the only thing that tells
19    /// them apart. Admitting the second as a spectator was how a latecomer
20    /// used to end up staring at frame zero forever.
21    pub(super) fn queue_place(&self, peer: usize) -> Option<NetMsg> {
22        debug_assert!(
23            self.is_host() || self.peer_seats.is_empty(),
24            "a joiner keeps no launch plan and must never answer with one"
25        );
26        let seated = self.peer_seats.len();
27        // Peers are registered in arrival order, so everyone between the
28        // plan and this one is already waiting.
29        let ahead = peer.checked_sub(seated)?;
30        Some(NetMsg::Queued {
31            ahead: ahead.min(u8::MAX as usize) as u8,
32        })
33    }
34
35    /// Drain the socket between rounds, when the sim is stopped and `pump`
36    /// is therefore not running either.
37    ///
38    /// The results card is when people turn up wanting in, and when the
39    /// host decides there will be another round, so the two messages that
40    /// matter here are a greeting to queue and the invitation that answers
41    /// it. Inputs and hashes belong to the round that just ended, and are
42    /// dropped with it.
43    ///
44    /// A joiner also keeps greeting, which is not for the host's benefit:
45    /// the answer is what proves the host is still there. Nothing else is
46    /// sent between rounds, so without it the silence clock would grow on
47    /// a table where everybody is present and simply reading the scores,
48    /// and [`Self::host_gone`] would call the round off under them.
49    pub fn poll_between_rounds(&mut self, delta: f32) {
50        let host = self.is_host();
51        self.age_the_silence(delta);
52        if !host {
53            self.greet_in -= delta;
54            if self.greet_in <= 0.0 {
55                self.greet_in = crate::app::lobby::ANNOUNCE_EVERY;
56                let me = self
57                    .session
58                    .seat()
59                    .map_or("", |seat| &self.names[usize::from(seat)]);
60                self.transport.send(NetMsg::hello(me));
61            }
62        }
63        for (msg, from) in self.transport.recv_all() {
64            self.mark_heard(from);
65            match msg {
66                NetMsg::Hello { .. } | NetMsg::Watch => {
67                    if host {
68                        let answer = self
69                            .queue_place(from)
70                            .unwrap_or_else(|| self.start_msg(self.seat_of(from)));
71                        self.transport.send_to(from, answer);
72                    }
73                }
74                NetMsg::Start {
75                    seats,
76                    seat,
77                    terms,
78                    names,
79                    round,
80                    wins,
81                    beach,
82                } => {
83                    if !host && self.is_next_round(&terms) {
84                        self.beach = beach;
85                        let table = std::array::from_fn(|i| name_from_wire(&names[i]));
86                        self.begin_round(seats, seat, terms, table);
87                        self.series_standing = (terms.series == 1).then_some((round, wins));
88                        self.next_round = true;
89                    }
90                }
91                NetMsg::Input(_)
92                | NetMsg::Hash { .. }
93                | NetMsg::Pause { .. }
94                | NetMsg::Resume { .. }
95                | NetMsg::Queued { .. }
96                | NetMsg::Chat { .. }
97                | NetMsg::Roster { .. }
98                | NetMsg::Abandoned { .. }
99                | NetMsg::Incompatible { .. } => {}
100            }
101        }
102    }
103
104    /// Host: the table for the next round, admitting whoever queued while
105    /// this one played.
106    ///
107    /// Seats are handed out in peer order as they were at the launch, so
108    /// those still here keep theirs, and the queue fills what is left,
109    /// pushing the AI back a seat at a time. Returns the new plan, one
110    /// entry per peer, `None` for a peer that watches or that the table
111    /// could not fit.
112    fn next_plan(&self, peers: usize) -> Vec<Option<u8>> {
113        let mut next = 1u8; // the host keeps seat 0
114        (0..peers)
115            .map(|peer| {
116                // A watcher, whether it sat out the launch (a `None` in the
117                // plan) or queued mid-round with W armed (a remembered wish),
118                // keeps no chair.
119                let watches = matches!(self.peer_seats.get(peer), Some(None))
120                    || self.peer_watch.get(peer).copied().unwrap_or(false);
121                if watches || usize::from(next) >= MAX_PLAYERS {
122                    return None;
123                }
124                let seat = next;
125                next += 1;
126                Some(seat)
127            })
128            .collect()
129    }
130
131    /// Host: write down a peer's name against its socket index, growing the
132    /// row to reach it. Kept for peers the seat table does not cover yet.
133    pub(super) fn remember_peer_name(&mut self, peer: usize, name: &str) {
134        if self.peer_names.len() <= peer {
135            self.peer_names.resize(peer + 1, String::new());
136        }
137        self.peer_names[peer] = name.to_string();
138    }
139
140    /// Host: note that a peer asked to watch rather than play.
141    pub(super) fn note_watch_wish(&mut self, peer: usize) {
142        if self.peer_watch.len() <= peer {
143            self.peer_watch.resize(peer + 1, false);
144        }
145        self.peer_watch[peer] = true;
146    }
147
148    /// The name a peer index goes by, from its greeting: its seat's name if
149    /// it holds one, else what it greeted with while queued.
150    fn peer_name(&self, peer: usize) -> Option<&str> {
151        if let Some(Some(seat)) = self.peer_seats.get(peer)
152            && let Some(name) = self.names.get(usize::from(*seat))
153            && !name.is_empty()
154        {
155            return Some(name);
156        }
157        self.peer_names
158            .get(peer)
159            .map(String::as_str)
160            .filter(|name| !name.is_empty())
161    }
162
163    /// Host: call the next round on `terms`, admitting the queue, and tell
164    /// every peer which seat it now holds. Arms this session too, so host
165    /// and joiners take the same path back into the arena.
166    ///
167    /// `round`/`wins` are the series standing as it is now, by *this*
168    /// round's seats; the return value is the same standing re-dealt to the
169    /// seats the next round hands out, which the caller folds back into its
170    /// `Tournament`. Seats are re-dealt in peer order every round (they have
171    /// to stay the contiguous `0..humans` the sim fills the top of with AI),
172    /// so a peer that leaves shifts everyone behind it up a chair; carrying
173    /// the wins across by hand is what keeps a survivor's rounds its own
174    /// rather than the next player's. Outside a series `round` is 0 and the
175    /// wins are ignored.
176    pub fn call_next_round(
177        &mut self,
178        mut terms: MatchTerms,
179        round: u8,
180        wins: [u8; MAX_PLAYERS],
181    ) -> (u8, [u8; MAX_PLAYERS]) {
182        let peers = self.transport.peer_count();
183        let plan = self.next_plan(peers);
184        // The wins follow the chairs: seat 0 is always the host's, and each
185        // peer's new seat inherits what its old seat had won.
186        let mut new_wins = [0u8; MAX_PLAYERS];
187        new_wins[0] = wins[0];
188        for (peer, slot) in plan.iter().enumerate() {
189            if let Some(new_seat) = slot
190                && let Some(Some(old_seat)) = self.peer_seats.get(peer)
191            {
192                new_wins[usize::from(*new_seat)] = wins[usize::from(*old_seat)];
193            }
194        }
195        // The number of the round about to begin: the caller passes the one
196        // just played, and every peer shows the same next number without
197        // each counting for itself.
198        let round = round.saturating_add(u8::from(round > 0));
199        let humans = 1 + plan.iter().flatten().count() as u8;
200        // A beach needs two castles, and a host whose table has emptied is
201        // still entitled to another round, against the AI, since playing
202        // itself is not a round. The same floor `seat_count` keeps.
203        terms.bots = terms
204            .bots
205            .min(MAX_PLAYERS as u8 - humans)
206            .max(2u8.saturating_sub(humans));
207        let seats = humans + terms.bots;
208        // Names travel with the invitation, as they did at the launch: a
209        // player admitted from the queue is a stranger to every other
210        // screen until this says otherwise.
211        let mut names: [String; MAX_PLAYERS] = Default::default();
212        names[0].clone_from(&self.names[0]);
213        for (peer, slot) in plan.iter().enumerate() {
214            if let Some(seat) = slot
215                && let Some(name) = self.peer_name(peer)
216            {
217                names[usize::from(*seat)] = name.to_string();
218            }
219        }
220        let wire: [_; MAX_PLAYERS] = std::array::from_fn(|i| wire_name(&names[i]));
221        for (peer, slot) in plan.iter().enumerate() {
222            self.transport.send_to(
223                peer,
224                NetMsg::Start {
225                    seats,
226                    seat: *slot,
227                    terms,
228                    names: wire,
229                    round,
230                    wins: new_wins,
231                    beach: self.beach.clone(),
232                },
233            );
234        }
235        self.peer_seats = plan;
236        self.begin_round(seats, Some(0), terms, names);
237        self.series_standing = (round > 0).then_some((round, new_wins));
238        self.next_round = true;
239        (round, new_wins)
240    }
241
242    /// Take up the terms of a new round: a fresh board, a lockstep back at
243    /// frame zero, and whatever the table is called now.
244    ///
245    /// The seed is what marks this a *new* round rather than the stale
246    /// `Start` a host re-answers stray greetings with, so a caller that
247    /// does not change it will find nothing happens.
248    #[cfg_attr(debug_assertions, track_caller)]
249    pub fn begin_round(
250        &mut self,
251        seats: u8,
252        seat: Option<u8>,
253        terms: MatchTerms,
254        names: [String; MAX_PLAYERS],
255    ) {
256        debug_assert!(
257            (2..=MAX_PLAYERS as u8).contains(&seats),
258            "a round for {seats} seats, which no per-seat array can hold"
259        );
260        debug_assert!(
261            seat.is_none_or(|seat| seat < seats),
262            "seated at {seat:?} of {seats}"
263        );
264        let humans = seats.saturating_sub(terms.bots).max(1);
265        let players: Vec<u8> = (0..humans).collect();
266        self.session = match seat {
267            None => Lockstep::observer(players, crate::sim::DEFAULT_DELAY),
268            Some(seat) => Lockstep::new(seat, players, crate::sim::DEFAULT_DELAY),
269        };
270        self.seats = seats;
271        self.terms = terms;
272        self.names = names;
273        // Last round's disagreement is last round's; a new board starts
274        // level, and the hashes that proved it are gone with it.
275        self.desync_at = None;
276        self.own_hashes.clear();
277        self.peer_hashes.clear();
278        self.resume_echo = 0;
279        // And nobody is late for a round that has not begun. The results
280        // card is a place a table sits for a while, and carrying that
281        // silence into the new round would call the host gone on its first
282        // frame.
283        self.stalled_for = 0.0;
284        self.stalled_on = self.session.frame();
285        self.waiting_hold = 0.0;
286        self.waiting_on = None;
287        for silence in self.peer_silence.iter_mut() {
288            *silence = 0.0;
289        }
290    }
291
292    /// Whether a `Start` off the wire is the next round rather than the
293    /// stale one a host re-answers a greeting with.
294    pub fn is_next_round(&self, terms: &MatchTerms) -> bool {
295        terms.seed != self.terms.seed
296    }
297
298    /// The `Start` the host answers a stray greeting with: the terms plus
299    /// the current name table, so a joiner that missed the launch still
300    /// learns what everyone is called.
301    pub(super) fn start_msg(&self, seat: Option<u8>) -> NetMsg {
302        let mut names = [[0u8; crate::transport::WIRE_NAME]; MAX_PLAYERS];
303        for (slot, name) in names.iter_mut().zip(&self.names) {
304            *slot = wire_name(name);
305        }
306        // The stale `Start` a greeting is re-answered with carries the same
307        // seed as the round in play, so a joiner reads it for the names and
308        // does not mistake it for a fresh round; the series standing rides
309        // along all the same, in case this is the message that seats a
310        // latecomer next round.
311        let (round, wins) = self.series_standing.unwrap_or((0, [0; MAX_PLAYERS]));
312        NetMsg::Start {
313            seats: self.seats,
314            seat,
315            terms: self.terms,
316            names,
317            round,
318            wins,
319            beach: self.beach.clone(),
320        }
321    }
322
323    /// The seat a peer index holds, or `None` for a watcher, and for a
324    /// peer the plan has never heard of. The host keeps the same list the
325    /// lobby handed out, so a late `Hello` is answered with the seat that
326    /// peer already has.
327    pub(super) fn seat_of(&self, peer: usize) -> Option<u8> {
328        self.peer_seats.get(peer).copied().flatten()
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use crate::sim::DEFAULT_DELAY;
336    use crate::transport::UdpTransport;
337
338    /// A session as the host holds it: seat 0, with a launch plan naming
339    /// everyone who was at the table when the round began.
340    fn hosting(plan: Vec<Option<u8>>) -> OnlineSession {
341        let transport = UdpTransport::host(0).expect("game socket");
342        let players = (0..=plan.iter().flatten().count() as u8).collect();
343        let mut session = OnlineSession::new(
344            transport,
345            Lockstep::new(0, players, DEFAULT_DELAY),
346            2,
347            MatchTerms::default(),
348        );
349        session.peer_seats = plan;
350        session
351    }
352
353    /// The launch plan is what membership means, not the seat number. A
354    /// peer seated as a spectator in the lobby and a stranger who greeted
355    /// mid-round both answer `None` to `seat_of`, and only one of them can
356    /// be served: lockstep replays from frame zero, so the stranger would
357    /// build a board nobody will ever send it inputs for.
358    #[test]
359    fn a_latecomer_is_queued_and_a_lobby_spectator_is_not() {
360        // Two peers at launch: one seated, one watching.
361        let session = hosting(vec![Some(1), None]);
362        assert_eq!(session.queue_place(0), None, "a seated peer plays on");
363        assert_eq!(
364            session.queue_place(1),
365            None,
366            "and so does one that came to watch: it has been in step since \
367             frame zero, which is the whole difference"
368        );
369        // Anyone the socket picked up afterwards is in line, in arrival
370        // order, and told how many are in front of them.
371        assert_eq!(session.queue_place(2), Some(NetMsg::Queued { ahead: 0 }));
372        assert_eq!(session.queue_place(3), Some(NetMsg::Queued { ahead: 1 }));
373        assert_eq!(session.queue_place(9), Some(NetMsg::Queued { ahead: 7 }));
374    }
375
376    /// A joiner holds no plan at all, and answers nobody: the star's spokes
377    /// never field a greeting.
378    #[test]
379    fn a_joiner_queues_nobody() {
380        let session = hosting(Vec::new());
381        assert!(!session.is_host() || session.peer_seats.is_empty());
382        // With an empty plan every peer index looks late, which is only ever
383        // consulted on the host; the guard that matters is `if host`.
384        assert_eq!(session.queue_place(0), Some(NetMsg::Queued { ahead: 0 }));
385    }
386}
387
388#[cfg(test)]
389mod next_round_tests {
390    use super::*;
391    use crate::sim::DEFAULT_DELAY;
392    use crate::transport::UdpTransport;
393
394    fn terms(seed: u64) -> MatchTerms {
395        MatchTerms {
396            seed,
397            series: 1,
398            ..MatchTerms::default()
399        }
400    }
401
402    /// A host and a real joiner over loopback, played to the point where
403    /// the host calls another round. The invitation has to reach the joiner
404    /// and rearm it on the same terms, or the two rebuild different beaches
405    /// and the round is a desync from frame zero.
406    #[test]
407    fn a_called_round_rearms_both_ends_on_the_same_terms() {
408        let mut host = OnlineSession::new(
409            UdpTransport::host(0).expect("host socket"),
410            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
411            2,
412            terms(111),
413        );
414        let port = host.transport.local_addr().expect("addr").port();
415        let mut joiner = OnlineSession::new(
416            UdpTransport::join(("127.0.0.1", port)).expect("join"),
417            Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
418            2,
419            terms(111),
420        );
421        // The greeting is what registers the joiner as a peer host-side.
422        joiner.transport.send(NetMsg::hello("Bo"));
423        for _ in 0..40 {
424            std::thread::sleep(std::time::Duration::from_millis(5));
425            host.poll_between_rounds(0.0);
426            if host.transport.peer_count() == 1 {
427                break;
428            }
429        }
430        assert_eq!(host.transport.peer_count(), 1, "the joiner is at the table");
431        host.peer_seats = vec![Some(1)];
432        host.names[0] = "Anna".into();
433        host.names[1] = "Bo".into();
434
435        host.call_next_round(terms(222), 0, [0; MAX_PLAYERS]);
436        assert!(host.next_round, "the host arms itself along with the table");
437        assert_eq!(host.terms.seed, 222);
438
439        for _ in 0..40 {
440            std::thread::sleep(std::time::Duration::from_millis(5));
441            joiner.poll_between_rounds(0.0);
442            if joiner.next_round {
443                break;
444            }
445        }
446        assert!(joiner.next_round, "the invitation reached the joiner");
447        assert_eq!(
448            joiner.terms, host.terms,
449            "and both will build the same beach from it"
450        );
451        assert_eq!(joiner.seats, host.seats);
452        assert_eq!(joiner.names[0], "Anna", "the table travels with it");
453        assert_eq!(joiner.session.seat(), Some(1), "keeping its own chair");
454        assert_eq!(joiner.session.frame(), 0, "a new round starts at zero");
455        assert_eq!(host.session.frame(), 0);
456    }
457
458    /// The stale `Start` a host re-answers a stray greeting with must not
459    /// read as a new round, or every late hello would restart the match.
460    #[test]
461    fn the_same_seed_is_not_a_new_round() {
462        let session = OnlineSession::new(
463            UdpTransport::host(0).expect("socket"),
464            Lockstep::new(0, vec![0], DEFAULT_DELAY),
465            2,
466            terms(111),
467        );
468        assert!(!session.is_next_round(&terms(111)), "the round it is in");
469        assert!(
470            session.is_next_round(&terms(112)),
471            "a beach it has not seen"
472        );
473    }
474
475    /// Whoever queued while the round played gets a chair in the next one,
476    /// and the AI gives way to them rather than the other way about.
477    #[test]
478    fn the_queue_is_seated_next_round() {
479        let mut host = OnlineSession::new(
480            UdpTransport::host(0).expect("socket"),
481            Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
482            2,
483            terms(1),
484        );
485        // One peer played, one watched, two turned up while they played.
486        host.peer_seats = vec![Some(1), None];
487        let plan = host.next_plan(4);
488        assert_eq!(
489            plan,
490            vec![Some(1), None, Some(2), Some(3)],
491            "the player keeps its seat, the watcher keeps watching, and the \
492             two who waited are seated behind them"
493        );
494        // The AI takes what the humans leave, however much the terms asked
495        // for. No peer ever connected to this socket, so the table the call
496        // actually plans is the host alone, five chairs for the AI.
497        let mut greedy = terms(2);
498        greedy.bots = 5;
499        host.call_next_round(greedy, 0, [0; MAX_PLAYERS]);
500        assert_eq!(host.seats, MAX_PLAYERS as u8);
501        assert_eq!(host.terms.bots, MAX_PLAYERS as u8 - 1);
502        // And the plan is drawn from who is actually connected, not from
503        // last round's list: a peer that left does not hold a chair.
504        assert!(host.peer_seats.is_empty(), "nobody is connected any more");
505    }
506}