use std::collections::HashMap;
use std::collections::HashSet;
use crate::data::Version;
use crate::game_params::keys::ComponentType;
use crate::game_params::ttx::constants::DispersionCurve;
use crate::game_params::ttx::constants::DispersionEllipse;
use crate::game_params::ttx::consumables::ConsumableCard;
use crate::game_params::ttx::consumables::effective_consumable;
use crate::game_params::ttx::effects::ReloadCoeffSources;
use crate::game_params::ttx::effects::ReloadCoeffs;
use crate::game_params::ttx::effects::walk_ability_slots;
use crate::game_params::ttx::factories;
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::model::AmmoCount;
use crate::game_params::ttx::model::ShipStats;
use crate::game_params::ttx::modifiers::ModifierBundle;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::ModifierSources;
use crate::game_params::ttx::provenance::Off;
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;
use crate::game_params::ttx::selection::ShipUpgradeSelection;
use crate::game_params::types::ArmorMap;
use crate::game_params::types::GameParamProvider;
use crate::game_params::types::Km;
use crate::game_params::types::Param;
use crate::game_params::types::Species;
use crate::recognized::Recognized;
const NO_FIRE_CONTROL_COEF: f32 = 1.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ArtilleryDispersion {
pub curve: DispersionCurve,
pub max_range: Km,
pub ideal_radius_coef: f32,
}
impl ArtilleryDispersion {
pub fn ellipse_at(&self, dist: Km) -> DispersionEllipse {
crate::game_params::ttx::constants::dispersion_ellipse(
&self.curve,
dist,
self.max_range,
self.ideal_radius_coef,
)
}
pub fn at_max_range(&self) -> DispersionEllipse {
self.ellipse_at(self.max_range)
}
}
pub fn artillery_dispersion(
ship: &Param,
selection: &ShipUpgradeSelection,
modifiers: &ModifierBundle,
) -> Option<ArtilleryDispersion> {
let components = ship.vehicle()?.ttx_components()?;
let arty = selection.artillery.as_deref().and_then(|name| components.artillery(name))?;
let curve = arty.guns.first()?.dispersion_curve()?;
let fc_coef = selection
.fire_control
.as_deref()
.and_then(|name| components.fire_control_max_dist_coef(name))
.unwrap_or(NO_FIRE_CONTROL_COEF);
let range_km = factories::artillery_range_km(arty, fc_coef, 1.0, modifiers)?;
Some(ArtilleryDispersion {
curve,
max_range: Km::from(range_km),
ideal_radius_coef: modifiers.coef("GMIdealRadius"),
})
}
const SMALL_SHELL_MAX_DIAMETER_M: f32 = 0.149;
pub fn ship_stats(
ship: &Param,
selection: &ShipUpgradeSelection,
modifiers: &ModifierBundle,
level: u32,
provider: &dyn GameParamProvider,
) -> ShipStats {
ship_stats_with(
ship,
selection,
modifiers,
&ModifierSources::default(),
ReloadCoeffs::default(),
1.0,
None,
ReloadCoeffSources::default(),
level,
Version::base(15, 0, 0),
provider,
&mut Off,
)
}
pub fn ship_stats_explained(
ship: &Param,
selection: &ShipUpgradeSelection,
modifiers: &ModifierBundle,
sources: &ModifierSources,
level: u32,
provider: &dyn GameParamProvider,
) -> (ShipStats, ShipStatsProvenance) {
let mut rec = On::default();
let stats = ship_stats_with(
ship,
selection,
modifiers,
sources,
ReloadCoeffs::default(),
1.0,
None,
ReloadCoeffSources::default(),
level,
Version::base(15, 0, 0),
provider,
&mut rec,
);
(stats, rec.into_provenance())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn ship_stats_with<R: Recorder>(
ship: &Param,
selection: &ShipUpgradeSelection,
modifiers: &ModifierBundle,
sources: &ModifierSources,
reload_coeffs: ReloadCoeffs,
spotter_dist_coef: f32,
spotter_dist_coef_source: Option<InputId>,
reload_coeff_sources: ReloadCoeffSources,
level: u32,
version: Version,
provider: &dyn GameParamProvider,
rec: &mut R,
) -> ShipStats {
let Some(vehicle) = ship.vehicle() else {
return ShipStats::default();
};
let Some(components) = vehicle.ttx_components() else {
return ShipStats::default();
};
let hull = selection.hull.as_deref().and_then(|name| components.hull(name));
let hull_name = selection.hull.as_deref().unwrap_or("");
let durability = hull.map(|h| factories::durability(h, hull_name, modifiers, sources, level, rec));
let mobility = hull.map(|h| {
let engine = selection.engine.as_deref().and_then(|name| components.engine(name)).cloned().unwrap_or_default();
factories::mobility(h, hull_name, &engine, selection.engine.as_deref(), modifiers, sources, rec)
});
let battery = hull.and_then(|h| factories::battery(h, hull_name, modifiers, sources, rec));
let fc_coef = selection
.fire_control
.as_deref()
.and_then(|name| components.fire_control_max_dist_coef(name))
.unwrap_or(NO_FIRE_CONTROL_COEF);
let arty_name = selection.artillery.as_deref().unwrap_or("");
let artillery = selection.artillery.as_deref().and_then(|name| components.artillery(name)).and_then(|arty| {
factories::artillery(
arty,
arty_name,
selection.fire_control.as_deref(),
modifiers,
sources,
fc_coef,
spotter_dist_coef,
spotter_dist_coef_source.clone(),
reload_coeffs.main,
reload_coeff_sources.main.clone(),
level,
provider,
rec,
)
});
let secondaries = selection.hull.as_deref().and_then(|name| components.secondaries(name)).and_then(|atba| {
factories::secondaries(
atba,
hull_name,
modifiers,
sources,
reload_coeffs.secondary,
reload_coeff_sources.secondary.clone(),
level,
provider,
rec,
)
});
let torp_name = selection.torpedoes.as_deref().unwrap_or("");
let torpedoes = selection.torpedoes.as_deref().and_then(|name| components.torpedoes(name)).and_then(|launchers| {
factories::torpedoes(
launchers,
modifiers,
reload_coeffs.torpedo,
reload_coeff_sources.torpedo.clone(),
provider,
torp_name,
sources,
rec,
)
});
let armor = vehicle.armor().and_then(|hull_armor| {
let arti_armor = artillery_armor_maps(vehicle, selection.hull.as_deref());
factories::armor(hull_armor, hull_name, arti_armor.iter().copied(), rec)
});
let has_big_gun_artillery = selection
.artillery
.as_deref()
.and_then(|name| components.artillery(name))
.and_then(|arty| arty.guns.first())
.and_then(|gun| gun.barrel_diameter)
.is_some_and(|d| d.value() >= SMALL_SHELL_MAX_DIAMETER_M);
let mg_max_dist_km = artillery.as_ref().and_then(|a| a.range.map(|r| r.value()));
let atba_max_dist_km = secondaries.as_ref().map(|b| b.range.value());
let visibility = hull.map(|h| {
factories::visibility(
h,
hull_name,
modifiers,
sources,
has_big_gun_artillery,
mg_max_dist_km,
atba_max_dist_km,
rec,
)
});
let consumables = build_consumable_cards(ship, modifiers, sources, version, provider, rec);
ShipStats {
durability,
mobility,
armor,
battery,
artillery,
secondaries,
torpedoes,
fire_control: None,
visibility,
consumables,
}
}
fn build_consumable_cards<R: Recorder>(
ship: &Param,
modifiers: &ModifierBundle,
sources: &ModifierSources,
version: Version,
provider: &dyn GameParamProvider,
rec: &mut R,
) -> Vec<ConsumableCard> {
let species = ship.species().and_then(|s| s.known().copied()).unwrap_or(Species::Destroyer);
let empty_bundle = ModifierBundle::empty(species);
let mut seen: HashSet<Recognized<crate::game_types::Consumable>> = HashSet::new();
let mut raw: Vec<(
Recognized<crate::game_types::Consumable>,
crate::game_params::ttx::consumables::EffectiveConsumable,
crate::game_params::ttx::consumables::ConsumableApplied,
crate::game_params::ttx::consumables::EffectiveConsumable,
)> = Vec::new();
walk_ability_slots(ship, provider, version, |recognized, cat| {
if seen.contains(&recognized) {
return;
}
seen.insert(recognized.clone());
let (stats, applied) = effective_consumable(cat, modifiers);
let (base_stats, _) = effective_consumable(cat, &empty_bundle);
raw.push((recognized, stats, applied, base_stats));
});
let mut label_count: HashMap<String, u32> = HashMap::new();
let mut cards: Vec<ConsumableCard> = Vec::new();
for (consumable, stats, applied, base_stats) in raw {
let base_label = match &consumable {
Recognized::Known(c) => c.name().to_string(),
Recognized::Unknown(raw_name) => raw_name.clone(),
};
let count = label_count.entry(base_label.clone()).or_insert(0);
*count += 1;
let label = if *count == 1 { base_label } else { format!("{base_label} ({})", *count) };
if R::ON {
record_consumable_stats(&consumable, &label, &stats, &applied, &base_stats, sources, rec);
}
cards.push(ConsumableCard { consumable, label, stats });
}
cards
}
fn record_consumable_stats<R: Recorder>(
consumable: &Recognized<crate::game_types::Consumable>,
label: &str,
stats: &crate::game_params::ttx::consumables::EffectiveConsumable,
applied: &crate::game_params::ttx::consumables::ConsumableApplied,
base_stats: &crate::game_params::ttx::consumables::EffectiveConsumable,
sources: &ModifierSources,
rec: &mut R,
) {
let base_source = InputId::Consumable(consumable.clone());
rec.record(
TtxStat::ConsumableReloadTime,
Some(label),
base_stats.reload_time.value(),
base_source.clone(),
stats.reload_time.value(),
|b| {
for m in &applied.reload_time {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
rec.record(
TtxStat::ConsumablePreparationTime,
Some(label),
base_stats.preparation_time.value(),
base_source.clone(),
stats.preparation_time.value(),
|b| {
for m in &applied.preparation_time {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
match (base_stats.charges, stats.charges) {
(AmmoCount::Infinite, AmmoCount::Infinite) => {
rec.record(TtxStat::ConsumableCharges, Some(label), -1.0, base_source.clone(), -1.0, |_| {});
}
(AmmoCount::Finite(base_n), AmmoCount::Finite(final_n)) => {
rec.record(
TtxStat::ConsumableCharges,
Some(label),
base_n as f32,
base_source.clone(),
final_n as f32,
|b| {
for m in &applied.charges {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
(base_kind, final_kind) => {
tracing::warn!(
consumable = %label,
"unexpected charge-kind mismatch: base={base_kind:?} final={final_kind:?}; skipping charges record"
);
}
}
if let (Some(base_wt), Some(final_wt)) = (base_stats.work_time, stats.work_time) {
rec.record(
TtxStat::ConsumableWorkTime,
Some(label),
base_wt.value(),
base_source.clone(),
final_wt.value(),
|b| {
for m in &applied.work_time {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_mc), Some(final_mc)) = (base_stats.max_capacity, stats.max_capacity) {
rec.record(TtxStat::ConsumableMaxCapacity, Some(label), base_mc, base_source.clone(), final_mc, |b| {
for m in &applied.max_capacity {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
});
}
if let Some(dr) = stats.detection_radius {
rec.record(
TtxStat::ConsumableDetectionRadius,
Some(label),
dr.value(),
base_source.clone(),
dr.value(),
|_| {},
);
}
if let (Some(base_rhs), Some(final_rhs)) = (base_stats.regeneration_hp_speed, stats.regeneration_hp_speed) {
rec.record(
TtxStat::ConsumableRegenerationHpSpeed,
Some(label),
base_rhs,
base_source.clone(),
final_rhs,
|b| {
for m in &applied.regeneration_hp_speed {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_sr), Some(final_sr)) = (base_stats.smoke_radius, stats.smoke_radius) {
rec.record(
TtxStat::ConsumableSmokeRadius,
Some(label),
base_sr.value(),
base_source.clone(),
final_sr.value(),
|b| {
for m in &applied.smoke_radius {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_sl), Some(final_sl)) = (base_stats.smoke_lifetime, stats.smoke_lifetime) {
rec.record(
TtxStat::ConsumableSmokeLifetime,
Some(label),
base_sl.value(),
base_source.clone(),
final_sl.value(),
|b| {
for m in &applied.smoke_lifetime {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_fc), Some(final_fc)) = (base_stats.fighters_count, stats.fighters_count) {
rec.record(TtxStat::ConsumableFightersCount, Some(label), base_fc, base_source.clone(), final_fc, |b| {
for m in &applied.fighters_count {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
});
}
if let (Some(base_cfr), Some(final_cfr)) = (base_stats.call_fighters_radius, stats.call_fighters_radius) {
rec.record(
TtxStat::ConsumableCallFightersRadius,
Some(label),
base_cfr.value(),
base_source.clone(),
final_cfr.value(),
|b| {
for m in &applied.call_fighters_radius {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_cftd), Some(final_cftd)) = (base_stats.call_fighters_time_delay, stats.call_fighters_time_delay) {
rec.record(
TtxStat::ConsumableCallFightersTimeDelay,
Some(label),
base_cftd.value(),
base_source.clone(),
final_cftd.value(),
|b| {
for m in &applied.call_fighters_time_delay {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_cffh), Some(final_cffh)) =
(base_stats.call_fighters_time_from_heaven, stats.call_fighters_time_from_heaven)
{
rec.record(
TtxStat::ConsumableCallFightersTimeFromHeaven,
Some(label),
base_cffh.value(),
base_source.clone(),
final_cffh.value(),
|b| {
for m in &applied.call_fighters_time_from_heaven {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
if let (Some(base_prr), Some(final_prr)) = (base_stats.plane_regeneration_rate, stats.plane_regeneration_rate) {
rec.record(
TtxStat::ConsumablePlaneRegenerationRate,
Some(label),
base_prr,
base_source.clone(),
final_prr,
|b| {
for m in &applied.plane_regeneration_rate {
match m.op {
Op::Mul => b.coef(sources, &m.name),
Op::Add => b.bonus(sources, &m.name, 1.0),
}
}
},
);
}
}
pub fn ship_stats_stock(ship: &Param, provider: &dyn GameParamProvider) -> ShipStats {
let selection = ShipUpgradeSelection::stock(ship);
let level = ship.vehicle().map(|v| v.level()).unwrap_or(0);
let Some(species) = ship.species().and_then(|s| s.known().copied()) else {
return ShipStats::default();
};
ship_stats(ship, &selection, &ModifierBundle::empty(species), level, provider)
}
fn artillery_armor_maps<'a>(
vehicle: &'a crate::game_params::types::Vehicle,
hull_name: Option<&str>,
) -> Vec<&'a ArmorMap> {
let Some(hull_name) = hull_name else {
return Vec::new();
};
let Some(config) = vehicle.hull_upgrade(hull_name) else {
return Vec::new();
};
let Some(mounts) = config.mounts(ComponentType::Artillery) else {
return Vec::new();
};
mounts.iter().filter_map(|m| m.mount_armor()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rc;
const SONAR_RELOAD_S: f32 = 90.0;
const SONAR_WORK_TIME_S: f32 = 100.0;
const SONAR_BASE_CHARGES: isize = 3;
const SUPERINTENDENT_BONUS: f32 = 1.0;
const SONAR_CHARGES_WITH_SUPER: f32 = 4.0;
const CONSUMABLE_RELOAD_COEFF: f32 = 0.9;
const SONAR_RELOAD_AFTER_MOD_S: f32 = 81.0;
const BIG_GUN_VISIBILITY_COEFF: f32 = 1.05;
const GEARING_SEA_DETECTION_KM: f32 = 7.33;
const FC_COEF: f32 = 1.2;
const GEARING_BASE_RANGE_KM: f32 = 11.13;
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::ShipTtxComponents;
use crate::game_params::ttx::components::TorpedoLauncherStats;
use crate::game_params::ttx::model::DegreesPerSecond;
use crate::game_params::ttx::model::Hp;
use crate::game_params::ttx::model::Knots;
use crate::game_params::ttx::model::Seconds;
use crate::game_params::types::BigWorldDistance;
use crate::game_params::types::Km;
use crate::game_params::types::Meters;
use crate::game_params::types::Param;
use crate::game_params::types::ParamData;
use crate::game_params::types::Projectile;
use crate::game_params::types::Species;
use crate::game_params::types::Vehicle;
use crate::game_types::GameParamId;
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 gearing_torpedo() -> Projectile {
Projectile::builder()
.ammo_type("torpedo".to_string())
.max_dist(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()],
}
}
fn gearing_artillery() -> ArtilleryComponentStats {
let gun = || ArtilleryGunStats {
shot_delay: Some(Seconds::from(4.6)),
rotation_speed: Some(DegreesPerSecond::from(20.0)),
num_barrels: Some(2.0),
barrel_diameter: Some(Meters::from(0.127)),
ammo_switch_coeff: Some(1.0),
min_radius: Some(1.0),
ideal_radius: Some(10.0),
ideal_distance: Some(1000.0),
radius_on_zero: None,
radius_on_delim: None,
radius_on_max: None,
delim: None,
ammo: vec!["PAPA127_127mm_HE".to_string()],
};
ArtilleryComponentStats { max_dist: Some(Meters::from(11130.0)), guns: vec![gun(), gun(), gun()] }
}
fn gearing_he() -> Projectile {
Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(1800.0)
.alpha_piercing_he(21.0)
.burn_prob(0.05)
.uw_critical(0.0)
.bullet_diametr(0.127)
.bullet_speed(792.0)
.build()
}
struct StubProvider {
params: Vec<Rc<Param>>,
}
impl StubProvider {
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();
StubProvider { params }
}
}
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.params.iter().find(|p| p.name() == name).cloned()
}
fn params(&self) -> &[Rc<Param>] {
&self.params
}
}
fn ship_with(name: &str, level: u32, species: Species, components: ShipTtxComponents) -> Param {
let vehicle = Vehicle::builder()
.level(level)
.group("g".to_string())
.maybe_abilities(None)
.upgrades(Vec::new())
.maybe_config_data(None)
.maybe_model_path(None)
.maybe_armor(None)
.maybe_hit_locations(None)
.permoflages(Vec::new())
.camera_trajectories(Vec::new())
.ttx_components(components)
.innate_skills(Vec::new())
.build();
Param::builder()
.id(GameParamId::from(900u32))
.index("IDX".to_string())
.name(name.to_string())
.nation("USA".to_string())
.species(crate::recognized::Recognized::Known(species))
.data(ParamData::Vehicle(vehicle))
.build()
}
fn gearing_ship() -> Param {
let mut components = ShipTtxComponents::default();
components.hulls.insert("PAUH911_Gearing_1945".to_string(), gearing_hull());
components.engines.insert("PAUE903_D10_ENG_STOCK".to_string(), EngineComponentStats { speed_coef: Some(0.0) });
components.artillery.insert("PAUA903_D10_ART_STOCK".to_string(), gearing_artillery());
components.torpedoes.insert("PAUT902_D10_NEW_STOCK".to_string(), vec![gearing_launcher()]);
components.fire_controls.insert("PAUS911_Suo".to_string(), 1.0);
components.stock_selection = ShipUpgradeSelection::new(
Some("PAUH911_Gearing_1945".to_string()),
Some("PAUE903_D10_ENG_STOCK".to_string()),
Some("PAUA903_D10_ART_STOCK".to_string()),
Some("PAUT902_D10_NEW_STOCK".to_string()),
Some("PAUS911_Suo".to_string()),
);
ship_with("PASD013_Gearing_1945", 10, Species::Destroyer, components)
}
fn gearing_provider() -> StubProvider {
StubProvider::new(&[("PAPT027_Mk_16_mod_1", gearing_torpedo()), ("PAPA127_127mm_HE", gearing_he())])
}
fn gearing_ship_with_dispersion_curve() -> Param {
let gun = || ArtilleryGunStats {
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.4),
radius_on_max: Some(1.8),
delim: Some(0.5),
..gearing_artillery().guns[0].clone()
};
let mut arty = gearing_artillery();
arty.guns = vec![gun(), gun(), gun()];
let mut components = ShipTtxComponents::default();
components.hulls.insert("PAUH911_Gearing_1945".to_string(), gearing_hull());
components.engines.insert("PAUE903_D10_ENG_STOCK".to_string(), EngineComponentStats { speed_coef: Some(0.0) });
components.artillery.insert("PAUA903_D10_ART_STOCK".to_string(), arty);
components.torpedoes.insert("PAUT902_D10_NEW_STOCK".to_string(), vec![gearing_launcher()]);
components.fire_controls.insert("PAUS911_Suo".to_string(), 1.0);
components.stock_selection = ShipUpgradeSelection::new(
Some("PAUH911_Gearing_1945".to_string()),
Some("PAUE903_D10_ENG_STOCK".to_string()),
Some("PAUA903_D10_ART_STOCK".to_string()),
Some("PAUT902_D10_NEW_STOCK".to_string()),
Some("PAUS911_Suo".to_string()),
);
ship_with("PASD013_Gearing_1945", 10, Species::Destroyer, components)
}
#[test]
fn artillery_dispersion_profile_resolves_and_evaluates() {
let ship = gearing_ship_with_dispersion_curve();
let sel = ShipUpgradeSelection::stock(&ship);
let bundle = ModifierBundle::empty(Species::Destroyer);
let profile = artillery_dispersion(&ship, &sel, &bundle).expect("profile");
let provider = gearing_provider();
let card = ship_stats(&ship, &sel, &bundle, 10, &provider).artillery.expect("arty");
assert!((profile.max_range.value() - card.range.expect("range").value()).abs() < 1e-3);
let e = profile.at_max_range();
assert!((e.vertical.value() - e.horizontal.value() * 1.8).abs() < 1e-3);
let near = profile.ellipse_at(Km::from(profile.max_range.value() / 2.0));
assert!(near.horizontal.value() < e.horizontal.value());
}
#[test]
fn artillery_dispersion_none_without_curve_or_battery() {
let ship = gearing_ship();
let sel = ShipUpgradeSelection::stock(&ship);
assert!(artillery_dispersion(&ship, &sel, &ModifierBundle::empty(Species::Destroyer)).is_none());
let no_arty = ShipUpgradeSelection { artillery: None, ..sel };
assert!(artillery_dispersion(&ship, &no_arty, &ModifierBundle::empty(Species::Destroyer)).is_none());
}
#[test]
fn stock_selection_picks_base_upgrades() {
let ship = gearing_ship();
let sel = ShipUpgradeSelection::stock(&ship);
assert_eq!(sel.hull.as_deref(), Some("PAUH911_Gearing_1945"));
assert_eq!(sel.engine.as_deref(), Some("PAUE903_D10_ENG_STOCK"));
assert_eq!(sel.artillery.as_deref(), Some("PAUA903_D10_ART_STOCK"));
assert_eq!(sel.torpedoes.as_deref(), Some("PAUT902_D10_NEW_STOCK"));
assert_eq!(sel.fire_control.as_deref(), Some("PAUS911_Suo"));
}
#[test]
fn stock_selection_default_when_not_a_vehicle() {
let proj = Param::builder()
.id(GameParamId::from(1u32))
.index("S0001".to_string())
.name("X".to_string())
.nation("USA".to_string())
.data(ParamData::Projectile(gearing_torpedo()))
.build();
assert_eq!(ShipUpgradeSelection::stock(&proj), ShipUpgradeSelection::default());
}
#[test]
fn gearing_stock_ship_stats_sections() {
let ship = gearing_ship();
let provider = gearing_provider();
let stats = ship_stats_stock(&ship, &provider);
let durability = stats.durability.expect("durability");
assert_eq!(durability.health.expect("health").value(), 19400.0);
let mobility = stats.mobility.expect("mobility");
assert_eq!(mobility.speed.expect("speed").value(), 36.0);
assert_eq!(mobility.turning_radius.expect("turning").value(), 640.0);
let torps = stats.torpedoes.expect("torpedoes");
let damage = torps.torpedoes[0].damage.expect("torp damage").value();
assert!((damage - (53500.0 / 3.0 + 1200.0)).abs() < 1e-1, "got {damage}");
let arty = stats.artillery.expect("artillery");
assert!((arty.reload_time.expect("reload").value() - 4.6).abs() < 1e-3);
assert!((arty.range.expect("range").value() - 11.13).abs() < 1e-3);
let vis = stats.visibility.expect("visibility");
assert!((vis.sea_detection.expect("sea").value() - 7.33).abs() < 1e-3);
assert!(stats.battery.is_none());
assert!(stats.secondaries.is_none());
assert!(stats.fire_control.is_none());
}
#[test]
fn fire_control_coef_scales_artillery_range() {
let mut components = ShipTtxComponents::default();
components.hulls.insert("PAUH911_Gearing_1945".to_string(), gearing_hull());
components.engines.insert("PAUE903_D10_ENG_STOCK".to_string(), EngineComponentStats { speed_coef: Some(0.0) });
components.artillery.insert("PAUA903_D10_ART_STOCK".to_string(), gearing_artillery());
components.fire_controls.insert("PAUS911_Suo".to_string(), 1.2);
components.stock_selection = ShipUpgradeSelection::new(
Some("PAUH911_Gearing_1945".to_string()),
Some("PAUE903_D10_ENG_STOCK".to_string()),
Some("PAUA903_D10_ART_STOCK".to_string()),
None,
Some("PAUS911_Suo".to_string()),
);
let ship = ship_with("PASD013_Gearing_1945", 10, Species::Destroyer, components);
let provider = gearing_provider();
let stats = ship_stats_stock(&ship, &provider);
let range = stats.artillery.expect("artillery").range.expect("range").value();
assert!((range - GEARING_BASE_RANGE_KM * FC_COEF).abs() < 1e-2, "got {range}");
}
#[test]
fn big_gun_visibility_gate_uses_caliber() {
use crate::game_params::types::CrewSkillModifier;
let mut components = ShipTtxComponents::default();
components.hulls.insert("H".to_string(), gearing_hull());
let big_gun = ArtilleryGunStats { barrel_diameter: Some(Meters::from(0.152)), ..ArtilleryGunStats::default() };
components.artillery.insert(
"A".to_string(),
ArtilleryComponentStats { max_dist: Some(Meters::from(15000.0)), guns: vec![big_gun] },
);
components.stock_selection =
ShipUpgradeSelection::new(Some("H".to_string()), None, Some("A".to_string()), None, None);
let ship = ship_with("BigGun", 10, Species::Cruiser, components);
let provider = StubProvider::new(&[]);
let modifier = CrewSkillModifier::builder()
.name("GMBigGunVisibilityCoeff".to_string())
.aircraft_carrier(1.05)
.auxiliary(1.05)
.battleship(1.05)
.cruiser(1.05)
.destroyer(1.05)
.submarine(1.05)
.excluded_consumables(Vec::new())
.build();
let bundle =
ModifierBundle::from_modifiers(&[modifier], Species::Cruiser, crate::data::Version::base(15, 0, 0))
.expect("test modifiers are all known");
let sel = ShipUpgradeSelection::stock(&ship);
let stats = ship_stats(&ship, &sel, &bundle, 10, &provider);
let sea = stats.visibility.expect("visibility").sea_detection.expect("sea").value();
assert!((sea - GEARING_SEA_DETECTION_KM * BIG_GUN_VISIBILITY_COEFF).abs() < 1e-3, "got {sea}");
}
#[test]
fn non_vehicle_yields_empty_stats() {
let proj = Param::builder()
.id(GameParamId::from(1u32))
.index("S0001".to_string())
.name("X".to_string())
.nation("USA".to_string())
.data(ParamData::Projectile(gearing_torpedo()))
.build();
let provider = StubProvider::new(&[]);
let stats = ship_stats(
&proj,
&ShipUpgradeSelection::default(),
&ModifierBundle::empty(Species::Destroyer),
10,
&provider,
);
assert!(stats.durability.is_none());
assert!(stats.artillery.is_none());
}
#[test]
fn stock_stats_default_when_species_unknown() {
let mut components = ShipTtxComponents::default();
components.hulls.insert("PAUH911_Gearing_1945".to_string(), gearing_hull());
components.stock_selection =
ShipUpgradeSelection::new(Some("PAUH911_Gearing_1945".to_string()), None, None, None, None);
let vehicle = Vehicle::builder()
.level(10)
.group("g".to_string())
.maybe_abilities(None)
.upgrades(Vec::new())
.maybe_config_data(None)
.maybe_model_path(None)
.maybe_armor(None)
.maybe_hit_locations(None)
.permoflages(Vec::new())
.camera_trajectories(Vec::new())
.ttx_components(components)
.innate_skills(Vec::new())
.build();
let ship = Param::builder()
.id(GameParamId::from(901u32))
.index("IDX".to_string())
.name("UnknownSpecies".to_string())
.nation("USA".to_string())
.species(crate::recognized::Recognized::Unknown("MadeUpSpecies".to_string()))
.data(ParamData::Vehicle(vehicle))
.build();
let provider = StubProvider::new(&[]);
let stats = ship_stats_stock(&ship, &provider);
assert!(stats.durability.is_none());
assert!(stats.mobility.is_none());
assert!(stats.artillery.is_none());
assert!(stats.visibility.is_none());
}
use crate::game_params::ttx::components::SecondaryComponentStats;
fn g150() -> ArtilleryGunStats {
ArtilleryGunStats {
shot_delay: Some(Seconds::from(7.5)),
rotation_speed: Some(DegreesPerSecond::from(10.0)),
num_barrels: Some(2.0),
barrel_diameter: Some(Meters::from(0.15)),
ammo_switch_coeff: None,
min_radius: Some(2.0),
ideal_radius: Some(15.0),
ideal_distance: Some(1000.0),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.4),
radius_on_max: Some(1.8),
delim: Some(0.5),
ammo: vec!["SEC_150mm_HE".to_string()],
}
}
fn g105() -> ArtilleryGunStats {
ArtilleryGunStats {
shot_delay: Some(Seconds::from(3.5)),
rotation_speed: Some(DegreesPerSecond::from(12.0)),
num_barrels: Some(1.0),
barrel_diameter: Some(Meters::from(0.105)),
ammo_switch_coeff: None,
min_radius: Some(1.5),
ideal_radius: Some(12.0),
ideal_distance: Some(1000.0),
radius_on_zero: Some(1.0),
radius_on_delim: Some(1.35),
radius_on_max: Some(1.7),
delim: Some(0.5),
ammo: vec!["SEC_105mm_HE".to_string()],
}
}
fn sec150_he() -> crate::game_params::types::Projectile {
crate::game_params::types::Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(2100.0)
.alpha_piercing_he(25.0)
.burn_prob(0.12)
.uw_critical(0.0)
.bullet_diametr(0.15)
.bullet_speed(800.0)
.build()
}
fn sec105_he() -> crate::game_params::types::Projectile {
crate::game_params::types::Projectile::builder()
.ammo_type("HE".to_string())
.alpha_damage(1200.0)
.alpha_piercing_he(17.0)
.burn_prob(0.07)
.uw_critical(0.0)
.bullet_diametr(0.105)
.bullet_speed(900.0)
.build()
}
fn secondaries_provider() -> StubProvider {
StubProvider::new(&[("SEC_150mm_HE", sec150_he()), ("SEC_105mm_HE", sec105_he())])
}
fn secondaries_ship() -> crate::game_params::types::Param {
let hull_name = "PGSH010_Hull_B";
let mut components = ShipTtxComponents::default();
components.hulls.insert(hull_name.to_string(), gearing_hull());
components.engines.insert("PGSE_Engine".to_string(), EngineComponentStats { speed_coef: Some(0.0) });
components.secondaries.insert(
hull_name.to_string(),
SecondaryComponentStats {
max_dist: Some(Meters::from(7600.0)),
guns: vec![g150(), g150(), g105(), g105()],
},
);
components.stock_selection =
ShipUpgradeSelection::new(Some(hull_name.to_string()), Some("PGSE_Engine".to_string()), None, None, None);
ship_with("PGSB_SecondaryBB", 10, Species::Battleship, components)
}
#[test]
fn secondaries_bearing_card_coverage_and_replay() {
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let ship = secondaries_ship();
let provider = secondaries_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (stats, prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Battleship),
&ModifierSources::default(),
10,
&provider,
);
let battery = stats.secondaries.as_ref().expect("secondaries");
assert_eq!(battery.mounts.len(), 2);
use std::collections::HashSet;
let row_keys: HashSet<_> = stats.rows().into_iter().map(|r| (r.stat, r.qualifier)).collect();
let prov_keys: HashSet<_> = prov.attributions.iter().map(|a| (a.stat, a.qualifier.clone())).collect();
assert_eq!(row_keys, prov_keys, "every secondary stat row must have exactly one attribution");
for a in &prov.attributions {
let replayed = ShipStatsProvenance::replay(a);
assert!(
(replayed - a.value).abs() <= 1e-2 + a.value.abs() * 1e-4,
"replay mismatch for {:?} ({:?}): replayed={} value={}",
a.stat,
a.qualifier,
replayed,
a.value
);
}
}
fn test_version() -> crate::data::Version {
crate::data::Version::base(15, 4, 0)
}
use std::collections::HashMap as StdHashMap;
fn finite_sonar_category() -> crate::game_params::types::AbilityCategory {
use crate::game_params::types::AbilityCategory;
use std::collections::BTreeMap;
let mut fields = BTreeMap::new();
fields.insert("lifeCycleType".to_string(), 0.0_f32); AbilityCategory::builder()
.consumable_type("sonar".to_string())
.group("ship".to_string())
.icon_id(String::new())
.num_consumables(SONAR_BASE_CHARGES)
.preparation_time(0.0)
.reload_time(SONAR_RELOAD_S)
.work_time(SONAR_WORK_TIME_S)
.effect_fields(fields)
.build()
}
struct ConsumablesProvider {
params: Vec<Rc<Param>>,
}
impl ConsumablesProvider {
fn new(
projectiles: &[(&str, Projectile)],
ability_name: &str,
variant_name: &str,
category: crate::game_params::types::AbilityCategory,
) -> Self {
use crate::game_params::types::Ability;
let mut params: Vec<Rc<Param>> = projectiles
.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();
let mut categories = StdHashMap::new();
categories.insert(variant_name.to_string(), category);
let ability = Ability::builder()
.can_buy(false)
.cost_credits(0)
.cost_gold(0)
.is_free(true)
.categories(categories)
.build();
let idx = params.len() + 1;
params.push(Rc::new(
Param::builder()
.id(GameParamId::from(idx as u32))
.index(format!("A{idx:04}"))
.name(ability_name.to_string())
.nation("USA".to_string())
.data(ParamData::Ability(ability))
.build(),
));
ConsumablesProvider { params }
}
}
impl GameParamProvider for ConsumablesProvider {
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 gearing_ship_with_sonar() -> Param {
let mut components = ShipTtxComponents::default();
components.hulls.insert("PAUH911_Gearing_1945".to_string(), gearing_hull());
components.engines.insert("PAUE903_D10_ENG_STOCK".to_string(), EngineComponentStats { speed_coef: Some(0.0) });
components.artillery.insert("PAUA903_D10_ART_STOCK".to_string(), gearing_artillery());
components.torpedoes.insert("PAUT902_D10_NEW_STOCK".to_string(), vec![gearing_launcher()]);
components.fire_controls.insert("PAUS911_Suo".to_string(), 1.0);
components.stock_selection = ShipUpgradeSelection::new(
Some("PAUH911_Gearing_1945".to_string()),
Some("PAUE903_D10_ENG_STOCK".to_string()),
Some("PAUA903_D10_ART_STOCK".to_string()),
Some("PAUT902_D10_NEW_STOCK".to_string()),
Some("PAUS911_Suo".to_string()),
);
let abilities = Some(vec![vec![("PCY_Sonar".to_string(), "IVariant".to_string())]]);
let vehicle = Vehicle::builder()
.level(10)
.group("g".to_string())
.maybe_abilities(abilities)
.upgrades(Vec::new())
.maybe_config_data(None)
.maybe_model_path(None)
.maybe_armor(None)
.maybe_hit_locations(None)
.permoflages(Vec::new())
.camera_trajectories(Vec::new())
.ttx_components(components)
.innate_skills(Vec::new())
.build();
Param::builder()
.id(GameParamId::from(900u32))
.index("IDX".to_string())
.name("PASD013_Gearing_1945".to_string())
.nation("USA".to_string())
.species(crate::recognized::Recognized::Known(Species::Destroyer))
.data(ParamData::Vehicle(vehicle))
.build()
}
fn sonar_consumables_provider() -> ConsumablesProvider {
ConsumablesProvider::new(
&[("PAPT027_Mk_16_mod_1", gearing_torpedo()), ("PAPA127_127mm_HE", gearing_he())],
"PCY_Sonar",
"IVariant",
finite_sonar_category(),
)
}
fn uniform_modifier(name: &str, value: f32) -> crate::game_params::types::CrewSkillModifier {
crate::game_params::types::CrewSkillModifier::builder()
.name(name.to_owned())
.aircraft_carrier(value)
.auxiliary(value)
.battleship(value)
.cruiser(value)
.destroyer(value)
.submarine(value)
.excluded_consumables(Vec::new())
.build()
}
#[test]
fn effective_modifiers_default_state_matches_ship_stats_empty_bundle() {
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let level = 10u32;
let base = ship_stats(&ship, &sel, &ModifierBundle::empty(Species::Destroyer), level, &provider);
let em = crate::game_params::ttx::effects::Effects::for_test(vec![])
.resolve(&crate::game_params::ttx::effects::EffectsState::default(), Species::Destroyer, test_version())
.unwrap();
let under_em = em.stats(&ship, &sel, level, &provider);
let base_reload = base.artillery.as_ref().and_then(|a| a.reload_time).map(|s| s.value());
let em_reload = under_em.artillery.as_ref().and_then(|a| a.reload_time).map(|s| s.value());
assert_eq!(base_reload, em_reload, "reload must match with identity coefficients");
let base_range = base.artillery.as_ref().and_then(|a| a.range).map(|r| r.value());
let em_range = under_em.artillery.as_ref().and_then(|a| a.range).map(|r| r.value());
assert_eq!(base_range, em_range, "range must match with identity spotter coeff");
}
#[test]
fn adrenaline_reload_half_hp_multiplies_main_reload() {
use crate::game_params::ttx::effects::Effect;
use crate::game_params::ttx::effects::EffectActivation;
use crate::game_params::ttx::effects::EffectId;
use crate::game_params::ttx::effects::EffectKind;
use crate::game_params::ttx::effects::Effects;
use crate::game_params::ttx::effects::EffectsState;
use crate::game_params::ttx::effects::HealthFraction;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let level = 10u32;
let raw_coeff = 0.2f32;
let adrenaline = Effect::for_test(
EffectId::Skill("ArmamentReloadAaDamage".into()),
EffectKind::HealthScaledReload,
vec![uniform_modifier("lastChanceReloadCoefficient_Main", raw_coeff)],
);
let effects = Effects::for_test(vec![adrenaline]);
let state_full = EffectsState::default();
let em_full = effects.resolve(&state_full, Species::Destroyer, test_version()).unwrap();
let reload_full = em_full
.stats(&ship, &sel, level, &provider)
.artillery
.and_then(|a| a.reload_time)
.map(|s| s.value())
.unwrap();
let state_half = EffectsState::default()
.set(EffectId::Skill("ArmamentReloadAaDamage".into()), EffectActivation::Health(HealthFraction::new(0.5)));
let em_half = effects.resolve(&state_half, Species::Destroyer, test_version()).unwrap();
let reload_half = em_half
.stats(&ship, &sel, level, &provider)
.artillery
.and_then(|a| a.reload_time)
.map(|s| s.value())
.unwrap();
let expected_coeff = 1.0 - 0.5 * raw_coeff;
let expected_reload = reload_full * expected_coeff;
assert!((reload_half - expected_reload).abs() < 1e-4, "got {reload_half}, expected {expected_reload}");
assert!(reload_half < reload_full, "adrenaline at 50% HP must reduce reload");
}
#[test]
fn spotter_consumable_extends_range_and_off_reverts_to_base() {
use crate::game_params::ttx::effects::Effect;
use crate::game_params::ttx::effects::EffectActivation;
use crate::game_params::ttx::effects::EffectId;
use crate::game_params::ttx::effects::EffectKind;
use crate::game_params::ttx::effects::Effects;
use crate::game_params::ttx::effects::EffectsState;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let level = 10u32;
let dist_coeff = 1.2f32;
let scout_effect = Effect::for_test(
EffectId::Consumable(crate::game_types::Consumable::SpottingAircraft),
EffectKind::Consumable { artillery_dist_coeff: dist_coeff },
vec![uniform_modifier("GMIdealRadius", 0.9)],
);
let effects = Effects::for_test(vec![scout_effect]);
let state_off = EffectsState::default();
let em_off = effects.resolve(&state_off, Species::Destroyer, test_version()).unwrap();
let range_off =
em_off.stats(&ship, &sel, level, &provider).artillery.and_then(|a| a.range).map(|r| r.value()).unwrap();
let state_on = EffectsState::default()
.set(EffectId::Consumable(crate::game_types::Consumable::SpottingAircraft), EffectActivation::On);
let em_on = effects.resolve(&state_on, Species::Destroyer, test_version()).unwrap();
let range_on =
em_on.stats(&ship, &sel, level, &provider).artillery.and_then(|a| a.range).map(|r| r.value()).unwrap();
let base_range = ship_stats(&ship, &sel, &ModifierBundle::empty(Species::Destroyer), level, &provider)
.artillery
.and_then(|a| a.range)
.map(|r| r.value())
.unwrap();
let scout_no_radius_mod = Effect::for_test(
EffectId::Consumable(crate::game_types::Consumable::SpottingAircraft),
EffectKind::Consumable { artillery_dist_coeff: dist_coeff },
vec![],
);
let effects_no_radius = Effects::for_test(vec![scout_no_radius_mod]);
let em_on_no_radius = effects_no_radius.resolve(&state_on, Species::Destroyer, test_version()).unwrap();
let dispersion_on =
em_on.stats(&ship, &sel, level, &provider).artillery.and_then(|a| a.dispersion).map(|d| d.value()).unwrap();
let dispersion_on_no_radius = em_on_no_radius
.stats(&ship, &sel, level, &provider)
.artillery
.and_then(|a| a.dispersion)
.map(|d| d.value())
.unwrap();
assert!((range_off - base_range).abs() < 1e-4, "spotter off: got {range_off}, expected {base_range}");
assert!(
(range_on - base_range * dist_coeff).abs() < 1e-3,
"spotter on: got {range_on}, expected {}",
base_range * dist_coeff
);
assert!(range_on > range_off, "spotter on must increase range");
assert!(
dispersion_on < dispersion_on_no_radius,
"GMIdealRadius 0.9 must tighten dispersion vs no-modifier baseline: on={dispersion_on}, baseline={dispersion_on_no_radius}"
);
}
#[test]
fn explained_provenance_covers_every_row() {
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (stats, prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&provider,
);
use std::collections::HashSet;
let row_keys: HashSet<_> = stats.rows().into_iter().map(|r| (r.stat, r.qualifier)).collect();
let prov_keys: HashSet<_> = prov.attributions.iter().map(|a| (a.stat, a.qualifier.clone())).collect();
assert_eq!(row_keys, prov_keys, "every stat row must have exactly one attribution");
}
#[test]
fn explained_provenance_replays_to_value() {
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (_stats, prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&provider,
);
for a in &prov.attributions {
let replayed = ShipStatsProvenance::replay(a);
assert!(
(replayed - a.value).abs() <= 1e-2 + a.value.abs() * 1e-4,
"replay mismatch for {:?}: {} vs {}",
a.stat,
replayed,
a.value
);
}
}
#[test]
fn consumables_bearing_coverage_and_replay() {
use crate::game_params::ttx::provenance::ShipStatsProvenance;
use std::collections::HashSet;
let ship = gearing_ship_with_sonar();
let provider = sonar_consumables_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (stats, prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&provider,
);
assert!(!stats.consumables.is_empty(), "fixture must have at least one consumable card");
let row_keys: HashSet<_> = stats.rows().into_iter().map(|r| (r.stat, r.qualifier)).collect();
let prov_keys: HashSet<_> = prov.attributions.iter().map(|a| (a.stat, a.qualifier.clone())).collect();
assert_eq!(row_keys, prov_keys, "consumable stats: row keys must equal provenance keys");
for a in &prov.attributions {
let replayed = ShipStatsProvenance::replay(a);
assert!(
(replayed - a.value).abs() <= 1e-2 + a.value.abs() * 1e-4,
"replay mismatch for {:?} ({:?}): replayed={} value={}",
a.stat,
a.qualifier,
replayed,
a.value
);
}
}
#[test]
fn consumable_charge_attribution_is_skill() {
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let ship = gearing_ship_with_sonar();
let provider = sonar_consumables_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let mods = [uniform_modifier("additionalConsumables", 1.0)];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Destroyer, test_version()).unwrap();
let mut sources = ModifierSources::default();
sources.record("additionalConsumables", InputId::Skill { name: "Superintendent".into() }, 1.0);
let (stats, prov) = ship_stats_explained(&ship, &sel, &bundle, &sources, 10, &provider);
let card = stats.consumables.iter().find(|c| c.label == "sonar").expect("sonar card");
let label = card.label.clone();
let charges_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ConsumableCharges && a.qualifier.as_deref() == Some(&label))
.expect("ConsumableCharges attribution for sonar");
let replayed = ShipStatsProvenance::replay(charges_attr);
assert!(
(replayed - charges_attr.value).abs() <= 1e-2,
"replay mismatch: replayed={} value={}",
replayed,
charges_attr.value
);
assert!(
(charges_attr.value - SONAR_CHARGES_WITH_SUPER).abs() < 1e-3,
"expected {} charges after +{} bonus, got {}",
SONAR_CHARGES_WITH_SUPER,
SUPERINTENDENT_BONUS,
charges_attr.value
);
let bonus_step = charges_attr
.steps
.iter()
.find(|s| s.modifier_name == "additionalConsumables")
.expect("additionalConsumables step on ConsumableCharges");
assert_eq!(
bonus_step.input,
InputId::Skill { name: "Superintendent".into() },
"ConsumableCharges bonus step must be attributed to the Superintendent skill"
);
}
#[test]
fn consumable_reload_attribution_is_upgrade() {
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
let ship = gearing_ship_with_sonar();
let provider = sonar_consumables_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let mods = [uniform_modifier("ConsumableReloadTime", CONSUMABLE_RELOAD_COEFF)];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Destroyer, test_version()).unwrap();
let mut sources = ModifierSources::default();
sources.record(
"ConsumableReloadTime",
InputId::Upgrade { name: "ConsumablesMod3".into() },
CONSUMABLE_RELOAD_COEFF,
);
let (stats, prov) = ship_stats_explained(&ship, &sel, &bundle, &sources, 10, &provider);
let card = stats.consumables.iter().find(|c| c.label == "sonar").expect("sonar card");
let label = card.label.clone();
let reload_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ConsumableReloadTime && a.qualifier.as_deref() == Some(&label))
.expect("ConsumableReloadTime attribution for sonar");
let replayed = ShipStatsProvenance::replay(reload_attr);
assert!(
(replayed - reload_attr.value).abs() <= 1e-2 + reload_attr.value.abs() * 1e-4,
"replay mismatch: replayed={} value={}",
replayed,
reload_attr.value
);
assert!(
(reload_attr.value - SONAR_RELOAD_AFTER_MOD_S).abs() < 1e-2,
"expected {} s reload after {}x modifier, got {}",
SONAR_RELOAD_AFTER_MOD_S,
CONSUMABLE_RELOAD_COEFF,
reload_attr.value
);
let coef_step = reload_attr
.steps
.iter()
.find(|s| s.modifier_name == "ConsumableReloadTime")
.expect("ConsumableReloadTime coef step");
assert_eq!(coef_step.op, Op::Mul, "ConsumableReloadTime step must be multiplicative");
assert_eq!(
coef_step.input,
InputId::Upgrade { name: "ConsumablesMod3".into() },
"ConsumableReloadTime step must be attributed to ConsumablesMod3 upgrade"
);
}
#[test]
fn derived_from_links_resolve_to_existing_rows() {
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::provenance::StatKey;
use std::collections::HashSet;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (_stats, prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&provider,
);
let keys: HashSet<StatKey> =
prov.attributions.iter().map(|a| StatKey { stat: a.stat, qualifier: a.qualifier.clone() }).collect();
for a in &prov.attributions {
for link in &a.derived_from {
assert!(keys.contains(link), "derived_from {:?} of {:?} has no matching attribution", link, a.stat);
}
}
let rt = prov.attributions.iter().find(|a| a.stat == TtxStat::GunRotationTime).expect("rotation time");
assert_eq!(rt.derived_from, vec![StatKey { stat: TtxStat::GunRotationSpeed, qualifier: None }]);
}
#[test]
fn spotter_step_input_is_consumable_not_module() {
use crate::game_params::ttx::effects::Effect;
use crate::game_params::ttx::effects::EffectActivation;
use crate::game_params::ttx::effects::EffectId;
use crate::game_params::ttx::effects::EffectKind;
use crate::game_params::ttx::effects::Effects;
use crate::game_params::ttx::effects::EffectsState;
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::provenance::InputId;
use crate::game_types::Consumable;
use crate::recognized::Recognized;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let scout_effect = Effect::for_test(
EffectId::Consumable(Consumable::SpottingAircraft),
EffectKind::Consumable { artillery_dist_coeff: 1.2 },
vec![],
);
let effects = Effects::for_test(vec![scout_effect]);
let state =
EffectsState::default().set(EffectId::Consumable(Consumable::SpottingAircraft), EffectActivation::On);
let em = effects.resolve(&state, Species::Destroyer, test_version()).unwrap();
let mut rec = crate::game_params::ttx::provenance::On::default();
crate::game_params::ttx::orchestration::ship_stats_with(
&ship,
&sel,
em.bundle(),
em.sources(),
em.reload_coeffs(),
em.artillery_dist_coeff(),
em.artillery_dist_coeff_source().cloned(),
em.reload_coeff_sources().clone(),
10,
em.version(),
&provider,
&mut rec,
);
let prov = rec.into_provenance();
let range_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ArtilleryRange)
.expect("ArtilleryRange must be recorded");
let spotter_step = range_attr.steps.iter().find(|s| s.modifier_name == "spotterDistCoeff");
assert!(spotter_step.is_some(), "spotterDistCoeff step must be recorded when coeff != 1.0");
assert_eq!(
spotter_step.unwrap().input,
InputId::Consumable(Recognized::Known(Consumable::SpottingAircraft)),
"spotterDistCoeff step must be attributed to the spotter consumable"
);
}
#[test]
fn every_changed_stat_is_explained() {
use crate::game_params::ttx::provenance::StatKey;
use std::collections::HashMap;
let ship = gearing_ship_with_sonar();
let provider = sonar_consumables_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let (_stock, stock_prov) = ship_stats_explained(
&ship,
&sel,
&ModifierBundle::empty(Species::Destroyer),
&ModifierSources::default(),
10,
&provider,
);
let stock_by_key: HashMap<StatKey, f32> = stock_prov
.attributions
.iter()
.map(|a| (StatKey { stat: a.stat, qualifier: a.qualifier.clone() }, a.value))
.collect();
let mods = [
uniform_modifier("visibilityDistCoeff", 0.9),
uniform_modifier("GMRotationSpeed", 1.2),
uniform_modifier("GMShotDelay", 0.9),
uniform_modifier("additionalConsumables", 1.0),
uniform_modifier("ConsumableReloadTime", 0.9),
];
let bundle = ModifierBundle::from_modifiers(&mods, Species::Destroyer, test_version()).unwrap();
let mut sources = ModifierSources::default();
sources.record("visibilityDistCoeff", InputId::Skill { name: "ConcealmentExpert".into() }, 0.9);
sources.record("GMRotationSpeed", InputId::Skill { name: "ExpertMarksman".into() }, 1.2);
sources.record("GMShotDelay", InputId::Upgrade { name: "MainBatteryMod3".into() }, 0.9);
sources.record("additionalConsumables", InputId::Skill { name: "Superintendent".into() }, 1.0);
sources.record("ConsumableReloadTime", InputId::Upgrade { name: "ConsumablesMod3".into() }, 0.9);
let (_built, prov) = ship_stats_explained(&ship, &sel, &bundle, &sources, 10, &provider);
for a in &prov.attributions {
let key = StatKey { stat: a.stat, qualifier: a.qualifier.clone() };
let changed = stock_by_key.get(&key).map(|s| (s - a.value).abs() > 1e-4).unwrap_or(true);
if !changed {
continue;
}
let explained = !a.steps.is_empty()
|| !a.derived_from.is_empty()
|| stock_prov
.attributions
.iter()
.find(|s| s.stat == a.stat && s.qualifier == a.qualifier)
.map(|s| s.base_source != a.base_source)
.unwrap_or(true);
assert!(explained, "changed stat {:?} ({:?}) has no explanation", a.stat, a.qualifier);
}
}
#[test]
fn adrenaline_reload_step_input_is_skill_not_module() {
use crate::game_params::ttx::effects::Effect;
use crate::game_params::ttx::effects::EffectActivation;
use crate::game_params::ttx::effects::EffectId;
use crate::game_params::ttx::effects::EffectKind;
use crate::game_params::ttx::effects::Effects;
use crate::game_params::ttx::effects::EffectsState;
use crate::game_params::ttx::effects::HealthFraction;
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::types::CrewSkillName;
let ship = gearing_ship();
let provider = gearing_provider();
let sel = ShipUpgradeSelection::stock(&ship);
let skill_name = CrewSkillName::from("ArmamentReloadAaDamage");
let adrenaline = Effect::for_test(
EffectId::Skill(skill_name.clone()),
EffectKind::HealthScaledReload,
vec![uniform_modifier("lastChanceReloadCoefficient_Main", 0.2)],
);
let effects = Effects::for_test(vec![adrenaline]);
let state = EffectsState::default()
.set(EffectId::Skill(skill_name.clone()), EffectActivation::Health(HealthFraction::new(0.5)));
let em = effects.resolve(&state, Species::Destroyer, test_version()).unwrap();
let mut rec = crate::game_params::ttx::provenance::On::default();
crate::game_params::ttx::orchestration::ship_stats_with(
&ship,
&sel,
em.bundle(),
em.sources(),
em.reload_coeffs(),
em.artillery_dist_coeff(),
em.artillery_dist_coeff_source().cloned(),
em.reload_coeff_sources().clone(),
10,
em.version(),
&provider,
&mut rec,
);
let prov = rec.into_provenance();
let reload_attr = prov
.attributions
.iter()
.find(|a| a.stat == TtxStat::ArtilleryReloadTime)
.expect("ArtilleryReloadTime must be recorded");
let reload_step = reload_attr.steps.iter().find(|s| s.modifier_name == "reloadCoeff");
assert!(reload_step.is_some(), "reloadCoeff step must be recorded when coeff != 1.0");
assert_eq!(
reload_step.unwrap().input,
InputId::Skill { name: skill_name },
"reloadCoeff step must be attributed to the Adrenaline skill"
);
}
}