diff --git a/crates/sds-core/examples/stands.rs b/crates/sds-core/examples/stands.rs index 8aa8953..200bc93 100644 --- a/crates/sds-core/examples/stands.rs +++ b/crates/sds-core/examples/stands.rs @@ -2,16 +2,15 @@ //! //! Every `(hex, facing)` a Mek can reach, scored against three enemies that //! have not moved yet, printed as a board: what we deal from a hex, what we take -//! at it, and which stands make the top *K*. +//! at it, and which stands make the top *K* of each list. //! //! cargo run --release -p sds-core --example stands //! //! The same board is printed three times, at three power-mean exponents over -//! where the enemies could be. That is the argument for the operator: at -//! `p -> -inf` the enemies find our worst case and the good stands are the ones -//! in cover; at `p = 1` they move without regard to us and the good stands are -//! the ones with the shot. If those three pictures came out the same, the -//! exponent would not be doing anything. +//! where the enemies could be. `plan/candidates.md` predicts that a lower +//! exponent moves the answer toward cover. The last thing printed is a table +//! saying whether it did, and it prints whichever way the answer comes out - +//! a demo tuned until it agreed with the prediction would be worth nothing. //! //! The boards are the real ones from `tests/corpus/pathfind.jsonl`, dumped from //! MegaMek: `Map Set 5/16x17 Open Terrain 1`, `Map Set 4/16x17 Heavy Forest 1` @@ -40,7 +39,11 @@ const TOP_K: usize = 8; /// Where everybody starts. Us in the south, three of them in the north. const START: Coord = Coord::new(4, 13); -const ENEMIES: [Coord; 3] = [Coord::new(5, 3), Coord::new(9, 4), Coord::new(12, 2)]; +/// Close enough that most of our reachable set is inside a weapon bracket, and +/// north of the woods belt at `x = 8..11, y = 7..11` so that belt lies between +/// the two sides. Further apart and the picture is about distance rather than +/// about the fight. +const ENEMIES: [Coord; 3] = [Coord::new(5, 6), Coord::new(9, 6), Coord::new(12, 7)]; /// The three exponents, worst case to average. const EXPONENTS: [(&str, f32); 3] = [ @@ -109,10 +112,22 @@ fn mek(id: i32, at: Coord, facing: i32) -> Unit { .expect("unit parses") } -fn wire_weapon(id: i32, damage: f32, short: i32, medium: i32, long: i32) -> Weapon { +/// A single-packet weapon at a stated mount, through the wire's deserialiser. +/// +/// `location` is a wire field now, so the mount arrives the way a real one does +/// rather than being pushed in afterwards. +fn wire_weapon( + id: i32, + damage: f32, + short: i32, + medium: i32, + long: i32, + location: MekLocation, +) -> Weapon { + let mount = location.abbreviation(); serde_json::from_str(&format!( r#"{{ - "id": {id}, "name": "Gun {id}", "heat": 3, + "id": {id}, "name": "Gun {id}", "heat": 3, "location": "{mount}", "short": {short}, "medium": {medium}, "long": {long}, "avgDamageShort": {damage}, "avgDamageMedium": {damage}, "avgDamageLong": {damage}, "rackSize": 0, @@ -122,16 +137,27 @@ fn wire_weapon(id: i32, damage: f32, short: i32, medium: i32, long: i32) -> Weap .expect("weapon parses") } -/// An autocannon in the right arm and a laser in the left torso: enough spread -/// that a twist changes what bears. +/// An AC/20 in the right arm, a PPC in the left torso and an LRM-10 in the +/// right torso. +/// +/// Spread across three locations, so a twist changes what bears. Heavy on +/// purpose: a pair of light guns leaves `p_kill` at 0.0002 and breaches under +/// 0.1 everywhere, which makes two of the five columns carry no information. +/// This is a fixture choice and not a model change. fn loadout() -> Vec { + let lrm: Weapon = serde_json::from_str( + r#"{ + "id": 3, "name": "LRM-10", "heat": 4, "location": "RT", + "short": 7, "medium": 14, "long": 21, + "avgDamageShort": 6.0, "avgDamageMedium": 6.0, "avgDamageLong": 6.0, + "rackSize": 10, "damagePerPacket": 1.0, "usable": true + }"#, + ) + .expect("weapon parses"); vec![ - MountedWeapon::from_wire( - &wire_weapon(1, 10.0, 5, 10, 15), - MekLocation::RightArm, - false, - ), - MountedWeapon::from_wire(&wire_weapon(2, 5.0, 3, 6, 9), MekLocation::LeftTorso, false), + MountedWeapon::from_wire(&wire_weapon(1, 20.0, 3, 6, 9, MekLocation::RightArm)), + MountedWeapon::from_wire(&wire_weapon(2, 10.0, 6, 12, 18, MekLocation::LeftTorso)), + MountedWeapon::from_wire(&lrm), ] } @@ -167,24 +193,23 @@ fn digit(value: Option, full: f32) -> char { } } -/// The best facing at each hex, by margin, with what it deals and takes. +/// Per hex: the most we can deal from it at any facing, and the least we can be +/// made to take at it at any facing. +/// +/// Two extremes over the facings rather than one facing's pair, because +/// choosing a facing to display would need a scalar to choose it with, and +/// there is deliberately no such scalar - see the note on +/// [`sds_core::stands::Ranking`]. They can be different facings, and the +/// per-stand lists below are where the real `(hex, facing)` states are. fn by_hex(ranking: &Ranking) -> BTreeMap<(i32, i32), (f32, f32)> { - let mut best: BTreeMap<(i32, i32), (f32, f32, f32)> = BTreeMap::new(); + let mut best: BTreeMap<(i32, i32), (f32, f32)> = BTreeMap::new(); for score in &ranking.scored { let at = (score.reach.stand.hex.x, score.reach.stand.hex.y); - let margin = score.margin(); - let entry = best.entry(at).or_insert((f32::NEG_INFINITY, 0.0, 0.0)); - if margin > entry.0 { - *entry = ( - margin, - score.offence.expected_damage, - score.defence.expected_damage, - ); - } + let entry = best.entry(at).or_insert((0.0, f32::INFINITY)); + entry.0 = entry.0.max(score.offence.expected_damage); + entry.1 = entry.1.min(score.defence.expected_damage); } - best.into_iter() - .map(|(at, (_, deal, take))| (at, (deal, take))) - .collect() + best } /// Two text lines per board row, because odd columns sit half a hex lower. @@ -205,7 +230,8 @@ struct Picture<'a> { fn render( picture: &Picture<'_>, values: &BTreeMap<(i32, i32), (f32, f32)>, - marked: &BTreeSet<(i32, i32)>, + offence: &BTreeSet<(i32, i32)>, + defence: &BTreeSet<(i32, i32)>, ) -> String { let Picture { board, @@ -233,13 +259,19 @@ fn render( } line.push(digit(held.map(|v| v.0), deal_full)); line.push(digit(held.map(|v| v.1), take_full)); - line.push(if enemies.contains(&key) { - 'E' - } else if marked.contains(&key) { - '*' - } else { - ' ' - }); + line.push( + match ( + enemies.contains(&key), + offence.contains(&key), + defence.contains(&key), + ) { + (true, _, _) => 'E', + (_, true, true) => 'b', + (_, true, false) => 'o', + (_, false, true) => 'd', + _ => ' ', + }, + ); line.push(' '); } out.push_str(line.trim_end()); @@ -386,7 +418,7 @@ fn main() { }; println!( - "cell: terrain, damage we deal, damage we take, then * for a top {TOP_K} stand or E for an enemy start" + "cell: terrain, damage dealt, damage taken, then o / d / b for a top {TOP_K} stand by\n offence / defence / both, or E for an enemy start" ); println!( "digits are 0-9 against one scale for all three boards: 9 = {deal_full:.1} dealt, 9 = {take_full:.1} taken" @@ -396,53 +428,120 @@ fn main() { ); println!("- = a hex we cannot stand in"); println!( - "stands are ranked on margin - damage dealt less damage taken - so a low exponent will" + "the two digits are the most we can deal from a hex at any facing and the least we can" ); - println!("prefer a hex with no exchange at all over one that trades evenly."); + println!("be made to take at it at any facing. They may be different facings."); + + // In woods, or with woods next door. A clearing ringed by trees is cover - + // nothing can see into it - and counting only the trees themselves would + // miss exactly the hex the defence list picks first. + let in_cover = |score: &sds_core::stands::StandScore| { + let at = score.reach.stand.hex; + std::iter::once(at) + .chain(sds_core::hex::neighbours(at)) + .any(|hex| move_board.contains(hex) && move_board.hex(hex).terrain_mp >= 1) + }; + let describe = |score: &sds_core::stands::StandScore| { + format!( + "({:>2},{:>2}) f{}", + score.reach.stand.hex.x, score.reach.stand.hex.y, score.reach.stand.facing + ) + }; + + let mut verdict: Vec<(String, String, String, usize, usize)> = Vec::new(); for (label, ranking) in &rankings { let values = by_hex(ranking); - let marked: BTreeSet<(i32, i32)> = ranking - .best() - .map(|score| (score.reach.stand.hex.x, score.reach.stand.hex.y)) - .collect(); + let hexes = |list: &mut dyn Iterator| { + list.map(|score| (score.reach.stand.hex.x, score.reach.stand.hex.y)) + .collect::>() + }; + let offence_hexes = hexes(&mut ranking.best_offence()); + let defence_hexes = hexes(&mut ranking.best_defence()); println!(); println!("=== {label} ==="); - println!("{}", render(&picture, &values, &marked)); - println!(" top {TOP_K} stands:"); - for score in ranking.best() { - println!( - " ({:>2},{:>2}) f{} deal {:>5.2} take {:>5.2} margin {:>6.2} \ - p_kill {:.4} breaches {:.2} target {}", - score.reach.stand.hex.x, - score.reach.stand.hex.y, - score.reach.stand.facing, - score.offence.expected_damage, - score.defence.expected_damage, - score.margin(), - score.offence.p_kill, - score.offence.expected_breaches, - score - .best_target - .map(|id| id.to_string()) - .unwrap_or_else(|| "none".to_string()), - ); + println!( + "{}", + render(&picture, &values, &offence_hexes, &defence_hexes) + ); + + for (name, list) in [ + ("offence - most dealt", ranking.by_offence.clone()), + ("defence - least taken", ranking.by_defence.clone()), + ] { + println!(" top {TOP_K} by {name}:"); + for at in &list { + let score = &ranking.scored[*at]; + println!( + " {} deal {:>6.2} take {:>6.2} p_kill {:.4} \ + mission {:.4} breaches {:.2} value {:>6.2} target {}", + describe(score), + score.offence.expected_damage, + score.defence.expected_damage, + score.offence.p_kill, + score.offence.p_mission_kill, + score.offence.expected_breaches, + score.offence.value_destroyed, + score + .best_target + .map(|id| id.to_string()) + .unwrap_or_else(|| "none".to_string()), + ); + } } - // In woods, or with woods next door. A clearing ringed by trees is - // cover - nothing can see into it - and counting only the trees - // themselves would miss exactly the hex minimax picks first. - let in_cover = ranking - .best() - .filter(|score| { - let at = score.reach.stand.hex; - std::iter::once(at) - .chain(sds_core::hex::neighbours(at)) - .any(|hex| move_board.contains(hex) && move_board.hex(hex).terrain_mp >= 1) - }) - .count(); - println!(" {in_cover} of the top {TOP_K} are in woods or beside them"); + + let offence_cover = ranking.best_offence().filter(|s| in_cover(s)).count(); + let defence_cover = ranking.best_defence().filter(|s| in_cover(s)).count(); + println!( + " in woods or beside them: {offence_cover}/{TOP_K} of the offence list, \ + {defence_cover}/{TOP_K} of the defence list" + ); + verdict.push(( + (*label).to_string(), + ranking + .best_offence() + .next() + .map(describe) + .unwrap_or_default(), + ranking + .best_defence() + .next() + .map(describe) + .unwrap_or_default(), + offence_cover, + defence_cover, + )); } + // The epic predicts that a lower exponent moves the answer toward cover. + // Printed as a table rather than asserted, and printed whichever way it + // comes out: a demo tuned until it agreed would be worth nothing. + println!(); + println!("does the exponent move the answer?"); + println!( + " {:<20} {:>12} {:>12} {:>10} {:>10}", + "exponent", "top offence", "top defence", "off cover", "def cover" + ); + for (label, offence, defence, offence_cover, defence_cover) in &verdict { + println!( + " {label:<20} {offence:>12} {defence:>12} {:>9}/{TOP_K} {:>9}/{TOP_K}", + offence_cover, defence_cover + ); + } + let offence_moved = verdict.iter().any(|row| row.1 != verdict[0].1); + let defence_moved = verdict.iter().any(|row| row.2 != verdict[0].2); + let cover_rises_as_p_falls = + verdict[0].4 > verdict[verdict.len() - 1].4 && verdict[0].3 >= verdict[verdict.len() - 1].3; + println!( + " top stand moves with the exponent: offence {}, defence {}", + if offence_moved { "yes" } else { "NO" }, + if defence_moved { "yes" } else { "NO" } + ); + println!( + " more cover as the exponent falls, as the epic predicts: {}", + if cover_rises_as_p_falls { "yes" } else { "NO" } + ); + println!(); let stats = cache.stats(); println!( diff --git a/crates/sds-core/src/stands.rs b/crates/sds-core/src/stands.rs index 253b287..3fa816a 100644 --- a/crates/sds-core/src/stands.rs +++ b/crates/sds-core/src/stands.rs @@ -26,12 +26,11 @@ //! offers its four verbs. This is the estimator and the instrument for reading //! it. -use crate::arc; use crate::hex::Stand; use crate::los::LosCache; use crate::pathfind::Reach; use crate::volley::{ - best_volley, twist_options, Firer, MountedWeapon, TargetState, VolleyCache, VolleyOutcome, + self, best_volley, twist_options, Firer, MountedWeapon, TargetState, VolleyCache, VolleyOutcome, }; use crate::wire::{Coord, Unit}; @@ -315,15 +314,6 @@ pub struct StandScore { pub best_target: Option, } -impl StandScore { - /// Damage dealt less damage taken. The scalar the ranking uses, and the one - /// number in this module that is a tactical opinion rather than a - /// measurement. - pub fn margin(&self) -> f32 { - self.offence.expected_damage - self.defence.expected_damage - } -} - /// What the sweep did, in counters. /// /// No clocks. The two prune columns are per `(L, M)` pair and are the @@ -338,19 +328,55 @@ pub struct StandStats { } /// The ranked stands and the counters from producing them. +/// +/// **Two lists, and no combined one.** Ranking on a single scalar means +/// choosing an exchange rate between damage dealt and damage taken, and that +/// rate is a tactical opinion: it is the thing [`crate::features`] and +/// `plan/strategies.md` exist to hold, and it changes with role, with damage +/// already taken, and with whether the force is winning. Collapsing to one +/// number here would bury a weight vector in the estimator and undo the reason +/// [`Aggregate`] carries five columns through both aggregations - a feature +/// chooses, and the last step must not choose for it. +/// +/// It also gave a wrong answer. Ranking on `offence - defence` made the top +/// stands at a low exponent the ones with no exchange at all, because a hex +/// out of everyone's range scores zero minus zero and beats every hex that +/// trades. That is "far away", not "behind a tree", and it is what +/// `examples/stands.rs` prints. +/// +/// The proposal set is the union of the two lists. A caller that wants one +/// ordering applies its own weights to [`Self::scored`], which is every stand. #[derive(Debug, Clone, PartialEq)] pub struct Ranking { /// Every stand, scored, in the order they were reached. pub scored: Vec, - /// Indices into [`Self::scored`], best first, at most `top_k` of them. - pub top: Vec, + /// Indices into [`Self::scored`], most dealt first, at most `top_k`. + pub by_offence: Vec, + /// Indices into [`Self::scored`], least taken first, at most `top_k`. + pub by_defence: Vec, pub stats: StandStats, } impl Ranking { - /// The top stands themselves. - pub fn best(&self) -> impl Iterator { - self.top.iter().map(|at| &self.scored[*at]) + /// The stands that deal the most, best first. + pub fn best_offence(&self) -> impl Iterator { + self.by_offence.iter().map(|at| &self.scored[*at]) + } + + /// The stands that take the least, best first. + pub fn best_defence(&self) -> impl Iterator { + self.by_defence.iter().map(|at| &self.scored[*at]) + } + + /// The proposal set: both lists, each stand once, in a fixed order. + pub fn proposals(&self) -> Vec { + let mut out = self.by_offence.clone(); + for at in &self.by_defence { + if !out.contains(at) { + out.push(*at); + } + } + out } } @@ -441,32 +467,37 @@ pub fn score_stands( }); } - let mut order: Vec = (0..scored.len()).collect(); - order.sort_by(|a, b| { - let (x, y) = (&scored[*a], &scored[*b]); - y.margin() - .total_cmp(&x.margin()) - .then( - y.offence - .expected_damage - .total_cmp(&x.offence.expected_damage), - ) - .then( - x.defence - .expected_damage - .total_cmp(&y.defence.expected_damage), - ) - // A total order, so the emitted set does not depend on the sort's - // stability or on the order the stands were reached in. - .then(x.reach.stand.hex.x.cmp(&y.reach.stand.hex.x)) - .then(x.reach.stand.hex.y.cmp(&y.reach.stand.hex.y)) - .then(x.reach.stand.facing.cmp(&y.reach.stand.facing)) - }); - order.truncate(params.top_k); + // Two orders, never one. See the note on `Ranking`: an exchange rate + // between the two columns is doctrine, and it does not live here. + let rank = |pick: fn(&StandScore) -> (f32, f32, f32), descending: bool| { + let mut order: Vec = (0..scored.len()).collect(); + order.sort_by(|a, b| { + let (x, y) = (&scored[*a], &scored[*b]); + let (x_key, y_key) = (pick(x), pick(y)); + let (first, second) = if descending { + (y_key, x_key) + } else { + (x_key, y_key) + }; + first + .0 + .total_cmp(&second.0) + .then(first.1.total_cmp(&second.1)) + .then(first.2.total_cmp(&second.2)) + // A total order, so the emitted set does not depend on the + // sort's stability or on the order the stands were reached in. + .then(x.reach.stand.hex.x.cmp(&y.reach.stand.hex.x)) + .then(x.reach.stand.hex.y.cmp(&y.reach.stand.hex.y)) + .then(x.reach.stand.facing.cmp(&y.reach.stand.facing)) + }); + order.truncate(params.top_k); + order + }; Ranking { + by_offence: rank(|score| score.offence.objective(), true), + by_defence: rank(|score| score.defence.objective(), false), scored, - top: order, stats, } } @@ -482,12 +513,7 @@ pub fn any_bears(state: &Firer, target: Coord, weapons: &[MountedWeapon]) -> boo } for twist in twist_options(state) { for weapon in weapons { - if !weapon.usable { - continue; - } - let firing = arc::firing_facing(state.facing, twist, weapon.location); - let which = arc::weapon_arc(weapon.location, weapon.rear_mounted, state.arms_flipped); - if arc::is_in_arc(state.hex, firing, target, which) { + if weapon.usable && volley::bears(state, twist, weapon, target) { return true; } } @@ -582,7 +608,8 @@ mod tests { use crate::features::fixture; use crate::hex; use crate::los::Rules; - use crate::wire::{Board, Weapon}; + use crate::wire::Board; + use std::collections::BTreeSet; fn flat(width: i32, height: i32) -> Board { Board { @@ -595,12 +622,12 @@ mod tests { /// A gun with a stated bracket, mounted in the right torso so a twist moves /// it. fn gun(id: i32, damage: f32, short: i32, medium: i32, long: i32) -> MountedWeapon { - let wire: Weapon = fixture::gun(id, damage, 3); + let wire = fixture::at(fixture::gun(id, damage, 3), MekLocation::RightTorso, false); MountedWeapon { short, medium, long, - ..MountedWeapon::from_wire(&wire, MekLocation::RightTorso, false) + ..MountedWeapon::from_wire(&wire) } } @@ -806,9 +833,12 @@ mod tests { crowded.defence.expected_damage ); - // The stand is good offence and bad defence, which is the sentence. - assert!(crowded.margin() < 0.0); - assert!(alone.margin() > 0.0); + // The stand is good offence and bad defence, which is the sentence. The + // two columns say so on their own; nothing here subtracts one from the + // other, because the rate at which they trade is doctrine's. + assert!(crowded.offence.expected_damage > crowded.defence.expected_damage / 3.0); + assert!(crowded.defence.expected_damage > crowded.offence.expected_damage); + assert!(alone.defence.expected_damage < alone.offence.expected_damage); } /// A single enemy that has not moved: `M` is a set, and dropping the @@ -896,15 +926,40 @@ mod tests { let second = score_stands(&mut cache, &los, &mover, &stands, &foes, ¶ms); assert_eq!(first.scored.len(), stands.len()); - assert_eq!(first.top.len(), 5); - assert_eq!(first.top, second.top); + assert_eq!(first.by_offence.len(), 5); + assert_eq!(first.by_defence.len(), 5); + assert_eq!(first.by_offence, second.by_offence); + assert_eq!(first.by_defence, second.by_defence); assert_eq!(first.stats.stands, stands.len() as u64); assert_eq!(first.stats.exchanges, stands.len() as u64 * 2); - let margins: Vec = first.best().map(|score| score.margin()).collect(); - for pair in margins.windows(2) { - assert!(pair[0] >= pair[1], "top K is not in order: {margins:?}"); + // Most dealt first. + let dealt: Vec = first + .best_offence() + .map(|score| score.offence.expected_damage) + .collect(); + for pair in dealt.windows(2) { + assert!(pair[0] >= pair[1], "offence is not in order: {dealt:?}"); } + // Least taken first. + let taken: Vec = first + .best_defence() + .map(|score| score.defence.expected_damage) + .collect(); + for pair in taken.windows(2) { + assert!(pair[0] <= pair[1], "defence is not in order: {taken:?}"); + } + + // The two lists are different questions, so they must be free to + // disagree - a board where they picked the same five would not be + // testing the split. + assert_ne!(first.by_offence, first.by_defence); + + // The proposal set is the union, each stand once. + let proposals = first.proposals(); + let unique: BTreeSet = proposals.iter().copied().collect(); + assert_eq!(proposals.len(), unique.len()); + assert!(proposals.len() <= 10 && proposals.len() > 5); } /// Both prunes counted over the same pairs, which is what makes the order a diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs index e4a97d7..25fa31c 100644 --- a/crates/sds-core/src/volley.rs +++ b/crates/sds-core/src/volley.rs @@ -371,6 +371,35 @@ pub fn twist_options(state: &Firer) -> Vec { out } +/// Whether one weapon bears on a hex from a state at a given secondary facing. +/// +/// The arc half of the `(L, M)` prune, on its own so that +/// [`crate::stands::any_bears`] can measure its selectivity against the line of +/// sight half's without keeping a second copy of these two rules. It had one, +/// and the copy went stale the first time the mount became optional. +/// +/// The twist moves a torso or arm weapon and leaves a leg weapon pointing where +/// the unit's feet do. A mount this build does not model is read as a leg: it +/// declines the twist, which is true of the one location known to be missing - +/// a tripod's centre leg - and is the safe direction for any other. +/// +/// The arc is not a guess. Every location but the two arms is forward, and +/// rear-mounted beats the location outright, so the centre torso's arc is right +/// for an unmodelled mount unless it is an arm, and forward is the narrower of +/// the two. +pub fn bears(state: &Firer, twist: i32, weapon: &MountedWeapon, target: Coord) -> bool { + let firing = match weapon.location { + Some(location) => arc::firing_facing(state.facing, twist, location), + None => state.facing, + }; + let arc = arc::weapon_arc( + weapon.location.unwrap_or(MekLocation::CenterTorso), + weapon.rear_mounted, + state.arms_flipped, + ); + arc::is_in_arc(state.hex, firing, target, arc) +} + /// Read the inputs and build the key the estimate is a function of. /// /// This is the only place that touches a board, a line of sight cache or a @@ -417,25 +446,7 @@ pub fn gather( if !weapon.usable { continue; } - // The twist moves a torso or arm weapon and leaves a leg weapon - // pointing where the unit's feet do. A mount this build does not - // model is read as a leg: it declines the twist, which is true of - // the one location known to be missing - a tripod's centre leg - - // and is the safe direction for any other. - let firing = match weapon.location { - Some(location) => arc::firing_facing(state.facing, twist, location), - None => state.facing, - }; - // The arc is not a guess. Every location but the two arms is - // forward, and rear-mounted beats the location outright, so the - // centre torso's arc is right for an unmodelled mount unless it is - // an arm - and forward is the narrower of the two. - let arc = arc::weapon_arc( - weapon.location.unwrap_or(MekLocation::CenterTorso), - weapon.rear_mounted, - state.arms_flipped, - ); - if !arc::is_in_arc(state.hex, firing, target.hex, arc) { + if !bears(state, twist, weapon, target.hex) { continue; } let Some((range_modifier, damage)) = weapon.bracket(range) else { diff --git a/plan/candidates.md b/plan/candidates.md index de5dc88..edabe97 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -151,11 +151,18 @@ good offence and bad defence, and a single operator over `N` cannot say so. rarely prunes and arc prunes hard; in forest the reverse. Do not fix the order by assumption - make it a measured choice. `stands::prune_selectivity` runs both over every `(L, M)` pair and counts - what each drops alone. On the three corpus boards, one Mek with 6 MP - against three with 4: open 17.2% arc against 15.8% line, forest 19.8% - against 82.8%, water 20.2% against 42.4%. The prediction holds - arc is - the better first test only on open ground. These are drop counts and not - costs; `volley::gather` still runs line of sight first + what each drops alone. **The answer depends on the engagement geometry as + much as on the board.** With the sides ten hexes apart, open came out + 17.2% arc against 15.8% line - the predicted shape. Moving the enemies to + fighting range flipped it: open 17.5% arc against 37.3% line, forest 18.7% + against 60.3%, water 19.5% against 39.6%. Line of sight is the better + first test in every one of those, because at close range there is usually + something between two units even on an open board. So the epic's "on open + ground LOS rarely prunes" is **not confirmed**, and the order cannot be + fixed per board type the way this item assumed - it would have to key on + range too, if it is worth keying on anything. These are drop counts and + not costs; `volley::gather` still runs line of sight first, which the + numbers now support - [x] Volley estimator `best_volley(state, target_hex, target_facing, weapons)`, maximising over the three twist options internally. Torso twist is chosen at firing time, so it is not part of `L` @@ -182,9 +189,16 @@ good offence and bad defence, and a single operator over `N` cannot say so. scores high on one and low on the other. `stands::score_stands`, and the five volley outputs stay five through both aggregations - [x] Emit the top *K* states as proposals. `stands::Ranking` carries every - state scored and the top *K* by margin, in a total order so the emitted - set does not depend on the order the states were reached in. Nothing - consumes them yet - `sds-bot` still offers its four verbs + state scored and **two** top-*K* lists, most dealt and least taken, each + in a total order so the emitted set does not depend on the order the + states were reached in; the proposal set is their union. There is no + combined ranking on purpose. A single scalar is an exchange rate between + the two columns, which is doctrine's and not the estimator's, and it + undoes the reason `Aggregate` carries five columns at all. It also gave a + wrong answer: ranking on `offence - defence` made the winners at a low + exponent the hexes with no exchange at all, since zero minus zero beats + every hex that trades. Nothing consumes the lists yet - `sds-bot` still + offers its four verbs - [ ] Features read `tmm_gained`, `elevation_gain`, `cover_quality` from the state rather than re-deriving them. Test: values match the old path for the same end hex @@ -224,7 +238,17 @@ rather than a test, because wall-clock assertions are flaky under load. state set rather than a point, both aggregations run, and a state that is merely good on average loses to one that is good against most of `M`. Assert: state count, pairs surviving each prune, and that lowering the - power-mean exponent moves the answer toward cover + power-mean exponent moves the answer toward cover. + `examples/stands.rs` runs this shape already and prints the verdict. The + cover claim holds on the defence list and only there: open goes 6/8 in + cover at `p -> -inf` against 1/8 at `p = 1`, water 5/8 against 4/8. The + offence list does not move toward cover at any exponent, which is correct + - a stand that shoots wants a line. Forest cannot answer the question at + all: every reachable hex is in or beside woods, so the measure reads 8/8 + at every exponent. **A test asserting this needs cover sparse enough to + discriminate, and must assert it of the defence list**, or it will pass + for the wrong reason on one board and fail for the wrong reason on + another - [ ] **`they-have-already-moved`**: the enemy is placed, so `M` collapses to a point. Two questions from one board: which state maximises expected damage, and which maximises the chance of a mission kill. **They must be