diff --git a/crates/sds-core/src/heatmap.rs b/crates/sds-core/src/heatmap.rs new file mode 100644 index 0000000..bb9bc83 --- /dev/null +++ b/crates/sds-core/src/heatmap.rs @@ -0,0 +1,299 @@ +//! The data behind a picture of what the estimator thinks of a board. +//! +//! `examples/stands.rs` prints the same numbers as digits and +//! `examples/heatmap.rs` draws them as hexes. Both read this, and this is what +//! the tests assert on: the hexes a render walks, in what order, on what scale. +//! A colour is not a regression target - `plan/candidates.md` says so - so the +//! encoding is a pair of numbers here and the pixels stay in the example. +//! +//! Two channels per hex, kept apart for the same reason +//! [`crate::stands::Ranking`] keeps two lists: what we deal from a hex and what +//! we take at it are different questions, and folding them into one scalar +//! would bury an exchange rate in the picture. + +use std::collections::BTreeMap; + +use crate::stands::Ranking; +use crate::wire::Coord; + +/// One hex we can stand in, with the two channels a render colours. +/// +/// The two extremes over the hex's facings rather than one facing's pair: +/// choosing a facing to draw would need a scalar to choose it with, and there +/// is deliberately no such scalar. They may be different facings, and the +/// stands themselves are still in [`Ranking::scored`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct HexCell { + pub hex: Coord, + /// The most we can deal from this hex at any facing. + pub deal: f32, + /// The least we can be made to take at it at any facing. + pub take: f32, + /// How many stands the ranking holds here. Never zero. + pub stands: usize, +} + +impl HexCell { + /// A hex we can reach where nothing happens in either direction. + /// + /// Distinct from a hex that is not in the map at all, which is one we + /// cannot stand in. Both draw dark, and confusing them would turn "out of + /// everyone's range" into "off the board". + pub fn is_quiet(&self) -> bool { + self.deal <= 0.0 && self.take <= 0.0 + } +} + +/// Every hex a ranking reaches, in a fixed order. +/// +/// Reading order - top row first, left to right - because that is the order a +/// render emits and the order a person checks it in. Fixed regardless of the +/// order [`Ranking::scored`] arrived in, so the same ranking always produces +/// the same sequence of hexes. +#[derive(Debug, Clone, PartialEq)] +pub struct HexMap { + cells: Vec, +} + +impl HexMap { + pub fn of(ranking: &Ranking) -> Self { + let mut best: BTreeMap<(i32, i32), HexCell> = BTreeMap::new(); + for score in &ranking.scored { + let hex = score.reach.stand.hex; + let cell = best.entry((hex.y, hex.x)).or_insert(HexCell { + hex, + deal: 0.0, + take: f32::INFINITY, + stands: 0, + }); + cell.deal = cell.deal.max(score.offence.expected_damage); + cell.take = cell.take.min(score.defence.expected_damage); + cell.stands += 1; + } + Self { + cells: best.into_values().collect(), + } + } + + /// Every reachable hex, in reading order. + pub fn cells(&self) -> &[HexCell] { + &self.cells + } + + /// The cell at a hex, or `None` for a hex we cannot stand in. + pub fn get(&self, hex: Coord) -> Option<&HexCell> { + self.cells + .binary_search_by_key(&(hex.y, hex.x), |cell| (cell.hex.y, cell.hex.x)) + .ok() + .map(|at| &self.cells[at]) + } +} + +/// What the darkest and the brightest hex on a page mean. +/// +/// One scale over every map drawn together, so a colour means the same thing on +/// all of them. Three exponents and three boards on one page cannot be compared +/// otherwise, and comparing them is the only reason to draw nine. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Scale { + pub deal_max: f32, + pub take_max: f32, +} + +impl Scale { + /// The smallest scale covering every cell of every map. + pub fn covering<'a>(maps: impl IntoIterator) -> Self { + let mut scale = Self { + deal_max: 0.0, + take_max: 0.0, + }; + for map in maps { + for cell in map.cells() { + scale.deal_max = scale.deal_max.max(cell.deal); + scale.take_max = scale.take_max.max(cell.take); + } + } + scale + } + + /// Whether a cell falls inside this scale, so nothing is drawn clipped. + pub fn covers(&self, cell: &HexCell) -> bool { + cell.deal <= self.deal_max && cell.take <= self.take_max + } + + /// A cell as the two numbers a render colours by. + pub fn shade(&self, cell: &HexCell) -> Shade { + let unit = |value: f32, full: f32| { + if full <= 0.0 { + 0.0 + } else { + (value / full).clamp(0.0, 1.0) + } + }; + let deal = unit(cell.deal, self.deal_max); + let take = unit(cell.take, self.take_max); + let sum = deal + take; + Shade { + total: (sum / 2.0).clamp(0.0, 1.0), + balance: if sum <= 0.0 { 0.5 } else { deal / sum }, + } + } +} + +/// A cell reduced to the two axes the picture uses. +/// +/// Lightness takes `total` and hue takes `balance`, which is the encoding +/// `plan/candidates.md` asks for: an additive blend of two colours puts "high in +/// both" and "middling in both" on the lightness axis alone, and that is the +/// axis the eye compares worst. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Shade { + /// How much is happening here, both directions together. 0 to 1. + pub total: f32, + /// The share of it that is ours to deal. 0 is all incoming, 1 is all + /// outgoing, and 0.5 is an even trade or a silent hex. + pub balance: f32, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hex::Stand; + use crate::pathfind::Reach; + use crate::stands::{Aggregate, StandScore, StandStats}; + + fn score(x: i32, y: i32, facing: i32, deal: f32, take: f32) -> StandScore { + StandScore { + reach: Reach { + stand: Stand::new(Coord::new(x, y), facing), + mp_spent: 0, + hexes_moved: 0, + }, + offence: Aggregate { + expected_damage: deal, + ..Aggregate::default() + }, + defence: Aggregate { + expected_damage: take, + ..Aggregate::default() + }, + best_target: None, + } + } + + fn ranking(scored: Vec) -> Ranking { + Ranking { + scored, + by_offence: Vec::new(), + by_defence: Vec::new(), + stats: StandStats::default(), + } + } + + /// The order a render walks, whatever order the stands were reached in. + #[test] + fn cells_come_out_in_reading_order() { + let map = HexMap::of(&ranking(vec![ + score(3, 4, 0, 1.0, 1.0), + score(1, 1, 0, 1.0, 1.0), + score(9, 0, 0, 1.0, 1.0), + score(2, 1, 0, 1.0, 1.0), + ])); + let order: Vec<(i32, i32)> = map.cells().iter().map(|c| (c.hex.x, c.hex.y)).collect(); + assert_eq!(order, vec![(9, 0), (1, 1), (2, 1), (3, 4)]); + } + + /// Same stands, shuffled: same picture. A render that depended on the + /// search order would move whenever the pathfinder's tie-breaks did. + #[test] + fn order_does_not_depend_on_the_search_order() { + let stands = vec![ + score(2, 2, 0, 3.0, 1.0), + score(2, 2, 3, 1.0, 5.0), + score(1, 3, 1, 2.0, 2.0), + ]; + let forward = HexMap::of(&ranking(stands.clone())); + let backward = HexMap::of(&ranking(stands.into_iter().rev().collect())); + assert_eq!(forward, backward); + } + + /// The best facing to shoot from and the best to be shot at in are not the + /// same facing, and the cell holds both. + #[test] + fn a_hex_takes_the_extreme_over_its_facings() { + let map = HexMap::of(&ranking(vec![ + score(2, 2, 0, 3.0, 9.0), + score(2, 2, 3, 1.0, 4.0), + ])); + let cell = map.get(Coord::new(2, 2)).expect("reachable"); + assert_eq!(cell.deal, 3.0); + assert_eq!(cell.take, 4.0); + assert_eq!(cell.stands, 2); + } + + /// The distinction the epic asks the picture to keep: a hex where nothing + /// happens is not a hex we cannot stand in. + #[test] + fn a_quiet_hex_is_not_an_unreachable_one() { + let map = HexMap::of(&ranking(vec![ + score(1, 1, 0, 0.0, 0.0), + score(2, 1, 0, 4.0, 2.0), + ])); + let quiet = map.get(Coord::new(1, 1)).expect("reachable"); + assert!(quiet.is_quiet()); + assert!(!map.get(Coord::new(2, 1)).expect("reachable").is_quiet()); + assert!(map.get(Coord::new(7, 7)).is_none()); + } + + /// Nothing drawn falls off the end of the legend's numbers. + #[test] + fn the_scale_covers_every_cell_it_was_built_from() { + let one = HexMap::of(&ranking(vec![score(1, 1, 0, 4.0, 9.0)])); + let two = HexMap::of(&ranking(vec![score(1, 1, 0, 11.0, 2.0)])); + let scale = Scale::covering([&one, &two]); + assert_eq!(scale.deal_max, 11.0); + assert_eq!(scale.take_max, 9.0); + for map in [&one, &two] { + for cell in map.cells() { + assert!(scale.covers(cell)); + let shade = scale.shade(cell); + assert!((0.0..=1.0).contains(&shade.total)); + assert!((0.0..=1.0).contains(&shade.balance)); + } + } + } + + /// Lightness is the total and hue is the ratio, so "high in both" and + /// "middling in both" differ on lightness while "all out" and "all in" + /// differ on hue at the same lightness. + #[test] + fn shade_separates_the_total_from_the_balance() { + let scale = Scale { + deal_max: 10.0, + take_max: 10.0, + }; + let at = |deal, take| { + scale.shade(&HexCell { + hex: Coord::new(0, 0), + deal, + take, + stands: 1, + }) + }; + let heavy = at(10.0, 10.0); + let light = at(3.0, 3.0); + assert!(heavy.total > light.total); + assert_eq!(heavy.balance, light.balance); + + let ours = at(8.0, 2.0); + let theirs = at(2.0, 8.0); + assert_eq!(ours.total, theirs.total); + assert!(ours.balance > 0.5 && theirs.balance < 0.5); + + // A silent hex sits at the bottom of the lightness axis and in the + // middle of the hue axis rather than at an end of it. + let silent = at(0.0, 0.0); + assert_eq!(silent.total, 0.0); + assert_eq!(silent.balance, 0.5); + } +} diff --git a/crates/sds-core/src/lib.rs b/crates/sds-core/src/lib.rs index 47b5838..4a507c0 100644 --- a/crates/sds-core/src/lib.rs +++ b/crates/sds-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod arc; pub mod ev; pub mod explore; pub mod features; +pub mod heatmap; pub mod hex; pub mod hitloc; pub mod los;