Skip to main content

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}