use super::*;
pub(super) const HOST_TTL: f32 = 5.0;
pub(super) fn host_ttl(one_in: u32) -> f32 {
HOST_TTL * one_in as f32
}
pub(super) const MAX_BEACHES: usize = 64;
pub(super) const PEER_TTL: f32 = 4.0;
const _: () = {
assert!(PEER_TTL > 3.0 * ANNOUNCE_EVERY);
assert!(PEER_TTL < HOST_TTL);
};
pub struct HostEntry {
pub addr: SocketAddr,
pub id: u64,
pub name: String,
pub host: String,
pub taken: u8,
pub seats: u8,
pub running: bool,
pub bots: bool,
pub full: bool,
pub age: f32,
pub heard: u32,
}
impl HostEntry {
pub fn has_room(&self) -> bool {
!self.full && (self.seats == 0 || self.taken < self.seats)
}
pub fn who(&self) -> String {
match (self.name.is_empty(), self.host.is_empty()) {
(false, _) => self.name.clone(),
(true, false) => self.host.clone(),
(true, true) => self.addr.to_string(),
}
}
pub fn creator(&self) -> &str {
match self.name.is_empty() {
true => "",
false => &self.host,
}
}
pub fn table(&self, tr: &crate::app::i18n::Tr) -> String {
match (self.seats, self.running, self.has_room()) {
(0, ..) => String::new(),
(seats, false, _) => format!("{}/{seats}", self.taken),
(seats, true, true) => format!("{}/{seats} {}", self.taken, tr.lobby_in_progress),
(seats, true, false) => format!("{}/{seats} {}", self.taken, tr.lobby_full_tag),
}
}
pub fn table_tone(&self) -> Color {
match (self.running, self.has_room()) {
(_, false) => palette::IDLE_ROW.darker(0.15),
(false, true) => palette::GOLD,
(true, true) => palette::INK_TIDE,
}
}
}
pub(super) fn refresh_hosts(
hosts: &mut Vec<HostEntry>,
heard: &[(SocketAddr, Beacon)],
delta: f32,
ttl: f32,
held: &[SocketAddr],
) {
for entry in hosts.iter_mut() {
entry.age += delta;
}
for (addr, beacon) in heard {
let addr = *addr;
match beacon {
Beacon::Closing { id } => hosts.retain(|host| !same_beach(host, *id, addr)),
Beacon::Here {
id,
name,
host,
taken,
seats,
running,
bots,
full,
} => {
let mut fresh = HostEntry {
addr,
id: *id,
name: name.clone(),
host: host.clone(),
taken: *taken,
seats: *seats,
running: *running,
bots: *bots,
full: *full,
age: 0.0,
heard: 1,
};
let room = hosts.len() < MAX_BEACHES;
match hosts.iter_mut().find(|host| same_beach(host, *id, addr)) {
Some(entry) => {
fresh.addr = entry.addr;
fresh.heard = entry.heard.saturating_add(1);
*entry = fresh;
}
None if room => hosts.push(fresh),
None => {
if let Some(at) = the_row_to_give_up(hosts, held) {
hosts[at] = fresh;
}
}
}
}
}
}
hosts.retain(|host| host.age < ttl);
hosts.sort_by(|a, b| {
fold_case(&a.name)
.cmp(fold_case(&b.name))
.then_with(|| (a.addr.ip(), a.addr.port()).cmp(&(b.addr.ip(), b.addr.port())))
});
}
pub(super) fn the_row_to_give_up(hosts: &[HostEntry], held: &[SocketAddr]) -> Option<usize> {
hosts
.iter()
.enumerate()
.filter(|(_, host)| !held.contains(&host.addr) && host.heard <= 1)
.min_by(|(_, a), (_, b)| a.heard.cmp(&b.heard).then(b.age.total_cmp(&a.age)))
.map(|(at, _)| at)
}
pub(super) fn same_beach(host: &HostEntry, id: u64, addr: SocketAddr) -> bool {
match id {
0 => host.id == 0 && host.addr == addr,
id => host.id == id,
}
}
fn fold_case(name: &str) -> impl Iterator<Item = char> + '_ {
name.chars().flat_map(char::to_lowercase)
}
pub fn discover(time: Res<Time>, settings: Res<GameSettings>, mut state: ResMut<LobbyState>) {
let delta = time.delta_secs();
let heard = state
.discovery
.as_mut()
.map(Discovery::poll)
.unwrap_or_default();
let closed = host_closed(&state, &heard);
let ttl = state
.discovery
.as_ref()
.and_then(Discovery::port)
.map_or(HOST_TTL, |port| {
host_ttl(crate::transport::beacons_heard_one_in(port))
});
let held: Vec<SocketAddr> = state
.joined_entry()
.map(|host| host.addr)
.into_iter()
.chain(state.selected)
.collect();
refresh_hosts(&mut state.hosts, &heard, delta, ttl, &held);
learn_the_beach(&mut state);
if closed {
state.let_go();
state.feedback = settings.tr().lobby_host_closed.to_string();
}
state.settle_cursor();
}
fn learn_the_beach(state: &mut LobbyState) {
let Some(joined) = state.joined() else {
return;
};
if joined.beach.is_some() {
return;
}
let Some(id) = state
.joined_entry()
.map(|host| host.id)
.filter(|&id| id != 0)
else {
return;
};
if let Some(joined) = state.joined_mut() {
joined.beach = Some(id);
}
}
#[cfg(test)]
pub(super) fn refresh_open(hosts: &mut Vec<HostEntry>, heard: &[(SocketAddr, Beacon)], delta: f32) {
refresh_hosts(hosts, heard, delta, HOST_TTL, &[]);
}
fn host_closed(state: &LobbyState, heard: &[(SocketAddr, Beacon)]) -> bool {
let Some(beach) = state.joined_entry() else {
return false;
};
heard.iter().any(
|(addr, beacon)| matches!(beacon, Beacon::Closing { id } if same_beach(beach, *id, *addr)),
)
}
pub(super) fn walk_the_list(keys: &ButtonInput<KeyCode>, state: &mut LobbyState) {
if state.standing().at_a_beach() {
return;
}
if keys.just_pressed(KeyCode::ArrowUp) {
state.step_cursor(Nav::Up);
}
if keys.just_pressed(KeyCode::ArrowDown) {
state.step_cursor(Nav::Down);
}
let digit = crate::app::menu_ui::number_pressed(keys, crate::app::menu_ui::NUMBER_KEYS.len());
state.point_at(digit);
}
#[cfg(test)]
mod list_tests {
use super::*;
use crate::app::i18n::EN;
#[test]
fn a_number_points_at_a_row_and_the_cursor_keeps_its_beach() {
let mut state = LobbyState::default();
refresh_open(
&mut state.hosts,
&[
beach(1, "Cove", 1, 6, false),
beach(2, "Dunes", 1, 6, false),
],
0.0,
);
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::Digit2);
walk_the_list(&keys, &mut state);
assert!(state.joined().is_none(), "pointing is not joining");
let dunes = state.hosts[1].addr;
assert_eq!(state.selected, Some(dunes));
refresh_open(&mut state.hosts, &[beach(3, "Atoll", 1, 6, false)], 0.0);
state.settle_cursor();
assert_eq!(state.selected, Some(dunes), "still on Dunes");
assert_eq!(state.selected_index(), Some(2), "a row further down");
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(KeyCode::Digit9);
walk_the_list(&keys, &mut state);
assert_eq!(state.selected, Some(dunes), "nine of three is no row");
}
fn beach(last: u8, name: &str, taken: u8, seats: u8, running: bool) -> (SocketAddr, Beacon) {
(
format!("10.0.0.{last}:47777").parse().expect("addr"),
Beacon::Here {
id: u64::from(last),
name: name.to_string(),
host: "Sam".to_string(),
taken,
seats,
running,
bots: false,
full: false,
},
)
}
fn entry(last: u8, name: &str, taken: u8, seats: u8, running: bool) -> HostEntry {
hosted_entry(last, name, "Sam", taken, seats, running)
}
fn hosted_entry(
last: u8,
name: &str,
host: &str,
taken: u8,
seats: u8,
running: bool,
) -> HostEntry {
HostEntry {
addr: format!("10.0.0.{last}:47777").parse().expect("addr"),
id: u64::from(last),
name: name.to_string(),
host: host.to_string(),
taken,
seats,
running,
bots: false,
full: false,
age: 0.0,
heard: 1,
}
}
#[test]
fn a_row_says_whose_beach_and_whether_there_is_a_way_in() {
let open = entry(1, "Anna", 2, 6, false);
assert_eq!(open.who(), "Anna");
assert_eq!(open.table(&EN), "2/6");
assert_eq!(open.table_tone(), palette::GOLD, "open for anyone");
let playing = entry(1, "Anna", 4, 6, true);
assert!(playing.table(&EN).contains(EN.lobby_in_progress));
assert_eq!(playing.table_tone(), palette::INK_TIDE, "queueable");
let full = entry(1, "Anna", 6, 6, true);
assert!(full.table(&EN).contains(EN.lobby_full_tag));
assert!(!full.table(&EN).contains(EN.lobby_in_progress));
assert_ne!(full.table_tone(), palette::INK_TIDE, "not an invitation");
assert_eq!(entry(1, "Anna", 0, 0, false).table(&EN), "");
}
#[test]
fn a_row_also_says_who_put_the_beach_up() {
let named = hosted_entry(1, "Room 3", "Anna", 2, 6, false);
assert_eq!(named.who(), "Room 3");
assert_eq!(named.creator(), "Anna", "and whose room it is");
let unnamed = hosted_entry(2, "", "Anna", 2, 6, false);
assert_eq!(unnamed.who(), "Anna");
assert_eq!(unnamed.creator(), "");
let old = hosted_entry(9, "", "", 1, 6, false);
assert!(old.who().contains("10.0.0.9"));
assert_eq!(old.creator(), "");
}
#[test]
fn the_cursor_follows_the_beach_not_the_row() {
let mut state = LobbyState::default();
state.hosts = vec![
entry(1, "Anna", 1, 6, false),
entry(2, "Bo", 1, 6, false),
entry(3, "Cy", 1, 6, false),
];
state.selected = Some(state.hosts[2].addr);
assert_eq!(state.selected_index(), Some(2));
state.hosts.remove(0);
state.settle_cursor();
assert_eq!(state.selected_index(), Some(1));
assert_eq!(state.hosts[state.selected_index().unwrap()].name, "Cy");
state.hosts.retain(|host| host.name != "Cy");
state.settle_cursor();
assert_eq!(state.selected_index(), Some(0));
state.hosts.clear();
state.settle_cursor();
assert_eq!(state.selected_index(), None);
state.step_cursor(Nav::Down);
assert_eq!(state.selected_index(), None);
}
#[test]
fn the_list_is_bounded_however_many_ids_arrive() {
let mut next = 1000u64;
let mut spray = |count: u64| -> Vec<(SocketAddr, Beacon)> {
(0..count)
.map(|_| {
let (addr, mut beacon) = beach(9, "spam", 1, 6, false);
if let Beacon::Here { id, .. } = &mut beacon {
*id = next;
}
next += 1;
(addr, beacon)
})
.collect()
};
let established = || beach(3, "Zed", 1, 6, false);
let joined = beach(2, "Yan", 1, 6, false);
let pointed = beach(4, "Xu", 1, 6, false);
let held = [joined.0, pointed.0];
let ttl = host_ttl(4);
let mut hosts = Vec::new();
for _ in 0..3 {
refresh_hosts(&mut hosts, &[established()], 1.0, ttl, &held);
}
refresh_hosts(&mut hosts, &[joined, pointed], 0.0, ttl, &held);
refresh_hosts(&mut hosts, &spray(MAX_BEACHES as u64), 0.0, ttl, &held);
assert_eq!(hosts.len(), MAX_BEACHES);
for frame in 0..40 {
let mut heard = spray(50);
if frame % 4 == 0 {
heard.push(established());
}
refresh_hosts(&mut hosts, &heard, 0.25, ttl, &held);
assert_eq!(hosts.len(), MAX_BEACHES, "and the list stops there");
for name in ["Zed", "Yan", "Xu"] {
assert!(
hosts.iter().any(|host| host.name == name),
"{name} lost its row to a made-up id at frame {frame}"
);
}
}
}
#[test]
fn a_listener_up_the_ladder_rides_out_a_lost_wide_beacon() {
use crate::transport::{LOBBY_PORTS, beacons_heard_one_in};
assert_eq!(beacons_heard_one_in(LOBBY_PORTS[0]), 1);
let up_the_ladder = beacons_heard_one_in(LOBBY_PORTS[1]);
assert!(up_the_ladder > 1, "only the wide beacons go past the first");
let ttl = host_ttl(up_the_ladder);
let mut hosts = Vec::new();
refresh_hosts(&mut hosts, &[beach(1, "Anna", 1, 6, false)], 0.0, ttl, &[]);
let silence = 2.0 * up_the_ladder as f32 * ANNOUNCE_EVERY + 0.5;
refresh_hosts(&mut hosts, &[], silence, ttl, &[]);
assert_eq!(hosts.len(), 1, "still listed after {silence} s");
assert_eq!(host_ttl(1), HOST_TTL, "the first port is as it was");
}
#[test]
fn a_full_list_of_real_beaches_holds_still() {
let real = |n: u64| {
let (_, mut beacon) = beach(1, &format!("B{n:02}"), 1, 6, false);
if let Beacon::Here { id, .. } = &mut beacon {
*id = 100 + n;
}
(format!("10.0.1.{n}:47777").parse().expect("addr"), beacon)
};
let all: Vec<(SocketAddr, Beacon)> = (0..MAX_BEACHES as u64).map(real).collect();
let mut hosts = Vec::new();
for _ in 0..3 {
refresh_open(&mut hosts, &all, 1.0);
}
let before: Vec<SocketAddr> = hosts.iter().map(|host| host.addr).collect();
for _ in 0..5 {
let mut heard = all.clone();
heard.push(real(MAX_BEACHES as u64));
refresh_open(&mut hosts, &heard, 1.0);
let now: Vec<SocketAddr> = hosts.iter().map(|host| host.addr).collect();
assert_eq!(now, before, "no real beach gave up its row");
}
}
#[test]
fn the_list_holds_its_order() {
let mut hosts = Vec::new();
let heard = [
beach(3, "Cy", 1, 6, false),
beach(1, "Anna", 1, 6, false),
beach(2, "Bo", 1, 6, false),
];
refresh_open(&mut hosts, &heard, 0.0);
let order: Vec<&str> = hosts.iter().map(|h| h.name.as_str()).collect();
assert_eq!(
order,
["Anna", "Bo", "Cy"],
"heard in one order, listed in another"
);
refresh_open(&mut hosts, &[heard[0].clone(), heard[2].clone()], 0.1);
let order: Vec<&str> = hosts.iter().map(|h| h.name.as_str()).collect();
assert_eq!(order, ["Anna", "Bo", "Cy"]);
}
#[test]
fn names_sort_without_regard_to_case() {
let mut hosts = Vec::new();
let heard = [
beach(1, "zoe", 1, 6, false),
beach(2, "Alice", 1, 6, false),
beach(3, "bob", 1, 6, false),
beach(4, "ÉLODIE", 1, 6, false),
];
refresh_open(&mut hosts, &heard, 0.0);
let order: Vec<&str> = hosts.iter().map(|h| h.name.as_str()).collect();
let mut expected: Vec<&str> = heard
.iter()
.map(|(_, beacon)| match beacon {
Beacon::Here { name, .. } => name.as_str(),
Beacon::Closing { .. } => unreachable!(),
})
.collect();
expected.sort_by_key(|name| name.to_lowercase());
assert_eq!(
order, expected,
"the cheap comparator and the obvious one agree"
);
}
#[test]
fn a_crowded_wire_still_gives_a_usable_list() {
let mut state = LobbyState::default();
let heard: Vec<_> = (1..=60u8)
.map(|n| beach(n, &format!("beach{n:02}"), n % 7, 6, n % 3 == 0))
.collect();
refresh_open(&mut state.hosts, &heard, 0.0);
assert_eq!(state.hosts.len(), 60, "every beach on the wire is listed");
state.settle_cursor();
for _ in 0..40 {
state.step_cursor(Nav::Down);
}
let at = state.selected_index().expect("still on something real");
assert_eq!(at, 40);
assert!(state.scroll <= at, "the cursor is not above the window");
assert!(
at < state.scroll + state.rows_shown(),
"nor below it: row {at}, window from {}",
state.scroll
);
let was = state.hosts[at].addr;
state.hosts.retain(|host| host.addr != was);
state.settle_cursor();
assert_ne!(state.selected, Some(was));
let at = state.selected_index().expect("landed somewhere real");
assert!(at < state.hosts.len());
}
#[test]
fn games_leaving_from_above_pull_the_window_back() {
use crate::app::menu_ui::scroll_bar::Scrolls;
let mut state = LobbyState::default();
let heard: Vec<_> = (1..=15u8)
.map(|n| beach(n, &format!("beach{n:02}"), 1, 6, false))
.collect();
refresh_open(&mut state.hosts, &heard, 0.0);
state.fit_rows(12);
state.point_at(Some(14));
assert_eq!(state.scroll, 3, "scrolled to the bottom");
let gone: Vec<_> = state.hosts[..5].iter().map(|host| host.addr).collect();
state.hosts.retain(|host| !gone.contains(&host.addr));
state.settle_cursor();
assert_eq!(state.selected_index(), Some(9), "still on its game");
assert_eq!(state.scroll, 0, "nothing hidden above");
assert!(state.listed(0).is_some());
assert!(state.window().thumb().is_none(), "the whole list in view");
}
#[test]
fn the_bar_follows_the_beach_list() {
use crate::app::menu_ui::scroll_bar::{self, BarOf, reading};
use bevy::ecs::system::RunSystemOnce;
let mut app = App::new();
let mut state = LobbyState::default();
let heard: Vec<_> = (1..=30u8)
.map(|n| beach(n, &format!("beach{n:02}"), 1, 6, false))
.collect();
refresh_open(&mut state.hosts, &heard, 0.0);
state.settle_cursor();
app.insert_resource(state);
app.add_systems(Update, scroll_bar::follow::<LobbyState>);
app.world_mut()
.run_system_once(|mut commands: Commands| {
commands.spawn(Node::default()).with_children(|list| {
scroll_bar::spawn(list, BarOf::<LobbyState>::default());
});
})
.expect("the bar is built");
let bar = |app: &mut App| {
app.update();
reading::<BarOf<LobbyState>>(app.world_mut())
};
let share = LobbyState::default().rows_shown() as f32 / 30.0;
assert_eq!(bar(&mut app), Some([0.0, share, 1.0 - share]), "at the top");
for _ in 0..29 {
app.world_mut()
.resource_mut::<LobbyState>()
.step_cursor(Nav::Down);
}
assert_eq!(
bar(&mut app),
Some([1.0 - share, share, 0.0]),
"at the bottom"
);
let mut state = app.world_mut().resource_mut::<LobbyState>();
let rows = state.rows_shown();
state.hosts.truncate(rows);
state.settle_cursor();
assert_eq!(bar(&mut app), None, "everything fits");
}
#[test]
fn one_beach_heard_from_two_addresses_is_one_row() {
let mut hosts = Vec::new();
let named = |addr: &str| {
(
addr.parse::<SocketAddr>().expect("addr"),
Beacon::Here {
id: 0x5EA5,
name: "Room 3".to_string(),
host: "Sam".to_string(),
taken: 2,
seats: 6,
running: false,
bots: false,
full: false,
},
)
};
let heard = [named("127.0.0.1:49213"), named("192.168.1.8:49213")];
refresh_open(&mut hosts, &heard, 0.0);
assert_eq!(hosts.len(), 1, "one game, one row: {:?}", hosts.len());
let kept = hosts[0].addr;
refresh_open(&mut hosts, &heard, 0.1);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].addr, kept, "the row does not churn its address");
refresh_open(
&mut hosts,
&[(
"127.0.0.1:49213".parse().expect("addr"),
Beacon::Closing { id: 0x5EA5 },
)],
0.0,
);
assert!(hosts.is_empty(), "and leaves nothing behind");
}
#[test]
fn two_beaches_sharing_a_port_number_are_still_two() {
let mut hosts = Vec::new();
let heard = [
(
"10.0.0.1:49213".parse::<SocketAddr>().expect("addr"),
Beacon::Here {
id: 1,
name: "one".into(),
host: "Sam".into(),
taken: 1,
seats: 6,
running: false,
bots: false,
full: false,
},
),
(
"10.0.0.2:49213".parse::<SocketAddr>().expect("addr"),
Beacon::Here {
id: 2,
name: "two".into(),
host: "Sam".into(),
taken: 1,
seats: 6,
running: false,
bots: false,
full: false,
},
),
];
refresh_open(&mut hosts, &heard, 0.0);
assert_eq!(hosts.len(), 2);
}
#[test]
fn two_beaches_of_the_same_name_keep_their_own_rows() {
let mut hosts = Vec::new();
let heard = [beach(9, "Sam", 1, 6, false), beach(4, "Sam", 1, 6, false)];
refresh_open(&mut hosts, &heard, 0.0);
assert_eq!(hosts.len(), 2, "one row each");
let first = hosts[0].addr;
refresh_open(&mut hosts, &heard, 0.1);
assert_eq!(hosts[0].addr, first, "and the same one each, every time");
}
#[test]
fn a_joiner_hears_its_beach_close() {
use crate::transport::UdpTransport;
let there: SocketAddr = "127.0.0.1:47999".parse().expect("addr");
let mut state = LobbyState::default();
let joined = Joined::dialled(UdpTransport::join(there).expect("join"), false);
state.standing = Standing::Joining(joined);
state.hosts.push(HostEntry {
addr: there,
id: 7,
name: "Bay".into(),
host: "Sam".into(),
taken: 2,
seats: 4,
running: true,
bots: false,
full: false,
age: 0.0,
heard: 1,
});
let elsewhere: SocketAddr = "10.0.0.9:47778".parse().expect("addr");
assert!(!host_closed(
&state,
&[(elsewhere, Beacon::Closing { id: 8 })]
));
assert!(host_closed(
&state,
&[(elsewhere, Beacon::Closing { id: 7 })]
));
learn_the_beach(&mut state);
assert_eq!(state.joined().expect("joined").beach, Some(7));
state.hosts[0].addr = "127.0.0.1:48000".parse().expect("addr");
assert!(host_closed(
&state,
&[(elsewhere, Beacon::Closing { id: 7 })]
));
}
}