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