pinch-points 0.5.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before the tide comes in
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
use crate::sim::crab::{Crab, CrabKind, Handedness};
use crate::sim::direction::Direction;
use crate::sim::gull::{
    EAT_RANGE, FLIGHT_MAX, FLIGHT_MIN, GULL_FLY_SPEED, GULL_WALK_SPEED, Gull, GullState,
    TAKEOFF_MAX, TAKEOFF_MIN,
};
use crate::sim::hash::Fnv;
use crate::sim::pose::Pose;
use crate::sim::rng::Pcg32;

/// Castle tier thresholds by banked score (spec §3.4).
pub const TIER_FLOORS: [u32; 4] = [0, 10, 25, 50];
/// Most live crabs a single castle hit can spill back onto the sand. The
/// score still drops a full tier (spec §3.4's legible loss); crabs beyond the
/// cap are lost to the flock rather than flooding the board.
pub const SPILL_CAP: u32 = 8;
/// Molting-crab lure duration: 10 s at 30 Hz (spec §3.2).
pub const LURE_TICKS: u32 = 300;
/// Quiet spell after a lure ends before another may start: 20 s, twice the
/// lure itself.
///
/// Balance: the lure pulls *every* loose crab to one castle, and the crabs it
/// delivers include the next molting crab, so an unguarded lure re-arms
/// itself. Non-stacking plus a cooldown stops the first molt from deciding
/// the round.
///
/// At 10 s, matching the lure, a measured six-player round ran lures 22.9,
/// 25.8, 27.2 and 23.3 s apart with one castle holding 50 of the 60 lured
/// seconds. Doubling the spell puts the floor at 30 s and the uptime
/// ceiling at a third of a round rather than half.
pub const LURE_COOLDOWN: u32 = 600;
/// Ticks after any tide event before the roulette may spin again.
///
/// The roulette fires when a Sparkling crab banks, and Sparkling is 2% of
/// what the *spawners* produce, so the events that raise the crab supply
/// raise their own rate: Crab Mania floods the spawners and Speed Up banks
/// everything faster, and both spin the wheel that rolled them. Unvalved,
/// the seven kept rounds ran from nought events in three minutes to twelve,
/// the worst putting six inside nineteen seconds.
///
/// One [`EVENT_TICKS`] is exactly "no second event until the first has
/// finished", the same valve [`LURE_COOLDOWN`] puts on the other
/// self-feeding mechanic. Re-run through it (`cargo run --example
/// round_events`), no event in any kept round lands inside another's
/// window.
pub const EVENT_COOLDOWN: u32 = EVENT_TICKS;
/// Versus signposts fade away after this many ticks (10 s, the original's
/// balance valve against stale fortifications). Puzzle-rule boards
/// (CapPolicy::Reject) keep posts forever: a fixed inventory implies
/// permanence.
pub const SIGNPOST_LIFETIME: u32 = 300;
/// The canonical simulation rate. Everything that converts ticks to
/// seconds (round lengths, clocks, speed settings) goes through this.
pub const TICKS_PER_SECOND: u32 = 30;
/// Tide-event durations (manias, tempo shifts): 10 s.
pub const EVENT_TICKS: u32 = 300;
/// The final-scramble threshold: with a round timer set, gull spawning
/// doubles in rate when this many ticks remain (spec §3.6: 30 s).
pub const SURGE_TICKS: u32 = 900;

/// Castle tier (0–3) for a banked-crab score.
pub fn castle_tier(score: u32) -> u8 {
    match score {
        0..=9 => 0,
        10..=24 => 1,
        25..=49 => 2,
        _ => 3,
    }
}

/// A creature's sub-tile offset from its tile centre, in subunits, as signed
/// screen-space (x right, y down) components.
fn sub_offset(dir: Direction, progress: u16) -> (i32, i32) {
    let (dx, dy) = dir.offset();
    (dx * i32::from(progress), dy * i32::from(progress))
}

pub type PlayerId = u8;

/// A player id as a seat index, if it names a real seat: the one spelling
/// of the bounds check every reader of an untrusted seat goes through.
pub fn seat(player: PlayerId) -> Option<usize> {
    let seat = usize::from(player);
    (seat < MAX_PLAYERS).then_some(seat)
}

/// The ring around a castle, nearest first: the four edge-adjacent tiles
/// in fixed order, then the diagonals. The fixed order keeps every ring
/// walk deterministic, spilled crabs and besieging gulls alike.
pub(crate) const CASTLE_RING: [(i32, i32); 8] = [
    (0, -1),
    (1, 0),
    (0, 1),
    (-1, 0),
    (1, -1),
    (1, 1),
    (-1, 1),
    (-1, -1),
];

