use super::*;
use crate::app::i18n::fill;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HostStep {
Ask,
Go,
Nothing,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct HostAsk {
pub answered: bool,
pub pressed_h: bool,
pub busy: bool,
pub auto: bool,
}
#[cfg(test)]
pub(super) fn idle() -> HostAsk {
HostAsk {
answered: false,
pressed_h: false,
busy: false,
auto: false,
}
}
#[cfg(test)]
pub(super) fn peers_named(names: &[&str], watchers: &[usize]) -> PeerBook {
let mut peers = PeerBook::default();
for (i, name) in names.iter().enumerate() {
peers.row(i).name = name.to_string();
}
for watcher in watchers {
peers.row(*watcher).watch = true;
}
peers
}
pub(super) fn address_here(transport: &UdpTransport) -> Option<String> {
let port = transport.local_addr().ok()?.port();
let ip = crate::transport::local_ip()?;
Some(SocketAddr::new(ip, port).to_string())
}
pub(super) fn host_step(ask: HostAsk) -> HostStep {
if ask.answered {
return HostStep::Go;
}
if ask.busy {
return HostStep::Nothing;
}
match (ask.pressed_h || ask.auto, ask.auto) {
(true, true) => HostStep::Go,
(true, false) => HostStep::Ask,
(false, _) => HostStep::Nothing,
}
}
#[allow(clippy::too_many_arguments)]
pub fn host_tick(
time: Res<Time>,
keys: Res<ButtonInput<KeyCode>>,
settings: Res<GameSettings>,
config: Res<MatchConfig>,
beaches: Res<crate::app::match_setup::CustomBeaches>,
mut state: ResMut<LobbyState>,
mut online: ResMut<Online>,
mut tournament: ResMut<crate::app::tournament::Tournament>,
mut next_screen: ResMut<NextState<Screen>>,
mut next_vphase: ResMut<NextState<VersusPhase>>,
) {
let tr = settings.tr();
let game_name = state.game_name.clone();
let Some(hosted) = state.hosted_mut() else {
return;
};
let taken = 1 + hosted.players_aboard() as u8;
let on_air = crate::transport::OnAir {
name: &game_name,
host: &settings.names[0],
taken,
seats: MAX_PLAYERS as u8,
};
let picked = work_the_socket(hosted, time.delta_secs(), on_air);
let do_announce = picked.announced;
for (_, name, text) in picked.said {
state.hear(&name, &text);
}
note_arrivals_and_departures(&mut state, tr, picked.greeted);
let joined = state.hosted().map_or(0, |hosted| hosted.peers.len());
state.table = state.roster(tr, &settings.names[0]);
if do_announce && let Some(hosted) = state.hosted() {
let names = crate::transport::wire_table(&state.table);
let terms = crate::app::match_setup::terms(&config, settings.team_mode, 0);
hosted.transport.send(NetMsg::Roster {
seats: state.table.len().min(MAX_PLAYERS) as u8,
names,
terms,
});
}
let (players_aboard, watchers_aboard, port) = state.hosted().map_or((0, 0, None), |hosted| {
(
hosted.players_aboard(),
hosted.watchers_aboard(),
hosted.transport.local_addr().ok().map(|addr| addr.port()),
)
});
if joined > 0 {
state.feedback = gathering_feedback(tr, &config, players_aboard, watchers_aboard);
} else {
state.feedback = match port {
Some(port) => fill(tr.lobby_hosting, &[("p", &port.to_string())]),
None => tr.lobby_hosting_noport.to_string(),
};
}
let quota = crate::app::dev::auto_host_quota();
let launch = should_launch(
joined,
state.typing.is_some(),
crate::app::menu_ui::enter(&keys),
quota,
);
if launch {
launch_the_match(
&mut state,
&settings,
&config,
&beaches,
joined,
&mut online,
&mut tournament,
&mut next_screen,
&mut next_vphase,
);
}
}
#[allow(clippy::too_many_arguments)]
fn launch_the_match(
state: &mut LobbyState,
settings: &GameSettings,
config: &MatchConfig,
beaches: &crate::app::match_setup::CustomBeaches,
joined: usize,
online: &mut Online,
tournament: &mut crate::app::tournament::Tournament,
next_screen: &mut NextState<Screen>,
next_vphase: &mut NextState<VersusPhase>,
) {
let Standing::Hosting(Hosted {
announcer,
transport,
peers,
announce_in: _,
}) = std::mem::take(&mut state.standing)
else {
unreachable!("launching a match from a lobby that is not hosting");
};
debug_assert_eq!(joined, peers.len());
let plan = seat_plan(&peers);
let humans = 1 + plan.iter().filter(|seat| seat.is_some()).count() as u8;
let bots = config
.bots
.min(MAX_PLAYERS as u8 - humans)
.max(2u8.saturating_sub(humans));
let seats = humans + bots;
let seed = crate::app::clock::fresh_seed();
let beach = crate::app::match_setup::beach_bytes(config, seats, beaches);
let terms = MatchTerms {
bots,
map: map_for(config, seats).index() as u8,
..crate::app::match_setup::terms(config, settings.team_mode, seed)
};
let mut names: [String; MAX_PLAYERS] = Default::default();
names[0].clone_from(&settings.names[0]);
for (peer, slot) in plan.iter().enumerate() {
if let Some(seat) = slot
&& let Some(peer) = peers.get(peer)
{
names[usize::from(*seat)].clone_from(&peer.name);
}
}
let wire_names = crate::transport::wire_table(&names);
let standing = terms
.is_series()
.then(crate::transport::SeriesStanding::opening);
for (peer, slot) in plan.iter().enumerate() {
transport.send_to(
peer,
NetMsg::Start {
seats,
seat: *slot,
terms,
names: wire_names,
standing,
beach: beach.clone(),
},
);
}
let mut session = OnlineSession::new(
transport,
crate::app::net::lockstep_for(Some(0), humans),
seats,
terms,
);
session.beach = beach;
session.peers = peers;
session.peers.deal(&plan);
session.names = names;
session.stay_on_air(announcer);
session.home.from_lobby = true;
session.home.game_name = state.game_name.clone();
online.0 = Some(session);
*tournament = crate::app::tournament::Tournament::from_terms(terms, standing);
next_vphase.set(VersusPhase::Running);
next_screen.set(Screen::Versus);
}
fn note_arrivals_and_departures(
state: &mut LobbyState,
tr: &'static crate::app::i18n::Tr,
greeted: Vec<(usize, String)>,
) {
for (from, told) in greeted {
let Some(hosted) = state.hosted_mut() else {
return;
};
let row = hosted.peers.row(from);
let arrived = row.name != told;
let newcomer = row.name.is_empty();
let watching = row.watch;
row.name.clone_from(&told);
if newcomer {
catch_up_on_the_feed(state, from);
}
if arrived {
let line = match watching {
true => tr.lobby_watching_joined,
false => tr.lobby_joined,
};
let notice = fill(line, &[("p", &told)]);
state.say("", ¬ice);
announce_to_table(state, "", ¬ice);
}
}
let gone: Vec<usize> = state.hosted().map_or(Vec::new(), |hosted| {
(0..hosted.peers.len())
.rev()
.filter(|peer| {
hosted
.peers
.get(*peer)
.is_some_and(|p| p.silence > PEER_TTL)
})
.collect()
});
for peer in gone {
let Some(hosted) = state.hosted_mut() else {
return;
};
let who = hosted
.peers
.get(peer)
.map_or_else(String::new, |p| p.name.clone());
hosted.forget_peer(peer);
if !who.is_empty() {
let notice = fill(tr.lobby_left, &[("p", &who)]);
state.say("", ¬ice);
announce_to_table(state, "", ¬ice);
}
}
}
#[derive(Default)]
struct Picked {
announced: bool,
greeted: Vec<(usize, String)>,
said: Vec<(
usize,
crate::transport::WireName,
crate::transport::WireChat,
)>,
}
fn work_the_socket(hosted: &mut Hosted, delta: f32, on_air: crate::transport::OnAir<'_>) -> Picked {
let Hosted {
announcer,
transport,
peers,
announce_in,
} = hosted;
let mut picked = Picked {
announced: once_a_second(announce_in, delta),
..Picked::default()
};
peers.age(delta);
if picked.announced
&& let Ok(addr) = transport.local_addr()
{
announcer.announce(addr.port(), on_air);
}
for (msg, from) in transport.recv_all() {
peers.reach(transport.peer_count());
peers.heard(from);
match msg {
NetMsg::Watch { name } => {
peers.row(from).watch = true;
let told = crate::transport::name_from_wire(&name);
if !told.is_empty() {
picked.greeted.push((from, told));
}
}
NetMsg::SpectatorVote { .. } | NetMsg::SpectatorTally { .. } => {}
NetMsg::Hello { name } => {
let told = crate::transport::name_from_wire(&name);
if !told.is_empty() {
picked.greeted.push((from, told));
}
}
NetMsg::Chat { name, text } => {
let known = peers.get(from).map_or("", |peer| peer.name.as_str());
let who = match known.is_empty() {
false => crate::transport::wire_name(known),
true => name,
};
if !crate::transport::name_from_wire(&who).is_empty() {
picked.said.push((from, who, text));
}
}
NetMsg::Inputs(_)
| NetMsg::Hash { .. }
| NetMsg::Start { .. }
| NetMsg::Pause { .. }
| NetMsg::Resume { .. }
| NetMsg::Queued { .. }
| NetMsg::Roster { .. }
| NetMsg::Abandoned { .. }
| NetMsg::CatchUp { .. }
| NetMsg::SpectatorPick { .. }
| NetMsg::CrowdPicks { .. }
| NetMsg::Incompatible { .. } => {}
}
}
for (from, name, text) in picked.said.iter() {
crate::app::net::relay(
transport,
*from,
NetMsg::Chat {
name: *name,
text: *text,
},
);
}
picked
}
pub(super) fn gathering_feedback(
tr: &'static crate::app::i18n::Tr,
config: &MatchConfig,
players_aboard: usize,
watching: usize,
) -> String {
let mut line = match players_aboard {
1 => tr.lobby_rivals_one.to_string(),
n => fill(tr.lobby_rivals_many, &[("n", &n.to_string())]),
};
if watching > 0 {
line += &fill(tr.lobby_watchers, &[("n", &watching.to_string())]);
}
let bots = config
.bots
.min((MAX_PLAYERS as u8).saturating_sub((players_aboard + 1) as u8));
if bots > 0 {
line += &fill(tr.lobby_ai_seats, &[("n", &bots.to_string())]);
}
line
}
pub(super) fn should_launch(
joined: usize,
typing: bool,
enter: bool,
quota: Option<usize>,
) -> bool {
if joined == 0 {
return false;
}
match quota {
Some(quota) if joined >= quota => true,
_ => enter && !typing,
}
}
fn catch_up_on_the_feed(state: &LobbyState, peer: usize) {
let Some(hosted) = state.hosted() else {
return;
};
for said in &state.chat {
hosted
.transport
.send_to(peer, NetMsg::chat(&said.who, &said.line));
}
}
pub(super) fn announce_to_table(state: &LobbyState, who: &str, line: &str) {
if let Some(hosted) = state.hosted() {
hosted.transport.send(NetMsg::chat(who, line));
}
}
pub(super) fn seat_plan(peers: &PeerBook) -> Vec<Option<u8>> {
let mut next = 1u8; peers
.iter()
.map(|peer| {
if peer.watch || usize::from(next) >= MAX_PLAYERS {
return None;
}
let seat = next;
next += 1;
Some(seat)
})
.collect()
}
pub(super) fn say_goodbye(state: &LobbyState) {
if let Some(hosted) = state.hosted()
&& let Ok(addr) = hosted.transport.local_addr()
{
hosted.announcer.closing(addr.port());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(n: usize, watchers: &[usize]) -> PeerBook {
let names = vec![""; n];
peers_named(&names, watchers)
}
#[test]
fn the_table_is_dealt_in_arrival_order_behind_the_host() {
assert_eq!(seat_plan(&table(0, &[])), Vec::<Option<u8>>::new());
assert_eq!(
seat_plan(&table(1, &[])),
vec![Some(1)],
"the host keeps zero"
);
assert_eq!(seat_plan(&table(3, &[])), vec![Some(1), Some(2), Some(3)]);
}
#[test]
fn watchers_are_stepped_over_and_the_seats_close_up() {
assert_eq!(seat_plan(&table(3, &[0])), vec![None, Some(1), Some(2)]);
assert_eq!(seat_plan(&table(3, &[1])), vec![Some(1), None, Some(2)]);
assert_eq!(seat_plan(&table(3, &[0, 1, 2])), vec![None, None, None]);
}
#[test]
fn hosting_asks_before_it_announces() {
assert_eq!(host_step(idle()), HostStep::Nothing);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
..idle()
}),
HostStep::Ask
);
assert_eq!(
host_step(HostAsk {
answered: true,
..idle()
}),
HostStep::Go
);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
busy: true,
..idle()
}),
HostStep::Nothing
);
assert_eq!(
host_step(HostAsk {
busy: true,
..idle()
}),
HostStep::Nothing
);
assert_eq!(
host_step(HostAsk {
auto: true,
..idle()
}),
HostStep::Go
);
assert_eq!(
host_step(HostAsk {
pressed_h: true,
auto: true,
..idle()
}),
HostStep::Go
);
}
#[test]
fn the_table_runs_out_of_chairs_before_the_socket_runs_out_of_peers() {
use crate::sim::MAX_PLAYERS;
assert_eq!(
seat_plan(&table(4, &[])),
vec![Some(1), Some(2), Some(3), Some(4)]
);
assert_eq!(
seat_plan(&table(4, &[1, 3])),
vec![Some(1), None, Some(2), None]
);
let full = seat_plan(&table(crate::transport::MAX_PEERS, &[]));
assert_eq!(
full.iter().filter(|seat| seat.is_some()).count(),
MAX_PLAYERS - 1,
"the host holds the sixth chair"
);
assert!(
full.iter()
.flatten()
.all(|&seat| usize::from(seat) < MAX_PLAYERS),
"no peer is given a seat the sim has no slot for"
);
assert!(full.last().unwrap().is_none(), "the late ones watch");
}
#[test]
fn a_half_written_sentence_does_not_start_the_match() {
assert!(should_launch(1, false, true, None));
assert!(
!should_launch(1, false, false, None),
"and nothing without it"
);
assert!(
!should_launch(0, false, true, None),
"nor with an empty table"
);
assert!(!should_launch(1, true, true, None));
assert!(!should_launch(3, true, true, None));
assert!(should_launch(2, false, false, Some(2)));
assert!(should_launch(2, true, false, Some(2)));
assert!(!should_launch(1, false, false, Some(2)), "not filled yet");
}
#[test]
fn a_peer_speaks_under_the_name_it_greeted_with() {
let mut state = LobbyState {
standing: Standing::hosting(
Announcer::new(0xC0FFEE).expect("announcer"),
UdpTransport::host(0).expect("game socket"),
),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let bo = UdpTransport::join(("127.0.0.1", port)).expect("join");
bo.send(NetMsg::hello("Bo"));
let mut said = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
let picked = work_the_socket(hosted, 0.0, crate::transport::OnAir::default());
for (from, told) in picked.greeted {
hosted.peers.row(from).name = told;
}
said.extend(picked.said);
if state
.hosted()
.expect("hosting")
.peers
.get(0)
.is_some_and(|peer| peer.name == "Bo")
{
break;
}
}
assert_eq!(
state
.hosted()
.expect("hosting")
.peers
.get(0)
.map(|p| p.name.as_str()),
Some("Bo"),
"the greeting is on file"
);
bo.send(NetMsg::chat("Bo", "ready?"));
bo.send(NetMsg::chat("Anna", "I am Anna, honest"));
bo.send(NetMsg::chat("", "Anna has left the beach"));
said.clear();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
said.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).said);
if said.len() >= 2 {
break;
}
}
let heard: Vec<(String, String)> = said
.iter()
.map(|(_, name, text)| {
(
crate::transport::name_from_wire(name),
crate::transport::chat_from_wire(text),
)
})
.collect();
assert_eq!(
heard,
[
("Bo".to_string(), "ready?".to_string()),
("Bo".to_string(), "I am Anna, honest".to_string()),
("Bo".to_string(), "Anna has left the beach".to_string()),
],
"every line under the name Bo greeted with: no rival's name to \
wear, and no forging the room's"
);
}
#[test]
fn a_late_arrival_is_shown_what_the_room_already_said() {
let mut state = LobbyState {
standing: Standing::hosting(
Announcer::new(0xC0FFEE).expect("announcer"),
UdpTransport::host(0).expect("game socket"),
),
..LobbyState::default()
};
state.say("Ann", "anyone up for a round?");
state.say("", "Bo joined");
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let mut latecomer = UdpTransport::join(("127.0.0.1", port)).expect("join");
latecomer.send(NetMsg::hello("Cy"));
let mut greeted = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
greeted
.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).greeted);
if !greeted.is_empty() {
break;
}
}
assert_eq!(greeted, [(0, "Cy".to_string())], "Cy is at the door");
note_arrivals_and_departures(&mut state, &crate::app::i18n::EN, greeted);
let mut heard = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
for (msg, _) in latecomer.recv_all() {
if let NetMsg::Chat { name, text } = msg {
heard.push((
crate::transport::name_from_wire(&name),
crate::transport::chat_from_wire(&text),
));
}
}
if heard.len() >= 3 {
break;
}
}
assert_eq!(
heard,
[
("Ann".to_string(), "anyone up for a round?".to_string()),
(String::new(), "Bo joined".to_string()),
(String::new(), "Cy joined".to_string()),
],
"the room as it stands, oldest first, and then their own arrival"
);
}
#[test]
fn a_spectator_is_announced_by_name_and_then_held_to_it() {
let mut state = LobbyState {
standing: Standing::hosting(
Announcer::new(0xDECAF).expect("announcer"),
UdpTransport::host(0).expect("game socket"),
),
..LobbyState::default()
};
let port = state
.hosted()
.expect("hosting")
.transport
.local_addr()
.expect("addr")
.port();
let watcher = UdpTransport::join(("127.0.0.1", port)).expect("join");
watcher.send(NetMsg::watch("Dee"));
let mut greeted = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
greeted
.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).greeted);
if !greeted.is_empty() {
break;
}
}
assert_eq!(
greeted,
[(0, "Dee".to_string())],
"a spectator says what to call it, like everybody else"
);
note_arrivals_and_departures(&mut state, &crate::app::i18n::EN, greeted);
assert!(
state.chat.iter().any(|said| said.line == "Dee is watching"),
"and the room is told, in the words for watching rather than \
for taking a chair: {:?}",
state.chat
);
watcher.send(NetMsg::chat("", "Anna has left the beach"));
watcher.send(NetMsg::chat("Cy", "good luck!"));
let mut said = Vec::new();
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
let hosted = state.hosted_mut().expect("hosting");
said.extend(work_the_socket(hosted, 0.0, crate::transport::OnAir::default()).said);
if !said.is_empty() {
break;
}
}
let heard: Vec<(String, String)> = said
.iter()
.map(|(_, name, text)| {
(
crate::transport::name_from_wire(name),
crate::transport::chat_from_wire(text),
)
})
.collect();
assert_eq!(
heard,
[
("Dee".to_string(), "Anna has left the beach".to_string()),
("Dee".to_string(), "good luck!".to_string()),
],
"both lines under the name it greeted with: no rival's name to \
wear, and the room's own voice no longer on offer either, \
which it was for as long as a spectator had no name to give \
back"
);
}
}