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;