diff --git a/CLAUDE.md b/CLAUDE.md --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,25 @@ `helm force` under-scores an Atlas by 122 points. If a figure looks wrong, check which dump produced it before reading the arithmetic. +## The firepower model + +`helm-core::expected` is the one opinionated figure in this repository: what a +design can expect to *land*, not what it can throw. It weights MegaMek's own +per-weapon averages by how often each weapon hits a gunnery 4 crew's target on +open ground with nothing moving. + +- The rules in it are tables - `WEAPON_MODIFIERS`, `NOT_FIREPOWER`, `loadings` + - so somebody with the rulebook can read the set and check it. +- Three parts are helm's reading rather than MegaMek's data, and are listed in + TODO.md so they can be argued with: the ranges an MML or an ATM fires at + under each loading, an infantry weapon of range nought reaching one hex, and + which weapons are left out entirely. +- Physical weapons are not in it, deliberately: their damage scales with the + design's tonnage, and counting them gives every design carrying one a spike + at one hex that drowns the shape of its curve. +- Rear-mounted weapons are not in it either. They cannot fire at what the + front guns are firing at. + ## Feature branch workflow If asked to do work, unless directly instructed otherwise, do not make changes to the user's top-level git checkout. All of your work will be on feature branches and inside git worktrees. diff --git a/TODO.md b/TODO.md --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,7 @@ ## What to do next, in order -Twenty-four things, ranked. The tiers are the argument: the first finishes what +Twenty-six things, ranked. The tiers are the argument: the first finishes what `plan/unit-rules.md` already promises, the second widens what helm can score at all, and the third is what makes any of it reach a player. Everything below appears again in its own section with the detail; this is the order to take @@ -91,42 +91,55 @@ until that service reads both. Both are helm's to publish and headquarters' to read: the change is `services/api/src/units.rs`, and this entry is here so it is not discovered by a deploy. -16. **Weapons the firepower model cannot see.** 2,350 designs expect to land - nothing, and 2,054 of them are infantry or battle armour, whose damage - MegaMek models per trooper rather than as an average per weapon. The rest - are physical weapons (a hatchet's damage is the Mek's tonnage, not the - weapon's), MML launchers whose damage depends on which ammunition is - loaded, and Arrow IV artillery. A design helm cannot model should say so - rather than reading as unarmed. -17. **The MCP server resolves equipment names too.** `weapon: ["particle"]` +16. **A physical-weapon facet of its own.** A hatchet, a sword, a claw and a + flail are left out of firepower deliberately: their damage scales with the + Mek's tonnage rather than with the weapon, and counting them would give + every design that carries one a spike at one hex that drowns the shape of + its curve. They are worth asking about - "what brawls" is a real question + - and they want a column of their own rather than a place in this one. +17. **Check the loadings and the caveats.** Three things in the firepower + model are helm's reading rather than MegaMek's data, and want a look from + somebody with the rulebook: the ranges an MML and an ATM fire at under + each loading, which the dump states as 0/0/0 and `helm-core::expected` + fills in; treating an infantry weapon of range nought as reaching one hex; + and the list of weapons left out entirely - artillery, TAG, anti-missile + systems, tasers and TSEMPs, and anti-Mek leg attacks. +18. **The 402 designs that still expect nothing.** Down from 2,350 once + infantry, battle armour, the launchers that choose their ammunition and + the weapons MegaMek states a plain damage for were modelled. What is left + is honest: 63 Meks with no weapon at all, 137 battle armour designs + carrying only anti-Mek attacks, and support vehicles that carry cargo. The + handful that carry only a TAG or a TSEMP read as nought because those are + left out on purpose. +19. **The MCP server resolves equipment names too.** `weapon: ["particle"]` works in a browser, which loads `equipment.json`; over MCP it is refused, because the database stores no aliases and `helm-mcp` builds its facets from SQL. An `equipment_names` table would close it. Until then an agent uses `resolve_equipment` and `carries`, which is what the tool already documents. -18. **An identity for the catalogue.** `units.json` and `loadout.json` carry a +20. **An identity for the catalogue.** `units.json` and `loadout.json` carry a `build` block and refuse to be joined across runs; `catalogue.jsonl` is JSON Lines with nowhere to put one, and is keyed only by the prefix it is published under. Two dumps of one MegaMek release are not interchangeable - see the note in CLAUDE.md - so the prefix is not enough. -19. **The redistribution question.** MegaMek's data is CC BY-NC-SA and serving +21. **The redistribution question.** MegaMek's data is CC BY-NC-SA and serving an index of it to browsers is redistribution. It needs a decision and an attribution mechanism *before* 13 ships. -20. **Provenance on every record.** Which MegaMek, which producer, which +22. **Provenance on every record.** Which MegaMek, which producer, which `RULES_VERSION`. The constants exist and nothing writes them into the database or into an ATProto record, so a stored figure cannot be told from one computed against a different MegaMek. -21. **The attribution and the repair list on a match page.** +23. **The attribution and the repair list on a match page.** `plan/after-action.md`. The pieces are built and cross the wasm boundary; what is missing is the screen that reads them. -22. **Damage that persists between matches.** `plan/campaign.md`. The +24. **Damage that persists between matches.** `plan/campaign.md`. The mechanical half exists; storing a force's condition as a record and reading it back is the rest, and the construct-write-read round trip supports it. -23. **Formation validation.** `list_formations` and `validate_formation` +25. **Formation validation.** `list_formations` and `validate_formation` against the Campaign Operations blueprints - "is this a legal Fire Lance". Validation before generation, and it needs no faction data: it is role and count, both of which helm holds. -24. **Ingest `data/forcegenerator/`.** 40 era files of per-faction availability +26. **Ingest `data/forcegenerator/`.** 40 era files of per-faction availability weights, `factions.xml`, and 61 faction rulesets that are a small DSL of their own. This is the foundation for force generation, faction filtering and era-correct scenarios, and nothing above it should be attempted until diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs --- a/crates/helm-bridge/src/lib.rs +++ b/crates/helm-bridge/src/lib.rs @@ -200,6 +200,10 @@ /// when it has not been. pub const CATALOGUE_FIELDS: &[&str] = &[ "_class", + // A platoon's damage is per trooper and its reach is stated on its own, + // so a rifle needs both to be scored at all. + "infantryDamage", + "infantryRange", "_classes", "_flags", "_internalName", @@ -455,6 +459,11 @@ internal_name, name, kind: s(v, "_class"), + infantry_damage: f(v, "infantryDamage").filter(|d| *d > 0.0), + // Kept at nought rather than dropped: a submachine gun reaches no + // further than the hex it is fired in, and that is a fact about it + // rather than a missing field. + infantry_range: i(v, "infantryRange").filter(|r| *r >= 0), // MegaMek's own list of every spelling it answers to, which is why // nothing here invents a synonym table. aliases: v["namesVector"] diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs --- a/crates/helm-core/src/catalogue.rs +++ b/crates/helm-core/src/catalogue.rs @@ -67,6 +67,16 @@ /// share an internal name, nearly all of them Inner Sphere and Clan /// versions of one weapon registered under a single key. pub kind: Option, + /// What one trooper's worth of this weapon does, for an infantry weapon. + /// + /// A rifle is not a gun bolted to a machine: a platoon's damage is what + /// one trooper's weapon does times how many troopers carry it, and + /// MegaMek keeps the per-trooper figure rather than a total. A Laser + /// Rifle is 0.28. + pub infantry_damage: Option, + /// How far that weapon reaches, in hexes, where MegaMek states it + /// separately from the range brackets every other weapon carries. + pub infantry_range: Option, /// Every name MegaMek answers to for this item, its own `namesVector`. /// /// A PPC is `PPC`, `Particle Cannon`, `IS PPC` and `ISPPC`. Nobody has to diff --git a/crates/helm-core/src/expected.rs b/crates/helm-core/src/expected.rs --- a/crates/helm-core/src/expected.rs +++ b/crates/helm-core/src/expected.rs @@ -28,7 +28,7 @@ //! It is a best case, deliberately. A real to-hit number depends on two //! machines, a map and a turn, none of which a library has. -use crate::{Catalogue, EquipmentEntry, Unit, mounted_items}; +use crate::{Catalogue, EquipmentEntry, Unit}; /// The crew this assumes: a regular MechWarrior, the same figure battle value /// is computed for. @@ -75,6 +75,90 @@ /// What a targeting computer does for the weapons it can help. const TARGETING_COMPUTER: i64 = -1; +/// Weapons this leaves out, whatever damage MegaMek records for them. +/// +/// Each one answers a question this figure is not asking. Artillery is fired +/// at a map square from off the board; a TAG marks a target and hurts nobody; +/// an anti-missile system shoots down missiles; a taser and a TSEMP shut a +/// machine down rather than damaging it; a leg attack is a platoon climbing a +/// Mek rather than shooting at one. +const NOT_FIREPOWER: &[&str] = &[ + "F_ARTILLERY", + "F_TAG", + "F_AMS", + "F_AMS_BAY", + "F_TSEMP", + "F_TASER", + "F_INFANTRY_ATTACK", +]; + +/// One way a launcher can be loaded, and what it does loaded that way. +/// +/// An MML fires long-range missiles or short ones and an ATM fires three +/// kinds of round, and the ammunition decides the reach as well as the +/// damage - which is why MegaMek writes 0/0/0 for an MML's ranges and leaves +/// the rest to the bin. `scale` is what a round does against the standard +/// one: an ATM's high-explosive round is three points a missile where the +/// standard is two. +struct Loading { + short: i64, + medium: i64, + long: i64, + minimum: i64, + scale: f64, +} + +/// The loadings for a launcher that has them, by the ammunition it takes. +/// +/// Ranges from the rulebook rather than from the dump, which states none for +/// these. The assumption is written down in TODO.md so somebody can check it. +fn loadings(ammo_type: Option<&str>) -> &'static [Loading] { + match ammo_type { + // Long-range missiles or short, out of the same tube. + Some("MML") => &[ + Loading { + short: 7, + medium: 14, + long: 21, + minimum: 6, + scale: 1.0, + }, + Loading { + short: 3, + medium: 6, + long: 9, + minimum: 0, + scale: 2.0, + }, + ], + // Standard, high-explosive and extended-range rounds. + Some("ATM") => &[ + Loading { + short: 5, + medium: 10, + long: 15, + minimum: 4, + scale: 1.0, + }, + Loading { + short: 3, + medium: 6, + long: 9, + minimum: 0, + scale: 1.5, + }, + Loading { + short: 9, + medium: 18, + long: 27, + minimum: 4, + scale: 0.5, + }, + ], + _ => &[], + } +} + /// The chance a 2d6 roll makes a number, out of 36. /// /// Written out rather than computed: it is eleven numbers and a table can be @@ -97,26 +181,102 @@ f64::from(ways) / 36.0 } -/// What the attacker needs to roll for this weapon at this range, or `None` -/// where the weapon cannot reach at all. -fn to_hit(weapon: &EquipmentEntry, range: i64, targeting_computer: bool) -> Option { - let long = weapon.long_range.unwrap_or(0); - if range > long || long <= 0 { +/// The reach and the damage a weapon has, once its loading is decided. +struct Profile { + short: i64, + medium: i64, + long: i64, + minimum: i64, + damage: f64, +} + +/// Every way this weapon can be fired, at whatever it is carrying. +/// +/// One profile for an ordinary gun. Two or three for a launcher whose +/// ammunition decides its reach, and one per trooper's rifle scaled to the +/// platoon holding it. +fn profiles(weapon: &EquipmentEntry, troopers: i64) -> Vec { + // A rifle is not a gun bolted to a machine. MegaMek keeps what one + // trooper's weapon does and how far it reaches, and the brackets are that + // reach doubled and trebled. + if let (Some(each), Some(reach)) = (weapon.infantry_damage, weapon.infantry_range) { + // A submachine gun's reach is nought, meaning the hex it is fired in. + // A curve starts at one hex, so that is where it is counted. + let reach = reach.max(1); + return vec![Profile { + short: reach, + medium: reach * 2, + long: reach * 3, + minimum: 0, + damage: each * troopers.max(1) as f64, + }]; + } + + // MegaMek's own average, cluster tables already applied: an LRM 20 is 12 + // here rather than a full salvo of 20. + // + // Sixty-one weapons carry a plain damage figure and no average - machine + // guns, most battle armour weapons, the light autocannons - because + // `shortAV` is an aerospace field MegaMek does not fill for everything. + // A single-shot weapon's average is its damage, so that is what is used, + // and only where the figure is a number: MegaMek writes -2 for a cluster + // weapon, -3 for a variable one, -4 for special and -5 for artillery. + let damage = weapon + .average_damage + .filter(|d| *d > 0.0) + .or_else(|| weapon.damage.map(|d| d as f64).filter(|d| *d > 0.0)) + .unwrap_or(0.0); + if damage <= 0.0 { + return Vec::new(); + } + + let choices = loadings(weapon.ammo_type.as_deref()); + if !choices.is_empty() { + return choices + .iter() + .map(|l| Profile { + short: l.short, + medium: l.medium, + long: l.long, + minimum: l.minimum, + damage: damage * l.scale, + }) + .collect(); + } + + vec![Profile { + short: weapon.short_range.unwrap_or(0), + medium: weapon.medium_range.unwrap_or(0), + long: weapon.long_range.unwrap_or(0), + // MegaMek writes Integer.MIN_VALUE for a weapon with no minimum + // range, and helm-bridge keeps the field as it found it. + minimum: weapon.min_range.filter(|m| *m > 0).unwrap_or(0), + damage, + }] +} + +/// What the attacker needs to roll for this profile at this range, or `None` +/// where it cannot be fired at all. +fn to_hit( + weapon: &EquipmentEntry, + profile: &Profile, + range: i64, + targeting_computer: bool, +) -> Option { + if profile.long <= 0 || range > profile.long { return None; } - let bracket = match range { - r if r <= weapon.short_range.unwrap_or(0) => SHORT, - r if r <= weapon.medium_range.unwrap_or(0) => MEDIUM, - _ => LONG, - }; - - // MegaMek writes Integer.MIN_VALUE for a weapon with no minimum range, - // and helm-bridge keeps the field as it found it. - let minimum = weapon.min_range.filter(|m| *m > 0).unwrap_or(0); - let too_close = if range <= minimum { - minimum - range + 1 + // Never into the minimum. A loading that cannot be fired here is not + // chosen; a weapon with only one is fired anyway, at a penalty. + let too_close = if range <= profile.minimum { + profile.minimum - range + 1 } else { 0 + }; + let bracket = match range { + r if r <= profile.short => SHORT, + r if r <= profile.medium => MEDIUM, + _ => LONG, }; let carried: i64 = WEAPON_MODIFIERS @@ -136,15 +296,22 @@ Some(GUNNERY + bracket + too_close + carried + computer) } -/// What one weapon can expect to land at this range. -fn expected(weapon: &EquipmentEntry, range: i64, targeting_computer: bool) -> f64 { - // MegaMek's own average for the weapon, cluster tables already applied: - // an LRM 20 is 12 here rather than 20. - let damage = weapon.average_damage.unwrap_or(0.0); - if damage <= 0.0 { +/// What one weapon can expect to land at this range, fired the best way it +/// can be. +fn expected(weapon: &EquipmentEntry, range: i64, troopers: i64, computer: bool) -> f64 { + if NOT_FIREPOWER.iter().any(|flag| weapon.has_flag(flag)) { return 0.0; } - to_hit(weapon, range, targeting_computer).map_or(0.0, |needed| damage * chance(needed)) + let all = profiles(weapon, troopers); + // A launcher picks its loading a shot at a time, so the one that lands + // most is the one it fires - and a loading it would have to fire into its + // own minimum range simply is not chosen. + let legal: Vec = all + .iter() + .filter(|p| all.len() == 1 || range > p.minimum) + .filter_map(|p| to_hit(weapon, p, range, computer).map(|needed| p.damage * chance(needed))) + .collect(); + legal.into_iter().fold(0.0, f64::max) } /// What a design can expect to land at each hex from 1 to [`crate::REACH`], in @@ -154,20 +321,76 @@ /// third the size of a chunk of decimals - and a tenth of a point of damage is /// already finer than the question deserves. pub fn expected_damage_curve(unit: &Unit, catalogue: &Catalogue) -> Vec { - let items = mounted_items(unit, catalogue); - let computer = items - .iter() - .any(|(entry, _)| entry.has_flag("F_TARGETING_COMPUTER")); - (1..=crate::REACH) - .map(|range| { - let total: f64 = items - .iter() - .filter(|(entry, _)| entry.is_weapon() && !entry.is_ammo()) - .map(|(entry, count)| expected(entry, range, computer) * *count as f64) - .sum(); - (total * 10.0).round() as i64 - }) - .collect() + firepower_of(unit, catalogue).curve +} + +/// What a design shoots forward with, and who is holding it. +struct Firing<'a> { + weapons: Vec<(&'a EquipmentEntry, i64)>, + troopers: i64, + computer: bool, +} + +impl<'a> Firing<'a> { + /// Read the design's guns once, so a curve does not read them two dozen + /// times. + fn read(unit: &Unit, catalogue: &'a Catalogue) -> Firing<'a> { + // The list MegaMek would evaluate, not the mounts alone: a platoon + // declares its rifles as `` and `` tags rather + // than as equipment, and nothing else finds them. + let items = crate::equipment_list(unit, catalogue); + let computer = items + .iter() + .any(|(entry, _)| entry.has_flag("F_TARGETING_COMPUTER")); + + // Weapons pointed backwards are left out entirely. A Warhammer's rear + // laser cannot fire at what its front guns are firing at, and a + // figure that adds it in credits the design with a shot it will never + // take at the target it is shooting. + let mut behind: std::collections::BTreeMap<&str, i64> = std::collections::BTreeMap::new(); + for mount in unit.equipment.iter().filter(|m| m.rear) { + if let Some(entry) = catalogue.resolve_for(&mount.name, unit.is_clan()) { + *behind.entry(entry.internal_name.as_str()).or_default() += 1; + } + } + + let weapons = items + .into_iter() + .filter(|(entry, _)| { + (entry.is_weapon() || entry.infantry_damage.is_some()) && !entry.is_ammo() + }) + .filter_map(|(entry, count)| { + let rear = behind + .get(entry.internal_name.as_str()) + .copied() + .unwrap_or(0); + let forward = count - rear; + (forward > 0).then_some((entry, forward)) + }) + .collect(); + + Firing { + weapons, + troopers: unit.troopers().unwrap_or(1), + computer, + } + } + + /// What the design expects to land at this range, everything firing. + fn at(&self, range: i64) -> f64 { + self.picked(range, &|_| true) + } + + /// The same, counting only the weapons `which` picks out. + fn picked(&self, range: i64, which: &dyn Fn(&EquipmentEntry) -> bool) -> f64 { + self.weapons + .iter() + .filter(|(entry, _)| which(entry)) + .map(|(entry, count)| { + expected(entry, range, self.troopers, self.computer) * *count as f64 + }) + .sum() + } } /// How much of what a design can expect to land it can only land once, as a @@ -182,17 +405,71 @@ /// Measured at the range the design is best at, so a launcher that cannot /// reach is not counted against it. `None` for a design that can expect to /// land nothing anywhere. +/// Everything the firepower model says about one design. +/// +/// Worked out together because reading a design's guns is most of what any of +/// it costs, and asking four times what can be asked once doubled the time an +/// index takes to build. +pub struct Firepower { + /// What it expects to land at each hex from 1 to [`crate::REACH`], in + /// tenths of a point. + pub curve: Vec, + /// The most it expects to land at any one range. + pub peak: f64, + /// How much of that comes out of a magazine, as a percentage. + pub ammo: Option, + /// How much of it can only be landed once, as a percentage. + pub one_shot: Option, +} + +/// Read a design's guns once and answer everything the model knows. +pub fn firepower_of(unit: &Unit, catalogue: &Catalogue) -> Firepower { + let guns = Firing::read(unit, catalogue); + let curve: Vec = (1..=crate::REACH) + .map(|range| (guns.at(range) * 10.0).round() as i64) + .collect(); + let peak = curve.iter().copied().max().unwrap_or(0) as f64 / 10.0; + + let best = (1..=crate::REACH).max_by(|a, b| guns.at(*a).total_cmp(&guns.at(*b))); + let share = |which: &dyn Fn(&EquipmentEntry) -> bool| -> Option { + let best = best?; + let total = guns.at(best); + if total <= 0.0 { + return None; + } + Some((guns.picked(best, which) / total * 100.0).round() as i64) + }; + + Firepower { + curve, + peak, + ammo: share(&is_ammo_fed), + one_shot: share(&is_one_shot), + } +} + +/// A weapon that has to be fed. An infantry weapon is not: a platoon's rifles +/// are carried by the troopers rather than drawn from bins, and they do not +/// run dry in a match. +fn is_ammo_fed(entry: &EquipmentEntry) -> bool { + entry + .ammo_type + .as_deref() + .is_some_and(|kind| kind != "INFANTRY") +} + +/// A weapon that is fired and thrown away. +fn is_one_shot(entry: &EquipmentEntry) -> bool { + entry.has_flag("F_ONE_SHOT") || entry.has_flag("F_DOUBLE_ONE_SHOT") +} + /// The most a design can expect to land at any one range. /// /// The peak of its curve, which is what "how hard does this hit" means when /// the question is about a machine rather than about a moment. Nought for a /// design with nothing this can model - see the module docs. pub fn firepower(unit: &Unit, catalogue: &Catalogue) -> f64 { - expected_damage_curve(unit, catalogue) - .into_iter() - .max() - .unwrap_or(0) as f64 - / 10.0 + firepower_of(unit, catalogue).peak } /// How much of what a design can expect to land comes out of a magazine, as a @@ -202,12 +479,7 @@ /// two shares are of the same number. Nought is a design that never needs /// resupply; a hundred is one whose guns are all fed from bins. pub fn ammo_share(unit: &Unit, catalogue: &Catalogue) -> Option { - share(unit, catalogue, |entry| { - entry - .ammo_type - .as_deref() - .is_some_and(|kind| kind != "INFANTRY") - }) + firepower_of(unit, catalogue).ammo } /// How much of what a design can expect to land it can only land once, as a @@ -219,51 +491,18 @@ /// design like that wants an asterisk beside it rather than a footnote /// somewhere else. pub fn one_shot_share(unit: &Unit, catalogue: &Catalogue) -> Option { - share(unit, catalogue, |entry| { - entry.has_flag("F_ONE_SHOT") || entry.has_flag("F_DOUBLE_ONE_SHOT") - }) -} - -/// What share of the damage a design expects to land comes from the weapons -/// `which` picks out, at the range it is best at. -/// -/// At its best range rather than at every range: a launcher that cannot reach -/// should not be counted against a design, and a share that changes hex by hex -/// is a curve rather than a fact about the machine. -fn share( - unit: &Unit, - catalogue: &Catalogue, - which: impl Fn(&EquipmentEntry) -> bool, -) -> Option { - let items = mounted_items(unit, catalogue); - let computer = items - .iter() - .any(|(entry, _)| entry.has_flag("F_TARGETING_COMPUTER")); - let weapons: Vec<(&&EquipmentEntry, &i64)> = items - .iter() - .filter(|(entry, _)| entry.is_weapon() && !entry.is_ammo()) - .map(|(entry, count)| (entry, count)) - .collect(); - - let at = |range: i64, picked: bool| -> f64 { - weapons - .iter() - .filter(|(entry, _)| !picked || which(entry)) - .map(|(entry, count)| expected(entry, range, computer) * **count as f64) - .sum() - }; - - let best = (1..=crate::REACH).max_by(|a, b| at(*a, false).total_cmp(&at(*b, false)))?; - let total = at(best, false); - if total <= 0.0 { - return None; - } - Some((at(best, true) / total * 100.0).round() as i64) + firepower_of(unit, catalogue).one_shot } #[cfg(test)] mod tests { use super::*; + + /// What the attacker needs for a weapon fired the only way it can be. + fn needed(weapon: &EquipmentEntry, range: i64, computer: bool) -> Option { + let all = profiles(weapon, 1); + to_hit(weapon, all.first()?, range, computer) + } fn weapon(name: &str, avg: f64, ranges: (i64, i64, i64), flags: &[&str]) -> EquipmentEntry { EquipmentEntry { @@ -304,9 +543,9 @@ fn a_pulse_laser_beats_a_plain_one_of_the_same_damage() { let plain = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); let pulse = weapon("Pulse", 9.0, (3, 7, 10), &["F_DIRECT_FIRE", "F_PULSE"]); - assert_eq!(to_hit(&plain, 3, false), Some(4)); - assert_eq!(to_hit(&pulse, 3, false), Some(2)); - assert!(expected(&pulse, 3, false) > expected(&plain, 3, false)); + assert_eq!(needed(&plain, 3, false), Some(4)); + assert_eq!(needed(&pulse, 3, false), Some(2)); + assert!(expected(&pulse, 3, 1, false) > expected(&plain, 3, 1, false)); } /// And the other half of the same complaint: a heavy laser hits hard and @@ -319,13 +558,13 @@ (5, 10, 15), &["F_DIRECT_FIRE", "HEAVY_LASER"], ); - assert_eq!(to_hit(&heavy, 5, false), Some(5)); + assert_eq!(needed(&heavy, 5, false), Some(5)); // Sixteen points at 30 in 36 is still more than nine at 33 in 36 - // the modifier narrows the gap rather than closing it, which is what // a modifier should do. let plain = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); - assert!(expected(&heavy, 5, false) > expected(&plain, 5, false)); - assert!(expected(&heavy, 5, false) < 16.0); + assert!(expected(&heavy, 5, 1, false) > expected(&plain, 5, 1, false)); + assert!(expected(&heavy, 5, 1, false) < 16.0); } /// A launcher inside its minimum range is harder to aim, hex by hex, and @@ -334,10 +573,10 @@ fn a_launcher_is_worse_the_closer_the_target_gets() { let mut lrm = weapon("LRM 20", 12.0, (7, 14, 21), &["F_MISSILE"]); lrm.min_range = Some(6); - assert_eq!(to_hit(&lrm, 7, false), Some(4), "outside the minimum"); - assert_eq!(to_hit(&lrm, 6, false), Some(5), "at the minimum"); - assert_eq!(to_hit(&lrm, 1, false), Some(10), "point blank"); - assert!(expected(&lrm, 1, false) < expected(&lrm, 7, false)); + assert_eq!(needed(&lrm, 7, false), Some(4), "outside the minimum"); + assert_eq!(needed(&lrm, 6, false), Some(5), "at the minimum"); + assert_eq!(needed(&lrm, 1, false), Some(10), "point blank"); + assert!(expected(&lrm, 1, 1, false) < expected(&lrm, 7, 1, false)); } /// MegaMek writes Integer.MIN_VALUE for a weapon with no minimum range, @@ -347,7 +586,7 @@ fn no_minimum_range_is_not_a_very_large_negative_one() { let mut laser = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); laser.min_range = Some(i64::from(i32::MIN)); - assert_eq!(to_hit(&laser, 1, false), Some(4)); + assert_eq!(needed(&laser, 1, false), Some(4)); } /// A targeting computer helps what is aimed and not what is lobbed. @@ -355,8 +594,8 @@ fn a_targeting_computer_helps_direct_fire_alone() { let laser = weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"]); let lrm = weapon("LRM 20", 12.0, (7, 14, 21), &["F_MISSILE"]); - assert_eq!(to_hit(&laser, 3, true), Some(3)); - assert_eq!(to_hit(&lrm, 7, true), Some(4)); + assert_eq!(needed(&laser, 3, true), Some(3)); + assert_eq!(needed(&lrm, 7, true), Some(4)); } /// The asterisk: a design whose damage is a one-time alpha is a different @@ -402,5 +641,104 @@ assert_eq!(curve[6], 65, "medium"); assert_eq!(curve[9], 38, "long"); assert_eq!(curve[10], 0, "past its reach"); + } +} + +#[cfg(test)] +mod loadings_and_troopers { + use super::*; + use crate::EquipmentEntry; + + fn launcher(ammo_type: &str, avg: f64) -> EquipmentEntry { + EquipmentEntry { + internal_name: format!("{ammo_type} 5"), + name: format!("{ammo_type} 5"), + classes: vec!["WeaponType".into()], + average_damage: Some(avg), + ammo_type: Some(ammo_type.into()), + // What MegaMek writes for a launcher whose ammunition decides + // its reach: nothing at all. + short_range: Some(0), + medium_range: Some(0), + long_range: Some(0), + ..Default::default() + } + } + + /// The launcher fires whichever loading lands most, and never one it + /// would have to fire into its own minimum range. + #[test] + fn an_mml_is_short_ranged_up_close_and_long_ranged_far_off() { + let mml = launcher("MML", 3.0); + + // At two hexes the long-range loading is inside its minimum, so the + // short one is fired: 6 points at 33 in 36. + assert!( + (expected(&mml, 2, 1, false) - 5.5).abs() < 0.05, + "{}", + expected(&mml, 2, 1, false) + ); + // At fifteen only the long-range loading reaches at all. + assert!((expected(&mml, 15, 1, false) - 1.25).abs() < 0.05); + // And past twenty-one neither does. + assert_eq!(expected(&mml, 22, 1, false), 0.0); + } + + /// An ATM picks between three rounds the same way. + #[test] + fn an_atm_picks_the_round_that_lands_most() { + let atm = launcher("ATM", 8.0); + // High-explosive up close: 12 points, and the other two are inside + // their minimum. + assert!( + (expected(&atm, 2, 1, false) - 11.0).abs() < 0.05, + "{}", + expected(&atm, 2, 1, false) + ); + // Extended range is the only one that reaches twenty. + assert!(expected(&atm, 20, 1, false) > 0.0); + assert_eq!(expected(&atm, 28, 1, false), 0.0); + } + + /// A platoon's damage is one trooper's rifle times the platoon. + #[test] + fn a_rifle_is_worth_what_the_platoon_carries() { + let rifle = EquipmentEntry { + internal_name: "Laser Rifle".into(), + name: "Laser Rifle".into(), + classes: vec!["WeaponType".into()], + flags: vec!["WeaponType.F_INFANTRY".into()], + infantry_damage: Some(0.28), + infantry_range: Some(2), + ..Default::default() + }; + // Twenty-eight troopers at 0.28 each, landing 33 times in 36. + let one = expected(&rifle, 1, 1, false); + let platoon = expected(&rifle, 1, 28, false); + assert!((platoon - one * 28.0).abs() < 1e-9); + assert!((one - 0.28 * 33.0 / 36.0).abs() < 1e-9); + // Its reach is two hexes, doubled and trebled for the brackets. + assert!(expected(&rifle, 6, 28, false) > 0.0); + assert_eq!(expected(&rifle, 7, 28, false), 0.0); + } + + /// Artillery, tags, anti-missile systems and the rest are not firepower, + /// whatever damage MegaMek records for them. + #[test] + fn what_is_not_firepower_lands_nothing() { + for flag in NOT_FIREPOWER { + let odd = EquipmentEntry { + internal_name: (*flag).into(), + name: (*flag).into(), + classes: vec!["WeaponType".into()], + flags: vec![format!("WeaponType.{flag}")], + average_damage: Some(25.0), + short_range: Some(3), + medium_range: Some(6), + long_range: Some(9), + ..Default::default() + }; + assert_eq!(expected(&odd, 3, 1, false), 0.0, "{flag}"); + } } } diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -40,7 +40,9 @@ ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, }; -pub use expected::{GUNNERY, ammo_share, expected_damage_curve, firepower, one_shot_share}; +pub use expected::{ + Firepower, GUNNERY, ammo_share, expected_damage_curve, firepower, firepower_of, one_shot_share, +}; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, REACH, can_make_anti_mek_attacks, can_ride_a_mek, count_carrying, damage_at_range, damage_curve, equipment_list, diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs --- a/crates/helm-core/src/unit.rs +++ b/crates/helm-core/src/unit.rs @@ -189,6 +189,28 @@ } } + /// How many troopers this design fights with, where it is people rather + /// than a machine. + /// + /// A platoon states a squad size and a number of squads; a battle armour + /// design states a trooper count, and four is the fallback because a + /// Point is four in the Inner Sphere. `None` for anything that is not + /// either, which is every Mek and every vehicle. + pub fn troopers(&self) -> Option { + let field = |key: &str| { + self.fields + .iter() + .find(|(k, _)| k.trim().eq_ignore_ascii_case(key)) + .and_then(|(_, v)| v.trim().parse::().ok()) + }; + if self.is_battle_armor() { + return Some(field("trooper count").filter(|n| *n > 0).unwrap_or(4)); + } + let squads = field("squadn")?; + let size = field("squad_size")?; + Some(squads * size).filter(|n| *n > 0) + } + /// Whether this design is a Mek. /// /// A `.mtf` is always a Mek and declares no unit type; a `.blk` declares diff --git a/crates/helm-db/src/lib.rs b/crates/helm-db/src/lib.rs --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -165,6 +165,9 @@ let as_ = cs.map(|s| &s.alpha_strike); // Only meaningful with a catalogue to look weapons up in. let metrics = (!catalogue.is_empty()).then(|| CombatMetrics::compute(u, catalogue)); + // One reading of the design's guns, for the columns and the curve + // alike: reading them is most of what any of it costs. + let guns = (!catalogue.is_empty()).then(|| helm_core::firepower_of(u, catalogue)); unit_stmt.execute(params![ uid, @@ -217,12 +220,8 @@ metrics .as_ref() .map(helm_core::CombatMetrics::heat_efficiency), - metrics - .as_ref() - .and_then(|_| helm_core::ammo_share(u, catalogue)), - metrics - .as_ref() - .and_then(|_| helm_core::one_shot_share(u, catalogue)), + guns.as_ref().and_then(|g| g.ammo), + guns.as_ref().and_then(|g| g.one_shot), metrics.as_ref().map(|m| m.weapon_count as i64), metrics .as_ref() @@ -261,10 +260,8 @@ } // Only where there is something to hit with: a design with no // weapons writes no rows rather than twenty-four zeroes. - for (at, damage) in helm_core::damage_curve(u, catalogue) - .into_iter() - .enumerate() - { + let curve = guns.as_ref().map(|g| g.curve.clone()).unwrap_or_default(); + for (at, damage) in curve.into_iter().enumerate() { if damage > 0 { curve_stmt.execute(params![uid, at as i64 + 1, damage])?; stats.curve_rows += 1; @@ -417,7 +414,8 @@ quantity INTEGER NOT NULL ); - -- What a design does at each hex, one row per hex. + -- What a design expects to land at each hex, in tenths of a point, + -- one row per hex. -- -- Rows rather than a packed column so the question a player actually -- asks is a query: hardest hitter between 3 and 9 hexes is an AVG diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -181,6 +181,8 @@ }; let metrics = catalogue.map(|c| CombatMetrics::compute(unit, c)); + // One reading of the design's guns for all four figures. + let guns = catalogue.map(|c| helm_core::firepower_of(unit, c)); UnitFacets { name: unit.name.clone(), @@ -204,8 +206,8 @@ omni: stats.and_then(|s| s.omni), clan: stats.and_then(|s| s.clan), loadout, - curve: catalogue.map_or_else(Vec::new, |c| helm_core::expected_damage_curve(unit, c)), - one_shot: catalogue.and_then(|c| helm_core::one_shot_share(unit, c)), + curve: guns.as_ref().map_or_else(Vec::new, |g| g.curve.clone()), + one_shot: guns.as_ref().and_then(|g| g.one_shot), firepower: metrics.as_ref().map(|m| m.firepower), max_range: metrics.as_ref().map(|m| m.max_range), heat: metrics.as_ref().map(|m| m.heat), @@ -213,7 +215,7 @@ heat_efficiency: metrics .as_ref() .map(helm_core::CombatMetrics::heat_efficiency), - ammo_dependence: catalogue.and_then(|c| helm_core::ammo_share(unit, c)), + ammo_dependence: guns.as_ref().and_then(|g| g.ammo), armor_pct: unit.armor_percent(), rules_level: stats.and_then(|s| s.tech_level.clone()), cost: stats.and_then(|s| s.cost),