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