use crate::app::Screen;
use crate::app::cycle::Cycle;
use crate::app::i18n::fill;
use crate::app::palette;
use crate::sim::BotLevel;
use crate::sim::MAX_PLAYERS;
use crate::transport::MatchTerms;
use bevy::prelude::*;
#[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 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 crate::app::cycle::Cycle for MapChoice {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl crate::app::cycle::Cycle for GullPressure {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl crate::app::cycle::Cycle for RoundLength {
const VARIANTS: &'static [Self] = &Self::ALL;
}
impl crate::app::cycle::Cycle for crate::sim::BotLevel {
const VARIANTS: &'static [Self] = &BOT_LEVELS;
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Row {
Players,
Bots,
BotLevel(u8),
Map,
Gulls,
Round,
Mode,
Name(u8),
}
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; 6 + 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::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 bot_levels: [BotLevel; MAX_PLAYERS],
pub map: MapChoice,
pub custom: usize,
pub gulls: GullPressure,
pub round: RoundLength,
pub series: bool,
pub armed: bool,
}
impl Default for MatchConfig {
fn default() -> Self {
MatchConfig {
seats: 2,
bots: 0,
bot_levels: [BotLevel::Normal; MAX_PLAYERS],
map: MapChoice::Classic,
custom: 0,
gulls: GullPressure::Normal,
round: RoundLength::Standard,
series: false,
armed: false,
}
}
}
pub const BOT_LEVELS: [BotLevel; 3] = [BotLevel::Easy, BotLevel::Normal, BotLevel::Hard];
pub fn terms(config: &MatchConfig, teams: crate::app::teams::TeamMode, seed: u64) -> MatchTerms {
MatchTerms {
bots: config.bots,
bot_level: config.bot_levels[usize::from(config.seats.saturating_sub(1))].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: u8::from(config.series),
}
}
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,
bot_levels: [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)),
series: terms.series == 1,
armed: false,
};
(
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 holds(map: MapChoice, seats: u8) -> bool {
seats <= CLASSIC_SEATS || map.size().0 >= WIDE_ENOUGH
}
pub fn next_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
for _ in 0..MapChoice::ALL.len() + beaches.0.len() {
cycle_map(config, true, 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(true),
fitting => config.custom = config.custom.min(fitting - 1),
}
}
if !holds(config.map, config.seats) {
config.map = MapChoice::GenXl;
}
}
#[derive(Resource, Default)]
pub struct CustomBeaches(pub Vec<Beach>);
pub struct Beach {
pub level: crate::sim::Level,
wire: Vec<u8>,
}
impl Beach {
fn new(level: crate::sim::Level) -> Beach {
let wire = crate::lzw::compress(level.to_text().as_bytes(), 8);
Beach { level, wire }
}
pub fn too_big_to_send(&self) -> bool {
self.wire.len() > crate::transport::MAX_BEACH_BYTES
}
}
pub fn refresh_custom_beaches(mut beaches: ResMut<CustomBeaches>) {
beaches.0 = crate::app::campaign::custom_arenas(crate::app::campaign::load_custom_levels())
.into_iter()
.map(Beach::new)
.collect();
}
impl CustomBeaches {
pub fn fitting(&self, seats: u8) -> Vec<&Beach> {
self.0
.iter()
.filter(|beach| beach.level.seats() >= seats)
.collect()
}
}
pub fn beaches_note(
config: &MatchConfig,
tr: &crate::app::i18n::Tr,
beaches: &CustomBeaches,
) -> Option<String> {
let all_too_small = !beaches.0.is_empty() && beaches.fitting(config.seats).is_empty();
all_too_small.then(|| fill(tr.map_none_seats, &[("n", &config.seats.to_string())]))
}
pub fn cycle_map(config: &mut MatchConfig, right: bool, beaches: &CustomBeaches) {
let beaches = beaches.fitting(config.seats).len();
if config.map == MapChoice::Custom {
let next = config.custom as i64 + if right { 1 } else { -1 };
if (0..beaches as i64).contains(&next) {
config.custom = next as usize;
return;
}
}
config.map = config.map.cycled(right);
if config.map == MapChoice::Custom {
if beaches == 0 {
config.map = config.map.cycled(right);
} else {
config.custom = if right { 0 } else { beaches - 1 };
}
}
}
pub fn map_label(
config: &MatchConfig,
tr: &crate::app::i18n::Tr,
beaches: &CustomBeaches,
) -> String {
if config.map == MapChoice::Custom {
return beaches
.fitting(config.seats)
.get(config.custom)
.map_or_else(
|| tr.map_names[MapChoice::GenXl.index()].to_string(),
|beach| {
let template = match beach.too_big_to_send() {
true => tr.map_custom_local,
false => tr.map_custom,
};
fill(template, &[("n", &beach.level.name)])
},
);
}
tr.map_names[config.map.index()].to_string()
}
pub fn beach_bytes(config: &MatchConfig, seats: u8, beaches: &CustomBeaches) -> Vec<u8> {
if config.map != MapChoice::Custom {
return Vec::new();
}
beaches
.fitting(config.seats)
.get(config.custom)
.filter(|beach| beach.level.seats() >= seats)
.filter(|beach| {
let sendable = !beach.too_big_to_send();
if !sendable {
warn!(
"{:?} is too big to send ({} bytes packed, {} allowed): \
the round falls back to a generated beach",
beach.level.name,
beach.wire.len(),
crate::transport::MAX_BEACH_BYTES
);
}
sendable
})
.map(|beach| beach.wire.clone())
.unwrap_or_default()
}
pub fn beach_from(bytes: &[u8]) -> Option<crate::sim::Level> {
let text = String::from_utf8(crate::lzw::decompress(bytes, 8)?).ok()?;
crate::sim::Level::parse(&text).ok()
}
pub fn board_from(terms: &MatchTerms, seats: u8, beach: &[u8]) -> crate::sim::Board {
let Some(level) = beach_from(beach) else {
return board_for(terms, seats);
};
let mut board = level.board();
board.set_gull_period(GullPressure::from_index(usize::from(terms.gulls)).period());
board.set_round_length(Some(
RoundLength::from_index(usize::from(terms.round)).ticks(),
));
board
}
pub fn board_for(terms: &MatchTerms, seats: u8) -> crate::sim::Board {
let map = MapChoice::from_index(usize::from(terms.map));
let (w, h) = map.size();
let mut board = if map == MapChoice::Classic {
crate::sim::classic_arena_seeded(terms.seed, false, seats)
} else {
crate::sim::generate_arena(terms.seed, seats, w, h)
};
board.set_wrap(map.wraps());
board.set_gull_period(GullPressure::from_index(usize::from(terms.gulls)).period());
board.set_round_length(Some(
RoundLength::from_index(usize::from(terms.round)).ticks(),
));
board
}
pub fn bot_seats_from(terms: &MatchTerms, seats: u8) -> [Option<BotLevel>; MAX_PLAYERS] {
let level = BotLevel::from_index(usize::from(terms.bot_level));
let mut out = [None; MAX_PLAYERS];
for seat in seats.saturating_sub(terms.bots)..seats {
if let Some(slot) = out.get_mut(usize::from(seat)) {
*slot = Some(level);
}
}
out
}
mod screen;
pub use screen::*;
#[cfg(test)]
use screen::{LABEL_W, ROW_FONT, VALUE_W, ai_seat, cycle_ai_level, live_rows, row_text};
#[cfg(test)]
mod tests {
use super::*;
#[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 in MapChoice::ALL {
if map == MapChoice::Custom {
continue;
}
let config = MatchConfig {
seats,
bots,
bot_levels: [BotLevel::Normal; MAX_PLAYERS],
map,
gulls: GullPressure::Frenzy,
round: RoundLength::Long,
series: true,
..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;
assert!(
card <= crate::app::settings::DESIGN_W,
"the match card is {card}px of the {}px it is allowed",
crate::app::settings::DESIGN_W
);
}
#[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_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, true, &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_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::GenXl, "and wide enough for five");
}
use crate::app::settings::GameSettings;
#[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::match_setup::CustomBeaches>();
app.init_resource::<crate::app::tournament::Tournament>();
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"
);
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, true); cycle_ai_level(&mut config, 2, false); assert_eq!(config.bot_levels, {
let mut want = [BotLevel::Normal; MAX_PLAYERS];
want[1] = BotLevel::Easy; want[3] = BotLevel::Hard; want
});
config.bots = 1;
cycle_ai_level(&mut config, 2, true);
assert_eq!(config.bot_levels[1], BotLevel::Easy);
}
}