diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index 22a04f7..a3c1ddd 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -85,6 +85,11 @@ pub fn strip_slot_markers(slot: &str) -> String { Some((before, _)) => before.to_string(), None => slot.to_string(), }; + // A `.blk` writes `:OMNI` on everything in a pod, the way a `.mtf` + // writes `(OMNIPOD)`. It is not part of the name either. + if name.to_ascii_uppercase().ends_with(":OMNI") { + name.truncate(name.len() - ":OMNI".len()); + } for marker in SLOT_MARKERS { while let Some(at) = name.find(marker) { name.replace_range(at..at + marker.len(), " "); @@ -94,8 +99,9 @@ pub fn strip_slot_markers(slot: &str) -> String { } /// The markers [`strip_slot_markers`] knows: rear-facing, pod-mounted, -/// turret-mounted and armoured. -pub const SLOT_MARKERS: [&str; 4] = ["(R)", "(OMNIPOD)", "(T)", "(ARMORED)"]; +/// turret-mounted, armoured, and the two small turrets a vehicle can hang a +/// weapon off - a sponson and a pintle. +pub const SLOT_MARKERS: [&str; 6] = ["(R)", "(OMNIPOD)", "(T)", "(ARMORED)", "(ST)", "(PT)"]; /// Reduce a name to lowercase alphanumerics. /// diff --git a/crates/helm-cost/src/mek.rs b/crates/helm-cost/src/mek.rs index 0f88911..553066e 100644 --- a/crates/helm-cost/src/mek.rs +++ b/crates/helm-cost/src/mek.rs @@ -491,7 +491,7 @@ fn equipment(unit: &Unit, catalogue: &Catalogue, tons: f64, rating: i64) -> f64 .fold(0.0, |total, (entry, count)| { let each = match entry.cost { Some(cost) => cost, - None if entry.variable_cost => variable(entry, unit, tons, rating), + None if entry.variable_cost => variable(entry, unit, catalogue, tons, rating), None => 0.0, }; total + each * *count as f64 @@ -511,8 +511,10 @@ pub(crate) fn targeting_computer(items: &[(&helm_core::EquipmentEntry, i64)], un { return 0.0; } + // A taser is direct fire and cannot be aimed by a computer, so it is the + // one exception to what the computer is sized against. let aimed = items.iter().fold(0.0, |tons, (entry, count)| { - if entry.is_weapon() && entry.has_flag("F_DIRECT_FIRE") { + if entry.is_weapon() && entry.has_flag("F_DIRECT_FIRE") && !entry.has_flag("F_TASER") { tons + entry.tonnage.unwrap_or(0.0) * *count as f64 } else { tons @@ -556,6 +558,26 @@ fn armored_components(unit: &Unit, catalogue: &Catalogue) -> f64 { components * 150_000.0 } +/// Whether an item would go off on a design nobody has fired yet. +/// +/// MegaMek asks the mounting rather than the equipment type, and an +/// autocannon is explosive there only once it has been fired this round - a +/// rotary only once it has jammed. A design on the shelf is neither, so a +/// Dark Crow 3 is charged for the two torsos holding its ammunition and not +/// for the arms holding its rotaries. +fn explodes_on_the_shelf(entry: &helm_core::EquipmentEntry) -> bool { + if !entry.explosive { + return false; + } + // The gun, not the bin it feeds from: a ton of autocannon ammunition + // states the same ammunition type and does go off. + !(entry.is_weapon() + && matches!( + entry.ammo_type.as_deref(), + Some("AC" | "AC_IMP" | "AC_ROTARY" | "LAC" | "PAC") + )) +} + /// What a Clan design's structural CASE costs. /// /// It is part of how the machine is built rather than something bolted in, so @@ -585,12 +607,7 @@ pub(crate) fn clan_case(unit: &Unit, catalogue: &Catalogue) -> f64 { slots .iter() .flat_map(|line| resolved(line)) - // A PPC capacitor is explosive only once it is charged, - // and a design on the shelf never is - MegaMek asks the - // mounting rather than the equipment type, and the answer is - // no. A Masakari (Warhawk) L is charged for one location and - // not two because of it. - .any(|e| e.explosive && !e.has_flag("F_PPC_CAPACITOR")) + .any(explodes_on_the_shelf) }) .count() as i64; let explicit = unit @@ -618,7 +635,7 @@ pub fn item_prices(unit: &Unit, catalogue: &Catalogue) -> Vec<(String, i64, f64) .map(|(entry, count)| { let each = match entry.cost { Some(cost) => cost, - None if entry.variable_cost => variable(entry, unit, tons, rating), + None if entry.variable_cost => variable(entry, unit, catalogue, tons, rating), None => 0.0, }; (entry.name.clone(), *count, each * *count as f64) @@ -643,6 +660,7 @@ pub fn item_prices(unit: &Unit, catalogue: &Catalogue) -> Vec<(String, i64, f64) pub(crate) fn variable( entry: &helm_core::EquipmentEntry, unit: &Unit, + catalogue: &Catalogue, tons: f64, rating: i64, ) -> f64 { @@ -659,6 +677,15 @@ pub(crate) fn variable( if entry.has_flag("S_SUPERCHARGER") { return rating * 10_000.0; } + // A small turret is a tenth of what it swings, shared between however + // many of them the design mounts: four light machine guns on one sponson + // is half a ton of ring, which is two thousand C-bills. + if entry.has_flag("F_SPONSON_TURRET") { + return small_turret(unit, catalogue, "(ST)", "F_SPONSON_TURRET") * 4_000.0; + } + if entry.has_flag("F_PINTLE_TURRET") { + return small_turret(unit, catalogue, "(PT)", "F_PINTLE_TURRET") * 1_000.0; + } // A VTOL's jet booster is also MASC, and is caught here first because it // is priced like a supercharger rather than like the myomer sort. if entry.has_flag("F_JET_BOOSTER") { @@ -725,8 +752,10 @@ pub(crate) fn variable( if entry.has_flag("F_ACTUATOR_ENHANCEMENT_SYSTEM") { return tons * 500.0; } + // Sealing a Mek against vacuum is 225 a ton. Sealing a vehicle is not + // charged for here at all: it is a quarter on the finished hull instead. if entry.has_flag("F_ENVIRONMENTAL_SEALING") { - return tons * 225.0; + return if unit.is_mek() { tons * 225.0 } else { 0.0 }; } // A hull that swims costs a thousand a ton; one that only wades costs a // twelfth of that in five-thousand-C-bill steps. @@ -750,6 +779,32 @@ pub(crate) fn variable( 0.0 } +/// How much ring one of a design's small turrets is, in tons. +/// +/// The weapons hung off it are marked on their own slot lines - `(ST)` for a +/// sponson, `(PT)` for a pintle - and the ring is a tenth of their weight, +/// welded in half tons and then split between however many rings there are. +fn small_turret(unit: &Unit, catalogue: &Catalogue, marker: &str, flag: &str) -> f64 { + let mounted = unit + .criticals + .values() + .flatten() + .flat_map(|line| line.split('|')) + .filter(|slot| slot.contains(marker)) + .filter_map(|slot| catalogue.resolve(&helm_core::strip_slot_markers(slot))) + .fold(0.0, |tons, entry| tons + entry.tonnage.unwrap_or(0.0)); + let rings = unit + .criticals + .values() + .flatten() + .flat_map(|line| line.split('|')) + .filter_map(|slot| catalogue.resolve(&helm_core::strip_slot_markers(slot))) + .filter(|entry| entry.has_flag(flag)) + .count() + .max(1) as f64; + crate::next_half_ton(mounted / 10.0) / rings +} + /// The size a design's own slots state for a variable-size item. fn declared_size(unit: &Unit, entry: &helm_core::EquipmentEntry) -> f64 { unit.criticals @@ -895,11 +950,12 @@ mod tests { ..Default::default() }; let unit = atlas(); + let catalogue = Catalogue::default(); // 25 tons is 1.25 tons of sword, rounded up to the half ton it is // forged in, at ten thousand a ton. - assert_eq!(variable(&sword, &unit, 25.0, 0), 15_000.0); - assert_eq!(variable(&sword, &unit, 40.0, 0), 20_000.0); - assert_eq!(variable(&sword, &unit, 45.0, 0), 25_000.0); + assert_eq!(variable(&sword, &unit, &catalogue, 25.0, 0), 15_000.0); + assert_eq!(variable(&sword, &unit, &catalogue, 40.0, 0), 20_000.0); + assert_eq!(variable(&sword, &unit, &catalogue, 45.0, 0), 25_000.0); } #[test] diff --git a/crates/helm-cost/src/vehicle.rs b/crates/helm-cost/src/vehicle.rs index b024bca..be42773 100644 --- a/crates/helm-cost/src/vehicle.rs +++ b/crates/helm-cost/src/vehicle.rs @@ -46,9 +46,18 @@ pub fn breakdown(unit: &Unit, catalogue: &Catalogue) -> Result 100.0 && !matches!(mode.as_str(), "naval" | "submarine"); + let doubled = is_superheavy(unit, tons) && !matches!(mode.as_str(), "naval" | "submarine"); let structure = next_half_ton(tons / if doubled { 5.0 } else { 10.0 }) * 10_000.0; - let control_systems = next_half_ton(tons * 0.05) * 10_000.0; + // A trailer is towed and steers nothing, and says so in its own file. + let control_systems = if unit + .fields + .keys() + .any(|k| k.eq_ignore_ascii_case("hasNoControlSystems")) + { + 0.0 + } else { + next_half_ton(tons * 0.05) * 10_000.0 + }; let power_amplifiers = power_amplifiers(unit, &loadout); let heat_sinks = heat_sinks(unit, &loadout); let turret = turret(unit, catalogue); @@ -376,6 +385,25 @@ fn runs_off_the_engine(entry: &helm_core::EquipmentEntry) -> bool { (entry.has_flag("F_LASER") || entry.has_flag("F_FLAMER")) && entry.ammo_type.is_none() } +/// Whether the hull is over what its motion type is built to carry. +/// +/// Each way of moving has its own ceiling - fifty tons on a skirt of air, +/// eighty on wheels or a ground-effect wing, a hundred on tracks, three +/// hundred on the water and thirty under a rotor - and a hull past it is +/// framed twice as heavily. +fn is_superheavy(unit: &Unit, tons: f64) -> bool { + let ceiling = match motion_type(unit).as_str() { + "vtol" => 30.0, + "hover" => 50.0, + "wheeled" | "wige" => 80.0, + "tracked" => 100.0, + "naval" | "submarine" => 300.0, + // A hydrofoil, a rail hull and anything else has no superheavy form. + _ => return false, + }; + tons > ceiling +} + /// How the hull moves, as a `.blk` spells it, lowercased so a match on it /// does not depend on which file wrote it. fn motion_type(unit: &Unit) -> String { @@ -414,7 +442,9 @@ fn equipment( // An item priced from the design is priced the same way here // as on a Mek: a supercharger by the engine it is strapped // to, a communications set by the tonnage on its own line. - None if entry.variable_cost => crate::mek::variable(entry, unit, tons, rating), + None if entry.variable_cost => { + crate::mek::variable(entry, unit, catalogue, tons, rating) + } None => 0.0, }; total + each * *count as f64 diff --git a/crates/helm-cost/tests/conformance.rs b/crates/helm-cost/tests/conformance.rs index 261e853..0081499 100644 --- a/crates/helm-cost/tests/conformance.rs +++ b/crates/helm-cost/tests/conformance.rs @@ -408,7 +408,9 @@ fn multiplier(report: &str) -> Option { let Some(at) = line.find("x ") else { continue; }; - if !line.contains("Multiplier") { + // A vehicle's report writes "Flotation Hull/Environmental Sealing + // multiplier" in lower case, and that one is a multiplier too. + if !line.to_ascii_lowercase().contains("multiplier") { continue; } if let Ok(n) = line[at + 2..].trim().parse::() {