Skip to main content

wowlab_engine_application/
paperdoll.rs

1//! Character-sheet (paperdoll) resolution: a sim config to a [`ResolvedPaperdoll`] for tooltips.
2
3use wowlab_engine_domain::{constants::TANK_HEALER_ROLE_MULT, stats::primary_stat_for_spec};
4use wowlab_engine_gamedata::ResolvedGameTables;
5use wowlab_engine_ports::{ContentCatalog, DataResolver, DynDataResolver, EngineError, Paperdoll};
6use wowlab_types::{
7    data::WeaponStats,
8    game::{Attribute, SpecId},
9    spell_render::{ResolvedPaperdoll, ResolvedPaperdollStats, WeaponDamage},
10};
11
12use crate::{
13    ApplicationError, ApplicationStage,
14    error::EngineResultExt as _,
15    intent_builder::IntentOverrides,
16    simulate_intent::{build_handler_from_setup, resolve_setup, run_on_sim_start},
17};
18
19/// Resolve a character profile (sim config) into a [`ResolvedPaperdoll`] for tooltip scaling.
20pub async fn resolve_paperdoll(
21    catalog: &ContentCatalog,
22    sim_config: &str,
23    level: u32,
24    is_male: bool,
25    resolver: &DynDataResolver<'_>,
26) -> Result<ResolvedPaperdoll, ApplicationError> {
27    let setup = resolve_setup(
28        catalog,
29        sim_config,
30        resolver,
31        &IntentOverrides {
32            skip_rotation: true,
33            ..Default::default()
34        },
35    )
36    .await?;
37    let spec = setup.descriptor.spec_id;
38    let talent_spell_ids: Vec<u32> = setup.talent_selections.iter().map(|t| t.spell_id).collect();
39
40    let mut handler = build_handler_from_setup(&setup)?;
41
42    run_on_sim_start(handler.handler_mut());
43    let pd = handler
44        .handler_mut()
45        .paperdoll()
46        .ok_or_else(|| EngineError::intent_validation("spec does not support paperdoll resolution"))
47        .in_application_stage(ApplicationStage::PaperdollResolution)?;
48
49    let scaling_data = resolver.get_scaling_data().await.map_err(|source| {
50        ApplicationError::spec_construction_resolver(
51            ApplicationStage::PaperdollResolution,
52            "failed to resolve scaling data",
53            source,
54        )
55    })?;
56    let tables = ResolvedGameTables::from_scaling(&scaling_data);
57    let health_per_stamina = tables
58        .health_per_stamina(level)
59        .ok_or_else(|| {
60            EngineError::spec_construction(format!("hp_per_sta missing for level {level}"))
61        })
62        .in_application_stage(ApplicationStage::PaperdollResolution)?;
63
64    let spec_order_index = resolver
65        .get_spec(wowlab_types::numeric::u32_to_i32_saturating(
66            spec.wow_spec_id(),
67        ))
68        .await
69        .map_err(|source| {
70            ApplicationError::spec_construction_resolver(
71                ApplicationStage::PaperdollResolution,
72                "failed to resolve specialization metadata",
73                source,
74            )
75        })?
76        .order_index;
77
78    Ok(map_paperdoll(
79        &pd,
80        PaperdollIdentity {
81            spec,
82            spec_order_index: wowlab_types::numeric::i32_to_u8_saturating(spec_order_index),
83            level,
84            is_male,
85        },
86        health_per_stamina,
87        &talent_spell_ids,
88    ))
89}
90
91/// Who the paperdoll belongs to, as the render contract needs it.
92#[derive(Clone, Copy, Debug)]
93struct PaperdollIdentity {
94    spec: SpecId,
95    spec_order_index: u8,
96    level: u32,
97    is_male: bool,
98}
99
100fn map_paperdoll(
101    pd: &Paperdoll,
102    identity: PaperdollIdentity,
103    health_per_stamina: f64,
104    talent_spell_ids: &[u32],
105) -> ResolvedPaperdoll {
106    let PaperdollIdentity {
107        spec,
108        spec_order_index,
109        level,
110        is_male,
111    } = identity;
112
113    let s = &pd.stats;
114    let primary_stat = match primary_stat_for_spec(spec) {
115        Attribute::Intellect => s.intellect,
116        Attribute::Agility => s.agility,
117        Attribute::Strength => s.strength,
118        Attribute::Stamina => s.intellect.max(s.agility).max(s.strength),
119    };
120    let role_mult = if spec.is_tank() || spec.is_healer() {
121        TANK_HEALER_ROLE_MULT
122    } else {
123        1.0
124    };
125    let max_health = s.stamina.floor() * health_per_stamina;
126
127    let speed_s = |w: &WeaponStats| f64::from(w.speed_ms) / wowlab_types::constants::MS_PER_SECOND;
128    let weapon = WeaponDamage {
129        main_min: pd.weapon_main.min_damage,
130        main_max: pd.weapon_main.max_damage,
131        main_speed: speed_s(&pd.weapon_main),
132        off_min: pd.weapon_off.map_or(0.0, |w| w.min_damage),
133        off_max: pd.weapon_off.map_or(0.0, |w| w.max_damage),
134        off_speed: pd.weapon_off.map_or(0.0, |w| speed_s(&w)),
135    };
136
137    let mut active_aura_ids: Vec<u32> = pd.precombat_aura_ids.clone();
138
139    active_aura_ids.extend(pd.talent_ranks.iter().map(|(id, _)| *id));
140    active_aura_ids.extend(pd.introspection.auras.iter().map(|a| a.aura_id));
141    active_aura_ids.sort_unstable();
142    active_aura_ids.dedup();
143    let mut known_spell_ids: Vec<u32> = pd
144        .introspection
145        .spells
146        .iter()
147        .map(|sp| sp.spell_id)
148        .collect();
149
150    known_spell_ids.extend(talent_spell_ids.iter().copied());
151    known_spell_ids.sort_unstable();
152    known_spell_ids.dedup();
153
154    ResolvedPaperdoll {
155        spec_id: spec.wow_spec_id(),
156        spec_order_index,
157        class_id: spec.class() as u32,
158        level,
159        is_male,
160        max_health,
161        role_mult,
162        stats: ResolvedPaperdollStats {
163            attack_power: s.attack_power,
164            spell_power: s.spell_power,
165            primary_stat,
166            intellect: s.intellect,
167            crit: s.crit_chance,
168            haste: s.haste,
169            mastery: s.mastery,
170            versatility: s.versatility,
171        },
172        weapon,
173        known_spell_ids,
174        active_aura_ids,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use googletest::prelude::*;
181    use rstest::rstest;
182    use wowlab_engine_domain::stats::default_stats;
183    use wowlab_engine_ports::{CombatStats, PaperdollLoadout};
184    use wowlab_types::game::SpecIntrospection;
185
186    use super::*;
187
188    fn paperdoll(
189        stats: CombatStats,
190        precombat_aura_ids: Vec<u32>,
191        talent_ranks: Vec<(u32, u8)>,
192        weapon_main: WeaponStats,
193        weapon_off: Option<WeaponStats>,
194    ) -> Paperdoll {
195        Paperdoll::new(
196            stats,
197            SpecIntrospection::default(),
198            PaperdollLoadout {
199                precombat_aura_ids,
200                talent_ranks,
201                item_use_spells: vec![],
202                weapon_main,
203                weapon_off,
204            },
205        )
206    }
207
208    fn map(pd: &Paperdoll, spec: SpecId, talent_spell_ids: &[u32]) -> ResolvedPaperdoll {
209        map_paperdoll(
210            pd,
211            PaperdollIdentity {
212                spec,
213                spec_order_index: 0,
214                level: 80,
215                is_male: true,
216            },
217            20.0,
218            talent_spell_ids,
219        )
220    }
221
222    #[gtest]
223    #[rstest]
224    #[case::intellect_spec(SpecId::Shadow, 10.0)]
225    #[case::agility_spec(SpecId::Outlaw, 20.0)]
226    #[case::strength_spec(SpecId::Arms, 30.0)]
227    fn primary_stat_selected_by_spec(#[case] spec: SpecId, #[case] expected: f64) -> Result<()> {
228        let mut stats = default_stats();
229
230        stats.intellect = 10.0;
231        stats.agility = 20.0;
232        stats.strength = 30.0;
233        let pd = paperdoll(stats, vec![], vec![], WeaponStats::default(), None);
234
235        verify_that!(map(&pd, spec, &[]).stats.primary_stat, near(expected, 1e-9))
236    }
237
238    #[gtest]
239    #[rstest]
240    #[case::tank(SpecId::ProtWarrior, TANK_HEALER_ROLE_MULT)]
241    #[case::healer(SpecId::HolyPaladin, TANK_HEALER_ROLE_MULT)]
242    #[case::dps(SpecId::Outlaw, 1.0)]
243    fn role_mult_tank_or_healer_vs_dps(#[case] spec: SpecId, #[case] expected: f64) -> Result<()> {
244        let pd = paperdoll(
245            default_stats(),
246            vec![],
247            vec![],
248            WeaponStats::default(),
249            None,
250        );
251
252        verify_that!(map(&pd, spec, &[]).role_mult, near(expected, 1e-9))
253    }
254
255    #[gtest]
256    fn max_health_floors_stamina() -> Result<()> {
257        let mut stats = default_stats();
258
259        stats.stamina = 100.7;
260        let pd = paperdoll(stats, vec![], vec![], WeaponStats::default(), None);
261
262        verify_that!(map(&pd, SpecId::Outlaw, &[]).max_health, near(2000.0, 1e-9))
263    }
264
265    #[gtest]
266    fn active_aura_ids_merged_sorted_deduped() -> Result<()> {
267        let pd = paperdoll(
268            default_stats(),
269            vec![5, 1],
270            vec![(1, 0), (3, 0)],
271            WeaponStats::default(),
272            None,
273        );
274
275        verify_that!(
276            map(&pd, SpecId::Outlaw, &[]).active_aura_ids,
277            eq(&vec![1u32, 3, 5])
278        )
279    }
280
281    #[gtest]
282    fn decoded_talents_remain_known_when_only_some_are_runtime_aura_markers() -> Result<()> {
283        let pd = paperdoll(
284            default_stats(),
285            vec![],
286            vec![(2, 1), (9, 1)],
287            WeaponStats::default(),
288            None,
289        );
290        let mapped = map(&pd, SpecId::Outlaw, &[2, 9, 9, 20, 30]);
291
292        verify_that!(mapped.active_aura_ids, eq(&vec![2u32, 9]))?;
293
294        verify_that!(mapped.known_spell_ids, eq(&vec![2u32, 9, 20, 30]))
295    }
296
297    #[gtest]
298    fn weapon_offhand_none_zeros() -> Result<()> {
299        let pd = paperdoll(
300            default_stats(),
301            vec![],
302            vec![],
303            WeaponStats::default(),
304            None,
305        );
306        let weapon = map(&pd, SpecId::Outlaw, &[]).weapon;
307
308        verify_that!(
309            weapon,
310            matches_pattern!(WeaponDamage {
311                off_min: near(0.0, 1e-9),
312                off_max: near(0.0, 1e-9),
313                off_speed: near(0.0, 1e-9),
314                ..
315            })
316        )
317    }
318
319    #[gtest]
320    fn weapon_offhand_some_mapped() -> Result<()> {
321        let off = WeaponStats {
322            min_damage: 5.0,
323            max_damage: 9.0,
324            speed_ms: 2000,
325            ..Default::default()
326        };
327        let pd = paperdoll(
328            default_stats(),
329            vec![],
330            vec![],
331            WeaponStats::default(),
332            Some(off),
333        );
334        let weapon = map(&pd, SpecId::Outlaw, &[]).weapon;
335
336        verify_that!(
337            weapon,
338            matches_pattern!(WeaponDamage {
339                off_min: near(5.0, 1e-9),
340                off_max: near(9.0, 1e-9),
341                off_speed: near(2.0, 1e-9),
342                ..
343            })
344        )
345    }
346}