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}