From 2558750a1b6c669efd4055dfb6257ed2d270d622 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 19:43:25 -0400 Subject: [PATCH] feat(unit-rules): damage a design until it is worth a target `wear_to` bisects on plate and then on the frame under it, spread evenly, and takes single points off the thickest location to close the last few - which turns a worst miss of 15 into 5 over a sample of the library. It touches no magazine and shoots nothing out: an empty bin stops being worth anything and stops exploding, so that lever has no direction to bisect on. --- crates/helm-bv/src/lib.rs | 2 + crates/helm-bv/src/wear.rs | 301 +++++++++++++++++++++++++++++++++++++ 2 files changed, 303 insertions(+) create mode 100644 crates/helm-bv/src/wear.rs diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index c7abb9b..2537f6e 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -43,6 +43,7 @@ mod mek; mod offensive; mod repair; mod rules; +mod wear; pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; pub use check::{Severity, Trouble, check}; @@ -54,6 +55,7 @@ pub use mek::Mek; pub use offensive::{Offensive, offensive, speed_factor}; pub use repair::{Repair, Slot, Total, repairs}; pub use rules::{Adjustments, RULES, Rule, adjustments, fired}; +pub use wear::{Worn, wear_to}; use helm_core::{BvBreakdown, Catalogue, ComputedStats, Unit}; diff --git a/crates/helm-bv/src/wear.rs b/crates/helm-bv/src/wear.rs new file mode 100644 index 0000000..7d4f9ad --- /dev/null +++ b/crates/helm-bv/src/wear.rs @@ -0,0 +1,301 @@ +//! Damage a design until it is worth what somebody asked for. +//! +//! A scenario is written as a budget, and the units that fit it are rarely the +//! ones it wants: a lance of fresh machines comes to more than the number, and +//! the honest way down is not to swap the Atlas for something lighter but to +//! field the Atlas that has already been in a fight. +//! +//! This is a search over conditions rather than a rule. Nothing here is a +//! battle value calculation; it asks [`crate::battle_value_in`] repeatedly and +//! keeps the closest answer. +//! +//! # Why it searches on plate first +//! +//! Armour is most of the defensive rating, it is what a machine actually +//! loses, and it is the one term the figure moves smoothly with: every point +//! removed is worth the same, so the value falls as the fraction stripped +//! rises and a bisection finds any figure between whole and stripped in a +//! dozen scorings. +//! +//! The frame underneath it is the second lever, and a smaller one - a Mek has +//! about a third as many structure points as armour points, at 1.5 each rather +//! than 2.5. It is only reached for targets below what stripping the plate can +//! manage. +//! +//! # What it will not do +//! +//! Shoot equipment out, and empty magazines. Both change the figure, and +//! neither changes it in one direction: an empty bin stops being worth +//! anything *and* stops exploding, so running a Locust dry raises its battle +//! value. A search that cannot tell which way a lever moves cannot bisect on +//! it, and a wear profile that quietly made a machine worth *more* is not what +//! anybody asked for. +//! +//! One point of frame is left in every location. At nought a location is off +//! the machine, which takes its weapons with it - a different question from +//! damaging one, and the point at which the figure stops being smooth. + +use helm_core::{Catalogue, Unit}; + +use crate::{Condition, Unsupported}; + +/// A machine worn down to a figure, and what it came to. +#[derive(Debug, Clone, PartialEq)] +pub struct Worn { + /// What was done to it. + pub condition: Condition, + /// What it is worth in that condition. + pub value: i64, + /// What it is worth whole, for the comparison the caller is making. + pub whole: i64, + /// How far the search got from the figure asked for. Zero is exact. + pub missed_by: i64, +} + +impl Worn { + /// Whether the target was reached exactly. + pub fn is_exact(&self) -> bool { + self.missed_by == 0 + } +} + +/// Wear a design down until it is worth `target`, or as near as it can get. +/// +/// The answer is always a real condition and a real figure: a target above +/// what the design is worth whole comes back undamaged, and one below what +/// damage can reach comes back at the floor. `missed_by` says which, and by +/// how much, rather than leaving the caller to compare and guess. +pub fn wear_to(unit: &Unit, catalogue: &Catalogue, target: i64) -> Result { + let whole = crate::battle_value_in(unit, catalogue, &Condition::undamaged())?; + let mut best = Worn { + condition: Condition::undamaged(), + value: whole, + whole, + missed_by: (whole - target).abs(), + }; + if target >= whole { + return Ok(best); + } + + // Plate first, then the frame under it, each bisected on how much of it is + // gone. The two run in order rather than together because stripping the + // plate is the cheaper lever and the one a machine actually loses first. + let mut consider = |condition: Condition, best: &mut Worn| -> Result { + let value = crate::battle_value_in(unit, catalogue, &condition)?; + let missed = (value - target).abs(); + // Ties go to the *less* damaged machine, which is the earlier one + // considered: two conditions worth the same are the same answer, and + // the one that took less damage is the more plausible. + if missed < best.missed_by { + *best = Worn { + condition, + value, + whole, + missed_by: missed, + }; + } + Ok(value) + }; + + let armor = strippable(&unit.armor_locations); + let frame = frame_of(unit); + + // A thousandth of the plate at a time, which is finer than a point on any + // design in the library and keeps the bisection independent of tonnage. + let mut low = 0; + let mut high = 1000; + while low < high { + let mid = (low + high) / 2; + let value = consider(worn_by(&armor, mid, &[], 0), &mut best)?; + if value > target { + low = mid + 1; + } else { + high = mid; + } + } + // `high` is now the least wear that comes in at or under the target, so + // one step back is the closest condition still above it. That is where the + // single points come off: taking them off a machine already under the + // target only moves it further away. + refine( + worn_by(&armor, high.saturating_sub(1), &[], 0), + target, + &mut best, + &mut consider, + )?; + if best.is_exact() { + return Ok(best); + } + + // Still above the target with the plate gone, so start on the frame - with + // the plate left stripped, because putting it back would undo the larger + // lever to pull the smaller one. + let stripped = worn_by(&armor, 1000, &[], 0); + if consider(stripped, &mut best)? <= target { + return Ok(best); + } + let mut low = 0; + let mut high = 1000; + while low < high { + let mid = (low + high) / 2; + let value = consider(worn_by(&armor, 1000, &frame, mid), &mut best)?; + if value > target { + low = mid + 1; + } else { + high = mid; + } + } + Ok(best) +} + +/// How many single points the search will take off after the bisection. +/// +/// The bisection moves every location at once, so one step of it can be worth +/// thirty points of battle value on a large design and the answer it lands on +/// is only as close as that step. Taking single points off afterwards closes +/// the gap, and a bound keeps a search that is not converging from scoring a +/// design three hundred times. +const REFINEMENTS: usize = 60; + +/// Take armour off a point at a time, from wherever there is most of it left. +/// +/// Off the thickest location rather than a fixed one, so the plate that is +/// left stays even - a machine worn to a figure should look like one that took +/// fire, not one with a bald leg. +fn refine( + from: Condition, + target: i64, + best: &mut Worn, + mut consider: impl FnMut(Condition, &mut Worn) -> Result, +) -> Result<(), Unsupported> { + let mut condition = from; + for _ in 0..REFINEMENTS { + if best.is_exact() { + return Ok(()); + } + // The thickest location, by name where two are equal so that the same + // design always wears the same way. + let Some((location, points)) = condition + .armor + .iter() + .filter(|(_, points)| **points > 0) + .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(a.0))) + .map(|(location, points)| (location.clone(), *points)) + else { + return Ok(()); + }; + condition.armor.insert(location, points - 1); + if consider(condition.clone(), best)? <= target { + return Ok(()); + } + } + Ok(()) +} + +/// The armour a design has, by location, as the condition keys it. +fn strippable(armor: &std::collections::BTreeMap) -> Vec<(String, i64)> { + armor + .iter() + .filter(|(_, points)| **points > 0) + .map(|(location, points)| (location.clone(), *points)) + .collect() +} + +/// The frame a design has, by location. +/// +/// Empty where the tonnage has no row in the table, which leaves the frame +/// lever unavailable rather than guessed at. +fn frame_of(unit: &Unit) -> Vec<(String, i64)> { + let Some(tons) = unit.mass else { + return Vec::new(); + }; + let shape = helm_core::Shape::from_config(unit.config.as_deref()); + helm_core::structure_per_location(tons, shape) + .unwrap_or_default() + .into_iter() + .map(|(location, points)| (location.to_string(), points)) + .collect() +} + +/// One condition: `armor_wear` thousandths of the plate gone and +/// `frame_wear` thousandths of the frame with it. +/// +/// Spread evenly rather than concentrated, because a machine that took a +/// scenario's worth of fire took it across the front rather than all in one +/// arm - and because an even spread is the profile that keeps the figure +/// falling smoothly as the fraction rises. +fn worn_by( + armor: &[(String, i64)], + armor_wear: i64, + frame: &[(String, i64)], + frame_wear: i64, +) -> Condition { + let left = |built: i64, wear: i64, floor: i64| { + let gone = (built * wear + 999) / 1000; + (built - gone).max(floor) + }; + Condition { + armor: armor + .iter() + .map(|(location, built)| (location.clone(), left(*built, armor_wear, 0))) + .collect(), + // A location with no frame left is off the machine and takes its + // weapons with it, which is a different question from damaging one. + structure: frame + .iter() + .map(|(location, built)| (location.clone(), left(*built, frame_wear, 1))) + .collect(), + ..Condition::undamaged() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plate() -> Vec<(String, i64)> { + vec![ + ("CT".to_string(), 47), + ("LA".to_string(), 34), + ("HD".to_string(), 9), + ] + } + + #[test] + fn no_wear_leaves_a_machine_whole() { + let condition = worn_by(&plate(), 0, &[], 0); + assert_eq!(condition.armor.get("CT"), Some(&47)); + assert_eq!(condition.armor.get("HD"), Some(&9)); + } + + /// Evenly, not off one location: a machine worn to a figure should look + /// like one that took fire across the front, not one with a bald leg. + #[test] + fn wear_is_spread_across_the_locations() { + let condition = worn_by(&plate(), 500, &[], 0); + assert_eq!(condition.armor.get("CT"), Some(&23)); + assert_eq!(condition.armor.get("LA"), Some(&17)); + assert_eq!(condition.armor.get("HD"), Some(&4)); + } + + /// Rounded so that any wear at all takes a point off the thinnest + /// location, rather than a head of nine surviving the first tenth intact + /// while everything around it is stripped. + #[test] + fn the_least_wear_still_costs_every_location_a_point() { + let condition = worn_by(&plate(), 1, &[], 0); + assert_eq!(condition.armor.get("CT"), Some(&46)); + assert_eq!(condition.armor.get("HD"), Some(&8)); + } + + #[test] + fn full_wear_strips_the_plate_and_leaves_the_frame_standing() { + let frame = vec![("CT".to_string(), 31), ("LA".to_string(), 17)]; + let condition = worn_by(&plate(), 1000, &frame, 1000); + assert_eq!(condition.armor.get("CT"), Some(&0)); + // One point left everywhere: at nought the location is off the + // machine, which takes its weapons with it. + assert_eq!(condition.structure.get("CT"), Some(&1)); + assert_eq!(condition.structure.get("LA"), Some(&1)); + } +} -- 2.51.2