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::ArtilleryGunStats;
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::labels::TtxStat;
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::module_options::ModuleSlot;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::ModifierSources;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::Recorder;
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;
#[cfg(test)]
use crate::game_params::types::Meters;
use crate::game_params::types::MetersPerSecond;
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<R: Recorder>(
hull: &HullComponentStats,
hull_name: &str,
modifiers: &ModifierBundle,
sources: &ModifierSources,
level: u32,
rec: &mut R,
) -> 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;
if R::ON {
rec.record(
TtxStat::Health,
None,
base.value(),
InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() },
raw,
|b| {
b.bonus(sources, "healthPerLevel", level as f32);
b.coef(sources, "healthHullCoeff");
},
);
}
Hp::from(rounded)
});
let torpedo_protection = hull.flood_prob.map(|prob| {
let value = prob * modifiers.coef("uwCoeffMultiplier") * 100.0 + modifiers.bonus("uwCoeffBonus");
if R::ON {
rec.record(
TtxStat::TorpedoProtection,
None,
prob * 100.0,
InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() },
value,
|b| {
b.coef(sources, "uwCoeffMultiplier");
b.bonus(sources, "uwCoeffBonus", 1.0);
},
);
}
Percent::from(value)
});
Durability { health, torpedo_protection }
}
pub fn mobility<R: Recorder>(
hull: &HullComponentStats,
hull_name: &str,
engine: &EngineComponentStats,
engine_name: Option<&str>,
modifiers: &ModifierBundle,
sources: &ModifierSources,
rec: &mut R,
) -> 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);
let value = max_speed.value() * coef * modifiers.coef("speedCoef");
if R::ON {
rec.record(
TtxStat::Speed,
None,
max_speed.value(),
InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() },
value,
|b| {
let engine_src = engine_name
.map(|n| InputId::Module { slot: ModuleSlot::Engine, name: n.to_string() })
.unwrap_or(InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() });
b.module(engine_src, "speedCoef", coef);
b.coef(sources, "speedCoef");
},
);
}
Some(Knots::from(value))
}
_ => None,
};
let turning_radius = hull.turning_radius;
if R::ON
&& let Some(t) = turning_radius
{
rec.record(
TtxStat::TurningRadius,
None,
t.value(),
InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() },
t.value(),
|_b| {},
);
}
let rudder_time = hull.rudder_time.map(|t| {
let value = t.value() * modifiers.coef("SGRudderTime");
if R::ON {
rec.record(
TtxStat::RudderTime,
None,
t.value(),
InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() },
value,
|b| b.coef(sources, "SGRudderTime"),
);
}
Seconds::from(value)
});
Mobility { speed, turning_radius, rudder_time }
}
pub fn battery<R: Recorder>(
hull: &HullComponentStats,
hull_name: &str,
modifiers: &ModifierBundle,
sources: &ModifierSources,
rec: &mut R,
) -> Option<Battery> {
let (capacity, regen) = (hull.battery_capacity?, hull.battery_regen_rate?);
if capacity == 0.0 {
return None;
}
let cap_value = capacity * modifiers.coef("batteryCapacityCoeff");
let regen_value = regen * modifiers.coef("batteryRegenCoeff");
if R::ON {
let hull_src = || InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() };
rec.record(TtxStat::BatteryCapacity, None, capacity, hull_src(), cap_value, |b| {
b.coef(sources, "batteryCapacityCoeff")
});
rec.record(TtxStat::BatteryRegeneration, None, regen, hull_src(), regen_value, |b| {
b.coef(sources, "batteryRegenCoeff")
});
}
Some(Battery { capacity: Some(cap_value), regeneration: Some(regen_value) })
}
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, R: Recorder>(
hull_armor: &ArmorMap,
hull_name: &str,
artillery_armor: impl IntoIterator<Item = &'a ArmorMap>,
rec: &mut R,
) -> 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),
};
if R::ON {
let src = || InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() };
rec.record(TtxStat::ArmorMin, None, min, src(), min, |_b| {});
rec.record(TtxStat::ArmorMax, None, max, src(), max, |_b| {});
}
Some(Armor { min: Some(Millimeters::from(min)), max: Some(Millimeters::from(max)) })
}
pub fn torpedo_stats<R: Recorder>(
name: String,
projectile: &Projectile,
modifiers: &ModifierBundle,
torp_name: &str,
sources: &ModifierSources,
rec: &mut R,
) -> TorpedoStats {
let torp_src = || InputId::Module { slot: ModuleSlot::Torpedoes, name: torp_name.to_string() };
let qualifier = name.as_str();
let damage = match (projectile.alpha_damage(), projectile.damage()) {
(Some(alpha), Some(flood)) => {
let base = alpha / TORPEDO_DAMAGE_CONSTANT + flood;
let value = base * modifiers.coef("torpedoDamageCoeff") * modifiers.coef("controllableWeaponDamageCoeff");
if R::ON {
rec.record(TtxStat::TorpedoDamage, Some(qualifier), base, torp_src(), value, |b| {
b.coef(sources, "torpedoDamageCoeff");
b.coef(sources, "controllableWeaponDamageCoeff");
});
}
Some(Hp::from(value))
}
_ => None,
};
let speed = projectile.speed().map(|s| {
let is_torpedo = projectile.ammo_type() == AMMO_TYPE_TORPEDO;
let normal = if is_torpedo { modifiers.coef("normalTorpedoSpeedMultiplier") } else { 1.0 };
let value = s * modifiers.coef("torpedoSpeedMultiplier") * normal + modifiers.bonus("torpedoSpeedBonus");
if R::ON {
rec.record(TtxStat::TorpedoSpeed, Some(qualifier), s, torp_src(), value, |b| {
b.coef(sources, "torpedoSpeedMultiplier");
if is_torpedo {
b.coef(sources, "normalTorpedoSpeedMultiplier");
}
b.bonus(sources, "torpedoSpeedBonus", 1.0);
});
}
Knots::from(value)
});
let range = projectile.max_dist().map(|d| {
let base = d.value() * BW_TO_BALLISTIC / KM_TO_M;
let value = base * modifiers.coef("torpedoRangeCoefficient");
if R::ON {
rec.record(TtxStat::TorpedoRange, Some(qualifier), base, torp_src(), value, |b| {
b.coef(sources, "torpedoRangeCoefficient");
});
}
Km::from(value)
});
let visibility = projectile.visibility_factor().map(|v| {
let value = v * modifiers.coef("torpedoVisibilityFactor");
if R::ON {
rec.record(TtxStat::TorpedoVisibility, Some(qualifier), v, torp_src(), value, |b| {
b.coef(sources, "torpedoVisibilityFactor");
});
}
Km::from(value)
});
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
});
if R::ON
&& let Some(flag) = is_damage_increasing
{
let v = if flag { 1.0 } else { 0.0 };
rec.record(TtxStat::TorpedoIsDamageIncreasing, Some(qualifier), v, torp_src(), v, |_b| {});
}
let disabled_underwater: Option<bool> = None;
if R::ON
&& let Some(flag) = disabled_underwater
{
let v = if flag { 1.0 } else { 0.0 };
rec.record(TtxStat::TorpedoDisabledUnderwater, Some(qualifier), v, torp_src(), v, |_b| {});
}
TorpedoStats {
name,
damage,
speed,
range,
visibility,
distance_of_max_damage: None,
is_damage_increasing,
disabled_underwater,
}
}
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");
}
}
#[allow(clippy::too_many_arguments)]
pub fn torpedoes<R: Recorder>(
launchers: &[TorpedoLauncherStats],
modifiers: &ModifierBundle,
reload_coeff: f32,
reload_coeff_source: Option<crate::game_params::ttx::provenance::InputId>,
provider: &dyn GameParamProvider,
torp_name: &str,
sources: &ModifierSources,
rec: &mut R,
) -> Option<Torpedoes> {
if launchers.is_empty() {
return None;
}
let torp_src = || InputId::Module { slot: ModuleSlot::Torpedoes, name: torp_name.to_string() };
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_candidates: Vec<(f32, f32)> = Vec::new();
let mut seen_ammo: Vec<String> = Vec::new();
let mut torpedoes = Vec::new();
for (idx, launcher) in launchers.iter().enumerate() {
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));
let num_barrels = launcher.num_barrels.map(|n| n as u32);
if R::ON {
let q = idx.to_string();
if let Some(speed) = rotation_speed {
let base_speed = launcher.rotation_speed.map(|r| r.value()).unwrap();
rec.record(TtxStat::LauncherRotationSpeed, Some(&q), base_speed, torp_src(), speed, |b| {
b.coef(sources, "GTRotationSpeed");
b.bonus(sources, "GTRotationSpeedBonus", 1.0);
});
}
if let Some(rt) = rotation_time {
rec.record(TtxStat::LauncherRotationTime, Some(&q), rt.value(), torp_src(), rt.value(), |b| {
b.derived_from(TtxStat::LauncherRotationSpeed, Some(&q));
});
}
if let Some(nb) = num_barrels {
rec.record(TtxStat::LauncherNumBarrels, Some(&q), nb as f32, torp_src(), nb as f32, |_b| {});
}
}
result_launchers.push(Launcher {
rotation_speed: rotation_speed.map(DegreesPerSecond::from),
rotation_time,
num_barrels,
});
if let Some(delay) = launcher.shot_delay {
let reload = delay.value() * shot_delay_coef * reload_coeff;
if reload != 0.0 {
reload_candidates.push((reload, delay.value()));
}
}
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, torp_name, sources, rec));
} else {
warn_unresolved_ammo(ammo_name);
}
}
}
let reload_time = if reload_candidates.is_empty() {
None
} else {
let (min_reload, min_base) = reload_candidates
.iter()
.copied()
.reduce(|(ra, ba), (rb, bb)| if ra <= rb { (ra, ba) } else { (rb, bb) })?;
if R::ON {
rec.record(TtxStat::TorpedoReloadTime, None, min_base, torp_src(), min_reload, |b| {
b.coef(sources, "GTShotDelay");
if reload_coeff != 1.0 {
let src = reload_coeff_source.clone().unwrap_or_else(torp_src);
b.module(src, "reloadCoeff", reload_coeff);
}
});
}
Some(Seconds::from(min_reload))
};
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;
struct ShellStatKeys {
caliber: TtxStat,
speed: TtxStat,
damage: TtxStat,
penetration: TtxStat,
burn_chance: TtxStat,
flood_chance: TtxStat,
max_ammo: TtxStat,
}
impl ShellStatKeys {
fn for_battery(is_atba: bool) -> Self {
if is_atba {
ShellStatKeys {
caliber: TtxStat::SecondaryShellCaliber,
speed: TtxStat::SecondaryShellSpeed,
damage: TtxStat::SecondaryShellDamage,
penetration: TtxStat::SecondaryShellPenetration,
burn_chance: TtxStat::SecondaryShellBurnChance,
flood_chance: TtxStat::SecondaryShellFloodChance,
max_ammo: TtxStat::SecondaryShellMaxAmmo,
}
} else {
ShellStatKeys {
caliber: TtxStat::ShellCaliber,
speed: TtxStat::ShellSpeed,
damage: TtxStat::ShellDamage,
penetration: TtxStat::ShellPenetration,
burn_chance: TtxStat::ShellBurnChance,
flood_chance: TtxStat::ShellFloodChance,
max_ammo: TtxStat::ShellMaxAmmo,
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn shell_stats<R: Recorder>(
name: String,
projectile: &Projectile,
modifiers: &ModifierBundle,
arty_name: &str,
sources: &ModifierSources,
level: u32,
is_atba: bool,
shell_qualifier: &str,
rec: &mut R,
) -> 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 qualifier = shell_qualifier;
let arty_src = || InputId::Module { slot: ModuleSlot::Artillery, name: arty_name.to_string() };
let stat = ShellStatKeys::for_battery(is_atba);
let caliber_m = projectile.bullet_diametr();
let caliber = caliber_m.map(|c| Millimeters::from(c * 1000.0));
if R::ON {
if let Some(c) = caliber {
rec.record(stat.caliber, Some(qualifier), c.value(), arty_src(), c.value(), |_b| {});
}
}
let speed = projectile.bullet_speed().map(|s| MetersPerSecond::from(s * projectile.time_factor().unwrap_or(1.0)));
if R::ON {
if let Some(s) = speed {
rec.record(stat.speed, Some(qualifier), s.value(), arty_src(), s.value(), |_b| {});
}
}
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 (alpha_coeff, alpha_applied) = alpha_damage_coeff(weapon, modifiers, false);
let (arty_coeff, arty_applied) =
artillery_damage_coeff(cal_m, weapon, modifiers, HEAVY_CRUISER_SHELL_DIAMETER_M);
let value = alpha * alpha_coeff * modifiers.coef("controllableWeaponDamageCoeff") * arty_coeff * csap;
if R::ON {
rec.record(stat.damage, Some(qualifier), alpha, arty_src(), value, |b| {
for m in &alpha_applied {
b.coef(sources, m.name);
}
b.coef(sources, "controllableWeaponDamageCoeff");
for m in &arty_applied {
b.coef(sources, m.name);
}
if weapon.is_csap() {
b.coef(sources, "citadelDamageMultiplierCSAP");
}
});
}
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| {
let floored = (p * he_pen_coef).floor();
if R::ON {
let pre_floor = p * he_pen_coef;
let pen_coef_name = if is_atba { "GSPenetrationCoeffHE" } else { "GMPenetrationCoeffHE" };
rec.record(stat.penetration, Some(qualifier), p, arty_src(), pre_floor, |b| {
b.coef(sources, pen_coef_name)
});
}
Millimeters::from(floored)
})
}
StatWeaponType::MainCs | StatWeaponType::AtbaCs => projectile.alpha_piercing_cs().map(|p| {
if R::ON {
rec.record(stat.penetration, Some(qualifier), p, arty_src(), p, |_b| {});
}
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| {
let (pre_clamp, burn_applied) = calculate_burn_chance(level, bp, modifiers, is_small);
let chance_val = pre_clamp.max(0.0) * 100.0;
if R::ON {
let base_pct = bp.max(0.0) * 100.0;
if base_pct > 0.0 {
let pre_clamp_pct = pre_clamp * 100.0;
rec.record(stat.burn_chance, Some(qualifier), base_pct, arty_src(), pre_clamp_pct, |b| {
for m in &burn_applied {
match m.op {
Op::Mul => b.coef(sources, m.name),
Op::Add => b.bonus(sources, m.name, 100.0),
}
}
});
} else {
rec.record(stat.burn_chance, Some(qualifier), 0.0, arty_src(), 0.0, |_b| {});
}
}
Percent::from(chance_val)
})
}
_ => None,
};
let flood_chance = match weapon {
StatWeaponType::MainHe | StatWeaponType::AtbaHe => projectile.uw_critical().map(|f| {
let value = f * 100.0;
if R::ON {
rec.record(stat.flood_chance, Some(qualifier), value, arty_src(), value, |_b| {});
}
Percent::from(value)
}),
_ => None,
};
let max_ammo = Some(AmmoCount::Infinite);
if R::ON {
rec.record(stat.max_ammo, Some(qualifier), -1.0, arty_src(), -1.0, |_b| {});
}
ShellStats {
name,
ammo_kind: Some(ammo_kind.to_string()),
damage,
caliber,
speed,
penetration,
burn_chance,
flood_chance,
max_ammo,
disabled_underwater: None,
}
}
pub(crate) fn artillery_range_km(
arty: &ArtilleryComponentStats,
fc_max_dist_coef: f32,
spotter_dist_coef: f32,
modifiers: &ModifierBundle,
) -> Option<f32> {
arty.max_dist.map(|d| (d.value() / KM_TO_M) * fc_max_dist_coef * modifiers.coef("GMMaxDist") * spotter_dist_coef)
}
#[allow(clippy::too_many_arguments)]
pub fn artillery<R: Recorder>(
arty: &ArtilleryComponentStats,
arty_name: &str,
fc_name: Option<&str>,
modifiers: &ModifierBundle,
sources: &ModifierSources,
fc_max_dist_coef: f32,
spotter_dist_coef: f32,
spotter_dist_coef_source: Option<crate::game_params::ttx::provenance::InputId>,
reload_coeff: f32,
reload_coeff_source: Option<crate::game_params::ttx::provenance::InputId>,
level: u32,
provider: &dyn GameParamProvider,
rec: &mut R,
) -> Option<Artillery> {
if arty.guns.is_empty() {
return None;
}
let arty_src = || InputId::Module { slot: ModuleSlot::Artillery, name: arty_name.to_string() };
let shot_delay_coef = modifiers.coef("GMShotDelay");
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| {
let value = d.value() * shot_delay_coef * reload_coeff;
if R::ON {
rec.record(TtxStat::ArtilleryReloadTime, None, d.value(), arty_src(), value, |b| {
b.coef(sources, "GMShotDelay");
if reload_coeff != 1.0 {
let src = reload_coeff_source.clone().unwrap_or_else(arty_src);
b.module(src, "reloadCoeff", reload_coeff);
}
});
}
Seconds::from(value)
});
let range_km = artillery_range_km(arty, fc_max_dist_coef, spotter_dist_coef, modifiers);
let range = range_km.map(|rng| {
if R::ON
&& let Some(base_km) = arty.max_dist.map(|d| d.value() / KM_TO_M)
{
let fc_src = fc_name
.map(|n| InputId::Module { slot: ModuleSlot::FireControl, name: n.to_string() })
.unwrap_or_else(arty_src);
rec.record(TtxStat::ArtilleryRange, None, base_km, arty_src(), rng, |b| {
b.module(fc_src, "maxDistCoef", fc_max_dist_coef);
b.coef(sources, "GMMaxDist");
if spotter_dist_coef != 1.0 {
let src = spotter_dist_coef_source.clone().unwrap_or_else(arty_src);
b.module(src, "spotterDistCoeff", spotter_dist_coef);
}
});
}
Km::from(rng)
});
let (dispersion, dispersion_vertical) = match (range_km, first.min_radius, first.ideal_radius, first.ideal_distance)
{
(Some(rng), Some(min_r), Some(ideal_r), Some(ideal_d)) => {
let h = constants::dispersion_horizontal(min_r, ideal_r, ideal_d, Km::from(rng), ideal_radius_coef);
let h_m = h.to_meters();
let vertical = match (first.radius_on_zero, first.radius_on_delim, first.radius_on_max, first.delim) {
(Some(z), Some(dl), Some(mx), Some(dm)) => {
let coeff = constants::clamped_dispersion_coeff(z, dl, mx, dm, Km::from(rng), Km::from(rng));
Some(((h * coeff).to_meters(), coeff))
}
_ => None,
};
if R::ON {
let base_h = constants::dispersion_horizontal(min_r, ideal_r, ideal_d, Km::from(rng), 1.0).to_meters();
rec.record(TtxStat::ArtilleryDispersion, None, base_h.value(), arty_src(), h_m.value(), |b| {
b.coef(sources, "GMIdealRadius")
});
if let Some((v, coeff)) = vertical {
rec.record(
TtxStat::ArtilleryDispersionVertical,
None,
base_h.value() * coeff,
arty_src(),
v.value(),
|b| b.coef(sources, "GMIdealRadius"),
);
}
}
(Some(h_m), vertical.map(|(v, _)| v))
}
_ => (None, None),
};
let ammo_switch_time = match (first.shot_delay, first.ammo_switch_coeff) {
(Some(delay), Some(coeff)) => {
let value = delay.value() * coeff * shot_delay_coef * switch_coef;
if R::ON {
rec.record(TtxStat::ArtilleryAmmoSwitchTime, None, delay.value() * coeff, arty_src(), value, |b| {
b.coef(sources, "GMShotDelay");
b.coef(sources, "switchAmmoReloadCoef");
});
}
Some(Seconds::from(value))
}
_ => 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));
if R::ON {
if let Some(speed) = rotation_speed {
let base_speed = first.rotation_speed.map(|r| r.value()).unwrap();
rec.record(TtxStat::GunRotationSpeed, None, base_speed, arty_src(), speed, |b| {
b.coef(sources, "GMRotationSpeed");
b.bonus(sources, "GMRotationSpeedBonus", 1.0);
});
}
if let Some(rt) = rotation_time {
rec.record(TtxStat::GunRotationTime, None, rt.value(), arty_src(), rt.value(), |b| {
b.derived_from(TtxStat::GunRotationSpeed, None);
});
}
}
let caliber = first.barrel_diameter.map(|b| b.to_mm());
let num_barrels = first.num_barrels.map(|n| n as u32);
let num_guns = arty.guns.len() as u32;
if R::ON {
if let Some(c) = caliber {
rec.record(TtxStat::GunCaliber, None, c.value(), arty_src(), c.value(), |_b| {});
}
if let Some(nb) = num_barrels {
rec.record(TtxStat::GunNumBarrels, None, nb as f32, arty_src(), nb as f32, |_b| {});
}
rec.record(TtxStat::GunNumGuns, None, num_guns as f32, arty_src(), num_guns as f32, |_b| {});
}
let gun = Some(MainGun {
caliber,
num_barrels,
num_guns: Some(num_guns),
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,
arty_name,
sources,
level,
false,
projectile.ammo_type(),
rec,
));
} else {
warn_unresolved_ammo(ammo_name);
}
}
Some(Artillery { reload_time, range, dispersion, dispersion_vertical, ammo_switch_time, gun, shells })
}
fn group_secondary_guns(guns: &[ArtilleryGunStats]) -> Vec<(&ArtilleryGunStats, u32)> {
let mut groups: Vec<(&ArtilleryGunStats, u32)> = Vec::new();
for gun in guns {
if let Some(entry) = groups.iter_mut().find(|(rep, _)| *rep == gun) {
entry.1 += 1;
} else {
groups.push((gun, 1));
}
}
groups
}
fn secondary_labels(calibers: &[Millimeters]) -> Vec<String> {
let base: Vec<String> = calibers.iter().map(|c| c.to_string()).collect();
let mut totals: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for b in &base {
*totals.entry(b.as_str()).or_default() += 1;
}
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
base.iter()
.map(|b| {
if totals[b.as_str()] <= 1 {
return b.clone();
}
let n = seen.entry(b.clone()).or_insert(0);
*n += 1;
if *n == 1 { b.clone() } else { format!("{b} ({n})") }
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn secondaries<R: Recorder>(
atba: &SecondaryComponentStats,
hull_name: &str,
modifiers: &ModifierBundle,
sources: &ModifierSources,
reload_coeff: f32,
reload_coeff_source: Option<crate::game_params::ttx::provenance::InputId>,
level: u32,
provider: &dyn GameParamProvider,
rec: &mut R,
) -> Option<crate::game_params::ttx::model::SecondaryBattery> {
use crate::game_params::ttx::model::SecondaryBattery;
use crate::game_params::ttx::model::SecondaryMount;
if atba.guns.is_empty() {
return None;
}
let hull_src = || InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() };
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 base_km = atba.max_dist.map(|d| d.value() / KM_TO_M)?;
let range_km = base_km * max_dist_coef;
if R::ON {
rec.record(TtxStat::SecondaryRange, None, base_km, hull_src(), range_km, |b| {
b.coef(sources, "GSMaxDist");
});
}
let mut kept: Vec<(&ArtilleryGunStats, u32, Millimeters)> = Vec::new();
for (gun, count) in group_secondary_guns(&atba.guns) {
match gun.barrel_diameter {
Some(d) => kept.push((gun, count, d.to_mm())),
None => warn_unresolved_ammo(gun.ammo.first().map(String::as_str).unwrap_or("<secondary mount>")),
}
}
let labels = secondary_labels(&kept.iter().map(|(_, _, caliber)| *caliber).collect::<Vec<_>>());
let mut mounts = Vec::new();
for (idx, (gun, num_mounts, caliber)) in kept.iter().enumerate() {
let caliber = *caliber;
let num_mounts = *num_mounts;
let label = labels[idx].clone();
let q = label.as_str();
let (Some(shot_delay), Some(rot), Some(nb), Some(min_r), Some(ideal_r), Some(ideal_d)) =
(gun.shot_delay, gun.rotation_speed, gun.num_barrels, gun.min_radius, gun.ideal_radius, gun.ideal_distance)
else {
warn_unresolved_ammo(gun.ammo.first().map(String::as_str).unwrap_or("<secondary mount>"));
continue;
};
let reload_value = shot_delay.value() * shot_delay_coef * reload_coeff;
if R::ON {
rec.record(TtxStat::SecondaryReloadTime, Some(q), shot_delay.value(), hull_src(), reload_value, |b| {
b.coef(sources, "GSShotDelay");
if reload_coeff != 1.0 {
let src = reload_coeff_source.clone().unwrap_or_else(hull_src);
b.module(src, "reloadCoeff", reload_coeff);
}
});
}
let rotation_value = rot.value() * yaw_coef + yaw_bonus;
if rotation_value <= 0.0 {
warn_unresolved_ammo(gun.ammo.first().map(String::as_str).unwrap_or("<secondary mount>"));
continue;
}
let rotation_time_value = 180.0 / rotation_value;
if R::ON {
rec.record(TtxStat::SecondaryGunRotationSpeed, Some(q), rot.value(), hull_src(), rotation_value, |b| {
b.coef(sources, "GSRotationSpeed");
b.bonus(sources, "GSRotationSpeedBonus", 1.0);
});
rec.record(
TtxStat::SecondaryGunRotationTime,
Some(q),
rotation_time_value,
hull_src(),
rotation_time_value,
|b| {
b.derived_from(TtxStat::SecondaryGunRotationSpeed, Some(q));
},
);
rec.record(TtxStat::SecondaryGunCaliber, Some(q), caliber.value(), hull_src(), caliber.value(), |_b| {});
rec.record(TtxStat::SecondaryGunNumBarrels, Some(q), nb, hull_src(), nb, |_b| {});
rec.record(
TtxStat::SecondaryGunNumGuns,
Some(q),
num_mounts as f32,
hull_src(),
num_mounts as f32,
|_b| {},
);
}
let h = constants::dispersion_horizontal(min_r, ideal_r, ideal_d, Km::from(range_km), ideal_radius_coef);
let h_m = h.to_meters();
let (Some(z), Some(dl), Some(mx), Some(dm)) =
(gun.radius_on_zero, gun.radius_on_delim, gun.radius_on_max, gun.delim)
else {
warn_unresolved_ammo(gun.ammo.first().map(String::as_str).unwrap_or("<secondary mount>"));
continue;
};
let coeff = constants::clamped_dispersion_coeff(z, dl, mx, dm, Km::from(range_km), Km::from(range_km));
let vertical_m = (h * coeff).to_meters();
if R::ON {
let base_h = constants::dispersion_horizontal(min_r, ideal_r, ideal_d, Km::from(range_km), 1.0).to_meters();
rec.record(TtxStat::SecondaryDispersion, Some(q), base_h.value(), hull_src(), h_m.value(), |b| {
b.coef(sources, "GSIdealRadius");
});
rec.record(
TtxStat::SecondaryDispersionVertical,
Some(q),
base_h.value() * coeff,
hull_src(),
vertical_m.value(),
|b| {
b.coef(sources, "GSIdealRadius");
},
);
}
let Some(ammo_name) = gun.ammo.first() else {
warn_unresolved_ammo("<secondary mount: no ammo>");
continue;
};
let shell = if let Some(param) = provider.game_param_by_name(ammo_name)
&& let Some(projectile) = param.projectile()
{
shell_stats(ammo_name.clone(), projectile, modifiers, hull_name, sources, level, true, q, rec)
} else {
warn_unresolved_ammo(ammo_name);
continue;
};
mounts.push(SecondaryMount {
caliber,
label,
reload_time: Seconds::from(reload_value),
rotation_speed: DegreesPerSecond::from(rotation_value),
rotation_time: Seconds::from(rotation_time_value),
num_barrels: nb as u32,
num_mounts,
dispersion: h_m,
dispersion_vertical: vertical_m,
shell,
});
}
if mounts.is_empty() {
return None;
}
Some(SecondaryBattery { range: Km::from(range_km), mounts })
}
const MINIMAL_VALID_VALUE: f32 = 0.01;
#[allow(clippy::too_many_arguments)]
pub fn visibility<R: Recorder>(
hull: &HullComponentStats,
hull_name: &str,
modifiers: &ModifierBundle,
sources: &ModifierSources,
has_big_gun_artillery: bool,
mg_max_dist_km: Option<f32>,
atba_max_dist_km: Option<f32>,
rec: &mut R,
) -> Visibility {
let hull_src = || InputId::Module { slot: ModuleSlot::Hull, name: hull_name.to_string() };
let mut coeff = modifiers.coef("visibilityDistCoeff");
if has_big_gun_artillery {
coeff *= modifiers.coef("GMBigGunVisibilityCoeff");
}
let sea = hull.visibility_factor.map(|v| v.value() * coeff);
let sea_detection = sea.map(Km::from);
if R::ON
&& let (Some(sea_val), Some(base)) = (sea, hull.visibility_factor)
{
rec.record(TtxStat::SeaDetection, None, base.value(), hull_src(), sea_val, |b| {
b.coef(sources, "visibilityDistCoeff");
if has_big_gun_artillery {
b.coef(sources, "GMBigGunVisibilityCoeff");
}
});
}
let sea_detection_on_fire = match (sea, hull.visibility_coef_fire) {
(Some(s), Some(fire)) => {
let value = s + fire.value();
if R::ON {
rec.record(TtxStat::SeaDetectionOnFire, None, s, hull_src(), value, |b| {
b.derived_from(TtxStat::SeaDetection, None);
b.module_add(hull_src(), "visibilityCoefFire", fire.value());
});
}
Some(Km::from(value))
}
_ => None,
};
let detection_in_smoke = hull.visibility_coef_gk_in_smoke.filter(|&v| v.value() > MINIMAL_VALID_VALUE).map(|v| {
if R::ON {
rec.record(TtxStat::DetectionInSmoke, None, v.value(), hull_src(), v.value(), |_b| {});
}
Km::from(v.value())
});
let main_gun_range_detection = match (sea, mg_max_dist_km) {
(Some(s), Some(mg)) => {
let value = s.max(mg);
if R::ON {
rec.record(TtxStat::MainGunRangeDetection, None, value, hull_src(), value, |b| {
b.derived_from(TtxStat::SeaDetection, None);
b.derived_from(TtxStat::ArtilleryRange, None);
});
}
Some(Km::from(value))
}
_ => None,
};
let secondary_range_detection = match (sea, atba_max_dist_km) {
(Some(s), Some(atba)) => {
let value = s.max(atba);
if R::ON {
rec.record(TtxStat::SecondaryRangeDetection, None, value, hull_src(), value, |b| {
b.derived_from(TtxStat::SeaDetection, None);
b.derived_from(TtxStat::SecondaryRange, None);
});
}
Some(Km::from(value))
}
_ => None,
};
let air = hull.visibility_factor_by_plane.map(|v| v.value() * coeff);
let air_detection = air.map(Km::from);
if R::ON
&& let (Some(air_val), Some(base)) = (air, hull.visibility_factor_by_plane)
{
rec.record(TtxStat::AirDetection, None, base.value(), hull_src(), air_val, |b| {
b.coef(sources, "visibilityDistCoeff");
if has_big_gun_artillery {
b.coef(sources, "GMBigGunVisibilityCoeff");
}
});
}
let air_detection_on_fire = match (air, hull.visibility_coef_fire_by_plane) {
(Some(a), Some(fire)) => {
let value = a + fire.value();
if R::ON {
rec.record(TtxStat::AirDetectionOnFire, None, a, hull_src(), value, |b| {
b.derived_from(TtxStat::AirDetection, None);
b.module_add(hull_src(), "visibilityCoefFireByPlane", fire.value());
});
}
Some(Km::from(value))
}
_ => None,
};
let periscope_depth_detection = hull.visibility_factor_by_periscope.map(|v| {
let value = v.value() * modifiers.coef("visibilityForSubmarineCoeff");
if R::ON {
rec.record(TtxStat::PeriscopeDepthDetection, None, v.value(), hull_src(), value, |b| {
b.coef(sources, "visibilityForSubmarineCoeff");
});
}
Km::from(value)
});
Visibility {
sea_detection,
sea_detection_on_fire,
air_detection,
air_detection_on_fire,
detection_in_smoke,
main_gun_range_detection,
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::ttx::provenance::ModifierSources;
use crate::game_params::ttx::provenance::Off;
use crate::game_params::types::CrewSkillModifier;
use crate::game_params::types::Species;
const VERSION: crate::data::Version = crate::data::Version::base(15, 0, 0);
const GEARING_HULL_HEALTH: f32 = 19400.0;
const GEARING_SPEED_KN: f32 = 36.0;
const GEARING_RUDDER_S: f32 = 4.25;
const GEARING_TURNING_M: f32 = 640.0;
const BALAO_BATTERY_CAPACITY: f32 = 240.0;
const BALAO_BATTERY_REGEN: f32 = 1.2;
const HEALTH_PER_LEVEL: f32 = 350.0;
const HEALTH_COEFF: f32 = 1.05;
const HEALTH_AFTER_MOD: f32 = 24050.0;
const TORP_ALPHA: f32 = 53500.0;
const TORP_FLOOD: f32 = 1200.0;
const TORP_SPEED: f32 = 66.0;
const TORP_MAX_DIST_BW: f32 = 350.0;
const TORP_VISIBILITY: f32 = 1.4;
const TORP_RANGE_KM: f32 = 10.5;
const LAUNCHER_SHOT_DELAY: f32 = 103.0;
const LAUNCHER_ROT_SPEED: f32 = 25.0;
const LAUNCHER_BARRELS: f32 = 5.0;
const SPEED_BONUS: f32 = 5.0;
const SPEED_AFTER_BONUS: f32 = 71.0;
const DAMAGE_COEFF: f32 = 1.2;
const TORP_ROT_COEFF: f32 = 1.2;
const TORP_ROT_AFTER_COEFF: f32 = 30.0;
const TORP_ROT_BONUS: f32 = 5.0;
const ROT_TIME: f32 = 6.0;
const SPEED_COEFF: f32 = 1.05;
const SPEED_AFTER_COEFF: f32 = 37.8;
const SPEED_MULTIPLIER: f32 = 1.05;
const NORMAL_SPEED_MULTIPLIER: f32 = 1.1;
const SPEED_BONUS_PROV: f32 = 2.0;
const RANGE_COEFF_PROV: f32 = 1.15;
fn gearing_hull() -> HullComponentStats {
HullComponentStats {
health: Some(Hp::from(GEARING_HULL_HEALTH)),
max_speed: Some(Knots::from(GEARING_SPEED_KN)),
speed_coef: Some(1.0),
turning_radius: Some(Meters::from(GEARING_TURNING_M)),
rudder_time: Some(Seconds::from(GEARING_RUDDER_S)),
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(BALAO_BATTERY_CAPACITY),
battery_regen_rate: Some(BALAO_BATTERY_REGEN),
..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(),
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&mut Off,
);
assert_eq!(durability.health, Some(Hp::from(GEARING_HULL_HEALTH)));
}
#[test]
fn gearing_stock_durability_ptz_zero() {
let durability = durability(
&gearing_hull(),
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&mut Off,
);
assert_eq!(durability.torpedo_protection, Some(Percent::from(0.0)));
}
#[test]
fn yamato_stock_durability_ptz() {
let durability = durability(
&yamato_hull(),
"H",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
10,
&mut Off,
);
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(), "H", &bundle, &ModifierSources::default(), 10, &mut Off);
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,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&mut Off,
);
assert!(durability.torpedo_protection.is_none());
}
#[test]
fn gearing_stock_mobility() {
let mobility = mobility(
&gearing_hull(),
"H",
&gearing_engine(),
Some("E"),
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
&mut Off,
);
assert_eq!(mobility.speed, Some(Knots::from(GEARING_SPEED_KN)));
assert_eq!(mobility.turning_radius, Some(Meters::from(GEARING_TURNING_M)));
assert_eq!(mobility.rudder_time, Some(Seconds::from(GEARING_RUDDER_S)));
}
#[test]
fn speed_coef_modifier_applies() {
let mods = [modifier("speedCoef", SPEED_COEFF)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let mobility = mobility(
&gearing_hull(),
"H",
&gearing_engine(),
Some("E"),
&bundle,
&ModifierSources::default(),
&mut Off,
);
let speed = mobility.speed.expect("speed computed").value();
assert!((speed - SPEED_AFTER_COEFF).abs() < 1e-4, "got {speed}");
}
#[test]
fn health_modifier_applies() {
let mods = [modifier("healthPerLevel", HEALTH_PER_LEVEL), modifier("healthHullCoeff", HEALTH_COEFF)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let durability = durability(&gearing_hull(), "H", &bundle, &ModifierSources::default(), 10, &mut Off);
assert_eq!(durability.health, Some(Hp::from(HEALTH_AFTER_MOD)));
}
#[test]
fn absent_inputs_are_none() {
let empty_hull = HullComponentStats::default();
let durability = durability(
&empty_hull,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&mut Off,
);
assert!(durability.health.is_none());
let mobility = mobility(
&empty_hull,
"H",
&EngineComponentStats::default(),
Some("E"),
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
&mut Off,
);
assert!(mobility.speed.is_none());
assert!(mobility.turning_radius.is_none());
assert!(mobility.rudder_time.is_none());
assert!(
battery(
&empty_hull,
"H",
&ModifierBundle::empty(Species::Submarine),
&ModifierSources::default(),
&mut Off
)
.is_none()
);
}
#[test]
fn balao_stock_battery() {
let battery = battery(
&balao_hull(),
"H",
&ModifierBundle::empty(Species::Submarine),
&ModifierSources::default(),
&mut Off,
)
.expect("battery computed");
assert_eq!(battery.capacity, Some(BALAO_BATTERY_CAPACITY));
assert_eq!(battery.regeneration, Some(BALAO_BATTERY_REGEN));
}
#[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(), "H", &bundle, &ModifierSources::default(), &mut Off).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(),
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
&mut Off
)
.is_none()
);
}
#[test]
fn durability_records_health_provenance() {
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::ModifierSources;
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::Recorder;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("healthPerLevel", HEALTH_PER_LEVEL), modifier("healthHullCoeff", HEALTH_COEFF)];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).unwrap();
let mut sources = ModifierSources::default();
sources.record("healthPerLevel", InputId::Upgrade { name: "U".into() }, HEALTH_PER_LEVEL);
sources.record("healthHullCoeff", InputId::Skill { name: "S".into() }, HEALTH_COEFF);
let mut rec = On::default();
let _ = durability(&gearing_hull(), "HULL", &bundle, &sources, 10, &mut rec);
let prov = rec.into_provenance();
let health = prov.attributions.iter().find(|a| a.stat == TtxStat::Health).unwrap();
assert_eq!(health.base_value, GEARING_HULL_HEALTH);
assert!(
matches!(&health.base_source, InputId::Module { slot, .. } if *slot == crate::game_params::ttx::module_options::ModuleSlot::Hull)
);
let expected = (GEARING_HULL_HEALTH + HEALTH_PER_LEVEL * 10.0) * HEALTH_COEFF;
assert!((ShipStatsProvenance::replay(health) - expected).abs() < 1e-1);
assert_eq!(health.steps.iter().filter(|c| c.op == Op::Add).count(), 1);
assert_eq!(health.steps.iter().filter(|c| c.op == Op::Mul).count(), 1);
}
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, "HULL", arti.iter(), &mut Off).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, "HULL", std::iter::empty(), &mut Off).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, "HULL", std::iter::empty(), &mut Off).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, "HULL", std::iter::empty(), &mut Off).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(TORP_MAX_DIST_BW))
.speed(TORP_SPEED)
.alpha_damage(TORP_ALPHA)
.damage(TORP_FLOOD)
.visibility_factor(TORP_VISIBILITY)
.torpedo_type(0)
.build()
}
fn gearing_launcher() -> TorpedoLauncherStats {
TorpedoLauncherStats {
shot_delay: Some(Seconds::from(LAUNCHER_SHOT_DELAY)),
rotation_speed: Some(DegreesPerSecond::from(LAUNCHER_ROT_SPEED)),
num_barrels: Some(LAUNCHER_BARRELS),
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),
"",
&ModifierSources::default(),
&mut Off,
);
let damage = stats.damage.expect("damage").value();
assert!(approx(damage, TORP_ALPHA / 3.0 + TORP_FLOOD), "got {damage}");
assert_eq!(stats.speed, Some(Knots::from(TORP_SPEED)));
let range = stats.range.expect("range").value();
assert!(approx(range, TORP_RANGE_KM), "got {range}");
assert_eq!(stats.visibility, Some(Km::from(TORP_VISIBILITY)));
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),
1.0,
None,
&provider,
"",
&ModifierSources::default(),
&mut Off,
)
.expect("torpedoes computed");
assert_eq!(torps.reload_time, Some(Seconds::from(LAUNCHER_SHOT_DELAY)));
assert_eq!(torps.launchers.len(), 1);
let launcher = &torps.launchers[0];
assert_eq!(launcher.rotation_speed, Some(DegreesPerSecond::from(LAUNCHER_ROT_SPEED)));
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, 7.2), "got {rt}");
assert_eq!(launcher.num_barrels, Some(LAUNCHER_BARRELS as u32));
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(), TORP_ALPHA / 3.0 + TORP_FLOOD));
assert_eq!(torp.speed, Some(Knots::from(TORP_SPEED)));
assert!(approx(torp.range.expect("range").value(), TORP_RANGE_KM));
assert_eq!(torp.visibility, Some(Km::from(TORP_VISIBILITY)));
}
#[test]
fn torpedo_speed_bonus_applies() {
let mods = [modifier("torpedoSpeedBonus", SPEED_BONUS)];
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,
"",
&ModifierSources::default(),
&mut Off,
);
let speed = stats.speed.expect("speed").value();
assert!(approx(speed, SPEED_AFTER_BONUS), "got {speed}");
}
#[test]
fn torpedo_damage_coeff_applies() {
let mods = [modifier("torpedoDamageCoeff", DAMAGE_COEFF)];
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,
"",
&ModifierSources::default(),
&mut Off,
);
let damage = stats.damage.expect("damage").value();
assert!(approx(damage, (TORP_ALPHA / 3.0 + TORP_FLOOD) * DAMAGE_COEFF), "got {damage}");
}
#[test]
fn torpedo_launcher_traverse_coef_applies() {
let mods = [modifier("GTRotationSpeed", TORP_ROT_COEFF)];
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, 1.0, None, &provider, "", &ModifierSources::default(), &mut Off)
.expect("torpedoes computed");
let launcher = &torps.launchers[0];
let rs = launcher.rotation_speed.expect("rotation_speed").value();
assert!(approx(rs, TORP_ROT_AFTER_COEFF), "got {rs}");
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, ROT_TIME), "got {rt}");
}
#[test]
fn torpedo_launcher_traverse_bonus_applies() {
let mods = [modifier("GTRotationSpeedBonus", TORP_ROT_BONUS)];
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, 1.0, None, &provider, "", &ModifierSources::default(), &mut Off)
.expect("torpedoes computed");
let launcher = &torps.launchers[0];
assert_eq!(launcher.rotation_speed, Some(DegreesPerSecond::from(TORP_ROT_AFTER_COEFF)));
let rt = launcher.rotation_time.expect("rotation_time").value();
assert!(approx(rt, ROT_TIME), "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),
1.0,
None,
&provider,
"",
&ModifierSources::default(),
&mut Off
)
.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),
"",
&ModifierSources::default(),
&mut Off,
);
assert!(stats.damage.is_none());
assert!(stats.speed.is_none());
assert!(stats.range.is_none());
assert!(stats.visibility.is_none());
}
#[test]
fn torpedo_speed_and_range_records_exact_replay() {
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [
modifier("torpedoSpeedMultiplier", SPEED_MULTIPLIER),
modifier("normalTorpedoSpeedMultiplier", NORMAL_SPEED_MULTIPLIER),
modifier("torpedoSpeedBonus", SPEED_BONUS_PROV),
modifier("torpedoRangeCoefficient", RANGE_COEFF_PROV),
];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("torpedoSpeedMultiplier", InputId::Upgrade { name: "U1".into() }, SPEED_MULTIPLIER);
sources.record("normalTorpedoSpeedMultiplier", InputId::Skill { name: "S1".into() }, NORMAL_SPEED_MULTIPLIER);
sources.record("torpedoSpeedBonus", InputId::Skill { name: "S2".into() }, SPEED_BONUS_PROV);
sources.record("torpedoRangeCoefficient", InputId::Upgrade { name: "U2".into() }, RANGE_COEFF_PROV);
let provider = StubProvider::new("PAPT027_Mk_16_mod_1", gearing_torpedo());
let launchers = [gearing_launcher()];
let mut rec = On::default();
let torps = torpedoes(&launchers, &bundle, 1.0, None, &provider, "PAUT902_D10_NEW_STOCK", &sources, &mut rec)
.expect("torpedoes computed");
let prov = rec.into_provenance();
let speed_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::TorpedoSpeed).expect("TorpedoSpeed recorded");
assert_eq!(speed_attr.qualifier.as_deref(), Some("PAPT027_Mk_16_mod_1"), "qualifier must be the torpedo name");
assert!(
matches!(&speed_attr.base_source, InputId::Module { slot, .. } if *slot == ModuleSlot::Torpedoes),
"base source must be the Torpedoes module"
);
assert_eq!(speed_attr.base_value, TORP_SPEED, "base must be raw speed");
let mul_steps: Vec<_> = speed_attr.steps.iter().filter(|c| c.op == Op::Mul).collect();
let add_steps: Vec<_> = speed_attr.steps.iter().filter(|c| c.op == Op::Add).collect();
assert_eq!(mul_steps.len(), 2, "two Mul steps: torpedoSpeedMultiplier and normalTorpedoSpeedMultiplier");
assert_eq!(add_steps.len(), 1, "one Add step: torpedoSpeedBonus");
let tsmul = mul_steps.iter().find(|c| c.modifier_name == "torpedoSpeedMultiplier");
assert!(tsmul.is_some(), "torpedoSpeedMultiplier step present");
assert!((tsmul.unwrap().operand - SPEED_MULTIPLIER).abs() < 1e-6);
let ntsmul = mul_steps.iter().find(|c| c.modifier_name == "normalTorpedoSpeedMultiplier");
assert!(ntsmul.is_some(), "normalTorpedoSpeedMultiplier step present");
assert!((ntsmul.unwrap().operand - NORMAL_SPEED_MULTIPLIER).abs() < 1e-6);
let expected_speed = torps.torpedoes[0].speed.expect("speed").value();
let replayed_speed = ShipStatsProvenance::replay(speed_attr);
assert!(
(replayed_speed - expected_speed).abs() < 1e-3,
"TorpedoSpeed replay got {replayed_speed}, factory={expected_speed}"
);
let range_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::TorpedoRange).expect("TorpedoRange recorded");
assert_eq!(range_attr.qualifier.as_deref(), Some("PAPT027_Mk_16_mod_1"), "qualifier must be the torpedo name");
assert!(
(range_attr.base_value - TORP_RANGE_KM).abs() < 1e-3,
"range base must be maxDist*BW_TO_BALLISTIC/KM_TO_M"
);
let rc_step = range_attr.steps.iter().find(|c| c.modifier_name == "torpedoRangeCoefficient");
assert!(rc_step.is_some(), "torpedoRangeCoefficient step present");
assert!((rc_step.unwrap().operand - RANGE_COEFF_PROV).abs() < 1e-6);
let expected_range = torps.torpedoes[0].range.expect("range").value();
let replayed_range = ShipStatsProvenance::replay(range_attr);
assert!(
(replayed_range - expected_range).abs() < 1e-3,
"TorpedoRange replay got {replayed_range}, factory={expected_range}"
);
}
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),
radius_on_zero: None,
radius_on_delim: None,
radius_on_max: None,
delim: None,
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(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.expect("artillery computed");
let expected = constants::dispersion_horizontal(1.1, 8.0, 1000.0, Km::from(15.32), 1.0).to_meters().value();
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}");
}
fn worcester_artillery_with_curve() -> 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),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.5),
radius_on_max: Some(2.0),
delim: Some(0.5),
ammo: vec!["PAPA051_152mm_HE_HC_Mark_39_Mod_0".to_string()],
};
ArtilleryComponentStats {
max_dist: Some(Meters::from(15320.0)),
guns: vec![gun(), gun(), gun(), gun(), gun(), gun()],
}
}
#[test]
fn artillery_vertical_dispersion_some_when_curve_fields_set() {
let provider = worcester_provider();
let arty = artillery(
&worcester_artillery_with_curve(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.expect("artillery computed");
let h = arty.dispersion.expect("dispersion").value();
let v = arty.dispersion_vertical.expect("dispersion_vertical").value();
assert!((v - h * 2.0).abs() < 1e-2, "got v={v} h={h}");
}
#[test]
fn artillery_vertical_dispersion_none_when_curve_fields_absent() {
let provider = worcester_provider();
let arty = artillery(
&worcester_artillery(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.expect("artillery computed");
assert!(arty.dispersion.is_some(), "horizontal dispersion must be present");
assert!(arty.dispersion_vertical.is_none(), "vertical must be None when curve fields absent");
}
#[test]
fn worcester_stock_he_shell() {
let provider = worcester_provider();
let arty = artillery(
&worcester_artillery(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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(MetersPerSecond::from(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(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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(MetersPerSecond::from(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(),
"",
None,
&bundle,
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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(),
"",
None,
&bundle,
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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,
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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(),
"",
None,
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut Off,
)
.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),
"",
&ModifierSources::default(),
10,
false,
"HE",
&mut Off,
);
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),
"",
&ModifierSources::default(),
10,
false,
"HE",
&mut Off,
);
assert_eq!(stats.speed, Some(MetersPerSecond::from(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),
"",
&ModifierSources::default(),
10,
false,
"HE",
&mut Off,
);
assert_eq!(plain_stats.speed, Some(MetersPerSecond::from(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),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.5),
radius_on_max: Some(2.0),
delim: Some(0.5),
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),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.5),
radius_on_max: Some(2.0),
delim: Some(0.5),
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 battery = secondaries(
&bismarck_secondaries(),
"HULL",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut Off,
)
.expect("secondaries computed");
assert!(approx(battery.range.value(), 7.6), "got {}", battery.range.value());
assert_eq!(battery.mounts.len(), 2);
let m150 = &battery.mounts[0];
assert_eq!(m150.caliber, Millimeters::from(150.0));
assert_eq!(m150.num_mounts, 2);
assert_eq!(m150.num_barrels, 2);
assert!(approx(m150.reload_time.value(), 7.5), "got {}", m150.reload_time.value());
assert_eq!(m150.rotation_speed, DegreesPerSecond::from(60.0));
let rt = m150.rotation_time.value();
assert!(approx(rt, 3.0), "got {rt}");
}
#[test]
fn bismarck_stock_secondaries_shells() {
let provider = bismarck_secondary_provider();
let battery = secondaries(
&bismarck_secondaries(),
"HULL",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut Off,
)
.expect("secondaries computed");
assert_eq!(battery.mounts.len(), 2);
let m150 = battery.mounts.iter().find(|m| m.label == "150 mm").expect("150mm mount");
let s150 = &m150.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(MetersPerSecond::from(875.0)));
assert_eq!(s150.flood_chance, Some(Percent::from(0.0)));
let m105 = battery.mounts.iter().find(|m| m.label == "105 mm").expect("105mm mount");
let s105 = &m105.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 battery = secondaries(
&bismarck_secondaries(),
"HULL",
&bundle,
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut Off,
)
.expect("secondaries computed");
let range = battery.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 battery = secondaries(
&bismarck_secondaries(),
"HULL",
&bundle,
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut Off,
)
.expect("secondaries computed");
let reload = battery.mounts[0].reload_time.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,
"HULL",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut Off
)
.is_none()
);
}
#[test]
fn secondary_shell_provenance_uses_secondary_variants() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let provider = bismarck_secondary_provider();
let mut rec = On::default();
let battery = secondaries(
&bismarck_secondaries(),
"HULL_B",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
1.0,
None,
8,
&provider,
&mut rec,
)
.expect("secondaries computed");
let prov = rec.into_provenance();
let shell_main_variants = [
TtxStat::ShellDamage,
TtxStat::ShellCaliber,
TtxStat::ShellSpeed,
TtxStat::ShellPenetration,
TtxStat::ShellBurnChance,
TtxStat::ShellFloodChance,
TtxStat::ShellMaxAmmo,
];
for attr in &prov.attributions {
assert!(
!shell_main_variants.contains(&attr.stat),
"secondary path must not record main-battery Shell* variant {:?}",
attr.stat
);
}
assert!(
prov.attributions.iter().any(|a| a.stat == TtxStat::SecondaryShellDamage),
"expected SecondaryShellDamage in provenance"
);
let damage_150 = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::SecondaryShellDamage && a.qualifier.as_deref() == Some("150 mm"))
.expect("SecondaryShellDamage for 150 mm mount recorded");
let factory_damage = battery
.mounts
.iter()
.find(|m| m.label == "150 mm")
.expect("150mm mount")
.shell
.damage
.expect("damage")
.value();
let replayed = ShipStatsProvenance::replay(damage_150);
assert!(
(replayed - factory_damage).abs() < 1e-2,
"SecondaryShellDamage replay got {replayed}, factory={factory_damage}"
);
}
#[test]
fn gearing_stock_visibility() {
let vis = visibility(
&gearing_hull(),
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
false,
None,
None,
&mut Off,
);
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.main_gun_range_detection.is_none());
assert!(vis.secondary_range_detection.is_none());
assert!(vis.periscope_depth_detection.is_none());
}
#[test]
fn concealment_modifier_reduces_sea_detection() {
let mods = [modifier("visibilityDistCoeff", 0.9)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Destroyer, VERSION).expect("test modifiers are all known");
let vis = visibility(&gearing_hull(), "H", &bundle, &ModifierSources::default(), false, None, None, &mut Off);
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(), "H", &bundle, &ModifierSources::default(), false, None, None, &mut Off);
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(), "H", &bundle, &ModifierSources::default(), true, None, None, &mut Off);
let sea = with_guns.sea_detection.expect("sea").value();
assert!(approx(sea, 7.6965), "got {sea}");
let without =
visibility(&gearing_hull(), "H", &bundle, &ModifierSources::default(), false, None, None, &mut Off);
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,
"H",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
false,
None,
Some(7.6),
&mut Off,
);
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,
"H",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
false,
None,
Some(7.6),
&mut Off,
);
assert_eq!(vis_far.secondary_range_detection, Some(Km::from(9.0)));
}
#[test]
fn main_gun_range_detection_floors_at_main_gun_range() {
let hull = HullComponentStats { visibility_factor: Some(Km::from(6.0)), ..gearing_hull() };
let vis = visibility(
&hull,
"H",
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
false,
Some(20.0),
None,
&mut Off,
);
assert_eq!(vis.main_gun_range_detection, Some(Km::from(20.0)));
assert!(vis.secondary_range_detection.is_none());
}
#[test]
fn main_gun_present_without_secondaries_yields_no_secondary_detection() {
let hull = HullComponentStats { visibility_factor: Some(Km::from(6.0)), ..gearing_hull() };
let vis = visibility(
&hull,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
false,
Some(11.0),
None,
&mut Off,
);
assert_eq!(vis.main_gun_range_detection, Some(Km::from(11.0)));
assert!(vis.secondary_range_detection.is_none());
}
#[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,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
false,
None,
None,
&mut Off,
);
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,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
false,
None,
None,
&mut Off,
);
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,
"H",
&ModifierBundle::empty(Species::Submarine),
&ModifierSources::default(),
false,
None,
None,
&mut Off,
);
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,
"H",
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
false,
None,
None,
&mut Off,
);
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.main_gun_range_detection.is_none());
assert!(vis.secondary_range_detection.is_none());
assert!(vis.periscope_depth_detection.is_none());
}
#[test]
fn visibility_records_sea_detection_and_on_fire_replay_exact() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("visibilityDistCoeff", 0.97), modifier("GMBigGunVisibilityCoeff", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("visibilityDistCoeff", InputId::Upgrade { name: "Camo".into() }, 0.97);
sources.record(
"GMBigGunVisibilityCoeff",
InputId::Skill { name: crate::game_params::types::CrewSkillName::from("BBSkill") },
1.05,
);
let mut rec = On::default();
let vis = visibility(&gearing_hull(), "HULL", &bundle, &sources, true, None, None, &mut rec);
let prov = rec.into_provenance();
let sea_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::SeaDetection).expect("SeaDetection recorded");
assert_eq!(sea_attr.base_value, 7.33);
assert!(
matches!(&sea_attr.base_source, InputId::Module { slot, .. } if *slot == ModuleSlot::Hull),
"base source must be Hull"
);
assert_eq!(sea_attr.steps.len(), 2, "expected visibilityDistCoeff + GMBigGunVisibilityCoeff");
assert!(sea_attr.steps.iter().all(|c| c.op == Op::Mul), "all sea steps must be Mul");
assert!(sea_attr.steps.iter().any(|c| c.modifier_name == "visibilityDistCoeff"));
assert!(sea_attr.steps.iter().any(|c| c.modifier_name == "GMBigGunVisibilityCoeff"));
let expected_sea = vis.sea_detection.expect("sea").value();
let replayed_sea = ShipStatsProvenance::replay(sea_attr);
assert!(
(replayed_sea - expected_sea).abs() < 1e-4,
"SeaDetection replay got {replayed_sea}, expected {expected_sea}"
);
let on_fire_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::SeaDetectionOnFire)
.expect("SeaDetectionOnFire recorded");
assert_eq!(on_fire_attr.steps.len(), 1, "SeaDetectionOnFire has one Add step");
assert_eq!(on_fire_attr.steps[0].op, Op::Add);
assert!((on_fire_attr.steps[0].operand - 2.0).abs() < 1e-6, "fire penalty is 2.0");
let expected_on_fire = vis.sea_detection_on_fire.expect("sea on fire").value();
let replayed_on_fire = ShipStatsProvenance::replay(on_fire_attr);
assert!(
(replayed_on_fire - expected_on_fire).abs() < 1e-4,
"SeaDetectionOnFire replay got {replayed_on_fire}, expected {expected_on_fire}"
);
}
#[test]
fn artillery_reload_records_gmshot_delay_and_replay_exact() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("GMShotDelay", 0.9)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("GMShotDelay", InputId::Upgrade { name: "U".into() }, 0.9);
let provider = worcester_provider();
let mut rec = On::default();
let arty = artillery(
&worcester_artillery(),
"ARTY",
None,
&bundle,
&sources,
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut rec,
)
.expect("artillery computed");
let prov = rec.into_provenance();
let reload_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ArtilleryReloadTime)
.expect("ArtilleryReloadTime recorded");
assert_eq!(reload_attr.base_value, 4.6);
assert!(
matches!(&reload_attr.base_source, InputId::Module { slot, .. } if *slot == ModuleSlot::Artillery),
"base source must be the artillery module"
);
let gm_step = reload_attr.steps.iter().find(|c| c.modifier_name == "GMShotDelay");
assert!(gm_step.is_some(), "GMShotDelay step must be recorded");
assert_eq!(gm_step.unwrap().op, Op::Mul);
assert!((gm_step.unwrap().operand - 0.9).abs() < 1e-6);
let expected_reload = arty.reload_time.expect("reload").value();
let replayed = ShipStatsProvenance::replay(reload_attr);
assert!((replayed - expected_reload).abs() < 1e-4, "replay got {replayed}, expected {expected_reload}");
}
#[test]
fn artillery_range_records_fc_coef_step_and_replay_exact() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let provider = worcester_provider();
let mut rec = On::default();
let fc_coef = 1.2f32;
let arty = artillery(
&worcester_artillery(),
"ARTY",
Some("FC"),
&ModifierBundle::empty(Species::Cruiser),
&ModifierSources::default(),
fc_coef,
1.0,
None,
1.0,
None,
10,
&provider,
&mut rec,
)
.expect("artillery computed");
let prov = rec.into_provenance();
let range_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::ArtilleryRange).expect("ArtilleryRange recorded");
let fc_step = range_attr.steps.iter().find(|c| c.modifier_name == "maxDistCoef");
assert!(fc_step.is_some(), "maxDistCoef step must be recorded when fc_max_dist_coef != 1.0");
assert!(
matches!(&fc_step.unwrap().input, InputId::Module { slot, .. } if *slot == ModuleSlot::FireControl),
"maxDistCoef step must be attributed to FireControl module"
);
assert!((fc_step.unwrap().operand - 1.2).abs() < 1e-6);
let expected_range = arty.range.expect("range").value();
let replayed = ShipStatsProvenance::replay(range_attr);
assert!((replayed - expected_range).abs() < 1e-3, "replay got {replayed}, expected {expected_range}");
}
#[test]
fn artillery_vertical_dispersion_records_exact_replay() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("GMIdealRadius", 0.95)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("GMIdealRadius", InputId::Upgrade { name: "U".into() }, 0.95);
let provider = worcester_provider();
let mut rec = On::default();
let arty = artillery(
&worcester_artillery_with_curve(),
"ARTY",
None,
&bundle,
&sources,
1.0,
1.0,
None,
1.0,
None,
10,
&provider,
&mut rec,
)
.expect("artillery computed");
let prov = rec.into_provenance();
let v_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ArtilleryDispersionVertical)
.expect("ArtilleryDispersionVertical recorded");
let expected_v = arty.dispersion_vertical.expect("dispersion_vertical").value();
let replayed = ShipStatsProvenance::replay(v_attr);
let rel_err = (replayed - expected_v).abs() / expected_v;
assert!(
rel_err < 1e-4,
"vertical dispersion replay not exact: replay={replayed}, factory={expected_v}, rel_err={rel_err}"
);
}
fn bismarck_secondaries_with_curve() -> 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),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.5),
radius_on_max: Some(2.0),
delim: Some(0.5),
ammo: vec!["PGPA003_150mm_HE_HE_N_F".to_string()],
};
SecondaryComponentStats { max_dist: Some(Meters::from(7600.0)), guns: vec![gun_150] }
}
#[test]
fn secondary_range_records_gs_max_dist_and_replay_exact() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("GSMaxDist", 1.2)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("GSMaxDist", InputId::Upgrade { name: "U".into() }, 1.2);
let provider = bismarck_secondary_provider();
let mut rec = On::default();
let sec = secondaries(&bismarck_secondaries(), "HULL_B", &bundle, &sources, 1.0, None, 8, &provider, &mut rec)
.expect("secondaries computed");
let prov = rec.into_provenance();
let range_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::SecondaryRange).expect("SecondaryRange recorded");
assert!(
matches!(&range_attr.base_source, InputId::Module { slot, .. } if *slot == ModuleSlot::Hull),
"SecondaryRange base source must be the Hull module"
);
let gs_step = range_attr.steps.iter().find(|c| c.modifier_name == "GSMaxDist");
assert!(gs_step.is_some(), "GSMaxDist step must be recorded");
assert!((gs_step.unwrap().operand - 1.2).abs() < 1e-6);
let expected_range = sec.range.value();
let replayed = ShipStatsProvenance::replay(range_attr);
assert!((replayed - expected_range).abs() < 1e-4, "replay got {replayed}, expected {expected_range}");
}
#[test]
fn secondary_vertical_dispersion_records_exact_replay() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("GSIdealRadius", 0.95)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Battleship, VERSION).expect("test modifiers are all known");
let mut sources = ModifierSources::default();
sources.record("GSIdealRadius", InputId::Upgrade { name: "U".into() }, 0.95);
let provider = bismarck_secondary_provider();
let mut rec = On::default();
let sec = secondaries(
&bismarck_secondaries_with_curve(),
"HULL_B",
&bundle,
&sources,
1.0,
None,
8,
&provider,
&mut rec,
)
.expect("secondaries computed");
let prov = rec.into_provenance();
let v_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::SecondaryDispersionVertical)
.expect("SecondaryDispersionVertical recorded");
let expected_v = sec.mounts[0].dispersion_vertical.value();
let replayed = ShipStatsProvenance::replay(v_attr);
let rel_err = (replayed - expected_v).abs() / expected_v;
assert!(
rel_err < 1e-4,
"vertical dispersion replay not exact: replay={replayed}, factory={expected_v}, rel_err={rel_err}"
);
let range_km =
atba_range_km_for_test(&bismarck_secondaries_with_curve(), &ModifierBundle::empty(Species::Battleship));
let base_h_only =
constants::dispersion_horizontal(1.0, 15.5, 333.333, Km::from(range_km), 1.0).to_meters().value();
let wrong_replay = base_h_only * 0.95;
assert!(
(wrong_replay - expected_v).abs() > 1e-3,
"a base_h-only replay should NOT equal the vertical value (coeff != 1.0 case)"
);
}
fn atba_range_km_for_test(atba: &SecondaryComponentStats, modifiers: &ModifierBundle) -> f32 {
atba.max_dist.map(|d| (d.value() / KM_TO_M) * modifiers.coef("GSMaxDist")).unwrap_or(0.0)
}
#[test]
fn group_secondary_guns_groups_by_identity_and_counts() {
use crate::game_params::ttx::components::ArtilleryGunStats;
let g150 = || ArtilleryGunStats {
barrel_diameter: Some(Meters::from(0.15)),
shot_delay: Some(Seconds::from(6.0)),
ammo: vec!["A150".into()],
..Default::default()
};
let g105 = || ArtilleryGunStats {
barrel_diameter: Some(Meters::from(0.105)),
shot_delay: Some(Seconds::from(4.0)),
ammo: vec!["A105".into()],
..Default::default()
};
let guns = vec![g150(), g105(), g150(), g105(), g105()];
let groups = group_secondary_guns(&guns);
assert_eq!(groups.len(), 2);
assert_eq!(groups[0].1, 2); assert_eq!(groups[1].1, 3); }
#[test]
fn secondary_labels_disambiguate_only_on_collision() {
let a = secondary_labels(&[Millimeters::from(150.0), Millimeters::from(105.0)]);
assert_eq!(a, vec!["150 mm", "105 mm"]);
let b = secondary_labels(&[Millimeters::from(150.0), Millimeters::from(150.0)]);
assert_eq!(b, vec!["150 mm", "150 mm (2)"]);
}
#[test]
fn shell_burn_chance_attributes_to_real_skill_input() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("burnChanceMultiplier", 1.17)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let skill_id = InputId::Skill { name: crate::game_params::types::CrewSkillName::from("DemoExpert") };
let mut sources = ModifierSources::default();
sources.record("burnChanceMultiplier", skill_id.clone(), 1.17);
let mut rec = On::default();
let stats =
shell_stats("PAPA051".to_string(), &worcester_he(), &bundle, "ARTY", &sources, 10, false, "HE", &mut rec);
let prov = rec.into_provenance();
let bc_attr =
prov.attributions.iter().find(|a| a.stat == TtxStat::ShellBurnChance).expect("ShellBurnChance recorded");
let mult_step = bc_attr
.steps
.iter()
.find(|c| c.modifier_name == "burnChanceMultiplier")
.expect("burnChanceMultiplier step present");
assert_eq!(mult_step.op, Op::Mul);
assert_eq!(&mult_step.input, &skill_id, "burnChanceMultiplier must be attributed to the skill");
let replayed = ShipStatsProvenance::replay(bc_attr);
assert!(
(replayed - bc_attr.value).abs() < 1e-4,
"burn chance replay got {replayed}, recorded value={}",
bc_attr.value
);
let model_pct = stats.burn_chance.expect("burn_chance").value();
assert!((model_pct - replayed.max(0.0)).abs() < 1e-4, "model={model_pct} replay_clamped={}", replayed.max(0.0));
}
#[test]
fn shell_damage_ap_attributes_gmap_to_real_input() {
use crate::game_params::ttx::provenance::On;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let mods = [modifier("GMAPDamageCoeff", 1.05)];
let bundle =
ModifierBundle::from_modifiers(&mods, Species::Cruiser, VERSION).expect("test modifiers are all known");
let upgrade_id = InputId::Upgrade { name: "APDamageUpgrade".into() };
let mut sources = ModifierSources::default();
sources.record("GMAPDamageCoeff", upgrade_id.clone(), 1.05);
let mut rec = On::default();
let stats =
shell_stats("PAPA050".to_string(), &worcester_ap(), &bundle, "ARTY", &sources, 10, false, "AP", &mut rec);
let prov = rec.into_provenance();
let dmg_attr = prov.attributions.iter().find(|a| a.stat == TtxStat::ShellDamage).expect("ShellDamage recorded");
let gmap_step =
dmg_attr.steps.iter().find(|c| c.modifier_name == "GMAPDamageCoeff").expect("GMAPDamageCoeff step present");
assert_eq!(gmap_step.op, Op::Mul);
assert_eq!(&gmap_step.input, &upgrade_id, "GMAPDamageCoeff must be attributed to the upgrade");
assert!(
!dmg_attr.steps.iter().any(|c| c.modifier_name == "damageCoeff"),
"damageCoeff net step must not appear after decomposition"
);
let factory_dmg = stats.damage.expect("damage").value();
let replayed = ShipStatsProvenance::replay(dmg_attr);
assert!((replayed - factory_dmg).abs() < 1e-2, "ShellDamage replay got {replayed}, factory={factory_dmg}");
}
}