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}