Skip to main content

pinch_points/sim/board/
mod.rs

1use crate::sim::crab::{Crab, CrabKind, Handedness};
2use crate::sim::direction::Direction;
3use crate::sim::gull::{
4    EAT_RANGE, FLIGHT_MAX, FLIGHT_MIN, GULL_FLY_SPEED, GULL_WALK_SPEED, Gull, GullState,
5    TAKEOFF_MAX, TAKEOFF_MIN,
6};
7use crate::sim::hash::Fnv;
8use crate::sim::rng::Pcg32;
9
10/// Castle tier thresholds by banked score (spec §3.4).
11pub const TIER_FLOORS: [u32; 4] = [0, 10, 25, 50];
12/// Most live crabs a single castle hit can spill back onto the sand. The
13/// score still drops a full tier (spec §3.4's legible loss); crabs beyond the
14/// cap are lost to the flock rather than flooding the board.
15pub const SPILL_CAP: u32 = 8;
16/// Molting-crab lure duration: 10 s at 30 Hz (spec §3.2).
17pub const LURE_TICKS: u32 = 300;
18/// Quiet spell after a lure ends before another may start, same 10 s.
19///
20/// Balance: the lure pulls *every* loose crab to one castle, and the crabs it
21/// delivers include the next molting crab, so an unguarded lure re-arms
22/// itself. Non-stacking plus a cooldown caps lure uptime at half a round and
23/// stops the first molt from deciding it.
24pub const LURE_COOLDOWN: u32 = 300;
25/// Versus signposts fade away after this many ticks (10 s, the original's
26/// balance valve against stale fortifications). Puzzle-rule boards
27/// (CapPolicy::Reject) keep posts forever: a fixed inventory implies
28/// permanence.
29pub const SIGNPOST_LIFETIME: u32 = 300;
30/// The canonical simulation rate. Everything that converts ticks to
31/// seconds (round lengths, clocks, speed settings) goes through this.
32pub const TICKS_PER_SECOND: u32 = 30;
33/// Tide-event durations (manias, tempo shifts): 10 s.
34pub const EVENT_TICKS: u32 = 300;
35/// The final-scramble threshold: with a round timer set, gull spawning
36/// doubles in rate when this many ticks remain (spec §3.6: 30 s).
37pub const SURGE_TICKS: u32 = 900;
38
39/// Castle tier (0–3) for a banked-crab score.
40pub fn castle_tier(score: u32) -> u8 {
41    match score {
42        0..=9 => 0,
43        10..=24 => 1,
44        25..=49 => 2,
45        _ => 3,
46    }
47}
48
49/// A creature's sub-tile offset from its tile centre, in subunits, as signed
50/// screen-space (x right, y down) components.
51fn sub_offset(dir: Direction, progress: u16) -> (i32, i32) {
52    let (dx, dy) = dir.offset();
53    (dx * i32::from(progress), dy * i32::from(progress))
54}
55
56pub type PlayerId = u8;
57
58/// A player id as a seat index, if it names a real seat: the one spelling
59/// of the bounds check every reader of an untrusted seat goes through.
60pub fn seat(player: PlayerId) -> Option<usize> {
61    let seat = usize::from(player);
62    (seat < MAX_PLAYERS).then_some(seat)
63}
64
65/// The ring around a castle, nearest first: the four edge-adjacent tiles
66/// in fixed order, then the diagonals. The fixed order keeps every ring
67/// walk deterministic, spilled crabs and besieging gulls alike.
68pub(crate) const CASTLE_RING: [(i32, i32); 8] = [
69    (0, -1),
70    (1, 0),
71    (0, 1),
72    (-1, 0),
73    (1, -1),
74    (1, 1),
75    (-1, 1),
76    (-1, -1),
77];
78
79/// Seats a board can hold. Six is the current cut: four corners and two
80/// long-edge castles on a generated arena (see
81/// [`castle_spots`](crate::sim::castle_spots)). The handcrafted classic
82/// arena is a four-castle beach and stays one.
83pub const MAX_PLAYERS: usize = 6;
84/// Spec §3.3: placing a fourth signpost removes that player's oldest.
85pub const MAX_SIGNPOSTS_PER_PLAYER: usize = 3;
86/// Balance: the ambient gull spawner pauses while this many gulls are on
87/// the beach. Tide events ignore the cap on purpose.
88pub const GULL_CAP: usize = 6;
89/// Balance: the ambient crab spawners pause once live crabs reach this
90/// fraction of the board's tiles. Past it the beach is a carpet rather than
91/// a puzzle. Crab Mania ignores the cap, the way Gull Mania ignores
92/// [`GULL_CAP`].
93pub const CRAB_CAP_TILES_PER_CRAB: usize = 3;
94/// Spec §4.2: one tile is 256 subunits; all movement is integer arithmetic.
95pub const SUBUNITS_PER_TILE: u16 = 256;
96
97#[derive(Clone, Copy, PartialEq, Eq, Debug)]
98pub enum TileKind {
99    Empty,
100    /// Impassable. Modelled as a tile no creature may enter rather than as
101    /// four walls, so level authoring can't desync rock and wall data.
102    Rock,
103    Castle(PlayerId),
104    Spawner(Spawner),
105    /// A pivoting driftwood log: deflects each crossing creature to its
106    /// right or left alternately, flipping after every crossing. A
107    /// deterministic 50/50 stream splitter with no PRNG draw.
108    Turnstile {
109        next_right: bool,
110    },
111    /// Seaweed: crabs slip through, but walking gulls are blocked and
112    /// flying gulls cannot land here (they glide one more tile).
113    Kelp,
114    /// Shallow water: creatures standing in it move at half speed.
115    Pool,
116}
117
118#[derive(Clone, Copy, PartialEq, Eq, Debug)]
119pub struct Spawner {
120    /// Direction emitted crabs initially face.
121    pub dir: Direction,
122    /// A crab spawns every `period` ticks, starting on tick 0.
123    pub period: u32,
124}
125
126#[derive(Clone, Copy, PartialEq, Eq, Debug)]
127pub enum SignpostHealth {
128    Full,
129    /// One walking-gull crossing away from destruction.
130    Worn,
131}
132
133#[derive(Clone, Copy, Debug)]
134pub struct Signpost {
135    pub dir: Direction,
136    pub owner: PlayerId,
137    pub health: SignpostHealth,
138    /// Monotonic placement counter, used to find the owner's oldest signpost
139    /// when the cap evicts one.
140    seq: u64,
141    /// Tick this signpost was placed (or re-pointed); drives expiry under
142    /// versus rules and the render-side fade.
143    pub placed: u64,
144}
145
146/// One player's input for one tick. Coordinates are the cursor's tile. The
147/// wire packs this into 2 bytes (spec §7.6); see [`crate::transport`].
148#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
149pub enum PlayerAction {
150    #[default]
151    None,
152    Place {
153        x: u8,
154        y: u8,
155        dir: Direction,
156    },
157    Remove {
158        x: u8,
159        y: u8,
160    },
161}
162
163/// What happens when a player places a signpost at their cap.
164#[derive(Clone, Copy, PartialEq, Eq, Debug)]
165pub enum CapPolicy {
166    /// Versus rules (spec §3.3): the player's oldest signpost is removed.
167    Evict,
168    /// Puzzle rules (spec §5.1): the placement fails, fixed inventory.
169    Reject,
170}
171
172impl CapPolicy {
173    /// The level-format token; `from_token` is its inverse.
174    pub fn token(self) -> &'static str {
175        match self {
176            CapPolicy::Evict => "evict",
177            CapPolicy::Reject => "reject",
178        }
179    }
180
181    pub fn from_token(token: &str) -> Option<CapPolicy> {
182        match token {
183            "evict" => Some(CapPolicy::Evict),
184            "reject" => Some(CapPolicy::Reject),
185            _ => None,
186        }
187    }
188}
189
190/// The entire game state. No engine types, no floats, no hash maps: every
191/// tick is a pure function of prior state plus one action per seat, so the
192/// same seed and input list replays bit-identically on any platform.
193#[derive(Clone, Debug)]
194pub struct Board {
195    width: u8,
196    height: u8,
197    /// Construction seed, kept for serialization (`Level::to_text`). Not
198    /// hashed: the live PRNG state, which is hashed, derives from it.
199    seed: u64,
200    /// Horizontal wall segments, `(height + 1)` rows × `width` columns.
201    /// `h_walls[y * width + x]` is the edge *above* tile `(x, y)`.
202    h_walls: Vec<bool>,
203    /// Vertical wall segments, `height` rows × `(width + 1)` columns.
204    /// `v_walls[y * (width + 1) + x]` is the edge *left of* tile `(x, y)`.
205    v_walls: Vec<bool>,
206    tiles: Vec<TileKind>,
207    signposts: Vec<Option<Signpost>>,
208    crabs: Vec<Crab>,
209    scores: [u32; MAX_PLAYERS],
210    rng: Pcg32,
211    tick: u64,
212    signpost_seq: u64,
213    next_crab_id: u32,
214    signpost_cap: u8,
215    cap_policy: CapPolicy,
216    gulls: Vec<Gull>,
217    next_gull_id: u32,
218    /// Auto-spawn a gull at a PRNG edge tile every this many ticks; 0 = off.
219    gull_period: u32,
220    /// Round length in ticks (the tide, spec §3.6). None = untimed. When it
221    /// reaches zero the sim freezes: scores are locked at the wave.
222    round_length: Option<u32>,
223    /// Active molting-crab lure: all loose crabs path toward this player's
224    /// castle for the remaining ticks (spec §3.2).
225    lure: Option<(PlayerId, u32)>,
226    /// Ticks until another lure may start; set when one ends.
227    lure_cooldown: u32,
228    /// Crabs banked since the start, all players combined. With
229    /// `next_crab_id` (crabs ever spawned) this distinguishes "every crab is
230    /// safe" from "the gulls got some" (spec §5.1 win condition).
231    crabs_banked: u32,
232    /// Golden crabs banked (challenge goals).
233    golden_banked: u32,
234    /// Tide events fire only where enabled (versus arenas, the attract
235    /// beach), never in puzzles or goal-checked challenges.
236    events_enabled: bool,
237    /// Active spawn mania: spawners flood crabs or emit gulls instead.
238    mania: Option<(Mania, u32)>,
239    /// Active tempo shift and the ticks left of it.
240    tempo: Option<(Tempo, u32)>,
241    /// The most recent tide event and the tick it fired (HUD banner).
242    last_event: Option<(TideEvent, u64)>,
243    /// Open edges: creatures walking (or flying) off one side re-enter on
244    /// the opposite side (spec §3.1 wrap-around, settled by research: the
245    /// original wraps).
246    wrap: bool,
247    /// Sparkling banks noticed during crab movement; the roulette spins
248    /// after the movement pass so events may safely mutate the crab list.
249    /// Always drained within the same tick (never hashed).
250    event_queue: Vec<PlayerId>,
251}
252
253impl Board {
254    /// An empty all-sand board with walled borders (spec §3.1; wrap-around
255    /// edges are a later, flag-gated variant).
256    pub fn new(width: u8, height: u8, seed: u64) -> Board {
257        assert!(width > 0 && height > 0, "board must be at least 1×1");
258        let (w, h) = (width as usize, height as usize);
259        let mut board = Board {
260            width,
261            height,
262            seed,
263            h_walls: vec![false; (h + 1) * w],
264            v_walls: vec![false; h * (w + 1)],
265            tiles: vec![TileKind::Empty; w * h],
266            signposts: vec![None; w * h],
267            crabs: Vec::new(),
268            scores: [0; MAX_PLAYERS],
269            rng: Pcg32::new(seed, 0x0005_eaba_55ed),
270            tick: 0,
271            signpost_seq: 0,
272            next_crab_id: 0,
273            signpost_cap: MAX_SIGNPOSTS_PER_PLAYER as u8,
274            cap_policy: CapPolicy::Evict,
275            gulls: Vec::new(),
276            next_gull_id: 0,
277            gull_period: 0,
278            round_length: None,
279            lure: None,
280            lure_cooldown: 0,
281            crabs_banked: 0,
282            golden_banked: 0,
283            events_enabled: false,
284            mania: None,
285            tempo: None,
286            last_event: None,
287            wrap: false,
288            event_queue: Vec::new(),
289        };
290        // The border walls are the wrap rule with wrap off; the border
291        // indices are stated once, in set_wrap.
292        board.set_wrap(false);
293        board
294    }
295
296    // --- level authoring -------------------------------------------------
297
298    /// Add or remove the wall on the `dir` side of tile `(x, y)`. Walls are
299    /// stored per-edge, so the neighbouring tile sees the same wall and the
300    /// two tiles cannot disagree (spec §7.3).
301    pub fn set_wall(&mut self, x: u8, y: u8, dir: Direction, present: bool) {
302        assert!(
303            self.in_bounds(i32::from(x), i32::from(y)),
304            "wall off the board"
305        );
306        self.set_edge(x as usize, y as usize, dir, present);
307    }
308
309    pub fn set_tile(&mut self, x: u8, y: u8, kind: TileKind) {
310        assert!(
311            self.in_bounds(i32::from(x), i32::from(y)),
312            "tile off the board"
313        );
314        if let TileKind::Castle(owner) = kind {
315            assert!(seat(owner).is_some(), "invalid castle owner");
316        }
317        if let TileKind::Spawner(s) = kind {
318            assert!(s.period > 0, "spawner period must be at least 1 tick");
319        }
320        let t = self.index(i32::from(x), i32::from(y));
321        self.tiles[t as usize] = kind;
322    }
323
324    // --- the player's one verb -------------------------------------------
325
326    /// Change the signpost cap and what happens at it. Versus keeps the
327    /// default (3, evict-oldest); puzzle mode sets (inventory, reject).
328    pub fn set_signpost_rule(&mut self, cap: u8, policy: CapPolicy) {
329        self.signpost_cap = cap;
330        self.cap_policy = policy;
331    }
332
333    /// Auto-spawn a gull every `period` ticks (0 disables). Doubled during
334    /// the final-scramble surge of a timed round.
335    pub fn set_gull_period(&mut self, period: u32) {
336        self.gull_period = period;
337    }
338
339    pub fn set_round_length(&mut self, ticks: Option<u32>) {
340        self.round_length = ticks;
341    }
342
343    /// Open (or close) the board edges. Opening removes the border walls so
344    /// creatures walk and fly off one side and re-enter on the opposite one.
345    pub fn set_wrap(&mut self, wrap: bool) {
346        self.wrap = wrap;
347        let (w, h) = (self.width as usize, self.height as usize);
348        for x in 0..w {
349            self.h_walls[x] = !wrap;
350            self.h_walls[h * w + x] = !wrap;
351        }
352        for y in 0..h {
353            self.v_walls[y * (w + 1)] = !wrap;
354            self.v_walls[y * (w + 1) + w] = !wrap;
355        }
356    }
357
358    pub fn wrap(&self) -> bool {
359        self.wrap
360    }
361
362    /// Enable tide events (the sparkling crab's roulette). Off by default:
363    /// puzzles and goal-checked challenges stay predictable.
364    pub fn events_enabled(&self) -> bool {
365        self.events_enabled
366    }
367
368    pub fn set_events_enabled(&mut self, enabled: bool) {
369        self.events_enabled = enabled;
370    }
371
372    /// The most recent tide event and when it fired.
373    pub fn last_event(&self) -> Option<(TideEvent, u64)> {
374        self.last_event
375    }
376
377    pub fn golden_banked(&self) -> u32 {
378        self.golden_banked
379    }
380
381    /// Preload a score (editor, sandboxes, tests). Not used by live play:
382    /// scores otherwise change only through banking and gull raids. A seat
383    /// that does not exist takes no score: the callers that parse untrusted
384    /// text already refuse it with a message, and this is the backstop.
385    pub fn set_score(&mut self, player: PlayerId, score: u32) {
386        if let Some(seat) = seat(player) {
387            self.scores[seat] = score;
388        }
389    }
390
391    // --- simulation ------------------------------------------------------
392
393    /// Advance one fixed 30 Hz step. Order within a tick is fixed and part of
394    /// the ruleset: player actions (in the tick's rotating seat order; on a
395    /// same-tile conflict, whoever that order reaches first wins), then
396    /// spawners, then crab movement, then gull movement, then gulls eat, in
397    /// stable creature order throughout. A signpost placed this tick affects
398    /// crabs arriving this tick. Once the tide is in (`round_over`), the sim
399    /// is frozen and ticks are no-ops: scores locked at the wave (spec §3.6).
400    pub fn tick(&mut self, actions: &[PlayerAction; MAX_PLAYERS]) {
401        if self.round_over() {
402            return;
403        }
404        for player in self.action_order() {
405            self.apply_action(player, actions[player as usize]);
406        }
407        self.expire_signposts();
408        self.run_spawners();
409        self.run_gull_spawner();
410        self.move_crabs();
411        self.move_gulls();
412        self.gulls_eat();
413        if let Some((_, ticks)) = &mut self.lure {
414            *ticks -= 1;
415            if *ticks == 0 {
416                self.lure = None;
417                self.lure_cooldown = LURE_COOLDOWN;
418            }
419        } else {
420            self.lure_cooldown = self.lure_cooldown.saturating_sub(1);
421        }
422        if let Some((_, ticks)) = &mut self.mania {
423            *ticks -= 1;
424            if *ticks == 0 {
425                self.mania = None;
426            }
427        }
428        if let Some((_, ticks)) = &mut self.tempo {
429            *ticks -= 1;
430            if *ticks == 0 {
431                self.tempo = None;
432            }
433        }
434        self.tick += 1;
435    }
436
437    /// The seat order this tick's actions are applied in: one seat leads,
438    /// then round the table.
439    ///
440    /// Balance: two players reaching for the same tile on the same tick
441    /// cannot both have it, so the lead rotates rather than always falling to
442    /// the lowest seat. It rotates over the seats *in play*, not the width of
443    /// the array, or the lead lands on seats that do not exist and the real
444    /// ones keep their relative order.
445    ///
446    /// The lead is a small mix of the tick rather than `tick % seats`,
447    /// because the things that act on a schedule act on multiples of four and
448    /// a plain modulo would hand every bot decision to one seat. Still a pure
449    /// function of the tick, so lockstep peers and replays agree.
450    fn action_order(&self) -> [PlayerId; MAX_PLAYERS] {
451        let seats = u64::from(self.seats_in_play()).max(1);
452        let first = (self.tick ^ (self.tick >> 3)) % seats;
453        std::array::from_fn(|i| {
454            let i = i as u64;
455            if i < seats {
456                ((first + i) % seats) as PlayerId
457            } else {
458                i as PlayerId // absent seats, in any order: they act on nothing
459            }
460        })
461    }
462
463    /// How many seats this board seats: one past the highest castle owner, so
464    /// a four-castle beach rotates ties among four however wide the arrays.
465    pub fn seats_in_play(&self) -> u8 {
466        self.castle_owners().max().map_or(0, |owner| owner + 1)
467    }
468
469    /// The tide has come in: the round is finished and the sim is frozen.
470    pub fn round_over(&self) -> bool {
471        self.round_length
472            .is_some_and(|len| self.tick >= u64::from(len))
473    }
474
475    /// Ticks left before the wave, if a round timer is set.
476    pub fn remaining_ticks(&self) -> Option<u64> {
477        self.round_length
478            .map(|len| u64::from(len).saturating_sub(self.tick))
479    }
480
481    /// The final scramble: the last 30 s of a timed round, when gulls spawn
482    /// at double rate.
483    pub fn in_surge(&self) -> bool {
484        self.remaining_ticks()
485            .is_some_and(|left| left <= u64::from(SURGE_TICKS))
486    }
487
488    /// Advance one step with no player input.
489    pub fn tick_idle(&mut self) {
490        self.tick(&[PlayerAction::None; MAX_PLAYERS]);
491    }
492
493    fn apply_action(&mut self, player: PlayerId, action: PlayerAction) {
494        match action {
495            PlayerAction::None => {}
496            PlayerAction::Place { x, y, dir } => {
497                let _ = self.place_signpost(player, x, y, dir);
498            }
499            PlayerAction::Remove { x, y } => {
500                let _ = self.remove_signpost(player, x, y);
501            }
502        }
503    }
504
505    /// A walker's step this tick: the tempo-adjusted speed, halved (never
506    /// to zero) while standing in a tide pool. One statement of the wading
507    /// rule, for crabs and gulls both.
508    fn walk_step(&self, tile: u16, base: u16) -> u16 {
509        debug_assert!(
510            usize::from(tile) < self.tiles.len(),
511            "walking off the board: tile {tile} of {}",
512            self.tiles.len()
513        );
514        let step = self.tempo_speed(base);
515        if self.tiles[tile as usize] == TileKind::Pool {
516            return (step / 2).max(1);
517        }
518        step
519    }
520
521    /// Tide-event tempo: doubled or halved speed for every creature.
522    fn tempo_speed(&self, base: u16) -> u16 {
523        match self.tempo {
524            Some((Tempo::Fast, _)) => base * 2,
525            Some((Tempo::Slow, _)) => (base / 2).max(1),
526            None => base,
527        }
528    }
529
530    // --- read access (render layer, modes, tests) ------------------------
531
532    pub fn width(&self) -> u8 {
533        self.width
534    }
535
536    pub fn height(&self) -> u8 {
537        self.height
538    }
539
540    pub fn ticks(&self) -> u64 {
541        self.tick
542    }
543
544    pub fn crabs(&self) -> &[Crab] {
545        &self.crabs
546    }
547
548    pub fn gulls(&self) -> &[Gull] {
549        &self.gulls
550    }
551
552    pub fn gull_period(&self) -> u32 {
553        self.gull_period
554    }
555
556    pub fn round_length(&self) -> Option<u32> {
557        self.round_length
558    }
559
560    /// The seed this board was constructed with.
561    pub fn seed(&self) -> u64 {
562        self.seed
563    }
564
565    /// Remove every crab standing on tile `(x, y)` (editor use).
566    pub fn remove_crabs_at(&mut self, x: u8, y: u8) {
567        let tile = self.index(i32::from(x), i32::from(y));
568        self.crabs.retain(|c| c.tile != tile);
569    }
570
571    /// Remove every gull standing on tile `(x, y)` (editor use).
572    pub fn remove_gulls_at(&mut self, x: u8, y: u8) {
573        let tile = self.index(i32::from(x), i32::from(y));
574        self.gulls.retain(|g| g.tile != tile);
575    }
576
577    /// Active molting lure, if any: (luring player, ticks left).
578    pub fn lure(&self) -> Option<(PlayerId, u32)> {
579        self.lure
580    }
581
582    /// Crabs banked since the start, all players combined.
583    pub fn crabs_banked(&self) -> u32 {
584        self.crabs_banked
585    }
586
587    /// Crabs ever spawned (initial, spawner-emitted, and castle-spilled).
588    pub fn crabs_spawned(&self) -> u32 {
589        self.next_crab_id
590    }
591
592    pub fn scores(&self) -> &[u32; MAX_PLAYERS] {
593        &self.scores
594    }
595
596    pub fn tile_at(&self, x: u8, y: u8) -> TileKind {
597        assert!(self.in_bounds(i32::from(x), i32::from(y)));
598        self.tiles[self.index(i32::from(x), i32::from(y)) as usize]
599    }
600
601    /// Every tile with its coordinates, in the board's own row-major order.
602    pub fn tiles(&self) -> impl Iterator<Item = (u8, u8, TileKind)> + '_ {
603        let width = self.width;
604        self.tiles.iter().enumerate().map(move |(index, &kind)| {
605            (
606                (index % width as usize) as u8,
607                (index / width as usize) as u8,
608                kind,
609            )
610        })
611    }
612
613    /// Where a seat's castle stands, if it has one.
614    pub fn castle_of(&self, player: PlayerId) -> Option<(u8, u8)> {
615        self.tiles()
616            .find(|&(_, _, kind)| kind == TileKind::Castle(player))
617            .map(|(x, y, _)| (x, y))
618    }
619
620    /// The highest seat number with a castle on the board: the seat count
621    /// a recorded board implies.
622    pub fn castle_owners(&self) -> impl Iterator<Item = PlayerId> + '_ {
623        self.tiles().filter_map(|(_, _, kind)| match kind {
624            TileKind::Castle(owner) => Some(owner),
625            TileKind::Empty
626            | TileKind::Rock
627            | TileKind::Spawner(_)
628            | TileKind::Turnstile { .. }
629            | TileKind::Kelp
630            | TileKind::Pool => None,
631        })
632    }
633
634    /// How many seats the board has castles for, counted by owner rather
635    /// than by castle: a beach seats as many players as there are banks to
636    /// run for. What the map dial measures a handmade beach against.
637    pub fn castle_seats(&self) -> u8 {
638        let mut seen = [false; MAX_PLAYERS];
639        for owner in self.castle_owners() {
640            if let Some(slot) = seen.get_mut(usize::from(owner)) {
641                *slot = true;
642            }
643        }
644        seen.iter().filter(|held| **held).count() as u8
645    }
646
647    /// The first signpost owned by `player` in reading order (top-left to
648    /// bottom-right), as tile coordinates. Drives the "clear one" input.
649    pub fn first_signpost_of(&self, player: PlayerId) -> Option<(u8, u8)> {
650        self.signposts
651            .iter()
652            .position(|post| post.is_some_and(|post| post.owner == player))
653            .map(|index| self.coords_u8(index as u16))
654    }
655
656    /// Tile index back to coordinates. [`Board::coords`] speaks `i32` for
657    /// the movement arithmetic; this is the public-facing form.
658    pub fn coords_u8(&self, tile: u16) -> (u8, u8) {
659        let (x, y) = self.coords(tile);
660        (x as u8, y as u8)
661    }
662
663    pub fn wall_at(&self, x: u8, y: u8, dir: Direction) -> bool {
664        assert!(self.in_bounds(i32::from(x), i32::from(y)));
665        self.edge_blocked(x as usize, y as usize, dir)
666    }
667}
668
669mod crabs;
670mod events;
671mod geometry;
672mod gulls;
673mod hashing;
674mod signposts;
675mod snapshot;
676#[cfg(test)]
677mod tests;
678
679pub use events::{Mania, Tempo, TideEvent};
680pub(crate) use geometry::Walker;