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;