//! `helm` - build and inspect the unit database.
//!
//! helm build --megamek
--out helm.sqlite [--bridge-dir ]
//! helm stats
//! helm bv-report --megamek --bridge-dir
//! helm art --megamek [--out ]
//! helm index --megamek --bridge-dir --out units.json
//! helm catalogue --bridge-dir --out
//! helm force --megamek --bridge-dir
//!
//! Argument parsing is by hand. A derive-macro dependency would cost more to
//! compile than it saves to read at this size.
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use helm_core::{Catalogue, ComputedStats, Provenance};
use helm_db::Inputs;
use helm_unitfile::mekset::Via;
fn main() -> ExitCode {
let args: Vec = std::env::args().skip(1).collect();
let result = match args.first().map(String::as_str) {
Some("build") => build(&args[1..]),
Some("stats") => stats(&args[1..]),
Some("bv-report") => bv_report(&args[1..]),
Some("art") => art(&args[1..]),
Some("index") => index(&args[1..]),
Some("catalogue") => catalogue(&args[1..]),
Some("wire-check") => wire_check(&args[1..]),
Some("force") => force(&args[1..]),
Some("check") => check(&args[1..]),
Some("repairs") => repairs(&args[1..]),
Some("wear") => wear(&args[1..]),
Some("-h") | Some("--help") | Some("help") | None => {
usage();
return ExitCode::SUCCESS;
}
Some(other) => Err(format!("unknown command: {other}")),
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("helm: {e}");
ExitCode::FAILURE
}
}
}
fn usage() {
eprintln!(
"helm - a queryable database of MegaMek's unit library
USAGE
helm build --megamek --out [--bridge-dir ]
helm stats
helm bv-report --megamek --bridge-dir
helm art --megamek [--out ]
helm index --megamek --bridge-dir --out [--helm-version []
helm force ] --megamek --bridge-dir
helm check --megamek --bridge-dir
helm repairs --megamek --bridge-dir
helm wear --to --megamek --bridge-dir
BUILD
--megamek A MegaMek install. Reads data/mekfiles/unit_files.zip,
or the loose .mtf and .blk files if the zip is absent.
--out Where to write the SQLite database.
--bridge-dir Directory holding units.jsonl and equipment.jsonl from
the Java bridge. Without it the database is built from
the unit files alone and the computed columns - battle
value, cost, Alpha Strike - are null.
--megamek-version Recorded in the meta table. Read from the install's
docs/history.txt when not given.
BV-REPORT
How helm-bv's battle values compare with MegaMek's own, design by design.
Needs both: the library to compute from, and the bridge dump to check
against. Exits non-zero while anything still disagrees.
--unit Print one design's working beside MegaMek's instead of
the whole report. Matches on any part of the name.
--clusters Group the designs that still disagree by what they have
in common, worst first. What to fix next, rather than
which single design is furthest out.
--label List the designs that disagree and carry that label, and
which half of the calculation is wrong. `*` lists every
design that disagrees. Implies --clusters.
--rules How many designs each design-level rule fires on. A rule
firing on nothing is a typo or a component this MegaMek
does not ship, and both look like an unwritten rule.
--bench What a recompute costs, split by what changed. Reseating
a pilot and repairing armour are not the same work.
--damaged Compare against damaged.jsonl instead of the whole
library: what a handful of designs are worth once they
have been shot at, which a .mtf cannot say.
CHECK
What is wrong with a .mul before anything else touches it: designs that are
not in the library, crews outside 0-8, locations a shape does not have,
more plate or frame than a design was built with. Exits non-zero when a
file has something wrong with it.
WEAR
Damage a design until it is worth a given battle value, for fitting a
machine that has already been in a fight into a scenario's budget. Plate
first, then the frame under it, spread evenly across the locations.
--to The figure to aim for. Required.
--battle Damage it in a battle instead of shaving plate evenly:
fire lands where the hit table sends it, clusters, and
takes equipment out when it gets through. Every seed is
a different machine worth the same, and none of them is
a mission kill.
--seed One battle in particular. Implies --battle.
--write Write the worn machine out as a .mul, so it can be
loaded into MegaMek rather than only read here.
REPAIRS
What a force needs putting right, location by location: points of plate,
points of frame, the equipment shot out and the magazines to fill.
--why Add what each machine's damage cost it in battle value.
FORCE
What each machine in a .mul is worth in the state the file leaves it in,
as flown by the crew the file seats.
--why For each damaged machine, where its value went: the
terms that moved against the design as it left the
factory, worst loss first.
"
);
}
struct Opts {
megamek: Option,
out: Option,
bridge_dir: Option,
version: Option,
unit: Option,
clusters: bool,
/// `index`: every unit type rather than Meks alone.
all_types: bool,
/// `index`: the release id of the helm that built it.
helm_version: Option,
label: Option,
rules: bool,
bench: bool,
damaged: bool,
why: bool,
to: Option,
out_mul: Option,
battle: bool,
seed: Option,
}
fn parse_opts(args: &[String]) -> Result {
let mut o = Opts {
megamek: None,
out: None,
bridge_dir: None,
version: None,
unit: None,
clusters: false,
all_types: false,
helm_version: None,
label: None,
rules: false,
bench: false,
damaged: false,
why: false,
to: None,
out_mul: None,
battle: false,
seed: None,
};
let mut i = 0;
while i < args.len() {
let need = |i: usize| -> Result {
args.get(i + 1)
.cloned()
.ok_or_else(|| format!("{} needs a value", args[i]))
};
match args[i].as_str() {
"--megamek" => {
o.megamek = Some(PathBuf::from(need(i)?));
i += 2;
}
"--out" => {
o.out = Some(PathBuf::from(need(i)?));
i += 2;
}
"--bridge-dir" => {
o.bridge_dir = Some(PathBuf::from(need(i)?));
i += 2;
}
"--megamek-version" => {
o.version = Some(need(i)?);
i += 2;
}
"--helm-version" => {
o.helm_version = Some(need(i)?);
i += 2;
}
"--unit" => {
o.unit = Some(need(i)?);
i += 2;
}
"--clusters" => {
o.clusters = true;
i += 1;
}
"--all-types" => {
o.all_types = true;
i += 1;
}
"--label" => {
o.label = Some(need(i)?);
o.clusters = true;
i += 2;
}
"--bench" => {
o.bench = true;
i += 1;
}
"--write" => {
o.out_mul = Some(PathBuf::from(need(i)?));
i += 2;
}
"--to" => {
o.to = Some(
need(i)?
.parse()
.map_err(|_| "--to takes a battle value".to_string())?,
);
i += 2;
}
"--battle" => {
o.battle = true;
i += 1;
}
"--seed" => {
o.seed = Some(
need(i)?
.parse()
.map_err(|_| "--seed takes a number".to_string())?,
);
o.battle = true;
i += 2;
}
"--damaged" => {
o.damaged = true;
i += 1;
}
"--why" => {
o.why = true;
i += 1;
}
"--rules" => {
o.rules = true;
i += 1;
}
other => return Err(format!("unknown flag: {other}")),
}
}
Ok(o)
}
/// Read a library out of an install, reporting what failed to parse.
///
/// Prefers the zip MegaMek ships and falls back to loose files, which is what
/// an extracted or partially unpacked install looks like.
fn read_library(megamek: &Path) -> Result {
let zip = megamek.join("data/mekfiles/unit_files.zip");
let library = if zip.is_file() {
eprintln!("reading {}", zip.display());
let file = std::fs::File::open(&zip).map_err(|e| format!("{}: {e}", zip.display()))?;
helm_unitfile::read_zip(file).map_err(|e| e.to_string())?
} else {
let dir = megamek.join("data/mekfiles");
let dir = if dir.is_dir() {
dir
} else {
megamek.to_path_buf()
};
eprintln!("reading {}", dir.display());
helm_unitfile::read_dir(&dir).map_err(|e| e.to_string())?
};
eprintln!("parsed {} units", library.units.len());
if !library.failures.is_empty() {
eprintln!("{} files failed to parse:", library.failures.len());
for (path, reason) in library.failures.iter().take(10) {
eprintln!(" {path}: {reason}");
}
if library.failures.len() > 10 {
eprintln!(" ... and {} more", library.failures.len() - 10);
}
}
Ok(library)
}
/// The MegaMek version to record: what was asked for, else what the install
/// says it is. A flag that can be typed wrong is a provenance hole, and the
/// install can be asked.
fn resolve_version(megamek: &Path, asked: Option) -> String {
if let Some(v) = asked {
return v;
}
match helm_unitfile::megamek_version(megamek) {
Some(v) => {
eprintln!("megamek {v} (read from the install)");
v
}
None => {
eprintln!("could not read a version from {}", megamek.display());
"unknown".to_string()
}
}
}
fn build(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let out = opts.out.ok_or("--out is required")?;
let version = resolve_version(&megamek, opts.version);
let library = read_library(&megamek)?;
// 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: 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()
));
}
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(&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 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!(
"wrote {} - {} units, {} equipment rows, {} quirks, {} crit slots, {} catalogue entries",
out.display(),
stats.units,
stats.equipment_rows,
stats.quirk_rows,
stats.critical_rows,
stats.catalogue_rows
);
if had_producer {
let missed = stats.units.saturating_sub(stats.with_stats);
eprintln!(
" {} units carry computed values, {missed} matched no record ({})",
stats.with_stats, provenance.stats_producer
);
}
if !stats.fts {
eprintln!(" note: this SQLite has no FTS5, so units_fts was not built");
}
Ok(())
}
fn stats(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("stats needs a database path")?;
let db = rusqlite::Connection::open(Path::new(path)).map_err(|e| e.to_string())?;
let mut stmt = db
.prepare("SELECT key, value FROM meta ORDER BY key")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.map_err(|e| e.to_string())?;
for row in rows {
let (k, v) = row.map_err(|e| e.to_string())?;
println!("{k:20} {v}");
}
let mut stmt = db
.prepare("SELECT unit_type, COUNT(*) FROM units GROUP BY unit_type ORDER BY 2 DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| e.to_string())?;
println!("\nby unit type:");
for row in rows {
let (t, n) = row.map_err(|e| e.to_string())?;
println!(" {n:6} {t}");
}
Ok(())
}
/// Print how helm-bv's answers compare with MegaMek's, and exit non-zero
/// while any of them disagree.
///
/// The same report the conformance test pins, printed for a human. It is
/// deliberately the same run - `Conformance::over` is one function - so that a
/// number seen here and a number seen in CI cannot differ by anything except
/// their inputs.
fn bv_report(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: there is nothing to check against without it")?;
let version = resolve_version(&megamek, opts.version);
let library = read_library(&megamek)?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let mut megamek_bv: std::collections::BTreeMap =
helm_bridge::read_units(&bridge.join("units.jsonl"))
.map_err(|e| e.to_string())?
.into_iter()
.filter_map(|s| {
let mut b = helm_core::BvBreakdown::new(&s.name);
b.battle_value = Some(s.battle_value?);
Some((s.name, b))
})
.collect();
// The bv dumps carry the working of MegaMek's calculation and not only its
// answer, so each rating can be checked on its own - Meks in one file and
// vehicles in the other. A bridge dump made before they existed still
// checks the total.
let mut found = false;
for file in ["bv.jsonl", "bv-vehicle.jsonl"] {
let path = bridge.join(file);
if !path.is_file() {
continue;
}
found = true;
for b in helm_bridge::read_bv(&path).map_err(|e| e.to_string())? {
megamek_bv.insert(b.name.clone(), b);
}
}
if !found {
eprintln!(
"no bv dump in {}: only the total is checkable",
bridge.display()
);
}
if let Some(needle) = &opts.unit {
return one_unit(&library, &catalogue, &megamek_bv, needle);
}
if opts.damaged {
return print_damaged(&library, &catalogue, &bridge);
}
if opts.damaged {
return print_damaged(&library, &catalogue, &bridge);
}
if opts.bench {
return print_bench(&library, &catalogue);
}
if opts.rules {
return print_rules(&library, &catalogue);
}
if opts.clusters {
return print_clusters(&library, &catalogue, &megamek_bv, opts.label.as_deref());
}
let report = helm_bv::Conformance::over(&library.units, &catalogue, &megamek_bv, &version);
println!("{report}");
if report.is_clean() {
Ok(())
} else {
Err(format!(
"{} of {} scored designs do not yet match MegaMek's battle value",
report.in_scope() - report.battle_value().matched(),
report.in_scope()
))
}
}
/// Print one design's working beside MegaMek's, for chasing a disagreement.
///
/// The conformance report says how many designs are wrong and by how much; it
/// cannot say which term. This can, and it is the difference between a search
/// and a bug report.
fn one_unit(
library: &helm_unitfile::Library,
catalogue: &Catalogue,
megamek: &std::collections::BTreeMap,
needle: &str,
) -> Result<(), String> {
let lower = needle.to_ascii_lowercase();
// An exact name wins outright. Twenty designs contain "Rhino" and one of
// them is called it, so asking by full name should not be ambiguous.
let matches: Vec<_> = library
.units
.iter()
.filter(|u| u.display_name().eq_ignore_ascii_case(needle))
.collect();
let matches: Vec<_> = if matches.is_empty() {
library
.units
.iter()
.filter(|u| u.display_name().to_ascii_lowercase().contains(&lower))
.collect()
} else {
matches
};
let unit = match matches.as_slice() {
[] => return Err(format!("no design matches {needle:?}")),
[only] => *only,
many => {
eprintln!("{} designs match {needle:?}:", many.len());
for u in many.iter().take(20) {
eprintln!(" {}", u.display_name());
}
return Err("be more specific".into());
}
};
let name = unit.display_name();
println!("{name}");
println!(
" {} tons, {} walk, {} jump, {}, {}",
unit.mass.unwrap_or_default(),
unit.walk_mp.unwrap_or_default(),
unit.jump_mp.unwrap_or_default(),
unit.engine.as_deref().unwrap_or("no engine"),
unit.heat_sinks.as_deref().unwrap_or("no heat sinks"),
);
let machine = helm_bv::Machine::read(unit, catalogue);
match machine
.as_ref()
.map_err(Clone::clone)
.and_then(helm_bv::defensive)
{
Ok(d) => {
println!("\ndefensive");
println!(" armour {:>10.1}", d.armor);
println!(" structure {:>10.1}", d.structure);
println!(" gyro {:>10.1}", d.gyro);
println!(" equipment {:>10.1}", d.equipment);
println!(" explosive {:>10.1}", d.explosive);
println!(" subtotal {:>10.1}", d.subtotal);
println!(" x factor {:>10.2}", d.factor);
println!(" = {:>22.1}", d.total);
}
Err(e) => println!("\ndefensive: {e}"),
}
match machine
.as_ref()
.map_err(Clone::clone)
.and_then(helm_bv::offensive)
{
Ok(o) => {
println!("\noffensive");
println!(" weapons {:>10.1}", o.weapons);
println!(" ammo {:>10.1}", o.ammo);
println!(" weight {:>10.1}", o.weight);
println!(" subtotal {:>10.1}", o.subtotal);
println!(" x speed {:>10.2}", o.speed_factor);
println!(" = {:>22.1}", o.total);
println!(
" (heat budget {}, weapons make {:.1})",
o.heat_budget, o.heat_used
);
}
Err(e) => println!("\noffensive: {e}"),
}
let items = helm_bv::classify(unit, catalogue);
if !items.is_empty() {
println!("\nequipment");
for i in &items {
println!(
" {:<34} {:>3} slots bv {:>7.1} {}",
i.name, i.slots, i.battle_value, i.role
);
}
}
if let Some(theirs) = megamek.get(&name) {
println!("\nmegamek");
println!(
" defensive {:>10.1}",
theirs.defensive.unwrap_or_default()
);
println!(
" offensive {:>10.1}",
theirs.offensive.unwrap_or_default()
);
println!(
" battle value {:>10}",
theirs.battle_value.unwrap_or_default()
);
} else {
println!("\nmegamek has no record for this design");
}
Ok(())
}
/// Say what is wrong with a `.mul`, and exit non-zero if anything is.
///
/// The file a player uploads is the one thing here that nobody in this
/// repository wrote, so it is the one that has to be told it is wrong rather
/// than quietly read for whatever parts made sense.
fn check(args: &[String]) -> Result<(), String> {
let path = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("give a .mul to check: helm check --megamek --bridge-dir ")?;
let opts = parse_opts(&args[1..])?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: the catalogue lives there")?;
let library = read_library(&megamek)?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let mul = helm_unitfile::parse_mul(&text).map_err(|e| format!("{path}: {e}"))?;
let mut wrong = 0;
let mut unsupported = 0;
let mut machines = 0;
for (at, entity) in mul.machines().enumerate() {
machines += 1;
let design = library
.units
.iter()
.find(|u| u.answers_to(&entity.chassis, &entity.model));
let troubles = helm_bv::check(design, &catalogue, entity);
if troubles.is_empty() {
continue;
}
if troubles
.iter()
.any(|t| t.severity() == helm_bv::Severity::Wrong)
{
wrong += 1;
} else {
unsupported += 1;
}
println!("#{} {}", at + 1, entity.display_name());
for trouble in &troubles {
println!(" {trouble}");
}
}
// Only the file's own faults fail the command. A force of tanks is a
// good file that helm cannot score, and exiting non-zero for that would
// be reporting our own gap as the player's mistake.
println!("{machines} machines, {wrong} with something wrong, {unsupported} helm cannot score");
if wrong == 0 {
return Ok(());
}
Err(format!(
"{wrong} of {machines} machines have something wrong"
))
}
/// Wear a design down to a battle value.
fn wear(args: &[String]) -> Result<(), String> {
let name = args.first().filter(|a| !a.starts_with("--")).ok_or(
"name a design: helm wear \"Atlas AS7-D\" --to 1500 --megamek --bridge-dir ",
)?;
let opts = parse_opts(&args[1..])?;
let target = opts
.to
.ok_or("--to is required: there is nothing to aim at without it")?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: the catalogue lives there")?;
let library = read_library(&megamek)?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let unit = library
.units
.iter()
.find(|u| u.display_name().eq_ignore_ascii_case(name))
.ok_or_else(|| format!("no design called {name}"))?;
let worn = if opts.battle {
let battle = helm_bv::Battle {
seed: opts.seed.unwrap_or(1),
..helm_bv::Battle::default()
};
// A handful of battles rather than one: damage is drawn rather than
// solved, so the first one can overshoot where the third lands on the
// figure. A named seed asks for one battle in particular.
let tries = if opts.seed.is_some() { 1 } else { 6 };
helm_bv::wear_in_battles(unit, &catalogue, target, &battle, tries)
} else {
helm_bv::wear_to(unit, &catalogue, target)
}
.map_err(|e| e.to_string())?;
println!(
"{}{} is {} whole, {} worn{}",
unit.display_name(),
match worn.seed {
Some(seed) => format!(" (battle {seed})"),
None => String::new(),
},
worn.whole,
worn.value,
if worn.is_exact() {
String::new()
} else {
format!(" - {} off {target}", worn.missed_by)
}
);
let repairs = helm_bv::repairs(unit, &worn.condition);
for repair in &repairs {
let mut parts = Vec::new();
if repair.armor > 0 {
parts.push(format!("{} armour", repair.armor));
}
if repair.rear_armor > 0 {
parts.push(format!("{} rear armour", repair.rear_armor));
}
if repair.structure > 0 {
parts.push(format!("{} structure", repair.structure));
}
println!(" {:<16} {}", repair.location, parts.join(", "));
// What a penetrating hit took with it, which is the part of a worn
// machine that makes it that machine rather than a generic one.
for slot in &repair.destroyed {
println!(" {:<16} slot {}: {}", "", slot.at + 1, slot.what);
}
}
let total = helm_bv::Total::of(&repairs);
println!(
" {:<16} {} armour, {} structure and {} items gone",
"", total.armor, total.structure, total.items
);
if let Some(path) = &opts.out_mul {
let mul = helm_unitfile::Mul {
units: vec![helm_unitfile::MulUnit {
chassis: unit.chassis.clone(),
model: unit.model.clone(),
unit_type: Some(
helm_core::Shape::from_config(unit.config.as_deref())
.megamek_name()
.to_string(),
),
gunnery: 4,
piloting: 5,
armor: worn.condition.armor.clone(),
structure: worn.condition.structure.clone(),
destroyed: worn.condition.destroyed.clone(),
empty_ammo: worn.condition.empty_ammo.clone(),
..Default::default()
}],
..Default::default()
};
std::fs::write(path, helm_unitfile::write_mul(&mul))
.map_err(|e| format!("{}: {e}", path.display()))?;
println!("\nwrote {}", path.display());
}
Ok(())
}
/// What a post-match `.mul` would take to put right.
///
/// The other half of reading a file that came back from a battle: `--why` says
/// what the damage cost, and this says what it would take to undo.
fn repairs(args: &[String]) -> Result<(), String> {
let path = args.first().filter(|a| !a.starts_with("--")).ok_or(
"give a .mul to read: helm repairs --megamek --bridge-dir ",
)?;
let opts = parse_opts(&args[1..])?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: the catalogue lives there")?;
let library = read_library(&megamek)?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let mul = helm_unitfile::parse_mul(&text).map_err(|e| format!("{path}: {e}"))?;
let mut force = helm_bv::Total::default();
for entity in mul.machines() {
let Some(unit) = library
.units
.iter()
.find(|u| u.answers_to(&entity.chassis, &entity.model))
else {
println!("{:<34} not in the library", entity.display_name());
continue;
};
let condition = helm_bv::Condition::from(entity);
let repairs = helm_bv::repairs(unit, &condition);
let total = helm_bv::Total::of(&repairs);
force.armor += total.armor;
force.structure += total.structure;
force.items += total.items;
force.reloads += total.reloads;
force.gone += total.gone;
if total.is_nothing() {
println!("{:<34} came back whole", entity.display_name());
continue;
}
println!(
"{:<34} {} armour, {} structure, {} items, {} magazines",
entity.display_name(),
total.armor,
total.structure,
total.items,
total.reloads
);
for repair in &repairs {
let mut parts = Vec::new();
if repair.armor > 0 {
parts.push(format!("{} armour", repair.armor));
}
if repair.rear_armor > 0 {
parts.push(format!("{} rear armour", repair.rear_armor));
}
if repair.structure > 0 {
parts.push(format!("{} structure", repair.structure));
}
println!(
" {:<16} {}{}",
repair.location,
parts.join(", "),
if repair.gone { " (location gone)" } else { "" }
);
for slot in &repair.destroyed {
println!(" {:<16} slot {}: {}", "", slot.at + 1, slot.what);
}
for slot in &repair.reload {
println!(
" {:<16} slot {}: {} is empty",
"",
slot.at + 1,
slot.what
);
}
}
if opts.why {
print_attribution(unit, &catalogue, &condition)?;
}
}
println!(
"\n{} armour, {} structure, {} items, {} magazines{}",
force.armor,
force.structure,
force.items,
force.reloads,
if force.gone > 0 {
format!(", {} locations gone", force.gone)
} else {
String::new()
}
);
Ok(())
}
/// Say where a damaged machine's battle value went, term by term.
///
/// Against the design as it left the factory, because that is the figure a
/// player knows it by. Only the terms that moved are printed, worst loss
/// first, so a report on a lance is as long as the damage rather than as long
/// as the rules.
fn print_attribution(
unit: &helm_core::Unit,
catalogue: &Catalogue,
condition: &helm_bv::Condition,
) -> Result<(), String> {
let fresh = helm_bv::Condition::undamaged();
let a = helm_bv::attribute(unit, catalogue, &fresh, condition).map_err(|e| e.to_string())?;
println!(" {} -> {} ({:+})", a.before, a.after, a.points());
for change in &a.changes {
let width = if change.term.is_factor() { 2 } else { 1 };
println!(
" {:<22} {:>9.*} -> {:<9.*} {:>+8.1}",
change.term.label(),
width,
change.before,
width,
change.after,
change.points
);
}
// Printed rather than hidden: it is the rounding and MegaMek's floor under
// a defensive rating, and a number here bigger than a point means the
// split above is wrong rather than merely approximate.
if a.residual.abs() >= 0.05 {
println!(" {:<22} {:>+30.1}", "rounding", a.residual);
}
Ok(())
}
/// Group the designs that still disagree by what they have in common.
///
/// The conformance report says how many are wrong; this says what kind. A
/// label carrying five hundred wrong designs is an afternoon that fixes five
/// hundred designs, and the ranked list of individual disagreements never
/// shows that - it shows whichever three designs are strangest.
fn print_clusters(
library: &helm_unitfile::Library,
catalogue: &Catalogue,
megamek: &std::collections::BTreeMap,
label: Option<&str>,
) -> Result<(), String> {
let mut designs = Vec::new();
for unit in &library.units {
let name = unit.display_name();
let Some(theirs) = megamek.get(&name).and_then(|b| b.battle_value) else {
continue;
};
let verdict = match helm_bv::battle_value(unit, catalogue) {
Ok(ours) => Some(ours == theirs),
// Declining by unit type is out of scope, not a gap.
Err(helm_bv::Unsupported::UnitType(_)) => continue,
Err(_) => None,
};
designs.push((unit, verdict));
}
let total = designs.len();
let matched = designs.iter().filter(|(_, v)| *v == Some(true)).count();
println!(
"{matched} of {total} in-scope designs match. Grouping the {} that do not:\n",
total - matched
);
println!("{:>6} {:>6} {:>6} label", "wrong", "of", "rate");
// One label's failures, when that is what was asked for. A cluster says a
// rule is missing; this says which designs and which half of the
// calculation, which is the next question every time.
if let Some(label) = label {
let mut listed = 0;
for (unit, verdict) in &designs {
let carries =
label == "*" || helm_bv::labels(unit, catalogue).iter().any(|l| l == label);
// Only designs that produced an answer and got it wrong. One this
// crate declines is reported by the summary under the reason it
// declined, and listing it here as a disagreement made the two
// counts differ by the number of LAMs.
if *verdict != Some(false) || !carries {
continue;
}
let name = unit.display_name();
let theirs = megamek.get(&name);
let machine = helm_bv::Machine::read(unit, catalogue).ok();
let gap = |ours: Option, theirs: Option| match (ours, theirs) {
(Some(a), Some(b)) if (a - b).abs() <= helm_core::RATING_TOLERANCE => {
" ok".to_string()
}
(Some(a), Some(b)) => format!("{:+7.1}", a - b),
_ => " -".to_string(),
};
println!(
" {name:<38} def {} off {}",
gap(
machine
.as_ref()
.and_then(|m| helm_bv::defensive(m).ok())
.map(|d| d.total),
theirs.and_then(|b| b.defensive)
),
gap(
machine
.as_ref()
.and_then(|m| helm_bv::offensive(m).ok())
.map(|o| o.total),
theirs.and_then(|b| b.offensive)
),
);
listed += 1;
}
if label == "*" {
println!("\n{listed} designs disagree.");
} else {
println!("\n{listed} designs carrying {label:?} disagree.");
}
return Ok(());
}
// The plainest failures first. A design that is wrong while carrying
// nothing unusual is wrong in arithmetic every other design shares, and
// that is worth more than the strangest design in the library.
let mut plain: Vec<_> = designs
.iter()
.filter(|(_, v)| *v != Some(true))
.map(|(unit, _)| (helm_bv::oddity(unit, catalogue), *unit))
.collect();
plain.sort_by(|a, b| {
a.0.cmp(&b.0)
.then_with(|| a.1.display_name().cmp(&b.1.display_name()))
});
println!("\nthe most ordinary designs that still disagree");
for (oddity, unit) in plain.iter().take(15) {
let name = unit.display_name();
let theirs = megamek
.get(&name)
.and_then(|b| b.battle_value)
.unwrap_or_default();
let ours = helm_bv::battle_value(unit, catalogue)
.map(|v| v.to_string())
.unwrap_or_else(|e| e.to_string());
let notable: Vec = helm_bv::labels(unit, catalogue)
.into_iter()
.filter(|l| !l.starts_with("config:") && !l.starts_with("tech:"))
.collect();
println!(
" {oddity:>2} odd {name:<34} ours {ours:>6} megamek {theirs:>6} {}",
notable.join(", ")
);
}
println!();
for c in helm_bv::clusters(designs, catalogue) {
let wrong = c.differed + c.unscored;
// A label with nothing wrong is noise, and so is one carrying a
// handful of designs - it cannot explain anything.
if wrong == 0 || c.total() < 5 {
continue;
}
let unscored = if c.unscored > 0 {
format!(" ({} unscored)", c.unscored)
} else {
String::new()
};
println!(
"{:>6} {:>6} {:>5.0}% {:<34}{}",
wrong,
c.total(),
100.0 * c.rate(),
c.label,
unscored
);
}
Ok(())
}
/// How many designs each design-level rule fires on.
///
/// The question this answers is "does this rule do anything at all". A wrong
/// flag name does not fail - the lookup matches nothing and the rule silently
/// stops applying, which looks exactly like a rule nobody has written yet.
/// A zero here says which.
fn print_rules(library: &helm_unitfile::Library, catalogue: &Catalogue) -> Result<(), String> {
let meks: Vec<_> = library
.units
.iter()
.filter_map(|unit| helm_bv::Machine::read(unit, catalogue).ok())
.collect();
let mut counts: std::collections::BTreeMap<&str, usize> =
helm_bv::RULES.iter().map(|rule| (rule.name, 0)).collect();
for machine in &meks {
for name in helm_bv::fired(machine) {
*counts.entry(name).or_default() += 1;
}
}
println!("{} designs read\n", meks.len());
println!("{:>7} rule", "designs");
let mut rows: Vec<_> = counts.into_iter().collect();
rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
for (name, n) in &rows {
let note = if *n == 0 {
" <- fires on nothing"
} else {
""
};
println!("{n:>7} {name}{note}");
}
if rows.iter().any(|(_, n)| *n == 0) {
return Err("a rule fires on no design at all; check its flag name".into());
}
Ok(())
}
/// How helm scores designs that have been shot at, against MegaMek's own
/// answers for the same states.
///
/// A `.mtf` describes a design as it leaves the factory, so this is the only
/// way to check the rules that only damage reaches - and it is the report the
/// conformance test asserts on, printed for a human.
fn print_damaged(
library: &helm_unitfile::Library,
catalogue: &Catalogue,
bridge: &std::path::Path,
) -> Result<(), String> {
let path = bridge.join("damaged.jsonl");
let rows = helm_bridge::read_damaged(&path).map_err(|e| format!("{}: {e}", path.display()))?;
if rows.is_empty() {
return Err(format!(
"{} holds no damaged designs; run bridge/dump.sh to make some",
path.display()
));
}
let mut checked = 0;
let mut wrong = 0;
let mut last = String::new();
for row in &rows {
let Some(unit) = library.units.iter().find(|u| u.display_name() == row.name) else {
continue;
};
let condition = helm_bv::Condition {
armor: row.armor.clone(),
structure: row.structure.clone(),
destroyed: row.destroyed.clone(),
empty_ammo: row.empty_ammo.clone(),
loaded: Default::default(),
};
checked += 1;
if row.name != last {
println!("{}", row.name);
last = row.name.clone();
}
match helm_bv::battle_value_in(unit, catalogue, &condition) {
Ok(ours) if ours == row.battle_value => {
println!(" {:<18} {:>6}", row.scenario, ours);
}
Ok(ours) => {
wrong += 1;
println!(
" {:<18} {:>6} megamek {} ({:+})",
row.scenario,
ours,
row.battle_value,
ours - row.battle_value
);
}
Err(why) => {
wrong += 1;
println!(" {:<18} {why}", row.scenario);
}
}
}
println!("\n{} of {checked} states agree", checked - wrong);
if wrong > 0 {
return Err(format!("{wrong} damaged states disagree"));
}
Ok(())
}
/// Time the operations a force-building screen actually performs.
///
/// The question this answers is whether a browser can recompute battle value
/// as somebody drags a slider, and the useful answer is not one number. The
/// three things a player does cost wildly different amounts:
///
/// * **Reseating a pilot** changes no part of the design. Battle value is the
/// design's figure times a number from a 9x9 table, so nothing is
/// recomputed at all.
/// * **Repairing or damaging** changes the armour, so the ratings have to be
/// worked out again - but the loadout has not moved, so the expensive part
/// does not have to be redone.
/// * **Changing the loadout** invalidates everything, including reading the
/// design's critical slots against the catalogue, which is where most of the
/// time goes.
fn print_bench(library: &helm_unitfile::Library, catalogue: &Catalogue) -> Result<(), String> {
use std::time::Instant;
let meks: Vec<&helm_core::Unit> = library
.units
.iter()
.filter(|u| helm_bv::Machine::read(u, catalogue).is_ok())
.collect();
if meks.is_empty() {
return Err("no scoreable designs; is the catalogue loaded?".into());
}
// Enough repetitions that the clock is not the thing being measured.
let rounds = 20;
let total = meks.len() * rounds;
let per = |elapsed: std::time::Duration| elapsed.as_secs_f64() * 1e9 / total as f64;
// Everything: read the design and score it. What a loadout change costs.
let start = Instant::now();
let mut sink = 0i64;
for _ in 0..rounds {
for unit in &meks {
sink += helm_bv::battle_value(unit, catalogue).unwrap_or(0);
}
}
let whole = per(start.elapsed());
// Reading alone, which is the part a repair does not have to repeat.
let start = Instant::now();
for _ in 0..rounds {
for unit in &meks {
sink += helm_bv::Machine::read(unit, catalogue)
.map(|m| m.tons as i64)
.unwrap_or(0);
}
}
let reading = per(start.elapsed());
// Scoring a design already read. What a repair costs.
let prepared: Vec> = meks
.iter()
.filter_map(|u| helm_bv::Machine::read(u, catalogue).ok())
.collect();
let start = Instant::now();
for _ in 0..rounds {
for machine in &prepared {
sink += helm_bv::defensive(machine)
.map(|d| d.total as i64)
.unwrap_or(0)
+ helm_bv::offensive(machine)
.map(|o| o.total as i64)
.unwrap_or(0);
}
}
let scoring = per(start.elapsed());
// Reseating a pilot: a table lookup and a multiply, over a design whose
// own battle value has not changed.
let start = Instant::now();
for round in 0..rounds {
for (i, _) in meks.iter().enumerate() {
let pilot = helm_force::Pilot::new((i % 8) as u8, (round % 8) as u8);
sink += (2000.0 * pilot.battle_value_multiplier()) as i64;
}
}
let reseating = per(start.elapsed());
println!("{} designs, {rounds} rounds each\n", meks.len());
println!("{:>12} what changed", "ns/unit");
println!("{reseating:>12.0} the pilot (a table lookup; nothing is recomputed)");
println!("{scoring:>12.0} armour or damage (both ratings, loadout already read)");
println!("{reading:>12.0} reading the design (resolving every critical slot)");
println!("{whole:>12.0} the loadout (reading and scoring together)");
println!("\nwhat that means for a screen, at these rates:");
for (units, label) in [(4, "a lance"), (12, "a company"), (36, "a battalion")] {
println!(
" {label:<12} {:>7.3} ms to rescore after a repair, {:>7.3} ms from scratch",
scoring * units as f64 / 1e6,
whole * units as f64 / 1e6
);
}
// Keep the optimiser from deleting the work being measured.
if sink == i64::MIN {
println!("(unreachable)");
}
Ok(())
}
/// Write what a browser needs to score the whole library, and what this build
/// says the answers are.
///
/// The rules compile twice, natively for the server and to wasm for the page,
/// and the whole reason `helm-bv` does no I/O is that both have to agree.
/// Nothing proved they did: the smoke test crosses the boundary with a handful
/// of designs. This writes the bundle that lets `crates/helm-wasm/wire.mjs`
/// check every one of them, which is a different claim entirely.
///
/// Three files, all derived and none committed: the catalogue a page loads,
/// every design as the text a page would be handed, and this build's answer
/// for each, fresh and shot at - because a condition crossing the boundary as
/// JSON is its own chance to disagree.
fn wire_check(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: the catalogue lives there")?;
let out = opts.out.ok_or("--out is required")?;
std::fs::create_dir_all(&out).map_err(|e| format!("{}: {e}", out.display()))?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let zip = megamek.join("data/mekfiles/unit_files.zip");
let file = std::fs::File::open(&zip).map_err(|e| format!("{}: {e}", zip.display()))?;
let texts = helm_unitfile::read_zip_text(file).map_err(|e| e.to_string())?;
// The condition every design is also scored in, chosen to touch the parts
// of a `Condition` that cross as JSON: plate, frame, a wrecked slot and an
// empty magazine.
let damaged = helm_bv::Condition {
armor: [("CT".to_string(), 3), ("LT".to_string(), 0)]
.into_iter()
.collect(),
structure: [("RT".to_string(), 2)].into_iter().collect(),
destroyed: [("Right Arm".to_string(), [4usize].into_iter().collect())]
.into_iter()
.collect(),
empty_ammo: [("Left Torso".to_string(), [1usize].into_iter().collect())]
.into_iter()
.collect(),
// Nothing swapped: what a slot holds is a `.mul`'s business, and this
// sweep reads `.mtf` files.
loaded: Default::default(),
};
let mut designs = String::new();
let mut native = String::new();
let mut written = 0;
for (path, text) in &texts {
let Ok(unit) = helm_unitfile::parse_mtf(path, text) else {
continue;
};
let Ok(fresh) = helm_bv::battle_value(&unit, &catalogue) else {
// Declined by decision or not yet written: a page will not be
// asked about it either.
continue;
};
let hurt = helm_bv::battle_value_in(&unit, &catalogue, &damaged).ok();
let name = unit.display_name();
designs.push_str(&serde_json::json!({ "name": name, "mtf": text }).to_string());
designs.push('\n');
native.push_str(
&serde_json::json!({ "name": name, "fresh": fresh, "damaged": hurt }).to_string(),
);
native.push('\n');
written += 1;
}
let write = |file: &str, body: &str| -> Result<(), String> {
let path = out.join(file);
std::fs::write(&path, body).map_err(|e| format!("{}: {e}", path.display()))
};
write("designs.jsonl", &designs)?;
write("native.jsonl", &native)?;
write(
"condition.json",
&serde_json::json!({
"armor": damaged.armor,
"structure": damaged.structure,
"destroyed": damaged.destroyed,
"emptyAmmo": damaged.empty_ammo,
})
.to_string(),
)?;
println!(
"wrote {} ({written} designs, {:.1} MB)",
out.display(),
designs.len() as f64 / 1_048_576.0
);
println!(
"check it with: node crates/helm-wasm/wire.mjs {} {}",
bridge.join("equipment.jsonl").display(),
out.display()
);
Ok(())
}
/// Write the equipment catalogue a browser needs, and nothing else.
///
/// The bridge dumps every field MegaMek's `EquipmentType` carries, which is
/// six megabytes for four thousand entries - a `namesVector` per row, a
/// `techAdvancement` per row, and several static tables repeated on every one
/// of them. The rules read about twenty of those fields.
///
/// Same format, same reader, a third of the size: 1.7MB rather than 6.0MB, and
/// 88ms rather than 216ms to parse in a browser. That is a page load, so it is
/// worth the one command.
/// `helm art` - which picture belongs to which design.
///
/// The art tree is 6,995 files and says nothing about which design uses which
/// of them; `mekset.txt` is the mapping, and reading it is the only part of
/// serving MegaMek's unit art that is not a file copy. So this writes the
/// mapping and leaves the bytes alone: the sync that puts the tree in a bucket
/// is `aws s3 sync data/images/units/`, and what needs generating is this.
///
/// `files` is what that sync is expected to carry, and `missing_files` is what
/// the set file names and the release does not ship - a sync that came out
/// short can be told from a release that was short to begin with.
fn art(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let version = resolve_version(&megamek, opts.version);
let units_dir = megamek.join("data/images/units");
if !units_dir.join("mekset.txt").is_file() {
return Err(format!(
"{} holds no mekset.txt - point --megamek at an install, not at its data directory",
units_dir.display()
));
}
let set = helm_unitfile::read_mekset(&units_dir).map_err(|e| e.to_string())?;
let (exact, chassis) = set.counts();
eprintln!("mekset: {exact} exact entries, {chassis} chassis entries");
let library = read_library(&megamek)?;
let mut rows = Vec::new();
let mut unmatched = Vec::new();
let mut files: BTreeSet = BTreeSet::new();
let mut by_via = [0usize; 3];
let mut units: Vec<&helm_core::Unit> = library.units.iter().collect();
units.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path)));
for unit in units {
match set.art_for(unit) {
Some(art) => {
let via = match art.via {
Via::Exact => "exact",
Via::Chassis => "chassis",
Via::Generic => "generic",
};
by_via[match art.via {
Via::Exact => 0,
Via::Chassis => 1,
Via::Generic => 2,
}] += 1;
files.insert(art.path.to_string());
rows.push(serde_json::json!({
"chassis": unit.chassis,
"model": unit.model,
"name": unit.name,
"unit_type": unit.unit_type,
"sprite": art.path,
"via": via,
}));
}
None => unmatched.push(unit.name.clone()),
}
}
// Named by the set file and not in the release. Reported rather than
// dropped: it is the one failure here that is upstream's rather than
// ours, and a sync cannot tell the two apart on its own.
let missing: Vec<&String> = files
.iter()
.filter(|path| !units_dir.join(path).is_file())
.collect();
let doc = serde_json::json!({
"megamek": version,
"source": "data/images/units/mekset.txt",
"prefix": "data/images/units",
"counts": {
"units": rows.len(),
"exact": by_via[0],
"chassis": by_via[1],
"generic": by_via[2],
"unmatched": unmatched.len(),
"files": files.len(),
"missing_files": missing.len(),
},
"units": rows,
"files": files,
"missing_files": missing,
"unmatched": unmatched,
});
let text = serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())?;
match &opts.out {
Some(out) => {
std::fs::write(out, format!("{text}\n"))
.map_err(|e| format!("{}: {e}", out.display()))?;
println!("wrote {}", out.display());
}
None => println!("{text}"),
}
println!(
"{} units: {} exact, {} chassis, {} generic, {} with no art",
rows.len(),
by_via[0],
by_via[1],
by_via[2],
unmatched.len()
);
println!(
"{} distinct files{}",
files.len(),
if missing.is_empty() {
String::new()
} else {
format!(", {} of them not in this release", missing.len())
}
);
Ok(())
}
/// `helm index` - the browser's view of the library.
///
/// The database is the server's and the agent's: 112MB of SQLite, indexed for
/// arbitrary queries. A force-building screen wants none of that and cannot
/// carry it. What it wants is every design it may offer, with the columns a
/// person filters and sorts on, small enough to fetch while somebody reads the
/// page.
///
/// So this is a *reduction* of the same read rather than a second extract with
/// its own truth: the same library, the same producer of computed values, the
/// same `mekset.txt` resolution `helm art` uses. A figure here and a figure in
/// the database cannot disagree, because neither is computed twice.
///
/// Every artefact is stamped with the MegaMek it came from and with what
/// produced its battle values. That is not bookkeeping: the art is served from
/// a bucket keyed by the same version, so an index that does not say which
/// release it describes is one whose sprites may or may not be there.
fn index(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let out = opts.out.ok_or("--out is required")?;
let version = resolve_version(&megamek, opts.version);
let library = read_library(&megamek)?;
let set = helm_unitfile::read_mekset(&megamek.join("data/images/units"))
.map_err(|e| e.to_string())?;
// The same choice `build` makes, and made in the same place: MegaMek's own
// figures when a bridge dump is at hand, ours when it is not. A screen
// showing a battle value the game will not agree with is worse than one
// showing none, so which producer answered is recorded rather than
// implied.
let mut by_name: std::collections::HashMap =
std::collections::HashMap::new();
let mut catalogue: Option = None;
let mut provenance = Provenance {
megamek_version: version.clone(),
stats_producer: "none".to_string(),
rules_version: None,
};
if let Some(dir) = &opts.bridge_dir {
let equipment = dir.join("equipment.jsonl");
if equipment.is_file() {
catalogue = Some(helm_bridge::read_catalogue(&equipment).map_err(|e| e.to_string())?);
}
let units = dir.join("units.jsonl");
if units.is_file() {
for stats in helm_bridge::read_units(&units).map_err(|e| e.to_string())? {
by_name.insert(stats.name.clone(), stats);
}
provenance = Provenance::from_bridge(&version);
}
}
let native = by_name.is_empty();
if native {
let Some(cat) = &catalogue else {
return Err(
"battle value needs the equipment catalogue: pass --bridge-dir with \
equipment.jsonl in it"
.to_string(),
);
};
for unit in &library.units {
if let Ok(bv) = helm_bv::battle_value(unit, cat) {
let mut stats = ComputedStats::new(unit.name.clone());
stats.battle_value = Some(bv);
by_name.insert(unit.name.clone(), stats);
}
}
provenance = Provenance {
megamek_version: version.clone(),
stats_producer: "helm-bv".to_string(),
rules_version: Some(env!("CARGO_PKG_VERSION").to_string()),
};
}
eprintln!(
"battle value: {} of {} designs, from {}",
by_name.len(),
library.units.len(),
provenance.stats_producer
);
if native {
// Canon and validity are MegaMek's judgement and arrive with its own
// figures. Without them nothing here can drop the 15 designs it would
// otherwise have dropped, and a screen would offer a unit MegaMek
// marks unplayable.
eprintln!("no units.jsonl: canon and validity are unknown, so nothing is filtered on them");
}
// Meks unless asked otherwise. The consumer is a force-building screen,
// and widening it triples the payload for units nothing offers yet.
let all_types = opts.all_types;
let mut units: Vec<&helm_core::Unit> = library
.units
.iter()
.filter(|u| all_types || u.unit_type.as_deref() == Some("Mek"))
.collect();
units.sort_by(|a, b| a.name.cmp(&b.name).then(a.path.cmp(&b.path)));
let mut rows = Vec::with_capacity(units.len());
let mut without_sprite = 0usize;
let mut without_bv = 0usize;
for unit in units {
let stats = by_name.get(&unit.name);
// Canon and validity are MegaMek's judgement and arrive with the
// computed values, so a library read without a producer cannot filter
// on them. Without one, everything is offered and the caller is told.
if let Some(s) = stats
&& (s.canon == Some(false) || s.invalid == Some(true))
{
continue;
}
let sprite = set.art_for(unit).map(|art| art.path.to_string());
if sprite.is_none() {
without_sprite += 1;
}
let bv = stats.and_then(|s| s.battle_value);
if bv.is_none() {
without_bv += 1;
}
let metrics = catalogue
.as_ref()
.map(|cat| helm_core::CombatMetrics::compute(unit, cat));
rows.push(serde_json::json!({
"chassis": unit.chassis,
"model": unit.model,
"name": unit.name,
"mul_id": unit.mul_id,
"unit_type": unit.unit_type,
"config": unit.config,
"tech_base": unit.tech_base,
"rules_level": unit.rules_level,
"role": unit.role,
"source": unit.source,
"year": unit.year,
"mass": unit.mass,
"walk_mp": unit.walk_mp,
"jump_mp": unit.jump_mp,
"run_mp": stats.and_then(|s| s.run_mp).or(unit.run_mp),
"total_armor": unit.total_armor(),
"bv": bv,
"cost": stats.and_then(|s| s.cost),
"weight_class": stats.and_then(|s| s.weight_class),
"omni": stats.and_then(|s| s.omni),
"clan": stats.and_then(|s| s.clan),
"sprite": sprite,
"firepower": metrics.as_ref().map(|m| m.firepower),
"max_range": metrics.as_ref().map(|m| m.max_range),
"heat_efficiency": metrics.as_ref().map(helm_core::CombatMetrics::heat_efficiency),
}));
}
let doc = serde_json::json!({
"megamek": provenance.megamek_version,
// Which helm wrote this, whichever producer filled the battle values.
// The file is a function of both: the columns and the derived figures
// are this code's, the library and the figures are that MegaMek's, and
// an artifact keyed by one of them alone is one that can be quietly
// replaced by a different build.
"helm": opts.helm_version,
"stats_producer": provenance.stats_producer,
"rules_version": provenance.rules_version,
// Relative to the release, and the prefix a sprite path is joined to.
// The bucket holds the tree at this path under the same version, so a
// consumer needs no second convention to turn one into an address.
"sprite_base": "data/images/units",
"counts": {
"units": rows.len(),
"without_battle_value": without_bv,
"without_sprite": without_sprite,
},
"units": rows,
});
let text = serde_json::to_string(&doc).map_err(|e| e.to_string())?;
std::fs::write(&out, format!("{text}\n")).map_err(|e| format!("{}: {e}", out.display()))?;
println!(
"wrote {} - {} units, {:.0} KB",
out.display(),
rows.len(),
text.len() as f64 / 1024.0
);
if without_bv > 0 || without_sprite > 0 {
println!(" {without_bv} without a battle value, {without_sprite} without art");
}
Ok(())
}
fn catalogue(args: &[String]) -> Result<(), String> {
let opts = parse_opts(args)?;
let bridge = opts.bridge_dir.ok_or("--bridge-dir is required")?;
let out = opts.out.ok_or("--out is required")?;
let text = std::fs::read_to_string(bridge.join("equipment.jsonl"))
.map_err(|e| format!("equipment.jsonl: {e}"))?;
let mut kept = String::with_capacity(text.len() / 3);
let mut entries = 0;
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let value: serde_json::Value =
serde_json::from_str(line).map_err(|e| format!("equipment.jsonl: {e}"))?;
let Some(object) = value.as_object() else {
continue;
};
let slim: serde_json::Map = object
.iter()
.filter(|(k, _)| helm_bridge::CATALOGUE_FIELDS.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
kept.push_str(&serde_json::Value::Object(slim).to_string());
kept.push('\n');
entries += 1;
}
std::fs::write(&out, &kept).map_err(|e| format!("{}: {e}", out.display()))?;
println!(
"wrote {} ({entries} entries, {:.1} MB from {:.1} MB)",
out.display(),
kept.len() as f64 / 1_048_576.0,
text.len() as f64 / 1_048_576.0
);
Ok(())
}
/// Score a force from a `.mul`, in whatever state the file left it in.
///
/// A `.mul` is MegaMek's own record of units in play and carries three of the
/// four scopes a battle value is worked out in: what each machine has lost,
/// who is flying it, and which force it belongs to. What it does not carry is
/// the designs, which is why this needs the library as well - an entity names
/// a design by chassis and model and says nothing about what is in it.
fn force(args: &[String]) -> Result<(), String> {
let path = args
.first()
.filter(|a| !a.starts_with("--"))
.ok_or("give a .mul to score: helm force --megamek --bridge-dir ")?;
let opts = parse_opts(&args[1..])?;
let megamek = opts.megamek.ok_or("--megamek is required")?;
let bridge = opts
.bridge_dir
.ok_or("--bridge-dir is required: the catalogue lives there")?;
let library = read_library(&megamek)?;
let catalogue =
helm_bridge::read_catalogue(&bridge.join("equipment.jsonl")).map_err(|e| e.to_string())?;
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let mul = helm_unitfile::parse_mul(&text).map_err(|e| format!("{path}: {e}"))?;
// Looked up by asking each design whether it answers to the name, because
// two tools spell a Clan design two ways: MegaMek keeps the second name
// apart and MekBay folds it into the chassis.
let find =
|chassis: &str, model: &str| library.units.iter().find(|u| u.answers_to(chassis, model));
// What each unit is worth before anything else has its say, which is what
// a C3 network is paid a share of.
let machines: Vec<&helm_unitfile::MulUnit> = mul.machines().collect();
let bases: Vec> = machines
.iter()
.copied()
.map(|entity| {
let unit = find(&entity.chassis, &entity.model)?;
helm_bv::battle_value_in(unit, &catalogue, &helm_bv::Condition::from(entity)).ok()
})
.collect();
let links: Vec = machines
.iter()
.copied()
.zip(&bases)
.map(|(entity, base)| helm_force::Linked {
kind: find(&entity.chassis, &entity.model)
.map(|unit| helm_bv::c3_kind(unit, &catalogue))
.unwrap_or_default(),
base: base.unwrap_or(0),
id: entity.c3_uuid.clone(),
master: entity.c3_master.clone(),
peers: entity.c3i_links.clone(),
})
.collect();
let c3 = helm_force::c3_bonuses_linked(&links);
let networked = c3.iter().filter(|bonus| **bonus > 0).count();
println!(
"{:<34} {:>8} {:>7} {:>6} {:>8}",
"unit", "crew", "base", "c3", "as flown"
);
let mut total = 0i64;
let mut missing = 0;
// One machine's line, wherever it is printed from. `indent` is how deep in
// the force tree it sits; a file with no forces passes none.
let mut show =
|at: usize, entity: &helm_unitfile::MulUnit, indent: &str| -> Result<(), String> {
let name = format!("{indent}{}", entity.display_name());
let Some(unit) = find(&entity.chassis, &entity.model) else {
println!("{name:<34} not in the library");
missing += 1;
return Ok(());
};
let condition = helm_bv::Condition::from(entity);
let pilot = helm_force::Pilot {
gunnery: entity.gunnery,
piloting: entity.piloting,
};
match helm_bv::battle_value_in(unit, &catalogue, &condition) {
Ok(base) => {
let bonus = c3[at];
let flown = helm_force::adjusted_battle_value_in_force(base, bonus, pilot);
total += flown;
println!(
"{:<34} {:>8} {:>7} {:>6} {:>8}",
name,
format!("{}/{}", entity.gunnery, entity.piloting),
base,
if bonus > 0 {
format!("+{bonus}")
} else {
String::new()
},
flown
);
if opts.why && !condition.is_undamaged() {
print_attribution(unit, &catalogue, &condition)?;
}
}
Err(e) => {
println!("{name:<34} {e}");
missing += 1;
}
}
Ok(())
};
// A `.mul` stores no force tree: each entity repeats its own chain, and
// helm-unitfile rebuilds one. MekBay writes forces, MegaMek's own bot
// files do not - so both shapes are printed, and a file with no forces
// looks exactly as it did before.
let tree = mul.force_tree();
if tree.is_empty() {
for (at, entity) in machines.iter().enumerate() {
show(at, entity, "")?;
}
} else {
// The tree indexes every entity; the table is machines only, crews
// that got out left off. This is the join between the two.
let mut machine_at = vec![None; mul.units.len()];
let mut next = 0;
for (index, unit) in mul.units.iter().enumerate() {
if !unit.is_crew() {
machine_at[index] = Some(next);
next += 1;
}
}
let mut stack: Vec<(&helm_unitfile::ForceNode, usize)> =
tree.iter().rev().map(|node| (node, 0usize)).collect();
while let Some((node, depth)) = stack.pop() {
let held = node.all_units().len();
println!(
"{}{} ({held} unit{})",
" ".repeat(depth),
node.name,
if held == 1 { "" } else { "s" }
);
let indent = " ".repeat(depth + 1);
for unit in &node.units {
if let Some(at) = machine_at[*unit] {
show(at, machines[at], &indent)?;
}
}
for child in node.children.iter().rev() {
stack.push((child, depth + 1));
}
}
let loose: Vec = mul
.loose_units()
.into_iter()
.filter_map(|unit| machine_at[unit])
.collect();
if !loose.is_empty() {
println!(
"in no force ({} unit{})",
loose.len(),
if loose.len() == 1 { "" } else { "s" }
);
for at in loose {
show(at, machines[at], " ")?;
}
}
}
println!("{:<34} {:>8} {:>7} {:>6} {:>8}", "", "", "", "", total);
if networked > 0 {
println!("({networked} units on a C3 network, paid a share of it)");
}
if missing > 0 {
println!("({missing} not scored)");
}
Ok(())
}