Skip to main content

pinch_points/app/match_setup/
mod.rs

1//! Match setup: the intermediary screen between the menu and a local versus
2//! round. Pick the seat count, how many seats the AI plays, the map, gull
3//! pressure, and round length; Enter starts the match.
4
5use 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/// The playable beach for a local match.
27#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
28pub enum MapChoice {
29    #[default]
30    Classic,
31    GenSmall,
32    GenClassic,
33    GenLarge,
34    GenXl,
35    /// Open ocean: a generated beach with no edges. Appended rather than
36    /// slotted in beside its size, because the index is what a `Start`
37    /// datagram carries, and reordering would silently change which beach a
38    /// host is inviting everyone to.
39    GenOcean,
40    /// A beach somebody built in the editor. Which one is not on the wire:
41    /// the beach itself rides along with the invitation, because no peer
42    /// but the host has the file it came from.
43    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            // Whatever the level says; the board arrives built, so this is
65            // only used to decide whether a table fits, and a handmade
66            // beach is offered only when it does.
67            MapChoice::Custom => (20, 13),
68        }
69    }
70
71    /// Whether the beach has edges. Open ocean does not: a creature walking
72    /// off one side comes back on the other, the same wrap the campaign
73    /// teaches at level 26.
74    pub fn wraps(self) -> bool {
75        matches!(self, MapChoice::GenOcean)
76    }
77}
78
79/// How aggressive the ambient gull spawner is.
80#[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/// Round length.
105#[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/// One match-setup row; input and render both match on this. The AI-level
143/// rows are per seat, one for each seat the AI can take, and the ones
144/// beyond the current AI count are hidden.
145#[derive(Clone, Copy, PartialEq, Eq, Debug)]
146pub enum Row {
147    Players,
148    Bots,
149    /// Difficulty for the `n`-th AI seat, counting down from the top seat.
150    BotLevel(u8),
151    Map,
152    Gulls,
153    Round,
154    Mode,
155    /// What to call seat `n`. One row per seat in the match; typing into it
156    /// renames that seat everywhere the game mentions it.
157    Name(u8),
158}
159
160/// The most seats the AI can hold: everyone but one human.
161pub const MAX_BOTS: usize = MAX_PLAYERS - 1;
162
163/// Seats a four-castle beach holds. Past this the match needs a generated
164/// arena wide enough for the two long-edge castles.
165pub const CLASSIC_SEATS: u8 = 4;
166/// The narrowest board that seats five or six: the two extra castles sit
167/// mid-edge, and they need room between the corners and the spawner holes.
168pub 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        // Last on the list: the rows above are the ones every match needs,
184        // and Enter on a name row types instead of starting the match.
185        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/// The configured local match. Persists between rounds so "again!" needs no
197/// re-setup; `armed` is set when the player launches from this screen and
198/// consumed by `load_versus`.
199#[derive(Resource)]
200pub struct MatchConfig {
201    pub seats: u8,
202    pub bots: u8,
203    /// Difficulty per seat, so one player can spar with a fierce rival and
204    /// an easy one at the same table. Only the AI-held seats are read.
205    pub bot_levels: [BotLevel; MAX_PLAYERS],
206    pub map: MapChoice,
207    /// Which handmade beach, when `map` is [`MapChoice::Custom`]. An index
208    /// into [`custom_beaches`], which is read fresh each time the dial is
209    /// turned: the editor may have saved another one since.
210    pub custom: usize,
211    pub gulls: GullPressure,
212    pub round: RoundLength,
213    /// One round, best of three, or best of five.
214    pub series: crate::app::tournament::SeriesLength,
215    /// True when the next versus round should be built from this config.
216    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    /// The daily challenge's table: today's arena, three fierce bots, a
237    /// standard round. Its own value rather than a write into the
238    /// player's config, which used to come back from the daily set to
239    /// this and show it on the setup screen as what they had chosen.
240    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
257/// The wire form of this screen's choices, for a host to send and every peer
258/// to build the same beach from. `teams` and `seed` are not on this screen:
259/// teams is a setting, and the seed is drawn when the match launches.
260pub fn terms(config: &MatchConfig, teams: crate::app::teams::TeamMode, seed: u64) -> MatchTerms {
261    MatchTerms {
262        bots: config.bots,
263        // Every AI seat plays at the top seat's level: the wire carries one
264        // difficulty, and a lobby has no per-seat rows to fill anyway.
265        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
275/// A [`MatchConfig`] and team mode that read back the same dials a set of
276/// wire [`MatchTerms`] carries, for painting a joiner's terms card: it is
277/// shown the match it is joining, not its own setup screen's idea of one.
278/// The seat count is the humans plus AI the terms name; `custom`/`armed`
279/// are display-only here. A `Custom` map cannot be named from the wire (the
280/// beach travels as bytes, not an index), so it reads as the generated size
281/// it will actually play on.
282pub 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
301/// The same terms on a new beach: a fresh seed, and the map stepped on as
302/// a local series steps it, through [`next_map`] and its guards. Everything
303/// the table agreed (seat count, gull pressure, round length, scoring) is
304/// kept, because a series is one match played several times, not several
305/// matches.
306///
307/// `seats` is the table as it sits, which the terms themselves do not
308/// carry: it is what keeps a table of five off the four-castle beaches.
309/// The shelf is not consulted, because online the beach a handmade round
310/// is played on rides in the invitation itself, unchanged from round to
311/// round (`net/rounds.rs` resends it), so `Custom` is not a stop the terms
312/// can step onto: with no shelf the stepper walks past it.
313pub 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
327/// Whether `map` has room for a table of `seats`. Five and six castles
328/// need a wide beach: the two extra sit mid-edge, and the handcrafted
329/// classic arena and the small generated one hold four, clamping a bigger
330/// table's castles down to it. A handmade beach answers by its castle
331/// count instead (see [`CustomBeaches::fitting`]).
332pub fn holds(map: MapChoice, seats: u8) -> bool {
333    seats <= CLASSIC_SEATS || map.size().0 >= WIDE_ENOUGH
334}
335
336/// Step the map on for the next round of a series: the dial's own step
337/// ([`cycle_map`], which walks the shelf and skips it when empty), and
338/// then on again past any beach the table does not fit on.
339///
340/// The one place a series and the dial differ: the dial, turned onto a
341/// small beach by hand, drops the seats it cannot hold, because the hand
342/// on it asked for that beach. A series asked for nobody to leave the
343/// table, so it keeps the seats and skips the beach. Before this the
344/// series stepped with a plain `cycled(Turn::Right)`, and a table of five went
345/// from the open ocean onto `Custom` with nothing on the shelf, then onto
346/// the classic arena with two of them castle-less.
347pub fn next_map(config: &mut MatchConfig, beaches: &CustomBeaches) {
348    // Bounded by the number of stops there are: every wide beach holds
349    // every table, so this returns well before the bound, but a loop over
350    // a dial should not be able to spin.
351    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
359/// Keep the map somewhere the table can play after something other than
360/// the dial moved: the seat count, or the shelf between two visits to the
361/// screen. Off `Custom` when no beach seats everyone (onto the stop after
362/// it, as the dial itself steps), onto the widest beach when five or six
363/// are seated and the map holds four. Without this the row kept reading a
364/// beach the match would not be played on: `Custom` with nothing fitting
365/// launched a generated 20x13 arena under the classic arena's name.
366pub 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    /// Every row fits the two cells that hold it, in every language, on
386    /// every stop of every dial.
387    ///
388    /// Measured in the same pixels the two cells are declared in, which
389    /// is the whole reason they are declared that way: [`screen::LABEL_W`]
390    /// carries what a character-counted budget did to this card.
391    #[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            // A seat named to the hilt: the naming row shows the name, a
401            // caret and the hint, and all three have to share the cell.
402            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                        // A handmade beach's name is the player's, up to
410                        // twenty-eight characters of it, and no cell on
411                        // any screen is built to hold that. It clips, the
412                        // way a typed name clips on the settings card.
413                        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                            // Every position of the mode dial, not the
424                            // longest guessed at: "best of 3" and "best of
425                            // 5" are the same width in English and are not
426                            // in every language this ships in.
427                            series,
428                            ..MatchConfig::default()
429                        };
430                        for row in Row::ALL {
431                            // Both states of the one row that is typed
432                            // into rather than stepped through.
433                            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    /// The card has to fit the window the interface was drawn for, the
463    /// same as the settings card does. One column here rather than two, so
464    /// there is room, but not so much that a cell can be widened without
465    /// looking.
466    #[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        // The crab and the gull stand in the same row as the card and do
470        // not shrink, so the width they take is the card's to spare. Left
471        // out, the card would be the one squeezed on a narrow window.
472        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    /// The point of sending the beach at all: a level the joiner has never
482    /// seen has to arrive whole, and arrive as the same board the host is
483    /// playing on. A seed cannot describe a beach somebody drew.
484    #[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        // Small enough to ride in a datagram, which is the whole reason it
492        // is compressed rather than sent as the text it came from.
493        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    /// The two beaches at the ends of what the editor can build: the
500    /// biggest sensible one has to travel, and the biggest possible one has
501    /// to be refused rather than truncated on arrival.
502    ///
503    /// The wire test can only check the number [`MAX_BEACH_BYTES`] promises;
504    /// this checks that the promise is about beaches a player can actually
505    /// paint, which is what an assumed four hundred bytes never did.
506    #[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        // A busy beach: every seat, rocks, holes and walls all over it.
523        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        // And the worst case: a crab on every free tile, which the editor
553        // will happily let an author paint. Each one is a header line of
554        // its own, so the text runs to thousands of characters.
555        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    /// A beach the host picked for two cannot hold the five who turned up:
589    /// three seats would have no castle and could never score. It is
590    /// dropped at launch and the round falls back to the terms.
591    #[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    /// And the same beach refused again at the far end, where the round is
613    /// actually built. The launch drops one that cannot seat the table, but
614    /// the round *after* it does not: `call_next_round` re-sends the beach
615    /// it played on and works out `seats` afresh, and the queue admitted
616    /// between rounds makes that number bigger. Without this the two
617    /// players let in sat at a beach with no castle for them and banked
618    /// nothing all round, with nothing on any screen to say why.
619    ///
620    /// Both ends run this same check on the same bytes, so they fall back
621    /// together and no peer desyncs over it.
622    #[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        // The table it was picked for: the handmade beach, as drawn.
636        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        // Four admitted between rounds, and the same bytes come round
643        // again. The beach gives way rather than the two new players.
644        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    /// Which chairs the AI holds under a set of terms.
659    ///
660    /// Host and joiner each work this out from the same wire terms rather
661    /// than being told, so it has to answer the same for both or the two
662    /// play different tables. It also indexes a per-seat array straight
663    /// off a byte a peer chose, so a `bots` count larger than the table
664    /// must fill what is there and reach no further.
665    #[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        // A table shorter than the array leaves the chairs past its end
685        // alone: those are not seats anybody is playing.
686        assert_eq!(held(2, 4), [false, false, true, true, false, false]);
687        // And a byte bigger than the table fills the table, not past it.
688        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        // Every AI seat plays at the level the terms name, since that is
695        // the one thing about them the table agreed on.
696        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    /// Nonsense on the wire must not stop the round: the beach falls back
706    /// to the terms, and the hash check is what says the peers disagree.
707    #[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    /// A versus arena wants a castle each, so a puzzle built for one crab
717    /// and one castle is not offered as a two-seat beach.
718    #[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    /// A beach that fits nobody at this table is not silently absent: the
735    /// dial skips its stop, and the row beside it says why. Two castles
736    /// stop being offered the moment a third player sits down, and that
737    /// used to read as a beach the game had lost.
738    #[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        // With nothing saved there is nothing to explain: an empty shelf is
757        // not a shelf whose beaches are the wrong size.
758        assert_eq!(beaches_note(&at(4), &EN, &CustomBeaches::default()), None);
759    }
760
761    /// The dial walks the built-ins, then every handmade beach, and steps
762    /// off the end rather than sticking. With none saved it never lands on
763    /// the custom stop at all, because an empty stop is a dead press.
764    #[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        // An empty shelf is the case that matters: the dial must not stop
771        // on a beach that is not there.
772        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    /// A series steps the map under the dial's guards, keeping the table:
778    /// five seated skip the empty shelf and the four-castle beaches rather
779    /// than losing two castles on the classic arena. Locally and on the
780    /// wire alike, since the wire form goes through the same stepper.
781    #[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        // Four seated walk every built-in beach in order.
800        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    /// The seat count moving under the map: `Custom` with no beach seating
827    /// the table steps off the shelf, and the label agrees with the launch
828    /// in the meantime, naming the XL arena the match would generate.
829    #[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    /// Open ocean is the one beach with no edges. The sim has supported
865    /// wrapping since the campaign started teaching it (level 26), but
866    /// nothing in versus ever turned it on, and every other map choice has
867    /// to stay walled, or a beach changes shape under everyone.
868    #[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        // And it is appended, so an older settings file or a `Start` from
885        // another machine still names the beach it meant.
886        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    /// Naming a seat, end to end in a headless App: Enter on a name row
893    /// takes the keyboard instead of starting the match, what is typed lands
894    /// in the name, and Enter hands the keyboard back.
895    #[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        // Tab opens the name box. Enter must not, or the name rows become
940        // a room with no door: they are last on the list, so a player who
941        // has just named everybody presses Enter to start and gets the
942        // name box again, and again.
943        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        // And now Enter starts the match from that very row, rather than
963        // reopening the box it just closed.
964        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    /// A name row per seat in the match, and none for the seats that are
976    /// not playing.
977    #[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    /// AI seats fill from the top down, one difficulty row each, and the
1000    /// unused rows stay hidden.
1001    #[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        // An all-human match hides every AI row, and a table of four hides
1023        // the name rows of the two seats nobody is sitting in.
1024        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    /// Each AI seat carries its own difficulty: turning one row must not
1033    /// drag the other seats with it.
1034    #[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); // seat 4: normal -> fierce
1042        cycle_ai_level(&mut config, 2, Turn::Left); // seat 2: normal -> easy
1043        assert_eq!(config.bot_levels, {
1044            let mut want = [BotLevel::Normal; MAX_PLAYERS];
1045            want[1] = BotLevel::Easy; // seat 2, stepped down
1046            want[3] = BotLevel::Hard; // seat 4, stepped up
1047            want
1048        });
1049        // A slot with no AI behind it is inert.
1050        config.bots = 1;
1051        cycle_ai_level(&mut config, 2, Turn::Right);
1052        assert_eq!(config.bot_levels[1], BotLevel::Easy);
1053    }
1054}