diff --git a/Cargo.lock b/Cargo.lock index d065d4f..4079940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,7 @@ version = "0.1.0" dependencies = [ "helm-bridge", "helm-core", + "helm-force", "helm-unitfile", "serde_json", ] diff --git a/TODO.md b/TODO.md index b823b67..1fc2290 100644 --- a/TODO.md +++ b/TODO.md @@ -131,6 +131,20 @@ Cheap: the columns exist and the MCP does not offer them. ## Force value +- [x] **A C3 network is worth something, and helm can say how much.** The third + of the four scopes, and the only one a unit cannot work out about itself: + every member is paid five percent of the network's combined base value, + seven for a boosted set, and a set with nobody to talk to is worth + nothing. `helm_bv::c3_kind` reads what a design carries - 244 standard + sets in the library, 178 C3i, 38 boosted and 29 Nova - and + `helm_force::c3_bonuses` says what a force of them is worth. Nova is + counted among its own kind rather than on a network, which is how MegaMek + pays it. + + Not yet wired to anything that reads a network *from* a file: a `.mul` + records which unit is whose master, and `helm force` still scores every + unit as though it were alone. + - [x] **Pilot skill changes battle value.** `helm-force` ports MegaMek's 9x9 `bvMultipliers`, indexed gunnery-then-piloting and clamped the way `bvSkillMultiplier` clamps. A regular 4/5 crew multiplies by exactly 1, diff --git a/crates/helm-bv/Cargo.toml b/crates/helm-bv/Cargo.toml index 4ae6033..0f730d0 100644 --- a/crates/helm-bv/Cargo.toml +++ b/crates/helm-bv/Cargo.toml @@ -24,6 +24,8 @@ default-features = false [dev-dependencies] helm-unitfile.workspace = true +# The C3 test asks what a network is worth, which is helm-force's arithmetic. +helm-force.workspace = true helm-bridge.workspace = true # Only the damaged-unit oracle, which is JSON Lines like the rest of the dump. serde_json.workspace = true diff --git a/crates/helm-bv/src/lib.rs b/crates/helm-bv/src/lib.rs index 8431b39..508e146 100644 --- a/crates/helm-bv/src/lib.rs +++ b/crates/helm-bv/src/lib.rs @@ -106,6 +106,30 @@ pub fn classify(unit: &Unit, catalogue: &Catalogue) -> Vec { out } +/// What a design carries of a C3 network. +/// +/// The set is equipment like any other, so this is a read of the loadout - and +/// it is here rather than in `helm-force` because only this crate knows what a +/// design is carrying. What the network is then *worth* is `helm-force`'s job, +/// since it depends on the other units on it. +/// +/// A boosted set beats a plain one where a design carries both, because that +/// is the share MegaMek pays it. +pub fn c3_kind(unit: &Unit, catalogue: &Catalogue) -> helm_core::C3 { + let loadout = loadout::Loadout::read(unit, catalogue); + for (flags, kind) in [ + (&["F_C3SBS", "F_C3MBS"][..], helm_core::C3::Boosted), + (&["F_C3I"][..], helm_core::C3::Improved), + (&["F_NOVA"][..], helm_core::C3::Nova), + (&["F_C3S", "F_C3M", "F_C3EM"][..], helm_core::C3::Standard), + ] { + if flags.iter().any(|flag| loadout.has_flag(flag)) { + return kind; + } + } + helm_core::C3::None +} + /// This crate's own version, recorded beside every number it produces. /// /// It exists so that a client and a server disagreeing can be pinned to a diff --git a/crates/helm-bv/tests/conformance.rs b/crates/helm-bv/tests/conformance.rs index ea327d7..3a391f2 100644 --- a/crates/helm-bv/tests/conformance.rs +++ b/crates/helm-bv/tests/conformance.rs @@ -1275,6 +1275,52 @@ fn the_version_the_rules_claim_is_the_one_they_were_measured_against() { ); } +/// The designs that carry a C3 set, and what a network of them is worth. +/// +/// Two checks in one: that the sets are found at all - a flag spelled wrong +/// finds nothing and looks exactly like a library with no C3 in it - and that +/// a network pays what MegaMek pays, five percent of the combined base. +#[test] +#[ignore = "needs a MegaMek install and a bridge dump; set HELM_MEGAMEK and HELM_BRIDGE"] +fn c3_sets_are_found_and_a_network_pays_for_itself() { + let Some(inputs) = inputs() else { + panic!("set HELM_MEGAMEK to a MegaMek install and HELM_BRIDGE to a bridge dump"); + }; + + let mut by_kind: std::collections::BTreeMap = Default::default(); + let mut networked = Vec::new(); + for unit in &inputs.library.units { + if unit.format_str != "mtf" { + continue; + } + let kind = helm_bv::c3_kind(unit, &inputs.catalogue); + if kind == helm_core::C3::None { + continue; + } + *by_kind.entry(format!("{kind:?}")).or_default() += 1; + if networked.len() < 2 + && let Ok(bv) = helm_bv::battle_value(unit, &inputs.catalogue) + { + networked.push((bv, kind)); + } + } + + println!("C3 sets in the library: {by_kind:?}"); + assert!( + by_kind.len() >= 3, + "only {} kinds of set found, which is fewer than the library has", + by_kind.len() + ); + + // A network of the first two: each is paid a share of both. + let bonuses = helm_force::c3_bonuses(&networked); + let total: i64 = networked.iter().map(|(bv, _)| bv).sum(); + for (bonus, (_, kind)) in bonuses.iter().zip(&networked) { + assert_eq!(*bonus, (total as f64 * kind.share()).round() as i64); + assert!(*bonus > 0, "a network paid nothing"); + } +} + /// The catalogue a browser loads scores the library the way the full one does. /// /// `helm catalogue` keeps about twenty fields of the several hundred the diff --git a/crates/helm-core/src/design.rs b/crates/helm-core/src/design.rs index 0b3ebc5..5b26eb4 100644 --- a/crates/helm-core/src/design.rs +++ b/crates/helm-core/src/design.rs @@ -496,3 +496,42 @@ mod tests { )); } } + +/// What a design carries of a C3 network, which is a battle value scope of +/// its own. +/// +/// A network is worth a share of what it can see: every member is paid five +/// percent of the combined base value of the network it is on, seven for a +/// boosted one. That is the third of the four scopes battle value is worked +/// out in, and the only one a unit cannot work out about itself - a C3 slave +/// Locust in a lance with an Atlas is paid for the Atlas. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum C3 { + #[default] + None, + /// A master or a slave on an ordinary network. + Standard, + /// A boosted master or slave, which is worth seven percent rather than + /// five. + Boosted, + /// C3i, which needs no master: any two of them are a network. + Improved, + /// Nova CEWS, which pays across every friendly unit carrying one rather + /// than across a network. + Nova, +} + +impl C3 { + /// What share of the network a member of this kind is paid. + pub fn share(&self) -> f64 { + match self { + C3::None => 0.0, + C3::Boosted => 0.07, + _ => 0.05, + } + } + + pub fn is_networked(&self) -> bool { + *self != C3::None + } +} diff --git a/crates/helm-core/src/lib.rs b/crates/helm-core/src/lib.rs index f19e6be..bd19e9b 100644 --- a/crates/helm-core/src/lib.rs +++ b/crates/helm-core/src/lib.rs @@ -36,7 +36,8 @@ pub use bv::{BvBreakdown, RATING_TOLERANCE}; pub use catalogue::{Catalogue, EquipmentEntry}; pub use computed::{AlphaStrike, ComputedStats}; pub use design::{ - ArmorKind, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, StructureKind, + ArmorKind, C3, CockpitKind, Engine, EngineKind, GyroKind, HeatSinkKind, HeatSinks, + StructureKind, }; pub use metrics::{ CombatMetrics, FORMATION_RANGES, FORMATION_SINGLE_RANGES, damage_at_range, diff --git a/crates/helm-force/src/c3.rs b/crates/helm-force/src/c3.rs new file mode 100644 index 0000000..833b8ef --- /dev/null +++ b/crates/helm-force/src/c3.rs @@ -0,0 +1,109 @@ +//! What a C3 network is worth to the units on it. +//! +//! The third of the four scopes a battle value is worked out in, and the only +//! one a unit cannot work out about itself: a C3 slave Locust in a lance with +//! an Atlas is paid for the Atlas. Every member is paid a share of the +//! combined base value of the network - five percent, or seven if its own set +//! is a boosted one. +//! +//! Two members make a network. One C3 slave with nobody to talk to is worth +//! what it was, which is why this takes the whole force rather than a unit. +//! +//! The bonus is added to the *rounded base* and then multiplied by the crew, +//! which is the order MegaMek's `adjustBV` uses: +//! `(base + c3) x pilot`, never `base x pilot + c3`. + +use helm_core::C3; + +/// What each unit gains from the network, in the order they were given. +/// +/// A unit not on a network gains nothing. Nova CEWS is paid across every +/// friendly unit carrying one rather than across a network, so it is counted +/// among its own kind rather than with the C3 sets. +pub fn c3_bonuses(units: &[(i64, C3)]) -> Vec { + let networked: i64 = units + .iter() + .filter(|(_, kind)| kind.is_networked() && *kind != C3::Nova) + .map(|(base, _)| base) + .sum(); + let members = units + .iter() + .filter(|(_, kind)| kind.is_networked() && *kind != C3::Nova) + .count(); + let nova: i64 = units + .iter() + .filter(|(_, kind)| *kind == C3::Nova) + .map(|(base, _)| base) + .sum(); + let novas = units.iter().filter(|(_, kind)| *kind == C3::Nova).count(); + + units + .iter() + .map(|(_, kind)| match kind { + C3::None => 0, + // One set with nothing to talk to is not a network. + C3::Nova if novas < 2 => 0, + C3::Nova => (nova as f64 * C3::Nova.share()).round() as i64, + _ if members < 2 => 0, + kind => (networked as f64 * kind.share()).round() as i64, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Locust is paid for the Atlas, which is the whole point of the + /// scope: what a unit is worth depends on what it can see for. + #[test] + fn every_member_is_paid_a_share_of_the_whole_network() { + let force = [(1897, C3::Standard), (432, C3::Standard)]; + let bonuses = c3_bonuses(&force); + // Five percent of 2,329 each. + assert_eq!(bonuses, vec![116, 116]); + } + + /// A set with nobody to talk to is worth nothing at all. + #[test] + fn one_set_is_not_a_network() { + assert_eq!(c3_bonuses(&[(1897, C3::Standard)]), vec![0]); + assert_eq!( + c3_bonuses(&[(1897, C3::Standard), (432, C3::None)]), + vec![0, 0] + ); + } + + /// A boosted set is paid seven percent where an ordinary one is paid five, + /// and the two can be on the same network. + #[test] + fn a_boosted_set_is_paid_more_of_the_same_network() { + let force = [(1000, C3::Boosted), (1000, C3::Standard)]; + assert_eq!(c3_bonuses(&force), vec![140, 100]); + } + + /// C3i needs no master, so any two of them are a network. + #[test] + fn improved_sets_need_no_master() { + let force = [(1000, C3::Improved), (1000, C3::Improved)]; + assert_eq!(c3_bonuses(&force), vec![100, 100]); + } + + /// Nova pays across the units carrying one rather than across a network, + /// so it does not join the C3 pool and does not need a master either. + #[test] + fn nova_is_counted_among_its_own_kind() { + let force = [ + (1000, C3::Nova), + (1000, C3::Nova), + (2000, C3::Standard), + (2000, C3::Standard), + ]; + assert_eq!(c3_bonuses(&force), vec![100, 100, 200, 200]); + } + + #[test] + fn a_force_with_no_sets_gains_nothing() { + assert_eq!(c3_bonuses(&[(1897, C3::None), (432, C3::None)]), vec![0, 0]); + } +} diff --git a/crates/helm-force/src/lib.rs b/crates/helm-force/src/lib.rs index 3332f39..e795efc 100644 --- a/crates/helm-force/src/lib.rs +++ b/crates/helm-force/src/lib.rs @@ -11,8 +11,10 @@ //! //! [`forces`]: https://tangled.org/lance.blue/headquarters +mod c3; mod pilot; +pub use c3::c3_bonuses; pub use pilot::{ Pilot, adjusted_battle_value, adjusted_battle_value_in_force, bv_skill_multiplier, };