Something went wrong. Try again.
Data management tools for playing BattleTech
Something went wrong. Try again.
Rust
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528//! The commands that read a `.mul`: what a force is worth, what is wrong with//! the file, what a machine would take to put right, and how to wear one down//! to a figure.
use helm_core::Catalogue;
use crate::inputs::library_and_catalogue;use crate::options::parse_opts;
/// 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./// What one location needs putting right, as a phrase.////// Only what is actually wanted: a location with its plate intact and its/// frame bent reads "3 structure" rather than "0 armour, 3 structure".fn plate_and_frame(repair: &helm_bv::Repair) -> String { let mut parts = Vec::new(); for (points, what) in [ (repair.armor, "armour"), (repair.rear_armor, "rear armour"), (repair.structure, "structure"), ] { if points > 0 { parts.push(format!("{points} {what}")); } } parts.join(", ")}/// 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.pub(crate) 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 (library, catalogue) = library_and_catalogue(&opts)?; 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.pub(crate) 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 (library, catalogue) = library_and_catalogue(&opts)?; 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 { println!(" {:<16} {}", repair.location, plate_and_frame(repair)); // 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.pub(crate) 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 (library, catalogue) = library_and_catalogue(&opts)?; 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(())}/// 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./// The force's firing, drawn as a line of blocks.////// The interesting shape is a force's rather than a machine's: four designs/// that peak in four different places cover a board between them, and four/// that peak in the same place leave a hole where the fifth should be. Each/// unit is counted as its own crew flies it, so a veteran lance lands more/// than the library's own figures say.fn print_curve(force: &helm_force::Force) { let curve = force.curve(); let Some((best, peak)) = force.best_range() else { return; }; let most = curve.iter().copied().max().unwrap_or(0).max(1);
println!(); println!("firepower, hex by hex - {peak:.1} at {best} hexes"); // Eight rows of blocks, which is enough to see a shape and few enough to // read in a terminal beside the table above it. const ROWS: i64 = 8; for row in (0..ROWS).rev() { let floor = most * row / ROWS; let line: String = curve .iter() .map(|damage| if *damage > floor { "\u{2588}" } else { " " }) .collect(); println!(" {line}"); } // A tick every six hexes, and the numbers under them. let ticks: String = (1..=helm_core::REACH) .map(|hex| if hex % 6 == 0 { '\u{252c}' } else { '\u{2500}' }) .collect(); let mut labels = vec![b' '; helm_core::REACH as usize]; for hex in (6..=helm_core::REACH).step_by(6) { let text = hex.to_string(); let start = (hex as usize).saturating_sub(text.len()); labels[start..start + text.len()].copy_from_slice(text.as_bytes()); } println!(" {ticks}"); println!( " {} hexes", String::from_utf8(labels).unwrap_or_default().trim_end() );}
pub(crate) 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 (library, catalogue) = library_and_catalogue(&opts)?; 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)"); }
// Built here rather than in the loop above, because a curve wants the // design and the crew together and the table wants neither. let force = helm_force::Force::new( machines .iter() .copied() .filter_map(|entity| { let unit = find(&entity.chassis, &entity.model)?; let curve = helm_core::firepower_flown_by(unit, &catalogue, entity.gunnery as i64); Some( helm_force::ForceUnit::new(&entity.chassis, &entity.model, 0) .firing(curve.curve), ) }) .collect(), ); print_curve(&force); if missing > 0 { println!("({missing} not scored)"); } Ok(())}