/// Seats a board can hold. Six is the current cut: four corners and two
/// long-edge castles on a generated arena (see
/// [`castle_spots`](crate::sim::castle_spots)). The handcrafted classic
/// arena is a four-castle beach and stays one.
pub const MAX_PLAYERS: usize = 6;
/// Spec §3.3: placing a fourth signpost removes that player's oldest.
pub const MAX_SIGNPOSTS_PER_PLAYER: usize = 3;
/// Balance: the ambient gull spawner pauses while this many gulls are on
/// the beach. Tide events ignore the cap on purpose.
pub const GULL_CAP: usize = 6;
/// Balance: the ambient crab spawners pause once live crabs reach this
/// fraction of the board's tiles. Past it the beach is a carpet rather than
/// a puzzle. Crab Mania ignores the cap, the way Gull Mania ignores
/// [`GULL_CAP`].
pub const CRAB_CAP_TILES_PER_CRAB: usize = 3;
/// Spec §4.2: one tile is 256 subunits; all movement is integer arithmetic.
pub const SUBUNITS_PER_TILE: u16 = 256;

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TileKind {
    Empty,
    /// Impassable. Modelled as a tile no creature may enter rather than as
    /// four walls, so level authoring can't desync rock and wall data.
    Rock,
    Castle(PlayerId),
    Spawner(Spawner),
    /// A pivoting driftwood log: deflects each crossing creature to its
    /// right or left alternately, flipping after every crossing. A
    /// deterministic 50/50 stream splitter with no PRNG draw.
    Turnstile {
        next_right: bool,
    },
    /// Seaweed: crabs slip through, but walking gulls are blocked and
    /// flying gulls cannot land here (they glide one more tile).
    Kelp,
    /// Shallow water: creatures standing in it move at half speed.
    Pool,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Spawner {
    /// Direction emitted crabs initially face.
    pub dir: Direction,
    /// A crab spawns every `period` ticks, starting on tick 0.
    pub period: u32,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SignpostHealth {
    Full,
    /// One walking-gull crossing away from destruction.
    Worn,
}

#[derive(Clone, Copy, Debug)]
pub struct Signpost {
    pub dir: Direction,
    pub owner: PlayerId,
    pub health: SignpostHealth,
    /// Monotonic placement counter, used to find the owner's oldest signpost
    /// when the cap evicts one.
    seq: u64,
    /// Tick this signpost was placed (or re-pointed); drives expiry under
    /// versus rules and the render-side fade.
    pub placed: u64,
}

/// One player's input for one tick. Coordinates are the cursor's tile. The
/// wire packs this into 2 bytes (spec §7.6); see [`crate::transport`].
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum PlayerAction {
    #[default]
    None,
    Place {
        x: u8,
        y: u8,
        dir: Direction,
    },
    Remove {
        x: u8,
        y: u8,
    },
    /// The spectators called a tide event, relayed by the host.
    ///
    /// An action rather than a message of its own, and that is the whole
    /// point: a peer cannot simulate a frame without every seat's input
    /// for it, so an event carried this way arrives with the frame it
    /// belongs to or the frame does not run. A side channel would have
    /// been echoed and hoped for, and a peer that missed every echo would
    /// have played a different round without ever knowing.
    CallEvent(TideEvent),
}

/// What happens when a player places a signpost at their cap.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum CapPolicy {
    /// Versus rules (spec §3.3): the player's oldest signpost is removed.
    Evict,
    /// Puzzle rules (spec §5.1): the placement fails, fixed inventory.
    Reject,
}

impl CapPolicy {
    /// The level-format token; `from_token` is its inverse.
    pub fn token(self) -> &'static str {
        match self {
            CapPolicy::Evict => "evict",
            CapPolicy::Reject => "reject",
        }
    }

    pub fn from_token(token: &str) -> Option<CapPolicy> {
        match token {
            "evict" => Some(CapPolicy::Evict),
            "reject" => Some(CapPolicy::Reject),
            _ => None,
        }
    }
}

