use super::*;
pub(super) const HOST_TTL: f32 = 5.0;
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 age: f32,
}
impl HostEntry {
pub fn has_room(&self) -> bool {
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,
) {
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,
} => {
let mut fresh = HostEntry {
addr,
id: *id,
name: name.clone(),
host: host.clone(),
taken: *taken,
seats: *seats,
running: *running,
age: 0.0,
};
match hosts.iter_mut().find(|host| same_beach(host, *id, addr)) {
Some(entry) => {
fresh.addr = entry.addr;
*entry = fresh;
}
None => hosts.push(fresh),
}
}
}
}
hosts.retain(|host| host.age < HOST_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 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>, mut state: ResMut<LobbyState>) {
let delta = time.delta_secs();
let heard = state
.discovery
.as_mut()
.map(Discovery::poll)
.unwrap_or_default();
refresh_hosts(&mut state.hosts, &heard, delta);
state.settle_cursor();
}
pub(super) fn walk_the_list(keys: &ButtonInput<KeyCode>, state: &mut LobbyState) {
if !state.standing().at_a_beach() {
if keys.just_pressed(KeyCode::ArrowUp) {
state.step_cursor(false);
}
if keys.just_pressed(KeyCode::ArrowDown) {
state.step_cursor(true);
}
}
}
#[cfg(test)]
mod list_tests {
use super::*;
use crate::app::i18n::EN;
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,
},
)
}
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,
age: 0.0,
}
}
#[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(true);
assert_eq!(state.selected_index(), None);
}
#[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_hosts(&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_hosts(&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_hosts(&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_hosts(&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(true);
}
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 + LIST_ROWS,
"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 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,
},
)
};
let heard = [named("127.0.0.1:49213"), named("192.168.1.8:49213")];
refresh_hosts(&mut hosts, &heard, 0.0);
assert_eq!(hosts.len(), 1, "one game, one row: {:?}", hosts.len());
let kept = hosts[0].addr;
refresh_hosts(&mut hosts, &heard, 0.1);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].addr, kept, "the row does not churn its address");
refresh_hosts(
&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,
},
),
(
"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,
},
),
];
refresh_hosts(&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_hosts(&mut hosts, &heard, 0.0);
assert_eq!(hosts.len(), 2, "one row each");
let first = hosts[0].addr;
refresh_hosts(&mut hosts, &heard, 0.1);
assert_eq!(hosts[0].addr, first, "and the same one each, every time");
}
}