use std::collections::HashMap;
use crate::data::Version;
use crate::game_params::modifier_settings_data::modifier_setting;
use crate::game_params::types::CrewSkillModifier;
use crate::game_params::types::Species;
const KNOWN_ADDITIVE: &[&str] = &["yawSpeedBonus", "buffsStartPool", "healthRegenPercent"];
#[derive(Debug, Clone, PartialEq)]
pub struct UnknownModifier {
pub name: String,
}
impl std::fmt::Display for UnknownModifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unrecognized modifier {}: not in MODIFIER_SETTINGS or the additive/multiplicative allowlists",
self.name
)
}
}
impl std::error::Error for UnknownModifier {}
#[derive(Debug, Clone, PartialEq)]
pub enum ModifierError {
Unknown(Vec<String>),
}
impl std::fmt::Display for ModifierError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ModifierError::Unknown(names) => write!(
f,
"unrecognized modifiers (not in MODIFIER_SETTINGS or the additive/multiplicative allowlists): {}",
names.join(", ")
),
}
}
}
impl std::error::Error for ModifierError {}
const KNOWN_MULTIPLICATIVE: &[&str] = &[
"uwCoeffMultiplier",
"burnChanceFactorHighLevel",
"burnChanceGMGSMultiplier",
"burnChanceMultiplier",
"reloadFactor",
"engineForwardForsagePower",
"engineBackwardForsagePower",
"hydrophoneWaveSpeedCoeff",
"planeEmptyReturnSpeed",
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Combine {
Multiply,
Add,
}
impl Combine {
fn classify(version: Version, name: &str) -> Result<Combine, UnknownModifier> {
if KNOWN_ADDITIVE.contains(&name) {
return Ok(Combine::Add);
}
if KNOWN_MULTIPLICATIVE.contains(&name) {
return Ok(Combine::Multiply);
}
match modifier_setting(version, name) {
Some(s) if s.base_value == 0.0 => Ok(Combine::Add),
Some(_) => Ok(Combine::Multiply),
None => Err(UnknownModifier { name: name.to_string() }),
}
}
fn identity(self) -> f32 {
match self {
Combine::Multiply => 1.0,
Combine::Add => 0.0,
}
}
fn apply(self, base: f32, amount: f32) -> f32 {
match self {
Combine::Multiply => base * amount,
Combine::Add => base + amount,
}
}
fn fold(self, acc: f32, value: f32) -> f32 {
match self {
Combine::Multiply => acc * value,
Combine::Add => acc + value,
}
}
}
pub(crate) fn modifier_identity(version: Version, name: &str) -> Result<f32, ModifierError> {
Combine::classify(version, name).map(Combine::identity).map_err(|e| ModifierError::Unknown(vec![e.name]))
}
#[derive(Clone, Debug)]
pub struct ModifierBundle {
species: Species,
values: HashMap<String, f32>,
rules: HashMap<String, Combine>,
}
impl ModifierBundle {
pub fn from_modifiers(
mods: &[CrewSkillModifier],
species: Species,
version: Version,
) -> Result<ModifierBundle, ModifierError> {
let mut values: HashMap<String, f32> = HashMap::new();
let mut rules: HashMap<String, Combine> = HashMap::new();
let mut unknown: Vec<String> = Vec::new();
for m in mods {
let name = m.name();
let combine = match rules.get(name) {
Some(c) => *c,
None => match Combine::classify(version, name) {
Ok(c) => {
rules.insert(name.to_string(), c);
c
}
Err(e) => {
unknown.push(e.name);
continue;
}
},
};
let entry = values.entry(name.to_string()).or_insert_with(|| combine.identity());
*entry = combine.fold(*entry, m.get_for_species(&species));
}
if !unknown.is_empty() {
unknown.sort();
unknown.dedup();
return Err(ModifierError::Unknown(unknown));
}
Ok(ModifierBundle { species, values, rules })
}
pub fn empty(species: Species) -> ModifierBundle {
ModifierBundle { species, values: HashMap::new(), rules: HashMap::new() }
}
pub fn species(&self) -> Species {
self.species
}
pub fn coef(&self, name: &str) -> f32 {
match self.values.get(name).copied() {
Some(v) => {
debug_assert_eq!(
self.rules.get(name),
Some(&Combine::Multiply),
"coef({name:?}) reads an additively-folded modifier; use bonus() instead"
);
v
}
None => 1.0,
}
}
pub fn bonus(&self, name: &str) -> f32 {
match self.values.get(name).copied() {
Some(v) => {
debug_assert_eq!(
self.rules.get(name),
Some(&Combine::Add),
"bonus({name:?}) reads a multiplicatively-folded modifier; use coef() instead"
);
v
}
None => 0.0,
}
}
pub fn apply(&self, base: f32, name: &str) -> f32 {
match (self.values.get(name).copied(), self.rules.get(name).copied()) {
(Some(value), Some(rule)) => rule.apply(base, value),
_ => base,
}
}
pub fn apply_all(&self, base: f32, names: &[&str]) -> f32 {
names.iter().fold(base, |b, n| self.apply(b, n))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_params::types::CrewSkillModifier;
const VERSION: Version = Version::base(15, 0, 0);
fn modifier(name: &str, battleship: f32) -> CrewSkillModifier {
CrewSkillModifier::builder()
.name(name.to_string())
.aircraft_carrier(1.0)
.auxiliary(1.0)
.battleship(battleship)
.cruiser(1.0)
.destroyer(1.0)
.submarine(1.0)
.excluded_consumables(Vec::new())
.build()
}
fn modifier_per_species(name: &str, battleship: f32, cruiser: f32) -> CrewSkillModifier {
CrewSkillModifier::builder()
.name(name.to_string())
.aircraft_carrier(1.0)
.auxiliary(1.0)
.battleship(battleship)
.cruiser(cruiser)
.destroyer(1.0)
.submarine(1.0)
.excluded_consumables(Vec::new())
.build()
}
#[test]
fn same_multiplicative_name_multiplies() {
let mods = [modifier("speedCoef", 0.9), modifier("speedCoef", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.coef("speedCoef") - 0.945).abs() < 1e-6, "got {}", bundle.coef("speedCoef"));
}
#[test]
fn same_additive_name_adds() {
let mods = [modifier("torpedoSpeedBonus", 5.0), modifier("torpedoSpeedBonus", 3.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.bonus("torpedoSpeedBonus") - 8.0).abs() < 1e-6, "got {}", bundle.bonus("torpedoSpeedBonus"));
}
#[test]
fn second_additive_name_adds() {
let mods = [modifier("buffsShiftMaxLevel", 2.0), modifier("buffsShiftMaxLevel", 1.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.bonus("buffsShiftMaxLevel") - 3.0).abs() < 1e-6);
}
#[test]
fn absent_name_is_identity() {
let bundle =
ModifierBundle::from_modifiers(&[], Species::Battleship, VERSION).expect("test modifiers are all known");
assert_eq!(bundle.coef("speedCoef"), 1.0);
assert_eq!(bundle.bonus("torpedoSpeedBonus"), 0.0);
}
#[test]
fn per_species_resolution() {
let mods = [modifier_per_species("speedCoef", 0.9, 1.2)];
let bb =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let ca =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
assert!((bb.coef("speedCoef") - 0.9).abs() < 1e-6);
assert!((ca.coef("speedCoef") - 1.2).abs() < 1e-6);
}
#[test]
fn captain_skill_table_gaps_classify() {
let mods = [
modifier("reloadFactor", 0.9),
modifier("engineForwardForsagePower", 1.2),
modifier("hydrophoneWaveSpeedCoeff", 1.0),
modifier("planeEmptyReturnSpeed", 0.5),
modifier("healthRegenPercent", 0.05),
];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION)
.expect("captain-skill table-gap modifiers must classify, not error");
assert!((bundle.coef("reloadFactor") - 0.9).abs() < 1e-6);
assert!((bundle.coef("engineForwardForsagePower") - 1.2).abs() < 1e-6);
assert!((bundle.coef("planeEmptyReturnSpeed") - 0.5).abs() < 1e-6);
assert!((bundle.bonus("healthRegenPercent") - 0.05).abs() < 1e-6);
}
#[test]
fn empty_bundle_is_identity() {
let bundle = ModifierBundle::empty(Species::Battleship);
assert_eq!(bundle.species(), Species::Battleship);
assert_eq!(bundle.coef("speedCoef"), 1.0);
assert_eq!(bundle.coef("torpedoSpeedMultiplier"), 1.0);
assert_eq!(bundle.bonus("torpedoSpeedBonus"), 0.0);
}
#[test]
fn known_additive_absent_name_adds() {
let mods = [modifier("yawSpeedBonus", 4.0), modifier("yawSpeedBonus", 2.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.bonus("yawSpeedBonus") - 6.0).abs() < 1e-6, "got {}", bundle.bonus("yawSpeedBonus"));
}
#[test]
fn absent_name_returns_identity_per_accessor() {
let bundle =
ModifierBundle::from_modifiers(&[], Species::Battleship, VERSION).expect("test modifiers are all known");
assert_eq!(bundle.coef("yawSpeedBonus"), 1.0);
assert_eq!(bundle.bonus("speedCoef"), 0.0);
assert_eq!(bundle.coef("nonexistentName"), 1.0);
assert_eq!(bundle.bonus("nonexistentName"), 0.0);
}
#[test]
fn apply_multiplies_a_coefficient_name() {
let mods = [modifier("speedCoef", 0.9)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.apply(100.0, "speedCoef") - 90.0).abs() < 1e-4, "got {}", bundle.apply(100.0, "speedCoef"));
}
#[test]
fn apply_adds_a_bonus_name() {
let mods = [modifier("torpedoSpeedBonus", 5.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
assert!((bundle.apply(60.0, "torpedoSpeedBonus") - 65.0).abs() < 1e-4);
}
#[test]
fn apply_absent_name_is_identity() {
let bundle =
ModifierBundle::from_modifiers(&[], Species::Battleship, VERSION).expect("test modifiers are all known");
assert_eq!(bundle.apply(42.0, "speedCoef"), 42.0);
assert_eq!(bundle.apply(42.0, "torpedoSpeedBonus"), 42.0);
assert_eq!(bundle.apply(42.0, "nonexistentName"), 42.0);
}
#[test]
fn apply_all_chains_left_to_right() {
let mods = [modifier("speedCoef", 0.9), modifier("torpedoSpeedBonus", 5.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let out = bundle.apply_all(100.0, &["speedCoef", "torpedoSpeedBonus"]);
assert!((out - 95.0).abs() < 1e-4, "got {out}");
}
#[test]
fn apply_all_empty_is_identity() {
let bundle =
ModifierBundle::from_modifiers(&[], Species::Battleship, VERSION).expect("test modifiers are all known");
assert_eq!(bundle.apply_all(7.0, &[]), 7.0);
}
#[test]
#[should_panic(expected = "additively-folded")]
#[cfg(debug_assertions)]
fn coef_on_additive_name_trips_assert() {
let mods = [modifier("yawSpeedBonus", 4.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let _ = bundle.coef("yawSpeedBonus");
}
#[test]
fn from_modifiers_classifies_known_names_at_old_version() {
let old = Version::base(11, 0, 0);
let mods = [modifier("GMShotDelay", 0.9), modifier("GMShotDelay", 0.8)];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Battleship, old)
.expect("known modifiers classify even at an old version");
assert!((bundle.coef("GMShotDelay") - 0.72).abs() < 1e-6, "got {}", bundle.coef("GMShotDelay"));
}
#[test]
fn from_modifiers_unknown_name_errors_with_name() {
let mods = [modifier("totallyNotARealModifier", 0.5)];
let err = ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION)
.expect_err("an unknown modifier must error, not silently multiply");
assert_eq!(err, ModifierError::Unknown(vec!["totallyNotARealModifier".to_string()]), "got {err:?}");
}
#[test]
fn classify_gmcritprob_at_default_version_is_multiply() {
assert_eq!(Combine::classify(Version::default(), "GMCritProb"), Ok(Combine::Multiply));
}
#[test]
fn modifier_identity_classifies() {
let v = crate::data::Version::base(15, 4, 0);
assert_eq!(modifier_identity(v, "GSPriorityTargetIdealRadius").unwrap(), 1.0, "multiplicative");
assert_eq!(modifier_identity(v, "yawSpeedBonus").unwrap(), 0.0, "additive");
assert!(
matches!(modifier_identity(v, "definitelyNotAModifier_xyz"), Err(ModifierError::Unknown(_))),
"unknown name errors"
);
}
}