From 09739b5bbccce90be3d87910fa8ee2689e67567d Mon Sep 17 00:00:00 2001 From: @permadeath.com Date: Wed, 19 Aug 2026 23:22:52 +0000 Subject: [PATCH] feat(unit-search): filter on armour as a fraction of the maximum `armor_pct` follows the other metrics through the facet, the database and the MCP, and answers "well armoured for its size" without naming a tonnage. An Atlas AS7-D carries 304 points and a Locust LCT-1V 64; as percentages they are 99 and 93. Declared data rather than derived from the catalogue, so the column is filled whether or not a build had one. --- crates/helm-db/src/lib.rs | 12 ++++++++++-- crates/helm-facet/src/query.rs | 9 +++++++++ crates/helm-mcp/src/library.rs | 5 ++++- crates/helm-mcp/src/tools.rs | 12 +++++++++++- crates/helm-mcp/tests/tools.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 5 file(s) changed, 79 insertion(s)(+), 4 deletion(s)(-) 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 @@ -111,7 +111,7 @@ unit_id, path, format, chassis, model, clan_name, name, mul_id, unit_type, config, tech_base, rules_level, role, source, year, mass, engine, structure, myomer, heat_sinks, armor, motion_type, - walk_mp, jump_mp, total_armor, equipment_count, + walk_mp, jump_mp, total_armor, armor_pct, equipment_count, bv, cost, tech_level, weight_class, weight_class_name, canon, invalid, omni, clan, run_mp, point_value, as_unit_type, as_size, as_tmm, as_damage, as_specials, @@ -122,7 +122,7 @@ ?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)", + ?49,?50,?51,?52,?53,?54,?55)", )?; let mut equip_stmt = tx.prepare( "INSERT INTO unit_equipment @@ -178,6 +178,7 @@ u.walk_mp, u.jump_mp, u.total_armor(), + u.armor_percent(), u.equipment.len() as i64, cs.and_then(|s| s.battle_value), cs.and_then(|s| s.cost), @@ -295,6 +296,12 @@ walk_mp INTEGER, jump_mp INTEGER, total_armor INTEGER, + -- Armour as a percentage of the most the design could carry, which + -- is what makes it comparable across weight classes. Declared data + -- rather than derived from the catalogue, so it is filled whether + -- or not one was supplied - and null for anything that is not a + -- Mek, the table being a Mek's. + armor_pct INTEGER, equipment_count INTEGER, -- Computed rather than declared; null when built with no producer. -- See the meta table for which producer filled them. @@ -422,6 +429,7 @@ CREATE INDEX idx_units_firepower ON units(firepower); CREATE INDEX idx_units_max_range ON units(max_range); CREATE INDEX idx_units_heat_efficiency ON units(heat_efficiency); + CREATE INDEX idx_units_armor_pct ON units(armor_pct); CREATE INDEX idx_equip_unit ON unit_equipment(unit_id); CREATE INDEX idx_equip_norm ON unit_equipment(name_norm); CREATE INDEX idx_equip_display ON unit_equipment(display_name); 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 @@ -73,6 +73,12 @@ pub dissipation: Option, /// Dissipation minus heat. Negative builds heat toward shutdown. pub heat_efficiency: Option, + /// Armour as a percentage of the most this design could carry. + /// + /// Needs no catalogue, unlike the five above it: the armour is declared + /// and the maximum follows from the tonnage. Absent for anything that is + /// not a Mek, since the table it is measured against is a Mek's. + pub armor_pct: Option, /// Rules level: Introductory, Standard, Advanced, Experimental. Comes /// from the computed record, not the `rules level:` line in the file, @@ -155,6 +161,7 @@ heat: metrics.as_ref().map(|m| m.heat), dissipation: metrics.as_ref().map(|m| m.dissipation), heat_efficiency: metrics.as_ref().map(|m| m.heat_efficiency()), + armor_pct: unit.armor_percent(), rules_level: stats.and_then(|s| s.tech_level.clone()), cost: stats.and_then(|s| s.cost), config: unit.config.as_deref().map(base_config), @@ -190,6 +197,7 @@ pub heat: Range, pub dissipation: Range, pub heat_efficiency: Range, + pub armor_pct: Range, pub cost: Range, pub run_mp: Range, @@ -274,6 +282,7 @@ || !self .heat_efficiency .matches(u.heat_efficiency.map(|v| v as f64)) + || !self.armor_pct.matches(u.armor_pct.map(|v| v as f64)) || !self.cost.matches(u.cost.map(|v| v as f64)) || !self.run_mp.matches(u.run_mp.map(|v| v as f64)) { diff --git a/crates/helm-mcp/src/library.rs b/crates/helm-mcp/src/library.rs --- a/crates/helm-mcp/src/library.rs +++ b/crates/helm-mcp/src/library.rs @@ -67,7 +67,7 @@ weight_class, year, mass, bv, walk_mp, jump_mp, canon, invalid, omni, clan, firepower, max_range, heat, dissipation, heat_efficiency, - tech_level, cost, config, run_mp, mul_id + tech_level, cost, config, run_mp, mul_id, armor_pct FROM meks ORDER BY unit_id", ) .map_err(|e| format!("querying meks: {e}"))?; @@ -107,6 +107,7 @@ .map(|c| helm_facet::base_config(&c)), run_mp: r.get(26)?, mul_id: r.get(27)?, + armor_pct: r.get(28)?, quirks: Vec::new(), }, )) @@ -308,6 +309,7 @@ "heat": f.heat, "dissipation": f.dissipation, "heat_efficiency": f.heat_efficiency, + "armor_pct": f.armor_pct, "engine": engine, "structure": structure, "heat_sinks": heat_sinks, @@ -336,6 +338,7 @@ "firepower": f.firepower, "max_range": f.max_range, "heat_efficiency": f.heat_efficiency, + "armor_pct": f.armor_pct, }) } } diff --git a/crates/helm-mcp/src/tools.rs b/crates/helm-mcp/src/tools.rs --- a/crates/helm-mcp/src/tools.rs +++ b/crates/helm-mcp/src/tools.rs @@ -70,6 +70,10 @@ /// it can alpha strike indefinitely; negative means it builds heat. pub heat_efficiency_min: Option, pub heat_efficiency_max: Option, + /// Armour as a percentage of the most the design could carry. + pub armor_pct_min: Option, + /// Armour as a percentage of the most the design could carry. + pub armor_pct_max: Option, /// True for OmniMeks only, false to exclude them. pub omni: Option, /// Canonical equipment names the unit must carry, all of them. Use @@ -231,6 +235,7 @@ heat: Range::any(), dissipation: Range::any(), heat_efficiency: Range::new(self.heat_efficiency_min, self.heat_efficiency_max), + armor_pct: Range::new(self.armor_pct_min, self.armor_pct_max), cost: Range::new(self.cost_min, self.cost_max), run_mp: Range::new(self.run_mp_min, None), rules_levels: self.rules_level.clone().unwrap_or_default(), @@ -337,6 +342,7 @@ .iter() .filter_map(|f| f.heat_efficiency) .collect(); + let armour: Vec = lib.facets.iter().filter_map(|f| f.armor_pct).collect(); let costs: Vec = lib.facets.iter().filter_map(|f| f.cost).collect(); let years: Vec = lib.facets.iter().filter_map(|f| f.year).collect(); let bvs: Vec = lib.facets.iter().filter_map(|f| f.battle_value).collect(); @@ -368,12 +374,16 @@ "max_range": { "min": ranges.iter().min(), "max": ranges.iter().max() }, "cost_cbills": { "min": costs.iter().min(), "max": costs.iter().max() }, "heat_efficiency": { "min": effic.iter().min(), "max": effic.iter().max() }, + "armor_pct": { "min": armour.iter().min(), "max": armour.iter().max() }, }, "derived_note": "firepower, max_range and heat_efficiency are worked out \ from the loadout rather than declared. firepower counts \ cluster weapons at their average, and heat_efficiency is \ dissipation minus the heat of firing everything - 0 or \ - more can alpha strike indefinitely.", + more can alpha strike indefinitely. armor_pct is the \ + armour carried as a percentage of the most the design \ + could carry, which is how 'well armoured for its size' \ + is asked without naming a tonnage.", "equipment_class_note": "carries_class/lacks_class take these labels and \ are usually what you want - they say 'an energy \ weapon' without naming every laser. Counted per \ diff --git a/crates/helm-mcp/tests/tools.rs b/crates/helm-mcp/tests/tools.rs --- a/crates/helm-mcp/tests/tools.rs +++ b/crates/helm-mcp/tests/tools.rs @@ -349,6 +349,51 @@ ); } +// The point of a percentage: "well armoured" without naming a tonnage. A +// light Mek at 95% and an assault at 60% are different machines, and the raw +// point count says the assault is the armoured one. +#[test] +#[ignore = "needs a built database; set HELM_DB"] +fn armour_as_a_percentage_is_not_armour_in_points() { + let lib = library(); + + let heaviest = tools::find_units( + &lib, + &filter(serde_json::json!({"armor_pct_min": 99, "tons_max": 35})), + Some(3), + ); + assert!( + heaviest["total_matched"].as_i64().unwrap() > 0, + "no light Mek is armoured to its maximum, which cannot be right" + ); + + // The same question asked in points selects a different set, because a + // light Mek carrying everything it can still carries few points. + let by_points = tools::find_units( + &lib, + &filter(serde_json::json!({"tons_max": 35, "bv_min": 1})), + Some(1), + ); + assert_ne!( + heaviest["total_matched"], by_points["total_matched"], + "the percentage selected every light Mek, so it is not filtering" + ); + + // And it discriminates in the other direction: something is thinly + // armoured for its size. + let thin = tools::find_units( + &lib, + &filter(serde_json::json!({"armor_pct_max": 50})), + Some(3), + ); + assert!(thin["total_matched"].as_i64().unwrap() > 0); + assert!( + thin["units"][0]["armor_pct"].as_i64().unwrap() <= 50, + "{:?}", + thin["units"][0] + ); +} + #[test] #[ignore = "needs a built database; set HELM_DB"] fn mul_id_goes_straight_to_one_unit() { -- tangled.sh