diff --git a/crates/sds-core/examples/common/mod.rs b/crates/sds-core/examples/common/mod.rs index 5ce643a..96d1be1 100644 --- a/crates/sds-core/examples/common/mod.rs +++ b/crates/sds-core/examples/common/mod.rs @@ -19,7 +19,9 @@ use std::collections::BTreeSet; use sds_core::arc::MekLocation; use sds_core::hex::Stand; use sds_core::los::{LosCache, Rules}; -use sds_core::pathfind::{reachable, MoveBoard, Reach, Walker, MEK_MAX_ELEVATION_CHANGE}; +use sds_core::pathfind::{ + reachable, search, MoveBoard, Reach, Search, Walker, MEK_MAX_ELEVATION_CHANGE, +}; use sds_core::stands::{Combatant, Foe, Mover, Presence}; use sds_core::volley::MountedWeapon; use sds_core::wire::{Board, BoardHex, Coord, Unit, Weapon}; @@ -172,8 +174,8 @@ pub struct Scene { pub los: LosCache, pub our_unit: Unit, pub enemy_units: Vec, - /// Every `(hex, facing)` we can reach on `OUR_MP`. - pub stands: Vec, + /// Every `(hex, facing)` we can reach on `OUR_MP`, and the route to each. + pub search: Search, } impl Scene { @@ -183,7 +185,7 @@ impl Scene { let los = LosCache::new(&message, Rules::default()); let our_unit = mek(1, START, 0); let occupied: Vec = ENEMIES.to_vec(); - let stands = reachable( + let found = search( &move_board, &Walker { start: Stand::new(START, 0), @@ -205,10 +207,15 @@ impl Scene { los, our_unit, enemy_units, - stands, + search: found, } } + /// Every end state, in the order the search gives. + pub fn stands(&self) -> &[Reach] { + &self.search.reached + } + pub fn mover(&self) -> Mover<'_> { Mover { who: Combatant::mek(&self.our_unit, loadout(), 4), diff --git a/crates/sds-core/examples/heatmap.rs b/crates/sds-core/examples/heatmap.rs index 5c759db..7df38e5 100644 --- a/crates/sds-core/examples/heatmap.rs +++ b/crates/sds-core/examples/heatmap.rs @@ -1,4 +1,4 @@ -//! What the estimator thinks of a board, as a picture. +//! What the estimator thinks of a board, as a page. //! //! The same numbers `examples/stands.rs` prints as digits, drawn as hexes and //! written to one self-contained HTML file: @@ -13,23 +13,27 @@ //! and "middling in both" on the lightness axis alone, which is the axis the eye //! compares worst. //! +//! Each board also gets its routes: the stand each exponent picks, and the walk +//! the pathfinder actually took to it, from `Search::path_to`. +//! //! Nothing here is tuned to make the picture look good. If a board comes out -//! flat, that is the result, and the page says what the numbers were. +//! flat, that is the result, and the page says so. //! //! Not a test. `sds_core::heatmap` holds the data the render walks, and that is //! what the tests assert on: a colour is not a regression target. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write as _; use sds_core::heatmap::{HexCell, HexMap, Scale, Shade}; -use sds_core::pathfind::MoveBoard; -use sds_core::stands::{score_stands, Params, Ranking}; +use sds_core::hex::{translated, Stand}; +use sds_core::pathfind::{MoveBoard, Search}; +use sds_core::stands::{score_stands, Params, Ranking, StandScore}; use sds_core::volley::VolleyCache; use sds_core::wire::Coord; mod common; -use common::{Scene, BOARDS, ENEMIES, EXPONENTS, OUR_MP, START, THEIR_MP, TOP_K}; +use common::{Scene, BOARDS, ENEMIES, OUR_MP, START, THEIR_MP, TOP_K}; /// Pixels per unit of the board's own geometry, where a hex is 2 units tall. const UNIT: f32 = 13.0; @@ -40,6 +44,58 @@ const RADIUS: f32 = 1.154_700_5; const INSET: f32 = 0.94; const PAD: f32 = 6.0; +/// One way of collapsing where the enemies might be. +struct Regime { + /// The CSS class that gives this regime its colour, everywhere on the page. + key: &'static str, + name: &'static str, + maths: &'static str, + reads: &'static str, + exponent: f32, + /// Stroke weight and dash, so three routes crossing the same hexes stay + /// three routes. + stroke: &'static str, + /// Pixels to shift this regime's line sideways off the hex centres, and the + /// radius of the ring on its chosen hex. Two regimes often pick the same + /// stand, and the route to a stand is one route - drawn on top of each + /// other they would read as one regime having decided. + spread: f32, + ring: f32, +} + +const REGIMES: [Regime; 3] = [ + Regime { + key: "r-min", + name: "minimax", + maths: "p \u{2192} -\u{221e}", + reads: "they find our worst case", + exponent: f32::NEG_INFINITY, + stroke: r##"stroke-width="2.6""##, + spread: -2.4, + ring: 0.66, + }, + Regime { + key: "r-mid", + name: "middling", + maths: "p = -1", + reads: "they lean towards our worst case", + exponent: -1.0, + stroke: r##"stroke-width="2.6" stroke-dasharray="7 5""##, + spread: 0.0, + ring: 0.50, + }, + Regime { + key: "r-mean", + name: "mean", + maths: "p = 1", + reads: "they move without regard to us", + exponent: 1.0, + stroke: r##"stroke-width="2.6" stroke-dasharray="2 5""##, + spread: 2.4, + ring: 0.34, + }, +]; + /// Where a hex sits, in pixels. fn centre(hex: Coord) -> (f32, f32) { let cx = (3 * hex.x + 2) as f32 / 3.0_f32.sqrt(); @@ -62,150 +118,138 @@ fn corners(cx: f32, cy: f32, radius: f32) -> String { .join(" ") } -/// What a hex is made of, for the parts of the picture colour does not carry. -struct Terrain { - /// The unlit ground under everything. - fill: &'static str, - /// The ring drawn around a hex we can stand in, so terrain still reads - /// where the data colour covers the fill. - ring: &'static str, - /// Printed in the hex when it is not zero. - level: i32, - impassable: bool, -} - -fn terrain(board: &MoveBoard, at: Coord) -> Terrain { +/// What a hex is made of. The class carries the colour, so the page can be +/// read in either theme without the renderer knowing which one. +fn terrain_class(board: &MoveBoard, at: Coord) -> &'static str { let hex = board.hex(at); - let (fill, ring) = if hex.impassable { - ("#2a1618", "#7d3b3f") + if hex.impassable { + "t-block" } else if hex.depth > 0 { - ("#0e2033", "#4a90d9") + "t-water" } else if hex.terrain_mp >= 2 { - ("#16281a", "#79c14f") + "t-heavy" } else if hex.terrain_mp >= 1 { - ("#15221a", "#4e8a3a") + "t-light" } else { - ("#111419", "#39414f") - }; - Terrain { - fill, - ring, - level: hex.level, - impassable: hex.impassable, + "t-clear" } } -/// A shade as a colour: lightness is the total, hue is the ratio. -/// -/// Hue runs from 210 - all of it dealt by us - through 280 for an even trade to -/// 350, all of it taken. A silent hex sits at 280 and at the bottom of the -/// lightness ramp, where hue cannot be seen at all, which is the honest place -/// for a ratio of nothing to nothing. -fn colour(shade: Shade) -> String { - let hue = 350.0 - 140.0 * shade.balance; - let lightness = 8.0 + 56.0 * shade.total; - format!("hsl({hue:.0} 68% {lightness:.0}%)") +/// A shade as two custom properties. The stylesheet turns them into a colour, +/// which is what lets one ramp serve a light page and a dark one. +fn shade_vars(shade: Shade) -> String { + format!("--t:{:.3};--b:{:.3}", shade.total, shade.balance) } -/// One map, drawn. -struct Panel<'a> { - scene: &'a Scene, - map: &'a HexMap, - scale: Scale, - offence: BTreeSet<(i32, i32)>, - defence: BTreeSet<(i32, i32)>, +/// The size of a board's drawing, in SVG user units. +fn extent(scene: &Scene) -> (f32, f32) { + let (right, bottom) = centre(Coord::new( + scene.message.width - 1, + scene.message.height - 1, + )); + (right + RADIUS * UNIT + PAD, bottom + UNIT + PAD) } -fn draw(panel: &Panel<'_>) -> String { - let board = &panel.scene.board; - let (width, height) = (panel.scene.message.width, panel.scene.message.height); - let enemies = panel.scene.enemy_hexes(); - let (right, bottom) = centre(Coord::new(width - 1, height - 1)); - let mut svg = String::new(); - let _ = write!( - svg, - r##""##, - right + RADIUS * UNIT + PAD, - bottom + UNIT + PAD, - panel.scene.name - ); - - for y in 0..height { - for x in 0..width { +/// The board itself: terrain, and the hexes we cannot stand in. +fn ground(scene: &Scene, svg: &mut String) { + for y in 0..scene.message.height { + for x in 0..scene.message.width { let at = Coord::new(x, y); let (cx, cy) = centre(at); - let ground = terrain(board, at); - let outer = corners(cx, cy, RADIUS * UNIT * INSET); let _ = write!( svg, - r##""##, - ground.fill + r##""##, + terrain_class(&scene.board, at), + corners(cx, cy, RADIUS * UNIT * INSET), ); - - if let Some(cell) = panel.map.get(at) { - let _ = write!( - svg, - r##"{}"##, - corners(cx, cy, RADIUS * UNIT * INSET * 0.82), - colour(panel.scale.shade(cell)), - ground.ring, - describe(cell, panel.scale), - ); - } else if !ground.impassable { + let level = scene.board.hex(at).level; + if level != 0 { let _ = write!( svg, - r##""##, - corners(cx, cy, RADIUS * UNIT * INSET * 0.82), - ground.ring, - ); - } - - if ground.level != 0 { - let _ = write!( - svg, - r##"{}"##, + r##"{level}"##, cx, - cy - RADIUS * UNIT * 0.42, - ground.level + cy - RADIUS * UNIT * 0.40, ); } + } + } +} - let key = (x, y); - if enemies.contains(&key) { - let _ = write!( - svg, - r##"E"##, - corners(cx, cy, RADIUS * UNIT * INSET), - cx, - cy + 4.0 - ); - } else if at == START { - let _ = write!( - svg, - r##"U"##, - corners(cx, cy, RADIUS * UNIT * INSET), - cx, - cy + 4.0 - ); - } +/// Both sides' start hexes, drawn over whatever else is in them. +fn starts(svg: &mut String) { + for (at, label, class) in + std::iter::once((START, "us", "us")).chain(ENEMIES.iter().map(|at| (*at, "E", "them"))) + { + let (cx, cy) = centre(at); + let _ = write!( + svg, + r##"{label}"##, + corners(cx, cy, RADIUS * UNIT * INSET), + cx, + cy + 4.0, + ); + } +} - let top_offence = panel.offence.contains(&key); - let top_defence = panel.defence.contains(&key); - if top_offence || top_defence { - svg.push_str(&mark(cx, cy, top_offence, top_defence)); - } +/// One scored map. +struct Panel<'a> { + scene: &'a Scene, + map: &'a HexMap, + scale: Scale, + offence: BTreeSet<(i32, i32)>, + defence: BTreeSet<(i32, i32)>, +} + +fn draw_heat(panel: &Panel<'_>) -> String { + let (width, height) = extent(panel.scene); + let mut svg = String::new(); + let _ = write!( + svg, + r##""##, + panel.scene.name, + ); + ground(panel.scene, &mut svg); + + for cell in panel.map.cells() { + let (cx, cy) = centre(cell.hex); + let _ = write!( + svg, + r##"{}"##, + shade_vars(panel.scale.shade(cell)), + corners(cx, cy, RADIUS * UNIT * INSET * 0.80), + describe(cell, panel.scale), + ); + let key = (cell.hex.x, cell.hex.y); + let on_offence = panel.offence.contains(&key); + let on_defence = panel.defence.contains(&key); + if on_offence || on_defence { + svg.push_str(&mark(cx, cy, on_offence, on_defence)); } } + + starts(&mut svg); svg.push_str(""); svg } -/// The top-K marks: a triangle up for offence, down for defence, both for a hex -/// on both lists. +/// The top-K marks: a triangle up for offence, down for defence, a diamond for +/// a hex on both lists. fn mark(cx: f32, cy: f32, offence: bool, defence: bool) -> String { - let r = RADIUS * UNIT * 0.34; - let mut out = String::new(); - let shape = |sign: f32| { + let r = RADIUS * UNIT * 0.32; + let points = if offence && defence { + format!( + "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", + cx, + cy - r, + cx + r * 0.78, + cy, + cx, + cy + r, + cx - r * 0.78, + cy + ) + } else { + let sign = if offence { 1.0 } else { -1.0 }; format!( "{:.1},{:.1} {:.1},{:.1} {:.1},{:.1}", cx, @@ -216,33 +260,7 @@ fn mark(cx: f32, cy: f32, offence: bool, defence: bool) -> String { cy + sign * r * 0.55 ) }; - if offence && defence { - let _ = write!( - out, - r##""##, - cx, - cy - r, - cx + r * 0.8, - cy, - cx, - cy + r, - cx - r * 0.8, - cy - ); - } else if offence { - let _ = write!( - out, - r##""##, - shape(1.0) - ); - } else { - let _ = write!( - out, - r##""##, - shape(-1.0) - ); - } - out + format!(r##""##) } /// The tooltip on a hex we can stand in. @@ -254,15 +272,78 @@ fn describe(cell: &HexCell, scale: Scale) -> String { ) } +/// One regime's pick, and the walk that reaches it. +struct Route { + regime: usize, + stand: Stand, + mp_spent: i32, + hexes_moved: i32, + hexes: Vec, +} + +/// The route map: plain terrain, and one line per regime. +fn draw_routes(scene: &Scene, routes: &[Route], label: &str) -> String { + let (width, height) = extent(scene); + let mut svg = String::new(); + let _ = write!( + svg, + r##""##, + scene.name, + ); + ground(scene, &mut svg); + + for route in routes { + let regime = ®IMES[route.regime]; + let points: Vec<(f32, f32)> = route.hexes.iter().map(|hex| centre(*hex)).collect(); + // Each segment shifted along its own normal, so three routes over the + // same hexes are three parallel lines rather than one. + for pair in points.windows(2) { + let ((x1, y1), (x2, y2)) = (pair[0], pair[1]); + let (dx, dy) = (x2 - x1, y2 - y1); + let length = (dx * dx + dy * dy).sqrt().max(0.001); + let (nx, ny) = (-dy / length * regime.spread, dx / length * regime.spread); + let _ = write!( + svg, + r##""##, + regime.key, + regime.stroke, + x1 + nx, + y1 + ny, + x2 + nx, + y2 + ny, + ); + } + let (cx, cy) = centre(route.stand.hex); + // The facing it arrives on, as a tick towards the hex it would step + // into next. + let (ax, ay) = centre(translated(route.stand.hex, route.stand.facing, 1)); + let (dx, dy) = (ax - cx, ay - cy); + let length = (dx * dx + dy * dy).sqrt().max(0.001); + let reach = RADIUS * UNIT * INSET * regime.ring; + let _ = write!( + svg, + r##""##, + regime.key, + corners(cx, cy, reach), + cx + dx / length * reach, + cy + dy / length * reach, + cx + dx / length * (reach + RADIUS * UNIT * 0.34), + cy + dy / length * (reach + RADIUS * UNIT * 0.34), + ); + } + + starts(&mut svg); + svg.push_str(""); + svg +} + /// The two-axis key: hue across, lightness down. -/// -/// Blue on the left because the caption reads deal before take, so the ramp -/// runs the same way the sentence does. fn legend(scale: Scale, brightest: f32) -> String { let mut out = String::new(); - out.push_str(r##""##); - let steps = 9; - let rows = 5; + out.push_str( + r##""##, + ); + let (steps, rows) = (9, 5); for row in 0..rows { // Brightest at the top, so the axis reads the way the caption does. let total = 1.0 - row as f32 / (rows - 1) as f32; @@ -270,30 +351,32 @@ fn legend(scale: Scale, brightest: f32) -> String { let balance = 1.0 - step as f32 / (steps - 1) as f32; let _ = write!( out, - r##""##, - 40 + step * 30, - 14 + row * 22, - colour(Shade { total, balance }) + r##""##, + shade_vars(Shade { total, balance }), + 46 + step * 30, + 18 + row * 22, ); } let _ = write!( out, - r##"{total:.2}"##, - 30 + row * 22 + r##"{total:.2}"##, + 34 + row * 22 ); } - out.push_str( - r##"all dealt -all taken -total -balance across, total down"##, + let _ = write!( + out, + r##"all of it dealt by us +all of it taken by us +total +hue across · lightness down"##, ); out.push_str(""); let _ = write!( out, - r##"

Total 1.00 is a hex at both maxima: {:.1} dealt and -{:.1} taken. The brightest hex on this page is {brightest:.2}.

"##, - scale.deal_max, scale.take_max + r##"

Total 1.00 is a hex at both maxima: {:.1} damage dealt and {:.1} taken. +The brightest hex anywhere on this page is total {brightest:.2}. Nothing is normalised per map, so a +colour means the same thing on all nine.

"##, + scale.deal_max, scale.take_max, ); out } @@ -302,6 +385,91 @@ fn escape(text: &str) -> String { text.replace('&', "&").replace('<', "<") } +/// A regime's name as a coloured chip, used wherever a regime is named. +fn chip(regime: &Regime) -> String { + format!( + r##"{} {}"##, + regime.key, regime.name, regime.maths + ) +} + +/// The stand each regime puts first on one of the two lists, with the walk that +/// reaches it. +fn routes_for( + scene: &Scene, + rankings: &[Ranking], + top: impl Fn(&Ranking) -> Option, +) -> Vec { + let mut routes: Vec = Vec::new(); + for (index, ranking) in rankings.iter().enumerate() { + let Some(at) = top(ranking) else { continue }; + let score = &ranking.scored[at]; + let Some(walk) = scene.search.path_to(score.reach.stand) else { + continue; + }; + routes.push(Route { + regime: index, + stand: score.reach.stand, + mp_spent: score.reach.mp_spent, + hexes_moved: score.reach.hexes_moved, + hexes: Search::hexes_along(&walk), + }); + } + routes +} + +/// What the routes say in words, including which regimes agreed. +/// +/// Said plainly rather than drawn twice: two regimes choosing the same stand is +/// a result, and one line drawn over another would read as one regime. +fn routes_caption(routes: &[Route]) -> String { + let mut out = String::from(r##"
    "##); + let mut shared: BTreeMap<(i32, i32, i32), Vec> = BTreeMap::new(); + for route in routes { + shared + .entry((route.stand.hex.x, route.stand.hex.y, route.stand.facing)) + .or_default() + .push(route.regime); + } + for route in routes { + let _ = write!( + out, + r##"
  • {} walks to ({}, {}) f{} · {} MP, {} hexes
  • "##, + chip(®IMES[route.regime]), + route.stand.hex.x, + route.stand.hex.y, + route.stand.facing, + route.mp_spent, + route.hexes_moved, + ); + } + out.push_str("
"); + let agreed: Vec = shared + .values() + .filter(|regimes| regimes.len() > 1) + .map(|regimes| { + regimes + .iter() + .map(|at| REGIMES[*at].name) + .collect::>() + .join(" and ") + }) + .collect(); + if !agreed.is_empty() { + let _ = write!( + out, + r##"

{} pick the same stand, so one line covers both.

"##, + agreed.join("; "), + ); + } + out +} + +fn hexes_of(list: &mut dyn Iterator) -> BTreeSet<(i32, i32)> { + list.map(|score| (score.reach.stand.hex.x, score.reach.stand.hex.y)) + .collect() +} + fn main() { let out_path = std::env::args().nth(1).unwrap_or_else(|| { eprintln!("usage: heatmap "); @@ -318,17 +486,17 @@ fn main() { let foes = scene.foes(); let mut cache = VolleyCache::new(); rankings.push( - EXPONENTS + REGIMES .iter() - .map(|(_, exponent)| { + .map(|regime| { score_stands( &mut cache, &scene.los, &mover, - &scene.stands, + scene.stands(), &foes, &Params { - exponent: *exponent, + exponent: regime.exponent, top_k: TOP_K, }, ) @@ -350,104 +518,88 @@ fn main() { .fold(0.0_f32, f32::max); let mut body = String::new(); + body.push_str(&prologue()); body.push_str(&format!( - r##"

What the estimator thinks of a board

-

Every (hex, facing) one Mek can reach on {OUR_MP} MP, scored against -three enemies with {THEIR_MP} MP who have not moved yet. Lightness is the total - -how much is dealt and taken at that hex together. Hue is the ratio - blue where we -deal it, red where we take it, purple where both sides trade heavily. Rows are the three corpus -boards; columns are the power mean over where the enemies could be, from the enemy finding our -worst case to the enemy moving without regard to us. One scale across all nine.

-

Per hex: the most we can deal from it at any facing, and the least we can be made to -take at it. They may be different facings. Hover a hex for its numbers.

-"## - )); - body.push_str(&format!( - r##"
{}
-

marks

-

a top {TOP_K} stand by offence, most dealt

-

a top {TOP_K} stand by defence, least taken

-

on both lists

-

E an enemy start, U where we start

-

terrain, drawn as the ring around a hex

-

clear   light woods -  heavy woods

-

water   impassable -  a numeral in a hex is its level

-

A hollow ring with no fill is a hex we cannot stand in. A hex filled at the bottom of the -lightness ramp is one we can stand in where nothing happens.

-
"##, + r##"

The colour

+

Two numbers per hex, and they are kept apart. Lightness carries the total: how much +damage passes through that hex in a turn, ours and theirs added. Hue carries the ratio: +blue where nearly all of it is dealt by us, red where nearly all of it is taken by us, and violet in +between, where both sides trade heavily. A hex that is quiet in both directions sits at the bottom of +the lightness ramp, where hue cannot be read at all, which is the honest place for a ratio of nothing +to nothing.

+

An additive blend of two colours was the obvious alternative and is the wrong one: it puts +“heavy in both” and “middling in both” on the lightness axis alone, and lightness +is the axis the eye compares worst.

+
{}
"##, legend(scale, brightest) )); + body.push_str(&how_to_read()); - for (scene, (row, maps)) in scenes.iter().zip(rankings.iter().zip(maps.iter())) { + for (index, scene) in scenes.iter().enumerate() { + let row = &rankings[index]; + let board_maps = &maps[index]; let _ = write!( body, - r##"

{}

{} stands over {} hexes; {} enemy positions in M.

"##, + r##"

{}

{} stands over {} hexes we can stop in · {} enemy positions in M · {} exchanges scored per direction

"##, escape(&scene.name), - scene.stands.len(), - maps[0].cells().len(), + scene.stands().len(), + board_maps[0].cells().len(), scene .foes() .iter() .map(|foe| foe.may_be.len()) .sum::(), + scene.stands().len() + * scene + .foes() + .iter() + .map(|foe| foe.may_be.len()) + .sum::(), ); - for ((label, _), (ranking, map)) in EXPONENTS.iter().zip(row.iter().zip(maps.iter())) { - let hexes = |list: &mut dyn Iterator| { - list.map(|score| (score.reach.stand.hex.x, score.reach.stand.hex.y)) - .collect::>() - }; - let offence = hexes(&mut ranking.best_offence()); - let defence = hexes(&mut ranking.best_defence()); - let cover = |list: &mut dyn Iterator| { - list.filter(|score| scene.in_cover(score.reach.stand.hex)) - .count() - }; - let top = |score: Option<&sds_core::stands::StandScore>| { - score - .map(|s| { - format!( - "({}, {}) f{}", - s.reach.stand.hex.x, s.reach.stand.hex.y, s.reach.stand.facing - ) - }) - .unwrap_or_else(|| "none".to_string()) - }; + for (regime, (ranking, map)) in REGIMES.iter().zip(row.iter().zip(board_maps.iter())) { let panel = Panel { scene, map, scale, - offence, - defence, + offence: hexes_of(&mut ranking.best_offence()), + defence: hexes_of(&mut ranking.best_defence()), + }; + let cover = |list: &mut dyn Iterator| { + list.filter(|score| scene.in_cover(score.reach.stand.hex)) + .count() }; let _ = write!( body, - r##"
{}
{}

top offence {} · top defence {}
in woods or beside them: {}/{TOP_K} offence, {}/{TOP_K} defence

"##, - escape(label), - draw(&panel), - top(ranking.best_offence().next()), - top(ranking.best_defence().next()), + r##"
{} {}
{}
{}

in woods or beside them: {}/{TOP_K} of the offence list, {}/{TOP_K} of the defence list

"##, + regime.key, + regime.name, + regime.maths, + regime.reads, + draw_heat(&panel), cover(&mut ranking.best_offence()), cover(&mut ranking.best_defence()), ); } body.push_str("
"); + + let offence_routes = routes_for(scene, row, |r| r.by_offence.first().copied()); + let defence_routes = routes_for(scene, row, |r| r.by_defence.first().copied()); + let _ = write!( + body, + r##"

The walk each regime would take

+

The stand at the top of each list, and the route the pathfinder recorded reaching it - not a line +drawn between two points. A turn costs a movement point and stays in its hex, so the MP is usually +more than the hexes.

+
most damage dealt
{}{}
+
least damage taken
{}{}
"##, + draw_routes(scene, &offence_routes, "offence"), + routes_caption(&offence_routes), + draw_routes(scene, &defence_routes, "defence"), + routes_caption(&defence_routes), + ); } - let _ = write!( - body, - r##"

Generated by crates/sds-core/examples/heatmap.rs from -crates/sds-core/tests/corpus/pathfind.jsonl. No MegaMek, no match. Us at ({}, {}), -enemies at {}. Nothing is tuned to make the picture look good.

"##, - START.x, - START.y, - ENEMIES - .iter() - .map(|at| format!("({}, {})", at.x, at.y)) - .collect::>() - .join(", "), - ); + body.push_str(&findings(&scenes, &rankings)); let page = format!( r##" @@ -467,48 +619,440 @@ enemies at {}. Nothing is tuned to make the picture look good.

"##, ); } +/// The opening, and the mechanism the pictures are of. +/// +/// Mechanism only. The one slot that would be written in somebody's voice is +/// left as a marked placeholder, because `CLAUDE.md` keeps prominent prose in +/// human hands and a generator is not a human. +fn prologue() -> String { + format!( + r##"
+

sds · movement candidates

+

What the estimator thinks of a board

+

A bot deciding where to move scores every place it could stand. This page draws +those scores on the board they were computed for: three maps, three ways of guessing what the enemy +will do, and the walk the bot would actually take under each.

+

PLACEHOLDER-QUILLON-MARGRAVE — the opening paragraph in a +human voice goes here. Everything below it is mechanism and can stay as written.

+
+ +

The decision being drawn

+

One Mek, {OUR_MP} movement points, three enemies with {THEIR_MP} each. Nobody has moved yet. The +question is which hex to end the turn in, and which way to be facing when the shooting starts.

+
+
L
+
Every end state we could reach: a hex and a facing, because turning costs movement points +and a hex reached at the edge of the budget arrives facing one way with nothing left to turn with. +Facing decides which weapons bear and which armour faces the shooter, so two facings in one hex are +two different offers.
+
M
+
Every position an enemy could be in when we get there. We choose L. We do not choose +M, so each of our stands is scored not against one enemy position but against all of +them.
+
N
+
Which enemy. There are three, which is the smallest number that makes the next part interesting: +against a single enemy the two aggregations below are the same number.
+
+

Every (L, M, N) triple is run through the volley estimator twice, once in each +direction: what we deal from there, and what we take there. That is the pair of numbers a hex is +coloured by.

+
+ +

Two aggregations, and they are not the same operator

+

Over M: a power mean

+

A stand has a value against every position an enemy might take, and those have to be collapsed into +one number. Which collapse is a statement about the opponent. The power mean is one operator with an +exponent that spans the whole range: as p falls towards minus infinity it becomes the +minimum, an enemy who finds our worst case; at p = 1 it is the plain average, an enemy +who moves without regard to us. Everything between is a lean.

+

That is why the same board is drawn three times. A cautious commander runs a low exponent, and the +claim worth testing is that a low exponent should pull the good stands towards cover, since cover is +what limits the worst case. The maps are that claim made visible, and the counts underneath each one +are the same claim as a number.

+

Over N: best target against summed incoming

+

Offence takes the best enemy: we fire at one thing. Defence takes the +sum: all of them shoot us. A stand that is excellent against one enemy and exposed to +the other two is good offence and bad defence, and no single operator over N can say +so.

+

Which is also why there are two ranked lists and no combined one. Ranking on a single number would +mean fixing an exchange rate between damage dealt and damage taken, and that rate is a tactical +opinion that changes with the unit's role, with how much armour it has left, and with whether its +side is winning. It belongs to whatever is choosing, not to the estimator.

+
"## + ) +} + +/// The key to the marks, kept next to the maps rather than at the foot. +fn how_to_read() -> String { + String::from( + r##"

Reading a map

+
+

marks

+

a top 8 stand by offence, most damage dealt

+

a top 8 stand by defence, least damage taken

+

a hex on both lists

+

an enemy start · where we start

+

A mark sits on a hex, but the lists rank stands. One hex can hold several facings and +only one of them need be in the top 8.

+

terrain

+

clear · light woods · + heavy woods

+

water · impassable

+

A numeral in a hex is its level. Terrain shows through as the ground under the scored hexes, which +are drawn smaller and sit inside it.

+

A hex with no scored hexagon inside it is one we cannot stop in. A scored hexagon at +the bottom of the lightness ramp is one we can stop in where nothing happens - those are different +things, and telling them apart is most of what a map like this is for.

+
"##, + ) +} + +/// What the maps came out at, computed rather than asserted. +/// +/// Printed whichever way it falls. A page tuned until it agreed with the +/// prediction would be worth nothing. +fn findings(scenes: &[Scene], rankings: &[Vec]) -> String { + let mut out = String::from( + r##"

What came out

+

The prediction on record is that a lower exponent moves the answer towards cover. Cover here is a +stand in woods or with woods next door, because a clearing ringed by trees is cover too - nothing can +see into it - and counting only the trees would miss the hex the defence list picks first.

+
+"##, + ); + let mut verdicts: Vec = Vec::new(); + for (scene, row) in scenes.iter().zip(rankings.iter()) { + let mut defence_cover: Vec = Vec::new(); + for (regime, ranking) in REGIMES.iter().zip(row.iter()) { + let top = |score: Option<&StandScore>| { + score + .map(|s| { + format!( + "({}, {}) f{}", + s.reach.stand.hex.x, s.reach.stand.hex.y, s.reach.stand.facing + ) + }) + .unwrap_or_else(|| "none".to_string()) + }; + let cover = |list: &mut dyn Iterator| { + list.filter(|score| scene.in_cover(score.reach.stand.hex)) + .count() + }; + let in_defence = cover(&mut ranking.best_defence()); + defence_cover.push(in_defence); + let _ = write!( + out, + r##" +"##, + escape(&scene.name), + chip(regime), + top(ranking.best_offence().next()), + top(ranking.best_defence().next()), + cover(&mut ranking.best_offence()), + in_defence, + ); + } + let first = defence_cover[0]; + let last = defence_cover[defence_cover.len() - 1]; + let verdict = if first > last { + "falls as the exponent rises, which is the predicted direction" + } else if first == last { + "does not move with the exponent on this board" + } else { + "RISES as the exponent rises, against the prediction" + }; + verdicts.push(format!( + "
  • On {}, the defence list goes from {first}/{TOP_K} in cover at \ + minimax to {last}/{TOP_K} at the mean: it {verdict}.
  • ", + escape(&scene.name), + )); + } + out.push_str("
    boardregimetop offencetop defenceoffence in coverdefence in cover
    {}{}{}{}{}/{TOP_K}{}/{TOP_K}
      "); + for verdict in &verdicts { + out.push_str(verdict); + } + out.push_str( + r##"
    +

    Two things worth saying plainly rather than hiding. The forest board saturates: nearly every hex +the unit can reach is in or beside woods, so the cover count is at its ceiling in every column and the +map is a handful of hexes rather than a field. That is a property of the board and of a six point +budget in heavy woods, not a fault in the scoring, and it is exactly the case where this kind of +picture stops being informative. And the offence list barely moves with the exponent, while the +defence list does - which fits, since cover limits the worst case and the worst case is what a low +exponent is looking at.

    +
    +"##, + ); + out +} + +/// The whole stylesheet. +/// +/// **Every colour is defined once on bare `:root`**, and the dark blocks +/// redefine tokens rather than introducing them, so a viewer whose theme is +/// "system" gets a complete palette either way. +/// +/// The heat ramp is three tokens rather than a colour, which is what lets one +/// encoding serve both themes: the hue is the ratio in both, and lightness runs +/// pale-to-dark on paper and dark-to-bright on a black page. More ink and more +/// light are the same statement. const CSS: &str = r##" -:root { color-scheme: dark; } -body { margin: 0; background: #05070b; color: #d7dde8; - font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; } -main { max-width: 1400px; margin: 0 auto; padding: 32px 20px 64px; } -h1 { font-size: 26px; margin: 0 0 12px; } -h2 { font-size: 19px; margin: 36px 0 4px; text-transform: lowercase; - letter-spacing: 0.04em; color: #9fb0c8; border-top: 1px solid #1b2230; padding-top: 20px; } -p.lede { max-width: 78ch; color: #a9b6c9; margin: 0 0 12px; } -code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.92em; - color: #cbd6e6; } -.keys { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; - background: #0b0f16; border: 1px solid #1b2230; border-radius: 6px; padding: 16px; } -.keycol { flex: none; width: 340px; } -svg.key { width: 340px; height: 151px; display: block; } -.keynote { font-size: 12px; color: #a9b6c9; margin: 4px 0 0; max-width: 340px; } -.key text.axis { fill: #8fa0b8; font-size: 9px; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.marks { font-size: 13px; color: #a9b6c9; } -.marks p { margin: 0 0 6px; } -.sw { display: inline-block; width: 12px; height: 12px; vertical-align: -1px; } -.sw.up { background: #fff; clip-path: polygon(50% 0, 100% 100%, 0 100%); } -.sw.down { background: #fff; clip-path: polygon(0 0, 100% 0, 50% 100%); } -.sw.both { background: #fff; clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); } -.sw.ring { border: 2px solid #fff; background: none; border-radius: 50%; } -.sw.t-clear { background: #39414f; } -.sw.t-light { background: #4e8a3a; } -.sw.t-heavy { background: #79c14f; } -.sw.t-water { background: #4a90d9; } -.sw.t-block { background: #7d3b3f; } -.row { display: flex; flex-wrap: wrap; gap: 20px; } -figure { margin: 0; flex: 1 1 380px; min-width: 320px; } -figcaption { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; - color: #8fa0b8; margin-bottom: 6px; white-space: pre; } -svg.map { width: 100%; height: auto; display: block; background: #05070b; - border: 1px solid #131a26; border-radius: 4px; } -.map text.lvl { fill: #d8c58a; font-size: 8px; text-anchor: middle; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -.map text.who { fill: #ffffff; font-size: 11px; font-weight: 700; text-anchor: middle; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -p.note { font-size: 12px; color: #8fa0b8; margin: 6px 0 0; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } -p.foot { margin-top: 40px; font-size: 13px; color: #7d8ba1; max-width: 78ch; - border-top: 1px solid #1b2230; padding-top: 16px; } +:root { + --paper: #f6f3ec; + --panel: #fffdf8; + --ink: #1b2430; + --ink-soft: #46566a; + --ink-faint: #78899d; + --rule: #ddd5c6; + --rule-soft: #ebe5da; + --map-bg: #fbf9f4; + + --t-clear: #e9e4d7; + --t-light: #d7e4c6; + --t-heavy: #bcd5a2; + --t-water: #c8dcef; + --t-block: #ecc9c3; + --hex-edge: #cfc7b6; + + --lvl: #9a8b6d; + --mark: #16202c; + --mark-edge: #fffdf8; + --us: #1b2430; + --them: #8c2f2f; + + /* The heat ramp. Pale is nothing, saturated and dark is a lot. */ + --ramp-base: 94%; + --ramp-span: -56%; + --ramp-sat: 62%; + + --r-min: #b06a00; + --r-mid: #0e766c; + --r-mean: #6b3fa0; + + --shadow: 0 1px 2px rgba(27, 36, 48, 0.07); +} + +:root:not([data-theme="light"]) { + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --paper: #070a0f; + --panel: #0d1219; + --ink: #dde5f0; + --ink-soft: #9fb0c6; + --ink-faint: #74869d; + --rule: #1e2836; + --rule-soft: #151d28; + --map-bg: #080b11; + + --t-clear: #171c25; + --t-light: #16251a; + --t-heavy: #1b3320; + --t-water: #102232; + --t-block: #2c1719; + --hex-edge: #0a0e14; + + --lvl: #b9a273; + --mark: #ffffff; + --mark-edge: #05070b; + --us: #ffffff; + --them: #ff8a8a; + + --ramp-base: 8%; + --ramp-span: 56%; + --ramp-sat: 66%; + + --r-min: #f0a500; + --r-mid: #2ec4b6; + --r-mean: #b78af0; + + --shadow: none; + } +} + +:root[data-theme="dark"] { + color-scheme: dark; + --paper: #070a0f; + --panel: #0d1219; + --ink: #dde5f0; + --ink-soft: #9fb0c6; + --ink-faint: #74869d; + --rule: #1e2836; + --rule-soft: #151d28; + --map-bg: #080b11; + + --t-clear: #171c25; + --t-light: #16251a; + --t-heavy: #1b3320; + --t-water: #102232; + --t-block: #2c1719; + --hex-edge: #0a0e14; + + --lvl: #b9a273; + --mark: #ffffff; + --mark-edge: #05070b; + --us: #ffffff; + --them: #ff8a8a; + + --ramp-base: 8%; + --ramp-span: 56%; + --ramp-sat: 66%; + + --r-min: #f0a500; + --r-mid: #2ec4b6; + --r-mean: #b78af0; + + --shadow: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; + +} + +main { max-width: 1180px; margin: 0 auto; padding: 56px 22px 72px; } + +header { margin-bottom: 52px; } +.eyebrow { + font: 600 12px/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-faint); + margin: 0 0 14px; +} +h1 { + font: 400 clamp(30px, 5vw, 46px)/1.12 ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif; + letter-spacing: -0.015em; margin: 0 0 18px; max-width: 20ch; +} +.standfirst { font-size: 19px; line-height: 1.6; color: var(--ink-soft); max-width: 62ch; margin: 0 0 20px; } +.placeholder { + border: 1px dashed var(--rule); border-radius: 4px; padding: 12px 14px; max-width: 62ch; + color: var(--ink-faint); font-size: 14px; margin: 0; +} + +h2 { + font: 400 clamp(21px, 3vw, 27px)/1.25 ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif; + margin: 0 0 14px; padding-top: 26px; border-top: 1px solid var(--rule); +} +h3 { + font: 600 15px/1.4 ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0.02em; margin: 26px 0 10px; color: var(--ink); +} +section { margin-bottom: 46px; } +p { max-width: 68ch; margin: 0 0 14px; } +strong { font-weight: 650; } +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.88em; background: var(--rule-soft); padding: 0.1em 0.34em; border-radius: 3px; +} +.note { font-size: 13.5px; color: var(--ink-faint); max-width: 66ch; } +.stat { + font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: var(--ink-faint); max-width: none; +} + +dl.terms { margin: 0 0 16px; max-width: 68ch; } +dl.terms dt { float: left; clear: left; width: 3.4em; margin-top: 2px; } +dl.terms dd { margin: 0 0 12px 4.2em; color: var(--ink-soft); } + +.chip { display: inline-flex; align-items: baseline; gap: 6px; white-space: nowrap; } +.chip code { background: none; padding: 0; color: inherit; } +.dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex: none; } +.r-min .dot, .sw.r-min { background: var(--r-min); } +.r-mid .dot, .sw.r-mid { background: var(--r-mid); } +.r-mean .dot, .sw.r-mean { background: var(--r-mean); } + +.keybox { background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; + padding: 16px 18px; box-shadow: var(--shadow); max-width: 420px; } +svg.key { width: 100%; max-width: 380px; height: auto; display: block; } +.key text.axis { fill: var(--ink-faint); font-size: 8.5px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + +.legend { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 22px 34px; + background: var(--panel); border: 1px solid var(--rule); border-radius: 6px; padding: 18px 20px; + box-shadow: var(--shadow); } +.legend h3 { margin-top: 0; } +.legend p { font-size: 14px; margin: 0 0 8px; color: var(--ink-soft); } +.sw { display: inline-block; width: 13px; height: 13px; vertical-align: -2px; } +.sw.up { background: var(--mark); clip-path: polygon(50% 0, 100% 100%, 0 100%); } +.sw.down { background: var(--mark); clip-path: polygon(0 0, 100% 0, 50% 100%); } +.sw.both { background: var(--mark); clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%); } +.sw.us { border: 2px dashed var(--us); } +.sw.them { border: 2px solid var(--them); } +.sw.t-clear { background: var(--t-clear); border: 1px solid var(--hex-edge); } +.sw.t-light { background: var(--t-light); } +.sw.t-heavy { background: var(--t-heavy); } +.sw.t-water { background: var(--t-water); } +.sw.t-block { background: var(--t-block); } + +.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 22px; + margin-bottom: 10px; } +figure { margin: 0; min-width: 0; } +figcaption { font-size: 14px; margin-bottom: 8px; display: flex; flex-wrap: wrap; + align-items: baseline; gap: 7px; } +figcaption code { background: none; padding: 0; color: var(--ink-faint); } +figcaption .reads { flex-basis: 100%; color: var(--ink-faint); font-size: 13px; } +figcaption.r-min .dot, figcaption.r-mid .dot, figcaption.r-mean .dot { align-self: center; } + +svg.map { width: 100%; height: auto; display: block; background: var(--map-bg); + border: 1px solid var(--rule); border-radius: 4px; } +.map .ground { stroke: var(--hex-edge); stroke-width: 1; } +.map .t-clear { fill: var(--t-clear); } +.map .t-light { fill: var(--t-light); } +.map .t-heavy { fill: var(--t-heavy); } +.map .t-water { fill: var(--t-water); } +.map .t-block { fill: var(--t-block); } +.map .cell, .key .cell { + fill: hsl(calc(350deg - var(--b) * 140deg) var(--ramp-sat) + calc(var(--ramp-base) + var(--t) * var(--ramp-span))); +} +.map .cell { stroke: var(--hex-edge); stroke-width: 0.6; } +.map .mark { fill: var(--mark); stroke: var(--mark-edge); stroke-width: 0.8; } +.map .lvl { fill: var(--lvl); font-size: 8px; text-anchor: middle; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.map .who { font-size: 9.5px; font-weight: 700; text-anchor: middle; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.map .who.us { fill: var(--us); } +.map .who.them { fill: var(--them); } +.map .start { fill: none; stroke-width: 2.4; } +.map .start.us { stroke: var(--us); stroke-dasharray: 4 3; } +.map .start.them { stroke: var(--them); } +.map .route { fill: none; stroke-linejoin: round; stroke-linecap: round; } +.map .pick { fill: none; stroke-width: 2.2; } +.map .facing { stroke-width: 2.2; stroke-linecap: round; } +.map .r-min { stroke: var(--r-min); } +.map .r-mid { stroke: var(--r-mid); } +.map .r-mean { stroke: var(--r-mean); } + +ul.routes { list-style: none; padding: 0; margin: 8px 0 4px; font-size: 13.5px; } +ul.routes li { margin-bottom: 5px; color: var(--ink-soft); } +ul.routes code { background: none; padding: 0; color: var(--ink); } + +.scroll { overflow-x: auto; margin-bottom: 16px; } +table { border-collapse: collapse; font-size: 13.5px; min-width: 640px; } +th, td { text-align: left; padding: 7px 14px 7px 0; border-bottom: 1px solid var(--rule-soft); + white-space: nowrap; } +th { font-weight: 600; color: var(--ink-faint); font-size: 12px; letter-spacing: 0.04em; + text-transform: uppercase; border-bottom: 1px solid var(--rule); } +td code { background: none; padding: 0; } +ul.verdict { max-width: 68ch; padding-left: 20px; color: var(--ink-soft); font-size: 14.5px; } +ul.verdict li { margin-bottom: 7px; } + +footer { border-top: 1px solid var(--rule); padding-top: 20px; margin-top: 40px; } +footer p { font-size: 13.5px; color: var(--ink-faint); } "##; diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 2ba64ec..826b452 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -149,7 +149,7 @@ fn main() { let (width, height) = (scene.message.width, scene.message.height); let move_board = &scene.board; let mover = scene.mover(); - let stands = &scene.stands; + let stands = scene.stands(); let foes = scene.foes(); let enemy_hexes: BTreeSet<(i32, i32)> = scene.enemy_hexes();