pinch-points 0.8.0

A fast, kid-friendly crab-routing game: route streams of crabs into your sandcastle before time runs out
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
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
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;
/// What a called golden crab banks for (see [`Rules::golden_every`]).
pub const GOLDEN_CALLED_VALUE: u32 = 30;
/// Ticks a called golden crab is announced before it comes out.
pub const GOLDEN_NOTICE: u32 = 5 * TICKS_PER_SECOND;

/// A Crab Monopoly sweep under [`Rules::sweep_flight`], in ticks: how long
/// the picked crabs wait where they stand, how far apart they set off, and
/// how long each is in the air before it lands and banks.
///
/// The wait is the event's banner, which covers the middle of the board
/// for its first two seconds: the crabs leave as it starts to fade.
pub const SWEEP_HOLD: u64 = 66;
pub const SWEEP_STAGGER: u64 = 4;
pub const SWEEP_FLIGHT: u64 = 30;
/// Ticks between called golden crabs in a versus round: three minutes,
/// so one a standard round and two in a long one.
pub const VERSUS_GOLDEN_EVERY: u32 = 3 * 60 * TICKS_PER_SECOND;
/// 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, unless the board's rules say otherwise ([`Rules::gull_cap`]).
/// 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 3 bytes, 8 with the seat and frame of the
/// [`crate::sim::InputMsg`] around it; see [`crate::sim::encode_action`].
#[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,
    /// How many gulls the ambient spawner allows on the beach at once.
    /// [`GULL_CAP`] unless a versus beach bigger than the classic one asked
    /// for more (`set_versus_rules`), which is the one place it moves.
    pub gull_cap: u8,
    /// Whether the flock turns over: at the spawner's turn with the flock
    /// full, the oldest gull clear of the castles flies off and a fresh
    /// one comes in, and the final rush raises the cap by half.
    ///
    /// Without it a gull leaves only by raiding, and a full flock that has
    /// settled into loops away from the castles holds the beach for the
    /// rest of the round: the spawner is shut out, and the final rush's
    /// doubled rate doubles nothing. In bot rounds on the classic size the
    /// flock sat at its cap for four fifths of the final minute, and one
    /// round in five saw no gull leave in its last sixty seconds. On for
    /// versus only: a puzzle's flock is part of what its solution was
    /// proven against, and a file that does not say so keeps the old rule.
    pub gull_turnover: bool,
    /// Ticks between called golden crabs; 0 leaves the golden crab in the
    /// spawn mix at its full worth, as every board before versus took it.
    ///
    /// With it set, the mix never rolls a golden crab. Instead, once every
    /// this many ticks the board calls one ([`Board::golden_call`]): a tick
    /// a third to two thirds of the way into the stretch and a spawner
    /// hole, both drawn when the stretch begins, so the call is on the
    /// board for everyone to see before the crab is, and the golden crab
    /// is worth [`GOLDEN_CALLED_VALUE`]. Four silent fifty-point crabs a
    /// round, each worth a third of a good score, decided rounds for
    /// whoever had an arrow in the right place; one a round, called, is a
    /// race the whole table can see coming.
    pub golden_every: u32,
    /// Whether the event wheel locks only while a timed event runs.
    ///
    /// Every event used to lock the wheel for [`EVENT_COOLDOWN`], the ones
    /// over in a tick (Crab Monopoly, Fresh Sand, Castle Swap, Gull Attack)
    /// as much as the ones that last. The lock is there so a Crab Mania's
    /// flood of crabs cannot spin the wheel again while it runs; after an
    /// event that is already over it only swallowed the next player's
    /// sparkling crab, with nothing on the beach to say why. On for versus
    /// only, and a file that does not say so keeps the old lock.
    pub timed_lock: bool,
    /// Whether Crab Monopoly carries its crabs home rather than banking
    /// them on the spot.
    ///
    /// Banked on the spot, half the beach vanished in one tick and the
    /// scores jumped before anything could be seen to happen; whatever the
    /// screen drew afterwards was decoration on a done deal. With this the
    /// picked crabs leave the beach (no gull can take them, no arrow turns
    /// them) and wait [`SWEEP_HOLD`], then fly to the banker's castle one
    /// after another, and each banks the tick it lands ([`Board::sweeping`]).
    /// On for versus only, and a file that does not say so keeps the
    /// instant sweep.
    pub sweep_flight: bool,
    /// 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,
    /// 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,
    /// The golden crab called and not yet out, as the tick and the
    /// spawner tile (see [`Rules::golden_every`]).
    golden_call: Option<(u64, u16)>,
    /// 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,
    /// The tide roulette and what it has set running.
    pub(crate) tide: events::Tide,
    /// 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>,
    /// Crabs Crab Monopoly is carrying home, in the order they land (see
    /// [`Rules::sweep_flight`]).
    sweeping: Vec<Sweeping>,
}

