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
//! 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::*;
impl Board {
/// 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).
/// 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 {
if seat(player).is_none() || !self.in_bounds(i32::from(x), i32::from(y)) {
return false;
}
let t = self.index(i32::from(x), i32::from(y)) as usize;
if self.tiles[t] != TileKind::Empty {
return false;
}
match self.signposts[t] {
// Your own signpost re-points in place; a rival's blocks.
Some(sp) => sp.owner == player,
// Empty tile: at the cap, only the evicting rule still places.
None => {
self.signpost_count(player) < self.signpost_cap as usize
|| self.cap_policy == CapPolicy::Evict
}
}
}
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.signpost_cap as usize
{
// CapPolicy::Evict (Reject was filtered above): drop the oldest.
let oldest = self
.signposts
.iter()
.enumerate()
.filter_map(|(i, slot)| slot.filter(|sp| sp.owner == player).map(|sp| (sp.seq, i)))
.min();
let (_, i) = oldest.expect("at cap implies at least one signpost");
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.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.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)`.
///
/// This is the anchor for a bot's cursor (see [`crate::sim::bot_action`]):
/// the last tile it reached, so the walk to the next one can be charged
/// for. Reading it from the board keeps the bot a pure function of the
/// state, so every peer of an online match 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)
})
}
/// 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.signpost_cap, self.cap_policy)
}
}