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::{Bots, Paused, RoundNotice, Screen, SeatNames, palette};
use crate::sim::BotLevel;
const ABANDON_AFTER: f32 = 5.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) {
while self.peer_silence.len() <= peer {
self.peer_silence.push(0.0);
}
self.peer_silence[peer] = 0.0;
}
pub(super) fn age_the_silence(&mut self, delta: f32) {
let peers = self.transport.peer_count().max(self.peer_seats.len());
while self.peer_silence.len() < peers {
self.peer_silence.push(0.0);
}
for silence in self.peer_silence.iter_mut() {
*silence += delta;
}
}
fn seat_silence(&self, seat: u8) -> Option<f32> {
let peer = self
.peer_seats
.iter()
.position(|held| *held == Some(seat))?;
self.peer_silence.get(peer).copied()
}
pub fn host_gone(&self) -> bool {
!self.is_host()
&& self
.peer_silence
.first()
.is_some_and(|since| *since >= 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.stalled_for = 0.0;
self.stalled_on = self.session.frame();
self.waiting_hold = 0.0;
self.waiting_on = None;
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();
if at != self.stalled_on || waiting.is_empty() {
self.stalled_on = at;
self.stalled_for = 0.0;
self.waiting_hold = (self.waiting_hold - delta).max(0.0);
if self.waiting_hold == 0.0 {
self.waiting_on = None;
}
return Vec::new();
}
self.stalled_for += delta;
if self.stalled_for > SAY_WAITING_AFTER {
self.waiting_hold = SAY_WAITING_FOR;
self.waiting_on = waiting.first().copied();
}
if !self.is_host() || self.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.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.peer_seats.iter().position(|held| *held == Some(seat)) else {
return;
};
self.transport.forget(peer);
self.peer_seats.remove(peer);
if peer < self.peer_silence.len() {
self.peer_silence.remove(peer);
}
if peer < self.peer_names.len() {
self.peer_names.remove(peer);
}
if peer < self.peer_watch.len() {
self.peer_watch.remove(peer);
}
debug_assert!(
self.peer_seats.len() <= self.transport.peer_count(),
"the launch plan outlived the peers it names"
);
debug_assert!(
self.peer_silence.len() <= self.transport.peer_count(),
"a silence outlived the peer it was kept for"
);
}
pub fn waiting_on(&self) -> Option<u8> {
self.waiting_on.filter(|_| self.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 bots: ResMut<Bots>,
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) = bots.0.get_mut(usize::from(*seat)) else {
continue;
};
if slot.is_some() {
continue;
}
*slot = Some(level);
log.push(
fill(tr.online_seat_abandoned, &[("p", &names.label(tr, *seat))]),
palette::player_color(*seat),
);
}
}
pub(crate) fn leave_a_hostless_round(
settings: Res<GameSettings>,
online: Res<Online>,
mut notice: ResMut<RoundNotice>,
mut next_screen: ResMut<NextState<Screen>>,
) {
if online.0.as_ref().is_some_and(OnlineSession::host_gone) {
notice.0 = settings.tr().online_host_gone.to_string();
next_screen.set(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.peer_seats = vec![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.peer_seats = vec![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.peer_seats = vec![Some(1)];
host.abandon_stalled(HOST_GONE_AFTER * 2.0, false);
assert!(!host.host_gone(), "the host is the host");
}
#[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.peer_seats = vec![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.peer_seats = vec![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.peer_seats = vec![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.peer_seats.is_empty(), "and from the launch plan");
host.call_next_round(
MatchTerms {
seed: 42,
..MatchTerms::default()
},
0,
[0; MAX_PLAYERS],
);
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");
}
#[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::<Bots>();
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(crate::sim::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::<Bots>().0[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::<Bots>().0[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 bots = Bots(app.world().resource::<Bots>().0);
let bots = &bots;
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(crate::sim::PlayerAction::None, |net| {
while let Some(mut actions) = net.session.advance() {
fill_bot_actions(&board, bots, &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);
}
}