use std::collections::HashMap;
use crate::data::ResourceLoader;
use crate::game_params::ttx::labels::TtxStat;
use crate::game_params::ttx::labels::stat_display_label;
use crate::game_params::ttx::model::StatRow;
use crate::game_params::ttx::provenance::Contribution;
use crate::game_params::ttx::provenance::InputId;
use crate::game_params::ttx::provenance::Op;
use crate::game_params::ttx::provenance::ShipStatsProvenance;
use crate::game_params::types::GameParamProvider;
#[derive(Clone, Debug, PartialEq)]
pub struct ContributorLine {
pub label: String,
pub effect: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AttributionLine {
pub stat: TtxStat,
pub qualifier: Option<String>,
pub label: String,
pub value: String,
pub base_label: String,
pub base_value: String,
pub contributors: Vec<ContributorLine>,
}
fn input_label(input: &InputId, loader: &dyn ResourceLoader, provider: &dyn GameParamProvider) -> String {
match input {
InputId::Module { name, .. } | InputId::Upgrade { name } => resolve_param_label(name, loader, provider),
InputId::Skill { name } => name.as_str().to_string(),
InputId::Consumable(c) => format!("{c:?}"),
InputId::Innate { skill_type } => skill_type.clone(),
}
}
fn resolve_param_label(key: &str, loader: &dyn ResourceLoader, provider: &dyn GameParamProvider) -> String {
provider
.game_param_by_name(key)
.and_then(|p| loader.localized_name_from_param(&p))
.unwrap_or_else(|| key.to_string())
}
fn format_effect(c: &Contribution) -> String {
match c.op {
Op::Mul => format!("x{}", trim(c.operand)),
Op::Add => format!("+{}", trim(c.operand)),
}
}
fn trim(v: f32) -> String {
let s = format!("{v:.3}");
let s = s.trim_end_matches('0').trim_end_matches('.');
s.to_string()
}
pub fn render_attributions(
prov: &ShipStatsProvenance,
rows: &[StatRow],
loader: &dyn ResourceLoader,
provider: &dyn GameParamProvider,
) -> Vec<AttributionLine> {
let display: HashMap<(TtxStat, Option<String>), String> =
rows.iter().map(|r| ((r.stat, r.qualifier.clone()), r.value.to_string())).collect();
prov.attributions
.iter()
.map(|a| {
let key = (a.stat, a.qualifier.clone());
debug_assert!(
display.contains_key(&key),
"render_attributions: stat {:?} qualifier {:?} absent from rows map; provenance key-set diverged from rows()",
a.stat,
a.qualifier
);
let value = display.get(&key).cloned().unwrap_or_else(|| trim(a.value));
let base_value = if a.steps.is_empty() {
value.clone()
} else {
trim(a.base_value)
};
AttributionLine {
stat: a.stat,
qualifier: a.qualifier.clone(),
label: stat_display_label(a.stat, loader),
value,
base_label: input_label(&a.base_source, loader, provider),
base_value,
contributors: a
.steps
.iter()
.map(|c| ContributorLine {
label: input_label(&c.input, loader, provider),
effect: format_effect(c),
})
.collect(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Rc;
use crate::game_params::ttx::model::AmmoCount;
use crate::game_params::ttx::model::Hp;
use crate::game_params::ttx::model::StatValue;
use crate::game_params::ttx::module_options::ModuleSlot;
use crate::game_params::ttx::provenance::StatAttribution;
use crate::game_params::types::CrewSkillName;
use crate::game_params::types::Param;
struct EchoLoader;
impl ResourceLoader for EchoLoader {
fn localized_name_from_param(&self, _p: &Param) -> Option<String> {
None
}
fn localized_name_from_id(&self, id: &crate::data::TranslationKey) -> Option<String> {
Some(id.as_str().to_string())
}
fn game_param_by_id(&self, _id: crate::game_types::GameParamId) -> Option<Rc<Param>> {
None
}
fn entity_specs(&self) -> &[crate::rpc::entitydefs::EntitySpec] {
&[]
}
}
struct EmptyProvider;
impl GameParamProvider for EmptyProvider {
fn game_param_by_id(&self, _id: crate::game_types::GameParamId) -> Option<Rc<Param>> {
None
}
fn game_param_by_index(&self, _i: &str) -> Option<Rc<Param>> {
None
}
fn game_param_by_name(&self, _n: &str) -> Option<Rc<Param>> {
None
}
fn params(&self) -> &[Rc<Param>] {
&[]
}
}
#[test]
fn renders_base_and_contributors() {
let prov = ShipStatsProvenance {
attributions: vec![StatAttribution {
stat: TtxStat::Health,
qualifier: None,
base_value: 19400.0,
base_source: InputId::Module { slot: ModuleSlot::Hull, name: "PAUH911".into() },
steps: vec![
Contribution {
input: InputId::Skill { name: CrewSkillName::from("AdrenalineRush") },
modifier_name: "healthHullCoeff".into(),
op: Op::Mul,
operand: 1.05,
},
Contribution {
input: InputId::Upgrade { name: "PCM030".into() },
modifier_name: "healthPerLevel".into(),
op: Op::Add,
operand: 3500.0,
},
],
value: 23870.0,
}],
};
let rows = vec![StatRow { stat: TtxStat::Health, qualifier: None, value: StatValue::Hp(Hp::from(23870.0)) }];
let lines = render_attributions(&prov, &rows, &EchoLoader, &EmptyProvider);
assert_eq!(lines.len(), 1);
let l = &lines[0];
assert_eq!(l.base_label, "PAUH911");
assert_eq!(l.base_value, "19400");
assert_eq!(l.value, "23870");
assert_eq!(l.contributors.len(), 2);
assert_eq!(l.contributors[0].label, "AdrenalineRush");
assert_eq!(l.contributors[0].effect, "x1.05");
assert_eq!(l.contributors[1].effect, "+3500");
}
#[test]
fn ammo_stat_shows_inf_not_sentinel() {
let prov = ShipStatsProvenance {
attributions: vec![StatAttribution {
stat: TtxStat::ShellMaxAmmo,
qualifier: Some("HE".into()),
base_value: -1.0,
base_source: InputId::Module { slot: ModuleSlot::Hull, name: "PAUH911".into() },
steps: vec![],
value: -1.0,
}],
};
let rows = vec![StatRow {
stat: TtxStat::ShellMaxAmmo,
qualifier: Some("HE".into()),
value: StatValue::Ammo(AmmoCount::Infinite),
}];
let lines = render_attributions(&prov, &rows, &EchoLoader, &EmptyProvider);
assert_eq!(lines.len(), 1);
let l = &lines[0];
assert_eq!(l.value, "inf", "value should be 'inf', not '-1'");
assert_eq!(l.base_value, "inf", "base_value should also be 'inf' when steps is empty");
}
#[test]
fn bool_stat_shows_yes_not_one() {
let prov = ShipStatsProvenance {
attributions: vec![StatAttribution {
stat: TtxStat::TorpedoIsDamageIncreasing,
qualifier: Some("0".into()),
base_value: 1.0,
base_source: InputId::Module { slot: ModuleSlot::Hull, name: "PAUH911".into() },
steps: vec![],
value: 1.0,
}],
};
let rows = vec![StatRow {
stat: TtxStat::TorpedoIsDamageIncreasing,
qualifier: Some("0".into()),
value: StatValue::Bool(true),
}];
let lines = render_attributions(&prov, &rows, &EchoLoader, &EmptyProvider);
assert_eq!(lines.len(), 1);
let l = &lines[0];
assert_eq!(l.value, "yes", "value should be 'yes', not '1'");
assert_eq!(l.base_value, "yes", "base_value should also be 'yes' when steps is empty");
}
}