Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
16 kB · 459 lines
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460//! 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<std::io::Error> 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<Vec<ComputedStats>, 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<Vec<BvBreakdown>, 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<f64>, pub offensive: Option<f64>, pub armor: BTreeMap<String, i64>, pub structure: BTreeMap<String, i64>, pub destroyed: BTreeMap<String, BTreeSet<usize>>, pub empty_ammo: BTreeMap<String, BTreeSet<usize>>,}
/// Read `damaged.jsonl`, the oracle for designs that have been shot at.pub fn read_damaged(path: &Path) -> Result<Vec<Damaged>, 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<String, i64> { 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<String, BTreeSet<usize>> { let mut map: BTreeMap<String, BTreeSet<usize>> = 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<Vec<Blueprint>, 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<Vec<Blueprint>, 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<Requirement> { 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<Grouping> { 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<String> { 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<Catalogue, Error> { 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<Catalogue, Error> { 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<Vec<(usize, Value)>, Error> { parse_objects(&std::fs::read_to_string(path)?)}
fn parse_objects(text: &str) -> Result<Vec<(usize, Value)>, 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<String> { v.get(key)?.as_str().map(str::to_string)}
fn i(v: &Value, key: &str) -> Option<i64> { v.get(key)?.as_i64()}
fn f(v: &Value, key: &str) -> Option<f64> { v.get(key)?.as_f64()}
fn b(v: &Value, key: &str) -> Option<bool> { v.get(key)?.as_bool()}
fn unit_record(v: &Value) -> Option<ComputedStats> { 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<EquipmentEntry> { 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()); }}