use crate::sim::{HASH_INTERVAL, Lockstep, MAX_PLAYERS, PlayerAction};
use crate::transport::{
Announcer, MatchTerms, NetMsg, SeriesStanding, UdpTransport, name_from_wire, table_from_wire,
wire_table,
};
use bevy::prelude::*;
pub use peers::{Peer, PeerBook, Place};
use std::collections::VecDeque;
const RESUME_ECHOES: u8 = 30;
pub struct OnlineSession {
pub transport: UdpTransport,
pub session: Lockstep,
pub seats: u8,
pub terms: MatchTerms,
pub beach: Vec<u8>,
pub(crate) peers: PeerBook,
pub names: [String; MAX_PLAYERS],
hashes: HashCheck,
stall: StallWatch,
pub abandoned: Vec<(u8, u32)>,
pub home: Home,
pub next_round: bool,
pub series_standing: Option<SeriesStanding>,
resume_echo: u8,
}
pub struct Home {
announcer: Farewell,
pub game_name: String,
announce_in: f32,
pub from_lobby: bool,
greet_in: f32,
}
impl Home {
fn nowhere() -> Home {
Home {
announcer: Farewell::silent(),
game_name: String::new(),
announce_in: 0.0,
from_lobby: false,
greet_in: 0.0,
}
}
}
#[derive(Default)]
struct StallWatch {
stalled_for: f32,
stalled_on: u32,
waiting_on: Option<u8>,
waiting_hold: f32,
}
impl StallWatch {
fn reset(&mut self, frame: u32) {
let Self {
stalled_for,
stalled_on,
waiting_on,
waiting_hold,
} = self;
*stalled_for = 0.0;
*stalled_on = frame;
*waiting_hold = 0.0;
*waiting_on = None;
}
}
#[derive(Default)]
struct HashCheck {
desync_at: Option<u32>,
own: VecDeque<(u32, u64)>,
peers: VecDeque<(u32, u64)>,
}
impl HashCheck {
fn record_own(&mut self, frame: u32, hash: u64) {
self.own.push_back((frame, hash));
while self.own.len() > 16 {
self.own.pop_front();
}
}
fn record_peer(&mut self, frame: u32, hash: u64) {
self.peers.push_back((frame, hash));
while self.peers.len() > 64 {
self.peers.pop_front();
}
}
fn compare(&mut self) {
for &(frame, peer) in &self.peers {
if let Some(&(_, own)) = self.own.iter().find(|(f, _)| *f == frame)
&& own != peer
{
self.desync_at.get_or_insert(frame);
}
}
}
fn reset(&mut self) {
*self = HashCheck::default();
}
}
struct Farewell {
announcer: Option<Announcer>,
port: u16,
}
impl Farewell {
fn silent() -> Farewell {
Farewell {
announcer: None,
port: 0,
}
}
fn aboard(&self) -> Option<&Announcer> {
self.announcer.as_ref()
}
fn disarm(mut self) -> Option<Announcer> {
self.announcer.take()
}
}
impl Drop for Farewell {
fn drop(&mut self) {
if let Some(announcer) = &self.announcer {
announcer.closing(self.port);
}
}
}
pub struct LobbyReturn {
pub announcer: Option<Announcer>,
pub transport: UdpTransport,
pub game_name: String,
pub peers: PeerBook,
pub watching: bool,
pub host: bool,
pub played_seed: u64,
}
#[derive(Resource, Default)]
pub struct Online(pub Option<OnlineSession>);
pub(crate) fn relay(transport: &UdpTransport, from: usize, msg: NetMsg) {
for other in 0..transport.peer_count() {
if other != from {
transport.send_to(other, msg.clone());
}
}
}
pub fn poll_between_rounds(time: Res<Time>, mut online: ResMut<Online>) {
if let Some(session) = &mut online.0 {
session.poll_between_rounds(time.delta_secs());
}
}
mod peers;
mod presence;
mod rounds;
pub(crate) use presence::{abandon_the_departed, leave_a_hostless_round};
pub use rounds::Invitation;
pub(crate) use rounds::lockstep_for;
impl OnlineSession {
pub fn new(
transport: UdpTransport,
session: Lockstep,
seats: u8,
terms: MatchTerms,
) -> OnlineSession {
OnlineSession {
transport,
session,
seats,
terms,
beach: Vec::new(),
peers: PeerBook::default(),
names: Default::default(),
hashes: HashCheck::default(),
stall: StallWatch::default(),
abandoned: Vec::new(),
home: Home::nowhere(),
next_round: false,
series_standing: None,
resume_echo: 0,
}
}
pub fn keep_announcing(&mut self, delta: f32) {
if self.home.announcer.aboard().is_none()
|| !crate::app::lobby::once_a_second(&mut self.home.announce_in, delta)
{
return;
}
let taken = self.players_spoken_for();
let (Some(announcer), Ok(addr)) =
(self.home.announcer.aboard(), self.transport.local_addr())
else {
return;
};
announcer.running(
addr.port(),
crate::transport::OnAir {
name: &self.home.game_name,
host: &self.names[0],
taken,
seats: MAX_PLAYERS as u8,
},
);
}
pub fn stay_on_air(&mut self, announcer: Announcer) {
let port = self
.transport
.local_addr()
.map(|addr| addr.port())
.unwrap_or(0);
self.home.announcer = Farewell {
announcer: Some(announcer),
port,
};
}
pub fn back_to_the_lobby(mut self) -> LobbyReturn {
let host = self.is_host();
let watching = self.watching();
for peer in 0..self.peers.len() {
if let Some(name) = self.peer_name(peer) {
let name = name.to_string();
self.peers.row(peer).name = name;
}
}
self.peers.unseat();
let Self {
transport,
session: _,
seats: _,
terms,
beach: _,
peers,
names: _,
hashes: _,
stall: _,
abandoned: _,
home,
next_round: _,
series_standing: _,
resume_echo: _,
} = self;
let Home {
announcer,
game_name,
announce_in: _,
from_lobby: _,
greet_in: _,
} = home;
LobbyReturn {
announcer: announcer.disarm(),
transport,
game_name,
peers,
watching,
host,
played_seed: terms.seed,
}
}
pub fn desync_at(&self) -> Option<u32> {
self.hashes.desync_at
}
pub fn request_pause(&mut self) {
if let Some(frame) = self.session.request_pause() {
self.transport.send(NetMsg::Pause { frame });
}
}
pub fn request_resume(&mut self) {
if self.watching() {
return;
}
let frame = self.session.resume();
self.resume_echo = RESUME_ECHOES;
self.transport.send(NetMsg::Resume { frame });
}
fn resume_msg(&self) -> NetMsg {
NetMsg::Resume {
frame: self.session.lifted_pause().unwrap_or(0),
}
}
pub fn is_host(&self) -> bool {
self.session.seat() == Some(0)
}
pub fn watching(&self) -> bool {
self.session.watching()
}
pub(crate) fn players_spoken_for(&self) -> u8 {
let queued = self
.peers
.iter()
.filter(|peer| peer.place == peers::Place::Queued && !peer.watch)
.count();
(self.session.player_count() + queued).min(MAX_PLAYERS) as u8
}
pub fn pump(&mut self, local_action: PlayerAction, mut tick: impl FnMut(&mut Self)) -> bool {
let committed = self.session.commit_local(local_action).is_some();
for &msg in self.session.recent_commits() {
self.transport.send(NetMsg::Input(msg));
}
match self.session.pause_frame() {
Some(frame) => self.transport.send(NetMsg::Pause { frame }),
None if self.resume_echo > 0 => {
self.resume_echo -= 1;
self.transport.send(self.resume_msg());
}
None => {}
}
let host = self.is_host();
let mut rearmed = false;
if host {
for &(seat, frame) in &self.abandoned {
self.transport.send(NetMsg::Abandoned { seat, frame });
}
}
for (msg, from) in self.transport.recv_all() {
self.mark_heard(from);
match msg {
NetMsg::Hello { name } => {
if host {
let answer = self.answer_greeting(from, &name_from_wire(&name), false);
self.transport.send_to(from, answer);
}
}
NetMsg::Watch => {
if host {
let answer = self.answer_greeting(from, "", true);
self.transport.send_to(from, answer);
}
}
NetMsg::Input(input) => {
if host {
let allowed = if self.peers.planned() == 0 {
Some(input.player) != self.session.seat()
} else {
self.seat_of(from) == Some(input.player)
};
if !allowed {
continue;
}
}
self.session.receive(input);
if host {
relay(&self.transport, from, NetMsg::Input(input));
}
}
NetMsg::Hash { frame, hash } => {
self.hashes.record_peer(frame, hash);
}
NetMsg::Pause { frame } => {
self.session.receive_pause(frame);
if host {
relay(&self.transport, from, NetMsg::Pause { frame });
}
}
NetMsg::Resume { frame } => {
let frame = self.session.receive_resume(frame);
self.resume_echo = RESUME_ECHOES;
if host {
relay(&self.transport, from, NetMsg::Resume { frame });
}
}
NetMsg::Start {
seats,
seat,
terms,
names,
standing,
beach,
} if !host && self.is_next_round(&terms) => {
self.take_up(Invitation {
seats,
seat,
terms,
names,
standing,
beach,
});
self.next_round = true;
rearmed = true;
}
NetMsg::Start { names, .. } => {
if !host {
for (slot, name) in self.names.iter_mut().zip(table_from_wire(&names)) {
if !name.is_empty() {
*slot = name;
}
}
}
}
NetMsg::Incompatible { .. } => {}
NetMsg::Queued { .. } => {}
NetMsg::Chat { .. } => {}
NetMsg::Abandoned { seat, frame } => {
if !host && !self.abandoned.iter().any(|(gone, _)| *gone == seat) {
self.session.abandon(seat, frame);
self.abandoned.push((seat, frame));
}
}
NetMsg::Roster { .. } => {}
}
}
self.hashes.compare();
if rearmed {
return committed;
}
for _ in 0..2 {
tick(self);
}
self.hashes.compare();
committed
}
pub fn after_frame(&mut self, hash: u64) {
let frame = self.session.frame();
if frame.is_multiple_of(HASH_INTERVAL) {
self.hashes.record_own(frame, hash);
self.transport.send(NetMsg::Hash { frame, hash });
}
}
}
pub fn session_from_env() -> Option<OnlineSession> {
let bots: u8 = crate::app::dev::bots().unwrap_or(0);
let humans = 2u8;
let bots = bots.min(crate::sim::MAX_PLAYERS as u8 - humans);
let seats = humans + bots;
let terms = MatchTerms {
bots,
..crate::app::match_setup::terms(
&crate::app::match_setup::MatchConfig::default(),
crate::app::teams::TeamMode::Solo,
0,
)
};
let own = crate::app::settings::GameSettings::load().0.names[0].clone();
if let Some(port) = crate::app::dev::direct_host() {
let port: u16 = port.parse().ok()?;
let transport = UdpTransport::host(port).ok()?;
info!("hosting on UDP port {port}, waiting for a peer ({seats} seats)");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], crate::sim::DEFAULT_DELAY),
seats,
terms,
);
session.names[0] = own;
return Some(session);
}
if let Some(addr) = crate::app::dev::direct_join() {
let transport = UdpTransport::join(addr.as_str()).ok()?;
transport.send(NetMsg::hello(&own));
info!("joining {addr} ({seats} seats)");
let mut session = OnlineSession::new(
transport,
Lockstep::new(1, vec![0, 1], crate::sim::DEFAULT_DELAY),
seats,
terms,
);
session.names[1] = own;
return Some(session);
}
None
}
#[cfg(test)]
mod homecoming_tests {
use super::*;
use crate::sim::DEFAULT_DELAY;
use crate::transport::{Beacon, Discovery};
fn hosting_session(seed: u64) -> OnlineSession {
OnlineSession::new(
UdpTransport::host(0).expect("game socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms {
seed,
..MatchTerms::default()
},
)
}
fn heard_for(discovery: &mut Discovery, port: u16) -> Vec<Beacon> {
let mut heard = Vec::new();
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(10));
heard.extend(
discovery
.poll()
.into_iter()
.filter(|(addr, _)| addr.port() == port)
.map(|(_, beacon)| beacon),
);
if !heard.is_empty() {
break;
}
}
heard
}
#[test]
fn a_dropped_session_still_says_goodbye() {
let mut discovery = Discovery::bind().expect("lobby port");
let mut session = hosting_session(0);
let port = session.transport.local_addr().expect("addr").port();
session.stay_on_air(Announcer::new(0xD00D).expect("announcer"));
drop(session);
let heard = heard_for(&mut discovery, port);
let beacon = heard.first().expect("the goodbye went out");
assert!(matches!(beacon, Beacon::Closing { .. }), "{beacon:?}");
}
#[test]
fn the_way_back_to_the_lobby_says_no_goodbye() {
let mut discovery = Discovery::bind().expect("lobby port");
let mut session = hosting_session(42);
let port = session.transport.local_addr().expect("addr").port();
session.stay_on_air(Announcer::new(0xB0A7).expect("announcer"));
session.home.game_name = "Room 3".into();
session.home.from_lobby = true;
let returned = session.back_to_the_lobby();
assert!(returned.host);
assert!(!returned.watching);
assert_eq!(returned.played_seed, 42, "the round it walked out of");
assert_eq!(returned.game_name, "Room 3");
assert_eq!(
returned.transport.local_addr().expect("addr").port(),
port,
"the same socket every peer is still talking to"
);
assert!(
returned.announcer.is_some(),
"the beacon rides home to announce again"
);
std::thread::sleep(std::time::Duration::from_millis(40));
assert!(
discovery
.poll()
.into_iter()
.all(|(addr, _)| addr.port() != port),
"no goodbye for a beach that is coming home"
);
}
#[test]
fn the_table_walks_back_with_its_names_and_watchers() {
let mut session = hosting_session(0);
let port = session.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
let rail = UdpTransport::join(("127.0.0.1", port)).expect("join");
for want in [1, 2] {
if want == 2 {
rail.send(NetMsg::Watch);
}
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
for (msg, from) in session.transport.recv_all() {
match msg {
NetMsg::Hello { name } => {
let told = name_from_wire(&name);
session.remember_peer_name(from, &told);
}
NetMsg::Watch => session.note_watch_wish(from),
NetMsg::Input(_)
| NetMsg::Hash { .. }
| NetMsg::Start { .. }
| NetMsg::Pause { .. }
| NetMsg::Resume { .. }
| NetMsg::Queued { .. }
| NetMsg::Chat { .. }
| NetMsg::Roster { .. }
| NetMsg::Abandoned { .. }
| NetMsg::Incompatible { .. } => {}
}
}
if session.transport.peer_count() >= want {
break;
}
}
assert_eq!(session.transport.peer_count(), want, "peer registered");
}
session.peers.deal(&[Some(1), None]);
let returned = session.back_to_the_lobby();
let names: Vec<&str> = returned.peers.iter().map(|p| p.name.as_str()).collect();
assert_eq!(
names,
["Bo", ""],
"each chair keeps the name its greeting carried"
);
let watching: Vec<bool> = returned.peers.iter().map(Peer::watches).collect();
assert_eq!(watching, [false, true], "and the rail stays the rail");
}
#[test]
fn a_watching_joiner_comes_home_to_the_rail() {
let session = OnlineSession::new(
UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
Lockstep::observer(vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms {
seed: 7,
..MatchTerms::default()
},
);
let returned = session.back_to_the_lobby();
assert!(!returned.host);
assert!(returned.watching, "the rail is remembered");
assert!(returned.announcer.is_none(), "a joiner has no beacon");
assert_eq!(returned.played_seed, 7);
}
}
#[cfg(test)]
mod table_tests {
use super::*;
use crate::sim::DEFAULT_DELAY;
fn session(local: Option<u8>, port: u16) -> OnlineSession {
let transport = match local {
Some(0) => UdpTransport::host(0).expect("host socket"),
_ => UdpTransport::join(("127.0.0.1", port)).expect("join"),
};
OnlineSession::new(
transport,
match local {
Some(seat) => Lockstep::new(seat, vec![0, 1], DEFAULT_DELAY),
None => Lockstep::observer(vec![0, 1], DEFAULT_DELAY),
},
2,
MatchTerms::default(),
)
}
#[test]
fn the_beacon_counts_the_queue_as_taken() {
let mut host = session(Some(0), 0);
assert_eq!(host.players_spoken_for(), 2, "the two at the table");
host.peers.row(0).name = "Bo".into();
assert_eq!(host.players_spoken_for(), 3, "one in line for a chair");
host.peers.row(1).watch = true;
assert_eq!(host.players_spoken_for(), 3, "a watcher wants no chair");
for peer in 2..8 {
host.peers.row(peer).name = "Cy".into();
}
assert_eq!(
host.players_spoken_for(),
MAX_PLAYERS as u8,
"and never more chairs than the table has"
);
}
#[test]
fn a_seat_told_of_its_own_abandonment_knows_it_was_dropped() {
let host = session(Some(0), 0);
let port = host.transport.local_addr().expect("addr").port();
let mut joiner = session(Some(1), port);
assert!(!joiner.dropped(), "nothing said yet");
joiner.abandoned.push((0, 30));
assert!(!joiner.dropped(), "another seat's abandonment is theirs");
joiner.abandoned.push((1, 30));
assert!(joiner.dropped(), "its own is the word it was waiting for");
let mut host = host;
host.abandoned.push((0, 30));
assert!(!host.dropped(), "the host drops nobody it is");
let mut watcher = session(None, port);
watcher.abandoned.push((1, 30));
assert!(
!watcher.dropped(),
"a spectator holds no seat to be dropped from"
);
}
}
#[cfg(test)]
mod hash_tests {
use super::*;
#[test]
fn a_peer_hash_that_arrives_early_is_still_weighed() {
let mut hashes = HashCheck::default();
hashes.record_peer(60, 0xBAD_B0A7);
hashes.compare();
assert_eq!(hashes.desync_at, None, "nothing to compare it to yet");
hashes.record_own(30, 0xC0FFEE);
hashes.record_peer(30, 0xC0FFEE);
hashes.compare();
assert_eq!(hashes.desync_at, None, "and they agree about frame 30");
hashes.record_own(60, 0x600D_B0A7);
hashes.compare();
assert_eq!(hashes.desync_at, Some(60), "the waiting hash caught it");
}
#[test]
fn a_round_that_agrees_is_never_flagged_and_a_new_one_starts_level() {
let mut hashes = HashCheck::default();
for frame in (0..300).step_by(HASH_INTERVAL as usize) {
let hash = u64::from(frame) * 0x9E37_79B9;
hashes.record_own(frame, hash);
hashes.record_peer(frame, hash);
hashes.compare();
}
assert_eq!(hashes.desync_at, None, "every frame agreed");
hashes.record_own(30, 1);
hashes.record_peer(30, 2);
hashes.compare();
assert_eq!(hashes.desync_at, Some(30));
hashes.reset();
hashes.compare();
assert_eq!(hashes.desync_at, None, "and the new board is level");
}
}