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, Turn};
use crate::app::i18n::fill;
use crate::app::match_setup::MatchConfig;
use crate::app::menu_ui::Nav;
use crate::app::net::{Invitation, LobbyReturn, Online, OnlineSession, PeerBook};
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::{Screen, VersusPhase};
use crate::sim::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;
pub(crate) fn once_a_second(clock: &mut f32, delta: f32) -> bool {
*clock -= delta;
if *clock > 0.0 {
return false;
}
*clock = ANNOUNCE_EVERY;
true
}
pub(crate) fn greeting(watching: bool, name: &str) -> NetMsg {
match watching {
true => NetMsg::watch(name),
false => NetMsg::hello(name),
}
}
pub enum Standing {
Choosing { watching: bool },
Joining(Joined),
Hosting(Hosted),
}
impl Default for Standing {
fn default() -> Self {
Standing::Choosing { watching: false }
}
}
impl Standing {
pub fn at_a_beach(&self) -> bool {
matches!(self, Standing::Joining(_) | Standing::Hosting(_))
}
pub fn hosting(announcer: Announcer, transport: UdpTransport) -> Standing {
Standing::Hosting(Hosted {
announcer,
transport,
peers: PeerBook::default(),
announce_in: 0.0,
})
}
}
pub struct Hosted {
pub announcer: Announcer,
pub transport: UdpTransport,
pub peers: PeerBook,
announce_in: f32,
}
impl Hosted {
fn forget_peer(&mut self, peer: usize) {
self.transport.forget(peer);
self.peers.forget(peer);
}
pub(super) fn turn_away(&mut self, peer: usize) {
self.transport.turn_away(peer);
self.peers.forget(peer);
}
pub fn players_aboard(&self) -> usize {
let seatable = MAX_PLAYERS - 1;
self.peers
.iter()
.filter(|peer| !peer.watch)
.count()
.min(seatable)
}
fn watchers_aboard(&self) -> usize {
self.peers.iter().filter(|peer| peer.watch).count()
}
}
pub struct Joined {
pub transport: UdpTransport,
pub watching: bool,
pub queued: Option<u8>,
hello_in: f32,
host_silence: f32,
host_answered: bool,
played_seed: Option<u64>,
pub terms: Option<MatchTerms>,
catching_up: crate::app::net::catch_up::Assembly,
pub bot: Option<String>,
pub beach: Option<u64>,
}
impl Joined {
pub fn dialled(transport: UdpTransport, watching: bool) -> Joined {
Joined {
transport,
watching,
queued: None,
hello_in: ANNOUNCE_EVERY,
host_silence: 0.0,
host_answered: false,
played_seed: None,
terms: None,
catching_up: Default::default(),
bot: None,
beach: None,
}
}
pub fn returned(
transport: UdpTransport,
watching: bool,
played_seed: u64,
bot: Option<String>,
) -> Joined {
Joined {
transport,
watching,
queued: None,
hello_in: 0.0,
host_silence: 0.0,
host_answered: true,
played_seed: Some(played_seed),
terms: None,
catching_up: Default::default(),
bot,
beach: None,
}
}
pub fn greeting(&self, name: &str) -> NetMsg {
match &self.bot {
Some(seat) => NetMsg::hello_bot(seat),
None => greeting(self.watching, name),
}
}
pub fn played_seed(&self) -> Option<u64> {
self.played_seed
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Intent {
Host,
Join(SocketAddr),
Dial(SocketAddr),
JoinBot(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>,
pub standing: Standing,
pub hosts: Vec<HostEntry>,
pub selected: Option<SocketAddr>,
pub scroll: usize,
pub chat: Vec<Said>,
pub typing: Option<Typing>,
pub dial: usize,
pub table: Vec<String>,
pub table_kinds: Vec<crate::sim::SeatKind>,
pub game_name: String,
pub feedback: String,
auto_done: bool,
pub kicking: bool,
pub bot_string: Option<String>,
}
impl LobbyState {
pub fn hear(&mut self, name: &crate::transport::WireName, text: &crate::transport::WireChat) {
let who = crate::transport::name_from_wire(name);
let line = crate::transport::chat_from_wire(text);
self.say(&who, &line);
}
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 {
&self.standing
}
pub fn can_chat(&self) -> bool {
self.standing.at_a_beach()
}
pub fn hosting(&self) -> bool {
self.hosted().is_some()
}
pub fn hosted(&self) -> Option<&Hosted> {
match &self.standing {
Standing::Hosting(hosted) => Some(hosted),
Standing::Choosing { .. } | Standing::Joining(_) => None,
}
}
fn hosted_mut(&mut self) -> Option<&mut Hosted> {
match &mut self.standing {
Standing::Hosting(hosted) => Some(hosted),
Standing::Choosing { .. } | Standing::Joining(_) => None,
}
}
pub fn joined(&self) -> Option<&Joined> {
match &self.standing {
Standing::Joining(joined) => Some(joined),
Standing::Choosing { .. } | Standing::Hosting(_) => None,
}
}
fn joined_mut(&mut self) -> Option<&mut Joined> {
match &mut self.standing {
Standing::Joining(joined) => Some(joined),
Standing::Choosing { .. } | Standing::Hosting(_) => None,
}
}
fn transport(&self) -> Option<&UdpTransport> {
match &self.standing {
Standing::Hosting(hosted) => Some(&hosted.transport),
Standing::Joining(joined) => Some(&joined.transport),
Standing::Choosing { .. } => None,
}
}
pub fn watching(&self) -> bool {
match &self.standing {
Standing::Choosing { watching } => *watching,
Standing::Joining(joined) => joined.watching,
Standing::Hosting(_) => false,
}
}
fn set_watching(&mut self, on: bool) {
match &mut self.standing {
Standing::Choosing { watching } => *watching = on,
Standing::Joining(joined) => joined.watching = on,
Standing::Hosting(_) => {}
}
}
fn let_go(&mut self) {
let watching = self.watching();
self.standing = Standing::Choosing { watching };
self.chat.clear();
self.table.clear();
self.table_kinds.clear();
}
pub fn roster(&self, tr: &crate::app::i18n::Tr, me: &str) -> Vec<String> {
let nobody = PeerBook::default();
let peers = self.hosted().map_or(&nobody, |hosted| &hosted.peers);
table_of(peers, tr, me)
}
pub fn joined_entry(&self) -> Option<&HostEntry> {
let joined = self.joined()?;
match joined.beach {
Some(id) => self.hosts.iter().find(|host| host.id == id),
None => {
let there = joined.transport.peer_addr()?;
self.hosts.iter().find(|host| host.addr == there)
}
}
}
pub fn joining_beach(&self) -> Option<String> {
let host = &self.hosts[self.selected_index()?];
Some(match host.name.is_empty() {
true => host.addr.to_string(),
false => host.name.clone(),
})
}
pub fn roster_kinds(&self) -> Vec<crate::sim::SeatKind> {
let nobody = PeerBook::default();
let peers = self.hosted().map_or(&nobody, |hosted| &hosted.peers);
std::iter::once(crate::sim::SeatKind::Human)
.chain(
peers
.iter()
.filter(|peer| !peer.watch)
.map(|peer| match peer.bot {
true => crate::sim::SeatKind::Bot,
false => crate::sim::SeatKind::Human,
}),
)
.collect()
}
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, nav: Nav) {
if self.hosts.is_empty() {
return;
}
let len = self.hosts.len();
let at = self.selected_index().unwrap_or(0);
let next = crate::app::menu_ui::step(nav, at, len);
self.selected = Some(self.hosts[next].addr);
self.settle_cursor();
}
}
pub(super) fn table_of(peers: &PeerBook, 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 peers.iter().filter(|peer| !peer.watch) {
table.push(match peer.name.is_empty() {
true => crate::app::seat_label(tr, table.len() as u8),
false => peer.name.clone(),
});
}
table
}
#[derive(Resource, Default)]
pub struct Homecoming(pub Option<LobbyReturn>);
fn settle_back_in(
state: &mut LobbyState,
returned: LobbyReturn,
tr: &'static crate::app::i18n::Tr,
) {
let LobbyReturn {
announcer,
transport,
game_name,
mut peers,
watching,
host,
played_seed,
bot,
} = returned;
if host {
let Some(announcer) = announcer else { return };
peers.fit(transport.peer_count());
peers.hush();
state.game_name = game_name;
state.standing = Standing::Hosting(Hosted {
announcer,
transport,
peers,
announce_in: 0.0,
});
} else {
state.standing = Standing::Joining(Joined::returned(transport, watching, played_seed, bot));
state.feedback = match watching {
true => tr.lobby_watching,
false => tr.lobby_aboard,
}
.to_string();
}
}
pub fn enter_lobby(
mut commands: Commands,
mut state: ResMut<LobbyState>,
mut homecoming: ResMut<Homecoming>,
mut notice: ResMut<crate::app::RoundNotice>,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
) {
*state = LobbyState::default();
spawn_lobby_ui(&mut commands, settings.tr(), &LobbyArt::from_art(&art));
if let Some(returned) = homecoming.0.take() {
settle_back_in(&mut state, returned, settings.tr());
}
let why = std::mem::take(&mut notice.0);
if !why.is_empty() {
state.feedback = why;
}
if state.hosting() {
return;
}
match Discovery::bind() {
Ok(discovery) => {
state.discovery = Some(discovery);
if state.feedback.is_empty() {
state.feedback = settings.tr().lobby_listening.into();
}
}
Err(e) => {
if state.feedback.is_empty() {
state.feedback = format!("discovery unavailable: {e}");
}
}
}
}
pub fn exit_lobby(mut state: ResMut<LobbyState>) {
say_goodbye(&state);
*state = LobbyState::default();
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct LobbyExits<'w> {
next_screen: ResMut<'w, NextState<Screen>>,
bots: ResMut<'w, crate::app::bot_seats::BotSeats>,
online: Res<'w, Online>,
}
fn open_door_for(
state: &mut LobbyState,
bots: &mut crate::app::bot_seats::BotSeats,
tr: &'static crate::app::i18n::Tr,
at: usize,
) {
let Some(host) = state.hosts.get(at) else {
return;
};
if !host.bots {
state.feedback = tr.lobby_no_bots.to_string();
return;
}
if !host.has_room() {
state.feedback = tr.lobby_beach_full.to_string();
return;
}
let addr = host.addr;
state.selected = Some(addr);
match crate::app::bot_seats::Doorway::open() {
Ok(mut door) => {
door.seat_bots(&[0]);
bots.door = Some(door);
bots.join_to = Some(addr);
bots.waiting = Some(crate::app::bot_seats::Waiting::Join);
}
Err(e) => state.feedback = fill(tr.door_could_not_listen, &[("e", &e.to_string())]),
}
}
pub fn lobby_input(
keyboard: crate::app::keycaps::Keyboard,
mut typed: MessageReader<bevy::input::keyboard::KeyboardInput>,
mut settings: ResMut<GameSettings>,
dials: crate::app::match_setup::Dials,
mut state: ResMut<LobbyState>,
exits: LobbyExits,
pads: Query<&Gamepad>,
) {
let crate::app::keycaps::Keyboard { keys, caps } = keyboard;
let LobbyExits {
mut next_screen,
mut bots,
online,
} = exits;
bots.leave_the_beach(state.joined().is_some() || online.0.is_some());
if bots.waiting.is_some() {
typed.clear();
let tr = settings.tr();
if let Some(door) = &bots.door
&& door.ready()
&& let Some(addr) = bots.join_to
{
let name = door.named_for(tr, &settings.names[0]);
bots.waiting = None;
dial_as(&mut state, &settings, tr, addr, name);
}
return;
}
let crate::app::match_setup::Dials {
mut config,
beaches,
} = dials;
let tr = settings.tr();
let tr: &'static crate::app::i18n::Tr = tr;
let quick = pads
.iter()
.any(|pad| pad.just_pressed(GamepadButton::North));
let mut intent: Option<Intent> = None;
match drive_typing(
&keys,
&mut typed,
quick,
&mut settings,
&caps,
&mut state,
tr,
) {
Typed::Taken => return,
Typed::Unblocked(unblocked) => intent = Some(unblocked),
Typed::Nothing => {}
}
typed.clear();
if intent.is_none() && caps.just_pressed(&keys, 'T') && state.can_chat() {
state.typing = Some(Typing::chat());
return;
}
if intent.is_none() && quick && state.can_chat() {
state.typing = Some(Typing::chat_saying(tr.quick_chat[0]));
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.set_watching(true);
}
if crate::app::dev::auto_join_bot() && !state.auto_done && !state.hosts.is_empty() {
state.auto_done = true;
open_door_for(&mut state, &mut bots, tr, 0);
return;
}
let join_bot = match intent {
Some(Intent::JoinBot(addr)) => {
let at = state.hosts.iter().position(|host| host.addr == addr);
if at.is_none() {
state.feedback = tr.lobby_host_closed.to_string();
}
at
}
_ if intent.is_none()
&& caps.just_pressed(&keys, 'B')
&& !state.standing().at_a_beach()
&& state.typing.is_none() =>
{
state.selected_index()
}
_ => None,
};
if let Some(at) = join_bot {
if settings.names[0].trim().is_empty() && intent.is_none() {
state.typing = Some(
Typing::player_name(Intent::JoinBot(state.hosts[at].addr), &settings.names[0])
.or_suggest(|| suggested_name(tr)),
);
} else {
open_door_for(&mut state, &mut bots, tr, at);
}
return;
}
let step = host_step(HostAsk {
answered: intent == Some(Intent::Host),
pressed_h: caps.just_pressed(&keys, 'H'),
busy: state.standing().at_a_beach(),
auto: auto_host,
});
if step == HostStep::Ask {
state.typing = Some(
Typing::player_name(Intent::Host, &settings.names[0]).or_suggest(|| suggested_name(tr)),
);
return;
}
if step == HostStep::Go && !state.standing.at_a_beach() {
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.standing = Standing::hosting(announcer, transport);
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() && state.typing.is_none() && caps.just_pressed(&keys, 'K') {
state.kicking = true;
return;
}
if state.kicking {
if keys.just_pressed(KeyCode::Escape) || !state.hosting() {
state.kicking = false;
return;
}
if let Some(at) =
crate::app::menu_ui::number_pressed(&keys, crate::app::menu_ui::NUMBER_KEYS.len())
{
state.kicking = false;
ask_to_leave(&mut state, &mut bots, tr, at + 1);
}
return;
}
if state.hosting() {
turn_the_dials(
&keys,
&mut settings,
&caps,
&mut config,
&mut state,
&beaches,
);
}
if !state.standing.at_a_beach() {
walk_the_list(&keys, &mut state);
}
take_a_beach(&keys, &settings, &mut state, tr, intent, auto_join);
if keys.just_pressed(KeyCode::KeyW)
&& let Standing::Choosing { watching } = &mut state.standing
{
*watching = !*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")
}
#[test]
fn a_spectator_greets_by_name_like_everybody_else() {
assert_eq!(
greeting(true, "Dee"),
NetMsg::Watch {
name: crate::transport::wire_name("Dee")
},
);
assert_eq!(
greeting(false, "Bo"),
NetMsg::Hello {
name: crate::transport::wire_name("Bo"),
bot: false,
},
);
}
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,
bots: false,
full: false,
},
)
}
#[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<(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.standing = Standing::hosting(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 homecoming_tests {
use super::*;
use crate::app::i18n::EN;
use crate::app::net::PeerBook;
use crate::app::lobby::hosting::peers_named;
fn with_peers(peers: usize) -> (UdpTransport, Vec<UdpTransport>) {
let mut transport = UdpTransport::host(0).expect("game socket");
let port = transport.local_addr().expect("addr").port();
let mut joiners = Vec::new();
for want in 1..=peers {
let joiner = UdpTransport::join(("127.0.0.1", port)).expect("join");
joiner.send(NetMsg::hello("someone"));
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let _ = transport.recv_all();
if transport.peer_count() >= want {
break;
}
}
assert_eq!(transport.peer_count(), want, "peer {want} registered");
joiners.push(joiner);
}
(transport, joiners)
}
#[test]
fn a_host_comes_home_still_hosting() {
let (transport, _joiners) = with_peers(2);
let port = transport.local_addr().expect("addr").port();
let mut state = LobbyState::default();
settle_back_in(
&mut state,
LobbyReturn {
announcer: Some(Announcer::new(0xB0A7).expect("announcer")),
transport,
game_name: "Room 3".into(),
peers: peers_named(&["Bo", "Cy"], &[1]),
watching: false,
host: true,
played_seed: 42,
bot: None,
},
&EN,
);
assert!(state.hosting());
assert_eq!(state.game_name, "Room 3");
let hosted = state.hosted().expect("hosting");
assert_eq!(
hosted.transport.local_addr().expect("addr").port(),
port,
"the socket the table is still talking to"
);
assert_eq!(hosted.announce_in, 0.0, "back on the air this frame");
assert_eq!(
state.roster(&EN, "Anna"),
["Anna", "Bo"],
"the table as it stood, minus the one watching"
);
}
#[test]
fn a_short_name_list_is_padded_to_the_peers_it_indexes() {
let (transport, _joiners) = with_peers(2);
let mut state = LobbyState::default();
settle_back_in(
&mut state,
LobbyReturn {
announcer: Some(Announcer::new(1).expect("announcer")),
transport,
game_name: String::new(),
peers: peers_named(&["Bo"], &[0]),
watching: false,
host: true,
played_seed: 0,
bot: None,
},
&EN,
);
let hosted = state.hosted().expect("hosting");
assert_eq!(
hosted.peers.len(),
hosted.transport.peer_count(),
"one row per peer the socket knows"
);
}
#[test]
fn a_joiner_comes_home_aboard() {
let mut state = LobbyState::default();
settle_back_in(
&mut state,
LobbyReturn {
announcer: None,
transport: UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
game_name: String::new(),
peers: PeerBook::default(),
watching: true,
host: false,
played_seed: 42,
bot: None,
},
&EN,
);
let joined = state.joined().expect("joining");
assert!(joined.watching, "still watching");
assert_eq!(joined.played_seed(), Some(42));
assert!(joined.host_answered, "the host was talking a moment ago");
assert_eq!(state.feedback, EN.lobby_watching);
}
}
#[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.standing = Standing::hosting(
Announcer::new(0xB0A7).expect("announcer"),
UdpTransport::host(0).expect("socket"),
);
assert!(state.can_chat(), "a host has a table to address");
state.standing = Standing::Joining(Joined::dialled(
UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
false,
));
assert!(state.can_chat(), "and a joiner has a host");
}
}
#[cfg(test)]
mod table_tests {
use super::*;
use crate::app::i18n::EN;
use crate::app::lobby::hosting::peers_named;
fn seated(names: &[&str], watchers: &[usize]) -> LobbyState {
let mut state = LobbyState {
standing: Standing::hosting(
Announcer::new(0xB0A7).expect("announcer"),
UdpTransport::host(0).expect("socket"),
),
..LobbyState::default()
};
state.hosted_mut().expect("hosting").peers = peers_named(names, watchers);
state
}
#[test]
fn the_table_is_the_host_and_the_players() {
let state = seated(&["Bo", "Cy"], &[]);
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Bo", "Cy"]);
let state = seated(&["Bo", "Cy"], &[0]);
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Cy"]);
let state = seated(&[""], &[]);
let table = state.roster(&EN, "");
assert_eq!(table.len(), 2);
assert!(table.iter().all(|who| !who.is_empty()), "{table:?}");
let state = LobbyState::default();
assert_eq!(state.roster(&EN, "Anna"), ["Anna"]);
}
#[test]
fn forgetting_a_peer_moves_everything_that_indexed_it() {
let mut state = seated(&["Bo", "Cy", "Dee"], &[2]);
assert_eq!(
state.roster(&EN, "Anna"),
["Anna", "Bo", "Cy"],
"Dee watches"
);
state.hosted_mut().expect("hosting").forget_peer(0);
let hosted = state.hosted().expect("hosting");
let names: Vec<&str> = hosted.peers.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Cy", "Dee"]);
assert_eq!(hosted.watchers_aboard(), 1, "the wish followed Dee down");
assert_eq!(hosted.players_aboard(), 1);
assert_eq!(
state.roster(&EN, "Anna"),
["Anna", "Cy"],
"Dee still watches"
);
state.hosted_mut().expect("hosting").forget_peer(1);
let hosted = state.hosted().expect("hosting");
assert_eq!(hosted.peers.len(), 1);
assert_eq!(hosted.watchers_aboard(), 0);
assert_eq!(state.roster(&EN, "Anna"), ["Anna", "Cy"]);
}
}
#[cfg(test)]
mod standing_tests {
use super::*;
#[test]
fn the_standing_carries_the_wish_to_watch_with_it() {
let mut state = LobbyState::default();
assert!(!state.standing().at_a_beach(), "nobody to talk to yet");
assert!(!state.watching());
state.set_watching(true);
assert!(state.watching());
assert!(!state.standing().at_a_beach());
let watching = state.watching();
state.standing = Standing::Joining(Joined::dialled(
UdpTransport::join(("127.0.0.1", 47999)).expect("join"),
watching,
));
assert!(state.joined().is_some());
assert!(state.watching(), "the wish went with the greeting");
assert!(state.standing().at_a_beach());
state.let_go();
assert!(state.joined().is_none());
assert!(state.watching());
state.standing = Standing::hosting(
Announcer::new(1).expect("announcer"),
UdpTransport::host(0).expect("socket"),
);
assert!(state.hosting());
assert!(!state.watching(), "a host plays");
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.set_watching(watching);
assert_eq!(state.can_chat(), state.standing().at_a_beach());
}
state.standing = Standing::Joining(Joined::dialled(
UdpTransport::join(("127.0.0.1", 47998)).expect("join"),
false,
));
assert_eq!(state.can_chat(), state.standing().at_a_beach());
}
}