diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index f93e04d..bf74eb6 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -32,6 +32,7 @@ use std::collections::{BTreeMap, BTreeSet}; +use sds_core::facts::{ArcSplit, Gait}; use sds_core::features::{firing, positional, Feature, Weights}; use sds_core::heatmap::HexMap; use sds_core::pathfind::MoveBoard; @@ -608,6 +609,13 @@ fn main() { } } + // The facts do not move with the exponent - they are properties of the + // ground and the geometry, not of how pessimistic the collapse is - so + // they are printed once rather than three times. + if let Some((label, ranking)) = rankings.first() { + facts_report(label, ranking); + } + println!(); let stats = cache.stats(); println!( @@ -638,3 +646,183 @@ fn main() { } ); } + +/// The facts a stand carries beside its two damage columns. +/// +/// Printed rather than asserted: these are new columns and what they look like +/// on a real sheet is the thing worth reading before any of them is promoted to +/// a feature. See `plan/candidates.md`. +fn facts_report(label: &str, ranking: &Ranking) { + let stands = &ranking.scored; + if stands.is_empty() { + return; + } + let pairs: Vec<&sds_core::facts::TargetFacts> = stands + .iter() + .flat_map(|stand| stand.facts.per_target.iter()) + .collect(); + let mean = |values: Vec| -> f32 { + if values.is_empty() { + 0.0 + } else { + values.iter().sum::() / values.len() as f32 + } + }; + + println!(); + println!("--- facts on {label} ---"); + println!( + " reasons, over {} (stand, enemy) pairs: no line {:.1}%, arc blocked {:.1}%, out of range {:.1}%, firing {:.1}%", + pairs.len(), + mean(pairs.iter().map(|f| f.outgoing.no_line).collect()) * 100.0, + mean(pairs.iter().map(|f| f.outgoing.arc_blocked).collect()) * 100.0, + mean(pairs.iter().map(|f| f.outgoing.out_of_range).collect()) * 100.0, + mean(pairs.iter().map(|f| f.outgoing.firing).collect()) * 100.0, + ); + println!( + " incoming: no line {:.1}%, arc blocked {:.1}%, out of range {:.1}%, firing {:.1}%", + mean(pairs.iter().map(|f| f.incoming.no_line).collect()) * 100.0, + mean(pairs.iter().map(|f| f.incoming.arc_blocked).collect()) * 100.0, + mean(pairs.iter().map(|f| f.incoming.out_of_range).collect()) * 100.0, + mean(pairs.iter().map(|f| f.incoming.firing).collect()) * 100.0, + ); + + // The separation the two-axis collapse could not make: every one of these + // pairs reads `deal 0.00`, and the four are different tactical situations. + let mut blind = 0usize; + let mut arc = 0usize; + let mut range = 0usize; + let mut low = 0usize; + let mut zeroes = 0usize; + for stand in stands { + for ((_, value), facts) in stand.per_target.iter().zip(stand.facts.per_target.iter()) { + if value.expected_damage > 0.0 { + continue; + } + zeroes += 1; + // Classified by the reason that holds over most of that enemy's + // `M`. A pair is a set of positions, not one, so this is a + // plurality rather than a partition of the pair itself. + let reasons = facts.outgoing; + let widest = reasons + .no_line + .max(reasons.arc_blocked) + .max(reasons.out_of_range) + .max(reasons.firing); + if widest == reasons.no_line { + blind += 1; + } else if widest == reasons.arc_blocked { + arc += 1; + } else if widest == reasons.out_of_range { + range += 1; + } else { + low += 1; + } + } + } + println!( + " pairs dealing 0.00: {zeroes} of {}, by the reason holding over most of M - {blind} blind (cover), {arc} nothing bears, {range} out of range, {low} firing but the collapse says zero", + pairs.len() + ); + + let ranges: Vec<&sds_core::facts::Spread> = pairs.iter().map(|f| &f.range).collect(); + println!( + " range to an enemy: min {} to {}, max {} to {}, mean {:.1}", + ranges.iter().map(|r| r.min).min().unwrap_or(0), + ranges.iter().map(|r| r.min).max().unwrap_or(0), + ranges.iter().map(|r| r.max).min().unwrap_or(0), + ranges.iter().map(|r| r.max).max().unwrap_or(0), + mean(ranges.iter().map(|r| r.mean).collect()), + ); + + let dealt: Vec = stands.iter().map(|s| s.facts.dealt_by_arc()).collect(); + let taken: Vec = stands.iter().map(|s| s.facts.taken_by_arc()).collect(); + let arcs = |split: &[ArcSplit]| { + let total: f32 = split.iter().map(|s| s.total()).sum(); + if total <= 0.0 { + return (0.0, 0.0, 0.0, 0.0); + } + ( + split.iter().map(|s| s.front).sum::() / total * 100.0, + split.iter().map(|s| s.left).sum::() / total * 100.0, + split.iter().map(|s| s.right).sum::() / total * 100.0, + split.iter().map(|s| s.rear).sum::() / total * 100.0, + ) + }; + let (f, l, r, b) = arcs(&dealt); + println!( + " damage dealt by their arc: front {f:.1}%, left {l:.1}%, right {r:.1}%, rear {b:.1}%" + ); + let (f, l, r, b) = arcs(&taken); + println!( + " damage taken by our arc: front {f:.1}%, left {l:.1}%, right {r:.1}%, rear {b:.1}%" + ); + let best = dealt + .iter() + .enumerate() + .filter(|(_, split)| split.total() > 0.0) + .max_by(|a, b| a.1.rear_share().total_cmp(&b.1.rear_share())); + if let Some((at, split)) = best { + let stand = &stands[at].reach.stand; + println!( + " most rear-loaded stand: ({}, {}) facing {} at {:.0}% rear of {:.1} damage", + stand.hex.x, + stand.hex.y, + stand.facing, + split.rear_share() * 100.0, + split.total() + ); + } + + let mut still = 0usize; + let mut walked = 0usize; + let mut ran = 0usize; + for stand in stands { + match stand.facts.gait { + Gait::Still => still += 1, + Gait::Walked => walked += 1, + Gait::Ran => ran += 1, + } + } + println!(" gait: {still} still, {walked} walked, {ran} ran (running is +2 to our own to-hit)"); + println!( + " tmm {} to {}, elevation delta {} to {}", + stands.iter().map(|s| s.facts.tmm).min().unwrap_or(0), + stands.iter().map(|s| s.facts.tmm).max().unwrap_or(0), + stands + .iter() + .map(|s| s.facts.elevation_delta) + .min() + .unwrap_or(0), + stands + .iter() + .map(|s| s.facts.elevation_delta) + .max() + .unwrap_or(0), + ); + let breadth: Vec = stands + .iter() + .filter_map(|s| s.facts.retreat_breadth) + .collect(); + if breadth.is_empty() { + println!(" retreat breadth: not measured (no move board)"); + } else { + println!( + " retreat breadth: {} to {}, mean {:.1}", + breadth.iter().min().unwrap(), + breadth.iter().max().unwrap(), + breadth.iter().sum::() as f32 / breadth.len() as f32 + ); + } + + // What carrying the wider aggregate costs. Facts only: the rest of a + // `StandScore` is unchanged. + let per_pair = std::mem::size_of::(); + let per_stand = std::mem::size_of::(); + let bytes = stands.len() * per_stand + pairs.len() * per_pair; + println!( + " facts cost: {per_stand} B a stand plus {per_pair} B a pair = {:.1} KB over {} stands", + bytes as f32 / 1024.0, + stands.len() + ); +}