Skip to main content

pinch_points/sim/
bot.rs

1//! A basic signpost-placing opponent. Deterministic and pure: the action is
2//! a function of the board alone, so bot matches replay bit-for-bit and the
3//! same bot could later run host-side in online games.
4//!
5//! Heuristic, in priority order:
6//! 1. Defend: a walking gull near the castle gets a signpost slammed in
7//!    front of it, pointing back where it came from.
8//! 2. Recruit: the most valuable crab nearby that is not already heading
9//!    castle-ward gets a signpost on the tile ahead of it, pointing home.
10//!
11//! Fierce additionally reads the terrain (see [`BotLevel::reads_terrain`]):
12//! it shoves gulls into kelp they cannot enter, walks its crabs around tide
13//! pools instead of through them, and never spends a signpost whose turn a
14//! turnstile would immediately undo.
15//!
16//! The bot acts on a fixed cadence (staggered per seat so bots do not move
17//! in lockstep, and rotated per cycle so no seat is permanently quickest;
18//! see [`BotLevel::acts_on`]) and simply retries next window if a placement
19//! was rejected.
20
21use crate::sim::board::{Board, PlayerAction, PlayerId, TileKind};
22use crate::sim::crab::{CrabKind, Handedness};
23use crate::sim::direction::Direction;
24use crate::sim::gull::GullState;
25
26/// Bot difficulty. Levels differ in reaction cadence and search radii;
27/// Hard additionally plays offense, steering gulls at the leading rival's
28/// castle. All levels stay pure functions of the board.
29#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
30pub enum BotLevel {
31    Easy,
32    #[default]
33    Normal,
34    Hard,
35}
36
37impl BotLevel {
38    /// Ticks between decisions, staggered per seat.
39    fn cadence(self) -> u64 {
40        match self {
41            BotLevel::Easy => 40,
42            BotLevel::Normal => 20,
43            BotLevel::Hard => 12,
44        }
45    }
46
47    fn defend_radius(self) -> i32 {
48        match self {
49            BotLevel::Easy => 2,
50            BotLevel::Normal => 4,
51            BotLevel::Hard => 6,
52        }
53    }
54
55    /// How far the bot will walk to attack rather than play its own corner.
56    /// Only the fierce bot attacks at all, and only when the gull is on its
57    /// way past.
58    fn reach(self) -> i32 {
59        match self {
60            BotLevel::Easy | BotLevel::Normal => 0,
61            BotLevel::Hard => 5,
62        }
63    }
64
65    /// How far the bot will go for a jackpot: a golden crab, or a molting
66    /// one and the lure that follows it home. Worth crossing the board for,
67    /// where chasing a rival is not. Fierce only; the other levels see no
68    /// further than their own corner.
69    fn jackpot_reach(self) -> i32 {
70        match self {
71            BotLevel::Easy | BotLevel::Normal => 0,
72            BotLevel::Hard => 14,
73        }
74    }
75
76    /// One placement in this many is aimed the wrong way: the easy bot's
77    /// hand slips. Zero means it never blunders.
78    ///
79    /// Derived from the tick and the seat rather than a random draw: the bot
80    /// must stay a pure function of the board so every peer of an online
81    /// match derives the same move for an AI seat.
82    fn blunder_every(self) -> u64 {
83        match self {
84            BotLevel::Easy => 4,
85            BotLevel::Normal => 8,
86            BotLevel::Hard => 0,
87        }
88    }
89
90    /// Whether the bot picks the crab that is *worth* the most (a molting
91    /// crab and its lure, a golden jackpot) or just the nearest one. The
92    /// easy bot cannot tell a jackpot from a common crab.
93    fn values_the_catch(self) -> bool {
94        !matches!(self, BotLevel::Easy)
95    }
96
97    /// Ticks the bot's cursor spends crossing one tile: the bot has a hand to
98    /// walk to a tile, as a player does, and this is how fast it is. A default
99    /// human cursor covers a tile in under three ticks, so Normal matches a
100    /// player and Fierce stays within reach of one.
101    fn cursor_ticks_per_tile(self) -> u64 {
102        match self {
103            BotLevel::Easy => 4,
104            BotLevel::Normal => 3,
105            BotLevel::Hard => 2,
106        }
107    }
108
109    fn recruit_radius(self) -> i32 {
110        match self {
111            BotLevel::Easy => 4,
112            BotLevel::Normal => 7,
113            BotLevel::Hard => 10,
114        }
115    }
116
117    /// Whether this level plays the terrain (kelp, pools, turnstiles) or
118    /// treats the beach as flat sand. Fierce only.
119    fn reads_terrain(self) -> bool {
120        matches!(self, BotLevel::Hard)
121    }
122
123    /// Whether this seat gets to think on this tick.
124    ///
125    /// Every seat decides once per cadence window (an unequal rate simply
126    /// lets the faster thinker play more) at a moment drawn from the window
127    /// and the seat. Drawn rather than a fixed grid of slots because
128    /// the spawner holes fire on even ticks, so a seat parked on an odd slot
129    /// would meet every new crab a beat before a seat on an even one.
130    ///
131    /// Ties inside a single tick are the sim's business, not the bot's:
132    /// see [`Board::action_order`](crate::sim::Board).
133    fn acts_on(self, player: PlayerId, ticks: u64) -> bool {
134        let cadence = self.cadence();
135        let window = ticks / cadence;
136        let mut z = window.wrapping_mul(0x9E37_79B9_7F4A_7C15)
137            ^ u64::from(player).wrapping_mul(0xBF58_476D_1CE4_E5B9);
138        z ^= z >> 31;
139        z = z.wrapping_mul(0x94D0_49BB_1331_11EB);
140        z ^= z >> 29;
141        ticks % cadence == z % cadence
142    }
143}
144
145/// How far from a rival castle a Hard bot will weaponize a passing gull.
146const ATTACK_RADIUS: i32 = 6;
147
148/// Ticks before a held cursor starts repeating, matching the human default.
149const CURSOR_LIFT: u64 = 8;
150
151/// Why the bot wants this tile. A gull bearing down on your castle is worth
152/// spending your last signpost on, since it carries off half the bank,
153/// where a wayward crab is not.
154#[derive(Clone, Copy, PartialEq, Eq, Debug)]
155enum Intent {
156    Defend,
157    Recruit,
158    Attack,
159}
160
161/// What the bot wants to do this tick, and whether its hand could get there
162/// in time to do it.
163///
164/// A bot's cursor is wherever it last placed a signpost, read off the board
165/// rather than stored, so the bot stays a pure function of the state and
166/// every peer of an online match derives the same move for it.
167pub fn bot_action(board: &Board, player: PlayerId, level: BotLevel) -> PlayerAction {
168    let (wanted, intent) = decide(board, player, level);
169    let wanted = fumble(wanted, player, level, board.ticks());
170    // Only a placement has a tile to walk to, or a post to cost; nothing
171    // else waits or weighs.
172    if let PlayerAction::Place { x, y, .. } = wanted
173        && (!hand_arrived(board, player, level, x, y)
174            || !worth_the_walk(board, player, level, x, y, intent))
175    {
176        return PlayerAction::None;
177    }
178    wanted
179}
180
181/// The easy bot's hand slips: one placement in four comes out pointing the
182/// wrong way. A legible difficulty knob, one you can watch happen, where
183/// "thinks less often" is invisible.
184fn fumble(action: PlayerAction, player: PlayerId, level: BotLevel, ticks: u64) -> PlayerAction {
185    let every = level.blunder_every();
186    if every == 0 {
187        return action;
188    }
189    // Only a placement can come out crooked; the tile is still the right one.
190    if let PlayerAction::Place { x, y, dir } = action
191        && (ticks ^ u64::from(player)).is_multiple_of(every)
192    {
193        return PlayerAction::Place {
194            x,
195            y,
196            dir: dir.right(),
197        };
198    }
199    action
200}
201
202/// Whether this placement is worth the trip.
203///
204/// Defending is always worth it: a gull reaching a castle carries off half
205/// the bank. Chasing a rival across the board is not: the walk is dead time
206/// that costs more than the raid pays. Offence has to be on the way.
207///
208/// Deliberately not a rule about evicting its own posts: churn helps rather
209/// than hurts, because a crab walks a tile every twenty ticks and a post
210/// aimed where one was four seconds ago is aimed at nothing.
211fn worth_the_walk(
212    board: &Board,
213    player: PlayerId,
214    level: BotLevel,
215    x: u8,
216    y: u8,
217    intent: Intent,
218) -> bool {
219    if intent != Intent::Attack {
220        return true;
221    }
222    let Some((from_x, from_y, _)) = board.newest_signpost_of(player) else {
223        return true;
224    };
225    let steps = i32::from(x.abs_diff(from_x)) + i32::from(y.abs_diff(from_y));
226    steps <= level.reach()
227}
228
229/// Whether the bot's cursor has had time to reach `(x, y)` since its last
230/// placement. With nothing of its standing it has been idle for at least a
231/// signpost's lifetime, which is longer than any walk across a beach.
232fn hand_arrived(board: &Board, player: PlayerId, level: BotLevel, x: u8, y: u8) -> bool {
233    let Some((from_x, from_y, since)) = board.newest_signpost_of(player) else {
234        return true;
235    };
236    let steps = u64::from(x.abs_diff(from_x)) + u64::from(y.abs_diff(from_y));
237    // Charged the way a player's hand works: the first tap moves a tile at
238    // once, and only a held key waits for the repeat to kick in. So a
239    // neighbouring tile is free, and a trip across the beach costs about what
240    // it costs a human.
241    let walk = match steps {
242        0 | 1 => 0,
243        far => CURSOR_LIFT + (far - 1) * level.cursor_ticks_per_tile(),
244    };
245    board.ticks().saturating_sub(since) >= walk
246}
247
248/// What the bot wants to do this tick.
249///
250/// Four strategies in order of what a round is won and lost on: a gull about
251/// to raid you costs half your bank, a jackpot is worth crossing the beach
252/// for, a wayward crab is bread and butter, and shoving a gull at the leader
253/// is the luxury you buy last. Each answers `None` when it has nothing to say.
254fn decide(board: &Board, player: PlayerId, level: BotLevel) -> (PlayerAction, Intent) {
255    let nothing = (PlayerAction::None, Intent::Recruit);
256    if !level.acts_on(player, board.ticks()) {
257        return nothing;
258    }
259    let Some(castle) = castle_of(board, player) else {
260        return nothing;
261    };
262    defend(board, player, level, castle)
263        .or_else(|| chase_jackpot(board, player, level, castle))
264        .or_else(|| recruit(board, player, level, castle))
265        .or_else(|| attack(board, player, level))
266        .unwrap_or(nothing)
267}
268
269/// Turn back the nearest gull threatening our castle.
270fn defend(
271    board: &Board,
272    player: PlayerId,
273    level: BotLevel,
274    castle: u16,
275) -> Option<(PlayerAction, Intent)> {
276    let mut best: Option<(i32, u16, Direction)> = None;
277    for gull in board.gulls() {
278        if gull.state != GullState::Walking {
279            continue;
280        }
281        let d = manhattan(board, gull.tile, castle);
282        if d > level.defend_radius() {
283            continue;
284        }
285        // A gull walking away is no threat, and a post that turns it round
286        // would make one: only a bird whose next step closes on the castle
287        // is worth a signpost, the way recruit and attack leave alone a
288        // creature already heading the right way.
289        let closing = board
290            .step(gull.tile, gull.dir)
291            .is_some_and(|next| manhattan(board, next, castle) < d);
292        if closing && best.is_none_or(|(bd, ..)| d < bd) {
293            best = Some((d, gull.tile, gull.dir));
294        }
295    }
296    let (_, tile, dir) = best?;
297    // Fierce shoves the gull into kelp beside its next tile when that is
298    // safe: a walking gull cannot enter kelp, so the sim turns it along the
299    // weed instead, and the shove is only taken when neither turn brings
300    // the bird closer than sending it back the way it came. Otherwise, and
301    // for the terrain-blind levels, reverse it.
302    let out = board
303        .step(tile, dir)
304        .filter(|_| level.reads_terrain())
305        .and_then(|target| safe_kelp_shove(board, target, tile, dir, castle))
306        .unwrap_or_else(|| dir.reverse());
307    let action = place_ahead(board, player, tile, dir, out, level)?;
308    Some((action, Intent::Defend))
309}
310
311/// Fierce only: go after a jackpot anywhere on the beach. A golden crab pays
312/// fifty and a molt turns the whole board toward home, so unlike chasing a
313/// rival this walk pays for itself.
314fn chase_jackpot(
315    board: &Board,
316    player: PlayerId,
317    level: BotLevel,
318    castle: u16,
319) -> Option<(PlayerAction, Intent)> {
320    if level.jackpot_reach() == 0 {
321        return None;
322    }
323    let mut best: Option<(u32, i32, u16, Direction)> = None;
324    for crab in board.crabs() {
325        let worth = match crab.kind {
326            CrabKind::Golden => 50,
327            CrabKind::Molting => 30, // 5 points, and then the lure
328            CrabKind::Giant => 10,
329            CrabKind::Common | CrabKind::Juvenile | CrabKind::Sparkling => continue,
330        };
331        let d = manhattan(board, crab.tile, castle);
332        if d == 0 || d > level.jackpot_reach() || crab.dir == toward(board, crab.tile, castle) {
333            continue;
334        }
335        if best.is_none_or(|(bw, bd, ..)| worth > bw || (worth == bw && d < bd)) {
336            best = Some((worth, d, crab.tile, crab.dir));
337        }
338    }
339    let (_, _, tile, dir) = best?;
340    let ahead = board.step(tile, dir).unwrap_or(tile);
341    let home = homeward(board, ahead, castle, level, TileKind::Pool);
342    let action = place_ahead(board, player, tile, dir, home, level)?;
343    Some((action, Intent::Recruit))
344}
345
346/// Turn the best wayward crab in range toward home.
347fn recruit(
348    board: &Board,
349    player: PlayerId,
350    level: BotLevel,
351    castle: u16,
352) -> Option<(PlayerAction, Intent)> {
353    let mut best: Option<(u32, i32, u16, Direction)> = None;
354    for crab in board.crabs() {
355        let d = manhattan(board, crab.tile, castle);
356        if d == 0 || d > level.recruit_radius() {
357            continue;
358        }
359        if crab.dir == toward(board, crab.tile, castle) {
360            continue; // already coming to us
361        }
362        // The easy bot cannot tell a jackpot from a common crab and simply
363        // grabs at whatever is closest.
364        let value = if level.values_the_catch() {
365            crab.kind.value()
366        } else {
367            1
368        };
369        if best.is_none_or(|(bv, bd, ..)| value > bv || (value == bv && d < bd)) {
370            best = Some((value, d, crab.tile, crab.dir));
371        }
372    }
373    let (_, _, tile, dir) = best?;
374    let ahead = board.step(tile, dir).unwrap_or(tile);
375    let home = homeward(board, ahead, castle, level, TileKind::Pool);
376    let action = place_ahead(board, player, tile, dir, home, level)?;
377    Some((action, Intent::Recruit))
378}
379
380/// Fierce only: steer a gull that is already near the leading rival's castle
381/// the rest of the way in.
382fn attack(board: &Board, player: PlayerId, level: BotLevel) -> Option<(PlayerAction, Intent)> {
383    if level != BotLevel::Hard {
384        return None;
385    }
386    let target = leading_rival_castle(board, player)?;
387    for gull in board.gulls() {
388        if gull.state != GullState::Walking {
389            continue;
390        }
391        let d = manhattan(board, gull.tile, target);
392        if d == 0 || d > ATTACK_RADIUS || gull.dir == toward(board, gull.tile, target) {
393            continue;
394        }
395        let ahead = board.step(gull.tile, gull.dir).unwrap_or(gull.tile);
396        let aim = homeward(board, ahead, target, level, TileKind::Kelp);
397        if let Some(action) = place_ahead(board, player, gull.tile, gull.dir, aim, level) {
398            return Some((action, Intent::Attack));
399        }
400    }
401    None
402}
403
404/// The castle of the highest-scoring rival (ties to the lowest seat).
405fn leading_rival_castle(board: &Board, player: PlayerId) -> Option<u16> {
406    let scores = board.scores();
407    let mut best: Option<(u32, PlayerId)> = None;
408    for seat in 0..crate::sim::MAX_PLAYERS as PlayerId {
409        if seat == player {
410            continue;
411        }
412        let Some(_) = castle_of(board, seat) else {
413            continue;
414        };
415        if best.is_none_or(|(s, _)| scores[seat as usize] > s) {
416            best = Some((scores[seat as usize], seat));
417        }
418    }
419    best.and_then(|(_, seat)| castle_of(board, seat))
420}
421
422/// A seat's castle as a tile index, the form the bot's distance and step
423/// helpers speak.
424fn castle_of(board: &Board, player: PlayerId) -> Option<u16> {
425    board.castle_of(player).map(|(x, y)| board.index_of(x, y))
426}
427
428fn manhattan(board: &Board, a: u16, b: u16) -> i32 {
429    let (ax, ay) = board.coords(a);
430    let (bx, by) = board.coords(b);
431    (ax - bx).abs() + (ay - by).abs()
432}
433
434/// Greedy direction from `from` toward `to` (the sim's lure tiebreak).
435fn toward(board: &Board, from: u16, to: u16) -> Direction {
436    let (fx, fy) = board.coords(from);
437    let (tx, ty) = board.coords(to);
438    Direction::toward(tx - fx, ty - fy)
439}
440
441/// The other axis's direction toward `to`, when that axis has ground left
442/// to cover. Straight lines have no second option.
443fn cross_toward(board: &Board, from: u16, to: u16) -> Option<Direction> {
444    let (fx, fy) = board.coords(from);
445    let (tx, ty) = board.coords(to);
446    let (dx, dy) = (tx - fx, ty - fy);
447    match toward(board, from, to) {
448        Direction::Left | Direction::Right => (dy != 0).then(|| Direction::toward(0, dy)),
449        Direction::Up | Direction::Down => (dx != 0).then(|| Direction::toward(dx, 0)),
450    }
451}
452
453/// The kind of the tile one step from `tile` in `dir`, if it is on the board.
454fn kind_ahead(board: &Board, tile: u16, dir: Direction) -> Option<TileKind> {
455    let target = board.step(tile, dir)?;
456    let (x, y) = board.coords(target);
457    Some(board.tile_at(x as u8, y as u8))
458}
459
460/// Whether a walking gull standing on `tile` may step toward `dir`: the
461/// bot's reading of the sim's passability (no wall on the edge, a tile on
462/// the far side, and neither rock nor kelp there), so it can predict what
463/// the wall resolution will do with a post it is about to plant.
464fn gull_passable(board: &Board, tile: u16, dir: Direction) -> bool {
465    let (x, y) = board.coords_u8(tile);
466    !board.wall_at(x, y, dir)
467        && !matches!(
468            kind_ahead(board, tile, dir),
469            None | Some(TileKind::Rock | TileKind::Kelp)
470        )
471}
472
473/// Where the sim sends a gull of `handed` hand that a signpost on `tile`
474/// has aimed at kelp: its preferred side, else the other, else back. The
475/// same ladder as the board's wall resolution, which the bot cannot call.
476fn kelp_turn(board: &Board, tile: u16, into_kelp: Direction, handed: Handedness) -> Direction {
477    let (first, second) = match handed {
478        Handedness::Left => (into_kelp.left(), into_kelp.right()),
479        Handedness::Right => (into_kelp.right(), into_kelp.left()),
480    };
481    if gull_passable(board, tile, first) {
482        first
483    } else if gull_passable(board, tile, second) {
484        second
485    } else {
486        into_kelp.reverse()
487    }
488}
489
490/// A direction out of `target` (the tile ahead of a gull on `tile` walking
491/// `travel`) that aims straight into kelp, when the shove is safe. Kelp is
492/// a wall to a walking gull, so the sim turns the bird along the weed by
493/// its handedness, and a right-handed gull turned to its right may be
494/// facing the castle. The shove is only offered when, whichever hand the
495/// gull has, the turn leaves it no closer than plain reversal would (which
496/// sends it back onto `tile`). Kelp straight ahead does not count: that is
497/// the gull's own line, and a post there changes nothing.
498fn safe_kelp_shove(
499    board: &Board,
500    target: u16,
501    tile: u16,
502    travel: Direction,
503    castle: u16,
504) -> Option<Direction> {
505    let reversed = manhattan(board, tile, castle);
506    Direction::ALL.into_iter().find(|&dir| {
507        dir != travel
508            && kind_ahead(board, target, dir) == Some(TileKind::Kelp)
509            && [Handedness::Left, Handedness::Right]
510                .into_iter()
511                .all(|handed| {
512                    let turned = kelp_turn(board, target, dir, handed);
513                    board
514                        .step(target, turned)
515                        .is_none_or(|next| manhattan(board, next, castle) >= reversed)
516                })
517    })
518}
519
520/// Which way to send a walker standing on `from` to reach `to`. Fierce
521/// steps around `hazard`, the terrain that stops this walker (tide pools
522/// halve a crab's speed, kelp walls out a gull), when the other axis makes
523/// progress too; the other levels always take the greedy step.
524fn homeward(board: &Board, from: u16, to: u16, level: BotLevel, hazard: TileKind) -> Direction {
525    let greedy = toward(board, from, to);
526    if !level.reads_terrain() || kind_ahead(board, from, greedy) != Some(hazard) {
527        return greedy;
528    }
529    match cross_toward(board, from, to) {
530        Some(other) if kind_ahead(board, from, other) != Some(hazard) => other,
531        _ => greedy,
532    }
533}
534
535/// Place a signpost on the tile ahead of a creature, pointing `dir_out`, if
536/// that tile can take one of ours.
537fn place_ahead(
538    board: &Board,
539    player: PlayerId,
540    creature_tile: u16,
541    creature_dir: Direction,
542    dir_out: Direction,
543    level: BotLevel,
544) -> Option<PlayerAction> {
545    let target = board.step(creature_tile, creature_dir)?;
546    let (x, y) = board.coords(target);
547    let (x, y) = (x as u8, y as u8);
548    if board.tile_at(x, y) != TileKind::Empty {
549        return None;
550    }
551    // A turnstile deflects whatever crosses it, so a post aimed straight
552    // into one buys a turn the log immediately takes back. Fierce spends
553    // the signpost somewhere it survives instead.
554    if level.reads_terrain()
555        && matches!(
556            kind_ahead(board, target, dir_out),
557            Some(TileKind::Turnstile { .. })
558        )
559    {
560        return None;
561    }
562    match board.signpost_at(x, y) {
563        Some(sp) if sp.owner != player => None,
564        Some(sp) if sp.dir == dir_out => None, // already doing its job
565        _ => Some(PlayerAction::Place { x, y, dir: dir_out }),
566    }
567}
568
569#[cfg(test)]
570mod tests;