/// The rules a board is built with: the signpost cap and what happens at
/// it, the ambient gull rate, the tide, and whether gulls may raid. Set
/// by the level or the lobby before the first tick and, apart from the
/// surge doubling of the gull rate, untouched by play. Hashed and saved
/// like the rest of the board: peers on different rules would desync.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Rules {
    pub signpost_cap: u8,
    pub cap_policy: CapPolicy,
    /// Auto-spawn a gull at a PRNG edge tile every this many ticks; 0 = off.
    pub gull_period: u32,
    /// Round length in ticks (the tide, spec §3.6). None = untimed. When it
    /// reaches zero the sim freezes: scores are locked at the wave.
    pub round_length: Option<u32>,
    /// Whether a gull reaching a castle carries off half its bank
    /// (spec §3.4). True on a versus beach, where robbing a rival is the
    /// point.
    ///
    /// False in a puzzle, where a raid has nothing to take and one bad
    /// side effect: the spill puts banked crabs back on the sand, each
    /// counting as newly spawned, which is the denominator of "Saved a/b".
    /// Nine shipped campaign levels grew their own target that way while
    /// playing their authored solution.
    ///
    /// The gull still walks in and still leaves the beach with the flock.
    /// That departure is load-bearing: a castle is the one tile that takes
    /// a gull off the sand, and shutting the door instead left Two Giants
    /// with no two-post solution at all.
    pub castle_raids: bool,
}

/// The entire game state. No engine types, no floats, no hash maps: every
/// tick is a pure function of prior state plus one action per seat, so the
/// same seed and input list replays bit-identically on any platform.
#[derive(Clone, Debug)]
pub struct Board {
    /// The beach itself: size, walls, tiles.
    grid: Grid,
    /// Construction seed, kept for serialization (`Level::to_text`). Not
    /// hashed: the live PRNG state, which is hashed, derives from it.
    seed: u64,
    signposts: Vec<Option<Signpost>>,
    /// The rules this board plays by, set at construction.
    rules: Rules,
    crabs: Vec<Crab>,
    scores: [u32; MAX_PLAYERS],
    rng: Pcg32,
    tick: u64,
    signpost_seq: u64,
    next_crab_id: u32,
    gulls: Vec<Gull>,
    next_gull_id: u32,
    /// Active molting-crab lure: all loose crabs path toward this player's
    /// castle for the remaining ticks (spec §3.2).
    lure: Option<(PlayerId, u32)>,
    /// Ticks until another lure may start; set when one ends.
    lure_cooldown: u32,
    /// Ticks until the tide roulette may spin again; set when any event
    /// fires. See [`EVENT_COOLDOWN`].
    event_cooldown: u32,
    /// Crabs banked since the start, all players combined. With
    /// `next_crab_id` (crabs ever spawned) this distinguishes "every crab is
    /// safe" from "the gulls got some" (spec §5.1 win condition).
    crabs_banked: u32,
    /// Golden crabs banked (challenge goals).
    golden_banked: u32,
    /// Tide events fire only where enabled (versus arenas, the attract
    /// beach), never in puzzles or goal-checked challenges.
    events_enabled: bool,
    /// Active spawn mania: spawners flood crabs or emit gulls instead.
    mania: Option<(Mania, u32)>,
    /// Active tempo shift and the ticks left of it.
    tempo: Option<(Tempo, u32)>,
    /// Ticks left of a Right Claws event, zero when none is running.
    ///
    /// A bare count rather than an `Option`, because unlike a mania or a
    /// tempo shift there is nothing to say but how long is left: the claw
    /// it favours never changes.
    claw_call: u32,
    /// The most recent tide event and the tick it fired (HUD banner).
    last_event: Option<(TideEvent, u64)>,
    /// Open edges: creatures walking (or flying) off one side re-enter on
    /// the opposite side (spec §3.1 wrap-around, settled by research: the
    /// original wraps).
    wrap: bool,
    /// Sparkling banks noticed during crab movement; the roulette spins
    /// after the movement pass so events may safely mutate the crab list.
    /// Always drained within the same tick (never hashed).
    event_queue: Vec<PlayerId>,
    /// Crabs the tide put straight into a keep, and who got them.
    ///
    /// `Monopoly` banks crabs where they stand rather than walking them
    /// home, so afterwards nothing on the board says what became of them:
    /// they are simply gone, from tiles that are not castles. The render
    /// layer tells a bank from a death by reading the tile a departed crab
    /// left, so a sweep of eighty-two crabs read as eighty-two deaths,
    /// with no sign that anybody scored.
    ///
    /// Nothing in here is ever read back by the sim, which is why it stays
    /// out of the fingerprint: it cannot make two peers play differently,
    /// and hashing it would only make this build disagree with builds it
    /// is playing identically to.
    swept_home: Vec<Swept>,
}

