use super::*;
use crate::app::cycle::Cycle;
use crate::app::i18n::fill;
use crate::app::settings::GameSettings;
use crate::app::side_panels::EventLog;
use crate::app::{Controllers, Paused, RoundNotice, Screen, SeatController, SeatNames, palette};
use crate::sim::BotLevel;
pub(super) const ABANDON_AFTER: f32 = 5.0;
const WATCHER_GONE_AFTER: f32 = 10.0;
const HOST_GONE_AFTER: f32 = 20.0;
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 seat_silence(&self, seat: u8) -> Option<f32> {
let peer = self.peers.holder_of(seat)?;
self.peers.get(peer).map(|peer| peer.silence)
}
pub fn host_gone(&self) -> bool {
!self.is_host()
&& self
.peers
.get(0)
.is_some_and(|host| host.silence >= HOST_GONE_AFTER)
}
pub fn abandon_stalled(&mut self, delta: f32, paused: bool) -> Vec<u8> {
self.age_the_silence(delta);
if paused || self.session.paused() {
self.stall.reset(self.session.frame());
return Vec::new();
}
if self.is_host() {
self.forget_gone_watchers();
}
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() || self.stall.stalled_for < ABANDON_AFTER {
return Vec::new();
}
let gone: Vec<u8> = waiting
.into_iter()
.filter(|seat| match self.seat_silence(*seat) {
Some(since) => since >= ABANDON_AFTER,
None => true,
})
.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 { seat: *seat, frame });
if !self.abandoned.iter().any(|(held, _)| held == seat) {
self.abandoned.push((*seat, frame));
}
self.forget_seat(*seat);
}
gone
}
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.transport.forget(peer);
self.peers.forget(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_gone_watchers(&mut self) {
for peer in (0..self.peers.len()).rev() {
let gone = self.peers.get(peer).is_some_and(|row| {
matches!(row.place, Place::Watching | Place::LateWatching)
&& row.silence >= WATCHER_GONE_AFTER
});
if gone {
self.transport.forget(peer);
self.peers.forget(peer);
}
}
}
pub(super) fn forget_the_silent(&mut self) {
debug_assert!(self.is_host(), "only the host keeps the table");
for peer in (0..self.peers.len()).rev() {
if self
.peers
.get(peer)
.is_some_and(|peer| peer.silence >= ABANDON_AFTER)
{
self.transport.forget(peer);
self.peers.forget(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.abandon_stalled(time.delta_secs(), paused.0);
let level = BotLevel::from_index(usize::from(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::player_color(*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);
}
}
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 session.kicked {
tr.lobby_kicked_you
} else if session.dropped() {
tr.online_dropped
} else if session.host_gone() {
tr.online_host_gone
} else {
return;
};
notice.0 = why.to_string();
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;
#[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 = session.abandon_stalled(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 = session.abandon_stalled(ABANDON_AFTER - 0.1, false);
assert!(gone.is_empty(), "the timer restarts with the round");
let gone = session.abandon_stalled(0.2, false);
assert_eq!(gone, vec![1], "and a seat that really is silent still goes");
}
#[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!(
session.abandon_stalled(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 = session.abandon_stalled(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_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.abandon_stalled(HOST_GONE_AFTER - 1.0, false);
assert!(!joiner.host_gone(), "still within a fair stumble");
joiner.abandon_stalled(2.0, false);
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.abandon_stalled(HOST_GONE_AFTER * 2.0, false);
assert!(!host.host_gone(), "the host is the host");
}
#[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.abandon_stalled(HOST_GONE_AFTER + 1.0, false);
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.kicked {
break;
}
}
assert!(joiner.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 = session.abandon_stalled(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);
session.abandon_stalled(delta, false);
}
fn stalling(session: &mut OnlineSession, delta: f32) {
session.mark_heard(0);
session.abandon_stalled(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"
);
session.abandon_stalled(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!(
session
.abandon_stalled(ABANDON_AFTER / 2.0, false)
.is_empty()
);
assert_eq!(frames, stuck_at, "and nothing has moved yet");
let given_up = session.abandon_stalled(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!(session.abandon_stalled(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!(joiner.abandon_stalled(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!(host.abandon_stalled(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(),
kinds: [0; MAX_PLAYERS],
standing: None,
beach: Vec::new(),
});
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!(
session
.abandon_stalled(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!(session.abandon_stalled(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!(
session
.abandon_stalled(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(6));
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);
}
}