use super::*;
use crate::app::i18n::fill;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Pick {
Nothing,
AskName(usize),
Take(usize),
Full(usize),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) struct JoinAsk {
pub digit: Option<usize>,
pub enter_on: Option<usize>,
pub intent: Option<Intent>,
pub auto_join: bool,
pub busy: bool,
pub named: bool,
pub to_watch: bool,
}
pub(super) fn which_beach(ask: JoinAsk, hosts: &[HostEntry]) -> Pick {
let JoinAsk {
digit,
enter_on,
intent,
auto_join,
busy,
named,
to_watch,
} = ask;
let asked = match (intent, auto_join) {
(Some(Intent::Join(at)), _) => Some(at),
(Some(Intent::Host | Intent::Dial(_)), _) => return Pick::Nothing,
(None, true) => Some(0),
(None, false) => digit.or(enter_on),
};
let Some(at) = asked.filter(|at| *at < hosts.len()) else {
return Pick::Nothing;
};
if busy {
return Pick::Nothing;
}
if !to_watch && !hosts[at].has_room() {
return Pick::Full(at);
}
match named || auto_join || intent.is_some() {
true => Pick::Take(at),
false => Pick::AskName(at),
}
}
pub(super) fn take_a_beach(
keys: &ButtonInput<KeyCode>,
settings: &GameSettings,
state: &mut LobbyState,
tr: &'static crate::app::i18n::Tr,
intent: Option<Intent>,
auto_join: bool,
) {
const DIGITS: [KeyCode; 9] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
];
if let Some(Intent::Dial(addr)) = intent {
if !state.standing().at_a_beach() {
dial_at(state, settings, tr, addr);
}
return;
}
let digit = DIGITS.iter().position(|k| keys.just_pressed(*k));
let enter_on = keys
.just_pressed(KeyCode::Enter)
.then(|| state.selected_index())
.flatten();
let pick = which_beach(
JoinAsk {
digit,
enter_on,
intent,
auto_join,
busy: state.standing().at_a_beach(),
named: !settings.names[0].trim().is_empty(),
to_watch: state.watching,
},
&state.hosts,
);
if auto_join && matches!(pick, Pick::Take(_)) {
state.auto_done = true;
}
match pick {
Pick::Nothing => {}
Pick::AskName(at) => {
state.typing = Some(Typing::player_name(Intent::Join(at), &settings.names[0]));
}
Pick::Full(_) => state.feedback = tr.lobby_beach_full.to_string(),
Pick::Take(at) => dial(state, settings, tr, at),
}
}
fn dial(
state: &mut LobbyState,
settings: &GameSettings,
tr: &'static crate::app::i18n::Tr,
at: usize,
) {
debug_assert!(
at < state.hosts.len(),
"dialling row {at} of {}",
state.hosts.len()
);
let Some(host) = state.hosts.get(at) else {
return;
};
dial_at(state, settings, tr, host.addr);
}
pub(super) const NO_ANSWER_AFTER: f32 = 6.0;
fn answer_the_silence(
state: &mut LobbyState,
tr: &'static crate::app::i18n::Tr,
heard: bool,
delta: f32,
) -> bool {
if heard {
if !state.host_answered {
state.host_answered = true;
state.feedback = match state.watching {
true => tr.lobby_watching.to_string(),
false => tr.lobby_aboard.to_string(),
};
}
state.host_silence = 0.0;
return false;
}
state.host_silence += delta;
if state.host_silence < NO_ANSWER_AFTER {
return false;
}
let called = state
.joining
.as_ref()
.and_then(|transport| transport.peer_addr())
.map(|addr| addr.to_string())
.unwrap_or_default();
state.joining = None;
state.table.clear();
state.joined_terms = None;
state.feedback = fill(tr.lobby_no_answer, &[("a", &called)]);
true
}
pub(super) fn dial_at(
state: &mut LobbyState,
settings: &GameSettings,
tr: &'static crate::app::i18n::Tr,
addr: SocketAddr,
) {
match UdpTransport::join(addr) {
Ok(transport) => {
let watching = state.watching;
transport.send(if watching {
NetMsg::Watch
} else {
NetMsg::hello(&settings.names[0])
});
state.joining = Some(transport);
state.hello_in = ANNOUNCE_EVERY;
state.host_answered = false;
state.host_silence = 0.0;
state.feedback = fill(tr.lobby_calling, &[("a", &addr.to_string())]);
}
Err(e) => state.feedback = fill(tr.lobby_could_not_join, &[("e", &e.to_string())]),
}
}
pub(super) struct Invitation {
pub seats: u8,
pub seat: Option<u8>,
pub terms: MatchTerms,
pub names: [crate::transport::WireName; MAX_PLAYERS],
pub round: u8,
pub wins: [u8; MAX_PLAYERS],
pub beach: Vec<u8>,
}
pub(super) fn accept_the_invitation(
state: &mut LobbyState,
online: &mut Online,
tournament: &mut crate::app::tournament::Tournament,
next_screen: &mut NextState<Screen>,
next_vphase: &mut NextState<VersusPhase>,
started: Option<Invitation>,
) {
if let Some(Invitation {
seats,
seat,
terms,
names,
round,
wins,
beach,
}) = started
{
debug_assert!(
(2..=MAX_PLAYERS as u8).contains(&seats),
"invited to a {seats}-seat beach, which decode should have refused"
);
debug_assert!(
seat.is_none_or(|seat| seat < seats),
"seated at {seat:?} of {seats}"
);
let transport = state.joining.take().expect("checked above");
let humans = seats.saturating_sub(terms.bots).max(1);
let players: Vec<u8> = (0..humans).collect();
let session = match seat {
None => Lockstep::observer(players, DEFAULT_DELAY),
Some(seat) => Lockstep::new(seat, players, DEFAULT_DELAY),
};
let mut session = OnlineSession::new(transport, session, seats, terms);
session.beach = beach;
session.names = std::array::from_fn(|i| crate::transport::name_from_wire(&names[i]));
online.0 = Some(session);
*tournament = match terms.series == 1 {
true => crate::app::tournament::Tournament {
active: true,
finished: false,
round: round.max(1),
wins,
},
false => crate::app::tournament::Tournament::default(),
};
next_vphase.set(VersusPhase::Running);
next_screen.set(Screen::Versus);
}
}
pub fn join_tick(
time: Res<Time>,
settings: Res<GameSettings>,
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>>,
) {
if state.joining.is_none() {
return;
}
state.hello_in -= time.delta_secs();
let say_hello = state.hello_in <= 0.0;
if say_hello {
state.hello_in = ANNOUNCE_EVERY;
}
let mut started = None;
let mut mismatch = None;
let mut queued = None;
let mut said: Vec<(crate::transport::WireName, crate::transport::WireChat)> = Vec::new();
let mut table: Option<Vec<String>> = None;
let mut host_terms: Option<MatchTerms> = None;
let mut heard = false;
let watching = state.watching;
if let Some(transport) = &mut state.joining {
if say_hello {
transport.send(if watching {
NetMsg::Watch
} else {
NetMsg::hello(&settings.names[0])
});
}
for (msg, _) in transport.recv_all() {
heard = true;
match msg {
NetMsg::Start {
seats,
seat,
terms,
names,
round,
wins,
beach,
} => {
started = Some(Invitation {
seats,
seat,
terms,
names,
round,
wins,
beach,
})
}
NetMsg::Incompatible { version } => mismatch = Some(version),
NetMsg::Queued { ahead } => queued = Some(ahead),
NetMsg::Chat { name, text } => said.push((name, text)),
NetMsg::Roster { names, terms, .. } => {
table = Some(
names
.iter()
.map(crate::transport::name_from_wire)
.take_while(|name| !name.is_empty())
.collect(),
);
host_terms = Some(terms);
}
NetMsg::Hello { .. }
| NetMsg::Watch
| NetMsg::Input(_)
| NetMsg::Hash { .. }
| NetMsg::Pause { .. }
| NetMsg::Abandoned { .. }
| NetMsg::Resume { .. } => {}
}
}
}
if answer_the_silence(&mut state, settings.tr(), heard, time.delta_secs()) {
return;
}
if let Some(version) = mismatch {
state.joining = None;
state.feedback = fill(
settings.tr().lobby_version_clash,
&[
("t", &version.to_string()),
("o", &crate::transport::PROTOCOL_VERSION.to_string()),
],
);
return;
}
if let Some(table) = table {
state.table = table;
}
if let Some(terms) = host_terms {
state.joined_terms = Some(terms);
}
for (name, text) in said {
let who = crate::transport::name_from_wire(&name);
let line = crate::transport::chat_from_wire(&text);
state.say(&who, &line);
}
if let Some(ahead) = queued {
let tr = settings.tr();
state.feedback = match ahead {
0 => tr.lobby_queued_next.to_string(),
n => fill(tr.lobby_queued_behind, &[("n", &n.to_string())]),
};
}
accept_the_invitation(
&mut state,
&mut online,
&mut tournament,
&mut next_screen,
&mut next_vphase,
started,
);
}
#[cfg(test)]
mod tests {
use super::*;
fn asking() -> JoinAsk {
JoinAsk {
digit: None,
enter_on: None,
intent: None,
auto_join: false,
busy: false,
named: true,
to_watch: false,
}
}
fn beaches(spec: &[(u8, u8)]) -> Vec<HostEntry> {
spec.iter()
.enumerate()
.map(|(i, (taken, seats))| HostEntry {
addr: format!("10.0.0.{}:47777", i + 1).parse().expect("addr"),
id: i as u64 + 1,
name: format!("beach{i}"),
host: "Sam".to_string(),
taken: *taken,
seats: *seats,
running: false,
age: 0.0,
})
.collect()
}
fn open() -> Vec<HostEntry> {
beaches(&[(1, 6), (1, 6), (1, 6)])
}
#[test]
fn a_dialled_beach_hears_the_same_greeting_as_a_listed_one() {
let mut host = UdpTransport::host(0).expect("bind");
let port = host.local_addr().expect("addr").port();
let mut state = LobbyState::default();
let mut settings = GameSettings::default();
settings.names[0] = "Cy".into();
let there: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
dial_at(&mut state, &settings, &crate::app::i18n::EN, there);
assert!(state.joining.is_some(), "the socket is open and greeting");
let mut greeted = None;
for _ in 0..40 {
std::thread::sleep(std::time::Duration::from_millis(5));
for (msg, _) in host.recv_all() {
if let NetMsg::Hello { name, .. } = msg {
greeted = Some(crate::transport::name_from_wire(&name));
}
}
if greeted.is_some() {
break;
}
}
assert_eq!(greeted.as_deref(), Some("Cy"), "the host heard who called");
}
#[test]
fn a_key_picks_the_row_it_names_and_no_further() {
let hosts = open();
assert_eq!(
which_beach(
JoinAsk {
digit: Some(1),
..asking()
},
&hosts
),
Pick::Take(1)
);
assert_eq!(
which_beach(
JoinAsk {
digit: Some(6),
..asking()
},
&hosts
),
Pick::Nothing,
"the seventh of three"
);
assert_eq!(
which_beach(
JoinAsk {
enter_on: Some(2),
..asking()
},
&hosts
),
Pick::Take(2),
"Enter takes the cursor's beach"
);
assert_eq!(
which_beach(asking(), &hosts),
Pick::Nothing,
"and nothing pressed takes nothing"
);
assert_eq!(
which_beach(
JoinAsk {
digit: Some(0),
..asking()
},
&[]
),
Pick::Nothing,
"nor does a key with an empty hall"
);
}
#[test]
fn a_nameless_player_is_asked_first_and_lands_where_they_meant() {
let hosts = open();
assert_eq!(
which_beach(
JoinAsk {
digit: Some(2),
named: false,
..asking()
},
&hosts
),
Pick::AskName(2),
"asked, and asked about the right beach"
);
assert_eq!(
which_beach(
JoinAsk {
intent: Some(Intent::Join(2)),
..asking()
},
&hosts
),
Pick::Take(2)
);
assert_eq!(
which_beach(
JoinAsk {
intent: Some(Intent::Join(2)),
..asking()
},
&hosts[..1]
),
Pick::Nothing
);
}
#[test]
fn a_full_beach_is_refused_rather_than_queued_for() {
let hosts = beaches(&[(6, 6), (5, 6), (0, 0)]);
assert_eq!(
which_beach(
JoinAsk {
digit: Some(0),
..asking()
},
&hosts
),
Pick::Full(0)
);
assert_eq!(
which_beach(
JoinAsk {
digit: Some(1),
..asking()
},
&hosts
),
Pick::Take(1),
"one chair is still a chair"
);
assert_eq!(
which_beach(
JoinAsk {
digit: Some(2),
..asking()
},
&hosts
),
Pick::Take(2),
"a beach that described no table is not a full one"
);
}
#[test]
fn keys_do_nothing_once_you_are_already_at_a_beach() {
let hosts = open();
for pressed in [Some(0), Some(1)] {
assert_eq!(
which_beach(
JoinAsk {
digit: pressed,
busy: true,
..asking()
},
&hosts
),
Pick::Nothing
);
}
assert_eq!(
which_beach(
JoinAsk {
intent: Some(Intent::Join(0)),
busy: true,
..asking()
},
&hosts
),
Pick::Nothing
);
}
#[test]
fn the_intent_to_host_is_not_the_intent_to_join() {
let hosts = open();
assert_eq!(
which_beach(
JoinAsk {
enter_on: Some(1),
intent: Some(Intent::Host),
..asking()
},
&hosts
),
Pick::Nothing
);
}
#[test]
fn a_beach_that_never_answers_is_given_up_on() {
let mut state = LobbyState {
joining: Some(UdpTransport::join(("127.0.0.1", 47999)).expect("join")),
..LobbyState::default()
};
let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, 1.0);
assert!(!quiet, "one second is a lost packet, not an empty address");
assert!(state.joining.is_some(), "still calling");
let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, NO_ANSWER_AFTER);
assert!(quiet, "and then it stops calling");
assert!(state.joining.is_none(), "the socket is let go");
assert!(
state.feedback.contains("127.0.0.1:47999"),
"and says which address went unanswered: {:?}",
state.feedback
);
assert!(
!state.standing().at_a_beach(),
"and the lobby is back to browsing"
);
}
#[test]
fn the_first_word_is_what_makes_it_a_beach() {
let mut state = LobbyState {
joining: Some(UdpTransport::join(("127.0.0.1", 47999)).expect("join")),
feedback: "calling...".into(),
..LobbyState::default()
};
answer_the_silence(&mut state, &crate::app::i18n::EN, true, 0.1);
assert_eq!(state.feedback, crate::app::i18n::EN.lobby_aboard);
assert!(state.host_answered);
answer_the_silence(
&mut state,
&crate::app::i18n::EN,
false,
NO_ANSWER_AFTER - 0.1,
);
answer_the_silence(&mut state, &crate::app::i18n::EN, true, 0.1);
let quiet = answer_the_silence(&mut state, &crate::app::i18n::EN, false, 1.0);
assert!(!quiet, "the clock restarted when the host spoke");
assert!(state.joining.is_some());
}
#[test]
fn a_dialled_address_is_not_a_row_of_the_list() {
let hosts = open();
let there: SocketAddr = "192.168.1.5:47777".parse().expect("addr");
assert_eq!(
which_beach(
JoinAsk {
enter_on: Some(1),
intent: Some(Intent::Dial(there)),
..asking()
},
&hosts
),
Pick::Nothing
);
assert_eq!(
which_beach(
JoinAsk {
intent: Some(Intent::Dial(there)),
..asking()
},
&[]
),
Pick::Nothing
);
}
#[test]
fn the_unattended_hook_takes_the_first_beach_without_being_asked() {
let hosts = open();
assert_eq!(
which_beach(
JoinAsk {
auto_join: true,
named: false,
..asking()
},
&hosts
),
Pick::Take(0),
"nameless, and taken anyway"
);
assert_eq!(
which_beach(
JoinAsk {
auto_join: true,
named: false,
..asking()
},
&[]
),
Pick::Nothing,
"but it cannot take what is not there"
);
}
}