Skip to main content

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}