use super::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Dial {
Map,
Gulls,
Round,
Posts,
Bots,
Teams,
Series,
Welcome,
}
impl Dial {
pub const ALL: [Dial; 8] = [
Dial::Map,
Dial::Gulls,
Dial::Round,
Dial::Posts,
Dial::Bots,
Dial::Teams,
Dial::Series,
Dial::Welcome,
];
pub const TURNS: [Dial; 7] = [
Dial::Gulls,
Dial::Round,
Dial::Posts,
Dial::Bots,
Dial::Teams,
Dial::Series,
Dial::Welcome,
];
pub fn turns(self) -> bool {
Dial::TURNS.contains(&self)
}
pub fn label(self, tr: &crate::app::i18n::Tr, card: &TermsCard) -> (&'static str, String) {
(self.name(tr), self.value(tr, card).unwrap_or_default())
}
fn name(self, tr: &crate::app::i18n::Tr) -> &'static str {
match self {
Dial::Map => tr.match_map,
Dial::Gulls => tr.match_gulls,
Dial::Round => tr.match_round,
Dial::Posts => tr.match_posts,
Dial::Bots => tr.match_ai,
Dial::Teams => tr.set_versus_mode,
Dial::Series => tr.match_mode,
Dial::Welcome => tr.lobby_bots_dial,
}
}
fn value(self, tr: &crate::app::i18n::Tr, card: &TermsCard) -> Option<String> {
use crate::app::cycle::Cycle;
let (config, teams, dealt) = card.terms()?;
let turns = matches!(card, TermsCard::Hosting { .. });
Some(match self {
Dial::Map => tr.lobby_map_at_start.to_string(),
Dial::Gulls => tr.gull_names[config.gulls.index()].to_string(),
Dial::Round => tr.round_names[config.round.index()].to_string(),
Dial::Posts => config.posts.to_string(),
Dial::Bots => {
let bots = dealt.map_or(config.lobby_bots, |dealt| dealt.bots);
match bots {
0 => bots.to_string(),
_ => format!(
"{bots} ({})",
tr.bot_levels[crate::app::match_setup::online_ai_level(config).index()]
),
}
}
Dial::Teams => match dealt {
Some(dealt) => crate::app::teams::row_value(tr, teams, dealt.seats, turns),
None => tr.team_modes[teams.index()].to_string(),
},
Dial::Series => tr.mode_names[config.series.index()].to_string(),
Dial::Welcome => tr.lobby_bots_values[usize::from(!config.bots_welcome)].to_string(),
})
}
pub fn turn(
self,
turn: Turn,
config: &mut MatchConfig,
teams: &mut crate::app::teams::TeamMode,
humans: u8,
) {
use crate::app::cycle::Cycle;
let room = (MAX_PLAYERS as u8).saturating_sub(humans);
match self {
Dial::Map => {}
Dial::Gulls => config.gulls = config.gulls.cycled(turn),
Dial::Round => config.round = config.round.cycled(turn),
Dial::Posts => {
config.posts = crate::app::cycle::dial(
config.posts,
turn,
1,
crate::app::match_setup::POSTS_RANGE,
);
}
Dial::Bots => {
let shown = dealt(config, humans).bots;
let step = (i32::from(shown) + turn.signum()).clamp(0, i32::from(room)) as u8;
if step != shown {
config.lobby_bots = step;
}
}
Dial::Teams => *teams = teams.cycled(turn),
Dial::Series => config.series = config.series.cycled(turn),
Dial::Welcome => config.bots_welcome = !config.bots_welcome,
}
}
}
pub enum TermsCard<'a> {
Choosing {
config: &'a MatchConfig,
mode: crate::app::teams::TeamMode,
},
Hosting {
config: &'a MatchConfig,
mode: crate::app::teams::TeamMode,
dealt: Dealt,
},
Waiting,
Joined {
config: MatchConfig,
mode: crate::app::teams::TeamMode,
dealt: Dealt,
},
}
impl TermsCard<'_> {
fn terms(&self) -> Option<(&MatchConfig, crate::app::teams::TeamMode, Option<Dealt>)> {
match self {
TermsCard::Choosing { config, mode } => Some((config, *mode, None)),
TermsCard::Hosting {
config,
mode,
dealt,
} => Some((config, *mode, Some(*dealt))),
TermsCard::Waiting => None,
TermsCard::Joined {
config,
mode,
dealt,
} => Some((config, *mode, Some(*dealt))),
}
}
}
pub(super) fn card_of<'a>(
state: &'a LobbyState,
config: &'a MatchConfig,
teams: crate::app::teams::TeamMode,
) -> TermsCard<'a> {
if let Some(dealt) = hosts_dealt(state, config) {
return TermsCard::Hosting {
config,
mode: teams,
dealt,
};
}
let Some(joined) = state.joined() else {
return TermsCard::Choosing {
config,
mode: teams,
};
};
let Some(host) = joined.host_terms.as_ref() else {
return TermsCard::Waiting;
};
let (mut config, mode) = crate::app::match_setup::config_from_terms(&host.terms);
config.bots_welcome = state.joined_entry().is_some_and(|entry| entry.bots);
TermsCard::Joined {
config,
mode,
dealt: roster_dealt(state.table.len(), &host.terms),
}
}
pub(super) fn humans_here(state: &LobbyState) -> u8 {
(state.table.len().min(MAX_PLAYERS) as u8)
.max(1 + state.hosted().map_or(0, Hosted::players_aboard) as u8)
}
pub(super) fn hosts_dealt(state: &LobbyState, config: &MatchConfig) -> Option<Dealt> {
state.hosting().then(|| dealt(config, humans_here(state)))
}
pub(super) fn dials_note(state: &LobbyState, tr: &crate::app::i18n::Tr) -> String {
state
.hosted()
.and_then(|hosted| hosted.bot_string.as_ref())
.map(|string| format!("{}: {string}", tr.lobby_bots_dial))
.unwrap_or_default()
}
pub(super) fn roster_beach(
config: &MatchConfig,
seats: u8,
beaches: &crate::app::match_setup::CustomBeaches,
) -> crate::transport::WireName {
let name = crate::app::match_setup::beach_on_air(config, seats, beaches)
.map_or("", |beach| beach.level.name.as_str());
crate::transport::wire_name(name)
}
pub(super) fn launch_bots(humans: u8, dial: u8) -> u8 {
dial.min(MAX_PLAYERS as u8 - humans)
.max(2u8.saturating_sub(humans))
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Dealt {
pub bots: u8,
pub seats: u8,
pub map: crate::app::match_setup::MapChoice,
}
pub(super) fn roster_dealt(table: usize, terms: &MatchTerms) -> Dealt {
let bots = terms.bots.min(MAX_PLAYERS as u8);
Dealt {
bots,
seats: (table.min(MAX_PLAYERS) as u8 + bots).min(MAX_PLAYERS as u8),
map: crate::app::match_setup::TermsDials::map(terms),
}
}
pub(super) fn dealt(config: &MatchConfig, humans: u8) -> Dealt {
let bots = launch_bots(humans, config.lobby_bots);
let seats = humans + bots;
Dealt {
bots,
seats,
map: map_for(config, seats),
}
}
pub fn map_for(config: &MatchConfig, seats: u8) -> crate::app::match_setup::MapChoice {
use crate::app::match_setup::{holds, sized_for};
match holds(config.map, seats) {
true => config.map,
false => sized_for(seats),
}
}
pub(super) fn turn_the_dials(
keys: &ButtonInput<KeyCode>,
settings: &mut GameSettings,
caps: &crate::app::keycaps::KeyCaps,
config: &mut MatchConfig,
state: &mut LobbyState,
) {
if state.hosting() {
let rows = Dial::TURNS.len();
if keys.just_pressed(KeyCode::ArrowUp) {
state.dial = (state.dial + rows - 1) % rows;
}
if keys.just_pressed(KeyCode::ArrowDown) {
state.dial = (state.dial + 1) % rows;
}
let turn = match (
keys.just_pressed(KeyCode::ArrowRight),
keys.just_pressed(KeyCode::ArrowLeft),
) {
(true, false) => Some(Turn::Right),
(false, true) => Some(Turn::Left),
_ => None,
};
if let Some(turn) = turn
&& let Some(dial) = Dial::TURNS.get(state.dial).copied()
{
let humans = humans_here(state);
let mut teams = settings.team_mode;
dial.turn(turn, config, &mut teams, humans);
if teams != settings.team_mode {
settings.team_mode = teams;
settings.save(caps);
}
}
}
}
#[cfg(test)]
mod dial_tests {
use super::*;
use crate::app::match_setup::{CLASSIC_SEATS, MapChoice};
use crate::app::teams::TeamMode;
#[test]
fn every_dial_reads_as_a_row() {
let config = MatchConfig::default();
for lang in crate::app::i18n::ALL_LANGS {
for dial in Dial::ALL {
let card = TermsCard::Choosing {
config: &config,
mode: TeamMode::Solo,
};
let (name, value) = dial.label(lang.tr(), &card);
assert!(!name.is_empty(), "{dial:?} in {lang:?} has no name");
assert!(!value.is_empty(), "{dial:?} in {lang:?} has no value");
}
}
}
#[test]
fn a_joiners_card_reads_the_hosts_terms_or_nothing() {
let tr = &crate::app::i18n::EN;
for dial in Dial::ALL {
let (name, value) = dial.label(tr, &TermsCard::Waiting);
assert!(!name.is_empty(), "{dial:?}");
assert!(
value.is_empty(),
"{dial:?} reads {value:?} before any terms"
);
}
let config = MatchConfig::default();
let card = TermsCard::Joined {
dealt: dealt(&config, 2),
config,
mode: TeamMode::Solo,
};
assert_eq!(Dial::Map.label(tr, &card).1, tr.lobby_map_at_start);
}
#[test]
fn every_dial_turns_and_only_itself() {
for dial in Dial::TURNS {
for turn in [Turn::Right, Turn::Left] {
let mut config = MatchConfig {
lobby_bots: 1,
..MatchConfig::default()
};
let mut teams = TeamMode::Solo;
let before = (
config.map,
config.gulls,
config.round,
config.lobby_bots,
teams,
config.series,
config.posts,
config.bots_welcome,
);
dial.turn(turn, &mut config, &mut teams, 2);
let after = (
config.map,
config.gulls,
config.round,
config.lobby_bots,
teams,
config.series,
config.posts,
config.bots_welcome,
);
assert_ne!(before, after, "{dial:?} turned {turn:?} and did nothing");
let moved = [
before.0 != after.0,
before.1 != after.1,
before.2 != after.2,
before.3 != after.3,
before.4 != after.4,
before.5 != after.5,
before.6 != after.6,
before.7 != after.7,
];
assert_eq!(
moved.iter().filter(|m| **m).count(),
1,
"{dial:?} moved more than itself"
);
}
}
}
#[test]
fn the_ai_takes_only_the_chairs_the_players_leave() {
let mut config = MatchConfig::default();
let mut teams = TeamMode::Solo;
for _ in 0..10 {
Dial::Bots.turn(Turn::Right, &mut config, &mut teams, 4);
}
assert_eq!(config.lobby_bots, MAX_PLAYERS as u8 - 4);
for _ in 0..10 {
Dial::Bots.turn(Turn::Left, &mut config, &mut teams, 4);
}
assert_eq!(config.lobby_bots, 0);
Dial::Bots.turn(Turn::Right, &mut config, &mut teams, MAX_PLAYERS as u8);
assert_eq!(config.lobby_bots, 0, "six people is six people");
config.lobby_bots = 3;
for turn in [Turn::Right, Turn::Left] {
Dial::Bots.turn(turn, &mut config, &mut teams, MAX_PLAYERS as u8);
assert_eq!(config.lobby_bots, 3, "a dead press {turn:?}");
}
}
#[test]
fn a_small_beach_gives_way_to_the_people_who_turned_up() {
let mut config = MatchConfig {
map: MapChoice::Classic,
..MatchConfig::default()
};
assert!(config.map.size().0 < crate::app::match_setup::WIDE_ENOUGH);
for seats in 2..=CLASSIC_SEATS {
assert_eq!(map_for(&config, seats), MapChoice::Classic, "{seats} seats");
}
for seats in CLASSIC_SEATS + 1..=MAX_PLAYERS as u8 {
let map = map_for(&config, seats);
assert!(
map.size().0 >= crate::app::match_setup::WIDE_ENOUGH,
"{seats} seats landed on {map:?}, which cannot hold them"
);
}
config.map = MapChoice::GenLarge;
for seats in 2..=MAX_PLAYERS as u8 {
assert_eq!(map_for(&config, seats), MapChoice::GenLarge);
}
}
#[test]
fn what_the_host_turns_is_what_travels() {
let mut config = MatchConfig::default();
let mut teams = TeamMode::Solo;
for dial in Dial::TURNS {
dial.turn(Turn::Right, &mut config, &mut teams, 2);
}
let terms = crate::app::match_setup::terms(&config, teams, 7);
assert_eq!(terms.map, config.map.index() as u8);
assert_eq!(terms.gulls, config.gulls.index() as u8);
assert_eq!(terms.round, config.round.index() as u8);
assert_eq!(terms.bots, config.lobby_bots);
assert_eq!(terms.teams, teams.index() as u8);
assert_eq!(terms.series, config.series.index() as u8);
assert_eq!(terms.posts, config.posts);
assert_eq!(terms.seed, 7);
}
#[test]
fn the_hosts_card_reads_what_the_launch_deals() {
use crate::app::i18n::EN;
let mut config = MatchConfig {
map: MapChoice::Classic,
lobby_bots: 4,
..MatchConfig::default()
};
let dealt = dealt(&config, 5);
assert_eq!((dealt.bots, dealt.seats), (1, 6));
let row = |dial: Dial, config: &MatchConfig| {
let card = TermsCard::Hosting {
config,
mode: TeamMode::Solo,
dealt,
};
dial.label(&EN, &card).1
};
assert_eq!(row(Dial::Bots, &config), "1 (normal)");
assert_eq!(row(Dial::Map, &config), EN.lobby_map_at_start);
assert_eq!(
dealt.map,
MapChoice::GenLarge,
"five aboard outgrow Classic"
);
let mut teams = TeamMode::Solo;
Dial::Bots.turn(Turn::Left, &mut config, &mut teams, 5);
assert_eq!(config.lobby_bots, 0, "one less than the one shown");
}
#[test]
fn the_note_under_the_dials_is_the_bots_string() {
use crate::app::i18n::EN;
let mut state = LobbyState::default();
assert_eq!(dials_note(&state, &EN), "", "not hosting");
state.standing = hosting_lobby();
assert_eq!(dials_note(&state, &EN), "");
if let Standing::Hosting(hosted) = &mut state.standing {
hosted.bot_string = Some("pinch://here".into());
}
let note = dials_note(&state, &EN);
assert!(note.contains("pinch://here"), "{note}");
}
#[test]
fn the_teams_row_reads_what_will_be_played() {
use crate::app::i18n::EN;
let mut config = MatchConfig::default();
let three = dealt(&config, 3);
assert_eq!(three.seats, 3);
let card = MatchConfig::default();
let row = |mode: TeamMode, dealt: Dealt, turns: bool| {
let card = match turns {
true => TermsCard::Hosting {
config: &card,
mode,
dealt,
},
false => TermsCard::Joined {
config: MatchConfig::default(),
mode,
dealt,
},
};
Dial::Teams.label(&EN, &card).1
};
assert_eq!(
row(TeamMode::Pairs, three, false),
EN.team_modes[TeamMode::Solo.index()],
"a joiner reads what is played"
);
assert_eq!(
row(TeamMode::Pairs, three, true),
"pairs (free-for-all until 4)",
"the host reads what it dialled as well"
);
assert_eq!(
row(TeamMode::Trios, three, true),
"trios (free-for-all until 6)"
);
let five = Dealt { seats: 5, ..three };
assert_eq!(
row(TeamMode::Pairs, five, true),
"pairs (free-for-all until 6)",
"the next table that fits, not the first"
);
assert_eq!(
row(TeamMode::Solo, three, true),
EN.team_modes[TeamMode::Solo.index()]
);
let mut teams = TeamMode::Solo;
for want in [TeamMode::Pairs, TeamMode::Trios, TeamMode::Solo] {
Dial::Teams.turn(Turn::Right, &mut config, &mut teams, 3);
assert_eq!(teams, want);
}
let four = dealt(&config, 4);
assert_eq!(
row(TeamMode::Pairs, four, true),
EN.team_modes[TeamMode::Pairs.index()],
"played as dialled once it fits"
);
let terms = MatchTerms {
bots: 1,
..crate::app::match_setup::terms(&config, TeamMode::Pairs, 0)
};
assert_eq!(roster_dealt(2, &terms).seats, 3);
assert_eq!(
row(TeamMode::Pairs, roster_dealt(2, &terms), false),
EN.team_modes[TeamMode::Solo.index()]
);
}
#[test]
fn the_waiting_teams_row_fits_where_the_map_names_do() {
use crate::app::i18n::metrics::text_px;
let widest = crate::app::i18n::ALL_LANGS
.iter()
.flat_map(|lang| lang.tr().map_names.iter())
.map(|name| text_px(name, 18.0))
.fold(0.0, f32::max);
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
for mode in [TeamMode::Pairs, TeamMode::Trios] {
let row = fill(
tr.team_mode_waits,
&[("m", tr.team_mode_names[mode.index()]), ("n", "6")],
);
let wide = text_px(&row, 18.0);
assert!(
wide <= widest,
"{lang:?}: {row:?} is {wide:.0}px, the widest map name {widest:.0}px"
);
}
}
}
#[test]
fn the_waiting_teams_row_is_said_in_every_language() {
let three = dealt(&MatchConfig::default(), 3);
for lang in crate::app::i18n::ALL_LANGS {
let tr = lang.tr();
let card = TermsCard::Hosting {
config: &MatchConfig::default(),
mode: TeamMode::Pairs,
dealt: three,
};
let row = Dial::Teams.label(tr, &card).1;
assert!(!row.contains('{'), "{lang:?}: {row}");
assert!(row.contains('4'), "{lang:?}: {row}");
assert!(
row.contains(tr.team_mode_names[TeamMode::Pairs.index()]),
"{lang:?}: {row}"
);
}
}
}