use crate::app::cycle::{Cycle, Turn};
use crate::app::i18n::fill;
use crate::app::palette;
use crate::app::{Screen, SeatController};
use crate::sim::BotLevel;
use crate::sim::{MAX_PLAYERS, MAX_SIGNPOSTS_PER_PLAYER};
use crate::transport::MatchTerms;
use bevy::prelude::*;
mod beaches;
mod board;
mod preview;
mod steps;
mod ui;
pub use beaches::{
Beach, CustomBeaches, beach_bytes, beach_from, beach_in_play, beach_on_air, beaches_note,
cycle_map, map_about, map_label, map_name, map_title, refresh_custom_beaches,
};
pub use board::{apply_terms, board_for, board_from, bot_seats_from, local_board, versus_board};
pub use preview::{
GameCamera, MapPreviews, PREVIEW_PX, PREVIEW_TICKS, Pic, PreviewCamera, PreviewPipelines,
draw_board_picture, preview_board, release_previews, render_previews, retire_previews,
watch_preview_pipelines,
};
pub use steps::*;
#[cfg(test)]
pub(crate) use ui::card_note;
pub use ui::{
MapBar, MatchUi, enter_match_setup, follow_map_bar, host_map_title, leave_match_setup,
moved_note, paint_game_step, paint_map_step, paint_players_step, paint_start_button,
paint_teams, start_button_label, update_host_map_ui, update_match_ui,
};
pub(crate) use ui::{cut_to, line_px};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MapChoice {
#[default]
Classic,
GenSmall,
GenClassic,
GenLarge,
GenXl,
GenOcean,
Custom,
TheGates,
TheMoat,
LogJam,
ThreeLanes,
RoundTable,
RoundTheWorld,
Duel,
Surprise,
TheFountain,
FourDoors,
GullRoad,
BackToBack,
}
impl MapChoice {
pub const ALL: [MapChoice; 19] = [
MapChoice::Classic,
MapChoice::GenSmall,
MapChoice::GenClassic,
MapChoice::GenLarge,
MapChoice::GenXl,
MapChoice::GenOcean,
MapChoice::Custom,
MapChoice::TheGates,
MapChoice::TheMoat,
MapChoice::LogJam,
MapChoice::ThreeLanes,
MapChoice::RoundTable,
MapChoice::RoundTheWorld,
MapChoice::Duel,
MapChoice::Surprise,
MapChoice::TheFountain,
MapChoice::FourDoors,
MapChoice::GullRoad,
MapChoice::BackToBack,
];
pub const DIAL: [MapChoice; 19] = [
MapChoice::Classic,
MapChoice::TheGates,
MapChoice::TheMoat,
MapChoice::LogJam,
MapChoice::ThreeLanes,
MapChoice::RoundTable,
MapChoice::RoundTheWorld,
MapChoice::Duel,
MapChoice::TheFountain,
MapChoice::FourDoors,
MapChoice::GullRoad,
MapChoice::BackToBack,
MapChoice::GenSmall,
MapChoice::GenClassic,
MapChoice::GenLarge,
MapChoice::GenXl,
MapChoice::GenOcean,
MapChoice::Custom,
MapChoice::Surprise,
];
pub fn token(self) -> &'static str {
match self {
MapChoice::Classic => "classic",
MapChoice::GenSmall => "small",
MapChoice::GenClassic => "generated",
MapChoice::GenLarge => "large",
MapChoice::GenXl => "xl",
MapChoice::GenOcean => "ocean",
MapChoice::Custom => "custom",
MapChoice::TheGates => "gates",
MapChoice::TheMoat => "moat",
MapChoice::LogJam => "log-jam",
MapChoice::ThreeLanes => "three-lanes",
MapChoice::RoundTable => "round-table",
MapChoice::RoundTheWorld => "round-the-world",
MapChoice::Duel => "duel",
MapChoice::Surprise => "surprise",
MapChoice::TheFountain => "fountain",
MapChoice::FourDoors => "four-doors",
MapChoice::GullRoad => "gull-road",
MapChoice::BackToBack => "back-to-back",
}
}
pub fn from_token(token: &str) -> Option<MapChoice> {
let token = match token {
"kelp-keep" => MapChoice::TheGates.token(),
token => token,
};
MapChoice::ALL.into_iter().find(|map| map.token() == token)
}
pub fn handmade(self) -> Option<usize> {
match self {
MapChoice::TheGates => Some(0),
MapChoice::TheMoat => Some(1),
MapChoice::LogJam => Some(2),
MapChoice::ThreeLanes => Some(3),
MapChoice::RoundTable => Some(4),
MapChoice::RoundTheWorld => Some(5),
MapChoice::Duel => Some(6),
MapChoice::TheFountain => Some(7),
MapChoice::FourDoors => Some(8),
MapChoice::GullRoad => Some(9),
MapChoice::BackToBack => Some(10),
MapChoice::Classic
| MapChoice::GenSmall
| MapChoice::GenClassic
| MapChoice::GenLarge
| MapChoice::GenXl
| MapChoice::GenOcean
| MapChoice::Custom
| MapChoice::Surprise => None,
}
}
pub fn size(self) -> (u8, u8) {
match self {
MapChoice::Classic | MapChoice::GenClassic => (12, 9),
MapChoice::GenSmall => (9, 7),
MapChoice::GenLarge => (16, 11),
MapChoice::GenXl => (20, 13),
MapChoice::GenOcean => (16, 11),
MapChoice::Custom => (20, 13),
MapChoice::TheGates | MapChoice::TheMoat | MapChoice::LogJam => (13, 9),
MapChoice::ThreeLanes => (15, 9),
MapChoice::RoundTable => (15, 11),
MapChoice::RoundTheWorld => (21, 12),
MapChoice::Duel => (11, 7),
MapChoice::TheFountain => (13, 11),
MapChoice::FourDoors => (21, 11),
MapChoice::GullRoad => (25, 13),
MapChoice::BackToBack => (21, 12),
MapChoice::Surprise => crate::sim::surprise_size(CLASSIC_SEATS),
}
}
pub fn wraps(self) -> bool {
match self {
MapChoice::GenOcean
| MapChoice::RoundTheWorld
| MapChoice::FourDoors
| MapChoice::GullRoad
| MapChoice::BackToBack => true,
MapChoice::Classic
| MapChoice::GenSmall
| MapChoice::GenClassic
| MapChoice::GenLarge
| MapChoice::GenXl
| MapChoice::Custom
| MapChoice::TheGates
| MapChoice::TheMoat
| MapChoice::LogJam
| MapChoice::ThreeLanes
| MapChoice::RoundTable
| MapChoice::Duel
| MapChoice::TheFountain
| MapChoice::Surprise => false,
}
}
pub fn tables(self) -> Option<&'static [u8]> {
match self {
MapChoice::TheGates
| MapChoice::TheMoat
| MapChoice::LogJam
| MapChoice::ThreeLanes
| MapChoice::TheFountain => Some(&[2, 4]),
MapChoice::RoundTable | MapChoice::RoundTheWorld | MapChoice::BackToBack => {
Some(&[2, 4, 6])
}
MapChoice::Duel => Some(&[2]),
MapChoice::FourDoors => Some(&[3]),
MapChoice::GullRoad => Some(&[5]),
MapChoice::Classic
| MapChoice::GenSmall
| MapChoice::GenClassic
| MapChoice::GenLarge
| MapChoice::GenXl
| MapChoice::GenOcean
| MapChoice::Custom
| MapChoice::Surprise => None,
}
}
pub fn most_seats(self) -> u8 {
if let Some(tables) = self.tables() {
return tables.iter().copied().max().unwrap_or(CLASSIC_SEATS);
}
match holds(self, MAX_PLAYERS as u8) {
true => MAX_PLAYERS as u8,
false => CLASSIC_SEATS,
}
}
pub fn turned(self, turn: Turn) -> MapChoice {
let n = MapChoice::DIAL.len();
let at = MapChoice::DIAL
.iter()
.position(|&map| map == self)
.unwrap_or(0);
let step = match turn {
Turn::Right => 1,
Turn::Left => n - 1,
};
MapChoice::DIAL[(at + step) % n]
}
fn dealt(self) -> bool {
match self {
MapChoice::GenXl | MapChoice::Surprise | MapChoice::Custom => false,
MapChoice::Classic
| MapChoice::GenSmall
| MapChoice::GenClassic
| MapChoice::GenLarge
| MapChoice::GenOcean
| MapChoice::TheGates
| MapChoice::TheMoat
| MapChoice::LogJam
| MapChoice::ThreeLanes
| MapChoice::RoundTable
| MapChoice::RoundTheWorld
| MapChoice::Duel
| MapChoice::TheFountain
| MapChoice::FourDoors
| MapChoice::GullRoad
| MapChoice::BackToBack => true,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum GullPressure {
Calm,
#[default]
Normal,
Frenzy,
}
impl GullPressure {
pub const ALL: [GullPressure; 3] = [
GullPressure::Calm,
GullPressure::Normal,
GullPressure::Frenzy,
];
pub fn period(self) -> u32 {
match self {
GullPressure::Calm => 340,
GullPressure::Normal => 240,
GullPressure::Frenzy => 150,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum RoundLength {
Quick,
Short,
#[default]
Standard,
Long,
}
impl RoundLength {
pub const ALL: [RoundLength; 4] = [
RoundLength::Quick,
RoundLength::Short,
RoundLength::Standard,
RoundLength::Long,
];
pub fn ticks(self) -> u32 {
match self {
RoundLength::Quick => 60 * crate::sim::TICKS_PER_SECOND,
RoundLength::Short => 2 * 60 * crate::sim::TICKS_PER_SECOND,
RoundLength::Standard => 3 * 60 * crate::sim::TICKS_PER_SECOND,
RoundLength::Long => 5 * 60 * crate::sim::TICKS_PER_SECOND,
}
}
}
impl Cycle for MapChoice {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl Cycle for GullPressure {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl Cycle for RoundLength {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl Cycle for BotLevel {
const VARIANTS: &'static [Self] = &BOT_LEVELS;
}
impl Cycle for crate::app::tournament::SeriesLength {
const VARIANTS: &'static [Self] = &crate::app::tournament::SeriesLength::ALL;
}
pub const POSTS_RANGE: std::ops::RangeInclusive<u8> = 1..=6;
pub const SEATS_RANGE: std::ops::RangeInclusive<u8> = 2..=MAX_PLAYERS as u8;
pub trait TermsDials {
fn map(&self) -> MapChoice;
fn gulls(&self) -> GullPressure;
fn round(&self) -> RoundLength;
fn bot_level(&self) -> BotLevel;
fn series(&self) -> crate::app::tournament::SeriesLength;
fn teams(&self) -> crate::app::teams::TeamMode;
fn posts(&self) -> u8;
}
impl TermsDials for MatchTerms {
fn map(&self) -> MapChoice {
MapChoice::from_index(usize::from(self.map))
}
fn gulls(&self) -> GullPressure {
GullPressure::from_index(usize::from(self.gulls))
}
fn round(&self) -> RoundLength {
RoundLength::from_index(usize::from(self.round))
}
fn bot_level(&self) -> BotLevel {
BotLevel::from_index(usize::from(self.bot_level))
}
fn series(&self) -> crate::app::tournament::SeriesLength {
crate::app::tournament::SeriesLength::from_index(usize::from(self.series))
}
fn teams(&self) -> crate::app::teams::TeamMode {
crate::app::teams::TeamMode::from_index(usize::from(self.teams))
}
fn posts(&self) -> u8 {
self.posts.clamp(*POSTS_RANGE.start(), *POSTS_RANGE.end())
}
}
pub const MAX_BOTS: usize = MAX_PLAYERS - 1;
pub const CLASSIC_SEATS: u8 = 4;
pub const WIDE_ENOUGH: u8 = 16;
#[derive(Resource, Clone)]
pub struct MatchConfig {
pub seats: u8,
pub controllers: [SeatController; MAX_PLAYERS],
pub ai_level: BotLevel,
pub filled_at_start: [bool; MAX_PLAYERS],
pub map: MapChoice,
pub pick: Option<Card>,
pub custom: usize,
pub random: bool,
pub gulls: GullPressure,
pub round: RoundLength,
pub posts: u8,
pub series: crate::app::tournament::SeriesLength,
pub armed: bool,
pub bots_welcome: bool,
pub lobby_bots: u8,
pub seed: Option<u64>,
pub people: crate::app::partners::Who,
}
impl Default for MatchConfig {
fn default() -> Self {
MatchConfig {
seats: 2,
controllers: [SeatController::Local; MAX_PLAYERS],
ai_level: BotLevel::Normal,
filled_at_start: [false; MAX_PLAYERS],
map: MapChoice::Classic,
pick: None,
custom: 0,
random: false,
gulls: GullPressure::Normal,
round: RoundLength::Standard,
posts: MAX_SIGNPOSTS_PER_PLAYER as u8,
series: crate::app::tournament::SeriesLength::Single,
armed: false,
bots_welcome: true,
lobby_bots: 0,
seed: None,
people: crate::app::partners::Who::default(),
}
}
}
impl MatchConfig {
pub fn with_ai(seats: u8, ai: u8) -> MatchConfig {
let mut config = MatchConfig {
seats: seats.clamp(*SEATS_RANGE.start(), *SEATS_RANGE.end()),
..MatchConfig::default()
};
let ai = ai.min(config.seats - 1);
for seat in config.seats - ai..config.seats {
config.controllers[usize::from(seat)] = SeatController::Ai(config.ai_level);
}
config
}
pub fn name_row(&self, seat: u8) -> u8 {
self.people
.0
.get(usize::from(seat))
.copied()
.unwrap_or(seat)
}
pub fn seat_names(&self, saved: &[String; MAX_PLAYERS]) -> [String; MAX_PLAYERS] {
std::array::from_fn(|seat| saved[usize::from(self.name_row(seat as u8))].clone())
}
pub fn human_seats(&self) -> Vec<u8> {
(0..self.seats)
.filter(|&seat| self.controller(seat) == SeatController::Local)
.collect()
}
pub fn humans(&self) -> u8 {
self.human_seats().len() as u8
}
pub fn ai_seats(&self) -> u8 {
self.seats - self.humans()
}
pub fn clamp_bots(&mut self) {
self.controllers[0] = SeatController::Local;
self.lobby_bots = self.lobby_bots.min(MAX_BOTS as u8);
}
pub fn controller(&self, seat: u8) -> SeatController {
self.controllers[usize::from(seat).min(MAX_PLAYERS - 1)]
}
pub fn daily() -> Self {
MatchConfig {
ai_level: BotLevel::Hard,
map: MapChoice::GenClassic,
armed: true,
bots_welcome: false,
..MatchConfig::with_ai(4, 3).levelled(BotLevel::Hard)
}
}
fn levelled(mut self, level: BotLevel) -> Self {
self.ai_level = level;
for controller in &mut self.controllers {
if controller.ai().is_some() {
*controller = SeatController::Ai(level);
}
}
self
}
}
pub const BOT_LEVELS: [BotLevel; 3] = [BotLevel::Easy, BotLevel::Normal, BotLevel::Hard];
#[derive(bevy::ecs::system::SystemParam)]
pub struct Dials<'w> {
pub config: ResMut<'w, MatchConfig>,
pub beaches: Res<'w, CustomBeaches>,
}
pub fn online_ai_level(config: &MatchConfig) -> BotLevel {
config.ai_level
}
pub fn terms(config: &MatchConfig, teams: crate::app::teams::TeamMode, seed: u64) -> MatchTerms {
MatchTerms {
bots: config.lobby_bots,
bot_level: online_ai_level(config).index() as u8,
map: config.map.index() as u8,
gulls: config.gulls.index() as u8,
round: config.round.index() as u8,
teams: teams.index() as u8,
seed,
series: config.series.index() as u8,
posts: config.posts,
order: crate::transport::IN_SEAT_ORDER,
}
}
pub fn config_from_terms(terms: &MatchTerms) -> (MatchConfig, crate::app::teams::TeamMode) {
let bots = terms.bots.min(MAX_PLAYERS as u8);
let config = MatchConfig {
seats: bots.max(2),
lobby_bots: bots,
ai_level: terms.bot_level(),
map: terms.map(),
gulls: terms.gulls(),
round: terms.round(),
posts: terms.posts(),
series: terms.series(),
..MatchConfig::default()
};
(config, terms.teams())
}
pub fn next_round_terms(terms: MatchTerms, seats: u8, seed: u64) -> MatchTerms {
if terms.map() == MapChoice::Custom {
return MatchTerms { seed, ..terms };
}
let mut config = MatchConfig {
map: terms.map(),
seats,
..MatchConfig::default()
};
next_map(&mut config, &CustomBeaches::default());
MatchTerms {
map: config.map.index() as u8,
seed,
..terms
}
}
pub fn fit_terms(terms: MatchTerms, seats: u8, beach: &[u8]) -> MatchTerms {
let map = terms.map();
if map == MapChoice::Custom {
return match beach_in_play(map, beach_from(beach), seats) {
Some(_) => terms,
None => MatchTerms {
map: sized_for(seats).index() as u8,
..terms
},
};
}
if holds(map, seats) {
return terms;
}
next_round_terms(terms, seats, terms.seed)
}
pub fn is_custom(terms: &MatchTerms) -> bool {
terms.map() == MapChoice::Custom
}
pub fn holds(map: MapChoice, seats: u8) -> bool {
match (map, map.tables()) {
(MapChoice::Surprise, _) => true,
(_, Some(tables)) => tables.contains(&seats),
(_, None) => seats <= CLASSIC_SEATS || map.size().0 >= WIDE_ENOUGH,
}
}
pub fn sized_for(seats: u8) -> MapChoice {
match seats {
0..=2 => MapChoice::GenSmall,
3..=CLASSIC_SEATS => MapChoice::GenClassic,
_ => MapChoice::GenLarge,
}
}
pub fn next_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
if config.map == MapChoice::Surprise {
return;
}
for _ in 0..MapChoice::ALL.len() + beaches.0.len() {
cycle_map(config, Turn::Right, beaches);
let dealt = config.map.dealt() || config.map == MapChoice::Custom;
if holds(config.map, config.seats) && dealt {
return;
}
}
}
pub fn maps_that_seat(seats: u8, beaches: &CustomBeaches) -> Vec<(MapChoice, usize)> {
MapChoice::DIAL
.into_iter()
.filter(|&map| map.dealt() && holds(map, seats))
.map(|map| (map, 0))
.chain((0..beaches.fitting(seats).len()).map(|custom| (MapChoice::Custom, custom)))
.collect()
}
pub fn draw_map(config: &mut MatchConfig, beaches: &CustomBeaches, roll: u64) {
let maps = maps_that_seat(config.seats, beaches);
if let Some(&(map, custom)) = maps.get((roll % maps.len().max(1) as u64) as usize) {
config.map = map;
config.custom = custom;
}
}
pub fn draw_online_map(config: &mut MatchConfig, beaches: &CustomBeaches, roll: u64) {
let fitting = beaches.fitting(config.seats);
let maps: Vec<(MapChoice, usize)> = maps_that_seat(config.seats, beaches)
.into_iter()
.filter(|&(map, custom)| {
map != MapChoice::Custom
|| fitting
.get(custom)
.is_some_and(|beach| !beach.too_big_to_send())
})
.collect();
if let Some(&(map, custom)) = maps.get((roll % maps.len().max(1) as u64) as usize) {
config.map = map;
config.custom = custom;
}
}
pub fn map_for_next_round(config: &mut MatchConfig, beaches: &CustomBeaches, roll: u64) {
match config.random {
true => draw_map(config, beaches, roll),
false => next_map(config, beaches),
}
}
pub fn reseat(config: &mut MatchConfig, seats: u8, beaches: &CustomBeaches) {
let pick = current_pick(config, beaches);
config.seats = seats.clamp(*SEATS_RANGE.start(), *SEATS_RANGE.end());
config.clamp_bots();
place_pick(config, pick, beaches);
}
pub fn settle(config: &mut MatchConfig, beaches: &CustomBeaches) {
open_the_seats_filled_at_start(config);
config.clamp_bots();
settle_map(config, beaches);
}
pub fn open_the_seats_filled_at_start(config: &mut MatchConfig) {
let filled = std::mem::take(&mut config.filled_at_start);
for (seat, filled) in filled.into_iter().enumerate() {
if filled && config.controllers[seat].ai().is_some() {
config.controllers[seat] = SeatController::Local;
}
}
}
pub fn settle_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
let pick = current_pick(config, beaches);
place_pick(config, pick, beaches);
}
fn current_pick(config: &MatchConfig, beaches: &CustomBeaches) -> Card {
if let Some(pick) = config.pick.filter(|pick| on_the_shelf(*pick, beaches)) {
return pick;
}
match config.map {
MapChoice::Custom => beaches
.on_shelf(config.seats, config.custom)
.map_or(Card::Map(sized_for(config.seats)), Card::Shelf),
map @ (MapChoice::Classic
| MapChoice::GenSmall
| MapChoice::GenClassic
| MapChoice::GenLarge
| MapChoice::GenXl
| MapChoice::GenOcean
| MapChoice::TheGates
| MapChoice::TheMoat
| MapChoice::LogJam
| MapChoice::ThreeLanes
| MapChoice::RoundTable
| MapChoice::RoundTheWorld
| MapChoice::Duel
| MapChoice::TheFountain
| MapChoice::FourDoors
| MapChoice::GullRoad
| MapChoice::BackToBack
| MapChoice::Surprise) => Card::Map(map),
}
}
fn on_the_shelf(card: Card, beaches: &CustomBeaches) -> bool {
match card {
Card::Shelf(shelf) => shelf < beaches.0.len(),
Card::Map(_) | Card::Random => true,
}
}
fn place_pick(config: &mut MatchConfig, pick: Card, beaches: &CustomBeaches) {
let (map, custom) = map_in_force(pick, config.seats, beaches);
config.map = map;
config.custom = custom;
let moved = match pick {
Card::Map(picked) => picked != map,
Card::Shelf(_) => map != MapChoice::Custom,
Card::Random => false,
};
config.pick = moved.then_some(pick);
}
pub fn map_in_force(pick: Card, seats: u8, beaches: &CustomBeaches) -> (MapChoice, usize) {
let (w, h) = match pick {
Card::Map(map) if holds(map, seats) => return (map, 0),
Card::Map(map) => map.size(),
Card::Shelf(shelf) => {
if let Some(custom) = beaches.among_fitting(seats, shelf) {
return (MapChoice::Custom, custom);
}
beaches
.0
.get(shelf)
.map_or(MapChoice::Custom.size(), |beach| {
(beach.level.board().width(), beach.level.board().height())
})
}
Card::Random => return (sized_for(seats), 0),
};
let area = |(w, h): (u8, u8)| i32::from(w) * i32::from(h);
let wanted = area((w, h));
let closest = MapChoice::ALL
.into_iter()
.filter(|&map| map.dealt() && map.tables().is_none() && holds(map, seats))
.min_by_key(|&map| (area(map.size()) - wanted).abs())
.unwrap_or(sized_for(seats));
(closest, 0)
}
pub fn map_moved(config: &MatchConfig, beaches: &CustomBeaches) -> Option<(Card, Card)> {
let pick = config
.pick
.filter(|pick| !config.random && on_the_shelf(*pick, beaches))?;
Some((pick, picked_card(config, beaches)))
}
pub fn pick_the_map_in_force(config: &mut MatchConfig) {
config.pick = None;
}
#[cfg(test)]
mod flow_tests;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_maps_that_seat_five_and_six_are_the_guides() {
let seating = |seats| -> Vec<MapChoice> {
MapChoice::ALL
.into_iter()
.filter(|&map| map != MapChoice::Custom && holds(map, seats))
.collect()
};
let wide: Vec<MapChoice> = MapChoice::ALL
.into_iter()
.filter(|&map| map.tables().is_none() && map.size().0 >= WIDE_ENOUGH)
.filter(|&map| !matches!(map, MapChoice::Custom | MapChoice::Surprise))
.collect();
assert_eq!(
wide,
[MapChoice::GenLarge, MapChoice::GenXl, MapChoice::GenOcean]
);
let mut five = wide.clone();
five.extend([MapChoice::Surprise, MapChoice::GullRoad]);
assert_eq!(seating(5), five);
let mut six = vec![
MapChoice::RoundTable,
MapChoice::RoundTheWorld,
MapChoice::BackToBack,
MapChoice::Surprise,
];
six.extend(wide);
let mut seat_six = seating(6);
seat_six.sort_by_key(|map| MapChoice::ALL.iter().position(|m| m == map));
six.sort_by_key(|map| MapChoice::ALL.iter().position(|m| m == map));
assert_eq!(seat_six, six);
assert_eq!(MapChoice::RoundTable.size().0, 15);
let guide = include_str!("../../../docs/guide.md").replace("\n ", " ");
assert!(
guide.contains(&format!(
"five players need a generated map {WIDE_ENOUGH} tiles across or more"
)),
"the guide says the rule"
);
}
#[test]
fn the_lobbys_ai_plays_at_step_ones_level() {
let mut config = MatchConfig::with_ai(3, 2);
config.ai_level = BotLevel::Hard;
config.controllers[2] = SeatController::Bot;
config.controllers[1] = SeatController::Ai(BotLevel::Easy);
config.lobby_bots = 2;
let terms = terms(&config, crate::app::teams::TeamMode::Solo, 1);
assert_eq!(terms.bot_level, BotLevel::Hard.index() as u8);
assert_eq!(
terms.bots, 2,
"the lobby's own count, not the couch's seats"
);
let (back, _) = config_from_terms(&terms);
assert_eq!((back.lobby_bots, back.ai_level), (2, BotLevel::Hard));
}
#[test]
fn player_one_stays_a_person() {
let mut config = MatchConfig {
lobby_bots: 9,
..MatchConfig::with_ai(2, 1)
};
config.controllers[0] = SeatController::Ai(BotLevel::Easy);
settle(&mut config, &CustomBeaches::default());
assert_eq!(config.controller(0), SeatController::Local);
assert_eq!(config.lobby_bots, MAX_BOTS as u8);
assert_eq!(config.humans(), 1, "somebody still plays");
assert_eq!(
MatchConfig::with_ai(4, 9).ai_seats(),
3,
"never all of them"
);
}
#[test]
fn only_p2s_own_keys_say_p2_is_here() {
use crate::app::settings::{CommitScheme, GameSettings};
let pressed = |key: KeyCode, settings: &GameSettings| {
let mut keys = ButtonInput::<KeyCode>::default();
keys.press(key);
p2_pressed(&keys, settings)
};
let stock = GameSettings::default();
assert!(pressed(KeyCode::KeyI, &stock), "P2's up");
assert!(!pressed(KeyCode::KeyW, &stock), "P1's up");
assert!(!pressed(KeyCode::Enter, &stock), "the menu's start");
let one_hand = GameSettings {
commit: CommitScheme::Ijkl,
..GameSettings::default()
};
assert!(
pressed(KeyCode::KeyI, &one_hand),
"P2's up under the preset, as the round plays it"
);
assert!(
!pressed(KeyCode::ArrowUp, &one_hand),
"P1's placement under the preset, as the round plays it"
);
}
#[test]
fn a_handmade_beach_survives_the_wire() {
let text = "name: Sent Beach\nposts: 2\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+-+-+\n|. . . . .|\n+ + + + + +\n|. . . . 0|\n\
+ + + + + +\n|. . . . .|\n+-+-+-+-+-+\n";
let level = crate::sim::Level::parse(text).expect("a level");
let packed = crate::lzw::compress(level.to_text().as_bytes(), 8);
assert!(packed.len() < 512, "{} bytes", packed.len());
let back = beach_from(&packed).expect("reads back");
assert_eq!(back.name, "Sent Beach");
assert_eq!(back.to_text(), level.to_text(), "byte for byte");
}
#[test]
fn the_biggest_beaches_the_editor_builds_are_sent_or_refused() {
use crate::sim::{Board, CrabKind, Direction, Handedness, LevelKind, Spawner, TileKind};
let full_size = || Board::new(20, 13, 0xBEEF);
let seat_it = |board: &mut Board| {
for owner in 0..MAX_PLAYERS as u8 {
board.set_tile(owner, 0, TileKind::Castle(owner));
}
};
let as_beach = |board: Board| {
Beach::new(
crate::sim::Level::from_board("A Beach With A Long Name", 3, board)
.with_kind(LevelKind::Arena),
)
};
let mut busy = full_size();
seat_it(&mut busy);
for x in 0..20u8 {
for y in 1..13u8 {
if (x + y).is_multiple_of(3) {
busy.set_tile(x, y, TileKind::Rock);
}
if (x + y).is_multiple_of(7) {
busy.set_tile(
x,
y,
TileKind::Spawner(Spawner {
dir: Direction::Right,
period: 60,
}),
);
}
if (x * y).is_multiple_of(5) {
busy.set_wall(x, y, Direction::Up, true);
}
}
}
let busy = as_beach(busy);
assert!(
!busy.too_big_to_send(),
"a beach anyone would build must travel: {} bytes",
busy.wire.len()
);
let mut soup = full_size();
seat_it(&mut soup);
for x in 0..20u8 {
for y in 0..13u8 {
if soup.tile_at(x, y) == TileKind::Empty {
soup.spawn_crab(
x,
y,
Direction::Right,
Handedness::Left,
CrabKind::Sparkling,
);
}
}
}
let soup = as_beach(soup);
assert!(
soup.too_big_to_send(),
"{} bytes was expected to be over the line",
soup.wire.len()
);
let config = MatchConfig {
map: MapChoice::Custom,
seats: 2,
custom: 0,
..MatchConfig::default()
};
assert!(
beach_bytes(&config, 2, &CustomBeaches(vec![soup])).is_empty(),
"an oversized beach is dropped, not sent in pieces"
);
}
#[test]
fn a_beach_too_small_for_the_table_is_not_sent() {
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
let shelf = CustomBeaches(vec![Beach::new(two)]);
let config = MatchConfig {
map: MapChoice::Custom,
seats: 2,
custom: 0,
..MatchConfig::default()
};
assert!(!beach_bytes(&config, 2, &shelf).is_empty(), "fits a pair");
assert!(
beach_bytes(&config, 5, &shelf).is_empty(),
"five turned up and it has two castles"
);
assert_eq!(
beach_on_air(&config, 2, &shelf).map(|beach| beach.level.name.as_str()),
Some("Two")
);
assert!(beach_on_air(&config, 5, &shelf).is_none());
}
#[test]
fn the_terms_give_up_a_beach_the_table_outgrew() {
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
let packed = crate::lzw::compress(two.to_text().as_bytes(), 8);
let custom = MatchTerms {
map: MapChoice::Custom.index() as u8,
..MatchTerms::default()
};
assert_eq!(
fit_terms(custom, 2, &packed),
custom,
"two seats, two castles"
);
for seats in 3..=6u8 {
let fitted = fit_terms(custom, seats, &packed);
assert_eq!(
MapChoice::from_index(usize::from(fitted.map)),
sized_for(seats),
"{seats} seats"
);
assert_eq!(
MatchTerms {
map: custom.map,
..fitted
},
custom,
"nothing else"
);
}
let fitted = fit_terms(custom, 2, &[]);
assert_eq!(MapChoice::from_index(usize::from(fitted.map)), sized_for(2));
}
#[test]
fn a_handmade_beach_turns_the_wheel() {
let level = crate::sim::Level::parse(
"name: Two\nposts: 1\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
assert!(!level.board().events_enabled(), "the file says nothing");
let packed = crate::lzw::compress(level.to_text().as_bytes(), 8);
let terms = MatchTerms {
map: MapChoice::Custom.index() as u8,
..MatchTerms::default()
};
let online = board_from(&terms, 2, &packed);
assert_eq!((online.width(), online.height()), (3, 3), "on the beach");
let config = MatchConfig {
map: MapChoice::Custom,
seats: 2,
..MatchConfig::default()
};
let shelf = CustomBeaches(vec![Beach::new(level)]);
let couch = local_board(&config, &shelf, terms.seed);
assert_eq!((couch.width(), couch.height()), (3, 3), "on the beach");
for mut board in [online, couch] {
assert!(board.events_enabled(), "the wheel is on");
let replay = crate::sim::Level::from_board("Replay", 1, board.clone()).to_text();
assert!(replay.contains("events: on"), "{replay}");
let (x, y) = board.castle_of(1).expect("seat 1 has a castle");
let (from, facing) = match x {
0 => (x + 1, crate::sim::Direction::Left),
_ => (x - 1, crate::sim::Direction::Right),
};
board.spawn_crab(
from,
y,
facing,
crate::sim::Handedness::Left,
crate::sim::CrabKind::Sparkling,
);
for _ in 0..120 {
board.tick_idle();
if board.last_event().is_some() {
break;
}
}
assert!(board.last_event().is_some(), "the bank spun an event");
}
}
#[test]
fn a_beach_too_small_for_the_table_is_not_played_on_either() {
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
assert_eq!(two.seats(), 2, "two castles and no more");
let packed = crate::lzw::compress(two.to_text().as_bytes(), 8);
let terms = MatchTerms {
map: MapChoice::Custom.index() as u8,
..MatchTerms::default()
};
let played = board_from(&terms, 2, &packed);
assert_eq!(
(played.width(), played.height()),
(3, 3),
"the beach somebody built"
);
let grown = board_from(&terms, 4, &packed);
let generated = board_for(&terms, 4);
assert_eq!(
(grown.width(), grown.height()),
(generated.width(), generated.height()),
"back to a generated arena, which every peer builds alike"
);
assert_eq!(
grown.castle_seats(),
4,
"and it has a castle for everyone who turned up"
);
}
#[test]
fn spare_castles_are_put_away_and_a_skipped_one_is_never_offered() {
let beach = |row: &str| {
crate::sim::Level::parse(&format!(
"name: B\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|{row}|\n+ + + +\n|. . .|\n+-+-+-+\n"
))
.expect("a level")
};
let three = beach("0 1 2");
let packed = crate::lzw::compress(three.to_text().as_bytes(), 8);
let terms = MatchTerms {
map: MapChoice::Custom.index() as u8,
..MatchTerms::default()
};
let played = board_from(&terms, 2, &packed);
assert_eq!((played.width(), played.height()), (3, 2), "the beach built");
assert_eq!(played.seats_in_play(), 2, "the third castle put away");
assert_eq!(played.tile_at(2, 0), crate::sim::TileKind::Empty);
assert_eq!(board_from(&terms, 3, &packed).seats_in_play(), 3);
let shelf = CustomBeaches(vec![Beach::new(three), Beach::new(beach("0 . 2"))]);
for seats in 2..=3 {
let fitting: Vec<&str> = shelf
.fitting(seats)
.iter()
.map(|b| b.level.name.as_str())
.collect();
assert_eq!(fitting.len(), 1, "the gapped beach offered to {seats}");
}
assert!(beach_in_play(MapChoice::Custom, Some(&shelf.0[1].level), 2).is_none());
}
#[test]
fn the_ai_holds_the_last_places_and_reaches_no_further() {
let terms = |bots| MatchTerms {
bots,
..MatchTerms::default()
};
let held = |bots, seats| {
bot_seats_from(&terms(bots), seats)
.iter()
.map(Option::is_some)
.collect::<Vec<bool>>()
};
assert_eq!(held(0, 6), [false; 6], "no AI, no chairs");
assert_eq!(
held(2, 6),
[false, false, false, false, true, true],
"the AI comes in at the top and the people keep the low seats"
);
assert_eq!(held(6, 6), [true; 6], "a table played out by itself");
assert_eq!(held(2, 4), [false, false, true, true, false, false]);
assert_eq!(held(u8::MAX, 4), [true, true, true, true, false, false]);
for seats in 0..=u8::MAX {
for bots in [0u8, 1, 6, 7, u8::MAX] {
let _ = bot_seats_from(&terms(bots), seats);
}
}
let fierce = MatchTerms {
bots: 2,
bot_level: 2,
..MatchTerms::default()
};
assert_eq!(bot_seats_from(&fierce, 6)[5], Some(BotLevel::Hard));
assert_eq!(bot_seats_from(&fierce, 6)[0], None);
let arranged = MatchTerms {
bots: 2,
order: [0, 2, 1, 3, 4, 5],
..MatchTerms::default()
};
let held: Vec<bool> = bot_seats_from(&arranged, 4)
.iter()
.map(Option::is_some)
.collect();
assert_eq!(held, [false, true, false, true, false, false]);
assert_eq!(arranged.player_seats(4), [0, 2]);
}
#[test]
fn a_beach_that_will_not_read_is_not_fatal() {
assert!(beach_from(&[]).is_none());
assert!(beach_from(&[0xFF; 40]).is_none());
let terms = MatchTerms::default();
let board = board_from(&terms, 2, &[0xFF; 40]);
assert_eq!(board.width(), board_for(&terms, 2).width());
}
#[test]
fn a_beach_is_offered_only_when_it_has_the_castles() {
let one = crate::sim::Level::parse(
"name: One\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
assert_eq!(one.seats(), 1);
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
assert_eq!(two.seats(), 2);
}
#[test]
fn the_map_row_says_why_a_beach_is_not_on_offer() {
use crate::app::i18n::EN;
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
let shelf = CustomBeaches(vec![Beach::new(two)]);
assert_eq!(beaches_note(2, &EN, &shelf), None, "it is on the dial");
let note = beaches_note(4, &EN, &shelf).expect("four cannot sit at it");
assert!(note.contains('4'), "{note}");
assert_eq!(beaches_note(4, &EN, &CustomBeaches::default()), None);
let gap = || {
Beach::new(
crate::sim::Level::parse(
"name: Gap\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 2|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level"),
)
};
assert_eq!(
beaches_note(2, &EN, &CustomBeaches(vec![gap()])).as_deref(),
Some(EN.map_skips_castle)
);
let mut both = shelf;
both.0.push(gap());
let note = beaches_note(4, &EN, &both).expect("four cannot sit at either");
assert!(note.contains('4'), "{note}");
}
#[test]
fn the_skipped_castle_note_fits_where_the_seats_note_does() {
use crate::app::i18n::metrics::text_px;
let size = 18.0;
let widest = crate::app::i18n::ALL_LANGS
.iter()
.map(|lang| text_px(&fill(lang.tr().map_none_seats, &[("n", "6")]), size))
.fold(0.0, f32::max);
for lang in crate::app::i18n::ALL_LANGS {
let note = lang.tr().map_skips_castle;
let wide = text_px(note, size);
assert!(
wide <= widest,
"{lang:?}: {note:?} is {wide:.0}px of {widest:.0}px"
);
}
}
#[test]
fn the_map_dial_skips_a_stop_with_nothing_on_it() {
let mut config = MatchConfig {
map: MapChoice::GenOcean,
..MatchConfig::default()
};
let shelf = CustomBeaches::default();
cycle_map(&mut config, Turn::Right, &shelf);
assert_ne!(config.map, MapChoice::Custom, "an empty stop is skipped");
}
#[test]
fn a_series_steps_past_beaches_the_table_does_not_fit() {
let mut config = MatchConfig {
map: MapChoice::GenOcean,
seats: 5,
..MatchConfig::default()
};
let shelf = CustomBeaches::default();
next_map(&mut config, &shelf);
assert_eq!(
config.map,
MapChoice::GullRoad,
"past Custom, Classic and the handmade maps for other tables"
);
assert_eq!(config.seats, 5, "nobody left the table");
next_map(&mut config, &shelf);
assert_eq!(
config.map,
MapChoice::GenLarge,
"past Back to Back, Small, 12x9"
);
next_map(&mut config, &shelf);
assert_eq!(config.map, MapChoice::GenOcean, "past the 20x13");
let mut four = MatchConfig {
map: MapChoice::GenOcean,
seats: 4,
..MatchConfig::default()
};
next_map(&mut four, &shelf);
assert_eq!(four.map, MapChoice::Classic);
let terms = MatchTerms {
map: MapChoice::GenOcean.index() as u8,
seed: 1,
..MatchTerms::default()
};
let next = next_round_terms(terms, 5, 2);
assert_eq!(
MapChoice::from_index(usize::from(next.map)),
MapChoice::GullRoad
);
assert_eq!(next.seed, 2);
let next = next_round_terms(terms, 2, 3);
assert_eq!(
MapChoice::from_index(usize::from(next.map)),
MapChoice::Classic
);
}
#[test]
fn a_series_on_a_handmade_beach_stays_on_it() {
let terms = MatchTerms {
map: MapChoice::Custom.index() as u8,
seed: 1,
..MatchTerms::default()
};
for seats in 2..=6 {
let next = next_round_terms(terms, seats, 9);
assert_eq!(
MapChoice::from_index(usize::from(next.map)),
MapChoice::Custom,
"{seats} seats"
);
assert_eq!(next, MatchTerms { seed: 9, ..terms }, "only the seed moves");
}
}
#[test]
fn a_grown_table_is_fitted_to_a_beach_that_holds_it() {
let classic = MatchTerms {
map: MapChoice::Classic.index() as u8,
seed: 7,
..MatchTerms::default()
};
assert_eq!(
fit_terms(classic, 4, &[]),
classic,
"four fit the classic arena"
);
let six = fit_terms(classic, 6, &[]);
assert!(holds(MapChoice::from_index(usize::from(six.map)), 6));
assert_eq!(six.seed, 7, "only the map moves");
}
#[test]
fn a_table_the_shelf_cannot_seat_moves_the_map_along() {
use crate::app::i18n::EN;
let two = crate::sim::Level::parse(
"name: Two\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
)
.expect("a level");
let shelf = CustomBeaches(vec![Beach::new(two)]);
let mut config = MatchConfig {
map: MapChoice::Custom,
seats: 2,
..MatchConfig::default()
};
settle_map(&mut config, &shelf);
assert_eq!(config.map, MapChoice::Custom, "two fit; nothing moves");
assert!(map_label(&config, &EN, &shelf).contains("Two"));
config.seats = 3;
assert_eq!(
map_label(&config, &EN, &shelf),
EN.map_names[MapChoice::GenXl.index()],
"the label names what would launch"
);
settle_map(&mut config, &shelf);
assert_eq!(
config.map,
sized_for(3),
"no beach to name: the one dealt to the table"
);
config.map = MapChoice::Custom;
config.seats = 5;
settle_map(&mut config, &shelf);
assert_eq!(config.map, MapChoice::GenLarge, "and wide enough for five");
}
#[test]
fn a_series_never_deals_the_largest_beach() {
let shelf = CustomBeaches::default();
for seats in 2..=MAX_PLAYERS as u8 {
let mut config = MatchConfig {
seats,
..MatchConfig::default()
};
for _ in 0..2 * MapChoice::ALL.len() {
next_map(&mut config, &shelf);
assert_ne!(config.map, MapChoice::GenXl, "{seats} seats");
assert!(holds(config.map, seats));
}
}
}
#[test]
fn reseating_keeps_the_beach_that_was_chosen() {
let beach = |name: &str, castles: &str| {
let text = format!(
"name: {name}\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
+-+-+-+\n|{castles}|\n+ + + +\n|. . .|\n+ + + +\n|. . .|\n+-+-+-+\n"
);
Beach::new(crate::sim::Level::parse(&text).expect("a level"))
};
let shelf = CustomBeaches(vec![beach("Pair", "0 . 1"), beach("Trio", "0 1 2")]);
let mut config = MatchConfig {
map: MapChoice::Custom,
seats: 2,
custom: 1,
..MatchConfig::default()
};
reseat(&mut config, 3, &shelf);
assert_eq!((config.map, config.seats), (MapChoice::Custom, 3));
let fitting = shelf.fitting(config.seats);
assert_eq!(fitting[config.custom].level.name, "Trio");
reseat(&mut config, 2, &shelf);
assert_eq!(shelf.fitting(2)[config.custom].level.name, "Trio");
reseat(&mut config, 4, &shelf);
assert!(holds(config.map, 4) && config.map != MapChoice::Custom);
let mut xl = MatchConfig {
map: MapChoice::GenXl,
seats: 4,
..MatchConfig::default()
};
reseat(&mut xl, 2, &shelf);
assert_eq!(xl.map, MapChoice::GenXl, "chosen by hand, kept");
let mut small = MatchConfig {
map: MapChoice::GenSmall,
seats: 2,
..MatchConfig::default()
};
reseat(&mut small, 5, &shelf);
assert_eq!(small.map, sized_for(5));
let mut classic = MatchConfig {
map: MapChoice::Classic,
..MatchConfig::with_ai(4, 3)
};
reseat(&mut classic, 5, &shelf);
assert!(holds(classic.map, 5));
assert_eq!(
classic.controller(4),
SeatController::Local,
"a seat added is a person's, open until somebody sits there"
);
reseat(&mut classic, 2, &shelf);
assert_eq!(
classic.ai_seats(),
1,
"the AI holds at most every seat but one"
);
}
#[test]
fn a_seat_dialled_away_and_back_keeps_its_choice() {
let shelf = CustomBeaches::default();
let mut config = MatchConfig {
seats: 4,
..MatchConfig::default()
};
config.controllers[3] = SeatController::Ai(BotLevel::Hard);
config.controllers[2] = SeatController::Bot;
reseat(&mut config, 2, &shelf);
reseat(&mut config, 4, &shelf);
assert_eq!(config.controller(3), SeatController::Ai(BotLevel::Hard));
assert_eq!(config.controller(2), SeatController::Bot);
}
#[test]
fn the_beach_sized_for_a_table_seats_it() {
let mut last = 0;
for seats in 2..=MAX_PLAYERS as u8 {
let map = sized_for(seats);
assert!(holds(map, seats), "{seats} seats on {map:?}");
assert_ne!(map, MapChoice::GenXl, "{seats} seats");
let (w, h) = map.size();
let area = u32::from(w) * u32::from(h);
assert!(area >= last, "{seats} seats got a smaller beach");
last = area;
}
assert_eq!(sized_for(2), MapChoice::GenSmall);
}
#[test]
fn only_the_open_beaches_have_no_edges() {
for map in MapChoice::ALL {
let terms = MatchTerms {
map: map.index() as u8,
seed: 99,
..MatchTerms::default()
};
let seats = map.tables().map_or(4, |tables| tables[0]);
let board = board_for(&terms, seats);
assert_eq!(
board.wrap(),
matches!(
map,
MapChoice::GenOcean
| MapChoice::RoundTheWorld
| MapChoice::FourDoors
| MapChoice::GullRoad
| MapChoice::BackToBack
),
"{map:?} wraps: {}",
board.wrap()
);
assert_eq!(board.wrap(), map.wraps(), "{map:?}");
}
for (index, map) in MapChoice::ALL.iter().enumerate() {
assert_eq!(MapChoice::from_index(index), *map);
}
assert_eq!(MapChoice::from_index(4), MapChoice::GenXl, "xl stayed put");
}
}