Skip to main content

shuyi_core/
threat.rs

1//! Threat areas over a square grid: the tiles a piece can strike next
2//! turn ([`strike_area`], [`strike_area_from`]), which threats reach a
3//! tile and whether it is safe ([`Threats`]), how much danger a tile
4//! stands in by the game's own forecast ([`Threats::danger`]), and how far
5//! a piece would walk from a tile to the nearest of some goals
6//! ([`GoalDistance`]). It is the danger zone a player reads: a bot that
7//! plays like a person keeps out of it, a roguelike draws a monster's
8//! reach as an overlay, a tower-defense game shows a tower's coverage.
9//! On them, [`TacticsOrder`] lists the few commands a planning bot tries
10//! first: every piece's best strike, then each piece's step towards the
11//! goals and its step to safety, and the end of the turn always kept
12//! ([`OrderHooks`], [`Kind`]).
13//!
14//! # Rules
15//!
16//! - **Ranges.** A strike range is an inclusive pair of Manhattan
17//!   distances, `(min, max)`, as [`Pos::manhattan`] counts them; a pair
18//!   with `min > max` is empty. A piece's [`Ranges`] are all of its
19//!   weapons, spells or arcs.
20//! - **Areas.** A piece strikes every tile of the grid within one of its
21//!   ranges of a tile it can stop on, and the stop tiles themselves when
22//!   it has any range at all: a piece that can strike threatens where it
23//!   can stand. Which tiles it can stop on is the game's: most often the
24//!   tiles of its [`Reach`](crate::Reach) ([`reachable`](crate::reachable))
25//!   that no other piece holds. A piece that never moves strikes from
26//!   where it stands ([`strike_area_from`]).
27//! - **Every threat counts.** [`Threats::danger`] adds up the forecast of
28//!   every threat that reaches a tile; it never guesses which one the
29//!   other side will choose. A bot that knows the danger zone and the
30//!   forecasts a player reads, and never whom the other side will strike,
31//!   plays fair.
32//! - **Cost.** Building an area walks a diamond around each stop tile,
33//!   row by row, clipped to the grid: stops × (rows × ranges + the tiles
34//!   in range). [`Threats::reaches`] and [`Threats::safe`] look up one bit
35//!   per threat and allocate nothing.
36//!
37//! A piece at `P` on a 5 × 5 grid stops on the `o` tiles (and on its own)
38//! and strikes at 1 to 2. Its area is every tile but the three `.`:
39//!
40//! ```text
41//!  stops      area
42//!  . . . . .  x x x x .
43//!  . o o . .  x x x x x
44//!  . o P o .  x x x x x
45//!  . . o . .  x x x x x
46//!  . . . . .  . x x x .
47//! ```
48//!
49//! ```
50//! use shuyi_core::{Pos, TileSet, strike_area};
51//!
52//! let mut stops = TileSet::new(5, 5);
53//! for (x, y) in [(2, 2), (1, 1), (2, 1), (1, 2), (3, 2), (2, 3)] {
54//!     stops.insert(Pos::new(x, y));
55//! }
56//! let area = strike_area(&stops, &[(1, 2)]);
57//! assert_eq!(area.len(), 25 - 3);
58//! for (x, y) in [(4, 0), (0, 4), (4, 4)] {
59//!     assert!(!area.contains(Pos::new(x, y)));
60//! }
61//! ```
62
63use alloc::collections::BTreeMap;
64use alloc::vec::Vec;
65
66use crate::grid::{Grid, Pos, TileSet};
67use crate::path::flow_field;
68
69mod order;
70
71pub use order::{Kind, OrderHooks, TacticsOrder};
72
73/// A piece's strike ranges: inclusive pairs of Manhattan distances
74/// `(min, max)`. A pair with `min > max` is empty.
75pub type Ranges = [(u32, u32)];
76
77/// The tiles a piece that can stop on `stops` strikes with `ranges`:
78/// every tile of the set's grid within one of `ranges` of a stop tile,
79/// and the stop tiles themselves when `ranges` is not empty. Empty
80/// `ranges` strike nothing. See the module docs for the cost.
81pub fn strike_area(stops: &TileSet, ranges: &Ranges) -> TileSet {
82    let mut area = TileSet::new(stops.width(), stops.height());
83    if ranges.is_empty() {
84        return area;
85    }
86    for stop in stops.iter() {
87        strike_into(&mut area, stop, ranges);
88    }
89    area
90}
91
92/// The tiles a piece that never moves, standing on `at`, strikes with
93/// `ranges` on a `width × height` grid: [`strike_area`] with `at` its
94/// only stop. A tile outside the grid strikes nothing.
95pub fn strike_area_from(width: u16, height: u16, at: Pos, ranges: &Ranges) -> TileSet {
96    let mut stops = TileSet::new(width, height);
97    stops.insert(at);
98    strike_area(&stops, ranges)
99}
100
101/// Adds to `area` every tile within one of `ranges` of `from` (a tile of
102/// `area`'s grid), and `from` itself: the diamond row by row, each row's
103/// two bands clipped to the grid.
104fn strike_into(area: &mut TileSet, from: Pos, ranges: &Ranges) {
105    let width = i32::from(area.width());
106    area.insert(from);
107    for y in 0..i32::from(area.height()) {
108        let dy = from.y.abs_diff(y);
109        for &(min, max) in ranges {
110            // The row's tiles at a distance in `min..=max` are those with
111            // `near <= |dx| <= far`.
112            let Some(far) = max.checked_sub(dy) else {
113                continue;
114            };
115            let far = i32::try_from(far).unwrap_or(i32::MAX);
116            let near = i32::try_from(min.saturating_sub(dy)).unwrap_or(i32::MAX);
117            let left = from.x.saturating_sub(far).max(0)..=from.x.saturating_sub(near);
118            let right = from.x.saturating_add(near)
119                ..from.x.saturating_add(far).saturating_add(1).min(width);
120            for x in left.chain(right) {
121                area.insert(Pos::new(x, y));
122            }
123        }
124    }
125}
126
127/// One piece that threatens tiles: the game's id for it and the tiles it
128/// can strike next turn.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct Threat<P> {
131    /// The game's id of the piece.
132    pub piece: P,
133    /// The tiles it can strike ([`strike_area`], [`strike_area_from`], or
134    /// the game's own).
135    pub area: TileSet,
136}
137
138impl<P> Threat<P> {
139    /// A threat of `piece` over `area`.
140    pub fn new(piece: P, area: TileSet) -> Self {
141        Self { piece, area }
142    }
143}
144
145/// The threats of one side, in the game's order. Answers which reach a
146/// tile, whether a tile is safe, the danger zone, and the danger at a
147/// tile by the game's forecast.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Threats<P> {
150    threats: Vec<Threat<P>>,
151}
152
153impl<P> Default for Threats<P> {
154    fn default() -> Self {
155        Self {
156            threats: Vec::new(),
157        }
158    }
159}
160
161impl<P> FromIterator<Threat<P>> for Threats<P> {
162    fn from_iter<I: IntoIterator<Item = Threat<P>>>(iter: I) -> Self {
163        Self::new(iter.into_iter().collect())
164    }
165}
166
167impl<P> Threats<P> {
168    /// The threats `threats`, kept in their order.
169    pub fn new(threats: Vec<Threat<P>>) -> Self {
170        Self { threats }
171    }
172
173    /// Adds a threat after the others.
174    pub fn push(&mut self, threat: Threat<P>) {
175        self.threats.push(threat);
176    }
177
178    /// Every threat, in order.
179    pub fn as_slice(&self) -> &[Threat<P>] {
180        &self.threats
181    }
182
183    /// How many threats there are.
184    pub fn len(&self) -> usize {
185        self.threats.len()
186    }
187
188    /// Whether there are none.
189    pub fn is_empty(&self) -> bool {
190        self.threats.is_empty()
191    }
192
193    /// The pieces whose area holds `at`, in order. Allocates nothing.
194    pub fn reaches(&self, at: Pos) -> impl Iterator<Item = &P> + '_ {
195        self.threats
196            .iter()
197            .filter(move |threat| threat.area.contains(at))
198            .map(|threat| &threat.piece)
199    }
200
201    /// Whether no threat reaches `at`. Allocates nothing.
202    pub fn safe(&self, at: Pos) -> bool {
203        self.reaches(at).next().is_none()
204    }
205
206    /// Every tile some threat reaches: the danger zone. Its grid is the
207    /// first threat's (an empty `0 × 0` set when there is none); a tile
208    /// of another threat outside that grid is left out.
209    pub fn zone(&self) -> TileSet {
210        let mut threats = self.threats.iter();
211        let Some(first) = threats.next() else {
212            return TileSet::new(0, 0);
213        };
214        let mut zone = first.area.clone();
215        for threat in threats {
216            zone.union_with(&threat.area);
217        }
218        zone
219    }
220
221    /// The danger at `at`: the sum of `strike(piece)` over every threat
222    /// that reaches `at` and that `strike` answers `Some` for (`None`: the
223    /// piece wouldn't strike there, as a healer wouldn't). Every threat
224    /// counts, with no guess at which the other side will choose (module
225    /// docs). Rounding and caps (at most the target's health, a share of
226    /// the striker's) are the game's, on the sums or in `strike`. Sums
227    /// saturate.
228    pub fn danger(&self, at: Pos, mut strike: impl FnMut(&P) -> Option<Strike>) -> Danger {
229        let mut danger = Danger::default();
230        for piece in self.reaches(at) {
231            let Some(hit) = strike(piece) else {
232                continue;
233            };
234            danger.expected = danger.expected.saturating_add(hit.expected);
235            danger.worst = danger.worst.saturating_add(hit.all_hit);
236            danger.struck_back = danger.struck_back.saturating_add(hit.struck_back);
237            danger.threats = danger.threats.saturating_add(1);
238        }
239        danger
240    }
241}
242
243/// The game's forecast of one threat's strike on whoever stands on a tile.
244/// Expected values are in hundredths of a point: a hit chance in percent
245/// times the damage.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247#[non_exhaustive]
248pub struct Strike {
249    /// The damage expected, in hundredths.
250    pub expected: i64,
251    /// The damage if every strike hits, in points.
252    pub all_hit: i64,
253    /// The damage expected to be struck back at the threat, in hundredths.
254    pub struck_back: i64,
255}
256
257impl Strike {
258    /// A forecast of `expected` damage (hundredths), `all_hit` damage if
259    /// every strike hits (points) and `struck_back` damage expected back
260    /// (hundredths).
261    pub const fn new(expected: i64, all_hit: i64, struck_back: i64) -> Self {
262        Self {
263            expected,
264            all_hit,
265            struck_back,
266        }
267    }
268}
269
270/// The danger at one tile: [`Threats::danger`]'s sums of the [`Strike`]s
271/// of every threat that reaches it.
272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
273#[non_exhaustive]
274pub struct Danger {
275    /// The damage expected, in hundredths.
276    pub expected: i64,
277    /// The damage if every strike of every threat hits, in points.
278    pub worst: i64,
279    /// The damage expected to be struck back, in hundredths.
280    pub struck_back: i64,
281    /// How many threats were added up.
282    pub threats: u32,
283}
284
285/// How far a piece would walk from a tile to the nearest of some goals,
286/// with one [`flow_field`] per movement kind `K`, built on first use and
287/// kept. Stepping onto a goal's own tile is free and always allowed: a
288/// goal is often a tile another piece stands on.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct GoalDistance<K> {
291    width: u16,
292    height: u16,
293    goals: Vec<Pos>,
294    fields: BTreeMap<K, Grid<Option<u32>>>,
295}
296
297impl<K: Ord + Copy> GoalDistance<K> {
298    /// The goals `goals` on a `width × height` grid. Goals outside it are
299    /// left out of every walk.
300    pub fn new(width: u16, height: u16, goals: Vec<Pos>) -> Self {
301        Self {
302            width,
303            height,
304            goals,
305            fields: BTreeMap::new(),
306        }
307    }
308
309    /// The goals, as given.
310    pub fn goals(&self) -> &[Pos] {
311        &self.goals
312    }
313
314    /// How far a piece of movement kind `kind` would walk from `from` to
315    /// the nearest goal, where entering a tile costs `cost(tile)` (`None`:
316    /// it can't be entered) and entering a goal costs 0. `None` with no
317    /// goals, or no walk. 0 on a goal.
318    ///
319    /// The first call for a `kind` builds its field with `cost`; later
320    /// calls read it and never call `cost`, so `cost` must answer the same
321    /// for every call with one `kind`. Make a new `GoalDistance` when the
322    /// costs change.
323    pub fn distance(
324        &mut self,
325        kind: K,
326        from: Pos,
327        cost: impl Fn(Pos) -> Option<u32>,
328    ) -> Option<u32> {
329        let (width, height, goals) = (self.width, self.height, &self.goals);
330        let field = self.fields.entry(kind).or_insert_with(|| {
331            let free = |tile: Pos| {
332                if goals.contains(&tile) {
333                    Some(0)
334                } else {
335                    cost(tile)
336                }
337            };
338            flow_field(width, height, goals.iter().copied(), free)
339        });
340        field.get(from).copied().flatten()
341    }
342}
343
344#[cfg(test)]
345mod tests;