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    /// Spec ยง3.3: signposts go on empty sand only, not on castles, rocks,
12    /// spawners, or a tile that already has one. At the cap, the outcome
13    /// depends on the board's `CapPolicy`: evict the player's oldest (versus)
14    /// or reject the placement (puzzle inventory).
15    /// Whether a placement at `(x, y)` would succeed, without mutating.
16    /// Mirrors [`Board::place_signpost`] exactly; the UI uses it for instant
17    /// denied feedback on a queued (not yet applied) action.
18    pub fn can_place_signpost(&self, player: PlayerId, x: u8, y: u8) -> bool {
19        if seat(player).is_none() || !self.in_bounds(i32::from(x), i32::from(y)) {
20            return false;
21        }
22        let t = self.index(i32::from(x), i32::from(y)) as usize;
23        if self.tiles[t] != TileKind::Empty {
24            return false;
25        }
26        match self.signposts[t] {
27            // Your own signpost re-points in place; a rival's blocks.
28            Some(sp) => sp.owner == player,
29            // Empty tile: at the cap, only the evicting rule still places.
30            None => {
31                self.signpost_count(player) < self.signpost_cap as usize
32                    || self.cap_policy == CapPolicy::Evict
33            }
34        }
35    }
36
37    pub fn place_signpost(&mut self, player: PlayerId, x: u8, y: u8, dir: Direction) -> bool {
38        if !self.can_place_signpost(player, x, y) {
39            return false;
40        }
41        let t = self.index(i32::from(x), i32::from(y)) as usize;
42        // Re-pointing your own signpost refreshes it to Full and makes it
43        // your newest for cap eviction; the count is unchanged so the cap
44        // never triggers.
45        if self.signposts[t].is_none() && self.signpost_count(player) >= self.signpost_cap as usize
46        {
47            // CapPolicy::Evict (Reject was filtered above): drop the oldest.
48            let oldest = self
49                .signposts
50                .iter()
51                .enumerate()
52                .filter_map(|(i, slot)| slot.filter(|sp| sp.owner == player).map(|sp| (sp.seq, i)))
53                .min();
54            let (_, i) = oldest.expect("at cap implies at least one signpost");
55            self.signposts[i] = None;
56        }
57        self.stamp_signpost(t, player, dir);
58        true
59    }
60
61    /// Write a fresh full-health signpost into slot `t`, taking the next
62    /// sequence number (which makes it the player's newest for eviction).
63    pub(super) fn stamp_signpost(&mut self, t: usize, player: PlayerId, dir: Direction) {
64        let seq = self.signpost_seq;
65        self.signpost_seq += 1;
66        self.signposts[t] = Some(Signpost {
67            dir,
68            owner: player,
69            health: SignpostHealth::Full,
70            seq,
71            placed: self.tick,
72        });
73    }
74
75    /// Remaining life of a signpost as a 0..=1 fraction (always 1 under
76    /// puzzle rules, where posts are permanent).
77    pub fn signpost_fade(&self, sp: &Signpost) -> f32 {
78        match self.cap_policy {
79            CapPolicy::Reject => 1.0,
80            CapPolicy::Evict => {
81                let age = self.tick.saturating_sub(sp.placed) as f32;
82                (1.0 - age / f32::from(SIGNPOST_LIFETIME as u16)).max(0.0)
83            }
84        }
85    }
86
87    pub(super) fn expire_signposts(&mut self) {
88        if self.cap_policy != CapPolicy::Evict {
89            return;
90        }
91        let now = self.tick;
92        for slot in &mut self.signposts {
93            if let Some(sp) = slot
94                && now.saturating_sub(sp.placed) >= u64::from(SIGNPOST_LIFETIME)
95            {
96                *slot = None;
97            }
98        }
99    }
100
101    /// Where a player's most recent signpost stands and when they placed it:
102    /// `(x, y, tick)`.
103    ///
104    /// This is the anchor for a bot's cursor (see [`crate::sim::bot_action`]):
105    /// the last tile it reached, so the walk to the next one can be charged
106    /// for. Reading it from the board keeps the bot a pure function of the
107    /// state, so every peer of an online match derives the same move for an
108    /// AI seat.
109    pub fn newest_signpost_of(&self, player: PlayerId) -> Option<(u8, u8, u64)> {
110        self.signposts
111            .iter()
112            .enumerate()
113            .filter_map(|(tile, slot)| {
114                let sp = slot.as_ref().filter(|sp| sp.owner == player)?;
115                Some((tile as u16, sp.seq, sp.placed))
116            })
117            .max_by_key(|&(_, seq, _)| seq)
118            .map(|(tile, _, placed)| {
119                let (x, y) = self.coords(tile);
120                (x as u8, y as u8, placed)
121            })
122    }
123
124    /// How many signposts `player` currently has on the board.
125    pub fn signpost_count(&self, player: PlayerId) -> usize {
126        self.signposts
127            .iter()
128            .flatten()
129            .filter(|sp| sp.owner == player)
130            .count()
131    }
132
133    /// Players may only remove their own signposts.
134    pub fn remove_signpost(&mut self, player: PlayerId, x: u8, y: u8) -> bool {
135        if !self.in_bounds(i32::from(x), i32::from(y)) {
136            return false;
137        }
138        let t = self.index(i32::from(x), i32::from(y)) as usize;
139        match self.signposts[t] {
140            Some(sp) if sp.owner == player => {
141                self.signposts[t] = None;
142                true
143            }
144            _ => false,
145        }
146    }
147
148    pub fn signpost_at(&self, x: u8, y: u8) -> Option<Signpost> {
149        assert!(self.in_bounds(i32::from(x), i32::from(y)));
150        self.signposts[self.index(i32::from(x), i32::from(y)) as usize]
151    }
152
153    /// The current signpost cap rule, for serialization.
154    pub fn signpost_rule(&self) -> (u8, CapPolicy) {
155        (self.signpost_cap, self.cap_policy)
156    }
157}