mod discovery;
mod entry;
mod hosting;
mod joining;
mod terms;
mod ui;
pub use discovery::*;
pub use entry::*;
pub use hosting::*;
pub use joining::*;
pub use terms::*;
pub use ui::*;
use crate::app::cycle::Cycle;
use crate::app::i18n::fill;
use crate::app::match_setup::MatchConfig;
use crate::app::net::{Online, OnlineSession};
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::{Screen, VersusPhase};
use crate::sim::{DEFAULT_DELAY, Lockstep, MAX_PLAYERS};
use crate::transport::{Announcer, Beacon, Discovery, MatchTerms, NetMsg, UdpTransport};
use bevy::ecs::relationship::RelatedSpawnerCommands;
use bevy::prelude::*;
use std::net::SocketAddr;
pub(crate) const ANNOUNCE_EVERY: f32 = 1.0;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Standing {
ChoosingToWatch,
Choosing,
Joining,
Hosting,
}
impl Standing {
pub fn at_a_beach(self) -> bool {
matches!(self, Standing::Joining | Standing::Hosting)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Intent {
Host,
Join(usize),
Dial(SocketAddr),
}
#[derive(Default, Clone, PartialEq, Eq, Debug)]
pub struct Said {
pub who: String,
pub line: String,
}
impl Said {
pub fn is_notice(&self) -> bool {
self.who.is_empty()
}
}
#[derive(Resource, Default)]
pub struct LobbyState {
discovery: Option<Discovery>,
hosting: Option<(Announcer, UdpTransport)>,
joining: Option<UdpTransport>,
pub hosts: Vec<HostEntry>,
pub joined_peers: usize,
watchers: Vec<usize>,
peer_names: Vec<String>,
peer_silence: Vec<f32>,
watching: bool,
pub selected: Option<SocketAddr>,
pub scroll: usize,
pub chat: Vec<Said>,
pub typing: Option<Typing>,
pub dial: usize,
pub table: Vec<String>,
pub joined_terms: Option<MatchTerms>,
pub game_name: String,
announce_in: f32,
hello_in: f32,
host_silence: f32,
host_answered: bool,
pub feedback: String,
auto_done: bool,
}
impl LobbyState {
fn forget_peer(&mut self, peer: usize) {
debug_assert!(
self.peer_names.len() >= self.peer_silence.len() || self.peer_silence.is_empty(),
"peer lists adrift: {} names, {} silences",
self.peer_names.len(),
self.peer_silence.len()
);
if let Some((_, transport)) = &mut self.hosting {
transport.forget(peer);
}
if peer < self.peer_names.len() {
self.peer_names.remove(peer);
}
if peer < self.peer_silence.len() {
self.peer_silence.remove(peer);
}
self.watchers.retain(|watcher| *watcher != peer);
for watcher in self.watchers.iter_mut() {
if *watcher > peer {
*watcher -= 1;
}
}
self.joined_peers = self.joined_peers.saturating_sub(1);
debug_assert!(
self.watchers
.iter()
.all(|w| *w < self.peer_names.len().max(1)),
"a watcher points past the peers it was indexing: {:?}",
self.watchers
);
}
pub fn say(&mut self, who: &str, line: &str) {
if line.is_empty() {
return;
}
self.chat.push(Said {
who: who.to_string(),
line: line.to_string(),
});
let over = self.chat.len().saturating_sub(CHAT_LINES);
self.chat.drain(..over);
}
pub fn standing(&self) -> Standing {
debug_assert!(
!(self.hosting.is_some() && self.joining.is_some()),
"hosting and joining at once, which no path should reach"
);
match (self.hosting.is_some(), self.joining.is_some()) {
(true, _) => Standing::Hosting,
(_, true) => Standing::Joining,
_ if self.watching => Standing::ChoosingToWatch,
_ => Standing::Choosing,
}
}
pub fn can_chat(&self) -> bool {
self.standing().at_a_beach()
}
pub fn hosting(&self) -> bool {
self.hosting.is_some()
}
pub fn joining(&self) -> bool {
self.joining.is_some()
}
pub fn roster(&self, tr: &crate::app::i18n::Tr, me: &str) -> Vec<String> {
let mine = match me.trim().is_empty() {
true => crate::app::seat_label(tr, 0),
false => me.to_string(),
};
let mut table = vec![mine];
for peer in 0..self.joined_peers {
if self.watchers.contains(&peer) {
continue;
}
let name = self.peer_names.get(peer).cloned().unwrap_or_default();
table.push(match name.is_empty() {
true => crate::app::seat_label(tr, table.len() as u8),
false => name,
});
}
table
}
fn players_aboard(&self) -> usize {
let seatable = MAX_PLAYERS - 1;
self.joined_peers
.saturating_sub(self.watchers.len())
.min(seatable)
}
pub fn selected_index(&self) -> Option<usize> {
let want = self.selected?;
self.hosts.iter().position(|host| host.addr == want)
}
fn settle_cursor(&mut self) {
if self.hosts.is_empty() {
self.selected = None;
self.scroll = 0;
return;
}
let at = match self.selected_index() {
Some(at) => at,
None => {
self.selected = self.hosts.first().map(|host| host.addr);
0
}
};
self.scroll = self.scroll.min(at).max(at.saturating_sub(LIST_ROWS - 1));
self.scroll = self.scroll.min(self.hosts.len().saturating_sub(1));
}
fn step_cursor(&mut self, down: bool) {
if self.hosts.is_empty() {
return;
}
let len = self.hosts.len();
let at = self.selected_index().unwrap_or(0);
let next = match down {
true => (at + 1) % len,
false => (at + len - 1) % len,
};
self.selected = Some(self.hosts[next].addr);
self.settle_cursor();
}
}
pub fn enter_lobby(
mut commands: Commands,
mut state: ResMut<LobbyState>,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
) {
*state = LobbyState::default();
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::from_art(&art));
match Discovery::bind() {
Ok(discovery) => {
state.discovery = Some(discovery);
state.feedback = settings.tr().lobby_listening.into();
}
Err(e) => state.feedback = format!("discovery unavailable: {e}"),
}
}
pub fn exit_lobby(mut state: ResMut<LobbyState>) {
say_goodbye(&state);
*state = LobbyState::default();
}
pub fn lobby_input(
keys: Res<ButtonInput<KeyCode>>,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
beaches: Res<crate::app::match_setup::CustomBeaches>,
mut settings: ResMut<GameSettings>,
mut config: ResMut<MatchConfig>,
mut state: ResMut<LobbyState>,
mut next_screen: ResMut<NextState<Screen>>,
) {
let tr = settings.tr();
let tr: &'static crate::app::i18n::Tr = tr;
let mut intent: Option<Intent> = None;
match drive_typing(&keys, &mut typed, &mut settings, &mut state, tr) {
Typed::Taken => return,
Typed::Unblocked(unblocked) => intent = Some(unblocked),
Typed::Nothing => {}
}
typed.clear();
if intent.is_none() && keys.just_pressed(KeyCode::KeyT) && state.can_chat() {
state.typing = Some(Typing::chat());
return;
}
if intent.is_none()
&& keys.just_pressed(KeyCode::KeyJ)
&& !state.standing().at_a_beach()
&& state.typing.is_none()
{
state.typing = Some(Typing::address(&settings.last_beach));
return;
}
let auto_host = crate::app::dev::auto_host_quota().is_some() && !state.auto_done;
let auto_watch = crate::app::dev::auto_watch();
let auto_join =
(crate::app::dev::auto_join() || auto_watch) && !state.auto_done && !state.hosts.is_empty();
if auto_watch {
state.watching = true;
}
let step = host_step(HostAsk {
answered: intent == Some(Intent::Host),
pressed_h: keys.just_pressed(KeyCode::KeyH),
busy: state.standing().at_a_beach(),
auto: auto_host,
});
if step == HostStep::Ask {
state.typing = Some(Typing::player_name(Intent::Host, &settings.names[0]));
return;
}
if step == HostStep::Go && state.hosting.is_none() && state.joining.is_none() {
state.auto_done = auto_host;
match (
Announcer::new(crate::app::clock::fresh_seed()),
UdpTransport::host(0),
) {
(Ok(announcer), Ok(transport)) => {
state.feedback = match transport.local_addr() {
Ok(addr) => fill(tr.lobby_hosting, &[("p", &addr.port().to_string())]),
Err(_) => tr.lobby_hosting_noport.into(),
};
if let Some(here) = address_here(&transport) {
let notice = fill(tr.lobby_hosting_at, &[("a", &here)]);
state.say("", ¬ice);
}
state.hosting = Some((announcer, transport));
state.announce_in = 0.0;
state.discovery = None;
state.hosts.clear();
}
(Err(e), _) | (_, Err(e)) => {
state.feedback = fill(tr.lobby_could_not_host, &[("e", &e.to_string())]);
}
}
}
if state.hosting() {
turn_the_dials(&keys, &mut settings, &mut config, &mut state, &beaches);
}
if !state.hosting() && !state.joining() {
walk_the_list(&keys, &mut state);
}
take_a_beach(&keys, &settings, &mut state, tr, intent, auto_join);
if keys.just_pressed(KeyCode::KeyW) && state.hosting.is_none() && state.joining.is_none() {
state.watching = !state.watching;
}
if keys.just_pressed(KeyCode::Escape) {
next_screen.set(Screen::Menu);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn addr(last: u8) -> SocketAddr {
format!("10.0.0.{last}:47777").parse().expect("addr")
}
fn open(last: u8) -> (SocketAddr, Beacon) {
beach(last, "", 1, 6, false)
}
fn beach(last: u8, name: &str, taken: u8, seats: u8, running: bool) -> (SocketAddr, Beacon) {
(
addr(last),
Beacon::Here {
id: u64::from(last),
name: name.to_string(),
host: "Sam".to_string(),
taken,
seats,
running,
},
)
}
#[test]
fn hosts_expire_when_they_stop_announcing() {
let mut hosts = Vec::new();
refresh_hosts(&mut hosts, &[open(7)], 0.0);
assert_eq!(hosts.len(), 1, "a new beach joins the list");
refresh_hosts(&mut hosts, &[open(7)], 1.0);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].age, 0.0);
refresh_hosts(&mut hosts, &[], HOST_TTL - 0.5);
assert_eq!(hosts.len(), 1, "given a moment's grace");
refresh_hosts(&mut hosts, &[], 1.0);
assert!(hosts.is_empty(), "a host that quit leaves the list");
}
#[test]
fn a_farewell_clears_a_host_without_waiting_out_the_timeout() {
let mut hosts = Vec::new();
refresh_hosts(&mut hosts, &[open(1), open(2)], 0.0);
assert_eq!(hosts.len(), 2);
refresh_hosts(&mut hosts, &[(addr(1), Beacon::Closing { id: 1 })], 0.0);
assert_eq!(hosts.len(), 1, "the one that said goodbye left");
assert_eq!(hosts[0].addr, addr(2), "and only that one");
refresh_hosts(&mut hosts, &[(addr(9), Beacon::Closing { id: 9 })], 0.0);
assert_eq!(hosts.len(), 1);
refresh_hosts(
&mut hosts,
&[open(5), (addr(5), Beacon::Closing { id: 5 })],
0.0,
);
assert!(
hosts.iter().all(|host| host.addr != addr(5)),
"a beach that opened and shut in one frame is not listed"
);
refresh_hosts(
&mut hosts,
&[(addr(5), Beacon::Closing { id: 5 }), open(5)],
0.0,
);
assert!(hosts.iter().any(|host| host.addr == addr(5)));
}
#[test]
fn the_gathering_line_counts_who_is_actually_there() {
use crate::app::i18n::EN;
let none = MatchConfig {
bots: 0,
..MatchConfig::default()
};
assert_eq!(gathering_feedback(&EN, &none, 1, 0), EN.lobby_rivals_one);
assert!(gathering_feedback(&EN, &none, 3, 0).contains('3'));
let watched = gathering_feedback(&EN, &none, 2, 2);
assert!(watched.len() > gathering_feedback(&EN, &none, 2, 0).len());
let greedy = MatchConfig {
bots: 5,
..MatchConfig::default()
};
let full = gathering_feedback(&EN, &greedy, MAX_PLAYERS - 1, 0);
assert_eq!(
full,
gathering_feedback(&EN, &none, MAX_PLAYERS - 1, 0),
"no room for a bot, so no promise of one"
);
}
#[test]
fn a_hosting_lobby_announces_its_own_departure() {
let mut discovery = Discovery::bind().expect("bind lobby port");
let mut state = LobbyState::default();
let transport = UdpTransport::host(0).expect("game socket");
let port = transport.local_addr().expect("addr").port();
let mine = |heard: Vec<(std::net::SocketAddr, Beacon)>| {
heard
.into_iter()
.filter(|(addr, _)| addr.port() == port)
.collect::<Vec<_>>()
};
say_goodbye(&state);
std::thread::sleep(std::time::Duration::from_millis(30));
assert!(
mine(discovery.poll()).is_empty(),
"a joiner has no beach to close"
);
state.hosting = Some((Announcer::new(0xB0A7).expect("announcer"), transport));
say_goodbye(&state);
let mut heard = Vec::new();
for _ in 0..20 {
std::thread::sleep(std::time::Duration::from_millis(10));
heard.extend(mine(discovery.poll()));
if !heard.is_empty() {
break;
}
}
let (_, beacon) = heard.first().expect("the farewell went out");
assert!(matches!(beacon, Beacon::Closing { .. }), "{beacon:?}");
}
#[test]
fn hosts_age_independently() {
let mut hosts = Vec::new();
refresh_hosts(&mut hosts, &[open(1), open(2)], 0.0);
assert_eq!(hosts.len(), 2);
refresh_hosts(&mut hosts, &[open(1)], HOST_TTL - 0.1);
assert_eq!(hosts.len(), 2, "the quiet one is on its last legs");
refresh_hosts(&mut hosts, &[open(1)], 0.2);
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].addr, addr(1));
}
}
#[cfg(test)]
mod chat_tests {
use super::*;
#[test]
fn the_feed_keeps_the_last_few_lines() {
let mut state = LobbyState::default();
for n in 0..CHAT_LINES + 4 {
state.say("Anna", &format!("line {n}"));
}
assert_eq!(state.chat.len(), CHAT_LINES);
assert_eq!(state.chat[0].line, "line 4", "{:?}", state.chat);
assert_eq!(
state.chat.last().unwrap().who,
"Anna",
"each line remembers who said it"
);
state.say("Anna", "");
assert_eq!(state.chat.len(), CHAT_LINES);
}
#[test]
fn a_notice_has_nobody_behind_it() {
let mut state = LobbyState::default();
state.say("", "Anna joined");
state.say("Anna", "ready?");
assert!(state.chat[0].is_notice(), "nobody said it");
assert!(!state.chat[1].is_notice(), "Anna did");
}
#[test]
fn there_is_nobody_to_talk_to_until_there_is() {
let mut state = LobbyState::default();
assert!(!state.can_chat(), "browsing the list is not a conversation");
state.hosting = Some((
Announcer::new(0xB0A7).expect("announcer"),
UdpTransport::host(0).expect("socket"),
));
assert!(state.can_chat(), "a host has a table to address");
state.hosting = None;
state.joining = Some(UdpTransport::join(("127.0.0.1", 47999)).expect("join"));
assert!(state.can_chat(), "and a joiner has a host");
}
}
#[cfg(test)]
mod table_tests {
use super::*;
use crate::app::i18n::EN;
fn seated(names: &[&str], watchers: Vec<usize>) -> LobbyState {
LobbyState {
joined_peers: names.len(),
peer_names: names.iter().map(|n| n.to_string()).collect(),
peer_silence: vec![0.0; names.len()],
watchers,
..LobbyState::default()
}
}
#[test]
fn the_table_is_the_host_and_the_players() {
let state = seated(&["Bo", "Cy"], Vec::new());
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Bo", "Cy"]);
let state = seated(&["Bo", "Cy"], vec![0]);
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Cy"]);
let state = seated(&[""], Vec::new());
let table = state.roster(&EN, "");
assert_eq!(table.len(), 2);
assert!(table.iter().all(|who| !who.is_empty()), "{table:?}");
}
#[test]
fn forgetting_a_peer_moves_everything_that_indexed_it() {
let mut state = seated(&["Bo", "Cy", "Dee"], vec![2]);
assert_eq!(
state.roster(&EN, "Anna"),
["Anna", "Bo", "Cy"],
"Dee watches"
);
state.forget_peer(0);
assert_eq!(state.peer_names, ["Cy", "Dee"]);
assert_eq!(state.peer_silence.len(), 2);
assert_eq!(state.watchers, [1], "the flag followed Dee down");
assert_eq!(state.joined_peers, 2);
assert_eq!(
state.roster(&EN, "Anna"),
["Anna", "Cy"],
"Dee still watches"
);
state.forget_peer(1);
assert_eq!(state.peer_names, ["Cy"]);
assert!(state.watchers.is_empty());
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Cy"]);
}
}
#[cfg(test)]
mod standing_tests {
use super::*;
#[test]
fn the_standing_is_read_from_the_sockets_that_are_open() {
let mut state = LobbyState::default();
assert_eq!(state.standing(), Standing::Choosing);
assert!(!state.standing().at_a_beach(), "nobody to talk to yet");
state.watching = true;
assert_eq!(state.standing(), Standing::ChoosingToWatch);
assert!(!state.standing().at_a_beach());
state.joining = Some(UdpTransport::join(("127.0.0.1", 47999)).expect("join"));
assert_eq!(
state.standing(),
Standing::Joining,
"a socket in flight outranks the flag that armed it"
);
assert!(state.standing().at_a_beach());
state.joining = None;
state.hosting = Some((
Announcer::new(1).expect("announcer"),
UdpTransport::host(0).expect("socket"),
));
assert_eq!(state.standing(), Standing::Hosting);
assert!(state.standing().at_a_beach());
}
#[test]
fn everything_that_asks_gets_the_same_answer() {
let mut state = LobbyState::default();
for watching in [false, true] {
state.watching = watching;
assert_eq!(state.can_chat(), state.standing().at_a_beach());
}
state.joining = Some(UdpTransport::join(("127.0.0.1", 47998)).expect("join"));
assert_eq!(state.can_chat(), state.standing().at_a_beach());
}
}