/// One crab the tide banked, for the render layer to find.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Swept {
    crab: u32,
    owner: PlayerId,
    /// The tick it happened on. The reader runs between frames and a frame
    /// can span several ticks - most likely the frame straight after a
    /// sweep, which is the busiest one the renderer ever draws - so the
    /// record has to outlive its own tick to be read at all.
    at: u64,
}

/// How many ticks a [`Swept`] record is kept before it is pruned: long
/// enough that a frame running several ticks behind still finds it, short
/// enough that the list is a handful of entries and never a round's worth.
const SWEEP_MEMORY: u64 = 8;

/// Replace `dst`'s contents with `src`'s, keeping `dst`'s buffer. The one
/// move [`Board::copy_from`] is made of.
fn refill<T: Copy>(dst: &mut Vec<T>, src: &[T]) {
    dst.clear();
    dst.extend_from_slice(src);
}

impl Board {
    /// An empty all-sand board with walled borders (spec §3.1; wrap-around
    /// edges are a later, flag-gated variant).
    pub fn new(width: u8, height: u8, seed: u64) -> Board {
        assert!(width > 0 && height > 0, "board must be at least 1×1");
        let (w, h) = (width as usize, height as usize);
        let mut board = Board {
            grid: Grid {
                width,
                height,
                h_walls: vec![false; (h + 1) * w],
                v_walls: vec![false; h * (w + 1)],
                tiles: vec![TileKind::Empty; w * h],
            },
            seed,
            signposts: vec![None; w * h],
            rules: Rules {
                signpost_cap: MAX_SIGNPOSTS_PER_PLAYER as u8,
                cap_policy: CapPolicy::Evict,
                gull_period: 0,
                round_length: None,
                castle_raids: true,
            },
            crabs: Vec::new(),
            scores: [0; MAX_PLAYERS],
            rng: Pcg32::new(seed, 0x0005_eaba_55ed),
            tick: 0,
            signpost_seq: 0,
            next_crab_id: 0,
            gulls: Vec::new(),
            next_gull_id: 0,
            lure: None,
            lure_cooldown: 0,
            event_cooldown: 0,
            crabs_banked: 0,
            golden_banked: 0,
            events_enabled: false,
            mania: None,
            tempo: None,
            claw_call: 0,
            last_event: None,
            wrap: false,
            event_queue: Vec::new(),
            swept_home: Vec::new(),
        };
        // The border walls are the wrap rule with wrap off; the border
        // indices are stated once, in set_wrap.
        board.set_wrap(false);
        board
    }

    /// Become an exact copy of `other`, reusing the buffers this board
    /// already holds instead of allocating fresh ones.
    ///
    /// `*self = other.clone()` is the same value and costs one allocation
    /// per non-empty `Vec`, which is a poor bargain for a caller that makes
    /// a copy per node of a search and drops it a moment later
    /// (`sim::solve`). `Clone::clone_from` is not available: the derive does
    /// not forward it to the fields, so a derived one allocates as much as
    /// `clone`.
    ///
    /// Every field is named rather than waved through with `..`, so a new
    /// field on `Board` fails to compile here instead of quietly going
    /// uncopied and letting the copy play differently.
    pub(crate) fn copy_from(&mut self, other: &Self) {
        let Self {
            grid,
            seed,
            signposts,
            rules,
            crabs,
            scores,
            rng,
            tick,
            signpost_seq,
            next_crab_id,
            gulls,
            next_gull_id,
            lure,
            lure_cooldown,
            event_cooldown,
            crabs_banked,
            golden_banked,
            events_enabled,
            mania,
            tempo,
            claw_call,
            last_event,
            wrap,
            event_queue,
            swept_home,
        } = other;
        self.grid.copy_from(grid);
        self.seed = *seed;
        refill(&mut self.signposts, signposts);
        self.rules = *rules;
        refill(&mut self.crabs, crabs);
        self.scores = *scores;
        self.rng.clone_from(rng);
        self.tick = *tick;
        self.signpost_seq = *signpost_seq;
        self.next_crab_id = *next_crab_id;
        refill(&mut self.gulls, gulls);
        self.next_gull_id = *next_gull_id;
        self.lure = *lure;
        self.lure_cooldown = *lure_cooldown;
        self.event_cooldown = *event_cooldown;
        self.crabs_banked = *crabs_banked;
        self.golden_banked = *golden_banked;
        self.events_enabled = *events_enabled;
        self.mania = *mania;
        self.tempo = *tempo;
        self.claw_call = *claw_call;
        self.last_event = *last_event;
        self.wrap = *wrap;
        refill(&mut self.event_queue, event_queue);
        refill(&mut self.swept_home, swept_home);
    }

