Skip to main content

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}