/// A crab on its way home in a Crab Monopoly sweep.
#[derive(Clone, Copy, Debug)]
pub struct Sweeping {
    /// The crab as it stood when it was picked up.
    pub crab: Crab,
    /// Whose castle it is flying to, and whose score it banks into.
    pub owner: PlayerId,
    /// The tick it leaves the sand.
    pub launch: u64,
    /// The tick it lands and banks.
    pub lands: u64,
}

/// 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,
                gull_cap: GULL_CAP as u8,
                gull_turnover: false,
                golden_every: 0,
                timed_lock: false,
                sweep_flight: false,
                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,
            crabs_banked: 0,
            golden_banked: 0,
            golden_call: None,
            wrap: false,
            tide: events::Tide::default(),
            swept_home: Vec::new(),
            sweeping: 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,
            crabs_banked,
            golden_banked,
            golden_call,
            wrap,
            tide,
            swept_home,
            sweeping,
        } = 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.crabs_banked = *crabs_banked;
        self.golden_banked = *golden_banked;
        self.golden_call = *golden_call;
        self.wrap = *wrap;
        self.tide.copy_from(tide);
        refill(&mut self.swept_home, swept_home);
        refill(&mut self.sweeping, sweeping);
    }

    // --- 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).
    ///
    /// Except at the rim of an open-ocean board, where nothing is stored:
    /// see [`Board::on_open_rim`].
    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"
        );
        if self.on_open_rim(x, y, dir) {
            return;
        }
        self.set_edge(x as usize, y as usize, dir, present);
    }

    /// Whether the edge leaving `(x, y)` toward `dir` is the rim of a
    /// wrapping board, which takes no wall.
    ///
    /// The rim is the seam a creature crosses to the far side, and it is
    /// stored as two edges, one on each side of the beach. A wall on one of
    /// them was a one-way door: walked into from this side, open from the
    /// other. It also did not survive a save, since the level format opens
    /// the whole rim when it reads `wrap: on`. So the rim of an open beach
    /// is open, full stop: [`Board::set_wrap`] clears it, `set_wall` leaves
    /// it alone, and a snapshot holding a wall there is refused.
    pub fn on_open_rim(&self, x: u8, y: u8, dir: Direction) -> bool {
        let (dx, dy) = dir.offset();
        self.wrap && !self.in_bounds(i32::from(x) + dx, i32::from(y) + dy)
    }

    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;
    }

    /// How many ambient gulls may be on the beach at once.
    pub fn set_gull_cap(&mut self, cap: u8) {
        self.rules.gull_cap = cap;
    }

    /// Whether the flock turns over (see [`Rules::gull_turnover`]).
    pub fn set_gull_turnover(&mut self, on: bool) {
        self.rules.gull_turnover = on;
    }

    /// Carry Crab Monopoly's crabs home (see [`Rules::sweep_flight`]).
    pub fn set_sweep_flight(&mut self, on: bool) {
        self.rules.sweep_flight = on;
    }

    /// Lock the wheel only for timed events (see [`Rules::timed_lock`]).
    pub fn set_timed_lock(&mut self, on: bool) {
        self.rules.timed_lock = on;
    }

    /// Call a golden crab once every `ticks` (see [`Rules::golden_every`]).
    pub fn set_golden_every(&mut self, ticks: u32) {
        self.rules.golden_every = ticks;
    }

    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;
        self.grid.set_rim(!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.tide.enabled
    }

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

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

    /// Whose sparkling crab spun the most recent tide event, `None` when
    /// it was called instead (a spectators' vote).
    pub fn event_spun_by(&self) -> Option<PlayerId> {
        self.tide.spun_by
    }

    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.tide.cooldown = self.tide.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_golden_call();
        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.tide.mania {
            *ticks -= 1;
            if *ticks == 0 {
                self.tide.mania = None;
            }
        }
        self.tide.claw_call = self.tide.claw_call.saturating_sub(1);
        if let Some((_, ticks)) = &mut self.tide.tempo {
            *ticks -= 1;
            if *ticks == 0 {
                self.tide.tempo = None;
            }
        }
        self.land_sweeps();
        self.tick += 1;
        #[cfg(debug_assertions)]
        self.check_invariants();
    }

    /// What every tick must leave true, checked after each one in a debug
    /// build (which is what the tests run). Each is something a later
    /// reader relies on without looking:
    ///
    /// - a running timer has at least a tick left, because an expired one
    ///   is cleared the tick it reaches zero, and the snapshot parser
    ///   refuses a zero, so a board holding one could not be resumed or
    ///   sent to a watcher catching up;
    /// - a signpost stands on sand, since crabs turn on it and only sand
    ///   takes one, and its sequence number is behind the counter, or the
    ///   cap would evict the wrong post first;
    /// - no seat holds more posts than the cap;
    /// - every creature is on the board, which the tile indexing assumes;
    /// - the golden crabs banked are some of the crabs banked;
    /// - an open-ocean board has no wall on its rim (see
    ///   [`Board::on_open_rim`]).
    ///
    /// The snapshot parser refuses a board breaking any of these, since a
    /// share code or a save file is text anybody can edit, and a board
    /// read from one must not trip an assertion a tick later.
    #[cfg(debug_assertions)]
    fn check_invariants(&self) {
        for (what, left) in [
            ("lure", self.lure.map(|(_, t)| t)),
            ("mania", self.tide.mania.map(|(_, t)| t)),
            ("tempo", self.tide.tempo.map(|(_, t)| t)),
        ] {
            debug_assert!(left != Some(0), "a {what} left running with no ticks");
        }
        let tiles = self.grid.tiles.len();
        let mut held = [0usize; MAX_PLAYERS];
        for (t, post) in self.signposts.iter().enumerate() {
            let Some(post) = post else {
                continue;
            };
            debug_assert_eq!(
                self.grid.tiles[t],
                TileKind::Empty,
                "a signpost stands on a {:?} at tile {t}",
                self.grid.tiles[t]
            );
            debug_assert!(post.seq < self.signpost_seq, "a signpost from the future");
            if let Some(slot) = held.get_mut(usize::from(post.owner)) {
                *slot += 1;
            }
        }
        debug_assert!(
            held.iter()
                .all(|&n| n <= usize::from(self.rules.signpost_cap)),
            "posts over the cap of {}: {held:?}",
            self.rules.signpost_cap
        );
        debug_assert!(
            self.crabs.iter().all(|crab| usize::from(crab.tile) < tiles),
            "a crab off the board"
        );
        debug_assert!(
            self.gulls.iter().all(|gull| usize::from(gull.tile) < tiles),
            "a gull off the board"
        );
        debug_assert!(self.golden_banked <= self.crabs_banked);
        debug_assert!(
            !(self.wrap && self.grid.rim_walled()),
            "a wall on the rim of an open beach"
        );
    }

    /// 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.
    /// Bank the swept crabs due this tick, as they reach their castle.
    ///
    /// Everything still in the air lands on the last tick of a round: the
    /// sim freezes at the wave, and a crab caught mid-flight by it would
    /// be points the event promised and never paid.
    fn land_sweeps(&mut self) {
        if self.sweeping.is_empty() {
            return;
        }
        let tick = self.tick;
        let last = self
            .rules
            .round_length
            .is_some_and(|len| tick + 1 >= u64::from(len));
        let mut i = 0;
        while i < self.sweeping.len() {
            if !last && self.sweeping[i].lands > tick {
                i += 1;
                continue;
            }
            let Sweeping { crab, owner, .. } = self.sweeping.remove(i);
            self.credit_bank(owner, &crab);
            self.crabs_banked += 1;
            if crab.kind == CrabKind::Golden {
                self.golden_banked += 1;
            }
            self.swept_home.push(Swept {
                crab: crab.id,
                owner,
                at: tick,
            });
        }
    }

    pub(super) fn credit_bank(&mut self, owner: PlayerId, crab: &Crab) {
        let points = crab.bank_points(self.crab_value(crab.kind), self.in_claw_call());
        let score = &mut self.scores[owner as usize];
        *score = score.saturating_add_signed(points);
    }

    /// 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.tide.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 gull_cap(&self) -> u8 {
        self.rules.gull_cap
    }

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

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

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

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

    /// Crabs Crab Monopoly is carrying home, soonest to land first.
    pub fn sweeping(&self) -> &[Sweeping] {
        &self.sweeping
    }

    /// The golden crab the board has called, once the call is public: the
    /// tick it comes out on and the spawner hole it comes out of, from
    /// [`GOLDEN_NOTICE`] ticks before it does.
    ///
    /// Drawn a minute or more ahead, but told to nobody until the notice:
    /// a bot reads the board and a person reads the screen, and a minute's
    /// warning for one and five seconds for the other is not one race.
    pub fn golden_call(&self) -> Option<(u64, u8, u8)> {
        self.golden_call
            .filter(|&(at, _)| at.saturating_sub(self.tick) <= u64::from(GOLDEN_NOTICE))
            .map(|(at, tile)| {
                let (x, y) = self.coords_u8(tile);
                (at, x, y)
            })
    }

    /// What banking a crab of this kind is worth on this board, before any
    /// Right Claws call: its kind's worth, except a called golden crab's.
    pub fn crab_value(&self, kind: CrabKind) -> u32 {
        match (kind, self.rules.golden_every) {
            (CrabKind::Golden, 1..) => GOLDEN_CALLED_VALUE,
            _ => kind.value(),
        }
    }

    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
    }

    /// Ticks left in the quiet spell after a lure, when a molt banked
    /// starts none. Public: anyone who watched the lure end can count it.
    pub fn lure_cooldown(&self) -> u32 {
        self.lure_cooldown
    }

    /// 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.tide.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> {
        u32::try_from(crab.bank_points(self.crab_value(crab.kind), self.in_claw_call())).ok()
    }

    /// 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 lookahead;
mod signposts;
mod snapshot;
#[cfg(test)]
mod tests;

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

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