    // --- level authoring -------------------------------------------------

    /// Add or remove the wall on the `dir` side of tile `(x, y)`. Walls are
    /// stored per-edge, so the neighbouring tile sees the same wall and the
    /// two tiles cannot disagree (spec §7.3).
    pub fn set_wall(&mut self, x: u8, y: u8, dir: Direction, present: bool) {
        assert!(
            self.in_bounds(i32::from(x), i32::from(y)),
            "wall off the board"
        );
        self.set_edge(x as usize, y as usize, dir, present);
    }

    pub fn set_tile(&mut self, x: u8, y: u8, kind: TileKind) {
        assert!(
            self.in_bounds(i32::from(x), i32::from(y)),
            "tile off the board"
        );
        if let TileKind::Castle(owner) = kind {
            assert!(seat(owner).is_some(), "invalid castle owner");
        }
        if let TileKind::Spawner(s) = kind {
            assert!(s.period > 0, "spawner period must be at least 1 tick");
        }
        let t = self.index(i32::from(x), i32::from(y));
        self.grid.tiles[t as usize] = kind;
    }

    // --- the player's one verb -------------------------------------------

    /// Let gulls into the castles, or keep them out. See `castle_raids`.
    pub fn set_castle_raids(&mut self, raids: bool) {
        self.rules.castle_raids = raids;
    }

    pub fn castle_raids(&self) -> bool {
        self.rules.castle_raids
    }

    /// Change the signpost cap and what happens at it. Versus keeps the
    /// default (3, evict-oldest); puzzle mode sets (inventory, reject).
    pub fn set_signpost_rule(&mut self, cap: u8, policy: CapPolicy) {
        self.rules.signpost_cap = cap;
        self.rules.cap_policy = policy;
    }

    /// Auto-spawn a gull every `period` ticks (0 disables). Doubled during
    /// the final-scramble surge of a timed round.
    pub fn set_gull_period(&mut self, period: u32) {
        self.rules.gull_period = period;
    }

    pub fn set_round_length(&mut self, ticks: Option<u32>) {
        self.rules.round_length = ticks;
    }

    /// Open (or close) the board edges. Opening removes the border walls so
    /// creatures walk and fly off one side and re-enter on the opposite one.
    pub fn set_wrap(&mut self, wrap: bool) {
        self.wrap = wrap;
        let (w, h) = (self.grid.width as usize, self.grid.height as usize);
        for x in 0..w {
            self.grid.h_walls[x] = !wrap;
            self.grid.h_walls[h * w + x] = !wrap;
        }
        for y in 0..h {
            self.grid.v_walls[y * (w + 1)] = !wrap;
            self.grid.v_walls[y * (w + 1) + w] = !wrap;
        }
    }

    pub fn wrap(&self) -> bool {
        self.wrap
    }

    /// Enable tide events (the sparkling crab's roulette). Off by default:
    /// puzzles and goal-checked challenges stay predictable.
    pub fn events_enabled(&self) -> bool {
        self.events_enabled
    }

    pub fn set_events_enabled(&mut self, enabled: bool) {
        self.events_enabled = enabled;
    }

    /// The most recent tide event and when it fired.
    pub fn last_event(&self) -> Option<(TideEvent, u64)> {
        self.last_event
    }

    pub fn golden_banked(&self) -> u32 {
        self.golden_banked
    }

    /// Preload a score (editor, sandboxes, tests). Not used by live play:
    /// scores otherwise change only through banking and gull raids. A seat
    /// that does not exist takes no score: the callers that parse untrusted
    /// text already refuse it with a message, and this is the backstop.
    pub fn set_score(&mut self, player: PlayerId, score: u32) {
        if let Some(seat) = seat(player) {
            self.scores[seat] = score;
        }
    }

    // --- simulation ------------------------------------------------------

