1use 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
19pub 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#[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}