use super::*;
use crate::app::i18n::fill;
use crate::app::match_setup::TermsDials;
use crate::app::settings::GameSettings;
use crate::app::side_panels::EventLog;
use crate::app::{Controllers, Paused, RoundNotice, Screen, SeatController, SeatNames, palette};
#[derive(Default)]
pub(super) struct StallWatch {
pub(super) stalled_for: f32,
pub(super) stalled_on: u32,
pub(super) waiting_on: Option<u8>,
pub(super) waiting_hold: f32,
}
impl StallWatch {
pub(super) 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;
}
}
pub(super) const ABANDON_AFTER: f32 = 8.0;
const WATCHER_GONE_AFTER: f32 = 10.0;
const HOST_GONE_AFTER: f32 = 20.0;
const QUIET_HOST: f32 = 1.0;
const GOODBYE_GRACE: f32 = 0.3;
fn has_gone(peer: &Peer, patience: f32) -> bool {
match peer.goodbye.said() {
true => peer.goodbye.stood(GOODBYE_GRACE),
false => peer.silence >= patience,
}
}
const SAY_WAITING_AFTER: f32 = 0.6;
const SAY_WAITING_FOR: f32 = 1.5;
const _: () = assert!(SAY_WAITING_FOR > SAY_WAITING_AFTER);
impl OnlineSession {
pub(super) fn mark_heard(&mut self, peer: usize) {
self.peers.heard(peer);
}
pub(super) fn age_the_silence(&mut self, delta: f32) {
self.peers.reach(self.transport.peer_count());
self.peers.age(delta);
}
fn greet_a_quiet_host(&mut self, delta: f32) {
if self.is_host() {
return;
}
let quiet = self
.peers
.get(0)
.is_none_or(|host| host.silence >= QUIET_HOST);
if !quiet {
self.home.greet_in = 0.0;
return;
}
if crate::app::lobby::once_a_second(&mut self.home.greet_in, delta) {
self.transport.send(self.own_greeting());
}
}
fn seat_holder(&self, seat: u8) -> Option<&Peer> {
let peer = self.peers.holder_of(seat)?;
self.peers.get(peer)
}
pub fn host_gone(&self) -> bool {
!self.is_host()
&& self
.peers
.get(0)
.is_some_and(|host| has_gone(host, HOST_GONE_AFTER))
}
pub fn keep_in_touch(&mut self, delta: f32) {
self.age_the_silence(delta);
self.greet_a_quiet_host(delta);
if self.is_host() {
self.forget_gone_bystanders();
}
}
pub fn abandon_stalled(&mut self, delta: f32, paused: bool) -> Vec<u8> {
if paused || self.session.paused() {
self.stall.reset(self.session.frame());
return Vec::new();
}
let mine = self.session.seat();
let waiting: Vec<u8> = self
.session
.awaiting()
.into_iter()
.filter(|seat| Some(*seat) != mine)
.collect();
let at = self.session.frame();
let stall = &mut self.stall;
if at != stall.stalled_on || waiting.is_empty() {
stall.stalled_on = at;
stall.stalled_for = 0.0;
stall.waiting_hold = (stall.waiting_hold - delta).max(0.0);
if stall.waiting_hold == 0.0 {
stall.waiting_on = None;
}
return Vec::new();
}
stall.stalled_for += delta;
if stall.stalled_for > SAY_WAITING_AFTER {
stall.waiting_hold = SAY_WAITING_FOR;
stall.waiting_on = waiting.first().copied();
}
if !self.is_host() {
return Vec::new();
}
let served = self.stall.stalled_for >= ABANDON_AFTER;
let gone: Vec<u8> = waiting
.into_iter()
.filter(|seat| match self.seat_holder(*seat) {
Some(peer) => (peer.left() || served) && has_gone(peer, ABANDON_AFTER),
None => served,
})
.collect();
if gone.is_empty() {
return Vec::new();
}
self.stall.stalled_for = 0.0;
let frame = self.session.frame();
for seat in &gone {
self.session.abandon(*seat, frame);
self.transport.send(NetMsg::Abandoned {
round: self.round_tag(),
seat: *seat,
frame,
});
if !self.abandoned.iter().any(|(held, _)| held == seat) {
self.abandoned.push((*seat, frame));
}
self.forget_seat(*seat);
}
gone
}
pub(super) fn release_a_departed_pause(&mut self, peer: usize) {
let holds = self.is_host()
&& self.session.pause_frame().is_some()
&& self.peers.get(peer).and_then(|row| row.pause) == self.session.pause_frame();
if holds {
self.request_resume();
}
}
fn forget_seat(&mut self, seat: u8) {
debug_assert!(usize::from(seat) < MAX_PLAYERS, "no such seat: {seat}");
let Some(peer) = self.peers.holder_of(seat) else {
return;
};
self.forget_peer(peer);
debug_assert!(
self.peers.len() <= self.transport.peer_count(),
"a peer's row outlived the peer it was kept for"
);
}
pub(super) fn forget_peer(&mut self, peer: usize) {
self.transport.forget(peer);
self.peers.forget(peer);
self.stands.tide.votes.voter_left(peer);
self.hashes.forget(peer);
self.catch_up.forget(peer);
}
pub(super) fn forget_gone_bystanders(&mut self) {
let lobby = self.home.from_lobby;
let gone = self.peers.to_forget(|row| {
let bystander = match row.place {
Place::Watching | Place::LateWatching => true,
Place::Queued => lobby,
Place::Seated(_) => false,
};
bystander && row.silence >= WATCHER_GONE_AFTER
});
for peer in gone {
self.forget_peer(peer);
}
}
pub(super) fn forget_the_silent(&mut self) {
debug_assert!(self.is_host(), "only the host keeps the table");
for peer in self.peers.to_forget(|row| row.silence >= ABANDON_AFTER) {
self.forget_peer(peer);
}
}
pub fn dropped(&self) -> bool {
!self.is_host()
&& self
.session
.seat()
.is_some_and(|mine| self.abandoned.iter().any(|(seat, _)| *seat == mine))
}
pub fn waiting_on(&self) -> Option<u8> {
self.stall
.waiting_on
.filter(|_| self.stall.waiting_hold > 0.0)
}
}
pub(crate) fn abandon_the_departed(
time: Res<Time>,
paused: Res<Paused>,
settings: Res<GameSettings>,
names: Res<SeatNames>,
mut online: ResMut<Online>,
mut controllers: ResMut<Controllers>,
mut log: ResMut<EventLog>,
) {
let Some(session) = &mut online.0 else {
return;
};
session.keep_in_touch(time.delta_secs());
session.abandon_stalled(time.delta_secs(), paused.0);
let level = session.terms.bot_level();
let tr = settings.tr();
for (seat, _) in &session.abandoned {
let Some(slot) = controllers.0.get_mut(usize::from(*seat)) else {
continue;
};
if slot.ai().is_some() {
continue;
}
*slot = SeatController::Ai(level);
log.push(
fill(tr.online_seat_abandoned, &[("p", &names.label(tr, *seat))]),
palette::seat_text(*seat),
);
}
}
const CATCH_UP_PATIENCE: f32 = 3.0;
pub(crate) fn catch_up_again(
time: Res<Time>,
mut online: ResMut<Online>,
mut homecoming: ResMut<crate::app::lobby::Homecoming>,
mut next_screen: ResMut<NextState<Screen>>,
mut stuck: Local<f32>,
) {
let waiting = online.0.as_ref().is_some_and(|session| {
session.catch_up.from_frame == Some(session.session.frame()) && !session.session.paused()
});
if !waiting {
*stuck = 0.0;
return;
}
*stuck += time.delta_secs();
if *stuck < CATCH_UP_PATIENCE {
return;
}
*stuck = 0.0;
if let Some(session) = online.0.take() {
homecoming.0 = Some(session.back_to_the_lobby());
next_screen.set(Screen::Lobby);
}
}
fn dropped_line(tr: &crate::app::i18n::Tr) -> String {
fill(tr.online_dropped, &[("s", &ABANDON_AFTER.to_string())])
}
pub(crate) fn leave_a_hostless_round(
settings: Res<GameSettings>,
online: Res<Online>,
mut notice: ResMut<RoundNotice>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let Some(session) = online.0.as_ref() else {
return;
};
let tr = settings.tr();
let why = if let Some(why) = session.put_out {
why.feedback(tr)
} else if session.dropped() {
dropped_line(tr)
} else if session.host_gone() {
tr.online_host_gone.to_string()
} else {
return;
};
notice.0 = why;
next_screen.set(match session.home.from_lobby {
true => Screen::Lobby,
false => Screen::Menu,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::session::fill_bot_actions;
use crate::sim::{DEFAULT_DELAY, PlayerAction, classic_arena};
use crate::transport::UdpTransport;
fn tick(session: &mut OnlineSession, delta: f32, paused: bool) -> Vec<u8> {
session.keep_in_touch(delta);
session.abandon_stalled(delta, paused)
}
#[test]
fn a_paused_round_is_not_an_abandoned_one() {
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
session.request_pause();
let gone = tick(&mut session, ABANDON_AFTER * 3.0, false);
assert!(gone.is_empty(), "gave up on {gone:?} for pausing");
assert!(session.abandoned.is_empty());
session.session.resume();
let gone = tick(&mut session, ABANDON_AFTER - 0.1, false);
assert!(gone.is_empty(), "the timer restarts with the round");
let gone = tick(&mut session, 0.2, false);
assert_eq!(gone, vec![1], "and a seat that really is silent still goes");
}
#[test]
fn a_dropped_seat_is_told_the_patience_the_host_has() {
for lang in crate::app::i18n::ALL_LANGS {
let line = dropped_line(lang.tr());
assert!(
line.contains(&ABANDON_AFTER.to_string()),
"{lang:?}: {line}"
);
assert!(!line.contains('{'), "{lang:?}: {line}");
}
}
#[test]
fn a_player_who_said_goodbye_is_not_waited_for() {
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
session.peers.said_goodbye(0);
assert!(session.peers.get(0).is_some_and(Peer::left));
assert!(tick(&mut session, 0.1, false).is_empty());
assert!(
tick(&mut session, 0.1, false).is_empty(),
"a moment for an input that crossed the goodbye"
);
assert_eq!(
tick(&mut session, GOODBYE_GRACE, false),
vec![1],
"and not the seconds a silent seat gets"
);
assert_eq!(session.peers.len(), 0, "forgotten with its seat");
}
#[test]
fn a_peer_that_is_still_talking_keeps_its_castle() {
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
let frame = 0.1;
for _ in 0..(ABANDON_AFTER * 4.0 / frame) as usize {
session.mark_heard(0);
assert!(
tick(&mut session, frame, false).is_empty(),
"gave up on somebody who is right there"
);
}
assert!(session.abandoned.is_empty());
let mut gone = Vec::new();
for _ in 0..(ABANDON_AFTER * 2.0 / frame) as usize {
gone = tick(&mut session, frame, false);
if !gone.is_empty() {
break;
}
}
assert_eq!(gone, vec![1], "a seat that really is silent still goes");
assert_eq!(session.abandoned.len(), 1);
assert_eq!(session.abandoned[0].0, 1);
}
#[test]
fn a_goodbye_taken_back_by_talking_keeps_the_castle() {
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
for _ in 0..12 {
session.mark_heard(0);
assert!(tick(&mut session, 0.1, false).is_empty());
}
session.peers.said_goodbye(0);
assert!(
tick(&mut session, 0.1, false).is_empty(),
"a moment from the goodbye, whatever the stall"
);
session.mark_heard(0);
assert!(!session.peers.get(0).is_some_and(Peer::left));
for _ in 0..30 {
session.mark_heard(0);
assert!(
tick(&mut session, 0.1, false).is_empty(),
"a forged goodbye cost a castle"
);
}
assert!(session.abandoned.is_empty());
}
#[test]
fn a_pause_goes_with_the_player_who_called_it() {
for leaver_paused in [true, false] {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("host socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
let port = host.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert_eq!(host.transport.peer_count(), 1, "Bo is aboard");
host.peers.deal(&[Some(1)]);
if leaver_paused {
let mut bos = Lockstep::new(1, vec![0, 1], DEFAULT_DELAY);
let frame = bos.request_pause().expect("a player may pause");
bo.send(NetMsg::Pause { round: 0, frame });
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
} else {
host.request_pause();
let frame = host.session.pause_frame().expect("paused");
bo.send(NetMsg::Pause { round: 0, frame });
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
}
assert!(host.session.paused());
drop(bo);
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert!(host.peers.get(0).is_some_and(Peer::left), "said goodbye");
assert_eq!(
host.session.paused(),
!leaver_paused,
"the leaver paused: {leaver_paused}"
);
}
}
#[test]
fn a_watcher_leaving_does_not_lift_the_pause() {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("host socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
let port = host.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
let dee = UdpTransport::join(("127.0.0.1", port)).expect("join");
for (socket, msg) in [(&bo, NetMsg::hello("Bo")), (&dee, NetMsg::watch("Dee"))] {
socket.send(msg);
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
}
assert_eq!(host.transport.peer_count(), 2, "Bo and Dee are aboard");
host.peers.deal(&[Some(1), None]);
let mut bos = Lockstep::new(1, vec![0, 1], DEFAULT_DELAY);
let frame = bos.request_pause().expect("a player may pause");
bo.send(NetMsg::Pause { round: 0, frame });
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
dee.send(NetMsg::Pause { round: 0, frame });
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert!(host.session.paused());
drop(dee);
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert!(
host.peers.get(1).is_some_and(Peer::left),
"Dee said goodbye"
);
assert!(host.session.paused(), "Dee's goodbye lifted Bo's pause");
drop(bo);
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert!(!host.session.paused(), "and Bo's own goodbye lifts it");
}
#[test]
fn a_player_in_line_who_leaves_mid_round_frees_the_place() {
let mut host = hosting(vec![0, 1]);
host.home.from_lobby = true;
let port = host.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
host.peers.deal(&[Some(1)]);
let cy = UdpTransport::join(("127.0.0.1", port)).expect("join");
cy.send(NetMsg::hello("Cy"));
std::thread::sleep(std::time::Duration::from_millis(20));
host.pump(PlayerAction::None, |_| {});
assert_eq!(host.transport.peer_count(), 2, "Cy is aboard");
assert_eq!(host.players_spoken_for(), 3, "Cy is owed a chair");
drop(cy);
std::thread::sleep(std::time::Duration::from_millis(20));
bo.send(NetMsg::Hash {
round: 0,
frame: 0,
hash: 0,
});
host.pump(PlayerAction::None, |_| {});
host.keep_in_touch(0.0);
assert_eq!(host.transport.peer_count(), 1, "Cy is forgotten");
assert_eq!(host.players_spoken_for(), 2, "and no chair is held for it");
assert_eq!(host.peers.seat_of(0), Some(1), "Bo keeps the seat");
}
#[test]
fn a_joiner_gives_up_on_a_host_that_has_gone() {
let transport = UdpTransport::host(0).expect("game socket");
let mut joiner = OnlineSession::new(
transport,
Lockstep::new(1, vec![0, 1], 0),
2,
MatchTerms::default(),
);
assert!(!joiner.host_gone(), "nothing has had time to go wrong");
joiner.mark_heard(0);
joiner.keep_in_touch(HOST_GONE_AFTER - 1.0);
assert!(!joiner.host_gone(), "still within a fair stumble");
joiner.keep_in_touch(2.0);
assert!(joiner.host_gone(), "and then the host is gone");
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("game socket"),
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
host.peers.deal(&[Some(1)]);
host.keep_in_touch(HOST_GONE_AFTER * 2.0);
assert!(!host.host_gone(), "the host is the host");
}
#[test]
fn a_hosts_goodbye_is_believed_after_the_grace() {
let mut joiner = OnlineSession::new(
UdpTransport::host(0).expect("game socket"),
Lockstep::new(1, vec![0, 1], 0),
2,
MatchTerms::default(),
);
joiner.mark_heard(0);
joiner.peers.said_goodbye(0);
assert!(!joiner.host_gone(), "believed on arrival");
joiner.keep_in_touch(GOODBYE_GRACE / 2.0);
joiner.mark_heard(0);
joiner.keep_in_touch(GOODBYE_GRACE * 2.0);
assert!(!joiner.host_gone(), "a host still talking is not gone");
joiner.peers.said_goodbye(0);
joiner.keep_in_touch(GOODBYE_GRACE / 2.0);
assert!(!joiner.host_gone(), "still within the grace");
joiner.keep_in_touch(GOODBYE_GRACE);
assert!(joiner.host_gone(), "and then the host is gone");
}
#[test]
fn a_round_without_a_host_hands_the_player_back_to_the_lobby() {
let walked_out = |from_lobby| {
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(GameSettings::default());
app.init_resource::<RoundNotice>();
let mut joiner = OnlineSession::new(
UdpTransport::host(0).expect("game socket"),
Lockstep::new(1, vec![0, 1], 0),
2,
MatchTerms::default(),
);
joiner.home.from_lobby = from_lobby;
joiner.mark_heard(0);
joiner.keep_in_touch(HOST_GONE_AFTER + 1.0);
assert!(joiner.host_gone(), "the host really is gone");
app.insert_resource(Online(Some(joiner)));
app.add_systems(Update, leave_a_hostless_round);
app.update();
app.update(); let screen = *app.world().resource::<State<Screen>>().get();
(screen, app.world().resource::<RoundNotice>().0.clone())
};
let (screen, why) = walked_out(true);
assert_eq!(screen, Screen::Lobby, "back to the hall it came from");
assert_eq!(why, crate::app::i18n::EN.online_host_gone, "and told why");
let (screen, why) = walked_out(false);
assert_eq!(screen, Screen::Menu, "a hand-wired pair has no hall");
assert_eq!(why, crate::app::i18n::EN.online_host_gone);
}
#[test]
fn a_joiner_asked_to_leave_on_the_card_walks_out() {
let mut host = UdpTransport::host(0).expect("host socket");
let port = host.local_addr().expect("addr").port();
let mut joiner = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
joiner.home.from_lobby = true;
let mut greeted = false;
for _ in 0..200 {
joiner.poll_between_rounds(1.0);
std::thread::sleep(std::time::Duration::from_millis(5));
if !host.recv_all().is_empty() {
greeted = true;
break;
}
}
assert!(greeted, "the joiner greeted");
host.turn_away(0);
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
joiner.poll_between_rounds(0.0);
if joiner.put_out.is_some() {
break;
}
}
assert_eq!(joiner.put_out, Some(PutOut::Kicked), "the joiner heard it");
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(GameSettings::default());
app.init_resource::<RoundNotice>();
app.insert_resource(Online(Some(joiner)));
app.add_systems(Update, leave_a_hostless_round);
app.update();
app.update();
assert_eq!(
*app.world().resource::<State<Screen>>().get(),
Screen::Lobby
);
assert_eq!(
app.world().resource::<RoundNotice>().0,
crate::app::i18n::EN.lobby_kicked_you,
"and was told why"
);
}
#[test]
fn a_quiet_results_card_is_not_a_host_that_has_gone() {
let mut host = OnlineSession::new(
UdpTransport::host(0).expect("game socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
let port = host.transport.local_addr().expect("addr").port();
let mut joiner = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
let step = 0.5;
for _ in 0..(HOST_GONE_AFTER * 2.0 / step) as usize {
joiner.poll_between_rounds(step);
std::thread::sleep(std::time::Duration::from_millis(2));
host.poll_between_rounds(step);
std::thread::sleep(std::time::Duration::from_millis(2));
joiner.poll_between_rounds(0.0);
assert!(
!joiner.host_gone(),
"walked out on a host that is answering"
);
}
assert_eq!(host.transport.peer_count(), 1, "and the host heard it");
drop(host);
for _ in 0..(HOST_GONE_AFTER * 2.0 / step) as usize {
joiner.poll_between_rounds(step);
}
assert!(joiner.host_gone(), "and a card over a dead host says so");
}
#[test]
fn a_round_that_is_running_says_nothing() {
use crate::sim::PlayerAction;
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
let frame = 1.0 / 60.0;
for _ in 0..(10.0 / frame) as usize {
let at = session.session.frame();
session.session.commit_local(PlayerAction::None);
session.session.receive(crate::sim::InputMsg {
player: 1,
frame: at,
action: PlayerAction::None,
});
assert!(session.session.advance().is_some(), "the frame ran");
session.mark_heard(0);
let gone = tick(&mut session, frame, false);
assert!(gone.is_empty(), "gave {gone:?} away mid-round");
assert_eq!(
session.waiting_on(),
None,
"said so about a round that is running"
);
}
assert!(session.abandoned.is_empty());
}
#[test]
fn a_round_that_keeps_stumbling_says_so_once() {
use crate::sim::PlayerAction;
let transport = UdpTransport::host(0).expect("game socket");
let mut session = OnlineSession::new(
transport,
Lockstep::new(0, vec![0, 1], 0),
2,
MatchTerms::default(),
);
session.peers.deal(&[Some(1)]);
let frame = 1.0 / 60.0;
fn moving(session: &mut OnlineSession, delta: f32) {
let at = session.session.frame();
session.session.commit_local(PlayerAction::None);
session.session.receive(crate::sim::InputMsg {
player: 1,
frame: at,
action: PlayerAction::None,
});
assert!(session.session.advance().is_some(), "the frame went");
assert!(
!session.session.awaiting().is_empty(),
"a healthy round looks stalled from here, every single time"
);
session.mark_heard(0);
tick(session, delta, false);
}
fn stalling(session: &mut OnlineSession, delta: f32) {
session.mark_heard(0);
tick(session, delta, false);
}
stalling(&mut session, frame);
assert_eq!(session.waiting_on(), None, "not for an ordinary moment");
for _ in 0..(SAY_WAITING_AFTER / frame) as usize + 1 {
stalling(&mut session, frame);
}
assert_eq!(session.waiting_on(), Some(1), "and then it says whose");
for _ in 0..20 {
for _ in 0..(0.3 / frame) as usize {
moving(&mut session, frame);
assert_eq!(session.waiting_on(), Some(1), "the line blinked out");
}
for _ in 0..(SAY_WAITING_AFTER / frame) as usize + 1 {
stalling(&mut session, frame);
assert_eq!(session.waiting_on(), Some(1), "nor back on again");
}
}
moving(&mut session, frame);
assert_eq!(
session.waiting_on(),
Some(1),
"gone on the very first frame is the strobe again"
);
for _ in 0..(SAY_WAITING_FOR / frame) as usize + 6 {
moving(&mut session, frame);
}
assert_eq!(
session.waiting_on(),
None,
"and a healthy round says nothing"
);
}
fn run_into_the_stall(session: &mut OnlineSession) {
for _ in 0..40 {
session.pump(
PlayerAction::None,
|net| {
while net.session.advance().is_some() {}
},
);
}
assert!(
!session.session.awaiting().is_empty(),
"expected the round to be held up by somebody"
);
tick(session, 0.0, false);
}
fn hosting(players: Vec<u8>) -> OnlineSession {
OnlineSession::new(
UdpTransport::host(0).expect("socket"),
Lockstep::new(0, players, DEFAULT_DELAY),
2,
MatchTerms::default(),
)
}
#[test]
fn a_round_left_hanging_starts_again_without_the_player() {
let mut session = hosting(vec![0, 1]);
let mut frames = 0;
let step = |session: &mut OnlineSession, frames: &mut u32| {
session.pump(PlayerAction::None, |net| {
while net.session.advance().is_some() {
*frames += 1;
}
});
};
for _ in 0..40 {
step(&mut session, &mut frames);
}
let stuck_at = frames;
assert!(
stuck_at <= DEFAULT_DELAY,
"it got no further than the delay"
);
assert!(tick(&mut session, ABANDON_AFTER / 2.0, false).is_empty());
assert_eq!(frames, stuck_at, "and nothing has moved yet");
let given_up = tick(&mut session, ABANDON_AFTER, false);
assert_eq!(given_up, vec![1], "the seat that went quiet");
for _ in 0..40 {
step(&mut session, &mut frames);
}
assert!(
frames > stuck_at,
"the round is still frozen at frame {stuck_at}"
);
}
#[test]
fn a_pause_is_not_a_departure() {
let mut session = hosting(vec![0, 1]);
run_into_the_stall(&mut session);
for _ in 0..20 {
assert!(tick(&mut session, ABANDON_AFTER, true).is_empty());
}
}
#[test]
fn a_joiner_never_decides_for_itself() {
let mut joiner = OnlineSession::new(
UdpTransport::host(0).expect("socket"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
assert!(!joiner.is_host());
run_into_the_stall(&mut joiner);
for _ in 0..20 {
assert!(tick(&mut joiner, ABANDON_AFTER, false).is_empty());
}
assert!(joiner.abandoned.is_empty(), "and nothing was given up");
}
#[test]
fn an_abandoned_player_does_not_haunt_the_next_round() {
let mut host = hosting(vec![0, 1]);
let port = host.transport.local_addr().expect("addr").port();
let joiner = UdpTransport::join(("127.0.0.1", port)).expect("join");
joiner.send(NetMsg::hello("Bo"));
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
host.poll_between_rounds(0.0);
if host.transport.peer_count() == 1 {
break;
}
}
assert_eq!(host.transport.peer_count(), 1);
host.peers.deal(&[Some(1)]);
drop(joiner);
run_into_the_stall(&mut host);
assert_eq!(tick(&mut host, ABANDON_AFTER * 2.0, false), vec![1]);
assert_eq!(host.transport.peer_count(), 0, "and gone from the socket");
assert!(host.peers.is_empty(), "and from the launch plan");
host.call_next_round(
MatchTerms {
seed: 42,
..MatchTerms::default()
},
None,
);
assert_eq!(host.session.player_count(), 1, "still waiting on a ghost");
assert_eq!(host.seats, 2, "a beach needs two castles");
assert_eq!(host.terms.bots, 1, "and somebody in the other one");
assert!(
host.abandoned.is_empty(),
"a new round has given up on nobody yet"
);
}
#[test]
fn last_rounds_dropped_seat_is_not_this_rounds() {
let mut joiner = OnlineSession::new(
UdpTransport::host(0).expect("socket"),
Lockstep::new(2, vec![0, 1, 2], DEFAULT_DELAY),
3,
MatchTerms::default(),
);
joiner.abandoned.push((1, 40));
joiner.take_up(Invitation {
seats: 2,
seat: Some(1),
terms: MatchTerms {
seed: 9,
..MatchTerms::default()
},
names: Default::default(),
..Invitation::blank()
});
assert!(joiner.abandoned.is_empty());
assert!(!joiner.dropped(), "its new seat was never given up on");
}
#[test]
fn a_host_never_abandons_its_own_castle() {
let mut session = hosting(vec![0]);
for _ in 0..40 {
session.pump(
PlayerAction::None,
|net| {
while net.session.advance().is_some() {}
},
);
}
for _ in 0..20 {
assert!(tick(&mut session, ABANDON_AFTER * 2.0, false).is_empty());
}
assert!(session.abandoned.is_empty());
}
#[test]
fn a_seat_is_given_up_once() {
let mut session = hosting(vec![0, 1]);
run_into_the_stall(&mut session);
assert_eq!(tick(&mut session, ABANDON_AFTER * 2.0, false), vec![1]);
assert_eq!(session.abandoned.len(), 1);
assert_eq!(session.abandoned[0].0, 1);
for _ in 0..10 {
assert!(tick(&mut session, ABANDON_AFTER * 2.0, false).is_empty());
}
assert_eq!(session.abandoned.len(), 1, "still just the one");
}
#[test]
fn a_departed_player_is_replaced_by_an_ai_and_the_feed_says_so() {
let mut app = App::new();
app.insert_resource(Time::<()>::default());
app.insert_resource(Paused(false));
app.insert_resource(GameSettings::default());
app.init_resource::<SeatNames>();
app.init_resource::<Controllers>();
app.init_resource::<EventLog>();
let mut session = OnlineSession::new(
UdpTransport::host(0).expect("socket"),
Lockstep::new(0, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
for _ in 0..40 {
session.pump(
PlayerAction::None,
|net| {
while net.session.advance().is_some() {}
},
);
}
app.insert_resource(Online(Some(session)));
app.add_systems(Update, abandon_the_departed);
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_millis(500));
app.update();
assert_eq!(
app.world().resource::<Controllers>().ai(1),
None,
"gave up on them after half a second"
);
assert!(app.world().resource::<EventLog>().0.is_empty());
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(ABANDON_AFTER + 1.0));
app.update();
assert!(
app.world().resource::<Controllers>().ai(1).is_some(),
"the empty castle has nobody in it"
);
let log = app.world().resource::<EventLog>();
assert_eq!(log.0.len(), 1, "the players are told, once");
let mut board = classic_arena(false, 2);
let controllers = *app.world().resource::<Controllers>();
let controllers = &controllers;
let mut online = app.world_mut().resource_mut::<Online>();
let session = online.0.as_mut().expect("a session");
let mut moved = 0;
for _ in 0..40 {
session.pump(PlayerAction::None, |net| {
while let Some(mut actions) = net.session.advance() {
fill_bot_actions(&board, controllers, &mut actions);
board.tick(&actions);
moved += 1;
}
});
}
assert!(moved > 0, "the beach is still frozen");
app.update();
assert_eq!(app.world().resource::<EventLog>().0.len(), 1);
}
fn settle() {
std::thread::sleep(std::time::Duration::from_millis(20));
}
#[test]
fn a_joiner_greets_a_quiet_host_until_it_hears_it() {
let mut host = UdpTransport::host(0).expect("host socket");
let port = host.local_addr().expect("addr").port();
let mut joiner = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
joiner.names[1] = "Bo".into();
let greetings = |host: &mut UdpTransport| {
settle();
host.recv_all()
.into_iter()
.filter(|(msg, _)| msg.greets())
.count()
};
joiner.keep_in_touch(0.5);
assert_eq!(greetings(&mut host), 0, "half a second is nothing yet");
joiner.keep_in_touch(0.6);
assert_eq!(greetings(&mut host), 1, "a quiet host is greeted");
joiner.keep_in_touch(0.5);
assert_eq!(greetings(&mut host), 0, "about once a second");
joiner.keep_in_touch(0.6);
assert_eq!(greetings(&mut host), 1, "and again");
host.send(NetMsg::Hash {
round: 0,
frame: 0,
hash: 0,
});
settle();
joiner.pump(PlayerAction::None, |_| {});
for _ in 0..4 {
joiner.keep_in_touch(0.2);
host.send(NetMsg::Hash {
round: 0,
frame: 0,
hash: 0,
});
settle();
joiner.pump(PlayerAction::None, |_| {});
}
assert_eq!(greetings(&mut host), 0, "a host heard is not greeted");
}
#[test]
fn a_dropped_seat_that_comes_back_hears_it_was_dropped() {
let mut host = hosting(vec![0, 1]);
host.home.from_lobby = true;
let port = host.transport.local_addr().expect("addr").port();
let mut joiner = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
joiner.home.from_lobby = true;
joiner.names[1] = "Bo".into();
joiner.transport.send(joiner.own_greeting());
settle();
host.pump(PlayerAction::None, |_| {});
host.peers.deal(&[Some(1)]);
joiner.keep_in_touch(QUIET_HOST + 0.1);
settle();
host.pump(PlayerAction::None, |_| {});
settle();
joiner.pump(PlayerAction::None, |_| {});
assert_eq!(host.transport.peer_count(), 1, "nobody new");
assert_eq!(host.peers.seat_of(0), Some(1), "Bo keeps the seat");
assert!(!joiner.next_round, "the round's own Start is no news");
assert_eq!(joiner.session.seat(), Some(1));
assert!(!joiner.dropped());
let frame = host.session.frame();
host.session.abandon(1, frame);
host.abandoned.push((1, frame));
host.forget_seat(1);
assert_eq!(host.transport.peer_count(), 0, "forgotten");
settle();
let _ = joiner.transport.recv_all();
joiner.keep_in_touch(QUIET_HOST + 0.1);
settle();
host.pump(PlayerAction::None, |_| {});
host.pump(PlayerAction::None, |_| {});
settle();
joiner.pump(PlayerAction::None, |_| {});
assert!(
joiner.dropped(),
"told it was dropped, not that the host left"
);
assert!(!joiner.host_gone());
}
#[test]
fn a_forgotten_watcher_is_caught_up_again() {
use crate::sim::{InputMsg, generate_arena};
let mut host = hosting(vec![0, 1]);
host.home.from_lobby = true;
let port = host.transport.local_addr().expect("addr").port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
let mut dee = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::observer(vec![0, 1], DEFAULT_DELAY),
2,
host.terms,
);
dee.home.from_lobby = true;
dee.home.wants_to_watch = true;
settle();
dee.transport.send(dee.own_greeting());
settle();
host.pump(PlayerAction::None, |_| {});
host.peers.deal(&[Some(1), None]);
let mut board = generate_arena(3, 2, 12, 9);
for frame in 0..80 {
host.session.commit_local(PlayerAction::None);
host.session.receive(InputMsg {
player: 1,
frame: DEFAULT_DELAY + frame,
action: PlayerAction::None,
});
while let Some(actions) = host.session.advance() {
board.tick(&actions);
}
}
host.forget_peer(1);
settle();
let _ = dee.transport.recv_all();
assert!(!dee.lost_the_thread(dee.session.frame() + 2), "a hiccup");
dee.keep_in_touch(QUIET_HOST + 0.1);
settle();
host.pump(PlayerAction::None, |_| {});
host.send_catch_ups(&board);
for _ in 0..40 {
settle();
dee.pump(PlayerAction::None, |_| {});
if dee.next_round {
break;
}
}
assert!(dee.next_round, "on its way back into the round");
assert!(dee.watching());
assert_eq!(dee.catch_up.from_frame, Some(host.session.frame()));
drop(bo);
}
#[test]
fn a_forged_goodbye_on_the_card_is_taken_back_by_the_host() {
let mut host = hosting(vec![0, 1]);
let port = host.transport.local_addr().expect("addr").port();
let mut joiner = OnlineSession::new(
UdpTransport::join(("127.0.0.1", port)).expect("join"),
Lockstep::new(1, vec![0, 1], DEFAULT_DELAY),
2,
MatchTerms::default(),
);
joiner.poll_between_rounds(1.0);
settle();
host.poll_between_rounds(0.0);
settle();
joiner.poll_between_rounds(0.0);
assert_eq!(host.transport.peer_count(), 1);
host.transport.send(NetMsg::Leaving);
settle();
joiner.poll_between_rounds(0.0);
assert!(joiner.peers.get(0).is_some_and(Peer::left), "heard");
settle();
host.poll_between_rounds(0.0);
settle();
joiner.poll_between_rounds(GOODBYE_GRACE / 2.0);
joiner.poll_between_rounds(GOODBYE_GRACE);
assert!(!joiner.host_gone(), "the host answered");
}
}