use crate::game_params::ttx::armor_materials::armor_type_classifies;
use crate::game_params::ttx::armor_materials::collision_material_name;
use crate::game_params::ttx::components::ArtilleryComponentStats;
use crate::game_params::ttx::components::EngineComponentStats;
use crate::game_params::ttx::components::HullComponentStats;
use crate::game_params::ttx::components::SecondaryComponentStats;
use crate::game_params::ttx::components::TorpedoLauncherStats;
use crate::game_params::ttx::constants;
use crate::game_params::ttx::constants::BW_TO_BALLISTIC;
use crate::game_params::ttx::constants::HULL_HEALTH_ROUND;
use crate::game_params::ttx::constants::KM_TO_M;
use crate::game_params::ttx::constants::TORPEDO_DAMAGE_CONSTANT;
use crate::game_params::ttx::model::AmmoCount;
use crate::game_params::ttx::model::Armor;
use crate::game_params::ttx::model::Artillery;
use crate::game_params::ttx::model::Battery;
use crate::game_params::ttx::model::DegreesPerSecond;
use crate::game_params::ttx::model::Durability;
use crate::game_params::ttx::model::Hp;
use crate::game_params::ttx::model::Knots;
use crate::game_params::ttx::model::Launcher;
use crate::game_params::ttx::model::MainGun;
use crate::game_params::ttx::model::Mobility;
use crate::game_params::ttx::model::Percent;
use crate::game_params::ttx::model::Seconds;
use crate::game_params::ttx::model::ShellStats;
use crate::game_params::ttx::model::TorpedoStats;
use crate::game_params::ttx::model::Torpedoes;
use crate::game_params::ttx::model::Visibility;
use crate::game_params::ttx::modifiers::ModifierBundle;
use crate::game_params::ttx::weapon_tables::StatWeaponType;
use crate::game_params::ttx::weapon_tables::alpha_damage_coeff;
use crate::game_params::ttx::weapon_tables::ammo_to_stat_weapon_table;
use crate::game_params::ttx::weapon_tables::artillery_damage_coeff;
use crate::game_params::ttx::weapon_tables::calculate_burn_chance;
use crate::game_params::ttx::weapon_tables::is_small_projectile;
use crate::game_params::types::ArmorMap;
use crate::game_params::types::GameParamProvider;
use crate::game_params::types::Km;
use crate::game_params::types::Meters;
use crate::game_params::types::Millimeters;
use crate::game_params::types::Projectile;
const AMMO_TYPE_TORPEDO: &str = "torpedo";
const TORPEDO_TYPE_COMMON: i64 = 0;
pub fn durability(hull: &HullComponentStats, modifiers: &ModifierBundle, level: u32) -> Durability {
let health = hull.health.map(|base| {
let raw = (base.value() + modifiers.bonus("healthPerLevel") * level as f32) * modifiers.coef("healthHullCoeff");
let rounded = (raw / HULL_HEALTH_ROUND).ceil() * HULL_HEALTH_ROUND;
Hp::from(rounded)
});
let torpedo_protection = hull.flood_prob.map(|prob| {
Percent::from(prob * modifiers.coef("uwCoeffMultiplier") * 100.0 + modifiers.bonus("uwCoeffBonus"))
});
Durability { health, torpedo_protection }
}
pub fn mobility(hull: &HullComponentStats, engine: &EngineComponentStats, modifiers: &ModifierBundle) -> Mobility {
let speed = match (hull.max_speed, hull.speed_coef) {
(Some(max_speed), Some(hull_speed_coef)) => {
let engine_speed_coef = engine.speed_coef.unwrap_or(0.0);
let coef = (hull_speed_coef + engine_speed_coef).clamp(0.0, 1.0);
Some(Knots::from(max_speed.value() * coef * modifiers.coef("speedCoef")))
}
_ => None,
};
let turning_radius = hull.turning_radius;
let rudder_time = hull.rudder_time.map(|t| Seconds::from(t.value() * modifiers.coef("SGRudderTime")));
Mobility { speed, turning_radius, rudder_time }
}
pub fn battery(hull: &HullComponentStats, modifiers: &ModifierBundle) -> Option<Battery> {
let (capacity, regen) = (hull.battery_capacity?, hull.battery_regen_rate?);
if capacity == 0.0 {
return None;
}
Some(Battery {
capacity: Some(capacity * modifiers.coef("batteryCapacityCoeff")),
regeneration: Some(regen * modifiers.coef("batteryRegenCoeff")),
})
}
const DEFAULT_LOWEST_ARMOR_THICKNESS: f32 = 6.0;
fn armor_list_min_max(armor: &ArmorMap) -> (f32, f32) {
let mut min = DEFAULT_LOWEST_ARMOR_THICKNESS;
let mut max = DEFAULT_LOWEST_ARMOR_THICKNESS;
let mut found = false;
for (&material_key, layers) in armor {
let material_id = (material_key & 0xFF) as u8;
if !armor_type_classifies(collision_material_name(material_id)) {
continue;
}
for &thickness in layers.values() {
if thickness <= 0.0 {
continue;
}
if found {
min = min.min(thickness);
max = max.max(thickness);
} else {
min = thickness;
max = thickness;
found = true;
}
}
}
(min, max)
}
pub fn armor<'a>(hull_armor: &ArmorMap, artillery_armor: impl IntoIterator<Item = &'a ArmorMap>) -> Option<Armor> {
if hull_armor.is_empty() {
return None;
}
let (hull_min, hull_max) = armor_list_min_max(hull_armor);
let mut arti_min: Option<f32> = None;
let mut arti_max: Option<f32> = None;
for map in artillery_armor {
let (m, x) = armor_list_min_max(map);
arti_min = Some(arti_min.map_or(m, |cur: f32| cur.min(m)));
arti_max = Some(arti_max.map_or(x, |cur: f32| cur.max(x)));
}
let (min, max) = match (arti_min, arti_max) {
(Some(amin), Some(amax)) => (amin.min(hull_min), amax.max(hull_max)),
_ => (hull_min, hull_max),
};
Some(Armor { min: Some(Millimeters::from(min)), max: Some(Millimeters::from(max)) })
}
pub fn torpedo_stats(name: String, projectile: &Projectile, modifiers: &ModifierBundle) -> TorpedoStats {
let damage = match (projectile.alpha_damage(), projectile.damage()) {
(Some(alpha), Some(flood)) => {
let base = alpha / TORPEDO_DAMAGE_CONSTANT + flood;
Some(Hp::from(
base * modifiers.coef("torpedoDamageCoeff") * modifiers.coef("controllableWeaponDamageCoeff"),
))
}
_ => None,
};
let speed = projectile.speed().map(|s| {
let normal = if projectile.ammo_type() == AMMO_TYPE_TORPEDO {
modifiers.coef("normalTorpedoSpeedMultiplier")
} else {
1.0
};
Knots::from(s * modifiers.coef("torpedoSpeedMultiplier") * normal + modifiers.bonus("torpedoSpeedBonus"))
});
let range = projectile
.max_dist()
.map(|d| Km::from(d.value() * modifiers.coef("torpedoRangeCoefficient") * BW_TO_BALLISTIC / KM_TO_M));
let visibility = projectile.visibility_factor().map(|v| Km::from(v * modifiers.coef("torpedoVisibilityFactor")));
let is_damage_increasing = projectile.distance_of_damage().filter(|d| !d.is_empty()).map(|pairs| {
let coeff_at_min_dist = pairs.iter().min_by(|a, b| a.1.total_cmp(&b.1)).map(|p| p.0).unwrap_or(0.0);
let coeff_at_max_dist = pairs.iter().max_by(|a, b| a.1.total_cmp(&b.1)).map(|p| p.0).unwrap_or(0.0);
coeff_at_max_dist > coeff_at_min_dist
});
TorpedoStats {
name,
damage,
speed,
range,
visibility,
distance_of_max_damage: None,
is_damage_increasing,
disabled_underwater: None,
}
}
fn warn_unresolved_ammo(name: &str) {
use std::collections::HashSet;
use std::sync::Mutex;
static WARNED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
let mut warned = WARNED.lock().unwrap();
if warned.get_or_insert_with(HashSet::new).insert(name.to_string()) {
eprintln!("TTX: ammo '{name}' did not resolve to a projectile; shell/torpedo row dropped");
}
}
pub fn torpedoes(
launchers: &[TorpedoLauncherStats],
modifiers: &ModifierBundle,
provider: &dyn GameParamProvider,
) -> Option<Torpedoes> {
if launchers.is_empty() {
return None;
}
let yaw_coef = modifiers.coef("GTRotationSpeed");
let yaw_bonus = modifiers.bonus("GTRotationSpeedBonus");
let shot_delay_coef = modifiers.coef("GTShotDelay");
let mut result_launchers = Vec::with_capacity(launchers.len());
let mut reload_times: Vec<f32> = Vec::new();
let mut seen_ammo: Vec<String> = Vec::new();
let mut torpedoes = Vec::new();
for launcher in launchers {
let rotation_speed = launcher.rotation_speed.map(|r| r.value() * yaw_coef + yaw_bonus);
let rotation_time = rotation_speed.filter(|&r| r != 0.0).map(|r| Seconds::from(180.0 / r));
result_launchers.push(Launcher {
rotation_speed: rotation_speed.map(DegreesPerSecond::from),
rotation_time,
num_barrels: launcher.num_barrels.map(|n| n as u32),
});
if let Some(delay) = launcher.shot_delay {
let reload = delay.value() * shot_delay_coef;
if reload != 0.0 {
reload_times.push(reload);
}
}
for ammo_name in &launcher.ammo {
if seen_ammo.iter().any(|n| n == ammo_name) {
continue;
}
seen_ammo.push(ammo_name.clone());
if let Some(param) = provider.game_param_by_name(ammo_name)
&& let Some(projectile) = param.projectile()
{
torpedoes.push(torpedo_stats(ammo_name.clone(), projectile, modifiers));
} else {
warn_unresolved_ammo(ammo_name);
}
}
}
let reload_time = reload_times.iter().copied().reduce(f32::min).map(Seconds::from);
Some(Torpedoes { reload_time, launchers: result_launchers, torpedoes })
}
const HEAVY_CRUISER_SHELL_DIAMETER_M: f32 = 0.0;
const SMALL_PROJECTILE_MAX_DIAMETER_M: f32 = 0.0;
pub fn shell_stats(
name: String,
projectile: &Projectile,
modifiers: &ModifierBundle,
level: u32,
is_atba: bool,
) -> ShellStats {
let ammo_kind = projectile.ammo_type();
let mut weapon = ammo_to_stat_weapon_table(ammo_kind);
if is_atba {
weapon = weapon.to_atba();
}
let caliber_m = projectile.bullet_diametr();
let caliber = caliber_m.map(|c| Millimeters::from(c * 1000.0));
let speed = projectile.bullet_speed().map(|s| s * projectile.time_factor().unwrap_or(1.0));
let damage = match (projectile.alpha_damage(), caliber_m) {
(Some(alpha), Some(cal_m)) => {
let csap = if weapon.is_csap() { modifiers.coef("citadelDamageMultiplierCSAP") } else { 1.0 };
let value = alpha
* alpha_damage_coeff(weapon, modifiers, false)
* modifiers.coef("controllableWeaponDamageCoeff")
* artillery_damage_coeff(cal_m, weapon, modifiers, HEAVY_CRUISER_SHELL_DIAMETER_M)
* csap;
Some(Hp::from(value))
}
_ => None,
};
let he_pen_coef =
if is_atba { modifiers.coef("GSPenetrationCoeffHE") } else { modifiers.coef("GMPenetrationCoeffHE") };
let penetration = match weapon {
StatWeaponType::MainHe | StatWeaponType::AtbaHe => {
projectile.alpha_piercing_he().map(|p| Millimeters::from((p * he_pen_coef).floor()))
}
StatWeaponType::MainCs | StatWeaponType::AtbaCs => {
projectile.alpha_piercing_cs().map(|p| Millimeters::from(p.floor()))
}
_ => None,
};
let is_small = caliber_m.is_some_and(|c| is_small_projectile(c, SMALL_PROJECTILE_MAX_DIAMETER_M));
let burn_chance = match weapon {
StatWeaponType::MainHe | StatWeaponType::MainAp | StatWeaponType::AtbaHe | StatWeaponType::AtbaAp => projectile
.burn_prob()
.map(|bp| Percent::from(calculate_burn_chance(level, bp, modifiers, is_small) * 100.0)),
_ => None,
};
let flood_chance = match weapon {
StatWeaponType::MainHe | StatWeaponType::AtbaHe => projectile.uw_critical().map(|f| Percent::from(f * 100.0)),
_ => None,
};
ShellStats {
name,
ammo_kind: Some(ammo_kind.to_string()),
damage,
caliber,
speed,
penetration,
burn_chance,
flood_chance,
max_ammo: Some(AmmoCount::Infinite),
disabled_underwater: None,
}
}
pub fn artillery(
arty: &ArtilleryComponentStats,
modifiers: &ModifierBundle,
fc_max_dist_coef: f32,
level: u32,
provider: &dyn GameParamProvider,
) -> Option<Artillery> {
if arty.guns.is_empty() {
return None;
}
let shot_delay_coef = modifiers.coef("GMShotDelay");
let max_dist_coef = modifiers.coef("GMMaxDist");
let ideal_radius_coef = modifiers.coef("GMIdealRadius");
let switch_coef = modifiers.coef("switchAmmoReloadCoef");
let yaw_coef = modifiers.coef("GMRotationSpeed");
let yaw_bonus = modifiers.bonus("GMRotationSpeedBonus");
let first = &arty.guns[0];
let reload_time = first.shot_delay.map(|d| Seconds::from(d.value() * shot_delay_coef));
let range_km = arty.max_dist.map(|d| (d.value() / KM_TO_M) * fc_max_dist_coef * max_dist_coef);
let range = range_km.map(Km::from);
let dispersion = match (range_km, first.min_radius, first.ideal_radius, first.ideal_distance) {
(Some(rng), Some(min_r), Some(ideal_r), Some(ideal_d)) => {
Some(Meters::from(constants::dispersion(min_r, ideal_r, ideal_d, rng, ideal_radius_coef)))
}
_ => None,
};
let ammo_switch_time = match (first.shot_delay, first.ammo_switch_coeff) {
(Some(delay), Some(coeff)) => Some(Seconds::from(delay.value() * coeff * shot_delay_coef * switch_coef)),
_ => None,
};
let rotation_speed = first.rotation_speed.map(|r| r.value() * yaw_coef + yaw_bonus);
let rotation_time = rotation_speed.filter(|&r| r != 0.0).map(|r| Seconds::from(180.0 / r));
let gun = Some(MainGun {
caliber: first.barrel_diameter.map(|b| b.to_mm()),
num_barrels: first.num_barrels.map(|n| n as u32),
num_guns: Some(arty.guns.len() as u32),
rotation_speed: rotation_speed.map(DegreesPerSecond::from),
rotation_time,
});
let mut shells = Vec::new();
let mut seen: Vec<String> = Vec::new();
for ammo_name in &first.ammo {
if seen.iter().any(|n| n == ammo_name) {
continue;
}
seen.push(ammo_name.clone());
if let Some(param) = provider.game_param_by_name(ammo_name)
&& let Some(projectile) = param.projectile()
{
shells.push(shell_stats(ammo_name.clone(), projectile, modifiers, level, false));
} else {
warn_unresolved_ammo(ammo_name);
}
}
Some(Artillery { reload_time, range, dispersion, ammo_switch_time, gun, shells })
}
pub fn secondaries(
atba: &SecondaryComponentStats,
modifiers: &ModifierBundle,
level: u32,
provider: &dyn GameParamProvider,
) -> Option<Artillery> {
if atba.guns.is_empty() {
return None;
}
let shot_delay_coef = modifiers.coef("GSShotDelay");
let max_dist_coef = modifiers.coef("GSMaxDist");
let ideal_radius_coef = modifiers.coef("GSIdealRadius");
let yaw_coef = modifiers.coef("GSRotationSpeed");
let yaw_bonus = modifiers.bonus("GSRotationSpeedBonus");
let first = &atba.guns[0];
let reload_time = first.shot_delay.map(|d| Seconds::from(d.value() * shot_delay_coef));
let range_km = atba.max_dist.map(|d| (d.value() / KM_TO_M) * max_dist_coef);
let range = range_km.map(Km::from);
let dispersion = match (range_km, first.min_radius, first.ideal_radius, first.ideal_distance) {
(Some(rng), Some(min_r), Some(ideal_r), Some(ideal_d)) => {
Some(Meters::from(constants::dispersion(min_r, ideal_r, ideal_d, rng, ideal_radius_coef)))
}
_ => None,
};
let rotation_speed = first.rotation_speed.map(|r| r.value() * yaw_coef + yaw_bonus);
let rotation_time = rotation_speed.filter(|&r| r != 0.0).map(|r| Seconds::from(180.0 / r));
let gun = Some(MainGun {
caliber: first.barrel_diameter.map(|b| b.to_mm()),
num_barrels: first.num_barrels.map(|n| n as u32),
num_guns: Some(atba.guns.len() as u32),
rotation_speed: rotation_speed.map(DegreesPerSecond::from),
rotation_time,
});
let mut shells = Vec::new();
let mut seen: Vec<String> = Vec::new();
for gun_stats in &atba.guns {
for ammo_name in &gun_stats.ammo {
if seen.iter().any(|n| n == ammo_name) {
continue;
}
seen.push(ammo_name.clone());
if let Some(param) = provider.game_param_by_name(ammo_name)
&& let Some(projectile) = param.projectile()
{
shells.push(shell_stats(ammo_name.clone(), projectile, modifiers, level, true));
} else {
warn_unresolved_ammo(ammo_name);
}
}
}
Some(Artillery {
reload_time,
range,
dispersion,
ammo_switch_time: None,
gun,
shells,
})
}
const MINIMAL_VALID_VALUE: f32 = 0.01;
pub fn visibility(
hull: &HullComponentStats,
modifiers: &ModifierBundle,
has_big_gun_artillery: bool,
mg_max_dist_km: Option<f32>,
atba_max_dist_km: Option<f32>,
) -> Visibility {
let mut coeff = modifiers.coef("visibilityDistCoeff");
if has_big_gun_artillery {
coeff *= modifiers.coef("GMBigGunVisibilityCoeff");
}
let sea = hull.visibility_factor.map(|v| v.value() * modifiers.coef("visibilityFactor") * coeff);
let sea_detection = sea.map(Km::from);
let sea_detection_on_fire = match (sea, hull.visibility_coef_fire) {
(Some(s), Some(fire)) => Some(Km::from(s + fire.value())),
_ => None,
};
let detection_in_smoke =
hull.visibility_coef_gk_in_smoke.filter(|&v| v.value() > MINIMAL_VALID_VALUE).map(|v| Km::from(v.value()));
let secondary_range_detection = match (sea, mg_max_dist_km, atba_max_dist_km) {
(Some(s), mg, atba) if mg.is_some() || atba.is_some() => {
let floor = mg.unwrap_or(s).max(atba.unwrap_or(s));
Some(Km::from(s.max(floor)))
}
_ => None,
};
let air = hull.visibility_factor_by_plane.map(|v| v.value() * modifiers.coef("visibilityFactorByPlane") * coeff);
let air_detection = air.map(Km::from);
let air_detection_on_fire = match (air, hull.visibility_coef_fire_by_plane) {
(Some(a), Some(fire)) => Some(Km::from(a + fire.value())),
_ => None,
};
let periscope_depth_detection = hull
.visibility_factor_by_periscope
.map(|v| Km::from(v.value() * modifiers.coef("visibilityForSubmarineCoeff")));
Visibility {
sea_detection,
sea_detection_on_fire,
air_detection,
air_detection_on_fire,
detection_in_smoke,
secondary_range_detection,
periscope_depth_detection,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game_params::ttx::components::ArtilleryGunStats;
use crate::game_params::ttx::constants::DEFAULT_UW_DAMAGE_COEFF;
use crate::game_params::types::CrewSkillModifier;
use crate::game_params::types::Species;
const VERSION: crate::data::Version = crate::data::Version::base(15, 0, 0);
fn gearing_hull() -> HullComponentStats {
HullComponentStats {
health: Some(Hp::from(19400.0)),
max_speed: Some(Knots::from(36.0)),
speed_coef: Some(1.0),
turning_radius: Some(Meters::from(640.0)),
rudder_time: Some(Seconds::from(4.25)),
visibility_factor: Some(Km::from(7.33)),
visibility_factor_by_plane: Some(Km::from(3.41)),
visibility_coef_fire: Some(Km::from(2.0)),
visibility_coef_fire_by_plane: Some(Km::from(2.0)),
visibility_coef_gk: Some(Km::from(1e-6)),
visibility_coef_gk_in_smoke: Some(Km::from(2.83)),
visibility_factor_by_periscope: None,
flood_prob: Some(0.0),
battery_capacity: None,
battery_regen_rate: None,
}
}
fn yamato_hull() -> HullComponentStats {
let flood_prob = (DEFAULT_UW_DAMAGE_COEFF - 0.15) / DEFAULT_UW_DAMAGE_COEFF;
HullComponentStats { flood_prob: Some(flood_prob), ..Default::default() }
}
fn balao_hull() -> HullComponentStats {
HullComponentStats { battery_capacity: Some(240.0), battery_regen_rate: Some(1.2), ..Default::default() }
}
fn gearing_engine() -> EngineComponentStats {
EngineComponentStats { speed_coef: Some(0.0) }
}
fn modifier(name: &str, value: f32) -> CrewSkillModifier {
CrewSkillModifier::builder()
.name(name.to_string())
.aircraft_carrier(value)
.auxiliary(value)
.battleship(value)
.cruiser(value)
.destroyer(value)
.submarine(value)
.excluded_consumables(Vec::new())
.build()
}
#[test]
fn gearing_stock_durability_health() {
let durability = durability(&gearing_hull(), &ModifierBundle::empty(Species::Destroyer), 10);
assert_eq!(durability.health, Some(Hp::from(19400.0)));
}
#[test]
fn gearing_stock_durability_ptz_zero() {
let durability = durability(&gearing_hull(), &ModifierBundle::empty(Species::Destroyer), 10);
assert_eq!(durability.torpedo_protection, Some(Percent::from(0.0)));
}
#[test]
fn yamato_stock_durability_ptz() {
let durability = durability(&yamato_hull(), &ModifierBundle::empty(Species::Battleship), 10);
let ptz = durability.torpedo_protection.expect("ptz computed").value();
let expected = (DEFAULT_UW_DAMAGE_COEFF - 0.15) / DEFAULT_UW_DAMAGE_COEFF * 100.0;
assert!((ptz - expected).abs() < 1e-4, "got {ptz}, expected {expected}");
assert!((ptz - 54.954956).abs() < 1e-3, "got {ptz}");
}
#[test]
fn yamato_ptz_modifier_applies() {
let mods = [modifier("uwCoeffBonus", 25.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let durability = durability(&yamato_hull(), &bundle, 10);
let ptz = durability.torpedo_protection.expect("ptz computed").value();
let expected = (DEFAULT_UW_DAMAGE_COEFF - 0.15) / DEFAULT_UW_DAMAGE_COEFF * 100.0 + 25.0;
assert!((ptz - expected).abs() < 1e-4, "got {ptz}, expected {expected}");
}
#[test]
fn ptz_none_when_flood_absent() {
let hull = HullComponentStats::default();
let durability = durability(&hull, &ModifierBundle::empty(Species::Destroyer), 10);
assert!(durability.torpedo_protection.is_none());
}
#[test]
fn gearing_stock_mobility() {
let mobility = mobility(&gearing_hull(), &gearing_engine(), &ModifierBundle::empty(Species::Destroyer));
assert_eq!(mobility.speed, Some(Knots::from(36.0)));
assert_eq!(mobility.turning_radius, Some(Meters::from(640.0)));
assert_eq!(mobility.rudder_time, Some(Seconds::from(4.25)));
}
#[test]
fn speed_coef_modifier_applies() {
let mods = [modifier("speedCoef", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let mobility = mobility(&gearing_hull(), &gearing_engine(), &bundle);
let speed = mobility.speed.expect("speed computed").value();
assert!((speed - 37.8).abs() < 1e-4, "got {speed}");
}
#[test]
fn health_modifier_applies() {
let mods = [modifier("healthPerLevel", 350.0), modifier("healthHullCoeff", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let durability = durability(&gearing_hull(), &bundle, 10);
assert_eq!(durability.health, Some(Hp::from(24050.0)));
}
#[test]
fn absent_inputs_are_none() {
let empty_hull = HullComponentStats::default();
let durability = durability(&empty_hull, &ModifierBundle::empty(Species::Destroyer), 10);
assert!(durability.health.is_none());
let mobility =
mobility(&empty_hull, &EngineComponentStats::default(), &ModifierBundle::empty(Species::Destroyer));
assert!(mobility.speed.is_none());
assert!(mobility.turning_radius.is_none());
assert!(mobility.rudder_time.is_none());
assert!(battery(&empty_hull, &ModifierBundle::empty(Species::Submarine)).is_none());
}
#[test]
fn balao_stock_battery() {
let battery = battery(&balao_hull(), &ModifierBundle::empty(Species::Submarine)).expect("battery computed");
assert_eq!(battery.capacity, Some(240.0));
assert_eq!(battery.regeneration, Some(1.2));
}
#[test]
fn balao_battery_modifiers_apply() {
let mods = [modifier("batteryCapacityCoeff", 1.1), modifier("batteryRegenCoeff", 1.25)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Submarine, VERSION).expect("test modifiers are all known");
let battery = battery(&balao_hull(), &bundle).expect("battery computed");
let capacity = battery.capacity.expect("capacity");
let regen = battery.regeneration.expect("regen");
assert!((capacity - 264.0).abs() < 1e-4, "got {capacity}");
assert!((regen - 1.5).abs() < 1e-4, "got {regen}");
}
#[test]
fn battery_none_for_non_sub() {
assert!(battery(&gearing_hull(), &ModifierBundle::empty(Species::Destroyer)).is_none());
}
fn armor_map(entries: &[(u32, f32)]) -> ArmorMap {
let mut m: ArmorMap = std::collections::HashMap::new();
for &(raw, thk) in entries {
let model_index = raw >> 16;
let material_id = raw & 0xFFFF;
m.entry(material_id).or_default().insert(model_index, thk);
}
m
}
fn yamato_hull_armor() -> ArmorMap {
armor_map(&[
(131072 | 61, 410.0), (131072 | 134, 560.0), (131072 | 82, 350.0), (131072 | 89, 19.0), (1, 0.0), ])
}
fn yamato_turret_armor() -> ArmorMap {
armor_map(&[
(65536 | 100, 650.0), (65536 | 32, 250.0), (65536 | 99, 135.0), ])
}
#[test]
fn yamato_armor_min_max_with_artillery() {
let hull = yamato_hull_armor();
let arti = [yamato_turret_armor()];
let armor = armor(&hull, arti.iter()).expect("armor computed");
assert_eq!(armor.max, Some(Millimeters::from(650.0)));
assert_eq!(armor.min, Some(Millimeters::from(19.0)));
}
#[test]
fn hull_only_armor_excludes_unclassified() {
let hull = yamato_hull_armor();
let armor = armor(&hull, std::iter::empty()).expect("armor computed");
assert_eq!(armor.max, Some(Millimeters::from(560.0)));
assert_eq!(armor.min, Some(Millimeters::from(19.0)));
}
#[test]
fn armor_none_when_hull_armor_absent() {
let empty: ArmorMap = std::collections::HashMap::new();
assert!(armor(&empty, std::iter::empty()).is_none());
}
#[test]
fn armor_defaults_when_no_classified_plate() {
let hull = armor_map(&[(131072 | 82, 350.0), (131072 | 80, 200.0)]); let armor = armor(&hull, std::iter::empty()).expect("armor computed");
assert_eq!(armor.min, Some(Millimeters::from(6.0)));
assert_eq!(armor.max, Some(Millimeters::from(6.0)));
}
use crate::Rc;
use crate::game_params::types::Param;
use crate::game_params::types::ParamData;
use crate::game_types::GameParamId;
fn gearing_torpedo() -> Projectile {
Projectile::builder()
.ammo_type("torpedo".to_string())
.max_dist(crate::game_params::types::BigWorldDistance::from(350.0))
.speed(66.0)
.alpha_damage(53500.0)
.damage(1200.0)
.visibility_factor(1.4)
.torpedo_type(0)
.build()
}
fn gearing_launcher() -> TorpedoLauncherStats {
TorpedoLauncherStats {
shot_delay: Some(Seconds::from(103.0)),
rotation_speed: Some(DegreesPerSecond::from(25.0)),
num_barrels: Some(5.0),
ammo_switch_coeff: None,
ammo: vec!["PAPT027_Mk_16_mod_1".to_string()],
}
}
struct StubProvider {
param: Rc<Param>,
}
impl StubProvider {
fn new(name: &str, projectile: Projectile) -> Self {
let param = Param::builder()
.id(GameParamId::from(1u32))
.index("S0001".to_string())
.name(name.to_string())
.nation("USA".to_string())
.data(ParamData::Projectile(projectile))
.build();
StubProvider { param: Rc::new(param) }
}
}
impl GameParamProvider for StubProvider {
fn game_param_by_id(&self, _id: GameParamId) -> Option<Rc<Param>> {
None
}
fn game_param_by_index(&self, _index: &str) -> Option<Rc<Param>> {
None
}
fn game_param_by_name(&self, name: &str) -> Option<Rc<Param>> {
(self.param.name() == name).then(|| self.param.clone())
}
fn params(&self) -> &[Rc<Param>] {
std::slice::from_ref(&self.param)
}
}
fn approx(a: f32, b: f32) -> bool {
(a - b).abs() < 1e-2
}
#[test]
fn gearing_stock_torpedo_stats() {
let stats = torpedo_stats(
"PAPT027_Mk_16_mod_1".to_string(),
&gearing_torpedo(),
&ModifierBundle::empty(Species::Destroyer),
);
let damage = stats.damage.expect("damage").value();
assert!(approx(damage, 53500.0 / 3.0 + 1200.0), "got {damage}");
assert_eq!(stats.speed, Some(Knots::from(66.0)));
let range = stats.range.expect("range").value();
assert!(approx(range, 10.5), "got {range}");
assert_eq!(stats.visibility, Some(Km::from(1.4)));
assert!(stats.is_damage_increasing.is_none());
assert!(stats.distance_of_max_damage.is_none());
assert!(stats.disabled_underwater.is_none());
}
#[test]
fn gearing_stock_torpedoes_via_provider() {
let launchers = [gearing_launcher()];
let provider = StubProvider::new("PAPT027_Mk_16_mod_1", gearing_torpedo());
let torps =
torpedoes(&launchers, &ModifierBundle::empty(Species::Destroyer), &provider).expect("torpedoes computed");
assert_eq!(torps.reload_time, Some(Seconds::from(103.0)));
assert_eq!(torps.launchers.len(), 1);
let launcher = &torps.launchers[0];
assert_eq!(launcher.rotation_speed, Some(DegreesPerSecond::from(25.0)));
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 7.2), "got {rt}");
assert_eq!(launcher.num_barrels, Some(5));
assert_eq!(torps.torpedoes.len(), 1);
let torp = &torps.torpedoes[0];
assert_eq!(torp.name, "PAPT027_Mk_16_mod_1");
assert!(approx(torp.damage.expect("damage").value(), 53500.0 / 3.0 + 1200.0));
assert_eq!(torp.speed, Some(Knots::from(66.0)));
assert!(approx(torp.range.expect("range").value(), 10.5));
assert_eq!(torp.visibility, Some(Km::from(1.4)));
}
#[test]
fn torpedo_speed_bonus_applies() {
let mods = [modifier("torpedoSpeedBonus", 5.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let stats = torpedo_stats("PAPT027_Mk_16_mod_1".to_string(), &gearing_torpedo(), &bundle);
let speed = stats.speed.expect("speed").value();
assert!(approx(speed, 71.0), "got {speed}");
}
#[test]
fn torpedo_damage_coeff_applies() {
let mods = [modifier("torpedoDamageCoeff", 1.2)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let stats = torpedo_stats("PAPT027_Mk_16_mod_1".to_string(), &gearing_torpedo(), &bundle);
let damage = stats.damage.expect("damage").value();
assert!(approx(damage, (53500.0 / 3.0 + 1200.0) * 1.2), "got {damage}");
}
#[test]
fn torpedo_launcher_traverse_coef_applies() {
let mods = [modifier("GTRotationSpeed", 1.2)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let launchers = [gearing_launcher()];
let provider = StubProvider::new("PAPT027_Mk_16_mod_1", gearing_torpedo());
let torps = torpedoes(&launchers, &bundle, &provider).expect("torpedoes computed");
let launcher = &torps.launchers[0];
let rs = launcher.rotation_speed.expect("rotation_speed").value();
assert!(approx(rs, 30.0), "got {rs}");
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 6.0), "got {rt}");
}
#[test]
fn torpedo_launcher_traverse_bonus_applies() {
let mods = [modifier("GTRotationSpeedBonus", 5.0)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let launchers = [gearing_launcher()];
let provider = StubProvider::new("PAPT027_Mk_16_mod_1", gearing_torpedo());
let torps = torpedoes(&launchers, &bundle, &provider).expect("torpedoes computed");
let launcher = &torps.launchers[0];
assert_eq!(launcher.rotation_speed, Some(DegreesPerSecond::from(30.0)));
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 6.0), "got {rt}");
}
#[test]
fn torpedoes_none_when_no_launchers() {
let provider = StubProvider::new("PAPT027_Mk_16_mod_1", gearing_torpedo());
assert!(torpedoes(&[], &ModifierBundle::empty(Species::Destroyer), &provider).is_none());
}
#[test]
fn torpedo_stats_none_when_inputs_absent() {
let empty = Projectile::builder().ammo_type("torpedo".to_string()).build();
let stats = torpedo_stats("X".to_string(), &empty, &ModifierBundle::empty(Species::Destroyer));
assert!(stats.damage.is_none());
assert!(stats.speed.is_none());
assert!(stats.range.is_none());
assert!(stats.visibility.is_none());
}
fn worcester_he() -> Projectile {
Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(2200.0)
.alpha_piercing_he(30.0)
.burn_prob(0.12)
.uw_critical(0.0)
.bullet_diametr(0.152)
.bullet_speed(812.0)
.build()
}
fn worcester_ap() -> Projectile {
Projectile::builder()
.ammo_type("AP".to_string())
.alpha_damage(3200.0)
.burn_prob(-0.5)
.uw_critical(0.0)
.bullet_diametr(0.152)
.bullet_speed(762.0)
.build()
}
fn worcester_artillery() -> ArtilleryComponentStats {
let gun = || ArtilleryGunStats {
shot_delay: Some(Seconds::from(4.6)),
rotation_speed: Some(DegreesPerSecond::from(25.0)),
num_barrels: Some(2.0),
barrel_diameter: Some(Meters::from(0.152)),
ammo_switch_coeff: Some(1.0),
min_radius: Some(1.1),
ideal_radius: Some(8.0),
ideal_distance: Some(1000.0),
ammo: vec!["PAPA051_152mm_HE_HC_Mark_39_Mod_0".to_string(), "PAPA050_152mm_AP_130lbs_Mk35".to_string()],
};
ArtilleryComponentStats {
max_dist: Some(Meters::from(15320.0)),
guns: vec![gun(), gun(), gun(), gun(), gun(), gun()],
}
}
struct MultiProvider {
params: Vec<Rc<Param>>,
}
impl MultiProvider {
fn new(entries: &[(&str, Projectile)]) -> Self {
let params = entries
.iter()
.enumerate()
.map(|(i, (name, proj))| {
Rc::new(
Param::builder()
.id(GameParamId::from((i + 1) as u32))
.index(format!("S{i:04}"))
.name(name.to_string())
.nation("USA".to_string())
.data(ParamData::Projectile(proj.clone()))
.build(),
)
})
.collect();
MultiProvider { params }
}
}
impl GameParamProvider for MultiProvider {
fn game_param_by_id(&self, _id: GameParamId) -> Option<Rc<Param>> {
None
}
fn game_param_by_index(&self, _index: &str) -> Option<Rc<Param>> {
None
}
fn game_param_by_name(&self, name: &str) -> Option<Rc<Param>> {
self.params.iter().find(|p| p.name() == name).cloned()
}
fn params(&self) -> &[Rc<Param>] {
&self.params
}
}
fn worcester_provider() -> MultiProvider {
MultiProvider::new(&[
("PAPA051_152mm_HE_HC_Mark_39_Mod_0", worcester_he()),
("PAPA050_152mm_AP_130lbs_Mk35", worcester_ap()),
])
}
#[test]
fn worcester_stock_artillery_gun_and_range() {
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider)
.expect("artillery computed");
assert_eq!(arty.range, Some(Km::from(15.32)));
assert_eq!(arty.reload_time, Some(Seconds::from(4.6)));
let st = arty.ammo_switch_time.expect("switch time").value();
assert!(approx(st, 4.6), "got {st}");
let gun = arty.gun.expect("gun");
assert_eq!(gun.caliber, Some(Millimeters::from(152.0)));
assert_eq!(gun.num_guns, Some(6));
assert_eq!(gun.num_barrels, Some(2));
assert_eq!(gun.rotation_speed, Some(DegreesPerSecond::from(25.0)));
let rt = gun.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 7.2), "got {rt}");
}
#[test]
fn worcester_stock_dispersion_matches_helper() {
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider)
.expect("artillery computed");
let expected = constants::dispersion(1.1, 8.0, 1000.0, 15.32, 1.0);
let got = arty.dispersion.expect("dispersion").value();
assert!((got - expected).abs() < 1e-3, "got {got} expected {expected}");
assert!((got - 138.7).abs() < 1.0, "got {got}");
}
#[test]
fn worcester_stock_he_shell() {
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider)
.expect("artillery computed");
let he = arty.shells.iter().find(|s| s.ammo_kind.as_deref() == Some("HE")).expect("HE shell");
assert_eq!(he.damage, Some(Hp::from(2200.0)));
assert_eq!(he.penetration, Some(Millimeters::from(30.0)));
assert_eq!(he.burn_chance, Some(Percent::from(12.0)));
assert_eq!(he.caliber, Some(Millimeters::from(152.0)));
assert_eq!(he.speed, Some(812.0));
assert_eq!(he.flood_chance, Some(Percent::from(0.0)));
assert_eq!(he.max_ammo, Some(AmmoCount::Infinite));
}
#[test]
fn worcester_stock_ap_shell() {
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider)
.expect("artillery computed");
let ap = arty.shells.iter().find(|s| s.ammo_kind.as_deref() == Some("AP")).expect("AP shell");
assert_eq!(ap.damage, Some(Hp::from(3200.0)));
assert!(ap.penetration.is_none());
assert_eq!(ap.burn_chance, Some(Percent::from(0.0)));
assert!(ap.flood_chance.is_none());
assert_eq!(ap.speed, Some(762.0));
}
#[test]
fn worcester_modified_reload_and_range() {
let mods = [modifier("GMShotDelay", 0.9), modifier("GMMaxDist", 1.1)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &bundle, 1.0, 10, &provider).expect("artillery computed");
let reload = arty.reload_time.expect("reload").value();
assert!(approx(reload, 4.14), "got {reload}");
let range = arty.range.expect("range").value();
assert!(approx(range, 16.852), "got {range}");
}
#[test]
fn worcester_traverse_modifier_applies() {
let mods = [modifier("GMRotationSpeed", 1.2)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let provider = worcester_provider();
let arty = artillery(&worcester_artillery(), &bundle, 1.0, 10, &provider).expect("artillery computed");
let gun = arty.gun.expect("gun");
let rs = gun.rotation_speed.expect("rotation_speed").value();
assert!(approx(rs, 30.0), "got {rs}");
let rt = gun.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 6.0), "got {rt}");
}
#[test]
fn artillery_none_when_no_guns() {
let provider = worcester_provider();
let empty = ArtilleryComponentStats::default();
assert!(artillery(&empty, &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider).is_none());
}
#[test]
fn artillery_drops_unresolvable_ammo_row() {
let provider = MultiProvider::new(&[("PAPA051_152mm_HE_HC_Mark_39_Mod_0", worcester_he())]);
let arty = artillery(&worcester_artillery(), &ModifierBundle::empty(Species::Cruiser), 1.0, 10, &provider)
.expect("artillery computed");
assert_eq!(arty.shells.len(), 1);
assert!(arty.shells.iter().any(|s| s.ammo_kind.as_deref() == Some("HE")));
assert!(!arty.shells.iter().any(|s| s.ammo_kind.as_deref() == Some("AP")));
}
#[test]
fn shell_stats_none_when_inputs_absent() {
let empty = Projectile::builder().ammo_type("HE".to_string()).build();
let stats = shell_stats("X".to_string(), &empty, &ModifierBundle::empty(Species::Cruiser), 10, false);
assert!(stats.damage.is_none());
assert!(stats.caliber.is_none());
assert!(stats.speed.is_none());
assert!(stats.penetration.is_none());
assert!(stats.burn_chance.is_none());
assert!(stats.flood_chance.is_none());
}
#[test]
fn shell_speed_applies_time_factor() {
let shell = Projectile::builder()
.ammo_type("HE".to_string())
.bullet_diametr(0.305)
.bullet_speed(762.0)
.time_factor(0.5)
.build();
let stats = shell_stats("X".to_string(), &shell, &ModifierBundle::empty(Species::Cruiser), 10, false);
assert_eq!(stats.speed, Some(381.0));
let plain = Projectile::builder().ammo_type("HE".to_string()).bullet_diametr(0.152).bullet_speed(812.0).build();
let plain_stats = shell_stats("Y".to_string(), &plain, &ModifierBundle::empty(Species::Cruiser), 10, false);
assert_eq!(plain_stats.speed, Some(812.0));
}
use crate::game_params::ttx::components::SecondaryComponentStats;
fn bismarck_150mm_he() -> Projectile {
Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(1700.0)
.alpha_piercing_he(38.0)
.burn_prob(0.08)
.uw_critical(0.0)
.bullet_diametr(0.15)
.bullet_speed(875.0)
.build()
}
fn bismarck_105mm_he() -> Projectile {
Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(1200.0)
.alpha_piercing_he(26.0)
.burn_prob(0.05)
.uw_critical(0.0)
.bullet_diametr(0.105)
.bullet_speed(900.0)
.build()
}
fn bismarck_secondaries() -> SecondaryComponentStats {
let gun_150 = ArtilleryGunStats {
shot_delay: Some(Seconds::from(7.5)),
rotation_speed: Some(DegreesPerSecond::from(60.0)),
num_barrels: Some(2.0),
barrel_diameter: Some(Meters::from(0.15)),
ammo_switch_coeff: None,
min_radius: Some(1.0),
ideal_radius: Some(15.5),
ideal_distance: Some(333.333),
ammo: vec!["PGPA003_150mm_HE_HE_N_F".to_string()],
};
let gun_105 = ArtilleryGunStats {
shot_delay: Some(Seconds::from(3.35)),
rotation_speed: Some(DegreesPerSecond::from(60.0)),
num_barrels: Some(2.0),
barrel_diameter: Some(Meters::from(0.105)),
ammo_switch_coeff: None,
min_radius: Some(1.0),
ideal_radius: Some(15.5),
ideal_distance: Some(333.333),
ammo: vec!["PGPA085_105mm_HE_HE_33lbs".to_string()],
};
SecondaryComponentStats {
max_dist: Some(Meters::from(7600.0)),
guns: vec![gun_150.clone(), gun_150, gun_105.clone(), gun_105],
}
}
fn bismarck_secondary_provider() -> MultiProvider {
MultiProvider::new(&[
("PGPA003_150mm_HE_HE_N_F", bismarck_150mm_he()),
("PGPA085_105mm_HE_HE_33lbs", bismarck_105mm_he()),
])
}
#[test]
fn bismarck_stock_secondaries_gun_and_range() {
let provider = bismarck_secondary_provider();
let sec = secondaries(&bismarck_secondaries(), &ModifierBundle::empty(Species::Battleship), 8, &provider)
.expect("secondaries computed");
assert_eq!(sec.range, Some(Km::from(7.6)));
assert_eq!(sec.reload_time, Some(Seconds::from(7.5)));
assert!(sec.ammo_switch_time.is_none());
let gun = sec.gun.expect("gun");
assert_eq!(gun.caliber, Some(Millimeters::from(150.0)));
assert_eq!(gun.num_guns, Some(4));
assert_eq!(gun.num_barrels, Some(2));
assert_eq!(gun.rotation_speed, Some(DegreesPerSecond::from(60.0)));
let rt = gun.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 3.0), "got {rt}");
}
#[test]
fn bismarck_stock_secondaries_shells() {
let provider = bismarck_secondary_provider();
let sec = secondaries(&bismarck_secondaries(), &ModifierBundle::empty(Species::Battleship), 8, &provider)
.expect("secondaries computed");
assert_eq!(sec.shells.len(), 2);
let s150 = sec.shells.iter().find(|s| s.name == "PGPA003_150mm_HE_HE_N_F").expect("150mm shell");
assert_eq!(s150.damage, Some(Hp::from(1700.0)));
assert_eq!(s150.penetration, Some(Millimeters::from(38.0)));
assert_eq!(s150.burn_chance, Some(Percent::from(8.0)));
assert_eq!(s150.caliber, Some(Millimeters::from(150.0)));
assert_eq!(s150.speed, Some(875.0));
assert_eq!(s150.flood_chance, Some(Percent::from(0.0)));
let s105 = sec.shells.iter().find(|s| s.name == "PGPA085_105mm_HE_HE_33lbs").expect("105mm shell");
assert_eq!(s105.damage, Some(Hp::from(1200.0)));
assert_eq!(s105.penetration, Some(Millimeters::from(26.0)));
assert_eq!(s105.burn_chance, Some(Percent::from(5.0)));
assert_eq!(s105.caliber, Some(Millimeters::from(105.0)));
}
#[test]
fn bismarck_secondary_range_modifier_applies() {
let mods = [modifier("GSMaxDist", 1.2)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let provider = bismarck_secondary_provider();
let sec = secondaries(&bismarck_secondaries(), &bundle, 8, &provider).expect("secondaries computed");
let range = sec.range.expect("range").value();
assert!(approx(range, 9.12), "got {range}");
}
#[test]
fn bismarck_secondary_reload_modifier_applies() {
let mods = [modifier("GSShotDelay", 0.85)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let provider = bismarck_secondary_provider();
let sec = secondaries(&bismarck_secondaries(), &bundle, 8, &provider).expect("secondaries computed");
let reload = sec.reload_time.expect("reload").value();
assert!(approx(reload, 6.375), "got {reload}");
}
#[test]
fn secondaries_none_when_no_guns() {
let provider = bismarck_secondary_provider();
let empty = SecondaryComponentStats::default();
assert!(secondaries(&empty, &ModifierBundle::empty(Species::Battleship), 8, &provider).is_none());
}
#[test]
fn gearing_stock_visibility() {
let vis = visibility(&gearing_hull(), &ModifierBundle::empty(Species::Destroyer), false, None, None);
assert_eq!(vis.sea_detection, Some(Km::from(7.33)));
let on_fire = vis.sea_detection_on_fire.expect("sea on fire").value();
assert!(approx(on_fire, 9.33), "got {on_fire}");
assert_eq!(vis.air_detection, Some(Km::from(3.41)));
let air_fire = vis.air_detection_on_fire.expect("air on fire").value();
assert!(approx(air_fire, 5.41), "got {air_fire}");
assert_eq!(vis.detection_in_smoke, Some(Km::from(2.83)));
assert!(vis.secondary_range_detection.is_none());
assert!(vis.periscope_depth_detection.is_none());
}
#[test]
fn concealment_modifier_reduces_sea_detection() {
let mods = [modifier("visibilityFactor", 0.9)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let vis = visibility(&gearing_hull(), &bundle, false, None, None);
let sea = vis.sea_detection.expect("sea").value();
assert!(approx(sea, 6.597), "got {sea}");
let on_fire = vis.sea_detection_on_fire.expect("sea on fire").value();
assert!(approx(on_fire, 8.597), "got {on_fire}");
}
#[test]
fn camouflage_dist_coeff_reduces_sea_detection() {
let mods = [modifier("visibilityDistCoeff", 0.97)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let vis = visibility(&gearing_hull(), &bundle, false, None, None);
let sea = vis.sea_detection.expect("sea").value();
assert!(approx(sea, 7.1101), "got {sea}");
let air = vis.air_detection.expect("air").value();
assert!(approx(air, 3.3077), "got {air}");
}
#[test]
fn big_gun_visibility_penalty_applies_only_with_non_small_artillery() {
let mods = [modifier("GMBigGunVisibilityCoeff", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let with_guns = visibility(&gearing_hull(), &bundle, true, None, None);
let sea = with_guns.sea_detection.expect("sea").value();
assert!(approx(sea, 7.6965), "got {sea}");
let without = visibility(&gearing_hull(), &bundle, false, None, None);
assert_eq!(without.sea_detection, Some(Km::from(7.33)));
}
#[test]
fn secondary_range_detection_floors_at_atba_range() {
let hull = HullComponentStats { visibility_factor: Some(Km::from(6.0)), ..gearing_hull() };
let vis = visibility(&hull, &ModifierBundle::empty(Species::Battleship), false, None, Some(7.6));
assert_eq!(vis.sea_detection, Some(Km::from(6.0)));
assert_eq!(vis.secondary_range_detection, Some(Km::from(7.6)));
let hull_far = HullComponentStats { visibility_factor: Some(Km::from(9.0)), ..gearing_hull() };
let vis_far = visibility(&hull_far, &ModifierBundle::empty(Species::Battleship), false, None, Some(7.6));
assert_eq!(vis_far.secondary_range_detection, Some(Km::from(9.0)));
}
#[test]
fn near_zero_smoke_coef_yields_no_smoke_detection() {
let hull = HullComponentStats { visibility_coef_gk_in_smoke: Some(Km::from(0.01)), ..gearing_hull() };
let vis = visibility(&hull, &ModifierBundle::empty(Species::Destroyer), false, None, None);
assert!(vis.detection_in_smoke.is_none());
let hull_zero = HullComponentStats { visibility_coef_gk_in_smoke: Some(Km::from(0.0)), ..gearing_hull() };
let vis_zero = visibility(&hull_zero, &ModifierBundle::empty(Species::Destroyer), false, None, None);
assert!(vis_zero.detection_in_smoke.is_none());
}
#[test]
fn submarine_periscope_detection_applies_coeff() {
let hull = HullComponentStats { visibility_factor_by_periscope: Some(Km::from(5.0)), ..gearing_hull() };
let vis = visibility(&hull, &ModifierBundle::empty(Species::Submarine), false, None, None);
assert_eq!(vis.periscope_depth_detection, Some(Km::from(5.0)));
}
#[test]
fn visibility_none_when_inputs_absent() {
let empty = HullComponentStats::default();
let vis = visibility(&empty, &ModifierBundle::empty(Species::Destroyer), false, None, None);
assert!(vis.sea_detection.is_none());
assert!(vis.sea_detection_on_fire.is_none());
assert!(vis.air_detection.is_none());
assert!(vis.air_detection_on_fire.is_none());
assert!(vis.detection_in_smoke.is_none());
assert!(vis.secondary_range_detection.is_none());
assert!(vis.periscope_depth_detection.is_none());
}
}