1use crate::app::Screen;
6use crate::app::cycle::{Cycle, Turn};
7use crate::app::i18n::fill;
8use crate::app::palette;
9use crate::sim::BotLevel;
10use crate::sim::MAX_PLAYERS;
11use crate::transport::MatchTerms;
12use bevy::prelude::*;
13
14mod beaches;
15mod board;
16
17pub use beaches::{
18 Beach, CustomBeaches, beach_bytes, beach_from, beaches_note, cycle_map, map_label,
19 refresh_custom_beaches,
20};
21pub use board::{board_for, board_from, bot_seats_from};
22
23mod screen;
24pub use screen::*;
25
26#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
28pub enum MapChoice {
29 #[default]
30 Classic,
31 GenSmall,
32 GenClassic,
33 GenLarge,
34 GenXl,
35 GenOcean,
40 Custom,
44}
45
46impl MapChoice {
47 pub const ALL: [MapChoice; 7] = [
48 MapChoice::Classic,
49 MapChoice::GenSmall,
50 MapChoice::GenClassic,
51 MapChoice::GenLarge,
52 MapChoice::GenXl,
53 MapChoice::GenOcean,
54 MapChoice::Custom,
55 ];
56
57 pub fn size(self) -> (u8, u8) {
58 match self {
59 MapChoice::Classic | MapChoice::GenClassic => (12, 9),
60 MapChoice::GenSmall => (9, 7),
61 MapChoice::GenLarge => (16, 11),
62 MapChoice::GenXl => (20, 13),
63 MapChoice::GenOcean => (16, 11),
64 MapChoice::Custom => (20, 13),
68 }
69 }
70
71 pub fn wraps(self) -> bool {
75 matches!(self, MapChoice::GenOcean)
76 }
77}
78
79#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
81pub enum GullPressure {
82 Calm,
83 #[default]
84 Normal,
85 Frenzy,
86}
87
88impl GullPressure {
89 pub const ALL: [GullPressure; 3] = [
90 GullPressure::Calm,
91 GullPressure::Normal,
92 GullPressure::Frenzy,
93 ];
94
95 pub fn period(self) -> u32 {
96 match self {
97 GullPressure::Calm => 340,
98 GullPressure::Normal => 240,
99 GullPressure::Frenzy => 150,
100 }
101 }
102}
103
104#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
106pub enum RoundLength {
107 Short,
108 #[default]
109 Standard,
110 Long,
111}
112
113impl RoundLength {
114 pub const ALL: [RoundLength; 3] =
115 [RoundLength::Short, RoundLength::Standard, RoundLength::Long];
116
117 pub fn ticks(self) -> u32 {
118 match self {
119 RoundLength::Short => 2 * 60 * crate::sim::TICKS_PER_SECOND,
120 RoundLength::Standard => 3 * 60 * crate::sim::TICKS_PER_SECOND,
121 RoundLength::Long => 5 * 60 * crate::sim::TICKS_PER_SECOND,
122 }
123 }
124}
125
126impl crate::app::cycle::Cycle for MapChoice {
127 const VARIANTS: &'static [Self] = &Self::ALL;
128}
129impl crate::app::cycle::Cycle for GullPressure {
130 const VARIANTS: &'static [Self] = &Self::ALL;
131}
132impl crate::app::cycle::Cycle for RoundLength {
133 const VARIANTS: &'static [Self] = &Self::ALL;
134}
135impl crate::app::cycle::Cycle for crate::sim::BotLevel {
136 const VARIANTS: &'static [Self] = &BOT_LEVELS;
137}
138impl crate::app::cycle::Cycle for crate::app::tournament::SeriesLength {
139 const VARIANTS: &'static [Self] = &crate::app::tournament::SeriesLength::ALL;
140}
141
142#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum Row {
147 Players,
148 Bots,
149 BotLevel(u8),
151 Map,
152 Gulls,
153 Round,
154 Mode,
155 Name(u8),
158}
159
160pub const MAX_BOTS: usize = MAX_PLAYERS - 1;
162
163pub const CLASSIC_SEATS: u8 = 4;
166pub const WIDE_ENOUGH: u8 = 16;
169
170impl Row {
171 pub const ALL: [Row; 6 + MAX_BOTS + MAX_PLAYERS] = [
172 Row::Players,
173 Row::Bots,
174 Row::BotLevel(0),
175 Row::BotLevel(1),
176 Row::BotLevel(2),
177 Row::BotLevel(3),
178 Row::BotLevel(4),
179 Row::Map,
180 Row::Gulls,
181 Row::Round,
182 Row::Mode,
183 Row::Name(0),
186 Row::Name(1),
187 Row::Name(2),
188 Row::Name(3),
189 Row::Name(4),
190 Row::Name(5),
191 ];
192}
193
194const ROWS: usize = Row::ALL.len();
195
196#[derive(Resource)]
200pub struct MatchConfig {
201 pub seats: u8,
202 pub bots: u8,
203 pub bot_levels: [BotLevel; MAX_PLAYERS],
206 pub map: MapChoice,
207 pub custom: usize,
211 pub gulls: GullPressure,
212 pub round: RoundLength,
213 pub series: crate::app::tournament::SeriesLength,
215 pub armed: bool,
217}
218
219impl Default for MatchConfig {
220 fn default() -> Self {
221 MatchConfig {
222 seats: 2,
223 bots: 0,
224 bot_levels: [BotLevel::Normal; MAX_PLAYERS],
225 map: MapChoice::Classic,
226 custom: 0,
227 gulls: GullPressure::Normal,
228 round: RoundLength::Standard,
229 series: crate::app::tournament::SeriesLength::Single,
230 armed: false,
231 }
232 }
233}
234
235impl MatchConfig {
236 pub fn daily() -> Self {
241 MatchConfig {
242 seats: 4,
243 bots: 3,
244 bot_levels: [BotLevel::Hard; MAX_PLAYERS],
245 map: MapChoice::GenClassic,
246 custom: 0,
247 gulls: GullPressure::Normal,
248 round: RoundLength::Standard,
249 series: crate::app::tournament::SeriesLength::Single,
250 armed: true,
251 }
252 }
253}
254
255pub const BOT_LEVELS: [BotLevel; 3] = [BotLevel::Easy, BotLevel::Normal, BotLevel::Hard];
256
257pub fn terms(config: &MatchConfig, teams: crate::app::teams::TeamMode, seed: u64) -> MatchTerms {
261 MatchTerms {
262 bots: config.bots,
263 bot_level: config.bot_levels[usize::from(config.seats.saturating_sub(1))].index() as u8,
266 map: config.map.index() as u8,
267 gulls: config.gulls.index() as u8,
268 round: config.round.index() as u8,
269 teams: teams.index() as u8,
270 seed,
271 series: config.series.index() as u8,
272 }
273}
274
275pub fn config_from_terms(terms: &MatchTerms) -> (MatchConfig, crate::app::teams::TeamMode) {
283 let bots = terms.bots.min(MAX_PLAYERS as u8);
284 let config = MatchConfig {
285 seats: bots.max(2),
286 bots,
287 bot_levels: [BotLevel::from_index(usize::from(terms.bot_level)); MAX_PLAYERS],
288 map: MapChoice::from_index(usize::from(terms.map)),
289 custom: 0,
290 gulls: GullPressure::from_index(usize::from(terms.gulls)),
291 round: RoundLength::from_index(usize::from(terms.round)),
292 series: crate::app::tournament::SeriesLength::from_index(usize::from(terms.series)),
293 armed: false,
294 };
295 (
296 config,
297 crate::app::teams::TeamMode::from_index(usize::from(terms.teams)),
298 )
299}
300
301pub fn next_round_terms(terms: MatchTerms, seats: u8, seed: u64) -> MatchTerms {
314 let mut config = MatchConfig {
315 map: MapChoice::from_index(usize::from(terms.map)),
316 seats,
317 ..MatchConfig::default()
318 };
319 next_map(&mut config, &CustomBeaches::default());
320 MatchTerms {
321 map: config.map.index() as u8,
322 seed,
323 ..terms
324 }
325}
326
327pub fn holds(map: MapChoice, seats: u8) -> bool {
333 seats <= CLASSIC_SEATS || map.size().0 >= WIDE_ENOUGH
334}
335
336pub fn next_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
348 for _ in 0..MapChoice::ALL.len() + beaches.0.len() {
352 cycle_map(config, Turn::Right, beaches);
353 if holds(config.map, config.seats) {
354 return;
355 }
356 }
357}
358
359pub fn settle_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
367 if config.map == MapChoice::Custom {
368 match beaches.fitting(config.seats).len() {
369 0 => config.map = MapChoice::Custom.cycled(Turn::Right),
370 fitting => config.custom = config.custom.min(fitting - 1),
371 }
372 }
373 if !holds(config.map, config.seats) {
374 config.map = MapChoice::GenXl;
375 }
376}
377
378#[cfg(test)]
379use screen::{LABEL_W, ROW_FONT, VALUE_W, ai_seat, cycle_ai_level, live_rows, row_text};
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
392 fn every_row_fits_its_cell_in_every_language() {
393 use crate::app::i18n::metrics::text_px;
394 use crate::app::settings::{GameSettings, NAME_MAX};
395 for lang in crate::app::i18n::ALL_LANGS {
396 let mut settings = GameSettings {
397 language: lang,
398 ..GameSettings::default()
399 };
400 settings.names[0] = "M".repeat(NAME_MAX);
403 let tr = settings.tr();
404 for seats in 2..=MAX_PLAYERS as u8 {
405 for bots in 0..seats {
406 for (map, series) in MapChoice::ALL.into_iter().flat_map(|map| {
407 crate::app::tournament::SeriesLength::ALL.map(move |s| (map, s))
408 }) {
409 if map == MapChoice::Custom {
414 continue;
415 }
416 let config = MatchConfig {
417 seats,
418 bots,
419 bot_levels: [BotLevel::Normal; MAX_PLAYERS],
420 map,
421 gulls: GullPressure::Frenzy,
422 round: RoundLength::Long,
423 series,
428 ..MatchConfig::default()
429 };
430 for row in Row::ALL {
431 for naming in [None, Some(0)] {
434 let (label, value) = row_text(
435 tr,
436 &config,
437 &settings,
438 &CustomBeaches::default(),
439 naming,
440 row,
441 );
442 let label_w = text_px(&label, ROW_FONT);
443 assert!(
444 label_w <= LABEL_W,
445 "{row:?} in {lang:?}: label {label:?} is \
446 {label_w:.1}px, and the cell holds {LABEL_W}"
447 );
448 let value_w = text_px(&value, ROW_FONT);
449 assert!(
450 value_w <= VALUE_W,
451 "{row:?} in {lang:?}: value {value:?} is \
452 {value_w:.1}px, and the cell holds {VALUE_W}"
453 );
454 }
455 }
456 }
457 }
458 }
459 }
460 }
461
462 #[test]
467 fn the_card_fits_the_window_it_was_drawn_for() {
468 let card = LABEL_W + VALUE_W + 2.0 * 10.0 + 2.0 * 22.0;
469 let across = card;
473 assert!(
474 across <= crate::app::settings::DESIGN_W,
475 "the match card and its company are {across}px of the {}px they \
476 are allowed ({card}px of it the card)",
477 crate::app::settings::DESIGN_W
478 );
479 }
480
481 #[test]
485 fn a_handmade_beach_survives_the_wire() {
486 let text = "name: Sent Beach\nposts: 2\ncrab: 0,1 R R common\nmap:\n\
487 +-+-+-+-+-+\n|. . . . .|\n+ + + + + +\n|. . . . 0|\n\
488 + + + + + +\n|. . . . .|\n+-+-+-+-+-+\n";
489 let level = crate::sim::Level::parse(text).expect("a level");
490 let packed = crate::lzw::compress(level.to_text().as_bytes(), 8);
491 assert!(packed.len() < 512, "{} bytes", packed.len());
494 let back = beach_from(&packed).expect("reads back");
495 assert_eq!(back.name, "Sent Beach");
496 assert_eq!(back.to_text(), level.to_text(), "byte for byte");
497 }
498
499 #[test]
507 fn the_biggest_beaches_the_editor_builds_are_sent_or_refused() {
508 use crate::sim::{Board, CrabKind, Direction, Handedness, LevelKind, Spawner, TileKind};
509 let full_size = || Board::new(20, 13, 0xBEEF);
510 let seat_it = |board: &mut Board| {
511 for owner in 0..MAX_PLAYERS as u8 {
512 board.set_tile(owner, 0, TileKind::Castle(owner));
513 }
514 };
515 let as_beach = |board: Board| {
516 Beach::new(
517 crate::sim::Level::from_board("A Beach With A Long Name", 3, board)
518 .with_kind(LevelKind::Arena),
519 )
520 };
521
522 let mut busy = full_size();
524 seat_it(&mut busy);
525 for x in 0..20u8 {
526 for y in 1..13u8 {
527 if (x + y).is_multiple_of(3) {
528 busy.set_tile(x, y, TileKind::Rock);
529 }
530 if (x + y).is_multiple_of(7) {
531 busy.set_tile(
532 x,
533 y,
534 TileKind::Spawner(Spawner {
535 dir: Direction::Right,
536 period: 60,
537 }),
538 );
539 }
540 if (x * y).is_multiple_of(5) {
541 busy.set_wall(x, y, Direction::Up, true);
542 }
543 }
544 }
545 let busy = as_beach(busy);
546 assert!(
547 !busy.too_big_to_send(),
548 "a beach anyone would build must travel: {} bytes",
549 busy.wire.len()
550 );
551
552 let mut soup = full_size();
556 seat_it(&mut soup);
557 for x in 0..20u8 {
558 for y in 0..13u8 {
559 if soup.tile_at(x, y) == TileKind::Empty {
560 soup.spawn_crab(
561 x,
562 y,
563 Direction::Right,
564 Handedness::Left,
565 CrabKind::Sparkling,
566 );
567 }
568 }
569 }
570 let soup = as_beach(soup);
571 assert!(
572 soup.too_big_to_send(),
573 "{} bytes was expected to be over the line",
574 soup.wire.len()
575 );
576 let config = MatchConfig {
577 map: MapChoice::Custom,
578 seats: 2,
579 custom: 0,
580 ..MatchConfig::default()
581 };
582 assert!(
583 beach_bytes(&config, 2, &CustomBeaches(vec![soup])).is_empty(),
584 "an oversized beach is dropped, not sent in pieces"
585 );
586 }
587
588 #[test]
592 fn a_beach_too_small_for_the_table_is_not_sent() {
593 let two = crate::sim::Level::parse(
594 "name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
595 +-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
596 )
597 .expect("a level");
598 let shelf = CustomBeaches(vec![Beach::new(two)]);
599 let config = MatchConfig {
600 map: MapChoice::Custom,
601 seats: 2,
602 custom: 0,
603 ..MatchConfig::default()
604 };
605 assert!(!beach_bytes(&config, 2, &shelf).is_empty(), "fits a pair");
606 assert!(
607 beach_bytes(&config, 5, &shelf).is_empty(),
608 "five turned up and it has two castles"
609 );
610 }
611
612 #[test]
623 fn a_beach_too_small_for_the_table_is_not_played_on_either() {
624 let two = crate::sim::Level::parse(
625 "name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
626 +-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
627 )
628 .expect("a level");
629 assert_eq!(two.seats(), 2, "two castles and no more");
630 let packed = crate::lzw::compress(two.to_text().as_bytes(), 8);
631 let terms = MatchTerms {
632 map: MapChoice::Custom.index() as u8,
633 ..MatchTerms::default()
634 };
635 let played = board_from(&terms, 2, &packed);
637 assert_eq!(
638 (played.width(), played.height()),
639 (3, 3),
640 "the beach somebody built"
641 );
642 let grown = board_from(&terms, 4, &packed);
645 let generated = board_for(&terms, 4);
646 assert_eq!(
647 (grown.width(), grown.height()),
648 (generated.width(), generated.height()),
649 "back to a generated arena, which every peer builds alike"
650 );
651 assert_eq!(
652 grown.castle_seats(),
653 4,
654 "and it has a castle for everyone who turned up"
655 );
656 }
657
658 #[test]
666 fn the_ai_holds_the_top_seats_and_reaches_no_further() {
667 let terms = |bots| MatchTerms {
668 bots,
669 ..MatchTerms::default()
670 };
671 let held = |bots, seats| {
672 bot_seats_from(&terms(bots), seats)
673 .iter()
674 .map(Option::is_some)
675 .collect::<Vec<bool>>()
676 };
677 assert_eq!(held(0, 6), [false; 6], "no AI, no chairs");
678 assert_eq!(
679 held(2, 6),
680 [false, false, false, false, true, true],
681 "the AI comes in at the top and the people keep the low seats"
682 );
683 assert_eq!(held(6, 6), [true; 6], "a table played out by itself");
684 assert_eq!(held(2, 4), [false, false, true, true, false, false]);
687 assert_eq!(held(u8::MAX, 4), [true, true, true, true, false, false]);
689 for seats in 0..=u8::MAX {
690 for bots in [0u8, 1, 6, 7, u8::MAX] {
691 let _ = bot_seats_from(&terms(bots), seats);
692 }
693 }
694 let fierce = MatchTerms {
697 bots: 2,
698 bot_level: 2,
699 ..MatchTerms::default()
700 };
701 assert_eq!(bot_seats_from(&fierce, 6)[5], Some(BotLevel::Hard));
702 assert_eq!(bot_seats_from(&fierce, 6)[0], None);
703 }
704
705 #[test]
708 fn a_beach_that_will_not_read_is_not_fatal() {
709 assert!(beach_from(&[]).is_none());
710 assert!(beach_from(&[0xFF; 40]).is_none());
711 let terms = MatchTerms::default();
712 let board = board_from(&terms, 2, &[0xFF; 40]);
713 assert_eq!(board.width(), board_for(&terms, 2).width());
714 }
715
716 #[test]
719 fn a_beach_is_offered_only_when_it_has_the_castles() {
720 let one = crate::sim::Level::parse(
721 "name: One\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
722 +-+-+-+\n|. . .|\n+ + + +\n|. . 0|\n+ + + +\n|. . .|\n+-+-+-+\n",
723 )
724 .expect("a level");
725 assert_eq!(one.seats(), 1);
726 let two = crate::sim::Level::parse(
727 "name: Two\nposts: 1\ncrab: 0,1 R R common\nmap:\n\
728 +-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
729 )
730 .expect("a level");
731 assert_eq!(two.seats(), 2);
732 }
733
734 #[test]
739 fn the_map_row_says_why_a_beach_is_not_on_offer() {
740 use crate::app::i18n::EN;
741 let two = crate::sim::Level::parse(
742 "name: Two\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
743 +-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
744 )
745 .expect("a level");
746 let shelf = CustomBeaches(vec![Beach::new(two)]);
747 let at = |seats| MatchConfig {
748 seats,
749 ..MatchConfig::default()
750 };
751
752 assert_eq!(beaches_note(&at(2), &EN, &shelf), None, "it is on the dial");
753 let note = beaches_note(&at(4), &EN, &shelf).expect("four cannot sit at it");
754 assert!(note.contains('4'), "{note}");
755
756 assert_eq!(beaches_note(&at(4), &EN, &CustomBeaches::default()), None);
759 }
760
761 #[test]
765 fn the_map_dial_skips_a_stop_with_nothing_on_it() {
766 let mut config = MatchConfig {
767 map: MapChoice::GenOcean,
768 ..MatchConfig::default()
769 };
770 let shelf = CustomBeaches::default();
773 cycle_map(&mut config, Turn::Right, &shelf);
774 assert_ne!(config.map, MapChoice::Custom, "an empty stop is skipped");
775 }
776
777 #[test]
782 fn a_series_steps_past_beaches_the_table_does_not_fit() {
783 let mut config = MatchConfig {
784 map: MapChoice::GenOcean,
785 seats: 5,
786 ..MatchConfig::default()
787 };
788 let shelf = CustomBeaches::default();
789 next_map(&mut config, &shelf);
790 assert_eq!(
791 config.map,
792 MapChoice::GenLarge,
793 "past Custom, Classic, Small, 12x9"
794 );
795 assert_eq!(config.seats, 5, "nobody left the table");
796 next_map(&mut config, &shelf);
797 assert_eq!(config.map, MapChoice::GenXl);
798
799 let mut four = MatchConfig {
801 map: MapChoice::GenOcean,
802 seats: 4,
803 ..MatchConfig::default()
804 };
805 next_map(&mut four, &shelf);
806 assert_eq!(four.map, MapChoice::Classic);
807
808 let terms = MatchTerms {
809 map: MapChoice::GenOcean.index() as u8,
810 seed: 1,
811 ..MatchTerms::default()
812 };
813 let next = next_round_terms(terms, 5, 2);
814 assert_eq!(
815 MapChoice::from_index(usize::from(next.map)),
816 MapChoice::GenLarge
817 );
818 assert_eq!(next.seed, 2);
819 let next = next_round_terms(terms, 2, 3);
820 assert_eq!(
821 MapChoice::from_index(usize::from(next.map)),
822 MapChoice::Classic
823 );
824 }
825
826 #[test]
830 fn a_table_the_shelf_cannot_seat_moves_the_map_along() {
831 use crate::app::i18n::EN;
832 let two = crate::sim::Level::parse(
833 "name: Two\nposts: 1\nkind: arena\ncrab: 0,1 R R common\nmap:\n\
834 +-+-+-+\n|0 . .|\n+ + + +\n|. . 1|\n+ + + +\n|. . .|\n+-+-+-+\n",
835 )
836 .expect("a level");
837 let shelf = CustomBeaches(vec![Beach::new(two)]);
838 let mut config = MatchConfig {
839 map: MapChoice::Custom,
840 seats: 2,
841 ..MatchConfig::default()
842 };
843 settle_map(&mut config, &shelf);
844 assert_eq!(config.map, MapChoice::Custom, "two fit; nothing moves");
845 assert!(map_label(&config, &EN, &shelf).contains("Two"));
846
847 config.seats = 3;
848 assert_eq!(
849 map_label(&config, &EN, &shelf),
850 EN.map_names[MapChoice::GenXl.index()],
851 "the label names what would launch"
852 );
853 settle_map(&mut config, &shelf);
854 assert_eq!(config.map, MapChoice::Classic, "the stop after Custom");
855
856 config.map = MapChoice::Custom;
857 config.seats = 5;
858 settle_map(&mut config, &shelf);
859 assert_eq!(config.map, MapChoice::GenXl, "and wide enough for five");
860 }
861
862 use crate::app::settings::GameSettings;
863
864 #[test]
869 fn only_the_open_ocean_has_no_edges() {
870 for map in MapChoice::ALL {
871 let terms = MatchTerms {
872 map: map.index() as u8,
873 seed: 99,
874 ..MatchTerms::default()
875 };
876 let board = board_for(&terms, 4);
877 assert_eq!(
878 board.wrap(),
879 map == MapChoice::GenOcean,
880 "{map:?} wraps: {}",
881 board.wrap()
882 );
883 }
884 for (index, map) in MapChoice::ALL.iter().enumerate() {
887 assert_eq!(MapChoice::from_index(index), *map);
888 }
889 assert_eq!(MapChoice::from_index(4), MapChoice::GenXl, "xl stayed put");
890 }
891
892 #[test]
896 fn typing_renames_a_seat_without_starting_the_match() {
897 use bevy::input::ButtonState;
898 use bevy::input::keyboard::{Key, KeyboardInput};
899
900 let mut app = App::new();
901 app.add_plugins(bevy::state::app::StatesPlugin);
902 app.init_state::<Screen>();
903 app.add_message::<KeyboardInput>();
904 app.init_resource::<ButtonInput<KeyCode>>();
905 app.init_resource::<MatchMenu>();
906 app.init_resource::<MatchConfig>();
907 app.init_resource::<crate::app::match_setup::CustomBeaches>();
908 app.init_resource::<crate::app::tournament::Tournament>();
909 app.insert_resource(GameSettings::default());
910 app.add_systems(Update, match_setup_input);
911
912 let name_row = Row::ALL
913 .iter()
914 .position(|row| matches!(row, Row::Name(0)))
915 .expect("a name row for seat 1");
916 app.world_mut().resource_mut::<MatchMenu>().selected = name_row;
917
918 let tap = |app: &mut App, key: KeyCode| {
919 let mut keys = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
920 keys.reset_all();
921 keys.press(key);
922 app.update();
923 };
924 let type_char = |app: &mut App, ch: &str| {
925 app.world_mut()
926 .resource_mut::<ButtonInput<KeyCode>>()
927 .reset_all();
928 app.world_mut().write_message(KeyboardInput {
929 key_code: KeyCode::KeyB,
930 logical_key: Key::Character(ch.into()),
931 state: ButtonState::Pressed,
932 text: Some(ch.into()),
933 repeat: false,
934 window: Entity::PLACEHOLDER,
935 });
936 app.update();
937 };
938
939 tap(&mut app, KeyCode::Tab);
944 assert_eq!(app.world().resource::<MatchMenu>().naming, Some(0));
945 assert!(
946 !app.world().resource::<MatchConfig>().armed,
947 "Tab named the seat instead of launching"
948 );
949
950 type_char(&mut app, "B");
951 type_char(&mut app, "o");
952 assert_eq!(app.world().resource::<GameSettings>().names[0], "Bo");
953 assert_eq!(app.world().resource::<GameSettings>().seat_name(0), "Bo");
954
955 tap(&mut app, KeyCode::Enter);
956 assert_eq!(app.world().resource::<MatchMenu>().naming, None);
957 assert!(
958 !app.world().resource::<MatchConfig>().armed,
959 "the Enter that finished the name did not launch either"
960 );
961
962 assert!(
965 matches!(Row::ALL[name_row], Row::Name(0)),
966 "still on a name row"
967 );
968 tap(&mut app, KeyCode::Enter);
969 assert!(
970 app.world().resource::<MatchConfig>().armed,
971 "Enter on a name row has to start the match"
972 );
973 }
974
975 #[test]
978 fn name_rows_follow_the_seat_count() {
979 let config = MatchConfig {
980 seats: 3,
981 ..MatchConfig::default()
982 };
983 let live = live_rows(&config);
984 let named: Vec<u8> = Row::ALL
985 .iter()
986 .enumerate()
987 .filter(|&(row, _)| live[row])
988 .filter_map(|(_, kind)| {
989 if let Row::Name(seat) = kind {
990 Some(*seat)
991 } else {
992 None
993 }
994 })
995 .collect();
996 assert_eq!(named, vec![0, 1, 2], "one row per seat, seat 4 sits out");
997 }
998
999 #[test]
1002 fn ai_rows_track_the_seats_the_ai_holds() {
1003 let mut config = MatchConfig {
1004 seats: 4,
1005 bots: 2,
1006 ..MatchConfig::default()
1007 };
1008 assert_eq!(ai_seat(&config, 0), Some(3));
1009 assert_eq!(ai_seat(&config, 1), Some(2));
1010 assert_eq!(ai_seat(&config, 2), None, "only two AI seats are taken");
1011
1012 let live = live_rows(&config);
1013 let hidden: Vec<bool> = Row::ALL
1014 .iter()
1015 .zip(live)
1016 .filter_map(|(row, live)| matches!(row, Row::BotLevel(_)).then_some(live))
1017 .collect();
1018 let mut want = vec![false; MAX_BOTS];
1019 want[0] = true;
1020 want[1] = true;
1021 assert_eq!(hidden, want, "one live row per AI seat, the rest folded");
1022 config.bots = 0;
1025 let empty_seats = MAX_PLAYERS - usize::from(config.seats);
1026 assert_eq!(
1027 live_rows(&config).iter().filter(|live| **live).count(),
1028 ROWS - MAX_BOTS - empty_seats
1029 );
1030 }
1031
1032 #[test]
1035 fn difficulties_are_per_seat() {
1036 let mut config = MatchConfig {
1037 seats: 4,
1038 bots: 3,
1039 ..MatchConfig::default()
1040 };
1041 cycle_ai_level(&mut config, 0, Turn::Right); cycle_ai_level(&mut config, 2, Turn::Left); assert_eq!(config.bot_levels, {
1044 let mut want = [BotLevel::Normal; MAX_PLAYERS];
1045 want[1] = BotLevel::Easy; want[3] = BotLevel::Hard; want
1048 });
1049 config.bots = 1;
1051 cycle_ai_level(&mut config, 2, Turn::Right);
1052 assert_eq!(config.bot_levels[1], BotLevel::Easy);
1053 }
1054}