    /// Advance one fixed 30 Hz step. Order within a tick is fixed and part of
    /// the ruleset: player actions (in the tick's rotating seat order; on a
    /// same-tile conflict, whoever that order reaches first wins), then
    /// spawners, then crab movement, then gull movement, then gulls eat, in
    /// stable creature order throughout. A signpost placed this tick affects
    /// crabs arriving this tick. Once the tide is in (`round_over`), the sim
    /// is frozen and ticks are no-ops: scores locked at the wave (spec §3.6).
    pub fn tick(&mut self, actions: &[PlayerAction; MAX_PLAYERS]) {
        if self.round_over() {
            return;
        }
        // Before anything that could spin the roulette, so an event
        // firing this tick gets its whole cooldown rather than one tick
        // less than it.
        self.event_cooldown = self.event_cooldown.saturating_sub(1);
        // And before anything that could add to it. Kept for a few ticks
        // rather than cleared outright: the only reader runs between
        // frames, and a frame can cover more than one tick.
        let tick = self.tick;
        self.swept_home
            .retain(|swept| swept.at + SWEEP_MEMORY > tick);
        for player in self.action_order() {
            self.apply_action(player, actions[player as usize]);
        }
        self.expire_signposts();
        self.run_spawners();
        self.run_gull_spawner();
        self.move_crabs();
        self.move_gulls();
        self.gulls_eat();
        if let Some((_, ticks)) = &mut self.lure {
            *ticks -= 1;
            if *ticks == 0 {
                self.lure = None;
                self.lure_cooldown = LURE_COOLDOWN;
            }
        } else {
            self.lure_cooldown = self.lure_cooldown.saturating_sub(1);
        }
        if let Some((_, ticks)) = &mut self.mania {
            *ticks -= 1;
            if *ticks == 0 {
                self.mania = None;
            }
        }
        self.claw_call = self.claw_call.saturating_sub(1);
        if let Some((_, ticks)) = &mut self.tempo {
            *ticks -= 1;
            if *ticks == 0 {
                self.tempo = None;
            }
        }
        self.tick += 1;
    }

    /// The seat order this tick's actions are applied in: one seat leads,
    /// then round the table.
    ///
    /// Balance: two players reaching for the same tile on the same tick
    /// cannot both have it, so the lead rotates rather than always falling to
    /// the lowest seat. It rotates over the seats *in play*, not the width of
    /// the array, or the lead lands on seats that do not exist and the real
    /// ones keep their relative order.
    ///
    /// The lead is a small mix of the tick rather than `tick % seats`,
    /// because the things that act on a schedule act on multiples of four and
    /// a plain modulo would hand every bot decision to one seat. Still a pure
    /// function of the tick, so lockstep peers and replays agree.
    fn action_order(&self) -> [PlayerId; MAX_PLAYERS] {
        let seats = u64::from(self.seats_in_play()).max(1);
        let first = (self.tick ^ (self.tick >> 3)) % seats;
        std::array::from_fn(|i| {
            let i = i as u64;
            if i < seats {
                ((first + i) % seats) as PlayerId
            } else {
                i as PlayerId // absent seats, in any order: they act on nothing
            }
        })
    }

    /// How many seats this board seats: one past the highest castle owner, so
    /// a four-castle beach rotates ties among four however wide the arrays.
    pub fn seats_in_play(&self) -> u8 {
        self.castle_owners().max().map_or(0, |owner| owner + 1)
    }

    /// The tide has come in: the round is finished and the sim is frozen.
    pub fn round_over(&self) -> bool {
        self.rules
            .round_length
            .is_some_and(|len| self.tick >= u64::from(len))
    }

    /// Ticks left before the wave, if a round timer is set.
    pub fn remaining_ticks(&self) -> Option<u64> {
        self.rules
            .round_length
            .map(|len| u64::from(len).saturating_sub(self.tick))
    }

    /// The final scramble: the last 30 s of a timed round, when gulls spawn
    /// at double rate.
    pub fn in_surge(&self) -> bool {
        self.remaining_ticks()
            .is_some_and(|left| left <= u64::from(SURGE_TICKS))
    }

    /// Advance one step with no player input.
    pub fn tick_idle(&mut self) {
        self.tick(&[PlayerAction::None; MAX_PLAYERS]);
    }

    fn apply_action(&mut self, player: PlayerId, action: PlayerAction) {
        match action {
            PlayerAction::None => {}
            PlayerAction::Place { x, y, dir } => {
                let _ = self.place_signpost(player, x, y, dir);
            }
            PlayerAction::Remove { x, y } => {
                let _ = self.remove_signpost(player, x, y);
            }
            // The spectators', not this seat's: the host carries it because
            // the host is the one every peer waits on anyway. It fires
            // whatever the wheel's own cooldown says, because their call is
            // not a spin of the wheel and should not be refused by one.
            //
            // Through `surge_safe` all the same. The swap it makes is not
            // the wheel's bookkeeping but a rule about the beach, measured:
            // Gull Mania on top of the surge leaves fifteen gulls and
            // almost no crabs with half a minute to play. A crowd calling
            // it down is the surge's worst case, not its exception.
            PlayerAction::CallEvent(event) => {
                self.force_tide_event(self.surge_safe(event), player);
            }
        }
    }

