diff --git a/Cargo.lock b/Cargo.lock index d68390d..f949bd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,28 +132,43 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "helm-bridge" +version = "0.1.0" +dependencies = [ + "helm-core", + "serde_json", +] + [[package]] name = "helm-cli" version = "0.1.0" dependencies = [ + "helm-bridge", + "helm-core", "helm-db", "helm-unitfile", "rusqlite", ] +[[package]] +name = "helm-core" +version = "0.1.0" + [[package]] name = "helm-db" version = "0.1.0" dependencies = [ + "helm-core", "helm-unitfile", "rusqlite", - "serde_json", ] [[package]] name = "helm-unitfile" version = "0.1.0" dependencies = [ + "helm-core", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 4cd1fee..62e2e69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,13 @@ # this repo, matching headquarters. [workspace] resolver = "3" -members = ["crates/helm-unitfile", "crates/helm-db", "crates/helm-cli"] +members = [ + "crates/helm-core", + "crates/helm-unitfile", + "crates/helm-bridge", + "crates/helm-db", + "crates/helm-cli", +] [workspace.package] version = "0.1.0" @@ -12,7 +18,9 @@ edition = "2024" publish = false [workspace.dependencies] +helm-core = { path = "crates/helm-core" } helm-unitfile = { path = "crates/helm-unitfile" } +helm-bridge = { path = "crates/helm-bridge" } helm-db = { path = "crates/helm-db" } # bundled: the build compiles its own libsqlite3, so no system package is diff --git a/README.md b/README.md index ebd3172..01dbeca 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,34 @@ and you get every declared column and null in the computed ones. Every computed column is a candidate for a native implementation that would retire a piece of it; that work belongs in this workspace. +Both sides fill one type — `helm_core::ComputedStats` — so when a native +producer exists, conformance is `ComputedStats::differences`, not a bespoke +harness. The database records which producer filled it, in `meta`: without that +a conformance failure is indistinguishable from a database built against a +different MegaMek. + ## Layout -- `crates/helm-unitfile` — the `.mtf` and `.blk` readers. No dependencies - beyond `zip`. This is what a battle value implementation would build on. -- `crates/helm-db` — schema and SQLite generation. -- `crates/helm-cli` — the `helm` binary. -- `bridge/` — the Java bridge. Runs in a container, so no JDK is needed. +- `crates/helm-core` — the types every other crate agrees on, and **no I/O at + all**. This is what the rules get written against, because battle value and + construction validation have to run in a browser as well as on a server. +- `crates/helm-unitfile` — the `.mtf` and `.blk` readers. Parsing one design is + pure; reading a whole library is behind the `library` feature, which is off + for wasm. +- `crates/helm-bridge` — a *producer* of `ComputedStats`, and the temporary + one. Reads what MegaMek computed. `helm-bv` will sit beside it and fill the + same type by computing it. +- `crates/helm-db` — schema and SQLite generation. Takes `ComputedStats` and + cannot tell who produced them, which is what keeps a battle value dependency + out of the database layer. +- `crates/helm-cli` — the `helm` binary, and the only crate that picks a + producer. +- `bridge/` — the Java side. Runs in a container, so no JDK is needed. + +`scripts/check-boundaries.sh` holds that graph in place: no I/O dependency may +reach `helm-core`, and no producer may reach `helm-db`. Both boundaries erode +silently otherwise, and the failure surfaces much later as "why can this not +compile to wasm". ## Tables diff --git a/crates/helm-bridge/Cargo.toml b/crates/helm-bridge/Cargo.toml new file mode 100644 index 0000000..768e81c --- /dev/null +++ b/crates/helm-bridge/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "helm-bridge" +version.workspace = true +edition.workspace = true +publish.workspace = true + +# One of two producers of helm_core::ComputedStats, and the temporary one. It +# fills the type by reading what MegaMek computed; helm-bv will fill the same +# type by computing it. Nothing downstream depends on this crate — helm-cli +# picks a producer, so retiring this one is deleting a dependency rather than +# unpicking a design. +[dependencies] +helm-core.workspace = true +serde_json.workspace = true diff --git a/crates/helm-bridge/src/lib.rs b/crates/helm-bridge/src/lib.rs new file mode 100644 index 0000000..dbaf3d7 --- /dev/null +++ b/crates/helm-bridge/src/lib.rs @@ -0,0 +1,193 @@ +//! 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::path::Path; + +use helm_core::{AlphaStrike, Catalogue, ComputedStats, EquipmentEntry}; +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 `equipment.jsonl` into the equipment catalogue. +pub fn read_catalogue(path: &Path) -> Result { + let mut entries = Vec::new(); + for (_, v) in objects(path)? { + if let Some(e) = equipment_record(&v) { + entries.push(e); + } + } + Ok(Catalogue::new(entries)) +} + +fn objects(path: &Path) -> Result, Error> { + let text = std::fs::read_to_string(path)?; + 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"), + run_mp: i(v, "runMp"), + 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"), + min_range: i(v, "minimumRange"), + short_range: i(v, "shortRange"), + medium_range: i(v, "mediumRange"), + long_range: i(v, "longRange"), + tonnage: f(v, "tonnage"), + criticals: i(v, "criticals"), + battle_value: f(v, "bv"), + cost: f(v, "cost"), + }) +} + +#[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()); + } +} diff --git a/crates/helm-cli/Cargo.toml b/crates/helm-cli/Cargo.toml index b604808..96e912d 100644 --- a/crates/helm-cli/Cargo.toml +++ b/crates/helm-cli/Cargo.toml @@ -8,7 +8,11 @@ publish.workspace = true name = "helm" path = "src/main.rs" +# The composition point: the only crate that picks a producer of ComputedStats, +# which is what makes retiring helm-bridge a change here and nowhere else. [dependencies] +helm-core.workspace = true helm-unitfile.workspace = true +helm-bridge.workspace = true helm-db.workspace = true rusqlite.workspace = true diff --git a/crates/helm-cli/src/main.rs b/crates/helm-cli/src/main.rs index e065ee9..badfb60 100644 --- a/crates/helm-cli/src/main.rs +++ b/crates/helm-cli/src/main.rs @@ -10,7 +10,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; -use helm_db::Bridge; +use helm_core::{Catalogue, ComputedStats, Provenance}; +use helm_db::Inputs; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -128,33 +129,48 @@ fn build(args: &[String]) -> Result<(), String> { } } - let bridge = match &opts.bridge_dir { - Some(dir) => { - let units = dir.join("units.jsonl"); - let equipment = dir.join("equipment.jsonl"); - let u = units.is_file().then_some(units.as_path()); - let e = equipment.is_file().then_some(equipment.as_path()); - if u.is_none() && e.is_none() { - return Err(format!( - "--bridge-dir {} holds neither units.jsonl nor equipment.jsonl", - dir.display() - )); - } - let b = Bridge::load(u, e).map_err(|e| e.to_string())?; - eprintln!( - "bridge: {} unit records, {} equipment types", - b.units.len(), - b.equipment.len() - ); - Some(b) + // Choosing the producer of ComputedStats is this function's job and + // nothing else's. When helm-bv exists it is chosen here, beside the + // bridge, and no other crate changes. + let mut computed: Vec = Vec::new(); + let mut catalogue: Option = None; + let mut provenance = Provenance { + megamek_version: opts.version.clone(), + stats_producer: "none".to_string(), + rules_version: None, + }; + + if let Some(dir) = &opts.bridge_dir { + let units = dir.join("units.jsonl"); + let equipment = dir.join("equipment.jsonl"); + if !units.is_file() && !equipment.is_file() { + return Err(format!( + "--bridge-dir {} holds neither units.jsonl nor equipment.jsonl", + dir.display() + )); } - None => { - eprintln!("no --bridge-dir: computed columns will be null"); - None + if units.is_file() { + computed = helm_bridge::read_units(&units).map_err(|e| e.to_string())?; } - }; + if equipment.is_file() { + catalogue = Some(helm_bridge::read_catalogue(&equipment).map_err(|e| e.to_string())?); + } + provenance = Provenance::from_bridge(&opts.version); + eprintln!( + "bridge: {} unit records, {} equipment types", + computed.len(), + catalogue.as_ref().map_or(0, Catalogue::len) + ); + } else { + eprintln!("no --bridge-dir: computed columns will be null"); + } - let stats = helm_db::build(&library, bridge.as_ref(), &out, &opts.version) + let had_producer = !computed.is_empty(); + let inputs = Inputs { + stats: &computed, + catalogue: catalogue.as_ref(), + }; + let stats = helm_db::build(&library, &inputs, &provenance, &out) .map_err(|e| format!("writing {}: {e}", out.display()))?; eprintln!( @@ -166,11 +182,11 @@ fn build(args: &[String]) -> Result<(), String> { stats.critical_rows, stats.catalogue_rows ); - if bridge.is_some() { - let missed = stats.units.saturating_sub(stats.bridged); + if had_producer { + let missed = stats.units.saturating_sub(stats.with_stats); eprintln!( - " {} units carry computed values, {missed} did not match a bridge record", - stats.bridged + " {} units carry computed values, {missed} matched no record ({})", + stats.with_stats, provenance.stats_producer ); } if !stats.fts { diff --git a/crates/helm-core/Cargo.toml b/crates/helm-core/Cargo.toml new file mode 100644 index 0000000..2c229ae --- /dev/null +++ b/crates/helm-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "helm-core" +version.workspace = true +edition.workspace = true +publish.workspace = true + +# No dependencies, on purpose. This crate is what the battle value and +# construction rules will be written against, and those have to compile to +# wasm32-unknown-unknown and run in a browser. Anything that reads a file, opens +# a database or talks to the network belongs in a crate that depends on this +# one, never in here. scripts/check-boundaries.sh enforces it. +[dependencies] diff --git a/crates/helm-core/src/catalogue.rs b/crates/helm-core/src/catalogue.rs new file mode 100644 index 0000000..264e2f2 --- /dev/null +++ b/crates/helm-core/src/catalogue.rs @@ -0,0 +1,170 @@ +//! MegaMek's equipment catalogue. +//! +//! This is the hidden prerequisite for computing anything. Battle value needs +//! every weapon's own BV, heat and tonnage, so owning the battle value formula +//! means owning the catalogue first. +//! +//! It is read from a MegaMek install rather than committed here. The stats are +//! Catalyst's game content and MegaMek's encoding of them is CC BY-NC-SA, so +//! the catalogue stays in their tree and helm is handed one. That is also why +//! this type is a plain value with no loader on it: `helm-cli` reads one from +//! the bridge today, and a browser is handed one the server generated, and +//! neither case wants a filesystem in this crate. + +use std::collections::HashMap; + +use crate::normalize; + +/// One piece of equipment. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct EquipmentEntry { + /// MegaMek's lookup key — `ISGaussRifle`. What a loadout records. + pub internal_name: String, + /// The display name — `Gauss Rifle`. What a person types. + pub name: String, + /// The implementing class. This is what tells apart the 22 entries that + /// share an internal name, nearly all of them Inner Sphere and Clan + /// versions of one weapon registered under a single key. + pub kind: Option, + + pub heat: Option, + pub damage: Option, + pub min_range: Option, + pub short_range: Option, + pub medium_range: Option, + pub long_range: Option, + pub tonnage: Option, + pub criticals: Option, + pub battle_value: Option, + pub cost: Option, +} + +impl EquipmentEntry { + /// Ammunition rather than a weapon or a component. + pub fn is_ammo(&self) -> bool { + normalize(&self.internal_name).contains("ammo") + } +} + +/// Every equipment type MegaMek knows, with lookups by both spellings. +#[derive(Debug, Clone, Default)] +pub struct Catalogue { + entries: Vec, + by_internal: HashMap, + by_norm: HashMap, +} + +impl Catalogue { + pub fn new(entries: Vec) -> Self { + let mut by_internal = HashMap::new(); + let mut by_norm = HashMap::new(); + for (i, e) in entries.iter().enumerate() { + // First entry wins on a shared key, so a lookup is deterministic + // across builds rather than depending on iteration order. + by_internal.entry(e.internal_name.clone()).or_insert(i); + by_norm.entry(normalize(&e.internal_name)).or_insert(i); + by_norm.entry(normalize(&e.name)).or_insert(i); + } + Catalogue { + entries, + by_internal, + by_norm, + } + } + + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub fn len(&self) -> usize { + self.entries.len() + } + + pub fn entries(&self) -> &[EquipmentEntry] { + &self.entries + } + + /// Look up by MegaMek's exact internal key. + pub fn get(&self, internal_name: &str) -> Option<&EquipmentEntry> { + self.by_internal + .get(internal_name) + .map(|&i| &self.entries[i]) + } + + /// Look up however it was spelled, falling back to the normalised form. + /// + /// This is what makes a loadout entry resolvable: MekSummary records some + /// mounts by internal key and others by display name, in the same field. + pub fn resolve(&self, name: &str) -> Option<&EquipmentEntry> { + self.get(name).or_else(|| { + self.by_norm + .get(&normalize(name)) + .map(|&i| &self.entries[i]) + }) + } + + /// The display name for a loadout entry, falling back to what was asked + /// for so a caller never has to handle a missing name. + pub fn display_name(&self, name: &str) -> String { + self.resolve(name) + .map(|e| e.name.clone()) + .unwrap_or_else(|| name.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(internal: &str, name: &str) -> EquipmentEntry { + EquipmentEntry { + internal_name: internal.into(), + name: name.into(), + ..Default::default() + } + } + + fn catalogue() -> Catalogue { + Catalogue::new(vec![ + entry("ISGaussRifle", "Gauss Rifle"), + entry("CLGaussRifle", "Gauss Rifle"), + entry("IS Gauss Ammo", "Gauss Ammo"), + ]) + } + + #[test] + fn resolves_by_internal_key_and_by_display_name() { + let c = catalogue(); + assert_eq!(c.resolve("ISGaussRifle").unwrap().name, "Gauss Rifle"); + // MekSummary records some mounts by display name instead. + assert_eq!( + c.resolve("Gauss Rifle").unwrap().internal_name, + "ISGaussRifle" + ); + } + + #[test] + fn display_name_falls_back_to_what_was_asked_for() { + let c = catalogue(); + assert_eq!(c.display_name("ISGaussRifle"), "Gauss Rifle"); + assert_eq!(c.display_name("Nonesuch"), "Nonesuch"); + } + + #[test] + fn ammunition_is_distinguishable_from_the_weapon() { + let c = catalogue(); + assert!(c.resolve("IS Gauss Ammo").unwrap().is_ammo()); + assert!(!c.resolve("ISGaussRifle").unwrap().is_ammo()); + } + + // 22 entries share an internal name upstream. A lookup has to be stable + // rather than whichever happened to be indexed last. + #[test] + fn a_shared_internal_key_resolves_to_the_first_entry() { + let c = Catalogue::new(vec![ + entry("AAA Missile", "AAA Missile (IS)"), + entry("AAA Missile", "AAA Missile (Clan)"), + ]); + assert_eq!(c.resolve("AAA Missile").unwrap().name, "AAA Missile (IS)"); + } +} diff --git a/crates/helm-core/src/computed.rs b/crates/helm-core/src/computed.rs new file mode 100644 index 0000000..668b83f --- /dev/null +++ b/crates/helm-core/src/computed.rs @@ -0,0 +1,167 @@ +//! What MegaMek works out that no unit file says. +//! +//! Battle value, C-bill cost and the Alpha Strike conversion are derived from +//! a design when MegaMek loads it. They are in no `.mtf` or `.blk`, and +//! producing them means applying the construction rules. +//! +//! This type is named for its shape and not its source, which is the whole +//! point of it. Two things fill it: +//! +//! * the Java bridge, by asking MegaMek — what happens today; +//! * `helm-bv`, by computing it — what should happen. +//! +//! Nothing downstream is allowed to tell which, so `helm-db` never grows a +//! battle value dependency. And because both producers fill one type, +//! conformance is a diff between two `ComputedStats` rather than a bespoke +//! comparison harness. + +/// A unit's Alpha Strike conversion. +/// +/// Kept as MegaMek's own notation rather than exploded — `damage` is a string +/// like `5/5/2` and `specials` like `AC2/2/-, IF1, SRCH` — because that +/// notation is the game's, and parsing it is a job for whoever needs the parts. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AlphaStrike { + pub point_value: Option, + pub unit_type: Option, + pub size: Option, + pub tmm: Option, + pub damage: Option, + /// Null for 967 units in MegaMek 0.51.0, 887 of them Tanks: + /// `ASTurretSummary.getSpecialsDisplayString()` throws on certain turreted + /// designs. That is an upstream bug, and it is deviation number one. + pub specials: Option, +} + +impl AlphaStrike { + pub fn is_empty(&self) -> bool { + self == &AlphaStrike::default() + } +} + +/// The derived figures for one unit. +/// +/// Every field is optional: a database built without a producer is a smaller +/// answer, not a broken one. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputedStats { + /// MegaMek's display name, `chassis (clanname) model`. The join key back + /// to a [`crate::Unit`]. + pub name: String, + + pub battle_value: Option, + pub cost: Option, + pub tech_level: Option, + pub weight_class: Option, + pub canon: Option, + pub invalid: Option, + pub omni: Option, + pub clan: Option, + /// Derived from walk MP and the engine, so computed rather than declared. + pub run_mp: Option, + + pub alpha_strike: AlphaStrike, +} + +impl ComputedStats { + pub fn new(name: impl Into) -> Self { + ComputedStats { + name: name.into(), + ..Default::default() + } + } + + /// Fields where two producers disagree, as `(field, left, right)`. + /// + /// This is the conformance harness in one method: run the bridge and + /// `helm-bv` over the same library, and every non-empty result is either a + /// bug of ours or a registered deviation of theirs. Fields the other side + /// did not populate are not disagreements — a producer that computes only + /// battle value should not read as wrong about cost. + pub fn differences(&self, other: &ComputedStats) -> Vec<(&'static str, String, String)> { + let mut out = Vec::new(); + + fn cmp( + out: &mut Vec<(&'static str, String, String)>, + field: &'static str, + a: &Option, + b: &Option, + ) { + if let (Some(a), Some(b)) = (a, b) + && a != b + { + out.push((field, format!("{a:?}"), format!("{b:?}"))); + } + } + + cmp( + &mut out, + "battle_value", + &self.battle_value, + &other.battle_value, + ); + cmp(&mut out, "cost", &self.cost, &other.cost); + cmp(&mut out, "tech_level", &self.tech_level, &other.tech_level); + cmp( + &mut out, + "weight_class", + &self.weight_class, + &other.weight_class, + ); + cmp(&mut out, "canon", &self.canon, &other.canon); + cmp(&mut out, "invalid", &self.invalid, &other.invalid); + cmp(&mut out, "omni", &self.omni, &other.omni); + cmp(&mut out, "clan", &self.clan, &other.clan); + cmp(&mut out, "run_mp", &self.run_mp, &other.run_mp); + + let (a, b) = (&self.alpha_strike, &other.alpha_strike); + cmp(&mut out, "as.point_value", &a.point_value, &b.point_value); + cmp(&mut out, "as.unit_type", &a.unit_type, &b.unit_type); + cmp(&mut out, "as.size", &a.size, &b.size); + cmp(&mut out, "as.tmm", &a.tmm, &b.tmm); + cmp(&mut out, "as.damage", &a.damage, &b.damage); + cmp(&mut out, "as.specials", &a.specials, &b.specials); + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identical_stats_do_not_differ() { + let mut a = ComputedStats::new("Atlas AS7-D"); + a.battle_value = Some(1897); + let b = a.clone(); + assert!(a.differences(&b).is_empty()); + } + + #[test] + fn a_disagreement_names_the_field_and_both_answers() { + let mut a = ComputedStats::new("Atlas AS7-D"); + a.battle_value = Some(1897); + let mut b = ComputedStats::new("Atlas AS7-D"); + b.battle_value = Some(1900); + + let diff = a.differences(&b); + assert_eq!(diff.len(), 1); + assert_eq!(diff[0].0, "battle_value"); + assert_eq!((diff[0].1.as_str(), diff[0].2.as_str()), ("1897", "1900")); + } + + // A producer that computes battle value and nothing else must not read as + // wrong about every field it declined to fill. + #[test] + fn a_field_only_one_side_populated_is_not_a_disagreement() { + let mut bridge = ComputedStats::new("Atlas AS7-D"); + bridge.battle_value = Some(1897); + bridge.cost = Some(9_626_000); + + let mut ours = ComputedStats::new("Atlas AS7-D"); + ours.battle_value = Some(1897); + + assert!(bridge.differences(&ours).is_empty()); + } +} diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs new file mode 100644 index 0000000..1bc40b7 --- /dev/null +++ b/crates/helm-core/src/lib.rs @@ -0,0 +1,131 @@ +//! The types every other helm crate agrees on, and no I/O whatsoever. +//! +//! This crate exists to be the thing the rules are written against. Battle +//! value and construction validation have to run in two places — in a browser +//! while somebody edits a design, and again on the server that believes the +//! answer — so they cannot depend on a filesystem, a database or a network. +//! Keeping those types here, with nothing under them, is what makes that +//! possible; `scripts/check-boundaries.sh` fails the build if it stops being +//! true. +//! +//! The split that matters most is between what a unit file *declares* and what +//! MegaMek *computes*: +//! +//! * [`Unit`] is declared. It comes out of a `.mtf` or `.blk` and says what the +//! designer chose. +//! * [`ComputedStats`] is derived. Battle value, cost and the Alpha Strike +//! conversion appear in no unit file; MegaMek works them out when it loads a +//! design. +//! +//! [`ComputedStats`] is deliberately named for its shape rather than its +//! source. Today the Java bridge fills it by asking MegaMek. Later `helm-bv` +//! will fill it by computing it. Nothing downstream should be able to tell +//! which, and having both fill one type is what makes conformance a diff +//! rather than a project. + +mod catalogue; +mod computed; +mod unit; + +pub use catalogue::{Catalogue, EquipmentEntry}; +pub use computed::{AlphaStrike, ComputedStats}; +pub use unit::{Format, Mount, Unit}; + +/// Reduce a name to lowercase alphanumerics. +/// +/// MegaMek names the same weapon three ways depending on where you read it — +/// `ISGaussRifle` in a loadout, `Gauss Rifle` in the catalogue, `IS Gauss +/// Ammo` for its ammunition — so matching on the raw string finds one and +/// misses the others. Normalising both sides is what makes a search for +/// "gauss rifle" return all of them. +pub fn normalize(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .map(|c| c.to_ascii_lowercase()) + .collect() +} + +/// Where a database's numbers came from. +/// +/// Without this, a conformance failure is indistinguishable from a database +/// built against a different MegaMek — which is exactly the ambiguity you hit +/// while chasing a deviation, and exactly when you can least afford it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Provenance { + /// The MegaMek release the unit files and catalogue were read from. + pub megamek_version: String, + /// What filled [`ComputedStats`]: `megamek-bridge`, or `helm-bv` once + /// that exists. + pub stats_producer: String, + /// The producer's own version, so a disagreement can be pinned to a + /// change on our side rather than theirs. + pub rules_version: Option, +} + +impl Provenance { + /// The provenance of a database whose computed values came from MegaMek + /// itself by way of the Java bridge. + pub fn from_bridge(megamek_version: impl Into) -> Self { + Provenance { + megamek_version: megamek_version.into(), + stats_producer: "megamek-bridge".to_string(), + rules_version: None, + } + } +} + +/// `weightClass` codes, from MegaMek's `EntityWeightClass`. +/// +/// 5 is both `WEIGHT_COLOSSAL` and `WEIGHT_SUPER_HEAVY` upstream, and which +/// one it reads as depends on the unit type, so the label carries both rather +/// than picking one and being wrong half the time. +pub fn weight_class_name(code: i64) -> Option<&'static str> { + Some(match code { + 0 => "Ultra Light", + 1 => "Light", + 2 => "Medium", + 3 => "Heavy", + 4 => "Assault", + 5 => "Colossal / Super Heavy", + 6 => "Small Craft", + 7 => "Small DropShip", + 8 => "Medium DropShip", + 9 => "Large DropShip", + 10 => "Small WarShip", + 11 => "Large WarShip", + 12 => "Small Support", + 13 => "Medium Support", + 14 => "Large Support", + _ => return None, + }) +} + +/// Unit types that are scenery or ordnance rather than something that takes +/// the field under its own power. +/// +/// These are the names the *files* use. MegaMek's summary spells several of +/// them differently — `Gun Emplacement` against `.blk`'s `GunEmplacement` — +/// and filtering on its spelling here silently excludes nothing at all. +pub const NOT_PLAYABLE: &[&str] = &["GunEmplacement", "BuildingEntity", "HandheldWeapon"]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_collapses_megameks_three_spellings() { + assert_eq!(normalize("ISGaussRifle"), "isgaussrifle"); + assert_eq!(normalize("Gauss Rifle"), "gaussrifle"); + assert_eq!(normalize("IS Gauss Ammo"), "isgaussammo"); + // The point of the whole exercise: one substring reaches both weapons. + assert!(normalize("ISGaussRifle").contains("gaussrifle")); + assert!(normalize("CLGaussRifle").contains("gaussrifle")); + } + + #[test] + fn weight_class_5_names_both_meanings() { + assert_eq!(weight_class_name(3), Some("Heavy")); + assert_eq!(weight_class_name(5), Some("Colossal / Super Heavy")); + assert_eq!(weight_class_name(99), None); + } +} diff --git a/crates/helm-core/src/unit.rs b/crates/helm-core/src/unit.rs new file mode 100644 index 0000000..c45ad6a --- /dev/null +++ b/crates/helm-core/src/unit.rs @@ -0,0 +1,177 @@ +//! A unit design as its file declares it. + +use std::collections::BTreeMap; + +/// Which of MegaMek's two formats a unit was read from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// `.mtf` — Meks. + Mtf, + /// `.blk` — vehicles, infantry, aerospace, buildings, everything else. + Blk, +} + +impl Format { + pub fn as_str(self) -> &'static str { + match self { + Format::Mtf => "mtf", + Format::Blk => "blk", + } + } +} + +/// One equipment entry as the file declares it. +/// +/// `.mtf` names a location per mount and repeats the line for each one; `.blk` +/// groups mounts under a location block. Both are normalised to one row per +/// mount so counting is a `GROUP BY`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Mount { + pub name: String, + pub location: Option, + /// True when the entry is rear-facing — MegaMek marks it with an `(R)` + /// suffix, which is not part of the equipment's name. + pub rear: bool, +} + +/// A unit design as declared in its file. +/// +/// Everything here is *declared*: it is what the designer chose, read back +/// verbatim. Nothing on this struct is derived, which is why battle value and +/// cost are not fields on it — see [`crate::ComputedStats`]. +#[derive(Debug, Clone, Default)] +pub struct Unit { + /// Path within the library, so a row can be traced back to its file. + pub path: String, + pub format_str: String, + + pub chassis: String, + pub model: String, + /// The Clan name, where the design has one alongside its Inner Sphere + /// reporting name — `Nova` to the `Black Hawk`, `Timber Wolf` to the + /// `Mad Cat`. MegaMek puts it in the display name, so it is part of a + /// unit's identity rather than fluff. + pub clan_name: Option, + /// `chassis` and `model` joined the way MegaMek displays a unit. + pub name: String, + /// The unit's masterunitlist.info record id, where the file declares one. + /// These survive locally even though that site does not. + pub mul_id: Option, + + /// `.blk` declares this outright; `.mtf` is always a Mek. + pub unit_type: Option, + /// `.mtf` only: Biped, Quad, Tripod, LAM and so on. + pub config: Option, + pub tech_base: Option, + pub rules_level: Option, + pub role: Option, + pub source: Option, + /// Year of introduction. `.mtf` calls it `era`, `.blk` calls it `year`. + pub year: Option, + + pub mass: Option, + pub engine: Option, + pub structure: Option, + pub myomer: Option, + pub heat_sinks: Option, + pub armor: Option, + pub motion_type: Option, + + pub walk_mp: Option, + pub run_mp: Option, + pub jump_mp: Option, + + pub quirks: Vec, + pub equipment: Vec, + /// Armour points by location, keyed by the file's own location name. + pub armor_locations: BTreeMap, + /// Slot contents by location: a Mek's critical hit table, and the + /// equipment blocks of a `.blk`. Empty slots are kept, because which slots + /// are free is part of the design and a battle value calculation will want + /// the placement, not just the inventory. + pub criticals: BTreeMap>, + + /// Every key the typed fields above do not claim, verbatim. Repeated keys + /// are joined with `\n`. + pub fields: BTreeMap, +} + +impl Unit { + /// MegaMek's display name, and the key that joins a parsed unit to a + /// computed record. + /// + /// The Clan name goes in parentheses between chassis and model, which is + /// how MegaMek renders it: `Black Hawk (Nova) Prime`. Getting this wrong + /// costs the join on every dual-named Clan design, and there are 434 of + /// them. + pub fn display_name(&self) -> String { + let chassis = self.chassis.trim(); + let model = self.model.trim(); + let head = match self.clan_name.as_deref().map(str::trim) { + Some(clan) if !clan.is_empty() => format!("{chassis} ({clan})"), + _ => chassis.to_string(), + }; + if model.is_empty() { + head + } else { + format!("{head} {model}") + } + } + + /// Sum of the declared armour across every location. + pub fn total_armor(&self) -> i64 { + self.armor_locations.values().sum() + } + + /// Store a key, joining repeats rather than letting the last one win. + pub fn push_field(&mut self, key: &str, value: &str) { + match self.fields.get_mut(key) { + Some(existing) => { + existing.push('\n'); + existing.push_str(value); + } + None => { + self.fields.insert(key.to_string(), value.to_string()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unit(chassis: &str, clan: Option<&str>, model: &str) -> Unit { + Unit { + chassis: chassis.into(), + clan_name: clan.map(Into::into), + model: model.into(), + ..Default::default() + } + } + + #[test] + fn display_name_puts_the_clan_name_in_parentheses() { + assert_eq!( + unit("Black Hawk", Some("Nova"), "Prime").display_name(), + "Black Hawk (Nova) Prime" + ); + assert_eq!(unit("Atlas", None, "AS7-D").display_name(), "Atlas AS7-D"); + // A .blk Model block is often present but empty. + assert_eq!( + unit("Condor HoverBall", None, "").display_name(), + "Condor HoverBall" + ); + } + + #[test] + fn repeated_fields_join_rather_than_overwrite() { + let mut u = unit("Atlas", None, "AS7-D"); + u.push_field("systemmanufacturer", "CHASSIS:Foundation"); + u.push_field("systemmanufacturer", "ENGINE:Vlar"); + assert_eq!( + u.fields.get("systemmanufacturer").unwrap(), + "CHASSIS:Foundation\nENGINE:Vlar" + ); + } +} diff --git a/crates/helm-db/Cargo.toml b/crates/helm-db/Cargo.toml index bcde2f6..5b38c65 100644 --- a/crates/helm-db/Cargo.toml +++ b/crates/helm-db/Cargo.toml @@ -4,7 +4,9 @@ version.workspace = true edition.workspace = true publish.workspace = true +# No helm-bridge and, when it exists, no helm-bv. Computed values arrive as +# helm_core::ComputedStats and this crate cannot tell who produced them. [dependencies] +helm-core.workspace = true helm-unitfile.workspace = true rusqlite.workspace = true -serde_json.workspace = true diff --git a/crates/helm-db/src/bridge.rs b/crates/helm-db/src/bridge.rs deleted file mode 100644 index 2fbff3c..0000000 --- a/crates/helm-db/src/bridge.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Reads the JSON Lines the Java bridge produces. -//! -//! The bridge 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 in -//! this workspace, the only way to have them without reimplementing the -//! construction rules is to ask MegaMek. -//! -//! Everything here is therefore meant to be temporary. The reader is -//! deliberately tolerant - it takes fields by name out of a generic JSON -//! object and ignores the rest - so the bridge can keep dumping every field -//! MegaMek has while this side takes only what the schema uses. - -use std::path::Path; - -use serde_json::Value; - -pub struct BridgeRecord { - /// MegaMek's display name, `chassis model`. The join key. - pub name: String, - pub bv: Option, - pub cost: Option, - pub tech_level: Option, - pub weight_class: Option, - pub canon: Option, - pub invalid: Option, - pub omni: Option, - pub clan: Option, - pub run_mp: Option, - pub point_value: Option, - pub as_unit_type: Option, - pub as_size: Option, - pub as_tmm: Option, - pub as_damage: Option, - pub as_specials: Option, -} - -pub struct EquipmentRecord { - pub internal_name: String, - pub name: String, - pub kind: Option, - pub heat: Option, - pub damage: Option, - pub min_range: Option, - pub short_range: Option, - pub medium_range: Option, - pub long_range: Option, - pub tonnage: Option, - pub criticals: Option, - pub bv: Option, - pub cost: Option, -} - -#[derive(Default)] -pub struct Bridge { - pub units: Vec, - pub equipment: Vec, -} - -#[derive(Debug)] -pub enum BridgeError { - Io(std::io::Error), - Json { line: usize, source: serde_json::Error }, -} - -impl std::fmt::Display for BridgeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - BridgeError::Io(e) => write!(f, "{e}"), - BridgeError::Json { line, source } => write!(f, "line {line}: {source}"), - } - } -} - -impl std::error::Error for BridgeError {} - -impl From for BridgeError { - fn from(e: std::io::Error) -> Self { - BridgeError::Io(e) - } -} - -impl Bridge { - /// Load one or both bridge files. Either may be absent. - pub fn load(units: Option<&Path>, equipment: Option<&Path>) -> Result { - let mut bridge = Bridge::default(); - if let Some(p) = units { - for (n, obj) in objects(p)? { - match unit_record(&obj) { - Some(r) => bridge.units.push(r), - None => { - // A record with no name cannot be joined to anything. - let _ = n; - } - } - } - } - if let Some(p) = equipment { - for (_, obj) in objects(p)? { - if let Some(r) = equipment_record(&obj) { - bridge.equipment.push(r); - } - } - } - Ok(bridge) - } -} - -fn objects(path: &Path) -> Result, BridgeError> { - let text = std::fs::read_to_string(path)?; - 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| BridgeError::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(BridgeRecord { - name, - bv: 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"), - run_mp: i(v, "runMp"), - point_value: i(v, "pointValue"), - as_unit_type: s(v, "asUnitType"), - as_size: i(v, "size"), - as_tmm: i(v, "tmm"), - as_damage: s(v, "standardDamage"), - as_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(EquipmentRecord { - internal_name, - name, - kind: s(v, "_class"), - heat: i(v, "heat"), - damage: i(v, "damage"), - min_range: i(v, "minimumRange"), - short_range: i(v, "shortRange"), - medium_range: i(v, "mediumRange"), - long_range: i(v, "longRange"), - tonnage: f(v, "tonnage"), - criticals: i(v, "criticals"), - bv: f(v, "bv"), - cost: f(v, "cost"), - }) -} diff --git a/crates/helm-db/src/lib.rs b/crates/helm-db/src/lib.rs index 317ed75..1098bce 100644 --- a/crates/helm-db/src/lib.rs +++ b/crates/helm-db/src/lib.rs @@ -1,60 +1,21 @@ //! Writes the parsed unit library out as an indexed SQLite database. //! //! Two sources feed one schema. The declared columns come from the unit files -//! themselves, read by `helm-unitfile`. The computed columns - battle value, -//! cost, the Alpha Strike conversion - come from the optional bridge, because -//! MegaMek derives them at load time and no unit file contains them. Every -//! computed column is nullable: a database built without the bridge is a -//! smaller answer, not a broken one, and each of those columns is a candidate -//! for a native implementation that would retire the bridge. +//! themselves, read by `helm-unitfile`. The computed columns arrive as +//! [`ComputedStats`], and this crate does not know or care who produced them — +//! the Java bridge today, `helm-bv` later. That is deliberate: it is what +//! keeps a battle value dependency out of the database layer. +//! +//! Every computed column is nullable. A database built with no producer is a +//! smaller answer, not a broken one. use std::collections::HashMap; use std::path::Path; -use helm_unitfile::{Library, Unit}; +use helm_core::{Catalogue, ComputedStats, NOT_PLAYABLE, Provenance, normalize, weight_class_name}; +use helm_unitfile::Library; use rusqlite::{Connection, params}; -mod bridge; -pub use bridge::{Bridge, BridgeRecord}; - -/// Reduce a name to lowercase alphanumerics. -/// -/// MegaMek names the same weapon three ways depending on where you read it - -/// `ISGaussRifle` in a loadout, `Gauss Rifle` in the catalogue, `IS Gauss -/// Ammo` for its ammunition - so matching on the raw string finds one and -/// misses the others. Normalising both sides is what makes a search for -/// "gauss rifle" return all of them. -pub fn normalize(name: &str) -> String { - name.chars() - .filter(|c| c.is_ascii_alphanumeric()) - .map(|c| c.to_ascii_lowercase()) - .collect() -} - -/// `weightClass` codes, from MegaMek's `EntityWeightClass`. 5 is both -/// COLOSSAL and SUPER_HEAVY upstream depending on unit type, so the label -/// carries both rather than picking one. -fn weight_class_name(code: i64) -> Option<&'static str> { - Some(match code { - 0 => "Ultra Light", - 1 => "Light", - 2 => "Medium", - 3 => "Heavy", - 4 => "Assault", - 5 => "Colossal / Super Heavy", - 6 => "Small Craft", - 7 => "Small DropShip", - 8 => "Medium DropShip", - 9 => "Large DropShip", - 10 => "Small WarShip", - 11 => "Large WarShip", - 12 => "Small Support", - 13 => "Medium Support", - 14 => "Large Support", - _ => return None, - }) -} - pub struct BuildStats { pub units: usize, pub equipment_rows: usize, @@ -62,26 +23,32 @@ pub struct BuildStats { pub critical_rows: usize, pub field_rows: usize, pub catalogue_rows: usize, - /// Units matched to a bridge record, so carrying computed values. - pub bridged: usize, + /// Units matched to a computed record, so carrying derived values. + pub with_stats: usize, pub fts: bool, } +/// Everything a build needs that is not the unit files. +#[derive(Default)] +pub struct Inputs<'a> { + /// Derived figures, keyed by display name. Empty is allowed. + pub stats: &'a [ComputedStats], + /// The equipment catalogue, for resolving loadout names. Empty is allowed. + pub catalogue: Option<&'a Catalogue>, +} + /// Build the database. Overwrites `dest` if it exists. pub fn build( library: &Library, - bridge: Option<&Bridge>, + inputs: &Inputs<'_>, + provenance: &Provenance, dest: &Path, - megamek_version: &str, ) -> rusqlite::Result { if dest.exists() { let _ = std::fs::remove_file(dest); } let mut db = Connection::open(dest)?; - db.execute_batch( - "PRAGMA journal_mode=OFF; - PRAGMA synchronous=OFF;", - )?; + db.execute_batch("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF;")?; schema(&db)?; let mut stats = BuildStats { @@ -91,15 +58,14 @@ pub fn build( critical_rows: 0, field_rows: 0, catalogue_rows: 0, - bridged: 0, + with_stats: 0, fts: false, }; - // The catalogue goes in first so loadout rows can resolve display names - // against it as they are written. - let mut display_by_internal: HashMap = HashMap::new(); - let mut display_by_norm: HashMap = HashMap::new(); - if let Some(b) = bridge { + let empty = Catalogue::default(); + let catalogue = inputs.catalogue.unwrap_or(&empty); + + if !catalogue.is_empty() { let tx = db.transaction()?; { let mut stmt = tx.prepare( @@ -109,15 +75,12 @@ pub fn build( tonnage, criticals, bv, cost) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)", )?; - for (n, e) in b.equipment.iter().enumerate() { - let norm = normalize(&e.internal_name); - display_by_internal.insert(e.internal_name.clone(), e.name.clone()); - display_by_norm.entry(norm.clone()).or_insert_with(|| e.name.clone()); + for (n, e) in catalogue.entries().iter().enumerate() { stmt.execute(params![ (n + 1) as i64, e.internal_name, e.name, - norm, + normalize(&e.internal_name), e.kind, e.heat, e.damage, @@ -127,7 +90,7 @@ pub fn build( e.long_range, e.tonnage, e.criticals, - e.bv, + e.battle_value, e.cost, ])?; stats.catalogue_rows += 1; @@ -136,22 +99,20 @@ pub fn build( tx.commit()?; } - let by_name: HashMap<&str, &BridgeRecord> = bridge - .map(|b| b.units.iter().map(|r| (r.name.as_str(), r)).collect()) - .unwrap_or_default(); + let by_name: HashMap<&str, &ComputedStats> = + inputs.stats.iter().map(|s| (s.name.as_str(), s)).collect(); let tx = db.transaction()?; { let mut unit_stmt = tx.prepare( "INSERT INTO units ( - unit_id, path, format, chassis, model, name, mul_id, + 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, 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, - clan_name + point_value, as_unit_type, as_size, as_tmm, as_damage, as_specials ) VALUES ( ?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, @@ -175,12 +136,12 @@ pub fn build( for (idx, u) in library.units.iter().enumerate() { let uid = (idx + 1) as i64; - let total_armor: i64 = u.armor_locations.values().sum(); - let br = by_name.get(u.name.as_str()).copied(); - if br.is_some() { - stats.bridged += 1; + let cs = by_name.get(u.name.as_str()).copied(); + if cs.is_some() { + stats.with_stats += 1; } - let wc = br.and_then(|r| r.weight_class); + let wc = cs.and_then(|s| s.weight_class); + let as_ = cs.map(|s| &s.alpha_strike); unit_stmt.execute(params![ uid, @@ -188,6 +149,7 @@ pub fn build( u.format_str, u.chassis, u.model, + u.clan_name, u.name, u.mul_id, u.unit_type, @@ -206,43 +168,37 @@ pub fn build( u.motion_type, u.walk_mp, u.jump_mp, - total_armor, + u.total_armor(), u.equipment.len() as i64, - br.and_then(|r| r.bv), - br.and_then(|r| r.cost), - br.and_then(|r| r.tech_level.clone()), + cs.and_then(|s| s.battle_value), + cs.and_then(|s| s.cost), + cs.and_then(|s| s.tech_level.clone()), wc, wc.and_then(weight_class_name), - br.and_then(|r| r.canon).map(i64::from), - br.and_then(|r| r.invalid).map(i64::from), - br.and_then(|r| r.omni).map(i64::from), - br.and_then(|r| r.clan).map(i64::from), - br.and_then(|r| r.run_mp), - br.and_then(|r| r.point_value), - br.and_then(|r| r.as_unit_type.clone()), - br.and_then(|r| r.as_size), - br.and_then(|r| r.as_tmm), - br.and_then(|r| r.as_damage.clone()), - br.and_then(|r| r.as_specials.clone()), - u.clan_name, + cs.and_then(|s| s.canon).map(i64::from), + cs.and_then(|s| s.invalid).map(i64::from), + cs.and_then(|s| s.omni).map(i64::from), + cs.and_then(|s| s.clan).map(i64::from), + cs.and_then(|s| s.run_mp), + as_.and_then(|a| a.point_value), + as_.and_then(|a| a.unit_type.clone()), + as_.and_then(|a| a.size), + as_.and_then(|a| a.tmm), + as_.and_then(|a| a.damage.clone()), + as_.and_then(|a| a.specials.clone()), ])?; stats.units += 1; for m in &u.equipment { let norm = normalize(&m.name); - let display = display_by_internal - .get(&m.name) - .or_else(|| display_by_norm.get(&norm)) - .cloned() - .unwrap_or_else(|| m.name.clone()); equip_stmt.execute(params![ uid, m.name, norm, - display, + catalogue.display_name(&m.name), m.location, i64::from(m.rear), - i64::from(norm_is_ammo(&norm)), + i64::from(norm.contains("ammo")), ])?; stats.equipment_rows += 1; } @@ -269,17 +225,13 @@ pub fn build( indexes(&db)?; stats.fts = full_text(&db).is_ok(); - views(&db, bridge.is_some())?; - meta(&db, &stats, library, megamek_version, bridge.is_some())?; + views(&db, !inputs.stats.is_empty(), !catalogue.is_empty())?; + meta(&db, &stats, library, provenance)?; db.execute_batch("VACUUM; ANALYZE;")?; Ok(stats) } -fn norm_is_ammo(norm: &str) -> bool { - norm.contains("ammo") -} - fn schema(db: &Connection) -> rusqlite::Result<()> { db.execute_batch( "CREATE TABLE units ( @@ -310,7 +262,8 @@ fn schema(db: &Connection) -> rusqlite::Result<()> { jump_mp INTEGER, total_armor INTEGER, equipment_count INTEGER, - -- Computed by MegaMek, supplied by the bridge. Null without it. + -- Computed rather than declared; null when built with no producer. + -- See the meta table for which producer filled them. bv INTEGER, cost INTEGER, tech_level TEXT, @@ -367,9 +320,7 @@ fn schema(db: &Connection) -> rusqlite::Result<()> { -- internal_name is NOT unique. 22 entries share one, nearly all of -- them Inner Sphere and Clan versions of the same weapon that MegaMek - -- registers under a single lookup key (CLAAAMissileWeapon and - -- ISAAAMissileWeapon are both 'AAA Missile'). `kind` holds the - -- implementing class, which is what tells them apart. + -- registers under a single lookup key. `kind` tells them apart. CREATE TABLE equipment ( equip_id INTEGER PRIMARY KEY, internal_name TEXT NOT NULL, @@ -432,24 +383,16 @@ fn full_text(db: &Connection) -> rusqlite::Result<()> { ) } -/// Unit types that are scenery or ordnance rather than something that takes -/// the field under its own power. -/// -/// These are the names the *files* use. MegaMek's summary spells several of -/// them differently ("Gun Emplacement" against `.blk`'s "GunEmplacement"), and -/// filtering on its spelling here silently excludes nothing. -const NOT_PLAYABLE: &[&str] = &["GunEmplacement", "BuildingEntity", "HandheldWeapon"]; - -fn views(db: &Connection, bridged: bool) -> rusqlite::Result<()> { +fn views(db: &Connection, has_stats: bool, has_catalogue: bool) -> rusqlite::Result<()> { let excluded = NOT_PLAYABLE .iter() .map(|t| format!("'{t}'")) .collect::>() .join(", "); - // Without the bridge there is no canon or invalid flag to filter on, so + // Without a producer there is no canon or invalid flag to filter on, so // `playable` narrows on unit type alone rather than silently returning // everything under a name that promises otherwise. - let canon_clause = if bridged { + let canon_clause = if has_stats { "COALESCE(canon, 1) = 1 AND COALESCE(invalid, 0) = 0 AND" } else { "" @@ -458,50 +401,52 @@ fn views(db: &Connection, bridged: bool) -> rusqlite::Result<()> { "CREATE VIEW playable AS SELECT * FROM units WHERE {canon_clause} unit_type NOT IN ({excluded})" ))?; - db.execute_batch( - "CREATE VIEW meks AS SELECT * FROM playable WHERE unit_type = 'Mek'; - - -- One row per internal_name, so joining a loadout to it cannot - -- double a unit's weapon count on the 22 shared keys. min(equip_id) - -- makes the choice deterministic across builds. - CREATE VIEW equipment_primary AS - SELECT * FROM equipment WHERE equip_id IN - (SELECT MIN(equip_id) FROM equipment GROUP BY internal_name); - - CREATE VIEW unit_weapons AS - SELECT ue.unit_id, u.name AS unit_name, ue.display_name, ue.location, - ue.rear, e.heat, e.damage, e.min_range, e.short_range, - e.medium_range, e.long_range - FROM unit_equipment ue - JOIN units u ON u.unit_id = ue.unit_id - LEFT JOIN equipment_primary e ON e.internal_name = ue.name - WHERE ue.is_ammo = 0;", - ) + db.execute_batch("CREATE VIEW meks AS SELECT * FROM playable WHERE unit_type = 'Mek';")?; + + if has_catalogue { + db.execute_batch( + // One row per internal_name, so joining a loadout to it cannot + // double a unit's weapon count on the 22 shared keys. + // min(equip_id) makes the choice deterministic across builds. + "CREATE VIEW equipment_primary AS + SELECT * FROM equipment WHERE equip_id IN + (SELECT MIN(equip_id) FROM equipment GROUP BY internal_name); + + CREATE VIEW unit_weapons AS + SELECT ue.unit_id, u.name AS unit_name, ue.display_name, ue.location, + ue.rear, e.heat, e.damage, e.min_range, e.short_range, + e.medium_range, e.long_range + FROM unit_equipment ue + JOIN units u ON u.unit_id = ue.unit_id + LEFT JOIN equipment_primary e ON e.internal_name = ue.name + WHERE ue.is_ammo = 0;", + )?; + } + Ok(()) } fn meta( db: &Connection, stats: &BuildStats, library: &Library, - megamek_version: &str, - bridged: bool, + provenance: &Provenance, ) -> rusqlite::Result<()> { let rows: Vec<(&str, String)> = vec![ - ("megamek_version", megamek_version.to_string()), + ("megamek_version", provenance.megamek_version.clone()), + ("stats_producer", provenance.stats_producer.clone()), + ( + "rules_version", + provenance + .rules_version + .clone() + .unwrap_or_else(|| "n/a".into()), + ), ("unit_count", stats.units.to_string()), + ("units_with_stats", stats.with_stats.to_string()), ("equipment_rows", stats.equipment_rows.to_string()), ("catalogue_rows", stats.catalogue_rows.to_string()), - ("bridged_units", stats.bridged.to_string()), ("parse_failures", library.failures.len().to_string()), ("fts5", if stats.fts { "yes" } else { "no" }.to_string()), - ( - "computed_columns", - if bridged { - "present (bv, cost, Alpha Strike)".to_string() - } else { - "absent - built without the bridge".to_string() - }, - ), ( "data_license", "MegaMek data is CC BY-NC-SA 4.0".to_string(), @@ -512,13 +457,10 @@ fn meta( ), ]; for (k, v) in rows { - db.execute("INSERT INTO meta (key, value) VALUES (?1, ?2)", params![k, v])?; + db.execute( + "INSERT INTO meta (key, value) VALUES (?1, ?2)", + params![k, v], + )?; } Ok(()) } - -/// Sum of a unit's declared armour, for callers that have a [`Unit`] but not -/// the database. -pub fn total_armor(unit: &Unit) -> i64 { - unit.armor_locations.values().sum() -} diff --git a/crates/helm-unitfile/Cargo.toml b/crates/helm-unitfile/Cargo.toml index 6384ce5..15a7e28 100644 --- a/crates/helm-unitfile/Cargo.toml +++ b/crates/helm-unitfile/Cargo.toml @@ -4,5 +4,14 @@ version.workspace = true edition.workspace = true publish.workspace = true +[features] +# Reading a whole library means reading MegaMek's unit_files.zip and walking +# directories, neither of which a browser does. Parsing a single design from a +# string is pure, and that is the part that has to reach wasm — so the I/O is +# behind a feature rather than in the crate unconditionally. +default = ["library"] +library = ["dep:zip"] + [dependencies] -zip.workspace = true +helm-core.workspace = true +zip = { workspace = true, optional = true } diff --git a/crates/helm-unitfile/src/blk.rs b/crates/helm-unitfile/src/blk.rs index 836a902..bc0391a 100644 --- a/crates/helm-unitfile/src/blk.rs +++ b/crates/helm-unitfile/src/blk.rs @@ -12,7 +12,9 @@ //! that location's mounts, and `` is armour points in the unit's own //! location order. -use crate::{Error, Format, Mount, Unit, push_field}; +use helm_core::{Format, Mount, Unit}; + +use crate::Error; pub fn parse_blk(path: &str, text: &str) -> Result { let mut unit = Unit { @@ -91,7 +93,10 @@ pub fn parse_blk(path: &str, text: &str) -> Result { } if lower == "armor" { - armor_values = body.iter().filter_map(|v| v.trim().parse::().ok()).collect(); + armor_values = body + .iter() + .filter_map(|v| v.trim().parse::().ok()) + .collect(); continue; } @@ -157,7 +162,7 @@ fn assign(unit: &mut Unit, key: &str, key_raw: &str, value: &str) { "chassis" => { // Support vehicles carry a separate chassis block; it does not // replace Name, so it is kept as a field. - push_field(&mut unit.fields, key_raw, value); + unit.push_field(key_raw, value); } "mul_id" | "mulid" => unit.mul_id = value.parse().ok(), "unittype" => unit.unit_type = Some(value.to_string()), @@ -182,6 +187,6 @@ fn assign(unit: &mut Unit, key: &str, key_raw: &str, value: &str) { } } } - _ => push_field(&mut unit.fields, key_raw, value), + _ => unit.push_field(key_raw, value), } } diff --git a/crates/helm-unitfile/src/lib.rs b/crates/helm-unitfile/src/lib.rs index f9392f8..e4a1703 100644 --- a/crates/helm-unitfile/src/lib.rs +++ b/crates/helm-unitfile/src/lib.rs @@ -1,11 +1,11 @@ //! Readers for MegaMek's two unit file formats. //! //! MegaMek stores a unit design in one of two hand-editable text formats: -//! `.mtf` for Meks and `.blk` for everything else. Both are declarative - they +//! `.mtf` for Meks and `.blk` for everything else. Both are declarative — they //! record what the designer chose, not what the choices add up to. Battle //! value, C-bill cost and the Alpha Strike conversion are computed when -//! MegaMek loads the design and appear in neither format, which is why helm -//! carries a bridge for them until those calculations are reimplemented here. +//! MegaMek loads the design and appear in neither format; those live in +//! [`helm_core::ComputedStats`]. //! //! What these readers give you is everything the designer declared: chassis //! and model, tech base, era, tonnage, engine, movement, armour, the equipment @@ -14,10 +14,12 @@ //! Nothing is discarded. Fields the typed struct does not name stay in //! [`Unit::fields`] under their original key, so a MegaMek release that adds //! one is readable before this crate knows about it. +//! +//! Parsing one design is pure and available everywhere. Reading a whole +//! library touches the filesystem and a zip, so it is behind the `library` +//! feature, which is on by default and off for wasm. -use std::collections::BTreeMap; -use std::io::{Read, Seek}; -use std::path::Path; +pub use helm_core::{Format, Mount, Unit}; mod blk; mod mtf; @@ -25,122 +27,17 @@ mod mtf; pub use blk::parse_blk; pub use mtf::{armor_location_order, parse_mtf, split_pipe_list, split_system_field}; -/// Which of MegaMek's two formats a unit was read from. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Format { - /// `.mtf` - Meks. - Mtf, - /// `.blk` - vehicles, infantry, aerospace, buildings, everything else. - Blk, -} - -impl Format { - pub fn as_str(self) -> &'static str { - match self { - Format::Mtf => "mtf", - Format::Blk => "blk", - } - } -} - -/// One equipment entry as the file declares it. -/// -/// `.mtf` names a location per mount and repeats the line for each one; `.blk` -/// groups mounts under a location block. Both are normalised to one row per -/// mount so counting is a `GROUP BY`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Mount { - pub name: String, - pub location: Option, - /// True when the entry is turret-mounted, rear-facing or otherwise - /// qualified in the source line. - pub rear: bool, -} - -/// A unit design as declared in its file. -#[derive(Debug, Clone, Default)] -pub struct Unit { - /// Path within the library, so a row can be traced back to its file. - pub path: String, - pub format_str: String, - - pub chassis: String, - pub model: String, - /// The Clan name, where the design has one alongside its Inner Sphere - /// reporting name - `Nova` to the `Black Hawk`, `Timber Wolf` to the - /// `Mad Cat`. MegaMek puts it in the display name, so it is part of a - /// unit's identity rather than fluff. - pub clan_name: Option, - /// `chassis` and `model` joined the way MegaMek displays a unit. - pub name: String, - /// The unit's masterunitlist.info record id, where the file declares one. - /// These survive locally even though the MUL site does not. - pub mul_id: Option, - - /// `.blk` declares this outright; `.mtf` is always a Mek. - pub unit_type: Option, - /// `.mtf` only: Biped, Quad, Tripod, LAM and so on. - pub config: Option, - pub tech_base: Option, - pub rules_level: Option, - pub role: Option, - pub source: Option, - /// Year of introduction. `.mtf` calls it `era`, `.blk` calls it `year`. - pub year: Option, - - pub mass: Option, - pub engine: Option, - pub structure: Option, - pub myomer: Option, - pub heat_sinks: Option, - pub armor: Option, - pub motion_type: Option, - - pub walk_mp: Option, - pub run_mp: Option, - pub jump_mp: Option, - - pub quirks: Vec, - pub equipment: Vec, - /// Armour points by location, keyed by the file's own location name. - pub armor_locations: BTreeMap, - /// Slot contents by location: a Mek's critical hit table, and the - /// equipment blocks of a `.blk`. Empty slots are kept, because which slots - /// are free is part of the design and a battle value calculation will want - /// the placement, not just the inventory. - pub criticals: BTreeMap>, - - /// Every key the typed fields above do not claim, verbatim. Repeated keys - /// are joined with `\n`. - pub fields: BTreeMap, -} - -impl Unit { - /// MegaMek's display name, and the key that joins a parsed unit to a - /// bridge record. - /// - /// The Clan name goes in parentheses between chassis and model, which is - /// how MegaMek renders it: `Black Hawk (Nova) Prime`. Getting this wrong - /// costs the join on every dual-named Clan design. - pub fn display_name(&self) -> String { - let chassis = self.chassis.trim(); - let model = self.model.trim(); - let head = match self.clan_name.as_deref().map(str::trim) { - Some(clan) if !clan.is_empty() => format!("{chassis} ({clan})"), - _ => chassis.to_string(), - }; - if model.is_empty() { - head - } else { - format!("{head} {model}") - } - } -} +#[cfg(feature = "library")] +mod library; +#[cfg(feature = "library")] +pub use library::{Library, read_dir, read_zip}; /// What went wrong reading a unit file. #[derive(Debug)] pub enum Error { + #[cfg(feature = "library")] Io(std::io::Error), + #[cfg(feature = "library")] Zip(zip::result::ZipError), /// The file parsed but does not identify a unit. Malformed { path: String, reason: String }, @@ -149,7 +46,9 @@ pub enum Error { impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + #[cfg(feature = "library")] Error::Io(e) => write!(f, "{e}"), + #[cfg(feature = "library")] Error::Zip(e) => write!(f, "{e}"), Error::Malformed { path, reason } => write!(f, "{path}: {reason}"), } @@ -158,12 +57,14 @@ impl std::fmt::Display for Error { impl std::error::Error for Error {} +#[cfg(feature = "library")] impl From for Error { fn from(e: std::io::Error) -> Self { Error::Io(e) } } +#[cfg(feature = "library")] impl From for Error { fn from(e: zip::result::ZipError) -> Self { Error::Zip(e) @@ -191,91 +92,3 @@ pub fn parse(path: &str, bytes: &[u8]) -> Result { }) } } - -/// Outcome of walking a whole library. -pub struct Library { - pub units: Vec, - /// Files that could not be read, with the reason. Reported rather than - /// swallowed: a library that silently shrinks is worse than one that - /// says what it dropped. - pub failures: Vec<(String, String)>, -} - -/// Read every unit out of `data/mekfiles/unit_files.zip`. -pub fn read_zip(reader: R) -> Result { - let mut archive = zip::ZipArchive::new(reader)?; - let mut units = Vec::new(); - let mut failures = Vec::new(); - - for i in 0..archive.len() { - let mut entry = archive.by_index(i)?; - if entry.is_dir() { - continue; - } - let name = entry.name().to_string(); - let lower = name.to_ascii_lowercase(); - if !(lower.ends_with(".mtf") || lower.ends_with(".blk")) { - continue; - } - let mut buf = Vec::with_capacity(entry.size() as usize); - if let Err(e) = entry.read_to_end(&mut buf) { - failures.push((name, e.to_string())); - continue; - } - match parse(&name, &buf) { - Ok(u) => units.push(u), - Err(e) => failures.push((name, e.to_string())), - } - } - - Ok(Library { units, failures }) -} - -/// Read every unit under a directory, recursively. -pub fn read_dir(root: &Path) -> Result { - let mut units = Vec::new(); - let mut failures = Vec::new(); - let mut stack = vec![root.to_path_buf()]; - - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - stack.push(path); - continue; - } - let lower = path.to_string_lossy().to_ascii_lowercase(); - if !(lower.ends_with(".mtf") || lower.ends_with(".blk")) { - continue; - } - let rel = path - .strip_prefix(root) - .unwrap_or(&path) - .to_string_lossy() - .to_string(); - match std::fs::read(&path) { - Ok(bytes) => match parse(&rel, &bytes) { - Ok(u) => units.push(u), - Err(e) => failures.push((rel, e.to_string())), - }, - Err(e) => failures.push((rel, e.to_string())), - } - } - } - - Ok(Library { units, failures }) -} - -/// Store a key, joining repeats rather than letting the last one win. -pub(crate) fn push_field(fields: &mut BTreeMap, key: &str, value: &str) { - match fields.get_mut(key) { - Some(existing) => { - existing.push('\n'); - existing.push_str(value); - } - None => { - fields.insert(key.to_string(), value.to_string()); - } - } -} diff --git a/crates/helm-unitfile/src/library.rs b/crates/helm-unitfile/src/library.rs new file mode 100644 index 0000000..9db0b47 --- /dev/null +++ b/crates/helm-unitfile/src/library.rs @@ -0,0 +1,86 @@ +//! Reading a whole unit library: MegaMek's `unit_files.zip`, or loose files. +//! +//! This is the part that touches a filesystem, so it is behind the `library` +//! feature and out of the way of anything that has to reach wasm. + +use std::io::{Read, Seek}; +use std::path::Path; + +use helm_core::Unit; + +use crate::{Error, parse}; + +/// Outcome of walking a whole library. +pub struct Library { + pub units: Vec, + /// Files that could not be read, with the reason. Reported rather than + /// swallowed: a library that silently shrinks is worse than one that says + /// what it dropped. + pub failures: Vec<(String, String)>, +} + +/// Read every unit out of `data/mekfiles/unit_files.zip`. +pub fn read_zip(reader: R) -> Result { + let mut archive = zip::ZipArchive::new(reader)?; + let mut units = Vec::new(); + let mut failures = Vec::new(); + + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + if entry.is_dir() { + continue; + } + let name = entry.name().to_string(); + let lower = name.to_ascii_lowercase(); + if !(lower.ends_with(".mtf") || lower.ends_with(".blk")) { + continue; + } + let mut buf = Vec::with_capacity(entry.size() as usize); + if let Err(e) = entry.read_to_end(&mut buf) { + failures.push((name, e.to_string())); + continue; + } + match parse(&name, &buf) { + Ok(u) => units.push(u), + Err(e) => failures.push((name, e.to_string())), + } + } + + Ok(Library { units, failures }) +} + +/// Read every unit under a directory, recursively. +pub fn read_dir(root: &Path) -> Result { + let mut units = Vec::new(); + let mut failures = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + let lower = path.to_string_lossy().to_ascii_lowercase(); + if !(lower.ends_with(".mtf") || lower.ends_with(".blk")) { + continue; + } + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + match std::fs::read(&path) { + Ok(bytes) => match parse(&rel, &bytes) { + Ok(u) => units.push(u), + Err(e) => failures.push((rel, e.to_string())), + }, + Err(e) => failures.push((rel, e.to_string())), + } + } + } + + Ok(Library { units, failures }) +} diff --git a/crates/helm-unitfile/src/mtf.rs b/crates/helm-unitfile/src/mtf.rs index 81e9ec7..cb69051 100644 --- a/crates/helm-unitfile/src/mtf.rs +++ b/crates/helm-unitfile/src/mtf.rs @@ -13,7 +13,9 @@ //! Anything else is stored under its own key, so a field this reader does not //! know about is still available. -use crate::{Error, Format, Mount, Unit, push_field}; +use helm_core::{Format, Mount, Unit}; + +use crate::Error; /// A Mek's critical slot sections. A line matching one of these, with nothing /// after the colon, opens a slot list rather than declaring an empty value. @@ -165,7 +167,7 @@ fn assign(unit: &mut Unit, key: &str, key_raw: &str, value: &str) { "walk mp" => unit.walk_mp = value.parse().ok(), "jump mp" => unit.jump_mp = value.parse().ok(), "quirk" | "weaponquirk" => unit.quirks.push(value.to_string()), - _ => push_field(&mut unit.fields, key_raw, value), + _ => unit.push_field(key_raw, value), } } diff --git a/scripts/check-boundaries.sh b/scripts/check-boundaries.sh new file mode 100755 index 0000000..b24df81 --- /dev/null +++ b/scripts/check-boundaries.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Hold the crate graph to the shape the rules depend on. +# +# ./scripts/check-boundaries.sh +# +# Two boundaries, both of which erode silently if nothing checks them. +# +# The rules have to run in a browser. Battle value and construction validation +# are wanted in two places - in the page while somebody edits a design, and +# again on the server that believes the answer - so the crates they are written +# against cannot acquire a filesystem, a database or a network. A `use` added +# in a hurry is all it takes, and the failure shows up much later as "why can +# this not compile to wasm". +# +# The database must not learn to compute. helm-db takes ComputedStats and is +# not allowed to know who produced them, so that swapping the Java bridge for a +# native implementation is a change in helm-cli and nowhere else. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +FAIL=0 +note() { printf ' %s\n' "$1"; } +fail() { printf 'FAIL: %s\n' "$1" >&2; FAIL=1; } + +# Crates that must reach wasm, and so must stay clear of these. +WASM_CRATES=(helm-core) +FORBIDDEN=(rusqlite zip serde_json libsqlite3-sys) + +# Crates that must not depend on a producer of ComputedStats. +CONSUMER_CRATES=(helm-db) +PRODUCERS=(helm-bridge helm-bv) + +echo "checking wasm-bound crates carry no I/O dependency" +for crate in "${WASM_CRATES[@]}"; do + if ! cargo metadata --format-version 1 --no-deps 2>/dev/null | grep -q "\"$crate\""; then + note "$crate: not in the workspace yet, skipped" + continue + fi + tree="$(cargo tree -p "$crate" --edges normal 2>/dev/null || true)" + for dep in "${FORBIDDEN[@]}"; do + if grep -qE "^[^a-z]*\b$dep v" <<<"$tree"; then + fail "$crate depends on $dep, which cannot go in a browser." + note "It belongs in a crate that depends on $crate, not in $crate." + fi + done + note "$crate: clean" +done + +echo "checking the database layer cannot compute" +for crate in "${CONSUMER_CRATES[@]}"; do + tree="$(cargo tree -p "$crate" --edges normal 2>/dev/null || true)" + for dep in "${PRODUCERS[@]}"; do + if grep -qE "^[^a-z]*\b$dep v" <<<"$tree"; then + fail "$crate depends on $dep." + note "Computed values reach $crate as helm_core::ComputedStats. Choosing" + note "who produces them is helm-cli's job, so that retiring a producer is" + note "a change in one crate." + fi + done + note "$crate: clean" +done + +echo "checking the parsers build without their I/O feature" +if cargo check -q -p helm-unitfile --no-default-features 2>/dev/null; then + note "helm-unitfile --no-default-features: ok" +else + fail "helm-unitfile does not build without its 'library' feature." + note "Parsing one design must not require the zip reader." +fi + +echo "checking the wasm target" +if rustup target list --installed 2>/dev/null | grep -q wasm32-unknown-unknown; then + for crate in "${WASM_CRATES[@]}"; do + if cargo check -q -p "$crate" --target wasm32-unknown-unknown 2>/dev/null; then + note "$crate: builds for wasm32-unknown-unknown" + else + fail "$crate does not build for wasm32-unknown-unknown." + fi + done + if cargo check -q -p helm-unitfile --no-default-features \ + --target wasm32-unknown-unknown 2>/dev/null; then + note "helm-unitfile (no default features): builds for wasm32-unknown-unknown" + else + fail "helm-unitfile does not build for wasm32 without its 'library' feature." + fi +else + # Not installed, and installing a target is not this script's decision to + # make. The dependency checks above are the ones that catch the mistake + # early anyway; this one only confirms it. + note "wasm32-unknown-unknown is not installed, so the build check is skipped." + note "Enable it with: rustup target add wasm32-unknown-unknown" +fi + +if [ "$FAIL" -ne 0 ]; then + echo + echo "crate boundaries are broken; see above." >&2 + exit 1 +fi +echo "crate boundaries hold."