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::pose::Pose;
9use crate::sim::rng::Pcg32;
10
11/// Castle tier thresholds by banked score (spec §3.4).
12pub const TIER_FLOORS: [u32; 4] = [0, 10, 25, 50];
13/// Most live crabs a single castle hit can spill back onto the sand. The
14/// score still drops a full tier (spec §3.4's legible loss); crabs beyond the
15/// cap are lost to the flock rather than flooding the board.
16pub const SPILL_CAP: u32 = 8;
17/// Molting-crab lure duration: 10 s at 30 Hz (spec §3.2).
18pub const LURE_TICKS: u32 = 300;
19/// Quiet spell after a lure ends before another may start: 20 s, twice the
20/// lure itself.
21///
22/// Balance: the lure pulls *every* loose crab to one castle, and the crabs it
23/// delivers include the next molting crab, so an unguarded lure re-arms
24/// itself. Non-stacking plus a cooldown stops the first molt from deciding
25/// the round.
26///
27/// Was 10 s, matching the lure. That set the floor for one lure to the next
28/// at 20 s, and the leader kept hitting it: a measured six-player round ran
29/// lures 22.9 s, 25.8 s, 27.2 s and 23.3 s apart, one castle holding 50 of
30/// the 60 lured seconds, a third of the round. Doubling the spell puts the
31/// floor at 30 s and the uptime ceiling at a third of a round rather than
32/// half, which is enough separation for the beach to refill and the other
33/// castles to have a turn at it.
34pub const LURE_COOLDOWN: u32 = 600;
35/// Ticks after any tide event before the roulette may spin again.
36///
37/// The roulette fires when a Sparkling crab banks, and Sparkling is 2% of
38/// what the *spawners* produce - so the events that raise the crab supply
39/// raise their own rate. Crab Mania floods the spawners and Speed Up banks
40/// everything faster, and both spin the wheel that rolled them. Measured
41/// over the seven kept rounds the result ranges from nought events in
42/// three minutes to twelve, and the worst of them put six inside nineteen
43/// seconds - three Crab Manias among them, two events three ticks apart -
44/// with the centre banner never off the screen.
45///
46/// One [`EVENT_TICKS`] is the value because it is the thing being fixed:
47/// an event's own effect lasts that long, so this is exactly "no second
48/// event until the first has finished". It is the same shape of valve as
49/// [`LURE_COOLDOWN`], which the other self-feeding mechanic already had.
50///
51/// Re-running the seven kept rounds through it (`cargo run --example
52/// round_events`) puts the worst of them at three events rather than
53/// eleven and the next at nine rather than twelve, and - the number that
54/// matters - **none** of them lands inside another's window, in any round,
55/// which is what it is for. The rounds diverge under the new rule, so
56/// those are the cadences that input stream produces now rather than a
57/// strict before-and-after of one round.
58pub const EVENT_COOLDOWN: u32 = EVENT_TICKS;
59/// Versus signposts fade away after this many ticks (10 s, the original's
60/// balance valve against stale fortifications). Puzzle-rule boards
61/// (CapPolicy::Reject) keep posts forever: a fixed inventory implies
62/// permanence.
63pub const SIGNPOST_LIFETIME: u32 = 300;
64/// The canonical simulation rate. Everything that converts ticks to
65/// seconds (round lengths, clocks, speed settings) goes through this.
66pub const TICKS_PER_SECOND: u32 = 30;
67/// Tide-event durations (manias, tempo shifts): 10 s.
68pub const EVENT_TICKS: u32 = 300;
69/// The final-scramble threshold: with a round timer set, gull spawning
70/// doubles in rate when this many ticks remain (spec §3.6: 30 s).
71pub const SURGE_TICKS: u32 = 900;
72
73/// Castle tier (0–3) for a banked-crab score.
74pub fn castle_tier(score: u32) -> u8 {
75    match score {
76        0..=9 => 0,
77        10..=24 => 1,
78        25..=49 => 2,
79        _ => 3,
80    }
81}
82
83/// A creature's sub-tile offset from its tile centre, in subunits, as signed
84/// screen-space (x right, y down) components.
85fn sub_offset(dir: Direction, progress: u16) -> (i32, i32) {
86    let (dx, dy) = dir.offset();
87    (dx * i32::from(progress), dy * i32::from(progress))
88}
89
90pub type PlayerId = u8;
91
92/// A player id as a seat index, if it names a real seat: the one spelling
93/// of the bounds check every reader of an untrusted seat goes through.
94pub fn seat(player: PlayerId) -> Option<usize> {
95    let seat = usize::from(player);
96    (seat < MAX_PLAYERS).then_some(seat)
97}
98
99/// The ring around a castle, nearest first: the four edge-adjacent tiles
100/// in fixed order, then the diagonals. The fixed order keeps every ring
101/// walk deterministic, spilled crabs and besieging gulls alike.
102pub(crate) const CASTLE_RING: [(i32, i32); 8] = [
103    (0, -1),
104    (1, 0),
105    (0, 1),
106    (-1, 0),
107    (1, -1),
108    (1, 1),
109    (-1, 1),
110    (-1, -1),
111];
112
113/// Seats a board can hold. Six is the current cut: four corners and two
114/// long-edge castles on a generated arena (see
115/// [`castle_spots`](crate::sim::castle_spots)). The handcrafted classic
116/// arena is a four-castle beach and stays one.
117pub const MAX_PLAYERS: usize = 6;
118/// Spec §3.3: placing a fourth signpost removes that player's oldest.
119pub const MAX_SIGNPOSTS_PER_PLAYER: usize = 3;
120/// Balance: the ambient gull spawner pauses while this many gulls are on
121/// the beach. Tide events ignore the cap on purpose.
122pub const GULL_CAP: usize = 6;
123/// Balance: the ambient crab spawners pause once live crabs reach this
124/// fraction of the board's tiles. Past it the beach is a carpet rather than
125/// a puzzle. Crab Mania ignores the cap, the way Gull Mania ignores
126/// [`GULL_CAP`].
127pub const CRAB_CAP_TILES_PER_CRAB: usize = 3;
128/// Spec §4.2: one tile is 256 subunits; all movement is integer arithmetic.
129pub const SUBUNITS_PER_TILE: u16 = 256;
130
131#[derive(Clone, Copy, PartialEq, Eq, Debug)]
132pub enum TileKind {
133    Empty,
134    /// Impassable. Modelled as a tile no creature may enter rather than as
135    /// four walls, so level authoring can't desync rock and wall data.
136    Rock,
137    Castle(PlayerId),
138    Spawner(Spawner),
139    /// A pivoting driftwood log: deflects each crossing creature to its
140    /// right or left alternately, flipping after every crossing. A
141    /// deterministic 50/50 stream splitter with no PRNG draw.
142    Turnstile {
143        next_right: bool,
144    },
145    /// Seaweed: crabs slip through, but walking gulls are blocked and
146    /// flying gulls cannot land here (they glide one more tile).
147    Kelp,
148    /// Shallow water: creatures standing in it move at half speed.
149    Pool,
150}
151
152#[derive(Clone, Copy, PartialEq, Eq, Debug)]
153pub struct Spawner {
154    /// Direction emitted crabs initially face.
155    pub dir: Direction,
156    /// A crab spawns every `period` ticks, starting on tick 0.
157    pub period: u32,
158}
159
160#[derive(Clone, Copy, PartialEq, Eq, Debug)]
161pub enum SignpostHealth {
162    Full,
163    /// One walking-gull crossing away from destruction.
164    Worn,
165}
166
167#[derive(Clone, Copy, Debug)]
168pub struct Signpost {
169    pub dir: Direction,
170    pub owner: PlayerId,
171    pub health: SignpostHealth,
172    /// Monotonic placement counter, used to find the owner's oldest signpost
173    /// when the cap evicts one.
174    seq: u64,
175    /// Tick this signpost was placed (or re-pointed); drives expiry under
176    /// versus rules and the render-side fade.
177    pub placed: u64,
178}
179
180/// One player's input for one tick. Coordinates are the cursor's tile. The
181/// wire packs this into 2 bytes (spec §7.6); see [`crate::transport`].
182#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
183pub enum PlayerAction {
184    #[default]
185    None,
186    Place {
187        x: u8,
188        y: u8,
189        dir: Direction,
190    },
191    Remove {
192        x: u8,
193        y: u8,
194    },
195}
196
197/// What happens when a player places a signpost at their cap.
198#[derive(Clone, Copy, PartialEq, Eq, Debug)]
199pub enum CapPolicy {
200    /// Versus rules (spec §3.3): the player's oldest signpost is removed.
201    Evict,
202    /// Puzzle rules (spec §5.1): the placement fails, fixed inventory.
203    Reject,
204}
205
206impl CapPolicy {
207    /// The level-format token; `from_token` is its inverse.
208    pub fn token(self) -> &'static str {
209        match self {
210            CapPolicy::Evict => "evict",
211            CapPolicy::Reject => "reject",
212        }
213    }
214
215    pub fn from_token(token: &str) -> Option<CapPolicy> {
216        match token {
217            "evict" => Some(CapPolicy::Evict),
218            "reject" => Some(CapPolicy::Reject),
219            _ => None,
220        }
221    }
222}
223
224/// The rules a board is built with: the signpost cap and what happens at
225/// it, the ambient gull rate, the tide, and whether gulls may raid. Set
226/// by the level or the lobby before the first tick and, apart from the
227/// surge doubling of the gull rate, untouched by play. Hashed and saved
228/// like the rest of the board: peers on different rules would desync.
229#[derive(Clone, Copy, PartialEq, Eq, Debug)]
230pub struct Rules {
231    pub signpost_cap: u8,
232    pub cap_policy: CapPolicy,
233    /// Auto-spawn a gull at a PRNG edge tile every this many ticks; 0 = off.
234    pub gull_period: u32,
235    /// Round length in ticks (the tide, spec §3.6). None = untimed. When it
236    /// reaches zero the sim freezes: scores are locked at the wave.
237    pub round_length: Option<u32>,
238    /// Whether a gull reaching a castle carries off half its bank
239    /// (spec §3.4). True on a versus beach, where robbing a rival is the
240    /// point.
241    ///
242    /// False in a puzzle, where a raid has nothing to take and one bad
243    /// side effect. The score it halves is never shown - the puzzle header
244    /// counts crabs saved, not points - while the spill puts banked crabs
245    /// back on the sand, and each of those counts as newly spawned. That
246    /// is the denominator of "Saved a/b", so the level's own target grew
247    /// every time a gull touched the castle. Nine shipped campaign levels
248    /// did it while playing their authored solution.
249    ///
250    /// The gull still walks in and still leaves the beach with the flock.
251    /// That departure is load-bearing and was nobody's plan: a castle is
252    /// the one tile that takes a gull off the sand, and the levels were
253    /// tuned around it. Shutting the door instead left Two Giants with no
254    /// two-post solution at all.
255    pub castle_raids: bool,
256}
257
258/// The entire game state. No engine types, no floats, no hash maps: every
259/// tick is a pure function of prior state plus one action per seat, so the
260/// same seed and input list replays bit-identically on any platform.
261#[derive(Clone, Debug)]
262pub struct Board {
263    /// The beach itself: size, walls, tiles.
264    grid: Grid,
265    /// Construction seed, kept for serialization (`Level::to_text`). Not
266    /// hashed: the live PRNG state, which is hashed, derives from it.
267    seed: u64,
268    signposts: Vec<Option<Signpost>>,
269    /// The rules this board plays by, set at construction.
270    rules: Rules,
271    crabs: Vec<Crab>,
272    scores: [u32; MAX_PLAYERS],
273    rng: Pcg32,
274    tick: u64,
275    signpost_seq: u64,
276    next_crab_id: u32,
277    gulls: Vec<Gull>,
278    next_gull_id: u32,
279    /// Active molting-crab lure: all loose crabs path toward this player's
280    /// castle for the remaining ticks (spec §3.2).
281    lure: Option<(PlayerId, u32)>,
282    /// Ticks until another lure may start; set when one ends.
283    lure_cooldown: u32,
284    /// Ticks until the tide roulette may spin again; set when any event
285    /// fires. See [`EVENT_COOLDOWN`].
286    event_cooldown: u32,
287    /// Crabs banked since the start, all players combined. With
288    /// `next_crab_id` (crabs ever spawned) this distinguishes "every crab is
289    /// safe" from "the gulls got some" (spec §5.1 win condition).
290    crabs_banked: u32,
291    /// Golden crabs banked (challenge goals).
292    golden_banked: u32,
293    /// Tide events fire only where enabled (versus arenas, the attract
294    /// beach), never in puzzles or goal-checked challenges.
295    events_enabled: bool,
296    /// Active spawn mania: spawners flood crabs or emit gulls instead.
297    mania: Option<(Mania, u32)>,
298    /// Active tempo shift and the ticks left of it.
299    tempo: Option<(Tempo, u32)>,
300    /// The most recent tide event and the tick it fired (HUD banner).
301    last_event: Option<(TideEvent, u64)>,
302    /// Open edges: creatures walking (or flying) off one side re-enter on
303    /// the opposite side (spec §3.1 wrap-around, settled by research: the
304    /// original wraps).
305    wrap: bool,
306    /// Sparkling banks noticed during crab movement; the roulette spins
307    /// after the movement pass so events may safely mutate the crab list.
308    /// Always drained within the same tick (never hashed).
309    event_queue: Vec<PlayerId>,
310    /// Crabs the tide put straight into a keep, and who got them.
311    ///
312    /// `Monopoly` banks crabs where they stand rather than walking them
313    /// home, so afterwards nothing on the board says what became of them:
314    /// they are simply gone, from tiles that are not castles. The render
315    /// layer tells a bank from a death by reading the tile a departed crab
316    /// left, so it read every one of them as the gulls' work - which for a
317    /// sweep of eighty-two crabs is eighty-two death sounds, eight hundred
318    /// feathers, a screen shake, and no sign at all that anybody scored.
319    ///
320    /// So the sim writes down what only the sim knows. Nothing in here is
321    /// ever read back by the sim, which is why it stays out of the
322    /// fingerprint: it cannot make two peers play differently, and hashing
323    /// it would only make this build disagree with builds it is playing
324    /// identically to.
325    swept_home: Vec<Swept>,
326}
327
328/// One crab the tide banked, for the render layer to find.
329#[derive(Clone, Copy, PartialEq, Eq, Debug)]
330struct Swept {
331    crab: u32,
332    owner: PlayerId,
333    /// The tick it happened on. The reader runs between frames and a frame
334    /// can span several ticks - most likely the frame straight after a
335    /// sweep, which is the busiest one the renderer ever draws - so the
336    /// record has to outlive its own tick to be read at all.
337    at: u64,
338}
339
340/// How many ticks a [`Swept`] record is kept before it is pruned: long
341/// enough that a frame running several ticks behind still finds it, short
342/// enough that the list is a handful of entries and never a round's worth.
343const SWEEP_MEMORY: u64 = 8;
344
345impl Board {
346    /// An empty all-sand board with walled borders (spec §3.1; wrap-around
347    /// edges are a later, flag-gated variant).
348    pub fn new(width: u8, height: u8, seed: u64) -> Board {
349        assert!(width > 0 && height > 0, "board must be at least 1×1");
350        let (w, h) = (width as usize, height as usize);
351        let mut board = Board {
352            grid: Grid {
353                width,
354                height,
355                h_walls: vec![false; (h + 1) * w],
356                v_walls: vec![false; h * (w + 1)],
357                tiles: vec![TileKind::Empty; w * h],
358            },
359            seed,
360            signposts: vec![None; w * h],
361            rules: Rules {
362                signpost_cap: MAX_SIGNPOSTS_PER_PLAYER as u8,
363                cap_policy: CapPolicy::Evict,
364                gull_period: 0,
365                round_length: None,
366                castle_raids: true,
367            },
368            crabs: Vec::new(),
369            scores: [0; MAX_PLAYERS],
370            rng: Pcg32::new(seed, 0x0005_eaba_55ed),
371            tick: 0,
372            signpost_seq: 0,
373            next_crab_id: 0,
374            gulls: Vec::new(),
375            next_gull_id: 0,
376            lure: None,
377            lure_cooldown: 0,
378            event_cooldown: 0,
379            crabs_banked: 0,
380            golden_banked: 0,
381            events_enabled: false,
382            mania: None,
383            tempo: None,
384            last_event: None,
385            wrap: false,
386            event_queue: Vec::new(),
387            swept_home: Vec::new(),
388        };
389        // The border walls are the wrap rule with wrap off; the border
390        // indices are stated once, in set_wrap.
391        board.set_wrap(false);
392        board
393    }
394
395    // --- level authoring -------------------------------------------------
396
397    /// Add or remove the wall on the `dir` side of tile `(x, y)`. Walls are
398    /// stored per-edge, so the neighbouring tile sees the same wall and the
399    /// two tiles cannot disagree (spec §7.3).
400    pub fn set_wall(&mut self, x: u8, y: u8, dir: Direction, present: bool) {
401        assert!(
402            self.in_bounds(i32::from(x), i32::from(y)),
403            "wall off the board"
404        );
405        self.set_edge(x as usize, y as usize, dir, present);
406    }
407
408    pub fn set_tile(&mut self, x: u8, y: u8, kind: TileKind) {
409        assert!(
410            self.in_bounds(i32::from(x), i32::from(y)),
411            "tile off the board"
412        );
413        if let TileKind::Castle(owner) = kind {
414            assert!(seat(owner).is_some(), "invalid castle owner");
415        }
416        if let TileKind::Spawner(s) = kind {
417            assert!(s.period > 0, "spawner period must be at least 1 tick");
418        }
419        let t = self.index(i32::from(x), i32::from(y));
420        self.grid.tiles[t as usize] = kind;
421    }
422
423    // --- the player's one verb -------------------------------------------
424
425    /// Let gulls into the castles, or keep them out. See `castle_raids`.
426    pub fn set_castle_raids(&mut self, raids: bool) {
427        self.rules.castle_raids = raids;
428    }
429
430    pub fn castle_raids(&self) -> bool {
431        self.rules.castle_raids
432    }
433
434    /// Change the signpost cap and what happens at it. Versus keeps the
435    /// default (3, evict-oldest); puzzle mode sets (inventory, reject).
436    pub fn set_signpost_rule(&mut self, cap: u8, policy: CapPolicy) {
437        self.rules.signpost_cap = cap;
438        self.rules.cap_policy = policy;
439    }
440
441    /// Auto-spawn a gull every `period` ticks (0 disables). Doubled during
442    /// the final-scramble surge of a timed round.
443    pub fn set_gull_period(&mut self, period: u32) {
444        self.rules.gull_period = period;
445    }
446
447    pub fn set_round_length(&mut self, ticks: Option<u32>) {
448        self.rules.round_length = ticks;
449    }
450
451    /// Open (or close) the board edges. Opening removes the border walls so
452    /// creatures walk and fly off one side and re-enter on the opposite one.
453    pub fn set_wrap(&mut self, wrap: bool) {
454        self.wrap = wrap;
455        let (w, h) = (self.grid.width as usize, self.grid.height as usize);
456        for x in 0..w {
457            self.grid.h_walls[x] = !wrap;
458            self.grid.h_walls[h * w + x] = !wrap;
459        }
460        for y in 0..h {
461            self.grid.v_walls[y * (w + 1)] = !wrap;
462            self.grid.v_walls[y * (w + 1) + w] = !wrap;
463        }
464    }
465
466    pub fn wrap(&self) -> bool {
467        self.wrap
468    }
469
470    /// Enable tide events (the sparkling crab's roulette). Off by default:
471    /// puzzles and goal-checked challenges stay predictable.
472    pub fn events_enabled(&self) -> bool {
473        self.events_enabled
474    }
475
476    pub fn set_events_enabled(&mut self, enabled: bool) {
477        self.events_enabled = enabled;
478    }
479
480    /// The most recent tide event and when it fired.
481    pub fn last_event(&self) -> Option<(TideEvent, u64)> {
482        self.last_event
483    }
484
485    pub fn golden_banked(&self) -> u32 {
486        self.golden_banked
487    }
488
489    /// Preload a score (editor, sandboxes, tests). Not used by live play:
490    /// scores otherwise change only through banking and gull raids. A seat
491    /// that does not exist takes no score: the callers that parse untrusted
492    /// text already refuse it with a message, and this is the backstop.
493    pub fn set_score(&mut self, player: PlayerId, score: u32) {
494        if let Some(seat) = seat(player) {
495            self.scores[seat] = score;
496        }
497    }
498
499    // --- simulation ------------------------------------------------------
500
501    /// Advance one fixed 30 Hz step. Order within a tick is fixed and part of
502    /// the ruleset: player actions (in the tick's rotating seat order; on a
503    /// same-tile conflict, whoever that order reaches first wins), then
504    /// spawners, then crab movement, then gull movement, then gulls eat, in
505    /// stable creature order throughout. A signpost placed this tick affects
506    /// crabs arriving this tick. Once the tide is in (`round_over`), the sim
507    /// is frozen and ticks are no-ops: scores locked at the wave (spec §3.6).
508    pub fn tick(&mut self, actions: &[PlayerAction; MAX_PLAYERS]) {
509        if self.round_over() {
510            return;
511        }
512        // Before anything that could spin the roulette, so an event
513        // firing this tick gets its whole cooldown rather than one tick
514        // less than it.
515        self.event_cooldown = self.event_cooldown.saturating_sub(1);
516        // And before anything that could add to it. Kept for a few ticks
517        // rather than cleared outright: the only reader runs between
518        // frames, and a frame can cover more than one tick.
519        let tick = self.tick;
520        self.swept_home
521            .retain(|swept| swept.at + SWEEP_MEMORY > tick);
522        for player in self.action_order() {
523            self.apply_action(player, actions[player as usize]);
524        }
525        self.expire_signposts();
526        self.run_spawners();
527        self.run_gull_spawner();
528        self.move_crabs();
529        self.move_gulls();
530        self.gulls_eat();
531        if let Some((_, ticks)) = &mut self.lure {
532            *ticks -= 1;
533            if *ticks == 0 {
534                self.lure = None;
535                self.lure_cooldown = LURE_COOLDOWN;
536            }
537        } else {
538            self.lure_cooldown = self.lure_cooldown.saturating_sub(1);
539        }
540        if let Some((_, ticks)) = &mut self.mania {
541            *ticks -= 1;
542            if *ticks == 0 {
543                self.mania = None;
544            }
545        }
546        if let Some((_, ticks)) = &mut self.tempo {
547            *ticks -= 1;
548            if *ticks == 0 {
549                self.tempo = None;
550            }
551        }
552        self.tick += 1;
553    }
554
555    /// The seat order this tick's actions are applied in: one seat leads,
556    /// then round the table.
557    ///
558    /// Balance: two players reaching for the same tile on the same tick
559    /// cannot both have it, so the lead rotates rather than always falling to
560    /// the lowest seat. It rotates over the seats *in play*, not the width of
561    /// the array, or the lead lands on seats that do not exist and the real
562    /// ones keep their relative order.
563    ///
564    /// The lead is a small mix of the tick rather than `tick % seats`,
565    /// because the things that act on a schedule act on multiples of four and
566    /// a plain modulo would hand every bot decision to one seat. Still a pure
567    /// function of the tick, so lockstep peers and replays agree.
568    fn action_order(&self) -> [PlayerId; MAX_PLAYERS] {
569        let seats = u64::from(self.seats_in_play()).max(1);
570        let first = (self.tick ^ (self.tick >> 3)) % seats;
571        std::array::from_fn(|i| {
572            let i = i as u64;
573            if i < seats {
574                ((first + i) % seats) as PlayerId
575            } else {
576                i as PlayerId // absent seats, in any order: they act on nothing
577            }
578        })
579    }
580
581    /// How many seats this board seats: one past the highest castle owner, so
582    /// a four-castle beach rotates ties among four however wide the arrays.
583    pub fn seats_in_play(&self) -> u8 {
584        self.castle_owners().max().map_or(0, |owner| owner + 1)
585    }
586
587    /// The tide has come in: the round is finished and the sim is frozen.
588    pub fn round_over(&self) -> bool {
589        self.rules
590            .round_length
591            .is_some_and(|len| self.tick >= u64::from(len))
592    }
593
594    /// Ticks left before the wave, if a round timer is set.
595    pub fn remaining_ticks(&self) -> Option<u64> {
596        self.rules
597            .round_length
598            .map(|len| u64::from(len).saturating_sub(self.tick))
599    }
600
601    /// The final scramble: the last 30 s of a timed round, when gulls spawn
602    /// at double rate.
603    pub fn in_surge(&self) -> bool {
604        self.remaining_ticks()
605            .is_some_and(|left| left <= u64::from(SURGE_TICKS))
606    }
607
608    /// Advance one step with no player input.
609    pub fn tick_idle(&mut self) {
610        self.tick(&[PlayerAction::None; MAX_PLAYERS]);
611    }
612
613    fn apply_action(&mut self, player: PlayerId, action: PlayerAction) {
614        match action {
615            PlayerAction::None => {}
616            PlayerAction::Place { x, y, dir } => {
617                let _ = self.place_signpost(player, x, y, dir);
618            }
619            PlayerAction::Remove { x, y } => {
620                let _ = self.remove_signpost(player, x, y);
621            }
622        }
623    }
624
625    /// A walker's step this tick: the tempo-adjusted speed, halved (never
626    /// to zero) while standing in a tide pool. One statement of the wading
627    /// rule, for crabs and gulls both.
628    fn walk_step(&self, tile: u16, base: u16) -> u16 {
629        debug_assert!(
630            usize::from(tile) < self.grid.tiles.len(),
631            "walking off the board: tile {tile} of {}",
632            self.grid.tiles.len()
633        );
634        let step = self.tempo_speed(base);
635        if self.grid.tiles[tile as usize] == TileKind::Pool {
636            return (step / 2).max(1);
637        }
638        step
639    }
640
641    /// Tide-event tempo: doubled or halved speed for every creature.
642    fn tempo_speed(&self, base: u16) -> u16 {
643        match self.tempo {
644            Some((Tempo::Fast, _)) => base * 2,
645            Some((Tempo::Slow, _)) => (base / 2).max(1),
646            None => base,
647        }
648    }
649
650    // --- read access (render layer, modes, tests) ------------------------
651
652    pub fn width(&self) -> u8 {
653        self.grid.width
654    }
655
656    pub fn height(&self) -> u8 {
657        self.grid.height
658    }
659
660    pub fn ticks(&self) -> u64 {
661        self.tick
662    }
663
664    pub fn crabs(&self) -> &[Crab] {
665        &self.crabs
666    }
667
668    pub fn gulls(&self) -> &[Gull] {
669        &self.gulls
670    }
671
672    pub fn gull_period(&self) -> u32 {
673        self.rules.gull_period
674    }
675
676    pub fn round_length(&self) -> Option<u32> {
677        self.rules.round_length
678    }
679
680    /// The seed this board was constructed with.
681    pub fn seed(&self) -> u64 {
682        self.seed
683    }
684
685    /// Remove every crab standing on tile `(x, y)` (editor use).
686    pub fn remove_crabs_at(&mut self, x: u8, y: u8) {
687        assert!(
688            self.in_bounds(i32::from(x), i32::from(y)),
689            "crab off the board"
690        );
691        let tile = self.index(i32::from(x), i32::from(y));
692        self.crabs.retain(|c| c.tile != tile);
693    }
694
695    /// Remove every gull standing on tile `(x, y)` (editor use).
696    pub fn remove_gulls_at(&mut self, x: u8, y: u8) {
697        assert!(
698            self.in_bounds(i32::from(x), i32::from(y)),
699            "gull off the board"
700        );
701        let tile = self.index(i32::from(x), i32::from(y));
702        self.gulls.retain(|g| g.tile != tile);
703    }
704
705    /// Active molting lure, if any: (luring player, ticks left).
706    pub fn lure(&self) -> Option<(PlayerId, u32)> {
707        self.lure
708    }
709
710    /// Crabs banked since the start, all players combined.
711    pub fn crabs_banked(&self) -> u32 {
712        self.crabs_banked
713    }
714
715    /// The seat a crab was banked *for* by the tide, if it was one of
716    /// those rather than one that walked home.
717    ///
718    /// The render layer's question, and the only reason [`Board`] keeps
719    /// the record at all: a crab that vanished off open sand looks eaten
720    /// from the outside, and half the time a `Monopoly` fires it is the
721    /// opposite of eaten.
722    pub fn swept_home(&self, crab: u32) -> Option<PlayerId> {
723        self.swept_home
724            .iter()
725            .find(|swept| swept.crab == crab)
726            .map(|swept| swept.owner)
727    }
728
729    /// Crabs ever spawned (initial, spawner-emitted, and castle-spilled).
730    pub fn crabs_spawned(&self) -> u32 {
731        self.next_crab_id
732    }
733
734    pub fn scores(&self) -> &[u32; MAX_PLAYERS] {
735        &self.scores
736    }
737
738    pub fn tile_at(&self, x: u8, y: u8) -> TileKind {
739        assert!(self.in_bounds(i32::from(x), i32::from(y)));
740        self.grid.tiles[self.index(i32::from(x), i32::from(y)) as usize]
741    }
742
743    /// Every tile with its coordinates, in the board's own row-major order.
744    pub fn tiles(&self) -> impl Iterator<Item = (u8, u8, TileKind)> + '_ {
745        let width = self.grid.width;
746        self.grid
747            .tiles
748            .iter()
749            .enumerate()
750            .map(move |(index, &kind)| {
751                (
752                    (index % width as usize) as u8,
753                    (index / width as usize) as u8,
754                    kind,
755                )
756            })
757    }
758
759    /// Where a seat's castle stands, if it has one.
760    pub fn castle_of(&self, player: PlayerId) -> Option<(u8, u8)> {
761        self.tiles()
762            .find(|&(_, _, kind)| kind == TileKind::Castle(player))
763            .map(|(x, y, _)| (x, y))
764    }
765
766    /// The highest seat number with a castle on the board: the seat count
767    /// a recorded board implies.
768    pub fn castle_owners(&self) -> impl Iterator<Item = PlayerId> + '_ {
769        // Over the tiles themselves rather than `tiles()`, which pays a
770        // divide and a remainder per tile to name coordinates this throws
771        // away. `seats_in_play` runs this on every tick, and the solver
772        // ticks a board up to `PUZZLE_TICK_LIMIT` times per node.
773        self.grid.tiles.iter().filter_map(|kind| match kind {
774            TileKind::Castle(owner) => Some(*owner),
775            TileKind::Empty
776            | TileKind::Rock
777            | TileKind::Spawner(_)
778            | TileKind::Turnstile { .. }
779            | TileKind::Kelp
780            | TileKind::Pool => None,
781        })
782    }
783
784    /// How many seats the board has castles for, counted by owner rather
785    /// than by castle: a beach seats as many players as there are banks to
786    /// run for. What the map dial measures a handmade beach against.
787    pub fn castle_seats(&self) -> u8 {
788        let mut seen = [false; MAX_PLAYERS];
789        for owner in self.castle_owners() {
790            if let Some(slot) = seen.get_mut(usize::from(owner)) {
791                *slot = true;
792            }
793        }
794        seen.iter().filter(|held| **held).count() as u8
795    }
796
797    /// The first signpost owned by `player` in reading order (top-left to
798    /// bottom-right), as tile coordinates. Drives the "clear one" input.
799    pub fn first_signpost_of(&self, player: PlayerId) -> Option<(u8, u8)> {
800        self.signposts
801            .iter()
802            .position(|post| post.is_some_and(|post| post.owner == player))
803            .map(|index| self.coords_u8(index as u16))
804    }
805
806    /// Tile index back to coordinates. [`Board::coords`] speaks `i32` for
807    /// the movement arithmetic; this is the public-facing form.
808    pub fn coords_u8(&self, tile: u16) -> (u8, u8) {
809        let (x, y) = self.coords(tile);
810        (x as u8, y as u8)
811    }
812
813    pub fn wall_at(&self, x: u8, y: u8, dir: Direction) -> bool {
814        assert!(self.in_bounds(i32::from(x), i32::from(y)));
815        self.edge_blocked(x as usize, y as usize, dir)
816    }
817}
818
819mod crabs;
820mod events;
821mod geometry;
822mod gulls;
823mod hashing;
824mod signposts;
825mod snapshot;
826#[cfg(test)]
827mod tests;
828
829pub use events::{Mania, Tempo, TideEvent};
830
831pub use geometry::Grid;
832pub(crate) use geometry::Walker;