    /// Credit a banked crab to `owner`, under whatever the tide is asking
    /// for right now.
    ///
    /// Every bank goes through here, the ordinary walk into a castle and
    /// Crab Monopoly's sweep alike: the two used to add the crab's value
    /// each in their own line, and a rule that arrived later would have
    /// landed on one of them.
    ///
    /// Right Claws is that rule. While it runs, a right-clawed crab is
    /// worth twice what it says and a left-clawed one costs the same
    /// again, down to nothing and no further: a score that has gone
    /// negative is a player who has stopped playing, and this game is for
    /// children.
    pub(super) fn credit_bank(&mut self, owner: PlayerId, crab: &Crab) {
        let value = crab.kind.value();
        let score = &mut self.scores[owner as usize];
        if self.claw_call == 0 {
            *score += value;
            return;
        }
        match crab.handed {
            Handedness::Right => *score += value * 2,
            Handedness::Left => *score = score.saturating_sub(value),
        }
    }

    /// A walker's step this tick: the tempo-adjusted speed, halved (never
    /// to zero) while standing in a tide pool. One statement of the wading
    /// rule, for crabs and gulls both.
    fn walk_step(&self, tile: u16, base: u16) -> u16 {
        debug_assert!(
            usize::from(tile) < self.grid.tiles.len(),
            "walking off the board: tile {tile} of {}",
            self.grid.tiles.len()
        );
        let step = self.tempo_speed(base);
        if self.grid.tiles[tile as usize] == TileKind::Pool {
            return (step / 2).max(1);
        }
        step
    }

    /// Tide-event tempo: doubled or halved speed for every creature.
    fn tempo_speed(&self, base: u16) -> u16 {
        match self.tempo {
            Some((Tempo::Fast, _)) => base * 2,
            Some((Tempo::Slow, _)) => (base / 2).max(1),
            None => base,
        }
    }

    // --- read access (render layer, modes, tests) ------------------------

    pub fn width(&self) -> u8 {
        self.grid.width
    }

    pub fn height(&self) -> u8 {
        self.grid.height
    }

    pub fn ticks(&self) -> u64 {
        self.tick
    }

    pub fn crabs(&self) -> &[Crab] {
        &self.crabs
    }

    pub fn gulls(&self) -> &[Gull] {
        &self.gulls
    }

    pub fn gull_period(&self) -> u32 {
        self.rules.gull_period
    }

    pub fn round_length(&self) -> Option<u32> {
        self.rules.round_length
    }

    /// The seed this board was constructed with.
    pub fn seed(&self) -> u64 {
        self.seed
    }

    /// Remove every crab standing on tile `(x, y)` (editor use).
    pub fn remove_crabs_at(&mut self, x: u8, y: u8) {
        assert!(
            self.in_bounds(i32::from(x), i32::from(y)),
            "crab off the board"
        );
        let tile = self.index(i32::from(x), i32::from(y));
        self.crabs.retain(|c| c.tile != tile);
    }

    /// Remove every gull standing on tile `(x, y)` (editor use).
    pub fn remove_gulls_at(&mut self, x: u8, y: u8) {
        assert!(
            self.in_bounds(i32::from(x), i32::from(y)),
            "gull off the board"
        );
        let tile = self.index(i32::from(x), i32::from(y));
        self.gulls.retain(|g| g.tile != tile);
    }

    /// Active molting lure, if any: (luring player, ticks left).
    pub fn lure(&self) -> Option<(PlayerId, u32)> {
        self.lure
    }

    /// Whether Right Claws is running: a right-clawed crab banks double
    /// and a left-clawed one costs. Read by the bots, who would otherwise
    /// go on herding whatever is nearest and hand their points back.
    pub fn in_claw_call(&self) -> bool {
        self.claw_call > 0
    }

    /// What a crab is worth to whoever banks it right now, which is not
    /// always what its kind says. `None` for one that would cost.
    pub fn bank_worth(&self, crab: &Crab) -> Option<u32> {
        if !self.in_claw_call() {
            return Some(crab.kind.value());
        }
        match crab.handed {
            Handedness::Right => Some(crab.kind.value() * 2),
            Handedness::Left => None,
        }
    }

