diff --git a/CLAUDE.md b/CLAUDE.md index 1ea10ef..c3f05c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,8 +61,9 @@ and puts them in the assets bucket under `//`, then writes `releases.helm` in infra. It applies nothing — the apply is infra's, and jmm runs it. -The index is a spine and six chunks - `art`, `figures`, `paperwork`, `combat`, -`quirks`, `loadout` - and `units.json` names them all in `chunks`. One command +The index is a spine and seven chunks - `art`, `figures`, `paperwork`, +`combat`, `curve`, `quirks`, `loadout` - and `units.json` names them all in +`chunks`. One command writes the whole set: a prefix holding a spine and half its columns would have a page filter on a library that quietly answers a narrower question. diff --git a/crates/helm-cli/src/build.rs b/crates/helm-cli/src/build.rs index 3f1d033..2e1047f 100644 --- a/crates/helm-cli/src/build.rs +++ b/crates/helm-cli/src/build.rs @@ -492,6 +492,9 @@ pub(crate) fn index(args: &[String]) -> Result<(), String> { &document.part::(), chunk.name(), ), + helm_query::Chunk::Curve => { + write_json(&beside, &document.part::(), chunk.name()) + } helm_query::Chunk::Quirks => write_json( &beside, &document.part::(), @@ -548,9 +551,17 @@ fn answer(document: &helm_query::Index, corpus: &Path) -> Result let query = filter .to_query(&vocabulary) .map_err(|e| format!("{value}: {e}"))?; - let hits: Vec = (0..units.len()) + let mut hits: Vec = (0..units.len()) .filter(|&i| query.matches(units[i])) .collect(); + if let Some(band) = filter.band().map_err(|e| format!("{value}: {e}"))? { + hits.sort_by_key(|&i| { + ( + std::cmp::Reverse(helm_query::Filter::hardest(band, units[i])), + i, + ) + }); + } out.push(serde_json::json!({ "filter": value, "units": hits })); } serde_json::to_string(&out).map_err(|e| e.to_string()) diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 7ff67c9..2cb7021 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -40,11 +40,11 @@ pub use design::{ StructureKind, }; pub use metrics::{ - CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, can_make_anti_mek_attacks, - can_ride_a_mek, count_carrying, damage_at_range, equipment_list, infantry_armor_is_encumbering, - infantry_can_make_anti_mek_attacks, infantry_can_swarm, infantry_is_mechanized, - is_conventional_infantry, jumping_mp, max_single_damage_at_range, mounted_items, one_shot_ammo, - platoon_weapons, slot_size, walking_mp, weapon_bays, + 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, + infantry_armor_is_encumbering, infantry_can_make_anti_mek_attacks, infantry_can_swarm, + infantry_is_mechanized, is_conventional_infantry, jumping_mp, max_single_damage_at_range, + mounted_items, one_shot_ammo, platoon_weapons, slot_size, walking_mp, weapon_bays, }; pub use rng::Rng; pub use structure::{Shape, max_armor, structure_per_location, structure_total_in}; diff --git a/crates/helm-core/src/metrics.rs b/crates/helm-core/src/metrics.rs index 6292464..a16127b 100644 --- a/crates/helm-core/src/metrics.rs +++ b/crates/helm-core/src/metrics.rs @@ -172,11 +172,30 @@ pub fn damage_at_range(unit: &Unit, catalogue: &Catalogue, range: i64) -> i64 { .max(0) } +/// What a design shoots with, and what it has to feed them. +/// +/// Worked out once and asked about many times: reading the loadout is most of +/// what a range question costs, and a curve asks two dozen of them. +struct Guns<'a> { + catalogue: &'a Catalogue, + loadout: Vec<(&'a EquipmentEntry, i64)>, + ammo_aboard: Vec<&'a EquipmentEntry>, +} + /// One entry per mount that reaches `range`, carrying that mount's damage. /// Shared so the total and the single-weapon maximum cannot drift apart. fn per_weapon_damage_at_range(unit: &Unit, catalogue: &Catalogue, range: i64) -> Vec { + guns(unit, catalogue).at(range) +} + +/// Read the design's weapons and its magazines, once. +fn guns<'a>(unit: &Unit, catalogue: &'a Catalogue) -> Guns<'a> { if catalogue.is_empty() { - return Vec::new(); + return Guns { + catalogue, + loadout: Vec::new(), + ammo_aboard: Vec::new(), + }; } // Read from the critical slots, which name a weapon the way MegaMek looks // it up rather than the way a person writes it - see `mounted_items`. @@ -205,46 +224,82 @@ fn per_weapon_damage_at_range(unit: &Unit, catalogue: &Catalogue, range: i64) -> .filter(|e| e.is_ammo()) .collect(); - let mut out = Vec::new(); - for (weapon, qty) in &loadout { - // Weapons only, by MegaMek's own test rather than by whether the - // catalogue holds an average for them. - if !weapon.is_weapon() || weapon.is_ammo() { - continue; - } - if weapon.long_range.unwrap_or(0) < range { - continue; - } + Guns { + catalogue, + loadout, + ammo_aboard, + } +} - let damage = match &weapon.ammo_type { - Some(ammo_type) => { - let matches = |e: &EquipmentEntry| { - e.ammo_type.as_deref() == Some(ammo_type.as_str()) - && e.rack_size == weapon.rack_size - }; - // A one-shot launcher arrives loaded. Its rounds are part of - // the weapon rather than a bin in a critical slot, so MegaMek - // makes the ammunition itself while it reads the design and - // finds it in the list a moment later - and helm, reading the - // file, finds nothing and scores a Firestarter FS9-C's six - // rocket launchers at zero. - let aboard = ammo_aboard.iter().copied().find(|e| matches(e)); - aboard - .or_else(|| one_shot_ammo(weapon, catalogue)) - .map_or(0, |a| { - a.damage_per_shot.unwrap_or(0) * a.rack_size.unwrap_or(1).max(1) - }) +impl Guns<'_> { + /// One entry per mount that reaches `range`, carrying its damage. + fn at(&self, range: i64) -> Vec { + let (loadout, ammo_aboard, catalogue) = (&self.loadout, &self.ammo_aboard, self.catalogue); + let mut out = Vec::new(); + for (weapon, qty) in loadout { + // Weapons only, by MegaMek's own test rather than by whether the + // catalogue holds an average for them. + if !weapon.is_weapon() || weapon.is_ammo() { + continue; + } + if weapon.long_range.unwrap_or(0) < range { + continue; } - None => weapon.damage_at(range), - }; - if damage > 0 { - for _ in 0..*qty { - out.push(damage); + let damage = match &weapon.ammo_type { + Some(ammo_type) => { + let matches = |e: &EquipmentEntry| { + e.ammo_type.as_deref() == Some(ammo_type.as_str()) + && e.rack_size == weapon.rack_size + }; + // A one-shot launcher arrives loaded. Its rounds are part of + // the weapon rather than a bin in a critical slot, so MegaMek + // makes the ammunition itself while it reads the design and + // finds it in the list a moment later - and helm, reading the + // file, finds nothing and scores a Firestarter FS9-C's six + // rocket launchers at zero. + let aboard = ammo_aboard.iter().copied().find(|e| matches(e)); + aboard + .or_else(|| one_shot_ammo(weapon, catalogue)) + .map_or(0, |a| { + a.damage_per_shot.unwrap_or(0) * a.rack_size.unwrap_or(1).max(1) + }) + } + None => weapon.damage_at(range), + }; + + if damage > 0 { + for _ in 0..*qty { + out.push(damage); + } } } + out } - out +} + +/// How far out a curve is drawn, in hexes. +/// +/// A PPC's extreme range, and about where a ground battle is fought. Fifty of +/// MegaMek's 1,071 weapons reach further and every one of them is artillery or +/// a capital missile - a Cruise Missile/120 reaches 150 - which is not what +/// "what hits hardest at this range" is asking about. +pub const REACH: i64 = 24; + +/// What a design does at each hex from 1 to [`REACH`]. +/// +/// 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. The curve is the whole answer, and a caller +/// picks the range band it cares about out of it. +/// +/// Maximum damage, the same figure [`damage_at_range`] gives, so a total read +/// off the curve and one asked for directly cannot disagree. +pub fn damage_curve(unit: &Unit, catalogue: &Catalogue) -> Vec { + let guns = guns(unit, catalogue); + (1..=REACH) + .map(|range| guns.at(range).into_iter().sum::().max(0)) + .collect() } /// The hardest single hit a design can land at a given range. @@ -1331,6 +1386,40 @@ mod reading_the_catalogue { } } + /// A curve read off the design agrees with the same range asked for + /// directly. Two ways to the same number is two chances to differ. + #[test] + fn the_curve_is_the_same_answer_at_every_hex() { + let catalogue = Catalogue::new(vec![ + weapon("ISMediumLaser", 5, 9), + weapon("ISLRM20", 20, 21), + ]); + // Read from the critical slots, the way a range question is. + let mut unit = mek("Inner Sphere", &[]); + unit.criticals.insert( + "Right Torso".into(), + vec!["ISMediumLaser".into(), "ISLRM20".into()], + ); + + let curve = damage_curve(&unit, &catalogue); + assert_eq!(curve.len() as i64, REACH); + for (at, damage) in curve.iter().enumerate() { + let range = at as i64 + 1; + assert_eq!( + *damage, + damage_at_range(&unit, &catalogue, range), + "at {range} hexes" + ); + } + + // The laser reaches nine hexes and the launcher twenty-one, which is + // the shape the whole idea rests on: where a design does its damage + // is a curve and not a number. + assert_eq!(curve[0], 25, "{curve:?}"); + assert_eq!(curve[20], 20, "{curve:?}"); + assert_eq!(curve[21], 0, "{curve:?}"); + } + #[test] fn a_clan_design_carries_the_clan_version_of_a_weapon_it_names() { let catalogue = Catalogue::new(vec![ diff --git a/crates/helm-db/src/lib.rs b/crates/helm-db/src/lib.rs index 5f72925..5bcd304 100644 --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -25,6 +25,8 @@ pub struct BuildStats { /// Rows in `unit_loadout`: what a filter sees, which is not what the files /// declare. pub loadout_rows: usize, + /// Rows in `unit_curve`: one per hex a design does damage at. + pub curve_rows: usize, pub quirk_rows: usize, pub critical_rows: usize, pub field_rows: usize, @@ -61,6 +63,7 @@ pub fn build( units: 0, equipment_rows: 0, loadout_rows: 0, + curve_rows: 0, quirk_rows: 0, critical_rows: 0, field_rows: 0, @@ -136,6 +139,8 @@ pub fn build( rear, is_ammo) VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", )?; + let mut curve_stmt = + tx.prepare("INSERT INTO unit_curve (unit_id, hex, damage) VALUES (?1,?2,?3)")?; let mut loadout_stmt = tx.prepare( "INSERT INTO unit_loadout (unit_id, canonical_name, quantity) VALUES (?1,?2,?3)", )?; @@ -251,6 +256,17 @@ pub fn build( ])?; stats.equipment_rows += 1; } + // 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() + { + if damage > 0 { + curve_stmt.execute(params![uid, at as i64 + 1, damage])?; + stats.curve_rows += 1; + } + } for (entry, count) in helm_core::equipment_list(u, catalogue) { loadout_stmt.execute(params![uid, entry.internal_name, count])?; stats.loadout_rows += 1; @@ -394,6 +410,18 @@ fn schema(db: &Connection) -> rusqlite::Result<()> { quantity INTEGER NOT NULL ); + -- What a design does at each hex, 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 + -- over a BETWEEN. There is no honest set of bands to store instead - + -- short, medium and long are per weapon. + CREATE TABLE unit_curve ( + unit_id INTEGER NOT NULL REFERENCES units(unit_id), + hex INTEGER NOT NULL, + damage INTEGER NOT NULL + ); + CREATE TABLE unit_quirks ( unit_id INTEGER NOT NULL REFERENCES units(unit_id), quirk TEXT NOT NULL @@ -466,6 +494,7 @@ fn indexes(db: &Connection) -> rusqlite::Result<()> { CREATE INDEX idx_units_max_range ON units(max_range); CREATE INDEX idx_units_heat_efficiency ON units(heat_efficiency); CREATE INDEX idx_units_ammo_dependence ON units(ammo_dependence); + CREATE INDEX idx_unit_curve_hex ON unit_curve(hex, damage); 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); @@ -553,6 +582,7 @@ fn meta( ("units_with_stats", stats.with_stats.to_string()), ("equipment_rows", stats.equipment_rows.to_string()), ("loadout_rows", stats.loadout_rows.to_string()), + ("curve_rows", stats.curve_rows.to_string()), ("catalogue_rows", stats.catalogue_rows.to_string()), ("parse_failures", library.failures.len().to_string()), ("fts5", if stats.fts { "yes" } else { "no" }.to_string()), diff --git a/crates/helm-facet/src/query.rs b/crates/helm-facet/src/query.rs index 69b88ab..3ab1766 100644 --- a/crates/helm-facet/src/query.rs +++ b/crates/helm-facet/src/query.rs @@ -34,6 +34,14 @@ 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`. + /// + /// 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. + pub curve: Vec, /// `(equipment name, quantity)`, the shape MegaMek evaluates against. /// /// [`helm_core::equipment_list`] where there is a catalogue: MegaMek's @@ -188,6 +196,7 @@ 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)), 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 ce8ef89..75d695c 100644 --- a/crates/helm-mcp/src/library.rs +++ b/crates/helm-mcp/src/library.rs @@ -120,6 +120,7 @@ impl Library { omni: r.get::<_, Option>(16)?.map(|v| v != 0), clan: r.get::<_, Option>(17)?.map(|v| v != 0), loadout: Vec::new(), + curve: Vec::new(), firepower: r.get(18)?, max_range: r.get(19)?, heat: r.get(20)?, @@ -185,6 +186,35 @@ impl Library { } } + // The damage curve in one pass, the same way. Sorted by hex so a + // caller reading the vector by index reads hex 1 first. + { + let mut stmt = db + .prepare("SELECT unit_id, hex, damage FROM unit_curve ORDER BY unit_id, hex") + .map_err(|e| format!("querying curves: {e}"))?; + let rows = stmt + .query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) + }) + .map_err(|e| format!("reading curves: {e}"))?; + for row in rows { + let (id, hex, damage) = row.map_err(|e| format!("row: {e}"))?; + if let Some(&i) = by_id.get(&id) { + let curve = &mut facets[i].curve; + // Absent hexes are nought: the table holds only the ones + // a design does damage at. + curve.resize(helm_core::REACH as usize, 0); + if let Some(at) = curve.get_mut((hex - 1).max(0) as usize) { + *at = damage; + } + } + } + } + // Quirks in one pass rather than a query per unit. let mut quirk_counts: HashMap = HashMap::new(); { diff --git a/crates/helm-query/src/facets.rs b/crates/helm-query/src/facets.rs index 718548b..ca9e61c 100644 --- a/crates/helm-query/src/facets.rs +++ b/crates/helm-query/src/facets.rs @@ -48,6 +48,9 @@ pub enum Chunk { Paperwork, /// How it fights: movement, firepower, reach, heat, plate. Combat, + /// What it does at each hex, which is the only honest way to say where a + /// design's damage is: short, medium and long are per weapon. + Curve, /// MegaMek's per-design quirks. Quirks, /// What it carries, as MegaMek lists it. @@ -56,11 +59,12 @@ pub enum Chunk { impl Chunk { /// Every chunk, in the order one command writes them. - pub const ALL: [Chunk; 6] = [ + pub const ALL: [Chunk; 7] = [ Chunk::Art, Chunk::Figures, Chunk::Paperwork, Chunk::Combat, + Chunk::Curve, Chunk::Quirks, Chunk::Loadout, ]; @@ -72,6 +76,7 @@ impl Chunk { Chunk::Figures => "figures", Chunk::Paperwork => "paperwork", Chunk::Combat => "combat", + Chunk::Curve => "curve", Chunk::Quirks => "quirks", Chunk::Loadout => "loadout", } @@ -319,6 +324,23 @@ impl Columns for Combat { } } +/// What each design does at each hex from 1 to `helm_core::REACH`. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Curve(pub Vec); + +impl Columns for Curve { + const CHUNK: Chunk = Chunk::Curve; + + fn take(unit: &UnitFacets, _extra: &Extras) -> Self { + Curve(unit.curve.clone()) + } + + fn put(self, unit: &mut UnitFacets, _extra: &mut Extras) { + unit.curve = self.0; + } +} + /// MegaMek's per-design quirks. #[derive(Debug, Default, Serialize, Deserialize)] #[serde(transparent)] @@ -586,6 +608,7 @@ mod tests { heat_efficiency: Some(-8), ammo_dependence: Some(62), armor_pct: Some(93), + curve: vec![25; 24], quirks: vec!["battle_fists".into()], loadout: vec![("Autocannon/20".into(), 1), ("Medium Laser".into(), 4)], }, @@ -614,11 +637,12 @@ mod tests { #[test] fn every_column_survives_the_split() { let whole = index(vec![a_unit()]); - let (art, figures, paperwork, combat, quirks, loadout) = ( + let (art, figures, paperwork, combat, curve, quirks, loadout) = ( whole.part::(), whole.part::(), whole.part::(), whole.part::(), + whole.part::(), whole.part::(), whole.part::(), ); @@ -634,6 +658,7 @@ mod tests { serde_json::to_string(&figures).unwrap(), serde_json::to_string(&paperwork).unwrap(), serde_json::to_string(&combat).unwrap(), + serde_json::to_string(&curve).unwrap(), serde_json::to_string(&quirks).unwrap(), serde_json::to_string(&loadout).unwrap(), ] { @@ -645,6 +670,7 @@ mod tests { read.join(serde_json::from_str::>(&part).unwrap()) } Chunk::Combat => read.join(serde_json::from_str::>(&part).unwrap()), + Chunk::Curve => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Quirks => read.join(serde_json::from_str::>(&part).unwrap()), Chunk::Loadout => read.join(serde_json::from_str::>(&part).unwrap()), } diff --git a/crates/helm-query/src/lib.rs b/crates/helm-query/src/lib.rs index 361ad5a..62b43f6 100644 --- a/crates/helm-query/src/lib.rs +++ b/crates/helm-query/src/lib.rs @@ -20,8 +20,8 @@ mod facets; pub use build::Build; pub use facets::{ - Art, Chunk, Columns, Combat, Counts, Extras, Figures, Index, Loadout, Mismatch, Paperwork, - Part, Quirks, Spine, + Art, Chunk, Columns, Combat, Counts, Curve, Extras, Figures, Index, Loadout, Mismatch, + Paperwork, Part, Quirks, Spine, }; use std::collections::BTreeSet; @@ -128,6 +128,15 @@ pub struct Filter { pub carries_class: Option>, /// Equipment classes the unit must carry none of. pub lacks_class: Option>, + + /// Rank what was selected by what it does between two hexes, hardest + /// first. `[3, 9]` is "what hits hardest at brawling range". + /// + /// A band rather than a hex, because nobody wants to know what a design + /// does at exactly six. A sort rather than a filter, because the useful + /// question is which of these is hardest here, not which of them clear + /// some bar - and the bar would be different for every band. + pub hardest_between: Option>, } /// What a filter named that the library does not have. @@ -295,6 +304,9 @@ impl Filter { if some(&self.has_quirks) { needs.push(Chunk::Quirks); } + if self.hardest_between.is_some() { + needs.push(Chunk::Curve); + } if some(&self.tech_base) || some(&self.role) || some(&self.rules_level) @@ -429,6 +441,44 @@ impl Filter { } } +impl Filter { + /// The band this filter ranks by, checked. + /// + /// `None` when it ranks by nothing, which is most filters. + pub fn band(&self) -> Result, Invalid> { + let Some(band) = &self.hardest_between else { + return Ok(None); + }; + let bad = |got: String| Invalid::Unknown { + field: "hardest_between", + got, + valid: vec![format!("two hexes from 1 to {}", helm_core::REACH)], + }; + let [from, to] = band[..] else { + return Err(bad(format!("{band:?}"))); + }; + if from < 1 || to > helm_core::REACH || from > to { + return Err(bad(format!("{from} to {to}"))); + } + Ok(Some((from, to))) + } + + /// What a design does across the band, for ranking. + /// + /// The sum rather than the mean: every design is measured over the same + /// band, so the two rank identically and one of them needs no division. + /// Nought for a design with no curve loaded, which cannot happen once + /// `needs` has been honoured. + pub fn hardest(band: (i64, i64), unit: &UnitFacets) -> i64 { + let (from, to) = band; + unit.curve + .iter() + .skip((from - 1).max(0) as usize) + .take((to - from + 1).max(0) as usize) + .sum() + } +} + /// One equipment class by name, or what the names are. fn class(label: &str) -> Result { WeaponClass::parse(label).ok_or_else(|| Invalid::Unknown { @@ -542,12 +592,13 @@ mod tests { year_max: Some(3025), bv_max: Some(2000), firepower_min: Some(10), + hardest_between: Some(vec![3, 9]), ..Default::default() }; let mut asked: Vec<&str> = everything.needs().iter().map(|c| c.name()).collect(); asked.sort_unstable(); // Art is the exception, and the only one: it is a column a screen - // draws and no filter reads. + // draws and nothing a filter reads or ranks by. let mut filterable: Vec<&str> = Chunk::ALL .iter() .map(|c| c.name()) diff --git a/crates/helm-wasm/filters.json b/crates/helm-wasm/filters.json index 8828e4a..12d53a5 100644 --- a/crates/helm-wasm/filters.json +++ b/crates/helm-wasm/filters.json @@ -1,30 +1,145 @@ [ {}, - { "name": "atlas" }, - { "tech_base": ["Clan"] }, - { "weight_class": ["Assault"], "bv_max": 2000 }, - { "role": ["Scout"], "walk_mp_min": 6 }, - { "year_max": 3025 }, - { "year_min": 3050, "year_max": 3067 }, - { "tons_min": 20, "tons_max": 35, "jump_mp_min": 6 }, - { "bv_min": 1800, "bv_max": 1900 }, - { "cost_max": 3000000 }, - { "omni": true }, - { "omni": false, "rules_level": ["Standard"] }, - { "config": ["Quad"] }, - { "source": ["TR:3039"] }, - { "has_quirks": ["easy_maintain"] }, - { "mul_id": 87 }, - { "carries": ["ISGaussRifle"] }, - { "lacks": ["ISGaussRifle"], "bv_min": 2500 }, - { "carries_class": ["Energy", "Missile"] }, - { "lacks_class": ["Ballistic"], "tons_min": 90 }, - { "firepower_min": 40 }, - { "max_range_min": 21 }, - { "heat_efficiency_min": 0, "bv_min": 2000 }, - { "armor_pct_min": 95 }, - { "run_mp_min": 12, "tons_max": 30 }, - { "name": "wolf", "tech_base": ["Clan"], "bv_min": 2000, "carries_class": ["Missile"] }, - { "name": "no such design anywhere" }, - { "bv_min": 99999 } + { + "name": "atlas" + }, + { + "tech_base": [ + "Clan" + ] + }, + { + "weight_class": [ + "Assault" + ], + "bv_max": 2000 + }, + { + "role": [ + "Scout" + ], + "walk_mp_min": 6 + }, + { + "year_max": 3025 + }, + { + "year_min": 3050, + "year_max": 3067 + }, + { + "tons_min": 20, + "tons_max": 35, + "jump_mp_min": 6 + }, + { + "bv_min": 1800, + "bv_max": 1900 + }, + { + "cost_max": 3000000 + }, + { + "omni": true + }, + { + "omni": false, + "rules_level": [ + "Standard" + ] + }, + { + "config": [ + "Quad" + ] + }, + { + "source": [ + "TR:3039" + ] + }, + { + "has_quirks": [ + "easy_maintain" + ] + }, + { + "mul_id": 87 + }, + { + "carries": [ + "ISGaussRifle" + ] + }, + { + "lacks": [ + "ISGaussRifle" + ], + "bv_min": 2500 + }, + { + "carries_class": [ + "Energy", + "Missile" + ] + }, + { + "lacks_class": [ + "Ballistic" + ], + "tons_min": 90 + }, + { + "firepower_min": 40 + }, + { + "max_range_min": 21 + }, + { + "heat_efficiency_min": 0, + "bv_min": 2000 + }, + { + "armor_pct_min": 95 + }, + { + "run_mp_min": 12, + "tons_max": 30 + }, + { + "name": "wolf", + "tech_base": [ + "Clan" + ], + "bv_min": 2000, + "carries_class": [ + "Missile" + ] + }, + { + "name": "no such design anywhere" + }, + { + "bv_min": 99999 + }, + { + "hardest_between": [ + 1, + 3 + ], + "tons_max": 55 + }, + { + "hardest_between": [ + 3, + 9 + ], + "bv_max": 1600 + }, + { + "hardest_between": [ + 15, + 21 + ] + } ] diff --git a/crates/helm-wasm/src/lib.rs b/crates/helm-wasm/src/lib.rs index 0d577c1..2bc58f6 100644 --- a/crates/helm-wasm/src/lib.rs +++ b/crates/helm-wasm/src/lib.rs @@ -585,6 +585,7 @@ pub unsafe extern "C" fn helm_library_chunk(handle: i64, ptr: *const u8, len: us helm_query::Chunk::Figures => join_part::(held, json), helm_query::Chunk::Paperwork => join_part::(held, json), helm_query::Chunk::Combat => join_part::(held, json), + helm_query::Chunk::Curve => join_part::(held, json), helm_query::Chunk::Quirks => join_part::(held, json), helm_query::Chunk::Loadout => join_part::(held, json), }; @@ -649,10 +650,25 @@ pub unsafe extern "C" fn helm_library_select(handle: i64, ptr: *const u8, len: u Ok(query) => query, Err(invalid) => return give(refused(&invalid).to_string()), }; + let band = match filter.band() { + Ok(band) => band, + Err(invalid) => return give(refused(&invalid).to_string()), + }; let units = held.index.facets(); - let hits: Vec = (0..units.len()) + let mut hits: Vec = (0..units.len()) .filter(|&i| query.matches(units[i])) .collect(); + if let Some(band) = band { + // Hardest first, and the library's own order where two designs do + // the same damage - so the same question asked twice answers in + // the same order. + hits.sort_by_key(|&i| { + ( + std::cmp::Reverse(helm_query::Filter::hardest(band, units[i])), + i, + ) + }); + } give(serde_json::json!({ "units": hits }).to_string()) }) }