pinch_points/sim/board/signposts.rs
1//! Signposts: placing them under the cap, wearing them out, and letting
2//! them wash away.
3//!
4//! The cap and the expiry are the versus balance valves (spec 3.3): three
5//! standing at once, a fourth evicting the oldest, and every one of them
6//! fading after ten seconds so no fortification is permanent.
7
8use super::*;
9
10impl Board {
11 /// Whether a placement at `(x, y)` would succeed, without mutating.
12 /// Mirrors [`Board::place_signpost`] exactly; the UI uses it for instant
13 /// denied feedback on a queued (not yet applied) action.
14 pub fn can_place_signpost(&self, player: PlayerId, x: u8, y: u8) -> bool {
15 if seat(player).is_none() || !self.in_bounds(i32::from(x), i32::from(y)) {
16 return false;
17 }
18 let t = self.index(i32::from(x), i32::from(y)) as usize;
19 if self.grid.tiles[t] != TileKind::Empty {
20 return false;
21 }
22 match self.signposts[t] {
23 // Your own signpost re-points in place; a rival's blocks.
24 Some(sp) => sp.owner == player,
25 // Empty tile: at the cap, only the evicting rule still places.
26 None => {
27 self.signpost_count(player) < self.rules.signpost_cap as usize
28 || self.rules.cap_policy == CapPolicy::Evict
29 }
30 }
31 }
32
33 /// Whether a refusal at `(x, y)` is the *inventory* talking rather than
34 /// the tile: the player has spent their posts and this board rejects
35 /// rather than evicts.
36 ///
37 /// The one branch of [`Board::can_place_signpost`] a player can fix by
38 /// picking up a signpost instead of by aiming somewhere else, and the
39 /// UI says so differently. Read off the same rule so the two cannot
40 /// drift: under `Evict` this is never true, because the placement
41 /// succeeds and takes the oldest in trade.
42 pub fn out_of_signposts(&self, player: PlayerId, x: u8, y: u8) -> bool {
43 if self.rules.cap_policy != CapPolicy::Reject
44 || seat(player).is_none()
45 || !self.in_bounds(i32::from(x), i32::from(y))
46 {
47 return false;
48 }
49 let t = self.index(i32::from(x), i32::from(y)) as usize;
50 // The inventory has to be the *only* thing in the way. A rock with
51 // a spent inventory refuses for two reasons at once, and "you have
52 // none left" is the wrong one to say: a post in hand would not have
53 // gone there either.
54 self.grid.tiles[t] == TileKind::Empty
55 && self.signposts[t].is_none()
56 && self.signpost_count(player) >= self.rules.signpost_cap as usize
57 }
58
59 /// Spec ยง3.3: signposts go on empty sand only, not on castles, rocks,
60 /// spawners, or a tile that already has one. At the cap, the outcome
61 /// depends on the board's `CapPolicy`: evict the player's oldest (versus)
62 /// or reject the placement (puzzle inventory).
63 pub fn place_signpost(&mut self, player: PlayerId, x: u8, y: u8, dir: Direction) -> bool {
64 if !self.can_place_signpost(player, x, y) {
65 return false;
66 }
67 let t = self.index(i32::from(x), i32::from(y)) as usize;
68 // Re-pointing your own signpost refreshes it to Full and makes it
69 // your newest for cap eviction; the count is unchanged so the cap
70 // never triggers.
71 if self.signposts[t].is_none()
72 && self.signpost_count(player) >= self.rules.signpost_cap as usize
73 {
74 // CapPolicy::Evict (Reject was filtered above): drop the oldest.
75 let oldest = self
76 .signposts
77 .iter()
78 .enumerate()
79 .filter_map(|(i, slot)| slot.filter(|sp| sp.owner == player).map(|sp| (sp.seq, i)))
80 .min();
81 // At the cap there is one to evict, unless the cap is zero,
82 // which every parser refuses under Evict; a board built by
83 // hand that way gets a refusal here rather than a panic.
84 let Some((_, i)) = oldest else {
85 return false;
86 };
87 self.signposts[i] = None;
88 }
89 self.stamp_signpost(t, player, dir);
90 true
91 }
92
93 /// Write a fresh full-health signpost into slot `t`, taking the next
94 /// sequence number (which makes it the player's newest for eviction).
95 pub(super) fn stamp_signpost(&mut self, t: usize, player: PlayerId, dir: Direction) {
96 let seq = self.signpost_seq;
97 self.signpost_seq += 1;
98 self.signposts[t] = Some(Signpost {
99 dir,
100 owner: player,
101 health: SignpostHealth::Full,
102 seq,
103 placed: self.tick,
104 });
105 }
106
107 /// Remaining life of a signpost as a 0..=1 fraction (always 1 under
108 /// puzzle rules, where posts are permanent).
109 pub fn signpost_fade(&self, sp: &Signpost) -> f32 {
110 match self.rules.cap_policy {
111 CapPolicy::Reject => 1.0,
112 CapPolicy::Evict => {
113 let age = self.tick.saturating_sub(sp.placed) as f32;
114 (1.0 - age / f32::from(SIGNPOST_LIFETIME as u16)).max(0.0)
115 }
116 }
117 }
118
119 pub(super) fn expire_signposts(&mut self) {
120 if self.rules.cap_policy != CapPolicy::Evict {
121 return;
122 }
123 let now = self.tick;
124 for slot in &mut self.signposts {
125 if let Some(sp) = slot
126 && now.saturating_sub(sp.placed) >= u64::from(SIGNPOST_LIFETIME)
127 {
128 *slot = None;
129 }
130 }
131 }
132
133 /// Where a player's most recent signpost stands and when they placed it:
134 /// `(x, y, tick)`.
135 ///
136 /// This is the anchor for a bot's cursor (see [`crate::sim::bot_action`]):
137 /// the last tile it reached, so the walk to the next one can be charged
138 /// for. Reading it from the board keeps the bot a pure function of the
139 /// state, so every peer of an online match derives the same move for an
140 /// AI seat.
141 pub fn newest_signpost_of(&self, player: PlayerId) -> Option<(u8, u8, u64)> {
142 self.signposts
143 .iter()
144 .enumerate()
145 .filter_map(|(tile, slot)| {
146 let sp = slot.as_ref().filter(|sp| sp.owner == player)?;
147 Some((tile as u16, sp.seq, sp.placed))
148 })
149 .max_by_key(|&(_, seq, _)| seq)
150 .map(|(tile, _, placed)| {
151 let (x, y) = self.coords(tile);
152 (x as u8, y as u8, placed)
153 })
154 }
155
156 /// How many signposts `player` currently has on the board.
157 pub fn signpost_count(&self, player: PlayerId) -> usize {
158 self.signposts
159 .iter()
160 .flatten()
161 .filter(|sp| sp.owner == player)
162 .count()
163 }
164
165 /// Players may only remove their own signposts.
166 pub fn remove_signpost(&mut self, player: PlayerId, x: u8, y: u8) -> bool {
167 if !self.in_bounds(i32::from(x), i32::from(y)) {
168 return false;
169 }
170 let t = self.index(i32::from(x), i32::from(y)) as usize;
171 match self.signposts[t] {
172 Some(sp) if sp.owner == player => {
173 self.signposts[t] = None;
174 true
175 }
176 _ => false,
177 }
178 }
179
180 pub fn signpost_at(&self, x: u8, y: u8) -> Option<Signpost> {
181 assert!(self.in_bounds(i32::from(x), i32::from(y)));
182 self.signposts[self.index(i32::from(x), i32::from(y)) as usize]
183 }
184
185 /// The current signpost cap rule, for serialization.
186 pub fn signpost_rule(&self) -> (u8, CapPolicy) {
187 (self.rules.signpost_cap, self.rules.cap_policy)
188 }
189}