    /// Crabs banked since the start, all players combined.
    pub fn crabs_banked(&self) -> u32 {
        self.crabs_banked
    }

    /// The seat a crab was banked *for* by the tide, if it was one of
    /// those rather than one that walked home.
    ///
    /// The render layer's question, and the only reason [`Board`] keeps the
    /// record: a crab that vanished off open sand looks eaten from the
    /// outside.
    pub fn swept_home(&self, crab: u32) -> Option<PlayerId> {
        self.swept_home
            .iter()
            .find(|swept| swept.crab == crab)
            .map(|swept| swept.owner)
    }

    /// Crabs ever spawned (initial, spawner-emitted, and castle-spilled).
    pub fn crabs_spawned(&self) -> u32 {
        self.next_crab_id
    }

    pub fn scores(&self) -> &[u32; MAX_PLAYERS] {
        &self.scores
    }

    pub fn tile_at(&self, x: u8, y: u8) -> TileKind {
        assert!(self.in_bounds(i32::from(x), i32::from(y)));
        self.grid.tiles[self.index(i32::from(x), i32::from(y)) as usize]
    }

    /// Every tile with its coordinates, in the board's own row-major order.
    pub fn tiles(&self) -> impl Iterator<Item = (u8, u8, TileKind)> + '_ {
        let width = self.grid.width;
        self.grid
            .tiles
            .iter()
            .enumerate()
            .map(move |(index, &kind)| {
                (
                    (index % width as usize) as u8,
                    (index / width as usize) as u8,
                    kind,
                )
            })
    }

    /// Where a seat's castle stands, if it has one.
    pub fn castle_of(&self, player: PlayerId) -> Option<(u8, u8)> {
        self.tiles()
            .find(|&(_, _, kind)| kind == TileKind::Castle(player))
            .map(|(x, y, _)| (x, y))
    }

    /// The highest seat number with a castle on the board: the seat count
    /// a recorded board implies.
    pub fn castle_owners(&self) -> impl Iterator<Item = PlayerId> + '_ {
        // Over the tiles themselves rather than `tiles()`, which pays a
        // divide and a remainder per tile to name coordinates this throws
        // away. `seats_in_play` runs this on every tick, and the solver
        // ticks a board up to `PUZZLE_TICK_LIMIT` times per node.
        self.grid.tiles.iter().filter_map(|kind| match kind {
            TileKind::Castle(owner) => Some(*owner),
            TileKind::Empty
            | TileKind::Rock
            | TileKind::Spawner(_)
            | TileKind::Turnstile { .. }
            | TileKind::Kelp
            | TileKind::Pool => None,
        })
    }

    /// How many seats the board has castles for, counted by owner rather
    /// than by castle: a beach seats as many players as there are banks to
    /// run for. What the map dial measures a handmade beach against.
    pub fn castle_seats(&self) -> u8 {
        let mut seen = [false; MAX_PLAYERS];
        for owner in self.castle_owners() {
            if let Some(slot) = seen.get_mut(usize::from(owner)) {
                *slot = true;
            }
        }
        seen.iter().filter(|held| **held).count() as u8
    }

    /// The first signpost owned by `player` in reading order (top-left to
    /// bottom-right), as tile coordinates. Drives the "clear one" input.
    pub fn first_signpost_of(&self, player: PlayerId) -> Option<(u8, u8)> {
        self.signposts
            .iter()
            .position(|post| post.is_some_and(|post| post.owner == player))
            .map(|index| self.coords_u8(index as u16))
    }

    /// Tile index back to coordinates. [`Board::coords`] speaks `i32` for
    /// the movement arithmetic; this is the public-facing form.
    pub fn coords_u8(&self, tile: u16) -> (u8, u8) {
        let (x, y) = self.coords(tile);
        (x as u8, y as u8)
    }

    pub fn wall_at(&self, x: u8, y: u8, dir: Direction) -> bool {
        assert!(self.in_bounds(i32::from(x), i32::from(y)));
        self.edge_blocked(x as usize, y as usize, dir)
    }
}

mod crabs;
mod events;
mod geometry;
mod gulls;
mod hashing;
mod signposts;
mod snapshot;
#[cfg(test)]
mod tests;

pub use events::{Mania, Tempo, TideEvent};

pub use geometry::Grid;
pub(crate) use geometry::Walker;