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;
pub use beaches::{
Beach, CustomBeaches, beach_bytes, beach_from, beaches_note, cycle_map, map_label,
refresh_custom_beaches,
};
pub use board::{board_for, board_from, bot_seats_from};
mod screen;
pub use screen::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MapChoice {
#[default]
Classic,
GenSmall,
GenClassic,
GenLarge,
GenXl,
GenOcean,
Custom,
}
impl MapChoice {
pub const ALL: [MapChoice; 7] = [
MapChoice::Classic,
MapChoice::GenSmall,
MapChoice::GenClassic,
MapChoice::GenLarge,
MapChoice::GenXl,
MapChoice::GenOcean,
MapChoice::Custom,
];
pub fn is_a_plain_size(self) -> bool {
matches!(
self,
MapChoice::GenSmall | MapChoice::GenClassic | MapChoice::GenLarge | MapChoice::GenXl
)
}
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),
}
}
pub fn wraps(self) -> bool {
matches!(self, MapChoice::GenOcean)
}
}
#[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 {
Short,
#[default]
Standard,
Long,
}
impl RoundLength {
pub const ALL: [RoundLength; 3] =
[RoundLength::Short, RoundLength::Standard, RoundLength::Long];
pub fn ticks(self) -> u32 {
match self {
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;
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Row {
Players,
Bots,
BotLevel(u8),
Map,
Gulls,
Round,
Posts,
Mode,
Name(u8),
}
pub const POSTS_RANGE: std::ops::RangeInclusive<u8> = 1..=6;
pub fn posts_from(terms: &MatchTerms) -> u8 {
terms.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;
impl Row {
pub const ALL: [Row; 7 + MAX_BOTS + MAX_PLAYERS] = [
Row::Players,
Row::Bots,
Row::BotLevel(0),
Row::BotLevel(1),
Row::BotLevel(2),
Row::BotLevel(3),
Row::BotLevel(4),
Row::Map,
Row::Gulls,
Row::Round,
Row::Posts,
Row::Mode,
Row::Name(0),
Row::Name(1),
Row::Name(2),
Row::Name(3),
Row::Name(4),
Row::Name(5),
];
}
const ROWS: usize = Row::ALL.len();
#[derive(Resource)]
pub struct MatchConfig {
pub seats: u8,
pub bots: u8,
pub controllers: [SeatController; MAX_PLAYERS],
pub map: MapChoice,
pub custom: usize,
pub gulls: GullPressure,
pub round: RoundLength,
pub posts: u8,
pub series: crate::app::tournament::SeriesLength,
pub armed: bool,
pub bots_welcome: bool,
}
impl Default for MatchConfig {
fn default() -> Self {
MatchConfig {
seats: 2,
bots: 0,
controllers: [SeatController::Ai(BotLevel::Normal); MAX_PLAYERS],
map: MapChoice::Classic,
custom: 0,
gulls: GullPressure::Normal,
round: RoundLength::Standard,
posts: MAX_SIGNPOSTS_PER_PLAYER as u8,
series: crate::app::tournament::SeriesLength::Single,
armed: false,
bots_welcome: true,
}
}
}
impl MatchConfig {
pub fn controller(&self, seat: u8) -> SeatController {
if seat < self.seats.saturating_sub(self.bots) {
SeatController::Local
} else {
self.controllers[usize::from(seat).min(MAX_PLAYERS - 1)]
}
}
pub fn level(&self, seat: u8) -> BotLevel {
self.controllers[usize::from(seat).min(MAX_PLAYERS - 1)]
.ai()
.unwrap_or_default()
}
pub fn daily() -> Self {
MatchConfig {
seats: 4,
bots: 3,
controllers: [SeatController::Ai(BotLevel::Hard); MAX_PLAYERS],
map: MapChoice::GenClassic,
custom: 0,
gulls: GullPressure::Normal,
round: RoundLength::Standard,
posts: MAX_SIGNPOSTS_PER_PLAYER as u8,
series: crate::app::tournament::SeriesLength::Single,
armed: true,
bots_welcome: false,
}
}
}
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 terms(config: &MatchConfig, teams: crate::app::teams::TeamMode, seed: u64) -> MatchTerms {
MatchTerms {
bots: config.bots,
bot_level: config.controllers[..usize::from(config.seats).min(MAX_PLAYERS)]
.iter()
.rev()
.find_map(|controller| controller.ai())
.unwrap_or_default()
.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,
}
}
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),
bots,
controllers: [SeatController::Ai(BotLevel::from_index(usize::from(terms.bot_level)));
MAX_PLAYERS],
map: MapChoice::from_index(usize::from(terms.map)),
custom: 0,
gulls: GullPressure::from_index(usize::from(terms.gulls)),
round: RoundLength::from_index(usize::from(terms.round)),
posts: posts_from(terms),
series: crate::app::tournament::SeriesLength::from_index(usize::from(terms.series)),
armed: false,
bots_welcome: true,
};
(
config,
crate::app::teams::TeamMode::from_index(usize::from(terms.teams)),
)
}
pub fn next_round_terms(terms: MatchTerms, seats: u8, seed: u64) -> MatchTerms {
let mut config = MatchConfig {
map: MapChoice::from_index(usize::from(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) -> MatchTerms {
if holds(MapChoice::from_index(usize::from(terms.map)), seats) {
return terms;
}
next_round_terms(terms, seats, terms.seed)
}
pub fn holds(map: MapChoice, seats: u8) -> bool {
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) {
for _ in 0..MapChoice::ALL.len() + beaches.0.len() {
cycle_map(config, Turn::Right, beaches);
if holds(config.map, config.seats) {
return;
}
}
}
pub fn settle_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
if config.map == MapChoice::Custom {
match beaches.fitting(config.seats).len() {
0 => config.map = MapChoice::Custom.cycled(Turn::Right),
fitting => config.custom = config.custom.min(fitting - 1),
}
}
if !holds(config.map, config.seats) {
config.map = sized_for(config.seats);
}
}
#[cfg(test)]
use screen::{
LABEL_W, ROW_FONT, VALUE_W, ai_seat, card_height, cycle_ai_level, has_an_opponent, live_rows,
p2_pressed, row_text,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bot_on_the_couch_does_not_set_the_lobbys_ai_level() {
let mut config = MatchConfig {
seats: 3,
bots: 2,
..MatchConfig::default()
};
config.controllers[2] = SeatController::Bot;
config.controllers[1] = SeatController::Ai(BotLevel::Hard);
let terms = terms(&config, crate::app::teams::TeamMode::Solo, 1);
assert_eq!(terms.bot_level, BotLevel::Hard.index() as u8);
}
#[test]
fn a_match_needs_an_opponent() {
let alone = MatchConfig {
seats: 2,
bots: 0,
..MatchConfig::default()
};
assert!(!has_an_opponent(&alone, 0, false), "an empty chair");
assert!(has_an_opponent(&alone, 1, false), "a pad joined");
assert!(has_an_opponent(&alone, 0, true), "P2 pressed a key");
let with_ai = MatchConfig { bots: 1, ..alone };
assert!(has_an_opponent(&with_ai, 0, false), "the AI plays");
}
#[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),
"P1's commit under the preset"
);
}
#[test]
fn every_row_fits_its_cell_in_every_language() {
use crate::app::i18n::metrics::text_px;
use crate::app::settings::{GameSettings, NAME_MAX};
for lang in crate::app::i18n::ALL_LANGS {
let mut settings = GameSettings {
language: lang,
..GameSettings::default()
};
settings.names[0] = "M".repeat(NAME_MAX);
let tr = settings.tr();
for seats in 2..=MAX_PLAYERS as u8 {
for bots in 0..seats {
for (map, series) in MapChoice::ALL.into_iter().flat_map(|map| {
crate::app::tournament::SeriesLength::ALL.map(move |s| (map, s))
}) {
if map == MapChoice::Custom {
continue;
}
let config = MatchConfig {
seats,
bots,
map,
gulls: GullPressure::Frenzy,
round: RoundLength::Long,
series,
..MatchConfig::default()
};
for row in Row::ALL {
for naming in [None, Some(0)] {
let (label, value) = row_text(
tr,
&config,
&settings,
&CustomBeaches::default(),
naming,
row,
);
let label_w = text_px(&label, ROW_FONT);
assert!(
label_w <= LABEL_W,
"{row:?} in {lang:?}: label {label:?} is \
{label_w:.1}px, and the cell holds {LABEL_W}"
);
let value_w = text_px(&value, ROW_FONT);
assert!(
value_w <= VALUE_W,
"{row:?} in {lang:?}: value {value:?} is \
{value_w:.1}px, and the cell holds {VALUE_W}"
);
}
}
}
}
}
}
}
#[test]
fn the_card_fits_the_window_it_was_drawn_for() {
let card = LABEL_W + VALUE_W + 2.0 * 10.0 + 2.0 * 22.0;
let across = card;
assert!(
across <= crate::app::settings::DESIGN_W,
"the match card and its company are {across}px of the {}px they \
are allowed ({card}px of it the card)",
crate::app::settings::DESIGN_W
);
}
fn fullest_table() -> usize {
(2..=MAX_PLAYERS as u8)
.flat_map(|seats| {
(0..seats).map(move |bots| MatchConfig {
seats,
bots,
..MatchConfig::default()
})
})
.map(|config| live_rows(&config).iter().filter(|live| **live).count())
.max()
.unwrap_or(0)
}
#[test]
fn the_fullest_table_fits_its_card() {
let rows = fullest_table();
assert_eq!(rows, ROWS, "a full table shows every row");
let needed = 2.0 * crate::app::menu_ui::CARD_PAD
+ crate::app::menu_ui::HEADING_H
+ rows as f32 * crate::app::menu_ui::row_pitch(ROW_FONT);
assert!(
card_height() >= needed,
"the card is {}px for {needed}px of rows",
card_height()
);
}
#[test]
fn the_fullest_table_closes_up_to_fit_the_design_window() {
use crate::app::menu_ui::{BAR_H, CARD_PAD, HEADING_H, ROW_H, type_scale};
let squeezed = 2.0 * CARD_PAD + HEADING_H + fullest_table() as f32 * ROW_H;
let footer = 20.0
+ 2.0 * (type_scale::BODY * crate::app::menu_ui::LINE_HEIGHT + 6.0)
+ 3.0 * FOOTER_GAP;
let room = crate::app::settings::DESIGN_H - 2.0 * BAR_H;
assert!(
squeezed + footer <= room,
"{squeezed}px of card and {footer}px under it, in {room}px"
);
}
#[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"
);
}
#[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 the_ai_holds_the_top_seats_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);
}
#[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)]);
let at = |seats| MatchConfig {
seats,
..MatchConfig::default()
};
assert_eq!(beaches_note(&at(2), &EN, &shelf), None, "it is on the dial");
let note = beaches_note(&at(4), &EN, &shelf).expect("four cannot sit at it");
assert!(note.contains('4'), "{note}");
assert_eq!(beaches_note(&at(4), &EN, &CustomBeaches::default()), None);
}
#[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::GenLarge,
"past Custom, Classic, Small, 12x9"
);
assert_eq!(config.seats, 5, "nobody left the table");
next_map(&mut config, &shelf);
assert_eq!(config.map, MapChoice::GenXl);
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::GenLarge
);
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_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, MapChoice::Classic, "the stop after Custom");
config.map = MapChoice::Custom;
config.seats = 5;
settle_map(&mut config, &shelf);
assert_eq!(config.map, MapChoice::GenLarge, "and wide enough for five");
}
use crate::app::settings::GameSettings;
#[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!(map.is_a_plain_size());
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_ocean_has_no_edges() {
for map in MapChoice::ALL {
let terms = MatchTerms {
map: map.index() as u8,
seed: 99,
..MatchTerms::default()
};
let board = board_for(&terms, 4);
assert_eq!(
board.wrap(),
map == MapChoice::GenOcean,
"{map:?} wraps: {}",
board.wrap()
);
}
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");
}
#[test]
fn typing_renames_a_seat_without_starting_the_match() {
use bevy::input::ButtonState;
use bevy::input::keyboard::{Key, KeyboardInput};
let mut app = App::new();
app.add_plugins(bevy::state::app::StatesPlugin);
app.init_state::<Screen>();
app.add_message::<KeyboardInput>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<MatchMenu>();
app.init_resource::<MatchConfig>();
app.init_resource::<crate::app::bot_seats::BotSeats>();
app.init_resource::<CustomBeaches>();
app.init_resource::<crate::app::tournament::Tournament>();
app.init_resource::<crate::app::gamepad::PadSeats>();
app.insert_resource(GameSettings::default());
app.add_systems(Update, match_setup_input);
let name_row = Row::ALL
.iter()
.position(|row| matches!(row, Row::Name(0)))
.expect("a name row for seat 1");
app.world_mut().resource_mut::<MatchMenu>().selected = name_row;
let tap = |app: &mut App, key: KeyCode| {
let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
keys.reset_all();
keys.press(key);
app.update();
};
let type_char = |app: &mut App, ch: &str| {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.reset_all();
app.world_mut().write_message(KeyboardInput {
key_code: KeyCode::KeyB,
logical_key: Key::Character(ch.into()),
state: ButtonState::Pressed,
text: Some(ch.into()),
repeat: false,
window: Entity::PLACEHOLDER,
});
app.update();
};
tap(&mut app, KeyCode::Tab);
assert_eq!(app.world().resource::<MatchMenu>().naming, Some(0));
assert!(
!app.world().resource::<MatchConfig>().armed,
"Tab named the seat instead of launching"
);
type_char(&mut app, "B");
type_char(&mut app, "o");
assert_eq!(app.world().resource::<GameSettings>().names[0], "Bo");
assert_eq!(app.world().resource::<GameSettings>().seat_name(0), "Bo");
tap(&mut app, KeyCode::Enter);
assert_eq!(app.world().resource::<MatchMenu>().naming, None);
assert!(
!app.world().resource::<MatchConfig>().armed,
"the Enter that finished the name did not launch either"
);
app.world_mut().resource_mut::<MatchMenu>().p2_here = true;
assert!(
matches!(Row::ALL[name_row], Row::Name(0)),
"still on a name row"
);
tap(&mut app, KeyCode::Enter);
assert!(
app.world().resource::<MatchConfig>().armed,
"Enter on a name row has to start the match"
);
}
#[test]
fn name_rows_follow_the_seat_count() {
let config = MatchConfig {
seats: 3,
..MatchConfig::default()
};
let live = live_rows(&config);
let named: Vec<u8> = Row::ALL
.iter()
.enumerate()
.filter(|&(row, _)| live[row])
.filter_map(|(_, kind)| {
if let Row::Name(seat) = kind {
Some(*seat)
} else {
None
}
})
.collect();
assert_eq!(named, vec![0, 1, 2], "one row per seat, seat 4 sits out");
}
#[test]
fn ai_rows_track_the_seats_the_ai_holds() {
let mut config = MatchConfig {
seats: 4,
bots: 2,
..MatchConfig::default()
};
assert_eq!(ai_seat(&config, 0), Some(3));
assert_eq!(ai_seat(&config, 1), Some(2));
assert_eq!(ai_seat(&config, 2), None, "only two AI seats are taken");
let live = live_rows(&config);
let hidden: Vec<bool> = Row::ALL
.iter()
.zip(live)
.filter_map(|(row, live)| matches!(row, Row::BotLevel(_)).then_some(live))
.collect();
let mut want = vec![false; MAX_BOTS];
want[0] = true;
want[1] = true;
assert_eq!(hidden, want, "one live row per AI seat, the rest folded");
config.bots = 0;
let empty_seats = MAX_PLAYERS - usize::from(config.seats);
assert_eq!(
live_rows(&config).iter().filter(|live| **live).count(),
ROWS - MAX_BOTS - empty_seats
);
}
#[test]
fn difficulties_are_per_seat() {
let mut config = MatchConfig {
seats: 4,
bots: 3,
..MatchConfig::default()
};
cycle_ai_level(&mut config, 0, Turn::Right); cycle_ai_level(&mut config, 2, Turn::Left); assert_eq!(config.controllers, {
let mut want = [SeatController::Ai(BotLevel::Normal); MAX_PLAYERS];
want[1] = SeatController::Ai(BotLevel::Easy); want[3] = SeatController::Ai(BotLevel::Hard); want
});
config.bots = 1;
cycle_ai_level(&mut config, 2, Turn::Right);
assert_eq!(config.controllers[1], SeatController::Ai(BotLevel::Easy));
}
}