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