From b07e88e788395477ce4a004f751a824299a211ae Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 24 Aug 2026 21:34:57 -0400 Subject: [PATCH] feat(unit-search): ask the legs a design stands on as a yes or a no `biped`, `quad` and `tripod` are the `Config:` line as three tri-state filters, read through `Shape::from_config` so a QuadVee is a quad and a LAM is a biped. A design with no configuration answers neither way, the rule the other tri-state filters follow. --- README.md | 15 +++++ TODO.md | 4 ++ crates/helm-facet/src/query.rs | 85 ++++++++++++++++++++++++++- crates/helm-query/src/lib.rs | 103 ++++++++++++++++++++++++++++----- crates/helm-wasm/filters.json | 14 +++++ 5 files changed, 204 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 996a1c2..16c87ba 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,21 @@ Nothing but a Mek is asked. A tank has a left side and a right side and no rules-level notion of mirroring them, so it answers nothing, and neither `true` nor `false` returns it. +### How many legs it stands on + +`biped`, `quad` and `tripod` are the `Config:` line as three yes-or-no +questions. `config: ["Quad"]` asks the same thing by name; a list of names +cannot say *not* a quad, which is what a control offering two answers has to +be able to mean. + +They are read through `Shape::from_config`, so the two configurations that do +not say their own shape come out right: a QuadVee stands on four legs and a +LAM on two. `OmniMek` and `FrankenMek` say how a design was built rather than +what shape it is, and are ignored here as they are everywhere else. + +Only a Mek has a configuration. Anything without one answers nothing, and +neither `true` nor `false` returns it. + ## Deploying the browser artifacts Three files go to the site's assets bucket, under one prefix named after the diff --git a/TODO.md b/TODO.md index 6284bca..b40418c 100644 --- a/TODO.md +++ b/TODO.md @@ -245,6 +245,10 @@ Cheap: the columns exist and the MCP does not offer them. list originally said: that field is populated for exactly zero Meks, being a `.blk` concept. The `OmniMek` suffix is stripped, so `config: Biped` finds OmniMeks too and `omni` asks that separately. +- [x] **`biped`, `quad`, `tripod`** — the same question as a yes or a no, for + a control that offers two answers rather than a list of names. Read + through `Shape::from_config`, so a QuadVee is a quad and a LAM is a + biped; a design with no `Config:` line answers neither way. - [x] **`run_mp`**. - [x] **`mul_id` lookup** — go straight to a unit from a MUL record id. 4,126 Meks carry one. diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index 7174988..ca0ac0f 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -1,6 +1,6 @@ //! The query itself, and the flattened view of a unit it filters. -use helm_core::{Catalogue, CombatMetrics, ComputedStats, Unit}; +use helm_core::{Catalogue, CombatMetrics, ComputedStats, Shape, Unit}; use crate::{Equipment, Range, Tri, contains_normalized}; @@ -245,6 +245,27 @@ impl UnitFacets { .unwrap_or_default(), } } + + /// How many legs this design stands on, where that is a question about it. + /// + /// `None` for anything with no `Config:` line - a tank, a platoon, a + /// fighter - and that is not "biped". Only a Mek has a configuration, so + /// only a Mek is returned by either side of a filter on one. + /// + /// [`Shape::from_config`] rather than a match on the string, because the + /// two configurations that do not say their own shape are the ones worth + /// getting right: a QuadVee stands on four legs and a LAM on two. + pub fn shape(&self) -> Option { + self.config + .as_deref() + .map(|config| Shape::from_config(Some(config))) + } + + /// Whether this design stands on the given shape's legs, where it is a + /// design the question applies to at all. + fn is_shape(&self, shape: Shape) -> Option { + self.shape().map(|held| held == shape) + } } /// A filter over units. @@ -297,6 +318,16 @@ pub struct FacetQuery { pub invalid: Tri, pub omni: Tri, pub clan: Tri, + /// Whether the design stands on two legs. A LAM does. + /// + /// The same question `configs` asks by name, as a yes or a no, because + /// that is what a form's control offers and what "not a quad" needs: + /// an any-of list of names cannot exclude one. + pub biped: Tri, + /// Whether the design stands on four legs. A QuadVee does. + pub quad: Tri, + /// Whether the design stands on three. + pub tripod: Tri, pub equipment: Option, } @@ -396,6 +427,13 @@ impl FacetQuery { return false; } + if !self.biped.matches(u.is_shape(Shape::Biped)) + || !self.quad.matches(u.is_shape(Shape::Quad)) + || !self.tripod.matches(u.is_shape(Shape::Tripod)) + { + return false; + } + if let Some(expr) = &self.equipment && !expr.matches(&u.loadout) { @@ -623,6 +661,51 @@ mod tests { assert!(!has_one.matches(&u)); } + // The two configurations that do not say their own shape are the ones + // worth pinning: a QuadVee stands on four legs and a LAM on two, which is + // MegaMek's own reading and not what the spelling suggests. + #[test] + fn legs_are_a_yes_or_a_no_and_only_a_mek_is_asked() { + let shaped = |config: Option<&str>| UnitFacets { + config: config.map(str::to_string), + ..Default::default() + }; + let asking = |q: FacetQuery, config: Option<&str>| q.matches(&shaped(config)); + + let quads = FacetQuery { + quad: Tri::Yes, + ..Default::default() + }; + assert!(asking(quads.clone(), Some("Quad"))); + assert!(asking(quads.clone(), Some("QuadVee"))); + assert!(!asking(quads.clone(), Some("Biped"))); + + let bipeds = FacetQuery { + biped: Tri::Yes, + ..Default::default() + }; + assert!(asking(bipeds.clone(), Some("LAM"))); + assert!(asking(bipeds.clone(), Some("Biped FrankenMek"))); + assert!(!asking(bipeds.clone(), Some("Tripod"))); + + let tripods = FacetQuery { + tripod: Tri::Yes, + ..Default::default() + }; + assert!(asking(tripods, Some("Tripod"))); + + // A design with no configuration answers neither way, the rule every + // other tri-state filter follows. + let not_a_quad = FacetQuery { + quad: Tri::No, + ..Default::default() + }; + assert!(asking(not_a_quad.clone(), Some("Biped"))); + assert!(!asking(not_a_quad, None)); + assert!(!asking(quads, None)); + assert!(!asking(bipeds, None)); + } + #[test] fn mul_id_is_an_exact_match() { let mut u = facets("Atlas AS7-D"); diff --git a/crates/helm-query/src/lib.rs b/crates/helm-query/src/lib.rs index 8777a3e..4ba5c97 100644 --- a/crates/helm-query/src/lib.rs +++ b/crates/helm-query/src/lib.rs @@ -124,6 +124,18 @@ pub struct Filter { pub symmetrical: Option, /// True for OmniMeks only, false to exclude them. pub omni: Option, + /// True for designs that stand on two legs, false to exclude them. A LAM + /// is one. + /// + /// The same question `config` asks by name, as a yes or a no. Neither + /// answer returns anything that is not a Mek: nothing else has a + /// configuration, so the question does not apply to it. + pub biped: Option, + /// True for designs that stand on four legs, false to exclude them. A + /// QuadVee is one. + pub quad: Option, + /// True for designs that stand on three legs, false to exclude them. + pub tripod: Option, /// Guns the design must carry, by any name MegaMek answers to. /// /// `"large laser"`, `"particle"`, `"ISERPPC"` - every word has to appear @@ -153,7 +165,8 @@ pub struct Filter { /// say "nothing experimental". pub rules_level: Option>, /// Any of: Biped, Quad, Tripod. OmniMeks are included under their base - /// config; ask for those with `omni`. + /// config; ask for those with `omni`. `biped`, `quad` and `tripod` ask + /// the same question as a yes or a no. pub config: Option>, /// Purchase price in C-bills. A different question from battle value, /// which is combat capability - say which one a budget means. @@ -393,6 +406,17 @@ impl Vocabulary { } } +/// A wire filter's optional yes or no, as the predicate's tri-state. +/// +/// Absent is no opinion, which is not the same as either answer. +fn tri(asked: Option) -> Tri { + match asked { + Some(true) => Tri::Yes, + Some(false) => Tri::No, + None => Tri::Any, + } +} + impl Filter { /// Whether this filter names equipment the way a person does, and so /// needs the names document to resolve it. @@ -556,16 +580,8 @@ impl Filter { heat_efficiency: Range::new(self.heat_efficiency_min, self.heat_efficiency_max), ammo_dependence: Range::new(self.ammo_dependence_min, self.ammo_dependence_max), one_shot: Range::new(self.one_shot_min, self.one_shot_max), - melee: match self.melee { - Some(true) => Tri::Yes, - Some(false) => Tri::No, - None => Tri::Any, - }, - symmetrical: match self.symmetrical { - Some(true) => Tri::Yes, - Some(false) => Tri::No, - None => Tri::Any, - }, + melee: tri(self.melee), + symmetrical: tri(self.symmetrical), 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), @@ -575,12 +591,11 @@ impl Filter { mul_id: self.mul_id, canon: Tri::Any, invalid: Tri::Any, - omni: match self.omni { - Some(true) => Tri::Yes, - Some(false) => Tri::No, - None => Tri::Any, - }, + omni: tri(self.omni), clan: Tri::Any, + biped: tri(self.biped), + quad: tri(self.quad), + tripod: tri(self.tripod), equipment: if equipment.is_empty() { None } else { @@ -833,6 +848,62 @@ mod tests { ); } + /// The legs a design stands on, as a yes or a no. + /// + /// `config` already answers this by name, and an any-of list of names + /// cannot say "not a quad" - which is what a control offering yes and no + /// has to be able to mean. + #[test] + fn legs_are_asked_of_meks_and_read_off_the_spine() { + let unit = |config: &str| UnitFacets { + config: Some(config.into()), + ..Default::default() + }; + let query = |filter: Filter| filter.to_query(&Vocabulary::unchecked()).expect("valid"); + + let quads = query(Filter { + quad: Some(true), + ..Default::default() + }); + assert!(quads.matches(&unit("Quad"))); + // MegaMek's own reading: a QuadVee stands on four legs and a LAM on + // two, neither of which the spelling gives away. + assert!(quads.matches(&unit("QuadVee"))); + assert!(!quads.matches(&unit("Biped"))); + assert!( + query(Filter { + biped: Some(true), + ..Default::default() + }) + .matches(&unit("LAM")) + ); + assert!( + query(Filter { + tripod: Some(true), + ..Default::default() + }) + .matches(&unit("Tripod OmniMek")) + ); + + // A design with no configuration is returned by neither answer. + let not_a_quad = query(Filter { + quad: Some(false), + ..Default::default() + }); + assert!(not_a_quad.matches(&unit("Biped"))); + assert!(!not_a_quad.matches(&UnitFacets::default())); + + // Config is a spine column, so a page needs no chunk to ask this. + assert_eq!( + Filter { + quad: Some(true), + ..Default::default() + } + .needs(), + vec![] + ); + } + /// Every chunk a filter can read is asked for by some term, and an empty /// filter asks for none. A term whose chunk is not named here filters on a /// column the browser may never have fetched. diff --git a/crates/helm-wasm/filters.json b/crates/helm-wasm/filters.json index 20bed68..4df8928 100644 --- a/crates/helm-wasm/filters.json +++ b/crates/helm-wasm/filters.json @@ -53,6 +53,20 @@ "Quad" ] }, + { + "quad": true + }, + { + "quad": false, + "tripod": false + }, + { + "biped": true, + "omni": true + }, + { + "tripod": true + }, { "source": [ "TR:3039" -- 2.51.2