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