diff --git a/crates/sds-core/src/lib.rs b/crates/sds-core/src/lib.rs index 1789770..f338c7d 100644 --- a/crates/sds-core/src/lib.rs +++ b/crates/sds-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod pathfind; pub mod plan; pub mod role; pub mod stance; +pub mod volley; pub mod wire; pub use role::Role; diff --git a/crates/sds-core/src/volley.rs b/crates/sds-core/src/volley.rs new file mode 100644 index 0000000..bd04bf8 --- /dev/null +++ b/crates/sds-core/src/volley.rs @@ -0,0 +1,1110 @@ +//! What shooting at a target from a candidate position is worth. +//! +//! The scoring half of [`crate::pathfind`]: given an end state a unit could +//! reach, this says what it could do from there to one enemy. Everything it +//! needs already exists - [`crate::arc`] answers what bears, [`crate::los`] +//! answers what can be seen, [`crate::ev`] turns shots into distributions and +//! [`crate::hitloc`] puts the damage in places - so this module is the wiring +//! and the two decisions the wiring has to make. +//! +//! **The torso twist is chosen here, not upstream.** A twist happens at firing +//! time, so it is not part of the state a path search enumerates; folding it +//! into the state would multiply a 151-state search by three for a choice that +//! costs nothing to make later. [`best_volley`] tries every legal secondary +//! facing and reports the best. Leg weapons do not follow it - +//! `Mek.isSecondaryArcWeapon` is false for a leg - and a port that reasoned +//! from the arc alone would silently credit a twist with recovering weapons +//! that never moved. +//! +//! **The cache key is built by the computation.** [`gather`] reads the state, +//! the target and the weapons and produces a [`VolleyKey`]; [`solve`] is a +//! function of that key and nothing else. Two calls with the same key therefore +//! have the same answer by construction rather than by inspection, which is the +//! property `tests::equal_keys_mean_equal_values` checks and the reason the key +//! is not written out by hand. Three duplicated feature columns have shipped +//! from hand-written keys already. +//! +//! **Five outputs, one pass.** Expected damage, `p_kill`, `p_mission_kill`, +//! expected breaches and `value_destroyed` all come off the same +//! [`LocationDamage`], because they mostly disagree and each one is otherwise a +//! reason to convolve the same volley again. Damage and value diverge hardest: +//! stripping armour off a leg is damage that moves no value, and destroying an +//! arm with two guns in it is little damage and a large drop. +//! +//! What is not modelled, and would flatter a shot if it were forgotten: minimum +//! range, extreme range, and heat. The wire carries no minimum range and no +//! extreme bracket, so a weapon is short, medium, long or out of range; +//! `Compute.getRangeMods` adds `minimum - distance + 1` inside minimum range and +//! nothing here can. Heat is the caller's - it is a property of the volley as a +//! whole and of the turn after this one. + +use std::collections::BTreeMap; + +use crate::arc::{self, MekLocation}; +use crate::ev::{EvCache, ShotKey}; +use crate::hex; +use crate::hitloc::{self, LocationDamage, LocationProfile}; +use crate::los::{self, AttackInfo, LosCache, Side, IMPOSSIBLE}; +use crate::wire::{Coord, Unit, Weapon}; + +/// To-hit penalty at medium range, from `Entity.getMediumRangeModifier`. +pub const MEDIUM_RANGE_MODIFIER: i32 = 2; +/// To-hit penalty at long range, from `Entity.getLongRangeModifier`. +pub const LONG_RANGE_MODIFIER: i32 = 4; + +/// The worst 2d6 target that can still land. Past it the shot is dropped rather +/// than kept at zero, so it costs nothing downstream. +pub const MAX_TO_HIT: i32 = 12; + +/// A weapon as the arc test needs it: the wire's weapon, plus where it is bolted +/// on. +/// +/// The wire does not carry a mount location - [`crate::wire::Weapon`] has ranges +/// and damage and no location - so the caller states it. A weapon whose mount is +/// unknown is honestly a torso weapon: [`MekLocation::CenterTorso`] fires +/// forward and follows the twist, which is what all but the arms and legs do. +#[derive(Debug, Clone, PartialEq)] +pub struct MountedWeapon { + pub id: i32, + /// Where it is mounted. Picks the arc, and decides whether a twist moves it. + pub location: MekLocation, + /// Rear-mounted weapons fire into the rear arc whatever their location. + pub rear_mounted: bool, + pub short: i32, + pub medium: i32, + pub long: i32, + pub avg_damage_short: f32, + pub avg_damage_medium: f32, + pub avg_damage_long: f32, + pub rack_size: i32, + pub damage_per_packet: f32, + pub usable: bool, +} + +impl MountedWeapon { + /// A wire weapon at a stated mount. + pub fn from_wire(weapon: &Weapon, location: MekLocation, rear_mounted: bool) -> Self { + Self { + id: weapon.id, + location, + rear_mounted, + short: weapon.short, + medium: weapon.medium, + long: weapon.long_range, + avg_damage_short: weapon.avg_damage_short, + avg_damage_medium: weapon.avg_damage_medium, + avg_damage_long: weapon.avg_damage_long, + rack_size: weapon.rack_size, + damage_per_packet: weapon.damage_per_packet, + usable: weapon.usable, + } + } + + /// `(to-hit penalty, average damage)` at this range, or `None` past long. + /// + /// Short is free, medium is +2 and long is +4, read out of + /// `Entity.getShortRangeModifier` and its two siblings rather than recalled. + /// A range of zero is nobody's bracket: `Compute.getRangeMods` refuses it + /// for everything but infantry weapons. + fn bracket(&self, range: i32) -> Option<(i32, f32)> { + if range <= 0 { + return None; + } + if range <= self.short { + Some((0, self.avg_damage_short)) + } else if range <= self.medium { + Some((MEDIUM_RANGE_MODIFIER, self.avg_damage_medium)) + } else if range <= self.long { + Some((LONG_RANGE_MODIFIER, self.avg_damage_long)) + } else { + None + } + } +} + +/// The unit doing the shooting, at one candidate end state. +/// +/// `hex` and `facing` are the `(hex, facing)` state the path search enumerates. +/// The secondary facing is *not* here: it is chosen inside [`best_volley`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Firer { + pub hex: Coord, + pub facing: i32, + /// Height above the hex's floor, and the unit's own height in levels. Both + /// feed [`AttackInfo::ground`], which is where the geometry is decided. + pub elevation: i32, + pub height: i32, + pub is_mek: bool, + /// Everything in the to-hit that depends on neither the target nor the + /// weapon: gunnery, the attacker's own movement, heat, damaged actuators. + /// + /// One number because those are all properties of the path, and the path is + /// the caller's. What this module adds to it is the range bracket, the line + /// of sight and the target's movement. + pub base_to_hit: i32, + /// Whether the unit may twist at all, from [`arc::can_twist`]. + pub can_twist: bool, + /// The `ext_twist` quirk: two hexsides instead of one. + pub extended_twist: bool, + /// Flipped arms fire both arm weapons into the rear arc. + pub arms_flipped: bool, +} + +/// The target, where it is and what it is made of. +#[derive(Debug, Clone, Copy)] +pub struct TargetState<'a> { + pub hex: Coord, + /// Picks the hit table through [`los::side_table`], and nothing else. + pub facing: i32, + pub elevation: i32, + pub height: i32, + pub is_mek: bool, + /// Hexes the target moved, for [`los::target_movement_modifier`]. + pub hexes_moved: i32, + pub jumped: bool, + pub unit: &'a Unit, +} + +/// Why there is no volley to estimate. +/// +/// Named rather than folded into an empty result, because "cannot be seen" and +/// "nothing bears" prune at different stages and the counts of each are what +/// says which prune to run first on which board. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NoVolley { + /// The attacker is standing in the target's hex. Not a weapon attack. + SameHex, + /// No line of sight, or one the modifiers make impossible. Full cover + /// arrives here: `LosEffects` sets it with `blocked`, so `has_los` is false. + NoLine, + /// A line, but no weapon bears at any legal twist and any bracket. + NothingBears, +} + +/// One secondary facing and the shots it permits. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct TwistOption { + /// The secondary facing, 0-5. + pub twist: i32, + /// Sorted, so two options built in different weapon orders are one option. + pub shots: Vec, +} + +/// Everything [`solve`] reads, and nothing else. +/// +/// Built by [`gather`]. The point of the split is that the memo key cannot drift +/// from the computation: `solve` takes this and has no other argument, so a +/// field left out of the key is a field the estimate cannot depend on. +/// +/// Field order is the sort order and is chosen so the cheap discriminators come +/// first. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct VolleyKey { + /// The arc the shots arrive in: the hit table and the armour pool. + pub side: Side, + /// The target reduced to thresholds and values. + pub profile: LocationProfile, + /// The twists worth trying, in preference order - no twist first, so a + /// twist has to be strictly better to be taken. + pub options: Vec, +} + +/// What a volley from one state at one target is worth. +/// +/// Every field is measured off the same [`LocationDamage`] for the same chosen +/// twist, so they describe one volley rather than five. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolleyOutcome { + /// Whether anything bore at all. False makes every number below zero. + pub bears: bool, + /// The secondary facing that produced these numbers. + pub twist: i32, + /// How many shots that twist fires. + pub shots: usize, + /// Mean damage arriving anywhere on the target. + pub expected_damage: f32, + /// `P(head or centre torso destroyed)`. + pub p_kill: f32, + /// `P(a leg destroyed)`. **Not a superset of [`Self::p_kill`]** - see the + /// note on [`solve`]. + pub p_mission_kill: f32, + /// Expected number of locations stripped to bare structure. A count, not a + /// probability, so it can exceed one. + pub expected_breaches: f32, + /// `sum over locations of P(destroyed) x value`: how much of the target's + /// ability to fight this is expected to stop. + pub value_destroyed: f32, +} + +impl VolleyOutcome { + /// No shot. Every number zero, and `bears` says why they are. + pub fn nothing(facing: i32) -> Self { + Self { + bears: false, + twist: facing.rem_euclid(6), + shots: 0, + expected_damage: 0.0, + p_kill: 0.0, + p_mission_kill: 0.0, + expected_breaches: 0.0, + value_destroyed: 0.0, + } + } + + /// The scalar the twist is chosen on, with its tiebreaks. + /// + /// Expected damage first: it is the finest discriminator of the five, and + /// `value_destroyed` is flat at zero for any volley that only chips armour, + /// which is most of them. The tiebreaks make the choice total, so it does + /// not depend on which twist was tried first. + fn objective(&self) -> (f32, f32, f32) { + (self.expected_damage, self.value_destroyed, self.p_kill) + } + + fn better_than(&self, other: &Self) -> bool { + let (a, b) = (self.objective(), other.objective()); + // `total_cmp` and not `partial_cmp`: a NaN must order rather than + // silently win, and the order must be the same float comparison every + // run. + a.0.total_cmp(&b.0) + .then(a.1.total_cmp(&b.1)) + .then(a.2.total_cmp(&b.2)) + .is_gt() + } +} + +/// How much work the estimator did and how much it skipped. +/// +/// Counters, not timings - nothing in this crate may read a clock. The two +/// prune counts are the measurement `plan/candidates.md` wants before fixing +/// the order the prunes run in. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VolleyStats { + /// Calls to [`best_volley`]. + pub asked: u64, + /// Dropped for standing in the target's hex. + pub same_hex: u64, + /// Dropped by the line of sight prune. + pub no_line: u64, + /// Dropped by the arc and range prune, having survived line of sight. + pub nothing_bears: u64, + /// Answered from the memo. + pub hits: u64, + /// Actually solved. + pub misses: u64, +} + +/// Memoised volley estimates, and the damage cache underneath them. +/// +/// `BTreeMap`, so nothing about iteration order can reach a result. Scope one to +/// a turn and clear it: the keys hold the target's armour state, which changes. +#[derive(Debug, Default)] +pub struct VolleyCache { + ev: EvCache, + outcomes: BTreeMap, + stats: VolleyStats, +} + +impl VolleyCache { + pub fn new() -> Self { + Self::default() + } + + /// The damage cache underneath. Shared on purpose: the same shot at the same + /// to-hit is one distribution however many states asked for it. + pub fn ev(&mut self) -> &mut EvCache { + &mut self.ev + } + + pub fn stats(&self) -> VolleyStats { + self.stats + } + + /// The estimate for a key, computed at most once. + pub fn outcome(&mut self, key: &VolleyKey) -> VolleyOutcome { + if let Some(outcome) = self.outcomes.get(key) { + self.stats.hits += 1; + return *outcome; + } + self.stats.misses += 1; + let outcome = solve(&mut self.ev, key); + self.outcomes.insert(key.clone(), outcome); + outcome + } + + /// Drop everything but the counters. Call it when the turn ends. + pub fn clear(&mut self) { + self.ev.clear(); + self.outcomes.clear(); + } +} + +/// The secondary facings to try, in preference order. +/// +/// No twist first, then one hexside each way, then two if the unit has +/// `ext_twist`. Preference order matters: two twists that bring the same +/// weapons to bear at the same numbers are the same volley, and the one taken +/// should be the one that turns least. +fn twist_options(state: &Firer) -> Vec { + let facing = state.facing.rem_euclid(6); + if !state.can_twist { + return vec![facing]; + } + let reach = if state.extended_twist { 2 } else { 1 }; + let mut out = vec![facing]; + for delta in 1..=reach { + for signed in [-delta, delta] { + let twist = (facing + signed).rem_euclid(6); + if arc::is_valid_secondary_facing(facing, twist, state.extended_twist) + && !out.contains(&twist) + { + out.push(twist); + } + } + } + out +} + +/// 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 +/// weapon list. Everything past it is arithmetic on the key. +pub fn gather( + los_cache: &LosCache, + state: &Firer, + target: &TargetState<'_>, + weapons: &[MountedWeapon], +) -> Result { + if state.hex == target.hex { + return Err(NoVolley::SameHex); + } + + let ai = AttackInfo::ground( + los_cache.board(), + state.hex, + state.elevation, + state.height, + state.is_mek, + target.hex, + target.elevation, + target.height, + target.is_mek, + ); + let line = los_cache.get(&ai); + if !line.can_see() { + return Err(NoVolley::NoLine); + } + let line_modifier = line.modifier_value(los_cache.rules()); + if line_modifier == IMPOSSIBLE { + return Err(NoVolley::NoLine); + } + + let side = los::side_table(target.hex, target.facing, state.hex); + let movement = los::target_movement_modifier(target.hexes_moved, target.jumped); + let range = hex::distance(state.hex, target.hex); + let fixed = state.base_to_hit + line_modifier + movement; + + let mut options: Vec = Vec::new(); + for twist in twist_options(state) { + let mut shots: Vec = Vec::new(); + for weapon in weapons { + if !weapon.usable { + continue; + } + // The twist moves a torso or arm weapon and leaves a leg weapon + // pointing where the unit's feet do. + let firing = arc::firing_facing(state.facing, twist, weapon.location); + let arc = arc::weapon_arc(weapon.location, weapon.rear_mounted, state.arms_flipped); + if !arc::is_in_arc(state.hex, firing, target.hex, arc) { + continue; + } + let Some((range_modifier, damage)) = weapon.bracket(range) else { + continue; + }; + let to_hit = fixed + range_modifier; + if to_hit > MAX_TO_HIT { + continue; + } + let key = if weapon.rack_size >= 2 { + ShotKey::cluster(to_hit, weapon.rack_size, weapon.damage_per_packet) + } else { + ShotKey::single(to_hit, damage) + }; + if let Some(key) = key { + shots.push(key); + } + } + if shots.is_empty() { + continue; + } + // Sorted, so the option is a function of the weapon set and not of the + // order it was walked in - the same reason `EvCache::allocation` sorts. + shots.sort_unstable(); + if options.iter().any(|option| option.shots == shots) { + // An identical volley from a different twist. The first one wins, + // and the first one is the one that turns least. + continue; + } + options.push(TwistOption { twist, shots }); + } + + if options.is_empty() { + return Err(NoVolley::NothingBears); + } + + Ok(VolleyKey { + side, + profile: LocationProfile::of(target.unit, side), + options, + }) +} + +/// The estimate, as a function of the key and nothing else. +/// +/// The five outputs come off one [`LocationDamage`] per twist, and the twist +/// with the best [`VolleyOutcome::objective`] is returned whole. +/// +/// **`p_kill` and `p_mission_kill` are not nested, in either direction.** +/// `p_kill` is `P(head or centre torso destroyed)`; `p_mission_kill` is +/// `P(a leg destroyed)`, which finishes a biped as a manoeuvring unit without +/// killing it. A centre torso takes 7 rolls in 36 and carries a torso's health; +/// a leg takes 4 and carries a leg's. So a volley that kills through a thin +/// centre torso can have almost no chance of taking a leg off, and one that +/// legs a healthy Mek can be nowhere near killing it. Neither bounds the other, +/// and `tests::the_two_kill_measures_are_different_columns` is the guard +/// against them quietly becoming one column again. +pub fn solve(ev: &mut EvCache, key: &VolleyKey) -> VolleyOutcome { + let mut best: Option = None; + for option in &key.options { + let locations = LocationDamage::from_profile(ev, &option.shots, key.side, &key.profile); + let outcome = VolleyOutcome { + bears: true, + twist: option.twist, + shots: option.shots.len(), + expected_damage: locations.mean(), + p_kill: locations.any_destroyed(&[hitloc::HD, hitloc::CT]), + p_mission_kill: locations.any_destroyed(&[hitloc::LL, hitloc::RL]), + // A count and not a probability: eight independent marginals, summed + // in a fixed order. + expected_breaches: (0..hitloc::COUNT).map(|at| locations.breached(at)).sum(), + value_destroyed: locations.expected_value_destroyed(), + }; + if best.as_ref().is_none_or(|held| outcome.better_than(held)) { + best = Some(outcome); + } + } + // `gather` never returns an empty option list, so this is the twist-free + // fallback for a key built by hand in a test. + best.unwrap_or_else(|| VolleyOutcome::nothing(0)) +} + +/// The value of shooting at a target from a candidate position. +/// +/// Maximises over the legal torso twists internally. Returns zeros without +/// touching the damage cache when the line does not exist or nothing bears. +pub fn best_volley( + cache: &mut VolleyCache, + los_cache: &LosCache, + state: &Firer, + target: &TargetState<'_>, + weapons: &[MountedWeapon], +) -> VolleyOutcome { + best_volley_keyed(cache, los_cache, state, target, weapons).1 +} + +/// The same, with the key it was answered from. +/// +/// The key is what the property test compares against; nothing in the bot needs +/// it. +pub fn best_volley_keyed( + cache: &mut VolleyCache, + los_cache: &LosCache, + state: &Firer, + target: &TargetState<'_>, + weapons: &[MountedWeapon], +) -> (Result, VolleyOutcome) { + cache.stats.asked += 1; + match gather(los_cache, state, target, weapons) { + Ok(key) => { + let outcome = cache.outcome(&key); + (Ok(key), outcome) + } + Err(reason) => { + match reason { + NoVolley::SameHex => cache.stats.same_hex += 1, + NoVolley::NoLine => cache.stats.no_line += 1, + NoVolley::NothingBears => cache.stats.nothing_bears += 1, + } + (Err(reason), VolleyOutcome::nothing(state.facing)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::explore::Stream; + use crate::features::fixture; + use crate::los::{Rules, COVER_FULL}; + use crate::wire::{Board, BoardHex}; + use std::collections::BTreeMap; + + /// An empty board of the right size. `LosBoard` fills every hex it is not + /// given with the default, so a flat field needs no hexes at all. + fn flat(width: i32, height: i32) -> Board { + Board { + width, + height, + hexes: Vec::new(), + } + } + + /// Woods hexes. `foliage_elev` goes with them: MegaMek's board files carry + /// it beside the woods level and the trace reads it, so woods without it are + /// woods a line passes straight through. + fn wooded(width: i32, height: i32, at: &[(i32, i32)], level: i32) -> Board { + Board { + width, + height, + hexes: at + .iter() + .map(|(x, y)| BoardHex { + x: *x, + y: *y, + level: 0, + terrain: [ + ("woods".to_string(), level), + ("foliage_elev".to_string(), 2), + ] + .into_iter() + .collect(), + }) + .collect(), + } + } + + fn cache(board: &Board) -> LosCache { + LosCache::new(board, Rules::default()) + } + + fn firer(x: i32, y: i32, facing: i32) -> Firer { + Firer { + hex: Coord::new(x, y), + facing, + elevation: 0, + height: 1, + is_mek: true, + base_to_hit: 4, + can_twist: true, + extended_twist: false, + arms_flipped: false, + } + } + + fn target<'a>(unit: &'a Unit, x: i32, y: i32, facing: i32) -> TargetState<'a> { + TargetState { + hex: Coord::new(x, y), + facing, + elevation: 0, + height: 1, + is_mek: true, + hexes_moved: 0, + jumped: false, + unit, + } + } + + /// A single-packet gun at a stated mount. + fn gun_at(id: i32, damage: f32, location: MekLocation) -> MountedWeapon { + MountedWeapon::from_wire(&fixture::gun(id, damage, 3), location, false) + } + + /// The bearing this whole file's geometry rests on: from (10,10), the hex + /// (14,10) lies at 90 degrees, so a unit facing 0 has it outside its + /// forward arc and one hexside of twist to the right brings it in. + /// + /// Written down because every twist test below is a statement about this + /// hex, and a change in the hex geometry should fail here first. + #[test] + fn the_twist_geometry_is_what_the_tests_assume() { + let from = Coord::new(10, 10); + let to = Coord::new(14, 10); + assert_eq!(hex::distance(from, to), 4); + assert!(!arc::is_in_arc(from, 0, to, arc::Arc::Forward)); + assert!(arc::is_in_arc(from, 1, to, arc::Arc::Forward)); + assert!(!arc::is_in_arc(from, 5, to, arc::Arc::Forward)); + } + + #[test] + fn a_twist_is_worth_more_than_no_twist() { + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let weapons = [gun_at(1, 10.0, MekLocation::RightTorso)]; + + let mut held = VolleyCache::new(); + let stuck = best_volley( + &mut held, + &los, + &Firer { + can_twist: false, + ..firer(10, 10, 0) + }, + &target(&unit, 14, 10, 0), + &weapons, + ); + assert!(!stuck.bears, "nothing bears from facing 0 without a twist"); + assert_eq!(stuck.expected_damage, 0.0); + + let mut twisting = VolleyCache::new(); + let twisted = best_volley( + &mut twisting, + &los, + &firer(10, 10, 0), + &target(&unit, 14, 10, 0), + &weapons, + ); + assert!(twisted.bears); + assert!(twisted.expected_damage > stuck.expected_damage); + // Exactly one hexside, to the right. Two would be illegal and none + // would not bear. + assert_eq!(twisted.twist, 1); + assert_eq!(twisted.shots, 1); + } + + #[test] + fn one_hexside_of_twist_is_all_that_is_offered() { + let state = firer(10, 10, 1); + assert_eq!(twist_options(&state), vec![1, 0, 2]); + assert_eq!( + twist_options(&Firer { + can_twist: false, + ..state + }), + vec![1] + ); + // The quirk widens it, and no further. + assert_eq!( + twist_options(&Firer { + extended_twist: true, + ..state + }), + vec![1, 0, 2, 5, 3] + ); + } + + #[test] + fn a_leg_weapon_does_not_follow_the_twist() { + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let state = firer(10, 10, 0); + let at = target(&unit, 14, 10, 0); + + // The same gun, mounted in a torso and then in a leg. The torso one is + // brought to bear by the twist; the leg one fires along the unit's own + // facing whatever the torso does. + let mut torso_cache = VolleyCache::new(); + let torso = best_volley( + &mut torso_cache, + &los, + &state, + &at, + &[gun_at(1, 10.0, MekLocation::RightTorso)], + ); + assert!(torso.bears); + + let mut leg_cache = VolleyCache::new(); + let (key, leg) = best_volley_keyed( + &mut leg_cache, + &los, + &state, + &at, + &[gun_at(1, 10.0, MekLocation::RightLeg)], + ); + assert_eq!(key, Err(NoVolley::NothingBears)); + assert!(!leg.bears); + assert_eq!(leg_cache.stats().nothing_bears, 1); + + // And it is the mount that decides, not the arc: both weapons carry + // `Arc::Forward`. + assert_eq!( + arc::weapon_arc(MekLocation::RightLeg, false, false), + arc::weapon_arc(MekLocation::RightTorso, false, false) + ); + assert!(!arc::uses_secondary_facing(MekLocation::RightLeg)); + } + + #[test] + fn a_twist_does_not_carry_a_leg_weapon_that_already_bore() { + // The mirror case: pointed straight at the target, a leg weapon bears + // and stays bearing, so a twist can only lose it. The estimator must + // still take the volley that keeps it. + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + let outcome = best_volley( + &mut held, + &los, + &firer(10, 10, 1), + &target(&unit, 14, 10, 0), + &[gun_at(1, 10.0, MekLocation::LeftLeg)], + ); + assert!(outcome.bears); + assert_eq!(outcome.twist, 1, "no twist, because a twist gains nothing"); + } + + #[test] + fn a_blocked_line_is_worth_nothing_and_the_estimator_returns_early() { + // Heavy woods over every intervening hex. The line from (10,10) to + // (14,10) is divided, so MegaMek traces both halves and takes the + // better one - blocking one half would leave the shot open. + let board = wooded(20, 20, &[(11, 9), (11, 10), (12, 10), (13, 9), (13, 10)], 2); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + let (key, outcome) = best_volley_keyed( + &mut held, + &los, + &firer(10, 10, 1), + &target(&unit, 14, 10, 0), + &[gun_at(1, 10.0, MekLocation::RightTorso)], + ); + + assert_eq!(key, Err(NoVolley::NoLine)); + assert!(!outcome.bears); + assert_eq!(outcome.expected_damage, 0.0); + assert_eq!(outcome.p_kill, 0.0); + assert_eq!(outcome.value_destroyed, 0.0); + // Returned early: no damage distribution was built, and no arc was + // tested. The counters are how a prune-ordering measurement reads this. + assert_eq!(held.stats().no_line, 1); + assert_eq!(held.stats().misses, 0); + assert_eq!(held.ev().stats().misses, 0); + } + + #[test] + fn full_cover_is_a_blocked_line() { + // `LosEffects` only ever sets full cover alongside `blocked`, so the + // early return above already covers it. This pins that: a line with + // full cover is impossible whatever else it carries. + let covered = los::Los { + blocked: true, + target_cover: COVER_FULL, + ..los::Los::default() + }; + assert!(!covered.can_see()); + assert_eq!(covered.modifier_value(Rules::default()), IMPOSSIBLE); + } + + #[test] + fn a_weapon_out_of_range_does_not_bear() { + let board = flat(40, 40); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + // `fixture::gun` reaches 18 hexes. 25 is past it. + let (key, _) = best_volley_keyed( + &mut held, + &los, + &firer(5, 20, 2), + &target(&unit, 30, 20, 0), + &[gun_at(1, 10.0, MekLocation::RightTorso)], + ); + assert_eq!(key, Err(NoVolley::NothingBears)); + } + + #[test] + fn range_brackets_are_megameks() { + let weapon = gun_at(1, 10.0, MekLocation::RightTorso); + assert_eq!(weapon.bracket(0), None, "nothing shoots at zero range"); + assert_eq!(weapon.bracket(1).expect("short").0, 0); + assert_eq!(weapon.bracket(6).expect("short").0, 0); + assert_eq!(weapon.bracket(7).expect("medium").0, MEDIUM_RANGE_MODIFIER); + assert_eq!(weapon.bracket(12).expect("medium").0, MEDIUM_RANGE_MODIFIER); + assert_eq!(weapon.bracket(13).expect("long").0, LONG_RANGE_MODIFIER); + assert_eq!(weapon.bracket(18).expect("long").0, LONG_RANGE_MODIFIER); + assert_eq!(weapon.bracket(19), None); + } + + #[test] + fn the_target_moving_makes_it_harder_to_hit() { + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let weapons = [gun_at(1, 10.0, MekLocation::RightTorso)]; + let mut held = VolleyCache::new(); + + let still = best_volley( + &mut held, + &los, + &firer(10, 10, 2), + &target(&unit, 14, 10, 0), + &weapons, + ); + let running = best_volley( + &mut held, + &los, + &firer(10, 10, 2), + &TargetState { + hexes_moved: 8, + ..target(&unit, 14, 10, 0) + }, + &weapons, + ); + assert!(still.expected_damage > running.expected_damage); + } + + // -- the randomised sweep ------------------------------------------------- + + /// A weapon pool small enough that keys repeat and varied enough that they + /// do not all collide. + fn pool() -> Vec { + vec![ + gun_at(1, 10.0, MekLocation::RightArm), + gun_at(2, 5.0, MekLocation::LeftTorso), + gun_at(3, 8.0, MekLocation::RightLeg), + MountedWeapon::from_wire(&fixture::rack(4, 10, 4, 12), MekLocation::LeftArm, false), + MountedWeapon::from_wire(&fixture::gun(5, 12.0, 6), MekLocation::CenterTorso, true), + ] + } + + /// A handful of targets in different states of repair. + /// + /// A pool rather than a fresh draw each time, and a small one: the property + /// under test is about two calls landing on the same key, and a target + /// whose armour is redrawn every iteration never produces one. + fn battered() -> Vec { + let mut units = Vec::new(); + let mut rng = Stream::keyed(0xDA_3A6E, 9, 1); + for _ in 0..4 { + let mut unit = fixture::mek(1); + for index in 0..unit.locations.len() { + if index >= 4 && rng.below(6) == 0 { + // Only limbs, so the unit stays a target the tables + // describe. + unit.locations[index].destroyed = true; + continue; + } + let armor = unit.locations[index].armor; + unit.locations[index].armor = armor - (rng.below(armor.max(1) as usize) as i32); + } + units.push(unit); + } + units + } + + /// One randomised draw of everything [`gather`] reads. + fn draw( + rng: &mut Stream, + weapons: &[MountedWeapon], + ) -> (Firer, i32, i32, i32, Vec) { + let state = Firer { + hex: Coord::new(8 + rng.below(5) as i32, 8 + rng.below(5) as i32), + facing: rng.below(6) as i32, + elevation: 0, + height: 1, + is_mek: true, + base_to_hit: 3 + rng.below(4) as i32, + can_twist: rng.below(4) != 0, + extended_twist: false, + arms_flipped: rng.below(8) == 0, + }; + let chosen: Vec = weapons + .iter() + .filter(|_| rng.below(2) == 0) + .cloned() + .collect(); + ( + state, + 8 + rng.below(7) as i32, + 8 + rng.below(7) as i32, + rng.below(6) as i32, + chosen, + ) + } + + /// **The deliverable.** Equal keys imply equal values. + /// + /// The key is built by [`gather`] from the inputs the computation reads, and + /// [`solve`] takes nothing else - so this is checking that the split is real + /// rather than that someone transcribed a key correctly. A field that + /// changes the answer and is missing from the key shows up here as two + /// different outcomes filed under one key. + /// + /// Three duplicated feature columns have shipped from hand-written keys. + #[test] + fn equal_keys_mean_equal_values() { + let board = wooded(20, 20, &[(11, 11), (12, 12), (9, 9)], 1); + let los = cache(&board); + let weapons = pool(); + let mut rng = Stream::keyed(0x5D5_0126, 1, 7); + let targets = battered(); + let mut seen: BTreeMap = BTreeMap::new(); + let mut repeats = 0; + let mut answered = 0; + + for _ in 0..3000 { + let unit = &targets[rng.below(targets.len())]; + let (state, x, y, facing, chosen) = draw(&mut rng, &weapons); + let at = TargetState { + hexes_moved: rng.below(9) as i32, + jumped: rng.below(4) == 0, + ..target(unit, x, y, facing) + }; + // A fresh cache every draw: a memo hit would return the stored + // answer and prove nothing. + let mut held = VolleyCache::new(); + let (key, outcome) = best_volley_keyed(&mut held, &los, &state, &at, &chosen); + let Ok(key) = key else { continue }; + answered += 1; + if let Some(before) = seen.get(&key) { + repeats += 1; + assert_eq!( + *before, outcome, + "one key, two answers: something the estimate reads is not in the key" + ); + } else { + seen.insert(key, outcome); + } + } + + // A test that never sees the same key twice asserts nothing. + assert!(answered > 500, "only {answered} draws produced a volley"); + assert!(repeats > 100, "only {repeats} keys repeated"); + assert!(seen.len() > 100, "only {} distinct keys", seen.len()); + } + + /// `p_kill` and `p_mission_kill` are two columns, not one wearing two names. + /// + /// The model runs them as *unnested*: `p_kill` is the head or centre torso, + /// `p_mission_kill` is a leg. Neither implies the other, so the sweep must + /// find the ordering broken in both directions. If it ever finds them equal + /// everywhere, one of them is redundant and should be deleted rather than + /// fitted - which is what happened to `los_in`/`los_out` and to `overkill`. + #[test] + fn the_two_kill_measures_are_different_columns() { + let board = flat(20, 20); + let los = cache(&board); + let weapons = pool(); + let mut rng = Stream::keyed(0xB0_11E7, 2, 3); + let targets = battered(); + let mut kill_ahead = 0; + let mut mission_ahead = 0; + let mut identical = 0; + let mut compared = 0; + + for _ in 0..2000 { + let unit = &targets[rng.below(targets.len())]; + let (state, x, y, facing, chosen) = draw(&mut rng, &weapons); + let at = target(unit, x, y, facing); + let mut held = VolleyCache::new(); + let outcome = best_volley(&mut held, &los, &state, &at, &chosen); + if !outcome.bears { + continue; + } + compared += 1; + if outcome.p_kill > outcome.p_mission_kill + 1e-6 { + kill_ahead += 1; + } else if outcome.p_mission_kill > outcome.p_kill + 1e-6 { + mission_ahead += 1; + } else { + identical += 1; + } + } + + assert!(compared > 200, "only {compared} volleys bore"); + assert!( + kill_ahead > 0 && mission_ahead > 0, + "p_kill led {kill_ahead} times and p_mission_kill {mission_ahead}: \ + one of these is contained in the other and only one should ship" + ); + assert!( + identical * 2 < compared, + "identical on {identical} of {compared}: this is one column, not two" + ); + } + + /// The five outputs describe one volley, and each moves for its own reason. + #[test] + fn the_outputs_do_not_move_together() { + let board = flat(20, 20); + let los = cache(&board); + let mut unit = fixture::mek(1); + // Strip the arms to paper and leave the torsos whole: a volley now does + // little damage to a lot of value. + for name in ["RA", "LA"] { + let at = unit + .locations + .iter() + .position(|location| location.name == name) + .expect("a limb"); + unit.locations[at].armor = 1; + unit.locations[at].internal = 1; + } + let mut held = VolleyCache::new(); + let outcome = best_volley( + &mut held, + &los, + &firer(10, 10, 2), + &target(&unit, 12, 10, 0), + &[gun_at(1, 10.0, MekLocation::RightTorso)], + ); + + assert!(outcome.bears); + assert!(outcome.expected_damage > 0.0); + // Value comes off without the target being anywhere near dead, which is + // the divergence the multi-output exists for. + assert!(outcome.value_destroyed > 0.0); + assert!(outcome.p_kill < 0.05, "p_kill {}", outcome.p_kill); + // A count, so it is allowed past one - though one gun will not get + // there. + assert!(outcome.expected_breaches > 0.0); + } + + #[test] + fn the_memo_answers_a_repeat_and_the_counters_say_so() { + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let weapons = [gun_at(1, 10.0, MekLocation::RightTorso)]; + let mut held = VolleyCache::new(); + let first = best_volley( + &mut held, + &los, + &firer(10, 10, 2), + &target(&unit, 14, 10, 0), + &weapons, + ); + let second = best_volley( + &mut held, + &los, + &firer(10, 10, 2), + &target(&unit, 14, 10, 0), + &weapons, + ); + assert_eq!(first, second); + assert_eq!(held.stats().asked, 2); + assert_eq!(held.stats().misses, 1); + assert_eq!(held.stats().hits, 1); + } + + #[test] + fn a_unit_does_not_shoot_its_own_hex() { + let board = flat(20, 20); + let los = cache(&board); + let unit = fixture::mek(1); + let mut held = VolleyCache::new(); + let (key, outcome) = best_volley_keyed( + &mut held, + &los, + &firer(10, 10, 0), + &target(&unit, 10, 10, 0), + &[gun_at(1, 10.0, MekLocation::RightTorso)], + ); + assert_eq!(key, Err(NoVolley::SameHex)); + assert!(!outcome.bears); + assert_eq!(held.stats().same_hex, 1); + } +} diff --git a/plan/candidates.md b/plan/candidates.md index a4517dd..9938bca 100644 --- a/plan/candidates.md +++ b/plan/candidates.md @@ -150,14 +150,14 @@ good offence and bad defence, and a single operator over `N` cannot say so. - [ ] **Measure which prune to run first, per board type.** On open ground LOS rarely prunes and arc prunes hard; in forest the reverse. Do not fix the order by assumption - make it a measured choice -- [ ] Volley estimator `best_volley(state, target_hex, target_facing, weapons)`, +- [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` -- [ ] **Multi-output**, so nothing is computed twice and a feature takes what it +- [x] **Multi-output**, so nothing is computed twice and a feature takes what it needs: expected damage, `p_kill`, `p_mission_kill`, expected breaches, and `value_destroyed`. Damage and value diverge - chipping armour is damage that barely moves value, a destroyed gun is little damage and a large drop -- [ ] Stats cache whose key is **constructed by the computation**, not written +- [x] Stats cache whose key is **constructed by the computation**, not written out by hand. Test: a randomised property test that equal keys imply equal values. Three duplicated columns shipped this way already (`los_in`/`los_out`, `p_kill`/`p_mission_kill`, the dead `overkill`), and