Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927//! `helm` - build and inspect the unit database.//!//! helm build --megamek <dir> --out helm.sqlite [--bridge-dir <dir>]//! helm stats <db>//! helm bv-report --megamek <dir> --bridge-dir <dir>//! helm art --megamek <dir> [--out <file>]//! helm index --megamek <dir> --bridge-dir <dir> --out units.json//! helm catalogue --bridge-dir <dir> --out <file>//! helm force <force.mul> --megamek <dir> --bridge-dir <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<String> = 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 <dir> --out <db> [--bridge-dir <dir>] helm stats <db> helm bv-report --megamek <dir> --bridge-dir <dir> helm art --megamek <dir> [--out <file>] helm index --megamek <dir> --bridge-dir <dir> --out <file> [--helm-version <ref>] helm force <force.mul> --megamek <dir> --bridge-dir <dir> helm check <force.mul> --megamek <dir> --bridge-dir <dir> helm repairs <force.mul> --megamek <dir> --bridge-dir <dir> helm wear <design> --to <bv> --megamek <dir> --bridge-dir <dir>
BUILD --megamek <dir> A MegaMek install. Reads data/mekfiles/unit_files.zip, or the loose .mtf and .blk files if the zip is absent. --out <db> Where to write the SQLite database. --bridge-dir <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 <v> 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 <name> 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 <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 <bv> 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 <n> One battle in particular. Implies --battle. --write <file.mul> 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<PathBuf>, out: Option<PathBuf>, bridge_dir: Option<PathBuf>, version: Option<String>, unit: Option<String>, 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<String>, label: Option<String>, rules: bool, bench: bool, damaged: bool, why: bool, to: Option<i64>, out_mul: Option<PathBuf>, battle: bool, seed: Option<u64>,}
fn parse_opts(args: &[String]) -> Result<Opts, String> { 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<String, String> { 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<helm_unitfile::Library, String> { 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>) -> 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<ComputedStats> = Vec::new(); let mut catalogue: Option<Catalogue> = 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<String, helm_core::BvBreakdown> = 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<String, helm_core::BvBreakdown>, 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 <force.mul> --megamek <dir> --bridge-dir <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 <dir> --bridge-dir <dir>", )?; let opts = parse_opts(&args[1..])?; let target = opts .to .ok_or("--to <bv> 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 <force.mul> --megamek <dir> --bridge-dir <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<String, helm_core::BvBreakdown>, 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<f64>, theirs: Option<f64>| 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<String> = 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<helm_bv::Machine<'_>> = 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 <dir> 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<String> = 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<String, ComputedStats> = std::collections::HashMap::new(); let mut catalogue: Option<Catalogue> = 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<String, serde_json::Value> = 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 <force.mul> --megamek <dir> --bridge-dir <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<Option<i64>> = 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<helm_force::Linked> = 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<usize> = 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(())}