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;