From c4377f73a0c29adca125155bbe736248376051a6 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 19 Aug 2026 20:20:35 -0400 Subject: [PATCH] feat(unit-rules): damage that looks like it came from a battle Fire lands where MegaMek's own hit table sends it, clusters where the last hit landed, arrives in the sizes weapons throw, and takes equipment out on MegaMek's critical roll. Two seeds give two different machines worth the same, where the even shave gives one machine every time. Nothing it produces is a mission kill: no location destroyed, the head left alone, one engine hit of three and one gyro of two, magazines never hit, and the walk stops before the guns are gone. --- crates/helm-bv/src/battle.rs | 574 +++++++++++++++++++++++++++++++++++ crates/helm-bv/src/lib.rs | 2 + crates/helm-bv/src/wear.rs | 84 +++++ 3 files changed, 660 insertions(+) create mode 100644 crates/helm-bv/src/battle.rs diff --git a/crates/helm-bv/src/battle.rs b/crates/helm-bv/src/battle.rs new file mode 100644 index 0000000..f8ce9d0 --- /dev/null +++ b/crates/helm-bv/src/battle.rs @@ -0,0 +1,574 @@ +//! Damage that looks like it came from a battle. +//! +//! [`crate::wear_to`] takes a design down to a figure by shaving armour evenly +//! off every location. It hits the number and it produces the same machine +//! every time: a Highlander at 1500 always has exactly the plate that arithmetic +//! demands, spread flat, and a lance of them looks like a spreadsheet. +//! +//! This does the same job with damage that has a shape. Fire lands where the +//! hit table sends it, keeps landing near where it last landed, punches through +//! plate into the frame, and takes equipment out when it gets there. Two seeds +//! give two different machines worth the same. +//! +//! # What is modelled and what is not +//! +//! This is not a combat simulation and does not want to be. There is no +//! attacker, no range, no to-hit roll and no turn order. What is borrowed from +//! the rules is the *shape* of damage: +//! +//! * **Where it lands.** The 2d6 hit location table, taken from MegaMek's own +//! `rollHitLocation`, which is why the torsos take the most and the head +//! takes one roll in thirty-six. +//! * **How much arrives at once.** The damage a Mek's weapons actually throw - +//! a machine gun's 2, a medium laser's 5, a PPC's 10, an autocannon/20's 20 - +//! rather than a smooth distribution. Almost nothing throws more than 20. +//! * **What a penetrating hit costs.** MegaMek rolls 2d6 when the frame is +//! damaged: 7 or less is nothing, 8-9 is one component, 10-11 is two, 12 is +//! three. So one lost component is much likelier than three, and most +//! penetrations cost nothing at all. +//! +//! Fire also clusters, which the hit table alone does not do. A gunner who has +//! the range keeps shooting the same spot, so a hit lands where the last one +//! did about a third of the time. That is a stand-in for aim, not a rule. +//! +//! # Not a mission kill +//! +//! A machine worn down for a scenario has to be able to take the field. So no +//! location is ever destroyed - the frame is chewed to one point and no +//! further, which also means no limb is ever blown off - the head is left +//! alone below its plate, the engine takes at most one hit of the three that +//! destroy it and the gyro one of the two, and the walk stops before the +//! offensive rating falls below half of what the design started with. That +//! last one is the real test: a Mek with its guns shot away is a wreck to be +//! recovered, not a unit somebody is being asked to field. +//! +//! Magazines are never hit either. One taking a critical explodes, and without +//! CASE that is the end of the machine - and it is the only hit that can raise +//! a battle value rather than lower it, since an empty bin stops being worth +//! anything and stops going off. + +use std::collections::{BTreeMap, BTreeSet}; + +use helm_core::{Catalogue, Rng, Unit}; + +use crate::{Condition, Unsupported, Worn}; + +/// How a battle is simulated. The defaults are the ones described above. +#[derive(Debug, Clone, PartialEq)] +pub struct Battle { + /// Which battle. The same seed and design always give the same damage. + pub seed: u64, + /// Percent chance a hit lands where the last one did. + pub focus: u64, + /// Percent of hits that come from behind, which is where a torso's thin + /// rear plate matters. + pub from_behind: u64, + /// How much of its offensive rating a machine must keep. Below this it is + /// a recovery job rather than a unit somebody can field. + pub keep_offense: f64, +} + +impl Default for Battle { + fn default() -> Self { + Battle { + seed: 1, + focus: 35, + from_behind: 20, + keep_offense: 0.5, + } + } +} + +/// Damage sizes, as the weapons that throw them. +/// +/// Weighted towards the middle of the range: a Mek's common weapons are the +/// 5-point ones, the 10-point ones are the heavy hitters, and 20 in one blow +/// is an autocannon/20 at point-blank range. The weights are a plausible mix +/// rather than anything counted off a battle report. +const DAMAGE: &[(i64, u64)] = &[ + (1, 6), // a single missile + (2, 10), // machine gun, small pulse laser + (3, 8), // small laser + (5, 22), // medium laser, autocannon/5, an SRM + (7, 8), // large pulse laser + (8, 10), // large laser, autocannon/2 burst + (10, 14), // PPC, autocannon/10, an LRM cluster + (12, 6), // a heavy salvo + (15, 4), // Gauss rifle + (20, 2), // autocannon/20 +]; + +/// Where a hit lands, by roll of 2d6, as an index into the shape's own +/// locations. +/// +/// The index is MegaMek's location constant, which is what +/// `helm_unitfile::locations_for_config` is ordered by - so one table serves a +/// biped and, with different numbers, a quad, without either needing to know +/// what the other calls its limbs. +/// +/// Taken from `Mek.innerRollHitLocation` and `QuadMek`'s override of it, front +/// arc. A roll of 2 is a through-armour critical in the game; here it is +/// simply a centre torso hit, since the critical it causes is the thing this +/// models separately. +const BIPED_HITS: [usize; 11] = [1, 4, 4, 6, 2, 1, 3, 7, 5, 5, 0]; +const QUAD_HITS: [usize; 11] = [1, 7, 5, 5, 3, 1, 2, 4, 4, 6, 0]; + +/// A tripod's legs, which rolls of 5 and 9 choose between on a d6: one and two +/// the right leg, three and four the centre, five and six the left. +const TRIPOD_LEGS: [usize; 6] = [6, 6, 8, 8, 7, 7]; + +/// Wear a design down in a battle until it is worth `target`. +/// +/// Damage accumulates hit by hit and the closest condition seen is kept, which +/// is what makes this safe against the one lever that does not have a +/// direction: a magazine shot out stops being worth anything *and* stops +/// exploding, so the figure can rise. A search that bisected would be lost; +/// one that walks forward and remembers the best is not. +pub fn wear_in_battle( + unit: &Unit, + catalogue: &Catalogue, + target: i64, + battle: &Battle, +) -> Result { + let whole = crate::battle_value_in(unit, catalogue, &Condition::undamaged())?; + let fresh_offense = crate::terms_in(unit, catalogue, &Condition::undamaged())? + .offensive + .total; + let mut best = Worn { + condition: Condition::undamaged(), + value: whole, + whole, + missed_by: (whole - target).abs(), + seed: Some(battle.seed), + }; + if target >= whole { + return Ok(best); + } + + let mut field = Field::new(unit); + let mut rng = Rng::new(battle.seed); + let mut at = None; + + // Enough hits to strip any design in the library several times over, and a + // bound so that a machine which cannot be hurt any further - every guard + // binding at once - stops rather than spins. + for _ in 0..600 { + let location = match at { + Some(previous) if rng.below(100) < Some(battle.focus) => previous, + _ => field.roll_location(&mut rng), + }; + at = Some(location); + let rear = rng.below(100) < Some(battle.from_behind); + let damage = weighted(&mut rng, DAMAGE); + + let Some(condition) = field.take(location, rear, damage, &mut rng) else { + continue; + }; + let terms = crate::terms_in(unit, catalogue, &condition)?; + // A machine that can no longer shoot is not one to field. The hit that + // took it there is undone, so the answer is always a machine that + // could still fight. + if terms.offensive.total < fresh_offense * battle.keep_offense { + field.undo(); + continue; + } + field.keep(); + + let value = terms.battle_value; + let missed = (value - target).abs(); + if missed < best.missed_by { + best = Worn { + condition, + value, + whole, + missed_by: missed, + seed: Some(battle.seed), + }; + } + if value <= target { + break; + } + } + + // The damage lands where it lands and the figure steps in whatever sizes + // the hits happened to be, so the last few points are shaved off the plate + // the way the even search does it. + crate::wear::polish(unit, catalogue, target, &mut best)?; + Ok(best) +} + +/// Try several battles and keep the machine that came closest. +/// +/// Damage is drawn rather than solved, so one battle can overshoot by more +/// than the next: the same design at the same figure is a different machine +/// for each seed, and taking the best of a handful costs a few hundred +/// scorings and removes the worst of the luck. +pub fn wear_in_battles( + unit: &Unit, + catalogue: &Catalogue, + target: i64, + battle: &Battle, + tries: u64, +) -> Result { + let mut best: Option = None; + for step in 0..tries.max(1) { + let worn = wear_in_battle( + unit, + catalogue, + target, + &Battle { + seed: battle.seed.wrapping_add(step), + ..battle.clone() + }, + )?; + let better = best.as_ref().is_none_or(|b| worn.missed_by < b.missed_by); + if better { + best = Some(worn); + } + if best.as_ref().is_some_and(Worn::is_exact) { + break; + } + } + best.ok_or(Unsupported::NotImplemented("a battle to fight")) +} + +/// One item from a weighted table. +fn weighted(rng: &mut Rng, table: &[(i64, u64)]) -> i64 { + let total: u64 = table.iter().map(|(_, weight)| weight).sum(); + let mut draw = rng.below(total).unwrap_or(0); + for (value, weight) in table { + if draw < *weight { + return *value; + } + draw -= weight; + } + table[0].0 +} + +/// 2d6, which is not the same shape as one roll of twelve - the whole reason +/// the hit table concentrates fire on the torsos. +fn two_dice(rng: &mut Rng) -> u64 { + rng.below(6).unwrap_or(0) + rng.below(6).unwrap_or(0) + 2 +} + +/// The machine being shot at: what is left of it, and what the last hit did. +struct Field { + /// `(code, name)` per location, in MegaMek's index order. + shape: &'static [(&'static str, &'static str)], + tripod: bool, + armor: BTreeMap, + structure: BTreeMap, + destroyed: BTreeMap>, + /// What is in each location's slots, so a critical hit takes out something + /// the design actually has. + slots: BTreeMap>, + /// Engine and gyro hits so far, which are what kill a Mek outright. + engine_hits: usize, + gyro_hits: usize, + /// The state before the hit being considered, for undoing it. + undo: Option>, +} + +impl Field { + fn new(unit: &Unit) -> Field { + let shape = helm_unitfile::locations_for_config(unit.config.as_deref()); + let structure = unit + .mass + .and_then(|tons| { + helm_core::structure_per_location( + tons, + helm_core::Shape::from_config(unit.config.as_deref()), + ) + }) + .unwrap_or_default() + .into_iter() + .map(|(code, points)| (code.to_string(), points)) + .collect(); + Field { + shape, + tripod: shape.len() > 8, + armor: unit.armor_locations.clone(), + structure, + destroyed: BTreeMap::new(), + slots: unit.criticals.clone(), + engine_hits: 0, + gyro_hits: 0, + undo: None, + } + } + + /// Which location the next hit lands in. + fn roll_location(&self, rng: &mut Rng) -> usize { + let roll = two_dice(rng) as usize; + let quad = self.shape.len() == 8 && self.shape.iter().any(|(code, _)| *code == "FLL"); + let table = if quad { &QUAD_HITS } else { &BIPED_HITS }; + let index = table[(roll - 2).min(10)]; + // A tripod shares the biped table except that a leg hit picks between + // three legs rather than being told which. + if self.tripod && (roll == 5 || roll == 9) { + return TRIPOD_LEGS[rng.below(6).unwrap_or(0) as usize]; + } + index + } + + /// Put `damage` into one location, and give back the condition it leaves. + /// + /// `None` when the hit cannot be taken at all - the head, or a location + /// already chewed to its last point of frame - which is a hit that missed + /// rather than one that did nothing. + fn take(&mut self, index: usize, rear: bool, damage: i64, rng: &mut Rng) -> Option { + let (code, name) = *self.shape.get(index)?; + // The head keeps its own counsel: a cockpit hit is a dead pilot and a + // head at one point of frame is a machine nobody will crew. + if code == "HD" { + return None; + } + self.undo = Some(Box::new(self.snapshot())); + + let key = match (rear, code) { + (true, "CT") => "RTC".to_string(), + (true, "LT") => "RTL".to_string(), + (true, "RT") => "RTR".to_string(), + _ => code.to_string(), + }; + let mut left = damage; + if let Some(plate) = self.armor.get_mut(&key) { + let absorbed = (*plate).min(left); + *plate -= absorbed; + left -= absorbed; + } + if left <= 0 { + return Some(self.condition()); + } + + // Through the plate and into the frame, which is where equipment + // starts to go. One point is always left: at nought the location is + // off the machine and takes everything in it. + let frame = self.structure.get_mut(code)?; + if *frame <= 1 { + self.undo(); + return None; + } + *frame = (*frame - left).max(1); + for _ in 0..criticals(rng) { + self.wreck_a_slot(name, rng); + } + Some(self.condition()) + } + + /// Take one component out of a location, if there is one that may go. + fn wreck_a_slot(&mut self, name: &str, rng: &mut Rng) { + let Some(slots) = self.slots.get(name) else { + return; + }; + let gone = self.destroyed.entry(name.to_string()).or_default(); + let mut candidates: Vec = Vec::new(); + for (at, what) in slots.iter().enumerate() { + if gone.contains(&at) || is_empty_slot(what) { + continue; + } + // The three that end a machine rather than damage it. One engine + // hit is five points of heat a turn and a Mek that still fights; + // two is one short of dead and nobody would take it out. A second + // gyro hit is a Mek that cannot stand up. + if is_engine(what) && self.engine_hits >= 1 { + continue; + } + if is_gyro(what) && self.gyro_hits >= 1 { + continue; + } + if is_life_support(what) { + continue; + } + // A magazine taking a critical hit explodes, and a Mek without + // CASE does not survive that. Leaving them alone keeps every + // machine this produces one somebody could field - and it keeps + // the walk honest, since an empty bin stops being worth anything + // and stops exploding, so it is the one hit that can raise a + // battle value rather than lower it. + if is_ammo(what) { + continue; + } + candidates.push(at); + } + let Some(&at) = rng.choose(&candidates) else { + return; + }; + let what = &slots[at]; + if is_engine(what) { + self.engine_hits += 1; + } + if is_gyro(what) { + self.gyro_hits += 1; + } + self.destroyed + .entry(name.to_string()) + .or_default() + .insert(at); + } + + fn condition(&self) -> Condition { + Condition { + armor: self.armor.clone(), + structure: self.structure.clone(), + destroyed: self.destroyed.clone(), + ..Condition::undamaged() + } + } + + fn snapshot(&self) -> Field { + Field { + shape: self.shape, + tripod: self.tripod, + armor: self.armor.clone(), + structure: self.structure.clone(), + destroyed: self.destroyed.clone(), + slots: self.slots.clone(), + engine_hits: self.engine_hits, + gyro_hits: self.gyro_hits, + undo: None, + } + } + + /// Put the machine back as it was before the last hit. + fn undo(&mut self) { + if let Some(before) = self.undo.take() { + let before = *before; + self.armor = before.armor; + self.structure = before.structure; + self.destroyed = before.destroyed; + self.engine_hits = before.engine_hits; + self.gyro_hits = before.gyro_hits; + } + } + + /// Accept the last hit. + fn keep(&mut self) { + self.undo = None; + } +} + +/// How many components a penetrating hit takes out. +/// +/// MegaMek's own roll: 7 or less is nothing at all, 8-9 is one, 10-11 is two, +/// and 12 is three. So most penetrations cost nothing, and losing three things +/// at once happens on one roll in thirty-six. +fn criticals(rng: &mut Rng) -> usize { + match two_dice(rng) { + 0..=7 => 0, + 8..=9 => 1, + 10..=11 => 2, + _ => 3, + } +} + +fn is_empty_slot(what: &str) -> bool { + let what = what.trim(); + what.is_empty() || what == "-Empty-" +} + +fn is_engine(what: &str) -> bool { + what.contains("Engine") +} + +fn is_gyro(what: &str) -> bool { + what.contains("Gyro") +} + +/// A magazine, which does not take a critical hit and leave a live Mek. +fn is_ammo(what: &str) -> bool { + what.contains("Ammo") +} + +/// The head's own systems, which is a dead pilot rather than a damaged Mek. +fn is_life_support(what: &str) -> bool { + what.contains("Life Support") || what.contains("Sensors") || what.contains("Cockpit") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The table as MegaMek writes it, roll by roll: a 7 is the centre torso, + /// a 12 is the head, and the right arm takes three rolls of the eleven. + /// Transcribing this off by one would send every head hit to a leg. + #[test] + fn the_biped_table_is_megameks() { + let at = |roll: usize| BIPED_HITS[roll - 2]; + assert_eq!(at(7), 1, "centre torso"); + assert_eq!(at(12), 0, "head"); + assert_eq!(at(6), 2, "right torso"); + assert_eq!(at(8), 3, "left torso"); + assert_eq!([at(2), at(3), at(4)], [1, 4, 4], "a 2 is a torso hit"); + assert_eq!([at(10), at(11)], [5, 5], "left arm"); + assert_eq!([at(5), at(9)], [6, 7], "the legs"); + } + + /// A quad has no arms: the same four rolls land on its front legs, which + /// sit at the indices a biped keeps its arms at. + #[test] + fn the_quad_table_puts_the_legs_where_the_arms_were() { + let at = |roll: usize| QUAD_HITS[roll - 2]; + assert_eq!(at(7), 1, "centre torso"); + assert_eq!(at(12), 0, "head"); + assert_eq!([at(4), at(5)], [5, 5], "front left leg"); + assert_eq!([at(9), at(10)], [4, 4], "front right leg"); + assert_eq!([at(3), at(11)], [7, 6], "the rear legs"); + } + + /// Two dice, not one of twelve. The difference is the whole reason fire + /// concentrates on the torsos: a 7 comes up six times as often as a 12, + /// so the head takes one hit in thirty-six and the centre torso six. + #[test] + fn the_dice_are_two_of_six() { + let mut rng = Rng::new(9); + let mut counts = [0usize; 13]; + for _ in 0..36_000 { + counts[two_dice(&mut rng) as usize] += 1; + } + assert!((900..=1100).contains(&counts[2]), "{}", counts[2]); + assert!((5700..=6300).contains(&counts[7]), "{}", counts[7]); + assert!((900..=1100).contains(&counts[12]), "{}", counts[12]); + assert_eq!(counts[0] + counts[1], 0, "nothing below two"); + } + + /// MegaMek's own critical roll: most penetrating hits cost nothing, one + /// component is the common answer, and three is one roll in thirty-six. + #[test] + fn most_penetrations_cost_nothing_and_three_is_rare() { + let mut rng = Rng::new(4); + let mut counts = [0usize; 4]; + for _ in 0..36_000 { + counts[criticals(&mut rng)] += 1; + } + // 21/36 nothing, 9/36 one, 5/36 two, 1/36 three. + assert!((20_000..=22_000).contains(&counts[0]), "{counts:?}"); + assert!((8_500..=9_500).contains(&counts[1]), "{counts:?}"); + assert!((4_500..=5_500).contains(&counts[2]), "{counts:?}"); + assert!((800..=1_200).contains(&counts[3]), "{counts:?}"); + } + + /// The damage table has to be weighted, and weighted the way it reads: + /// a medium laser's five is the common hit and an autocannon/20's twenty + /// is rare. A bug that ignored the weights would show up as a flat spread. + #[test] + fn damage_arrives_in_the_sizes_weapons_throw() { + let mut rng = Rng::new(11); + let mut fives = 0; + let mut twenties = 0; + let mut over_twenty = 0; + for _ in 0..10_000 { + match weighted(&mut rng, DAMAGE) { + 5 => fives += 1, + 20 => twenties += 1, + n if n > 20 => over_twenty += 1, + _ => {} + } + } + assert_eq!(over_twenty, 0, "nothing throws more than twenty"); + assert!( + fives > twenties * 5, + "{fives} fives against {twenties} twenties" + ); + } +} diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 2537f6e..8431b39 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -33,6 +33,7 @@ //! because a force list built on it looks fine. mod attribution; +mod battle; mod check; mod clusters; mod conformance; @@ -46,6 +47,7 @@ mod rules; mod wear; pub use attribution::{Attribution, Change, Term, Terms, attribute, between, terms_in}; +pub use battle::{Battle, wear_in_battle, wear_in_battles}; pub use check::{Severity, Trouble, check}; pub use clusters::{Cluster, clusters, labels, oddity}; pub use conformance::{Conformance, Mismatch, Tally}; diff --git a/crates/helm-bv/src/wear.rs b/crates/helm-bv/src/wear.rs index 7d4f9ad..bbffa2e 100644 --- a/crates/helm-bv/src/wear.rs +++ b/crates/helm-bv/src/wear.rs @@ -50,6 +50,9 @@ pub struct Worn { pub whole: i64, /// How far the search got from the figure asked for. Zero is exact. pub missed_by: i64, + /// Which battle this machine fought, where it fought one. `None` for the + /// even search, which has no luck in it and needs no seed to reproduce. + pub seed: Option, } impl Worn { @@ -72,6 +75,7 @@ pub fn wear_to(unit: &Unit, catalogue: &Catalogue, target: i64) -> Result= whole { return Ok(best); @@ -92,6 +96,7 @@ pub fn wear_to(unit: &Unit, catalogue: &Catalogue, target: i64) -> Result Result Result<(), Unsupported> { + let from = best.condition.clone(); + let mut consider = |condition: Condition, best: &mut Worn| -> Result { + let value = crate::battle_value_in(unit, catalogue, &condition)?; + let missed = (value - target).abs(); + if missed < best.missed_by { + *best = Worn { + condition, + value, + missed_by: missed, + ..best.clone() + }; + } + Ok(value) + }; + if best.value > target { + return refine(from, target, best, &mut consider); + } + // Already under the figure, which a battle can do in one hit: damage + // arrives in the size the weapon throws rather than the size that was + // wanted. So the machine is patched back up rather than shot further, a + // point at a time. + patch(unit, from, target, best, &mut consider) +} + +/// Put armour back a point at a time, until the figure comes up to the target. +/// +/// Onto the least damaged location first, which buffs out the scratches and +/// leaves the holes: a machine that took a leg full of autocannon should still +/// look like it afterwards. +fn patch( + unit: &Unit, + 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(()); + } + let Some((location, points)) = condition + .armor + .iter() + .filter(|(location, points)| { + unit.armor_locations + .get(*location) + .is_some_and(|built| points < &built) + }) + .min_by(|a, b| { + let missing = |(location, points): (&String, &i64)| { + unit.armor_locations.get(location).copied().unwrap_or(0) - points + }; + missing(*a).cmp(&missing(*b)).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(()) +} + /// 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 -- 2.51.2