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 z = avalanche(
137            window.wrapping_mul(0x9E37_79B9_7F4A_7C15)
138                ^ u64::from(player).wrapping_mul(0xBF58_476D_1CE4_E5B9),
139        );
140        ticks % cadence == z % cadence
141    }
142}
143
144/// How far from a rival castle a Hard bot will weaponize a passing gull.
145const ATTACK_RADIUS: i32 = 6;
146
147/// Ticks before a held cursor starts repeating, matching the human default.
148const CURSOR_LIFT: u64 = 8;
149
150/// Why the bot wants this tile. A gull bearing down on your castle is worth
151/// spending your last signpost on, since it carries off half the bank,
152/// where a wayward crab is not.
153#[derive(Clone, Copy, PartialEq, Eq, Debug)]
154enum Intent {
155    Defend,
156    Recruit,
157    Attack,
158}
159
160/// What the bot wants to do this tick, and whether its hand could get there
161/// in time to do it.
162///
163/// A bot's cursor is wherever it last placed a signpost, read off the board
164/// rather than stored, so the bot stays a pure function of the state and
165/// every peer of an online match derives the same move for it.
166pub fn bot_action(board: &Board, player: PlayerId, level: BotLevel) -> PlayerAction {
167    let (wanted, intent) = decide(board, player, level);
168    let wanted = fumble(wanted, player, level, board.ticks(), board.seed());
169    // Only a placement has a tile to walk to, or a post to cost; nothing
170    // else waits or weighs.
171    if let PlayerAction::Place { x, y, .. } = wanted
172        && (!hand_arrived(board, player, level, x, y)
173            || !worth_the_walk(board, player, level, x, y, intent))
174    {
175        return PlayerAction::None;
176    }
177    wanted
178}
179
180/// splitmix64's finaliser: the shifts and multiply that fold a struck-together
181/// key's high bits down over its low ones. Shared by the two draws this file
182/// makes from state that is not itself random.
183///
184/// It does not make every bit as good as every other - a multiply carries
185/// upwards only, so the lowest bits stay the least mixed. Whoever reads the
186/// result has to know which end to read from; see [`slips`].
187fn avalanche(mut z: u64) -> u64 {
188    z ^= z >> 31;
189    z = z.wrapping_mul(0x94D0_49BB_1331_11EB);
190    z ^= z >> 29;
191    z
192}
193
194/// The bot's hand slips: one placement in [`BotLevel::blunder_every`] comes
195/// out pointing the wrong way. A legible difficulty knob, one you can watch
196/// happen, where "thinks less often" is invisible.
197fn fumble(
198    action: PlayerAction,
199    player: PlayerId,
200    level: BotLevel,
201    ticks: u64,
202    seed: u64,
203) -> PlayerAction {
204    let every = level.blunder_every();
205    if every == 0 {
206        return action;
207    }
208    // Only a placement can come out crooked; the tile is still the right one.
209    if let PlayerAction::Place { x, y, dir } = action
210        && slips(ticks, player, seed, every)
211    {
212        return PlayerAction::Place {
213            x,
214            y,
215            dir: dir.right(),
216        };
217    }
218    action
219}
220
221/// Whether the hand slips on a placement made by `player` at `ticks` on the
222/// board `seed` built: one in `every`, and the same one on every peer.
223///
224/// The board's seed is in the draw, and both of the other two are why. This
225/// was `(ticks ^ player).is_multiple_of(every)`, which put each seat on a
226/// residue of its own mod `every`, while [`BotLevel::acts_on`] only offers
227/// a seat the chance to place every `cadence` ticks - and 20 and 8 share a
228/// factor, so the residues were not sampled evenly. The rates came out
229/// 8.9% to 13.7% against an intended 12.5%: a standing handicap on three
230/// seats in four, worth about 4 sigma of seat drift on a 3000-game sweep.
231///
232/// Being a function of the tick alone, it was also the *same* handicap in
233/// every round ever played, so no amount of averaging could find it. The
234/// seed is what varies it from board to board; it is fixed for the life of
235/// a board, consumes no PRNG state, and travels in the snapshot, so peers
236/// and pasted rounds still agree to the tick.
237fn slips(ticks: u64, player: PlayerId, seed: u64, every: u64) -> bool {
238    let z = avalanche(
239        ticks.wrapping_mul(0x9E37_79B9_7F4A_7C15)
240            ^ u64::from(player).wrapping_mul(0xBF58_476D_1CE4_E5B9)
241            ^ seed,
242    );
243    // The draw is `z` scaled into `0..every`, and a slip is the first
244    // bucket. Scaled from the top of the hash rather than taken as
245    // `z % every`, because every blunder rate here is a power of two and
246    // `%` on one of those reads the lowest bits - the bits a multiplying
247    // hash never carries into. Written that way the seat bias survived the
248    // rewrite at Easy's one-in-four: rates 23.7% to 26.3%, where reading
249    // the top bits gives 24.9% to 25.2%.
250    (u128::from(z) * u128::from(every)) >> 64 == 0
251}
252
253/// Whether this placement is worth the trip.
254///
255/// Defending is always worth it: a gull reaching a castle carries off half
256/// the bank. Chasing a rival across the board is not: the walk is dead time
257/// that costs more than the raid pays. Offence has to be on the way.
258///
259/// Deliberately not a rule about evicting its own posts: churn helps rather
260/// than hurts, because a crab walks a tile every twenty ticks and a post
261/// aimed where one was four seconds ago is aimed at nothing.
262fn worth_the_walk(
263    board: &Board,
264    player: PlayerId,
265    level: BotLevel,
266    x: u8,
267    y: u8,
268    intent: Intent,
269) -> bool {
270    if intent != Intent::Attack {
271        return true;
272    }
273    let Some((from_x, from_y, _)) = board.newest_signpost_of(player) else {
274        return true;
275    };
276    let steps = i32::from(x.abs_diff(from_x)) + i32::from(y.abs_diff(from_y));
277    steps <= level.reach()
278}
279
280/// Whether the bot's cursor has had time to reach `(x, y)` since its last
281/// placement. With nothing of its standing it has been idle for at least a
282/// signpost's lifetime, which is longer than any walk across a beach.
283fn hand_arrived(board: &Board, player: PlayerId, level: BotLevel, x: u8, y: u8) -> bool {
284    let Some((from_x, from_y, since)) = board.newest_signpost_of(player) else {
285        return true;
286    };
287    let steps = u64::from(x.abs_diff(from_x)) + u64::from(y.abs_diff(from_y));
288    // Charged the way a player's hand works: the first tap moves a tile at
289    // once, and only a held key waits for the repeat to kick in. So a
290    // neighbouring tile is free, and a trip across the beach costs about what
291    // it costs a human.
292    let walk = match steps {
293        0 | 1 => 0,
294        far => CURSOR_LIFT + (far - 1) * level.cursor_ticks_per_tile(),
295    };
296    board.ticks().saturating_sub(since) >= walk
297}
298
299/// What the bot wants to do this tick.
300///
301/// Four strategies in order of what a round is won and lost on: a gull about
302/// to raid you costs half your bank, a jackpot is worth crossing the beach
303/// for, a wayward crab is bread and butter, and shoving a gull at the leader
304/// is the luxury you buy last. Each answers `None` when it has nothing to say.
305fn decide(board: &Board, player: PlayerId, level: BotLevel) -> (PlayerAction, Intent) {
306    let nothing = (PlayerAction::None, Intent::Recruit);
307    if !level.acts_on(player, board.ticks()) {
308        return nothing;
309    }
310    let Some(castle) = castle_of(board, player) else {
311        return nothing;
312    };
313    defend(board, player, level, castle)
314        .or_else(|| chase_jackpot(board, player, level, castle))
315        .or_else(|| recruit(board, player, level, castle))
316        .or_else(|| attack(board, player, level))
317        .unwrap_or(nothing)
318}
319
320/// Turn back the nearest gull threatening our castle.
321fn defend(
322    board: &Board,
323    player: PlayerId,
324    level: BotLevel,
325    castle: u16,
326) -> Option<(PlayerAction, Intent)> {
327    let mut best: Option<(i32, u16, Direction)> = None;
328    for gull in board.gulls() {
329        if gull.state != GullState::Walking {
330            continue;
331        }
332        let d = manhattan(board, gull.tile, castle);
333        if d > level.defend_radius() {
334            continue;
335        }
336        // A gull walking away is no threat, and a post that turns it round
337        // would make one: only a bird whose next step closes on the castle
338        // is worth a signpost, the way recruit and attack leave alone a
339        // creature already heading the right way.
340        let closing = board
341            .step(gull.tile, gull.dir)
342            .is_some_and(|next| manhattan(board, next, castle) < d);
343        if closing && best.is_none_or(|(bd, ..)| d < bd) {
344            best = Some((d, gull.tile, gull.dir));
345        }
346    }
347    let (_, tile, dir) = best?;
348    // Fierce shoves the gull into kelp beside its next tile when that is
349    // safe: a walking gull cannot enter kelp, so the sim turns it along the
350    // weed instead, and the shove is only taken when neither turn brings
351    // the bird closer than sending it back the way it came. Otherwise, and
352    // for the terrain-blind levels, reverse it.
353    let out = board
354        .step(tile, dir)
355        .filter(|_| level.reads_terrain())
356        .and_then(|target| safe_kelp_shove(board, target, tile, dir, castle))
357        .unwrap_or_else(|| dir.reverse());
358    let action = place_ahead(board, player, tile, dir, out, level)?;
359    Some((action, Intent::Defend))
360}
361
362/// Fierce only: go after a jackpot anywhere on the beach. A golden crab pays
363/// fifty and a molt turns the whole board toward home, so unlike chasing a
364/// rival this walk pays for itself.
365fn chase_jackpot(
366    board: &Board,
367    player: PlayerId,
368    level: BotLevel,
369    castle: u16,
370) -> Option<(PlayerAction, Intent)> {
371    if level.jackpot_reach() == 0 {
372        return None;
373    }
374    let mut best: Option<(u32, i32, u16, Direction)> = None;
375    for crab in board.crabs() {
376        let worth = match crab.kind {
377            CrabKind::Golden => 50,
378            CrabKind::Molting => 30, // 5 points, and then the lure
379            CrabKind::Giant => 10,
380            CrabKind::Common | CrabKind::Juvenile | CrabKind::Sparkling => continue,
381        };
382        let d = manhattan(board, crab.tile, castle);
383        if d == 0 || d > level.jackpot_reach() || crab.dir == toward(board, crab.tile, castle) {
384            continue;
385        }
386        if best.is_none_or(|(bw, bd, ..)| worth > bw || (worth == bw && d < bd)) {
387            best = Some((worth, d, crab.tile, crab.dir));
388        }
389    }
390    let (_, _, tile, dir) = best?;
391    let ahead = board.step(tile, dir).unwrap_or(tile);
392    let home = homeward(board, ahead, castle, level, TileKind::Pool);
393    let action = place_ahead(board, player, tile, dir, home, level)?;
394    Some((action, Intent::Recruit))
395}
396
397/// Turn the best wayward crab in range toward home.
398fn recruit(
399    board: &Board,
400    player: PlayerId,
401    level: BotLevel,
402    castle: u16,
403) -> Option<(PlayerAction, Intent)> {
404    let mut best: Option<(u32, i32, u16, Direction)> = None;
405    for crab in board.crabs() {
406        let d = manhattan(board, crab.tile, castle);
407        if d == 0 || d > level.recruit_radius() {
408            continue;
409        }
410        if crab.dir == toward(board, crab.tile, castle) {
411            continue; // already coming to us
412        }
413        // The easy bot cannot tell a jackpot from a common crab and simply
414        // grabs at whatever is closest.
415        let value = if level.values_the_catch() {
416            crab.kind.value()
417        } else {
418            1
419        };
420        if best.is_none_or(|(bv, bd, ..)| value > bv || (value == bv && d < bd)) {
421            best = Some((value, d, crab.tile, crab.dir));
422        }
423    }
424    let (_, _, tile, dir) = best?;
425    let ahead = board.step(tile, dir).unwrap_or(tile);
426    let home = homeward(board, ahead, castle, level, TileKind::Pool);
427    let action = place_ahead(board, player, tile, dir, home, level)?;
428    Some((action, Intent::Recruit))
429}
430
431/// Fierce only: steer a gull that is already near the leading rival's castle
432/// the rest of the way in.
433fn attack(board: &Board, player: PlayerId, level: BotLevel) -> Option<(PlayerAction, Intent)> {
434    if level != BotLevel::Hard {
435        return None;
436    }
437    let target = leading_rival_castle(board, player)?;
438    for gull in board.gulls() {
439        if gull.state != GullState::Walking {
440            continue;
441        }
442        let d = manhattan(board, gull.tile, target);
443        if d == 0 || d > ATTACK_RADIUS || gull.dir == toward(board, gull.tile, target) {
444            continue;
445        }
446        let ahead = board.step(gull.tile, gull.dir).unwrap_or(gull.tile);
447        let aim = homeward(board, ahead, target, level, TileKind::Kelp);
448        if let Some(action) = place_ahead(board, player, gull.tile, gull.dir, aim, level) {
449            return Some((action, Intent::Attack));
450        }
451    }
452    None
453}
454
455/// The castle of the highest-scoring rival (ties to the lowest seat).
456fn leading_rival_castle(board: &Board, player: PlayerId) -> Option<u16> {
457    let scores = board.scores();
458    let mut best: Option<(u32, PlayerId)> = None;
459    for seat in 0..crate::sim::MAX_PLAYERS as PlayerId {
460        if seat == player {
461            continue;
462        }
463        let Some(_) = castle_of(board, seat) else {
464            continue;
465        };
466        if best.is_none_or(|(s, _)| scores[seat as usize] > s) {
467            best = Some((scores[seat as usize], seat));
468        }
469    }
470    best.and_then(|(_, seat)| castle_of(board, seat))
471}
472
473/// A seat's castle as a tile index, the form the bot's distance and step
474/// helpers speak.
475fn castle_of(board: &Board, player: PlayerId) -> Option<u16> {
476    board.castle_of(player).map(|(x, y)| board.index_of(x, y))
477}
478
479fn manhattan(board: &Board, a: u16, b: u16) -> i32 {
480    let (ax, ay) = board.coords(a);
481    let (bx, by) = board.coords(b);
482    (ax - bx).abs() + (ay - by).abs()
483}
484
485/// Greedy direction from `from` toward `to` (the sim's lure tiebreak).
486fn toward(board: &Board, from: u16, to: u16) -> Direction {
487    let (fx, fy) = board.coords(from);
488    let (tx, ty) = board.coords(to);
489    Direction::toward(tx - fx, ty - fy)
490}
491
492/// The other axis's direction toward `to`, when that axis has ground left
493/// to cover. Straight lines have no second option.
494fn cross_toward(board: &Board, from: u16, to: u16) -> Option<Direction> {
495    let (fx, fy) = board.coords(from);
496    let (tx, ty) = board.coords(to);
497    let (dx, dy) = (tx - fx, ty - fy);
498    match toward(board, from, to) {
499        Direction::Left | Direction::Right => (dy != 0).then(|| Direction::toward(0, dy)),
500        Direction::Up | Direction::Down => (dx != 0).then(|| Direction::toward(dx, 0)),
501    }
502}
503
504/// The kind of the tile one step from `tile` in `dir`, if it is on the board.
505fn kind_ahead(board: &Board, tile: u16, dir: Direction) -> Option<TileKind> {
506    let target = board.step(tile, dir)?;
507    let (x, y) = board.coords(target);
508    Some(board.tile_at(x as u8, y as u8))
509}
510
511/// Whether a walking gull standing on `tile` may step toward `dir`: the
512/// bot's reading of the sim's passability (no wall on the edge, a tile on
513/// the far side, and neither rock nor kelp there), so it can predict what
514/// the wall resolution will do with a post it is about to plant.
515fn gull_passable(board: &Board, tile: u16, dir: Direction) -> bool {
516    let (x, y) = board.coords_u8(tile);
517    !board.wall_at(x, y, dir)
518        && !matches!(
519            kind_ahead(board, tile, dir),
520            None | Some(TileKind::Rock | TileKind::Kelp)
521        )
522}
523
524/// Where the sim sends a gull of `handed` hand that a signpost on `tile`
525/// has aimed at kelp: its preferred side, else the other, else back. The
526/// same ladder as the board's wall resolution, which the bot cannot call.
527fn kelp_turn(board: &Board, tile: u16, into_kelp: Direction, handed: Handedness) -> Direction {
528    let (first, second) = match handed {
529        Handedness::Left => (into_kelp.left(), into_kelp.right()),
530        Handedness::Right => (into_kelp.right(), into_kelp.left()),
531    };
532    if gull_passable(board, tile, first) {
533        first
534    } else if gull_passable(board, tile, second) {
535        second
536    } else {
537        into_kelp.reverse()
538    }
539}
540
541/// A direction out of `target` (the tile ahead of a gull on `tile` walking
542/// `travel`) that aims straight into kelp, when the shove is safe. Kelp is
543/// a wall to a walking gull, so the sim turns the bird along the weed by
544/// its handedness, and a right-handed gull turned to its right may be
545/// facing the castle. The shove is only offered when, whichever hand the
546/// gull has, the turn leaves it no closer than plain reversal would (which
547/// sends it back onto `tile`). Kelp straight ahead does not count: that is
548/// the gull's own line, and a post there changes nothing.
549fn safe_kelp_shove(
550    board: &Board,
551    target: u16,
552    tile: u16,
553    travel: Direction,
554    castle: u16,
555) -> Option<Direction> {
556    let reversed = manhattan(board, tile, castle);
557    Direction::ALL.into_iter().find(|&dir| {
558        dir != travel
559            && kind_ahead(board, target, dir) == Some(TileKind::Kelp)
560            && [Handedness::Left, Handedness::Right]
561                .into_iter()
562                .all(|handed| {
563                    let turned = kelp_turn(board, target, dir, handed);
564                    board
565                        .step(target, turned)
566                        .is_none_or(|next| manhattan(board, next, castle) >= reversed)
567                })
568    })
569}
570
571/// Which way to send a walker standing on `from` to reach `to`. Fierce
572/// steps around `hazard`, the terrain that stops this walker (tide pools
573/// halve a crab's speed, kelp walls out a gull), when the other axis makes
574/// progress too; the other levels always take the greedy step.
575fn homeward(board: &Board, from: u16, to: u16, level: BotLevel, hazard: TileKind) -> Direction {
576    let greedy = toward(board, from, to);
577    if !level.reads_terrain() || kind_ahead(board, from, greedy) != Some(hazard) {
578        return greedy;
579    }
580    match cross_toward(board, from, to) {
581        Some(other) if kind_ahead(board, from, other) != Some(hazard) => other,
582        _ => greedy,
583    }
584}
585
586/// Place a signpost on the tile ahead of a creature, pointing `dir_out`, if
587/// that tile can take one of ours.
588fn place_ahead(
589    board: &Board,
590    player: PlayerId,
591    creature_tile: u16,
592    creature_dir: Direction,
593    dir_out: Direction,
594    level: BotLevel,
595) -> Option<PlayerAction> {
596    let target = board.step(creature_tile, creature_dir)?;
597    let (x, y) = board.coords(target);
598    let (x, y) = (x as u8, y as u8);
599    if board.tile_at(x, y) != TileKind::Empty {
600        return None;
601    }
602    // A turnstile deflects whatever crosses it, so a post aimed straight
603    // into one buys a turn the log immediately takes back. Fierce spends
604    // the signpost somewhere it survives instead.
605    if level.reads_terrain()
606        && matches!(
607            kind_ahead(board, target, dir_out),
608            Some(TileKind::Turnstile { .. })
609        )
610    {
611        return None;
612    }
613    match board.signpost_at(x, y) {
614        Some(sp) if sp.owner != player => None,
615        Some(sp) if sp.dir == dir_out => None, // already doing its job
616        _ => Some(PlayerAction::Place { x, y, dir: dir_out }),
617    }
618}
619
620#[cfg(test)]
621mod tests;