1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
//! Signposts: placing them under the cap, wearing them out, and letting
//! them wash away.
//!
//! The cap and the expiry are the versus balance valves (spec 3.3): three
//! standing at once, a fourth evicting the oldest, and every one of them
//! fading after ten seconds so no fortification is permanent.
use super::*;
/// Why the sim turned an action down. Read off the same branches that
/// decide it, so a bot hearing a reason is hearing the rule that applied.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Refusal {
/// The action named a seat that does not exist.
NoSeat,
OffBoard,
Rock,
Castle,
Spawner,
Turnstile,
Kelp,
Pool,
/// Somebody else's signpost stands there.
RivalPost,
/// Every post is spent and this board refuses rather than evicts.
OutOfPosts,
/// A removal aimed at a tile with no signpost on it.
NoPost,
}
impl Refusal {
/// The tile's own objection to a signpost, if it has one: only sand
/// takes one.
fn of_tile(tile: TileKind) -> Option<Refusal> {
match tile {
TileKind::Empty => None,
TileKind::Rock => Some(Refusal::Rock),
TileKind::Castle(_) => Some(Refusal::Castle),
TileKind::Spawner(_) => Some(Refusal::Spawner),
TileKind::Turnstile { .. } => Some(Refusal::Turnstile),
TileKind::Kelp => Some(Refusal::Kelp),
TileKind::Pool => Some(Refusal::Pool),
}
}
/// The protocol's word for it (see `docs/bot-protocol.md`).
pub fn token(self) -> &'static str {
match self {
Refusal::NoSeat => "no_seat",
Refusal::OffBoard => "off_board",
Refusal::Rock => "rock",
Refusal::Castle => "castle",
Refusal::Spawner => "spawner",
Refusal::Turnstile => "turnstile",
Refusal::Kelp => "kelp",
Refusal::Pool => "pool",
Refusal::RivalPost => "rival_post",
Refusal::OutOfPosts => "out_of_posts",
Refusal::NoPost => "no_post",
}
}
}
impl Board {
/// Whether a placement at `(x, y)` would succeed, without mutating.
/// Mirrors [`Board::place_signpost`] exactly; the UI uses it for instant
/// denied feedback on a queued (not yet applied) action.
pub fn can_place_signpost(&self, player: PlayerId, x: u8, y: u8) -> bool {
self.placement_refusal(player, x, y).is_none()
}
/// Why a placement at `(x, y)` would be refused, or `None` when it
/// would stand. The rule itself: [`Board::can_place_signpost`] is this
/// with the reason thrown away, so the two cannot disagree, and a bot
/// is told the same reason the sim acted on.
pub fn placement_refusal(&self, player: PlayerId, x: u8, y: u8) -> Option<Refusal> {
if seat(player).is_none() {
return Some(Refusal::NoSeat);
}
if !self.in_bounds(i32::from(x), i32::from(y)) {
return Some(Refusal::OffBoard);
}
let t = self.index(i32::from(x), i32::from(y)) as usize;
if let Some(refusal) = Refusal::of_tile(self.grid.tiles[t]) {
return Some(refusal);
}
match self.signposts[t] {
// Your own signpost re-points in place; a rival's blocks.
Some(sp) => (sp.owner != player).then_some(Refusal::RivalPost),
// Empty tile: at the cap, only the evicting rule still places.
None => (self.signpost_count(player) >= self.rules.signpost_cap as usize
&& self.rules.cap_policy != CapPolicy::Evict)
.then_some(Refusal::OutOfPosts),
}
}
/// Why a removal at `(x, y)` would do nothing, or `None` when it would
/// take a post up. The same rule as [`Board::remove_signpost`].
pub fn removal_refusal(&self, player: PlayerId, x: u8, y: u8) -> Option<Refusal> {
if !self.in_bounds(i32::from(x), i32::from(y)) {
return Some(Refusal::OffBoard);
}
let t = self.index(i32::from(x), i32::from(y)) as usize;
match self.signposts[t] {
Some(sp) if sp.owner == player => None,
Some(_) => Some(Refusal::RivalPost),
None => Some(Refusal::NoPost),
}
}
/// Whether a refusal at `(x, y)` is the *inventory* talking rather than
/// the tile: the player has spent their posts and this board rejects
/// rather than evicts.
///
/// The one branch of [`Board::can_place_signpost`] a player can fix by
/// picking up a signpost instead of by aiming somewhere else, which the
/// UI says differently. Read off the same rule so the two cannot drift:
/// under `Evict` it is never true, the placement succeeding and taking
/// the oldest in trade.
pub fn out_of_signposts(&self, player: PlayerId, x: u8, y: u8) -> bool {
// The inventory has to be the *only* thing in the way: a rock with
// a spent inventory refuses for two reasons, and a post in hand
// would not have gone there either. The refusal names the tile
// first, so it says "out of posts" only when nothing else would.
self.placement_refusal(player, x, y) == Some(Refusal::OutOfPosts)
}
/// Spec §3.3: signposts go on empty sand only, not on castles, rocks,
/// spawners, or a tile that already has one. At the cap, the outcome
/// depends on the board's `CapPolicy`: evict the player's oldest (versus)
/// or reject the placement (puzzle inventory).
pub fn place_signpost(&mut self, player: PlayerId, x: u8, y: u8, dir: Direction) -> bool {
if !self.can_place_signpost(player, x, y) {
return false;
}
let t = self.index(i32::from(x), i32::from(y)) as usize;
// Re-pointing your own signpost refreshes it to Full and makes it
// your newest for cap eviction; the count is unchanged so the cap
// never triggers.
if self.signposts[t].is_none()
&& self.signpost_count(player) >= self.rules.signpost_cap as usize
{
// CapPolicy::Evict (Reject was filtered above): drop the oldest.
// At the cap there is one to evict, unless the cap is zero,
// which every parser refuses under Evict; a board built by
// hand that way gets a refusal here rather than a panic.
let Some(i) = self.oldest_signpost(player) else {
return false;
};
self.signposts[i] = None;
}
self.stamp_signpost(t, player, dir);
true
}
/// Write a fresh full-health signpost into slot `t`, taking the next
/// sequence number (which makes it the player's newest for eviction).
pub(super) fn stamp_signpost(&mut self, t: usize, player: PlayerId, dir: Direction) {
let seq = self.signpost_seq;
self.signpost_seq += 1;
self.signposts[t] = Some(Signpost {
dir,
owner: player,
health: SignpostHealth::Full,
seq,
placed: self.tick,
});
}
/// Remaining life of a signpost as a 0..=1 fraction (always 1 under
/// puzzle rules, where posts are permanent).
pub fn signpost_fade(&self, sp: &Signpost) -> f32 {
match self.rules.cap_policy {
CapPolicy::Reject => 1.0,
CapPolicy::Evict => {
let age = self.tick.saturating_sub(sp.placed) as f32;
(1.0 - age / f32::from(SIGNPOST_LIFETIME as u16)).max(0.0)
}
}
}
pub(super) fn expire_signposts(&mut self) {
if self.rules.cap_policy != CapPolicy::Evict {
return;
}
let now = self.tick;
for slot in &mut self.signposts {
if let Some(sp) = slot
&& now.saturating_sub(sp.placed) >= u64::from(SIGNPOST_LIFETIME)
{
*slot = None;
}
}
}
/// Where a player's most recent signpost stands and when they placed it:
/// `(x, y, tick)`.
///
/// The anchor for a bot's cursor (see [`crate::sim::bot_action`]): the
/// last tile it reached, so the walk to the next can be charged for.
/// Read from the board, so the bot stays a pure function of the state
/// and every peer derives the same move for an AI seat.
pub fn newest_signpost_of(&self, player: PlayerId) -> Option<(u8, u8, u64)> {
self.signposts
.iter()
.enumerate()
.filter_map(|(tile, slot)| {
let sp = slot.as_ref().filter(|sp| sp.owner == player)?;
Some((tile as u16, sp.seq, sp.placed))
})
.max_by_key(|&(_, seq, _)| seq)
.map(|(tile, _, placed)| {
let (x, y) = self.coords(tile);
(x as u8, y as u8, placed)
})
}
/// The slot holding `player`'s oldest signpost: the first planted or
/// re-pointed, which is the one the cap takes and the one that runs out
/// first.
fn oldest_signpost(&self, player: PlayerId) -> Option<usize> {
self.signposts
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.filter(|sp| sp.owner == player).map(|sp| (sp.seq, i)))
.min()
.map(|(_, i)| i)
}
/// The signpost `player` would lose to the next one they plant, if the
/// next one costs them one: under the evicting rule at the cap, their
/// oldest. `None` with room to spare, and always under puzzle rules,
/// where a full inventory refuses instead.
///
/// The UI marks this post as the one about to go. Asked of the same
/// slot the eviction takes, so the mark cannot point at the wrong one.
pub fn next_to_go(&self, player: PlayerId) -> Option<(u8, u8)> {
if self.rules.cap_policy != CapPolicy::Evict
|| self.signpost_count(player) < usize::from(self.rules.signpost_cap)
{
return None;
}
self.oldest_signpost(player)
.map(|i| self.coords_u8(i as u16))
}
/// How much life each of `player`'s signposts has left, oldest first,
/// into `out` (cleared first): the order the cap takes them in, and
/// under versus rules the order they run out in.
pub fn signpost_lives(&self, player: PlayerId, out: &mut Vec<f32>) {
out.clear();
let mut posts: Vec<&Signpost> = self
.signposts
.iter()
.flatten()
.filter(|sp| sp.owner == player)
.collect();
posts.sort_by_key(|sp| sp.seq);
out.extend(posts.into_iter().map(|sp| self.signpost_fade(sp)));
}
/// How many signposts `player` currently has on the board.
pub fn signpost_count(&self, player: PlayerId) -> usize {
self.signposts
.iter()
.flatten()
.filter(|sp| sp.owner == player)
.count()
}
/// Players may only remove their own signposts.
pub fn remove_signpost(&mut self, player: PlayerId, x: u8, y: u8) -> bool {
if !self.in_bounds(i32::from(x), i32::from(y)) {
return false;
}
let t = self.index(i32::from(x), i32::from(y)) as usize;
match self.signposts[t] {
Some(sp) if sp.owner == player => {
self.signposts[t] = None;
true
}
_ => false,
}
}
pub fn signpost_at(&self, x: u8, y: u8) -> Option<Signpost> {
assert!(self.in_bounds(i32::from(x), i32::from(y)));
self.signposts[self.index(i32::from(x), i32::from(y)) as usize]
}
/// The current signpost cap rule, for serialization.
pub fn signpost_rule(&self) -> (u8, CapPolicy) {
(self.rules.signpost_cap, self.rules.cap_policy)
}
}