//! Reads the JSON Lines the Java bridge produces into [`helm_core`] types. //! //! This crate exists for one reason: battle value, C-bill cost and the Alpha //! Strike conversion are computed when MegaMek loads a design, and appear in //! no `.mtf` or `.blk` file. Until those calculations are reimplemented, the //! only way to have them without reimplementing the construction rules is to //! ask MegaMek — which is what `bridge/dump.sh` does. //! //! It is meant to be temporary, and the shape of the workspace says so. This //! is a *producer* of [`ComputedStats`] and [`Catalogue`], sitting beside a //! future `helm-bv` that produces the same types by computing them. Nothing //! depends on this crate except the CLI that chooses a producer, so retiring //! it is deleting a dependency rather than unpicking a design. //! //! The reader is deliberately tolerant: it takes fields by name out of a //! generic JSON object and ignores the rest, so the dumper can keep emitting //! every field MegaMek has while this side takes only what is modelled. use std::collections::{BTreeMap, BTreeSet}; use std::path::Path; use helm_core::{AlphaStrike, BvBreakdown, Catalogue, ComputedStats, EquipmentEntry}; use helm_force::{Blueprint, Grouping, Requirement, Rule}; use serde_json::Value; #[derive(Debug)] pub enum Error { Io(std::io::Error), Json { line: usize, source: serde_json::Error, }, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::Io(e) => write!(f, "{e}"), Error::Json { line, source } => write!(f, "line {line}: {source}"), } } } impl std::error::Error for Error {} impl From for Error { fn from(e: std::io::Error) -> Self { Error::Io(e) } } /// Read `units.jsonl` into one [`ComputedStats`] per unit. /// /// A record with no name is skipped rather than kept: the name is the only key /// back to a parsed unit, so a nameless record can never be joined to anything. pub fn read_units(path: &Path) -> Result, Error> { let mut out = Vec::new(); for (_, v) in objects(path)? { if let Some(stats) = unit_record(&v) { out.push(stats); } } Ok(out) } /// Read `bv.jsonl` into one [`BvBreakdown`] per unit. /// /// This file is not data helm needs; it is the oracle `helm-bv` is measured /// against, recorded from MegaMek's own calculator after it ran. It exists for /// exactly as long as the reimplementation is unfinished. pub fn read_bv(path: &Path) -> Result, Error> { let mut out = Vec::new(); for (_, v) in objects(path)? { let Some(name) = v.get("name").and_then(Value::as_str) else { continue; }; let mut b = BvBreakdown::new(name); b.battle_value = v.get("bv").and_then(Value::as_i64); b.defensive = v.get("defensiveValue").and_then(Value::as_f64); b.offensive = v.get("offensiveValue").and_then(Value::as_f64); b.run_mp = v.get("runMP").and_then(Value::as_i64); b.jump_mp = v.get("jumpMP").and_then(Value::as_i64); out.push(b); } Ok(out) } /// One row of `damaged.jsonl`: a design, a way of shooting it, and what /// MegaMek made of the result. /// /// A `.mtf` describes a design as it leaves the factory and nothing else, so /// what a Mek is worth once its plate is gone or its magazines are empty has /// to be asked of MegaMek directly. The state is recorded the way helm names /// it - armour and structure by location code, wrecked slots and empty bins by /// location and index - so it drops straight into a `Condition`. #[derive(Debug, Clone, PartialEq)] pub struct Damaged { pub name: String, /// What was done to it: `stripped`, `hip-gone`, `dry`. pub scenario: String, pub battle_value: i64, pub defensive: Option, pub offensive: Option, pub armor: BTreeMap, pub structure: BTreeMap, pub destroyed: BTreeMap>, pub empty_ammo: BTreeMap>, } /// Read `damaged.jsonl`, the oracle for designs that have been shot at. pub fn read_damaged(path: &Path) -> Result, Error> { let mut out = Vec::new(); for (_, v) in objects(path)? { let (Some(name), Some(scenario), Some(bv)) = ( v.get("name").and_then(Value::as_str), v.get("scenario").and_then(Value::as_str), v.get("bv").and_then(Value::as_i64), ) else { continue; }; let points = |field: &str| -> BTreeMap { v.get(field) .and_then(Value::as_object) .map(|o| { o.iter() .filter_map(|(k, n)| Some((k.clone(), n.as_i64()?))) .collect() }) .unwrap_or_default() }; let slots = |field: &str| -> BTreeMap> { let mut map: BTreeMap> = BTreeMap::new(); for pair in v .get(field) .and_then(Value::as_array) .unwrap_or(&Vec::new()) { let (Some(location), Some(at)) = ( pair.get(0).and_then(Value::as_str), pair.get(1).and_then(Value::as_u64), ) else { continue; }; map.entry(location.to_string()) .or_default() .insert(at as usize); } map }; out.push(Damaged { name: name.to_string(), scenario: scenario.to_string(), battle_value: bv, defensive: v.get("defensiveValue").and_then(Value::as_f64), offensive: v.get("offensiveValue").and_then(Value::as_f64), armor: points("armor"), structure: points("structure"), destroyed: slots("destroyed"), empty_ammo: slots("emptyAmmo"), }); } Ok(out) } /// The fields [`read_catalogue`] looks at, and so the only ones worth /// shipping. /// /// `helm catalogue` keeps these and drops the rest, which takes the dump from /// 6.0 MB to 1.7 MB and is what makes it small enough for a browser. It lives /// beside the reader because the reader is what makes the list true: a rule /// that starts reading a new field has to be added here, and helm-bv's /// conformance test scores the whole library from both catalogues to notice /// when it has not been. pub const CATALOGUE_FIELDS: &[&str] = &[ "_class", "_classes", "_flags", "_internalName", "_name", "ammoType", "baseDamageCapacity", "bv", "cost", "criticalSlots", "damage", "damageLong", "damageMedium", "damagePerShot", "damageShort", "explosive", "heat", "longRange", "mediumRange", "minimumRange", "rackSize", "shortAV", "shortRange", "tonnage", ]; /// Read `formations.jsonl`, the Campaign Operations formation blueprints. /// /// The requirements themselves do not cross the bridge - each is a Java lambda /// with a label beside it, and only the label survives - so this reads the /// labels back into rules with [`helm_force::Rule::parse`]. A label it cannot /// read becomes an unreadable rule rather than a guess, and a force carrying /// one gets no verdict rather than a wrong one. pub fn read_formations(path: &Path) -> Result, Error> { parse_formations(&std::fs::read_to_string(path)?) } /// The same, from the text rather than the file. pub fn parse_formations(text: &str) -> Result, Error> { let mut out = Vec::new(); for (_, v) in parse_objects(text)? { let Some(name) = s(&v, "name") else { continue; }; let main_description = s(&v, "mainDescription").unwrap_or_default(); out.push(Blueprint { name, category: s(&v, "category").unwrap_or_default(), // `Undetermined` is upstream's way of writing "no ideal role", and // carrying it through as a role would qualify any force whose // units all happen to lack one. ideal_role: s(&v, "idealRole").filter(|r| r != "Undetermined" && r != "None"), min_weight_class: i(&v, "minWeightClass").unwrap_or(0), max_weight_class: i(&v, "maxWeightClass").unwrap_or(i64::MAX), main: Rule::parse(&main_description), main_description, unit_types: strings(&v, "unitTypes"), criteria: v .get("criteria") .and_then(Value::as_array) .map(|a| a.iter().filter_map(requirement).collect()) .unwrap_or_default(), grouping: v.get("grouping").and_then(grouping), }); } Ok(out) } fn requirement(v: &Value) -> Option { let description = s(v, "description")?; Some(Requirement { rule: Rule::parse(&description), description, minimums: v .get("minimums") .and_then(Value::as_array)? .iter() .filter_map(Value::as_i64) .collect(), paired_with_next: b(v, "pairedWithNext").unwrap_or(false), paired_with_previous: b(v, "pairedWithPrevious").unwrap_or(false), }) } fn grouping(v: &Value) -> Option { Some(Grouping { description: s(v, "description")?, unit_types: strings(v, "unitTypes"), group_size: i(v, "groupSize")?, num_groups: i(v, "numGroups")?, }) } fn strings(v: &Value, key: &str) -> Vec { v.get(key) .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }) .unwrap_or_default() } /// Read `equipment.jsonl` into the equipment catalogue. pub fn read_catalogue(path: &Path) -> Result { parse_catalogue(&std::fs::read_to_string(path)?) } /// The same, from the text rather than the file. /// /// Split out because the browser gets the catalogue over the network and has /// no file to read: this is the half of the crate that has to reach wasm. pub fn parse_catalogue(text: &str) -> Result { let mut entries = Vec::new(); for (_, v) in parse_objects(text)? { if let Some(e) = equipment_record(&v) { entries.push(e); } } Ok(Catalogue::new(entries)) } fn objects(path: &Path) -> Result, Error> { parse_objects(&std::fs::read_to_string(path)?) } fn parse_objects(text: &str) -> Result, Error> { let mut out = Vec::new(); for (i, line) in text.lines().enumerate() { if line.trim().is_empty() { continue; } let value: Value = serde_json::from_str(line).map_err(|source| Error::Json { line: i + 1, source, })?; out.push((i + 1, value)); } Ok(out) } fn s(v: &Value, key: &str) -> Option { v.get(key)?.as_str().map(str::to_string) } fn i(v: &Value, key: &str) -> Option { v.get(key)?.as_i64() } fn f(v: &Value, key: &str) -> Option { v.get(key)?.as_f64() } fn b(v: &Value, key: &str) -> Option { v.get(key)?.as_bool() } fn unit_record(v: &Value) -> Option { let name = s(v, "name")?; Some(ComputedStats { name, battle_value: i(v, "bv"), cost: i(v, "cost"), tech_level: s(v, "techLevel"), weight_class: i(v, "weightClass"), canon: b(v, "canon"), invalid: b(v, "invalid"), omni: b(v, "omni"), clan: b(v, "clan"), walk_mp: i(v, "walkMp"), run_mp: i(v, "runMp"), jump_mp: i(v, "jumpMp"), alpha_strike: AlphaStrike { point_value: i(v, "pointValue"), unit_type: s(v, "asUnitType"), size: i(v, "size"), tmm: i(v, "tmm"), damage: s(v, "standardDamage"), specials: s(v, "specialAbilities"), }, }) } fn equipment_record(v: &Value) -> Option { let internal_name = s(v, "_internalName")?; let name = s(v, "_name").unwrap_or_else(|| internal_name.clone()); Some(EquipmentEntry { internal_name, name, kind: s(v, "_class"), heat: i(v, "heat"), damage: i(v, "damage"), average_damage: f(v, "shortAV").filter(|d| *d > 0.0), min_range: i(v, "minimumRange"), short_range: i(v, "shortRange"), medium_range: i(v, "mediumRange"), long_range: i(v, "longRange"), damage_short: i(v, "damageShort"), damage_medium: i(v, "damageMedium"), damage_long: i(v, "damageLong"), // "NA" is MegaMek's spelling of "needs no ammunition". ammo_type: s(v, "ammoType").filter(|a| a != "NA"), rack_size: i(v, "rackSize").filter(|r| *r > 0), damage_per_shot: i(v, "damagePerShot"), tonnage: f(v, "tonnage"), // EquipmentType calls this criticalSlots, not criticals. Reading the // wrong key silently left every entry's slot count null. criticals: i(v, "criticalSlots"), // MegaMek uses Integer.MIN_VALUE to mean "this item's value depends on // the design carrying it, so work it out there". Taking it at face // value makes a hatchet worth minus two billion points. battle_value: f(v, "bv").filter(|b| *b > i32::MIN as f64), variable_battle_value: f(v, "bv").is_some_and(|b| b <= i32::MIN as f64), cost: f(v, "cost"), damage_capacity: i(v, "baseDamageCapacity").filter(|c| *c > 0), flags: v .get("_flags") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }) .unwrap_or_default(), explosive: v.get("explosive").and_then(Value::as_bool).unwrap_or(false), classes: v .get("_classes") .and_then(Value::as_array) .map(|a| { a.iter() .filter_map(Value::as_str) .map(str::to_string) .collect() }) .unwrap_or_default(), sub_type: v.get("subType").and_then(Value::as_i64), }) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn reads_a_unit_record() { let v = json!({ "name": "Atlas AS7-D", "bv": 1897, "cost": 9626000, "techLevel": "Introductory", "weightClass": 4, "canon": true, "invalid": false, "runMp": 5, "pointValue": 52, "asUnitType": "BM", "size": 4, "tmm": 1, "standardDamage": "5/5/2" }); let s = unit_record(&v).unwrap(); assert_eq!(s.name, "Atlas AS7-D"); assert_eq!(s.battle_value, Some(1897)); assert_eq!(s.cost, Some(9_626_000)); assert_eq!(s.alpha_strike.point_value, Some(52)); assert_eq!(s.alpha_strike.damage.as_deref(), Some("5/5/2")); // Upstream throws formatting these for some units, so absent is normal. assert_eq!(s.alpha_strike.specials, None); } #[test] fn a_record_without_a_name_cannot_be_joined_and_is_dropped() { assert!(unit_record(&json!({ "bv": 1897 })).is_none()); } #[test] fn reads_an_equipment_record() { let v = json!({ "_internalName": "ISGaussRifle", "_name": "Gauss Rifle", "_class": "ISGaussRifle", "heat": 1, "damage": 15, "minimumRange": 2, "shortRange": 7, "mediumRange": 15, "longRange": 22, "tonnage": 15.0, "bv": 320.0 }); let e = equipment_record(&v).unwrap(); assert_eq!(e.name, "Gauss Rifle"); assert_eq!(e.damage, Some(15)); assert_eq!(e.long_range, Some(22)); assert!(!e.is_ammo()); } }