pinch_points/app/net/mod.rs
1//! Online versus, shell side: a UDP lockstep session driving the same
2//! versus mode local play uses (spec ยง7.6 fallback path; see sim::net for
3//! why lockstep rather than GGRS rollback today).
4//!
5//! Dev-grade session setup: `PINCH_HOST=<port>` hosts as player 0,
6//! `PINCH_JOIN=<addr:port>` joins as player 1. Both boot straight into the
7//! arena; the match starts once the handshake completes.
8
9use crate::sim::{HASH_INTERVAL, Lockstep, MAX_PLAYERS, PlayerAction};
10use crate::transport::{Announcer, MatchTerms, NetMsg, UdpTransport, name_from_wire, wire_name};
11use bevy::prelude::*;
12use std::collections::VecDeque;
13
14/// How many ticks a resume is repeated after the local session unpauses.
15/// A second's worth: enough to ride out a loss burst, and harmless if the
16/// peers already heard the first one.
17const RESUME_ECHOES: u8 = 30;
18
19pub struct OnlineSession {
20 pub transport: UdpTransport,
21 pub session: Lockstep,
22 /// Seats in the match, humans plus AI. The lockstep only carries the
23 /// humans, so this is not `session.player_count()`.
24 pub seats: u8,
25 /// What the lobby agreed the match is: AI seats, map, gulls, round
26 /// length, light, team scoring, and the board's seed. Every peer builds
27 /// from these, so nothing about the round is a local opinion.
28 pub terms: MatchTerms,
29 /// The host's handmade beach, compressed, when the round is played on
30 /// one. A generated arena travels as a seed in [`Self::terms`]; a
31 /// level somebody built has to travel as itself, because no peer but
32 /// the host has the file. Empty for the built-in maps.
33 pub beach: Vec<u8>,
34 /// Host side: the seat each peer index was given, `None` for the
35 /// watchers among them. Empty on a joiner, which answers nobody.
36 pub peer_seats: Vec<Option<u8>>,
37 /// Host side: what each peer index calls itself, from its greeting,
38 /// and whether it asked to watch rather than play. Kept for the peers
39 /// the launch plan does not cover: someone who queued mid-round holds
40 /// no seat yet, so its name and its wish to watch have nowhere else to
41 /// live until [`Self::call_next_round`] seats it. Grown as peers appear
42 /// and shifted with the socket's own list by [`Self::forget_peer_row`].
43 peer_names: Vec<String>,
44 peer_watch: Vec<bool>,
45 /// What each seat is called, agreed at the handshake so every peer
46 /// shows the same table. Empty entries fall back to seat labels; local
47 /// couch names never apply to an online round.
48 pub names: [String; MAX_PLAYERS],
49 /// First frame where the peers' state hashes disagreed, if ever: a
50 /// determinism bug surfaced loudly rather than played through.
51 pub desync_at: Option<u32>,
52 /// Seconds the round has been unable to move, and the frame it has been
53 /// unable to move off.
54 ///
55 /// Measured by the frame number rather than by whether the next frame's
56 /// inputs happen to be in, because the second question is asked at a
57 /// moment where the answer is always no. The sim runs on the fixed step
58 /// and advances until it *cannot*; the tick that watches for stalls runs
59 /// after it, in `Update`. So it always found the next frame's slots
60 /// empty and always called a healthy round stalled. That put "waiting
61 /// for Bob" on both screens of a round that was running perfectly, and
62 /// made the socket-silence rule necessary to stop the host handing out
63 /// its friends' castles five seconds into every match.
64 ///
65 /// "The picture has not moved" is the thing both readers actually want,
66 /// and it is not a matter of timing within a frame.
67 stalled_for: f32,
68 stalled_on: u32,
69 /// Who the status line is naming, and how long it has left to keep
70 /// naming them.
71 ///
72 /// A latch rather than a reading of `stalled_for`, because that number
73 /// snaps back to zero the moment one frame gets through, and a round
74 /// that is limping gets a frame through all the time. Reading it
75 /// directly put the line on and off once a second: a strobe in the
76 /// corner of the eye, which is worse than saying nothing at all.
77 waiting_on: Option<u8>,
78 waiting_hold: f32,
79 /// Seconds since each peer index was last heard from: anything at all,
80 /// an input, a hash, a stray greeting.
81 ///
82 /// Kept beside the transport's own peer list and shifted with it. This
83 /// is the difference between a player who has *gone* and one who is
84 /// merely behind: a machine that is still talking to us has not left
85 /// the room, however far its inputs have fallen back, and giving its
86 /// castle away because a burst of loss held one frame up is how you
87 /// lose a friend's round for them. A joiner keeps the one entry it has,
88 /// the host, and calls the round off when that goes quiet.
89 peer_silence: Vec<f32>,
90 /// Seats given up on this round, each with the frame it was emptied
91 /// from, so the shell can put an AI in each and say so once rather
92 /// than every frame, and the host can keep telling the table.
93 pub abandoned: Vec<(u8, u32)>,
94 /// Host side: the beacon, carried out of the lobby so the beach stays
95 /// on the air while the round runs, listed as in progress with its
96 /// occupancy, for anyone who wants the next one. `None` on a joiner,
97 /// and on a direct `PINCH_HOST` pair, which never announced at all.
98 pub announcer: Option<Announcer>,
99 /// What the beach is called, for the beacon it keeps up while the round
100 /// runs. Not a player's name: the list is choosing between games.
101 pub game_name: String,
102 announce_in: f32,
103 /// Joiner side: seconds until the next greeting while the results card
104 /// is up.
105 ///
106 /// An established session says nothing at all between rounds. Inputs
107 /// and hashes belong to the round that ended, and the host has nothing
108 /// to send until somebody calls the next one, so both ends fall silent
109 /// on purpose. That is fine until silence is being read as evidence,
110 /// and then it is a joiner walking out of a perfectly good table
111 /// twenty seconds into a results card. So a joiner keeps saying hello,
112 /// on the same cadence the lobby uses, and the host's reply is what
113 /// tells it the host is still there.
114 greet_in: f32,
115 /// A next round has been agreed and the session is armed for it; the
116 /// shell reads this and walks everyone back into the arena. Cleared by
117 /// whoever acts on it.
118 pub next_round: bool,
119 /// Where the series stands as the next round begins, as the host said
120 /// it: the 1-based round number and the wins per seat, both re-dealt to
121 /// this round's chairs. The shell folds it into its `Tournament` on the
122 /// way into the arena, so a peer admitted mid-series joins the table's
123 /// standings rather than starting its own, and a survivor whose seat
124 /// moved keeps the wins it earned. `None` outside a series.
125 pub series_standing: Option<(u8, [u8; MAX_PLAYERS])>,
126 /// Ticks of `Resume` still to repeat (see [`RESUME_ECHOES`]).
127 resume_echo: u8,
128 own_hashes: VecDeque<(u32, u64)>,
129 /// Peer hashes for frames we may not have simulated yet; compared (and
130 /// drained) as our own hashes appear, so no exchanged check is skipped.
131 peer_hashes: VecDeque<(u32, u64)>,
132}
133
134/// A hosted beach stops being announced when its session goes, whichever
135/// way that happens: the match ending, the player quitting to the menu, a
136/// desync giving up. Putting it here rather than at each of those exits is
137/// what keeps the promise: there is no path that drops a session and
138/// forgets to take the beach off the network with it.
139impl Drop for OnlineSession {
140 fn drop(&mut self) {
141 if let Some(announcer) = &self.announcer
142 && let Ok(addr) = self.transport.local_addr()
143 {
144 announcer.closing(addr.port());
145 }
146 }
147}
148
149#[derive(Resource, Default)]
150pub struct Online(pub Option<OnlineSession>);
151
152/// Drain the session while the results card is up. See
153/// [`OnlineSession::poll_between_rounds`] for why this cannot wait for the
154/// sim: the sim is exactly what has stopped.
155pub fn poll_between_rounds(time: Res<Time>, mut online: ResMut<Online>) {
156 if let Some(session) = &mut online.0 {
157 session.poll_between_rounds(time.delta_secs());
158 }
159}
160
161mod presence;
162mod rounds;
163pub(crate) use presence::{abandon_the_departed, leave_a_hostless_round};
164
165impl OnlineSession {
166 pub fn new(
167 transport: UdpTransport,
168 session: Lockstep,
169 seats: u8,
170 terms: MatchTerms,
171 ) -> OnlineSession {
172 OnlineSession {
173 transport,
174 session,
175 seats,
176 terms,
177 beach: Vec::new(),
178 peer_seats: Vec::new(),
179 peer_names: Vec::new(),
180 peer_watch: Vec::new(),
181 names: Default::default(),
182 stalled_for: 0.0,
183 stalled_on: 0,
184 waiting_on: None,
185 waiting_hold: 0.0,
186 peer_silence: Vec::new(),
187 abandoned: Vec::new(),
188 announcer: None,
189 game_name: String::new(),
190 announce_in: 0.0,
191 greet_in: 0.0,
192 next_round: false,
193 series_standing: None,
194 desync_at: None,
195 resume_echo: 0,
196 own_hashes: VecDeque::new(),
197 peer_hashes: VecDeque::new(),
198 }
199 }
200
201 /// Keep the beach on the air while the round runs, so a lobby can list
202 /// it as in progress rather than not at all. Hosts only, and the beacon
203 /// says running, so nobody is offered a join that lockstep could not
204 /// honour; what it offers is a place in the queue.
205 ///
206 /// `taken` counts the humans, not the table: an AI seat gives way to a
207 /// player who wants it, the same way the lobby fills bots in behind
208 /// whoever turned up.
209 pub fn keep_announcing(&mut self, delta: f32) {
210 if self.announcer.is_none() {
211 return;
212 }
213 self.announce_in -= delta;
214 if self.announce_in > 0.0 {
215 return;
216 }
217 self.announce_in = crate::app::lobby::ANNOUNCE_EVERY;
218 let taken = self.session.player_count() as u8;
219 let (Some(announcer), Ok(addr)) = (&self.announcer, self.transport.local_addr()) else {
220 return;
221 };
222 announcer.running(
223 addr.port(),
224 crate::transport::OnAir {
225 name: &self.game_name,
226 // Seat zero is the host's, and the host is the only one that
227 // announces.
228 host: &self.names[0],
229 taken,
230 seats: MAX_PLAYERS as u8,
231 },
232 );
233 }
234
235 /// Call a pause, and tell the peers which frame it lands on.
236 pub fn request_pause(&mut self) {
237 if self.watching() {
238 // A spectator's Escape opens their own menu; the match plays on
239 // behind it, and they are still in step when they close it.
240 return;
241 }
242 let frame = self.session.request_pause();
243 self.transport.send(NetMsg::Pause { frame });
244 }
245
246 /// Play on, and keep saying so for a moment (see `RESUME_ECHOES`).
247 pub fn request_resume(&mut self) {
248 if self.watching() {
249 return;
250 }
251 let frame = self.session.resume();
252 self.resume_echo = RESUME_ECHOES;
253 self.transport.send(NetMsg::Resume { frame });
254 }
255
256 /// The `Resume` to repeat: the pause most recently lifted here.
257 fn resume_msg(&self) -> NetMsg {
258 NetMsg::Resume {
259 frame: self.session.lifted_pause().unwrap_or(0),
260 }
261 }
262
263 /// Is this session the star's hub? Seat 0 hosts and relays.
264 ///
265 /// Public because the shell has to know who calls the next round, which
266 /// it did through a second method of the same one line, under a second
267 /// copy of this sentence.
268 pub fn is_host(&self) -> bool {
269 self.session.seat() == Some(0)
270 }
271
272 /// Whether this peer watches rather than plays.
273 pub fn watching(&self) -> bool {
274 self.session.watching()
275 }
276
277 /// One fixed-tick's worth of network pumping: commit and (re)send local
278 /// input, drain the socket (the host relays joiner inputs to the other
279 /// joiners and re-answers stray hellos with their seat), then simulate
280 /// every frame that has complete inputs via `tick`. Records hashes on
281 /// the [`HASH_INTERVAL`] cadence. Returns whether the local action was
282 /// committed (false = at the commit lead; retry it next tick).
283 pub fn pump(&mut self, local_action: PlayerAction, mut tick: impl FnMut(&mut Self)) -> bool {
284 let committed = self.session.commit_local(local_action).is_some();
285 for &msg in self.session.recent_commits() {
286 self.transport.send(NetMsg::Input(msg));
287 }
288 // Pause state is repeated every tick rather than sent once: UDP
289 // drops, and the peer that misses a Pause would otherwise sit
290 // watching a frozen beach with no card, while a missed Resume would
291 // leave the session stopped for good. Both self-heal here: the
292 // resume repeats until frames actually start moving again.
293 match self.session.pause_frame() {
294 Some(frame) => self.transport.send(NetMsg::Pause { frame }),
295 None if self.resume_echo > 0 => {
296 self.resume_echo -= 1;
297 self.transport.send(self.resume_msg());
298 }
299 None => {}
300 }
301 let host = self.is_host();
302 // Likewise every seat given up on, for the rest of the round: a
303 // joiner that misses the one notice waits on that seat forever,
304 // and the host never notices, because a joiner that is waiting is
305 // still talking. Five bytes a seat a tick.
306 if host {
307 for &(seat, frame) in &self.abandoned {
308 self.transport.send(NetMsg::Abandoned { seat, frame });
309 }
310 }
311 for (msg, from) in self.transport.recv_all() {
312 // Whatever it was, that peer is still there, which is all
313 // `abandon_stalled` and `host_gone` go on.
314 self.mark_heard(from);
315 match msg {
316 // A peer that missed the Start datagram keeps greeting us;
317 // repeat what it is until it stops. A watcher is told it is
318 // watching, so the seat it gets is not one.
319 NetMsg::Hello { name } => {
320 if host {
321 // Its name is written down whether it is at the
322 // table or waiting in line: a peer that queues
323 // mid-round holds no seat to keep it in yet, and
324 // without this it was seated nameless next round.
325 let told = name_from_wire(&name);
326 if !told.is_empty() {
327 self.remember_peer_name(from, &told);
328 }
329 if let Some(queued) = self.queue_place(from) {
330 self.transport.send_to(from, queued);
331 continue;
332 }
333 let seat = self.seat_of(from);
334 if let Some(slot) =
335 seat.and_then(|seat| self.names.get_mut(usize::from(seat)))
336 && !told.is_empty()
337 {
338 *slot = told;
339 }
340 let start = self.start_msg(seat);
341 self.transport.send_to(from, start);
342 }
343 }
344 NetMsg::Watch => {
345 if host {
346 // Watching is no more possible than playing once the
347 // round is under way: a spectator simulates the same
348 // frames as everyone else, from the same frame zero.
349 // The wish is remembered, so a peer that armed W and
350 // dialled in mid-round is seated as an onlooker next
351 // round rather than dealt a chair it never asked for.
352 self.note_watch_wish(from);
353 let answer = self
354 .queue_place(from)
355 .unwrap_or_else(|| self.start_msg(None));
356 self.transport.send_to(from, answer);
357 }
358 }
359 NetMsg::Input(input) => {
360 // The host knows which seat each peer was given, and
361 // takes inputs for that seat alone: a peer speaking for
362 // another's seat (a bug, or a spectator with ideas)
363 // would otherwise be believed by whoever heard it
364 // first, and the seats would diverge. A joiner hears
365 // only the host, which has already done this. The
366 // direct `PINCH_HOST` pair keeps no plan, having never
367 // been through a lobby; with nothing to check against
368 // it takes any seat but its own, as it always did.
369 if host {
370 let allowed = if self.peer_seats.is_empty() {
371 Some(input.player) != self.session.seat()
372 } else {
373 self.seat_of(from) == Some(input.player)
374 };
375 if !allowed {
376 continue;
377 }
378 }
379 self.session.receive(input);
380 if host {
381 // Star topology: joiners only see the host, so the
382 // host forwards every input to the other joiners.
383 for other in 0..self.transport.peer_count() {
384 if other != from {
385 self.transport.send_to(other, NetMsg::Input(input));
386 }
387 }
388 }
389 }
390 NetMsg::Hash { frame, hash } => {
391 // The peer may be ahead of us; buffer until we simulate
392 // that frame ourselves.
393 self.peer_hashes.push_back((frame, hash));
394 while self.peer_hashes.len() > 64 {
395 self.peer_hashes.pop_front();
396 }
397 }
398 NetMsg::Pause { frame } => {
399 self.session.receive_pause(frame);
400 if host {
401 // Star topology again: only the host hears everyone,
402 // so it passes the pause on to the other joiners.
403 for other in 0..self.transport.peer_count() {
404 if other != from {
405 self.transport.send_to(other, NetMsg::Pause { frame });
406 }
407 }
408 }
409 }
410 NetMsg::Resume { frame } => {
411 let frame = self.session.receive_resume(frame);
412 self.resume_echo = RESUME_ECHOES;
413 if host {
414 for other in 0..self.transport.peer_count() {
415 if other != from {
416 self.transport.send_to(other, NetMsg::Resume { frame });
417 }
418 }
419 }
420 }
421 NetMsg::Start {
422 seats,
423 seat,
424 terms,
425 names,
426 round,
427 wins,
428 beach,
429 } if !host && self.is_next_round(&terms) => {
430 self.beach = beach;
431 // The host has called the next round. A fresh seed is
432 // what says so; everything else about the round may
433 // well be the same. Taking it up here only rearms the
434 // session; the board is rebuilt on the way back into
435 // the arena, by the same path a first round uses.
436 let table = std::array::from_fn(|i| name_from_wire(&names[i]));
437 self.begin_round(seats, seat, terms, table);
438 self.series_standing = (terms.series == 1).then_some((round, wins));
439 self.next_round = true;
440 }
441 NetMsg::Start { names, .. } => {
442 // Stale as a launch signal, but a joiner in the direct
443 // PINCH_HOST/PINCH_JOIN pair, which never waits in a
444 // lobby, learns the table's names from it.
445 if !host {
446 for (slot, wire) in self.names.iter_mut().zip(&names) {
447 let name = name_from_wire(wire);
448 if !name.is_empty() {
449 *slot = name;
450 }
451 }
452 }
453 }
454 // Someone on another build is talking to this port. Every
455 // peer in a running match paired before it started, so this
456 // is a stranger, not a member: the match plays on without it.
457 NetMsg::Incompatible { .. } => {}
458 // Only a host hands these out, and a host never receives
459 // one: a joiner in the queue is still in the lobby.
460 NetMsg::Queued { .. } => {}
461 // Chat is a lobby thing. A line that arrives mid-round is
462 // from a peer still sitting on its lobby screen, and there
463 // is nowhere here to show it.
464 NetMsg::Chat { .. } => {}
465 NetMsg::Abandoned { seat, frame } => {
466 // The host's word, not our own patience. Idempotent,
467 // because it is repeated against packet loss.
468 if !host && !self.abandoned.iter().any(|(gone, _)| *gone == seat) {
469 self.session.abandon(seat, frame);
470 self.abandoned.push((seat, frame));
471 }
472 }
473 // The lobby's business, and the lobby is behind us.
474 NetMsg::Roster { .. } => {}
475 }
476 }
477 self.compare_hashes();
478 // At most a couple of frames per fixed tick: a lagging peer catches
479 // up gradually instead of spiralling.
480 for _ in 0..2 {
481 tick(self);
482 }
483 self.compare_hashes();
484 committed
485 }
486
487 fn compare_hashes(&mut self) {
488 for &(frame, peer) in &self.peer_hashes {
489 if let Some(&(_, own)) = self.own_hashes.iter().find(|(f, _)| *f == frame)
490 && own != peer
491 {
492 self.desync_at.get_or_insert(frame);
493 }
494 }
495 }
496
497 /// Called by the tick closure after simulating a frame, with the fresh
498 /// state hash.
499 pub fn after_frame(&mut self, hash: u64) {
500 let frame = self.session.frame();
501 if frame.is_multiple_of(HASH_INTERVAL) {
502 self.own_hashes.push_back((frame, hash));
503 while self.own_hashes.len() > 16 {
504 self.own_hashes.pop_front();
505 }
506 self.transport.send(NetMsg::Hash { frame, hash });
507 }
508 }
509}
510
511/// Parse the dev env vars into a ready session, if requested.
512/// `PINCH_HOST=47777` or `PINCH_JOIN=192.168.1.10:47777`.
513pub fn session_from_env() -> Option<OnlineSession> {
514 // The direct hooks skip the lobby, so there is no Start to agree on:
515 // both processes must be told the same terms. `PINCH_BOTS=n` seats n AI
516 // players behind the two humans and has to be set on both sides.
517 let bots: u8 = crate::app::dev::bots().unwrap_or(0);
518 // Two humans and the AI behind them; MAX_PLAYERS is the ceiling, and
519 // the clamp is on the bots so a wild PINCH_BOTS cannot wrap the sum
520 // back under the two humans.
521 let humans = 2u8;
522 let bots = bots.min(crate::sim::MAX_PLAYERS as u8 - humans);
523 let seats = humans + bots;
524 let terms = MatchTerms {
525 bots,
526 ..crate::app::match_setup::terms(
527 &crate::app::match_setup::MatchConfig::default(),
528 crate::app::teams::TeamMode::Solo,
529 0,
530 )
531 };
532 // The name this player goes by online: their P1 name from settings,
533 // read straight off disk because the dev hooks run before the app's
534 // resources are wired up.
535 let own = crate::app::settings::GameSettings::load().names[0].clone();
536 if let Some(port) = crate::app::dev::direct_host() {
537 let port: u16 = port.parse().ok()?;
538 let transport = UdpTransport::host(port).ok()?;
539 info!("hosting on UDP port {port}, waiting for a peer ({seats} seats)");
540 let mut session = OnlineSession::new(
541 transport,
542 Lockstep::new(0, vec![0, 1], crate::sim::DEFAULT_DELAY),
543 seats,
544 terms,
545 );
546 session.names[0] = own;
547 return Some(session);
548 }
549 if let Some(addr) = crate::app::dev::direct_join() {
550 let transport = UdpTransport::join(addr.as_str()).ok()?;
551 transport.send(NetMsg::hello(&own));
552 info!("joining {addr} ({seats} seats)");
553 let mut session = OnlineSession::new(
554 transport,
555 Lockstep::new(1, vec![0, 1], crate::sim::DEFAULT_DELAY),
556 seats,
557 terms,
558 );
559 session.names[1] = own;
560 return Some(session);
561 }
562 None
563}