From b09afbe044577bc55eedb5b2bbcdbff2c331449c Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Sat, 22 Aug 2026 16:59:21 -0400 Subject: [PATCH] feat(unit-search)!: the curve is what a design expects to land, not what it throws Weighted by how often each weapon hits: gunnery 4, open ground, nothing moving, and every modifier a weapon carries with it - range brackets, minimum range, pulse -2, heavy laser +1, a targeting computer -1 on direct fire. Damage is MegaMek's own cluster average, so an LRM 20 is 12 rather than 20. An Awesome AWS-8Q now peaks at six hexes rather than one, because a PPC has a minimum range; a heavy laser boat stops outranking everything. `one_shot` is the asterisk beside a design whose damage is a rocket launcher it can fire once. Change-Id: Id468349cf934e56870906a5847f15a220df91e30 --- TODO.md | 26 ++- crates/helm-core/src/expected.rs | 358 +++++++++++++++++++++++++++++++ crates/helm-core/src/lib.rs | 2 + crates/helm-db/src/lib.rs | 11 +- crates/helm-facet/src/query.rs | 17 +- crates/helm-mcp/src/library.rs | 15 +- crates/helm-query/src/facets.rs | 5 + 7 files changed, 412 insertions(+), 22 deletions(-) create mode 100644 crates/helm-core/src/expected.rs diff --git a/TODO.md b/TODO.md index 4ab2f1c..32abba1 100644 --- a/TODO.md +++ b/TODO.md @@ -17,7 +17,7 @@ problem, never code. ## What to do next, in order -Twenty-three things, ranked. The tiers are the argument: the first finishes what +Twenty-four 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,35 +91,43 @@ to helm. 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. **The MCP server resolves equipment names too.** `weapon: ["particle"]` +16. **`firepower` reads the wrong half of the design.** `CombatMetrics` + counts weapons off `unit.equipment`, the file's `Weapons:` block, while + every range figure reads the critical slots through `mounted_items` - + which is the reading MegaMek does. 153 of 8,666 designs expect to land + more than `firepower` says they throw, which is impossible: a Gunsmith + CH11-NGC 'Cinderella' carries four ER Medium Pulse Lasers and is credited + 3 points. Fixing it moves a published column, so it wants saying out loud + rather than slipping in. +17. **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. -17. **An identity for the catalogue.** `units.json` and `loadout.json` carry a +18. **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. -18. **The redistribution question.** MegaMek's data is CC BY-NC-SA and serving +19. **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. -19. **Provenance on every record.** Which MegaMek, which producer, which +20. **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. -20. **The attribution and the repair list on a match page.** +21. **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. -21. **Damage that persists between matches.** `plan/campaign.md`. The +22. **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. -22. **Formation validation.** `list_formations` and `validate_formation` +23. **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. -23. **Ingest `data/forcegenerator/`.** 40 era files of per-faction availability +24. **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-core/src/expected.rs b/crates/helm-core/src/expected.rs new file mode 100644 index 0000000..cda486a --- /dev/null +++ b/crates/helm-core/src/expected.rs @@ -0,0 +1,358 @@ +//! What a design can expect to land, hex by hex. +//! +//! # Why this exists beside `damage_at_range` +//! +//! [`crate::damage_at_range`] is what a design can *throw*: every shot hits, +//! every missile in the salvo lands, and a weapon counts flat from one hex out +//! to its long range. That is MegaMek's own figure and the formation rules are +//! written against it, so it stays exactly as it is. +//! +//! It is also the wrong thing to rank designs by. It says a Heavy Large Laser +//! is worth sixteen at fifteen hexes, when a heavy laser is +1 to hit and the +//! long-range bracket is +4 on top; and it says a Large Pulse Laser is worth +//! nine, when the pulse mechanism is -2 and it will land far more often. Rank +//! by what is thrown and a heavy laser boat looks like the best machine in the +//! library, which is not an opinion anybody who has played holds. +//! +//! # The fight this assumes +//! +//! One a design is never actually in, and the same one for every design, which +//! is what makes the numbers comparable: +//! +//! * a gunnery 4 crew, which is the regular figure a battle value assumes; +//! * open ground, no terrain, nothing in the way; +//! * neither machine moving, so no attacker or target movement modifier; +//! * every weapon fired, with no heat, no ammunition and no arc to worry +//! about. +//! +//! 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}; + +/// The crew this assumes: a regular MechWarrior, the same figure battle value +/// is computed for. +pub const GUNNERY: i64 = 4; + +/// What each range bracket adds to a to-hit roll. +const SHORT: i64 = 0; +const MEDIUM: i64 = 2; +const LONG: i64 = 4; + +/// One thing about a weapon that moves the to-hit number, and what it moves it +/// by. +/// +/// A table rather than a chain of `if`s for the same reason the priced items +/// are one: these are independent facts, somebody will add another, and a set +/// that can be read down is a set that can be checked against a rulebook. +struct Modifier { + /// The flag MegaMek tells it by. + flag: &'static str, + /// What it does to the number the attacker needs. + to_hit: i64, +} + +/// Everything about a weapon itself that changes how often it lands. +/// +/// Deliberately short. This is a comparison between designs rather than a +/// combat model, so what belongs here is what a weapon carries with it +/// wherever it goes - not what the ground, the range or the target are doing. +const WEAPON_MODIFIERS: &[Modifier] = &[ + // The pulse mechanism is the whole reason pulse lasers are worth their + // weight, and a figure that ignores it rates them below the plain sort. + Modifier { + flag: "F_PULSE", + to_hit: -2, + }, + // Clan heavy lasers hit hard and hit rarely. Without this they look like + // the best weapons in the game. + Modifier { + flag: "HEAVY_LASER", + to_hit: 1, + }, +]; + +/// What a targeting computer does for the weapons it can help. +const TARGETING_COMPUTER: i64 = -1; + +/// 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 +/// checked against the back of the rulebook at a glance. +fn chance(needed: i64) -> f64 { + let ways = match needed { + i64::MIN..=2 => 36, + 3 => 35, + 4 => 33, + 5 => 30, + 6 => 26, + 7 => 21, + 8 => 15, + 9 => 10, + 10 => 6, + 11 => 3, + 12 => 1, + _ => 0, + }; + 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 { + 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 + } else { + 0 + }; + + let carried: i64 = WEAPON_MODIFIERS + .iter() + .filter(|m| weapon.has_flag(m.flag)) + .map(|m| m.to_hit) + .sum(); + + // A targeting computer helps a weapon that is aimed, which is what + // MegaMek's direct-fire flag means. It does nothing for a missile. + let computer = if targeting_computer && weapon.has_flag("F_DIRECT_FIRE") { + TARGETING_COMPUTER + } else { + 0 + }; + + 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 { + return 0.0; + } + to_hit(weapon, range, targeting_computer).map_or(0.0, |needed| damage * chance(needed)) +} + +/// What a design can expect to land at each hex from 1 to [`crate::REACH`], in +/// tenths of a point. +/// +/// Tenths because the answer is fractional and a chunk of whole numbers is a +/// 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() +} + +/// How much of what a design can expect to land it can only land once, as a +/// percentage. +/// +/// A rocket launcher is fired and thrown away. A Longbow LGB-0H carries +/// twenty-eight of them and nothing else worth naming, so every figure that +/// counts them treats a one-shot alpha as a way of fighting - which is why a +/// design like that wants an asterisk beside it rather than a footnote +/// somewhere else. +/// +/// 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. +pub fn one_shot_share(unit: &Unit, catalogue: &Catalogue) -> 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, only_one_shot: bool| -> f64 { + weapons + .iter() + .filter(|(entry, _)| { + !only_one_shot + || entry.has_flag("F_ONE_SHOT") + || entry.has_flag("F_DOUBLE_ONE_SHOT") + }) + .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) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn weapon(name: &str, avg: f64, ranges: (i64, i64, i64), flags: &[&str]) -> EquipmentEntry { + EquipmentEntry { + internal_name: name.into(), + name: name.into(), + classes: vec!["WeaponType".into()], + average_damage: Some(avg), + short_range: Some(ranges.0), + medium_range: Some(ranges.1), + long_range: Some(ranges.2), + flags: flags.iter().map(|f| format!("WeaponType.{f}")).collect(), + ..Default::default() + } + } + + /// One weapon per location, because `mounted_items` counts an item that + /// fills several slots of one location once - which is right for a Gauss + /// rifle and wrong for a fixture pretending three launchers are three. + fn carrying(entries: Vec, slots: &[&str]) -> (Unit, Catalogue) { + let mut unit = Unit { + chassis: "Test".into(), + model: "1".into(), + format_str: "mtf".into(), + mass: Some(50.0), + ..Default::default() + }; + for (at, slot) in slots.iter().enumerate() { + unit.criticals + .insert(format!("Location {at}"), vec![(*slot).to_string()]); + } + (unit, Catalogue::new(entries)) + } + + /// The whole point: a gun that lands more often is worth more, and the + /// figure says so. A pulse laser needs a 2 at short range where the plain + /// one needs a 4. + #[test] + 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)); + } + + /// And the other half of the same complaint: a heavy laser hits hard and + /// rarely, and a figure that ignores the +1 rates it above everything. + #[test] + fn a_heavy_laser_pays_for_being_hard_to_aim() { + let heavy = weapon( + "Heavy", + 16.0, + (5, 10, 15), + &["F_DIRECT_FIRE", "HEAVY_LASER"], + ); + assert_eq!(to_hit(&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); + } + + /// A launcher inside its minimum range is harder to aim, hex by hex, and + /// this is the modifier that stops a missile boat topping a brawling sort. + #[test] + 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)); + } + + /// MegaMek writes Integer.MIN_VALUE for a weapon with no minimum range, + /// and helm-bridge keeps the field as it found it. Read as a number it + /// would make every weapon in the library unmissable. + #[test] + 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)); + } + + /// A targeting computer helps what is aimed and not what is lobbed. + #[test] + 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)); + } + + /// The asterisk: a design whose damage is a one-time alpha is a different + /// proposition from one that can do it every turn. + #[test] + fn a_rocket_boat_is_mostly_one_shot() { + let (unit, catalogue) = carrying( + vec![ + weapon("RL10", 6.0, (5, 11, 18), &["F_MISSILE", "F_ONE_SHOT"]), + weapon("Laser", 5.0, (3, 6, 9), &["F_DIRECT_FIRE"]), + ], + &["RL10", "RL10", "RL10", "Laser"], + ); + let share = one_shot_share(&unit, &catalogue).expect("it has weapons"); + assert!(share > 70, "{share}"); + + let (plain, catalogue) = carrying( + vec![weapon("Laser", 5.0, (3, 6, 9), &["F_DIRECT_FIRE"])], + &["Laser"], + ); + assert_eq!(one_shot_share(&plain, &catalogue), Some(0)); + } + + /// A design with nothing to shoot with is not nought percent one-shot. + #[test] + fn an_unarmed_design_has_no_share_at_all() { + let (unit, catalogue) = carrying(vec![], &[]); + assert_eq!(one_shot_share(&unit, &catalogue), None); + } + + /// The curve falls away as the brackets change and stops where the last + /// weapon does. + #[test] + fn the_curve_falls_off_with_the_brackets() { + let (unit, catalogue) = carrying( + vec![weapon("Laser", 9.0, (3, 7, 10), &["F_DIRECT_FIRE"])], + &["Laser"], + ); + let curve = expected_damage_curve(&unit, &catalogue); + assert_eq!(curve.len(), crate::REACH as usize); + // 9 damage at 33 in 36, then 26, then 15, then out of range. + assert_eq!(curve[2], 83, "short"); + assert_eq!(curve[6], 65, "medium"); + assert_eq!(curve[9], 38, "long"); + assert_eq!(curve[10], 0, "past its reach"); + } +} diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 41abbbd..ba60690 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -27,6 +27,7 @@ mod bv; mod catalogue; mod computed; mod design; +mod expected; mod metrics; mod rng; mod structure; @@ -39,6 +40,7 @@ pub use design::{ ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, }; +pub use expected::{GUNNERY, expected_damage_curve, 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-db/src/lib.rs b/crates/helm-db/src/lib.rs index 5bcd304..532cd96 100644 --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -124,14 +124,14 @@ pub fn build( canon, invalid, omni, clan, run_mp, point_value, as_unit_type, as_size, as_tmm, as_damage, as_specials, firepower, max_range, heat, dissipation, heat_efficiency, ammo_dependence, - weapon_count, + one_shot, weapon_count, damage_at_6, damage_at_7, damage_at_9, damage_at_18, single_damage_at_15, single_damage_at_18 ) VALUES ( ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18, ?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33,?34, ?35,?36,?37,?38,?39,?40,?41,?42,?43,?44,?45,?46,?47,?48, - ?49,?50,?51,?52,?53,?54,?55,?56)", + ?49,?50,?51,?52,?53,?54,?55,?56,?57)", )?; let mut equip_stmt = tx.prepare( "INSERT INTO unit_equipment @@ -220,6 +220,9 @@ pub fn build( metrics .as_ref() .and_then(helm_core::CombatMetrics::ammo_dependence), + metrics + .as_ref() + .and_then(|_| helm_core::one_shot_share(u, catalogue)), metrics.as_ref().map(|m| m.weapon_count as i64), metrics .as_ref() @@ -369,6 +372,10 @@ fn schema(db: &Connection) -> rusqlite::Result<()> { -- resupply is a different proposition in a campaign, and nothing -- else here says so. ammo_dependence INTEGER, + -- How much of what it can expect to land it can only land once. + -- A rocket launcher is fired and thrown away, and a design whose + -- damage is mostly that is a different proposition. + one_shot INTEGER, weapon_count INTEGER, -- Maximum damage reaching each band the formation rules ask about, -- and the hardest single hit at the two they ask that about. Not diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index 3ab1766..3da1570 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -34,14 +34,22 @@ pub struct UnitFacets { pub invalid: Option, pub omni: Option, pub clan: Option, - /// What this design does at each hex from 1 to `helm_core::REACH`. + /// What this design can expect to land at each hex from 1 to + /// `helm_core::REACH`, in tenths of a point. /// /// Short, medium and long are per weapon rather than universal - a small /// laser's long range is three hexes and a PPC's is eighteen - so there /// is no honest set of bands to publish, and the curve is the whole - /// answer. Empty for a design read without a catalogue to look its - /// weapons up in. + /// answer. + /// + /// Weighted by how often each weapon lands against a gunnery 4 crew on + /// open ground with nothing moving: see `helm_core::expected_damage_curve` + /// for what that assumes and why. Empty for a design read without a + /// catalogue to look its weapons up in. pub curve: Vec, + /// How much of what it can expect to land it can only land once, as a + /// percentage: a rocket launcher is fired and thrown away. + pub one_shot: Option, /// `(equipment name, quantity)`, the shape MegaMek evaluates against. /// /// [`helm_core::equipment_list`] where there is a catalogue: MegaMek's @@ -196,7 +204,8 @@ impl UnitFacets { 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::damage_curve(unit, c)), + 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)), 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), diff --git a/crates/helm-mcp/src/library.rs b/crates/helm-mcp/src/library.rs index 75d695c..f539361 100644 --- a/crates/helm-mcp/src/library.rs +++ b/crates/helm-mcp/src/library.rs @@ -91,7 +91,7 @@ impl Library { "SELECT unit_id, name, chassis, model, unit_type, tech_base, role, source, weight_class, year, mass, bv, walk_mp, jump_mp, canon, invalid, omni, clan, - firepower, max_range, heat, dissipation, heat_efficiency, ammo_dependence, + firepower, max_range, heat, dissipation, heat_efficiency, ammo_dependence, one_shot, tech_level, cost, config, run_mp, mul_id, armor_pct FROM meks ORDER BY unit_id", ) @@ -127,14 +127,15 @@ impl Library { dissipation: r.get(21)?, heat_efficiency: r.get(22)?, ammo_dependence: r.get(23)?, - rules_level: r.get(24)?, - cost: r.get(25)?, + one_shot: r.get(24)?, + rules_level: r.get(25)?, + cost: r.get(26)?, config: r - .get::<_, Option>(26)? + .get::<_, Option>(27)? .map(|c| helm_facet::base_config(&c)), - run_mp: r.get(27)?, - mul_id: r.get(28)?, - armor_pct: r.get(29)?, + run_mp: r.get(28)?, + mul_id: r.get(29)?, + armor_pct: r.get(30)?, quirks: Vec::new(), sources: r .get::<_, Option>(7)? diff --git a/crates/helm-query/src/facets.rs b/crates/helm-query/src/facets.rs index ca9e61c..1c1fffd 100644 --- a/crates/helm-query/src/facets.rs +++ b/crates/helm-query/src/facets.rs @@ -289,6 +289,8 @@ pub struct Combat { #[serde(skip_serializing_if = "Option::is_none")] pub ammo_dependence: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub one_shot: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub armor_pct: Option, } @@ -306,6 +308,7 @@ impl Columns for Combat { dissipation: unit.dissipation, heat_efficiency: unit.heat_efficiency, ammo_dependence: unit.ammo_dependence, + one_shot: unit.one_shot, armor_pct: unit.armor_pct, } } @@ -320,6 +323,7 @@ impl Columns for Combat { unit.dissipation = self.dissipation; unit.heat_efficiency = self.heat_efficiency; unit.ammo_dependence = self.ammo_dependence; + unit.one_shot = self.one_shot; unit.armor_pct = self.armor_pct; } } @@ -607,6 +611,7 @@ mod tests { dissipation: Some(20), heat_efficiency: Some(-8), ammo_dependence: Some(62), + one_shot: Some(0), armor_pct: Some(93), curve: vec![25; 24], quirks: vec!["battle_fists".into()], -- 2.51.2