pinch_points/sim/board/crabs.rs
1//! Crabs: the spawner holes, the walking pass, and what happens when one
2//! arrives somewhere - a castle, a turnstile, or a lure calling it home.
3
4use super::*;
5
6impl Board {
7 /// Place a crab directly (puzzle setups and tests; spawner tiles handle
8 /// the normal case). The crab immediately wall-resolves so it never starts
9 /// a tick facing a wall.
10 pub fn spawn_crab(&mut self, x: u8, y: u8, dir: Direction, handed: Handedness, kind: CrabKind) {
11 assert!(
12 self.in_bounds(i32::from(x), i32::from(y)),
13 "crab off the board"
14 );
15 let tile = self.index(i32::from(x), i32::from(y));
16 assert!(
17 self.tiles[tile as usize] != TileKind::Rock,
18 "crab on a rock"
19 );
20 let id = self.next_crab_id;
21 self.next_crab_id += 1;
22 let mut crab = Crab {
23 id,
24 tile,
25 dir,
26 progress: 0,
27 prev_tile: tile,
28 prev_progress: 0,
29 prev_dir: dir,
30 handed,
31 kind,
32 };
33 self.resolve_walls(&mut crab);
34 self.crabs.push(crab);
35 }
36
37 pub(super) fn run_spawners(&mut self) {
38 for t in 0..self.tiles.len() {
39 let TileKind::Spawner(s) = self.tiles[t] else {
40 continue;
41 };
42 // Manias override the cadence: floods every 8 ticks.
43 let period = match self.mania {
44 Some((Mania::Crab | Mania::Gull, _)) => 8,
45 None => u64::from(s.period),
46 };
47 if !self.tick.is_multiple_of(period) {
48 continue;
49 }
50 if let Some((Mania::Gull, _)) = self.mania {
51 // Balance: the mania flood is dramatic but bounded. Beyond
52 // three flocks' worth the beach becomes unplayable for the
53 // rest of the round (mania gulls only leave by raiding).
54 if self.gulls.len() < GULL_CAP * 3 {
55 let (x, y) = self.coords(t as u16);
56 self.spawn_gull(x as u8, y as u8, s.dir);
57 }
58 continue;
59 }
60 // The beach fills to the cap and then waits for it to clear.
61 // Crab Mania floods past it, which is the event, but only to
62 // twice the cap, the same way Gull Mania stops at three flocks:
63 // unbounded, it buried the board under two crabs a tile.
64 let ceiling = match self.mania {
65 Some((Mania::Crab, _)) => self.crab_cap() * 2,
66 _ => self.crab_cap(),
67 };
68 if self.crabs.len() >= ceiling {
69 continue;
70 }
71 let handed = self.roll_handedness();
72 // Weighted kind mix so live boards show the whole population:
73 // mostly commons, a scattering of juveniles, the odd giant or
74 // molting crab, and once in a blue tide a golden jackpot.
75 let kind = match self.rng.next_u32() % 100 {
76 0..=68 => CrabKind::Common,
77 69..=83 => CrabKind::Juvenile,
78 84..=91 => CrabKind::Giant,
79 92..=95 => CrabKind::Molting,
80 96..=97 => CrabKind::Golden,
81 98.. => CrabKind::Sparkling,
82 };
83 let id = self.next_crab_id;
84 self.next_crab_id += 1;
85 let mut crab = Crab {
86 id,
87 tile: t as u16,
88 dir: s.dir,
89 progress: 0,
90 prev_tile: t as u16,
91 prev_progress: 0,
92 prev_dir: s.dir,
93 handed,
94 kind,
95 };
96 self.resolve_walls(&mut crab);
97 self.crabs.push(crab);
98 }
99 }
100
101 /// How many live crabs the ambient spawners will fill the beach to.
102 /// Proportional to the board, so an XL arena still feels busy and the
103 /// smallest puzzle board is not starved.
104 pub(super) fn crab_cap(&self) -> usize {
105 (self.tiles.len() / CRAB_CAP_TILES_PER_CRAB).max(8)
106 }
107
108 pub(super) fn move_crabs(&mut self) {
109 // One board scan per tick, not one per crab arrival (refreshed on
110 // banks below, which is when a lure can start mid-tick).
111 let mut lure_target = self.lure_target();
112 let mut banked: Vec<usize> = Vec::new();
113 for i in 0..self.crabs.len() {
114 let mut crab = self.crabs[i];
115 crab.prev_tile = crab.tile;
116 crab.prev_progress = crab.progress;
117 crab.prev_dir = crab.dir;
118 // Arrival resolution guarantees the exit direction is passable
119 // except for a crab sealed in on all four sides; that crab waits.
120 if !self.passable(crab.tile, crab.dir) {
121 self.crabs[i] = crab;
122 continue;
123 }
124 crab.progress += self.walk_step(crab.tile, crab.kind.speed());
125 let mut was_banked = false;
126 while crab.progress >= SUBUNITS_PER_TILE {
127 crab.progress -= SUBUNITS_PER_TILE;
128 crab.tile = self.neighbor(crab.tile, crab.dir);
129 if self.resolve_arrival(&mut crab, lure_target) {
130 was_banked = true;
131 // A molting bank starts a lure that later arrivals this
132 // same tick must already obey, as they always did.
133 lure_target = self.lure_target();
134 break;
135 }
136 }
137 if was_banked {
138 banked.push(i);
139 } else {
140 self.crabs[i] = crab;
141 }
142 }
143 // Remove back-to-front so earlier indices stay valid and the stable
144 // creature order (our fixed resolution order) is preserved.
145 for &i in banked.iter().rev() {
146 self.crabs.remove(i);
147 }
148 // Sparkling banks spin the roulette only now: events like Monopoly
149 // drain the crab list, which must not happen mid-iteration.
150 let queued = std::mem::take(&mut self.event_queue);
151 for banker in queued {
152 self.spin_tide_event(banker);
153 }
154 }
155
156 /// Spec §4.1 resolution on arriving at a tile centre. Returns `true` if
157 /// the crab banked and must despawn.
158 ///
159 /// Frozen decision for spec §9 open question 2: a signpost pointing into
160 /// a wall is *followed*, and wall resolution then applies from the
161 /// signpost's direction.
162 ///
163 /// While a molting lure is active (spec §3.2), loose crabs ignore
164 /// signposts entirely and greedily head for the luring player's castle;
165 /// wall resolution still applies.
166 pub(super) fn resolve_arrival(&mut self, crab: &mut Crab, lure_target: Option<u16>) -> bool {
167 let t = crab.tile as usize;
168 if let TileKind::Castle(owner) = self.tiles[t] {
169 self.scores[owner as usize] += crab.kind.value();
170 self.crabs_banked += 1;
171 match crab.kind {
172 // A molt banked during a lure (anyone's) or in the quiet
173 // spell after one banks for its points and nothing more.
174 CrabKind::Molting => {
175 if self.lure.is_none() && self.lure_cooldown == 0 {
176 self.lure = Some((owner, LURE_TICKS));
177 }
178 }
179 CrabKind::Golden => self.golden_banked += 1,
180 CrabKind::Sparkling => self.event_queue.push(owner),
181 CrabKind::Common | CrabKind::Juvenile | CrabKind::Giant => {}
182 }
183 return true;
184 }
185 if self.turnstile_deflect(crab.tile, &mut crab.dir, crab.handed, Walker::Crab) {
186 return false;
187 }
188 if let Some(dir) = self.lure_step(crab.tile, lure_target) {
189 crab.dir = dir;
190 } else if let Some(sp) = self.signposts[t] {
191 crab.dir = sp.dir;
192 }
193 self.resolve_walls(crab);
194 false
195 }
196
197 /// One fair coin flip of the sim's PRNG stream.
198 pub(super) fn roll_handedness(&mut self) -> Handedness {
199 if self.rng.next_u32() & 1 == 0 {
200 Handedness::Left
201 } else {
202 Handedness::Right
203 }
204 }
205
206 /// The luring player's castle tile, if a lure is active and that player
207 /// still has a castle. Computed once per tick and threaded through
208 /// arrivals, so the board scan is not repeated per crab.
209 pub(super) fn lure_target(&self) -> Option<u16> {
210 let (owner, _) = self.lure?;
211 self.tiles
212 .iter()
213 .position(|t| *t == TileKind::Castle(owner))
214 .map(|t| t as u16)
215 }
216
217 /// Greedy step direction from `from` toward the cached lure target.
218 pub(super) fn lure_step(&self, from: u16, lure_target: Option<u16>) -> Option<Direction> {
219 let castle = lure_target?;
220 let (fx, fy) = self.coords(from);
221 let (cx, cy) = self.coords(castle);
222 let (dx, dy) = (cx - fx, cy - fy);
223 if dx == 0 && dy == 0 {
224 return None;
225 }
226 Some(Direction::toward(dx, dy))
227 }
228}