1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
6#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
7#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
8pub struct SpecIntrospection {
9 pub resource_primary: ResourceInfo,
10 pub resource_secondary: Option<ResourceInfo>,
11 pub spells: Vec<SpellInfo>,
12 pub auras: Vec<AuraInfo>,
13 pub auto_attacks: Vec<AutoAttackInfo>,
14 pub hero_talents: Vec<HeroTalentTree>,
15}
16
17#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
18#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
19#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
20pub struct ResourceInfo {
21 pub name: String,
22 pub max: f64,
23 pub regen: f64,
24 pub starts_at: f64,
25 pub type_id: u8,
26}
27
28#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
29#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
30#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
31pub struct SpellInfo {
32 pub spell_id: u32,
33 pub name: String,
34 pub slug: String,
35 pub cast_time_ms: u32,
36 pub start_recovery_category: Option<crate::data::CooldownCategoryId>,
37 pub off_gcd: bool,
38 pub gcd_ms: u32,
39 pub is_pet: bool,
40 pub breaks_stealth: bool,
41 pub resource_cost: f64,
42 pub resource_gain: f64,
43 pub secondary_resource_cost: f64,
44 pub secondary_resource_gain: f64,
45 pub damage: DamageInfo,
46 pub applies_aura_id: Option<u32>,
47 pub cooldown: Option<CooldownInfo>,
48 pub cdr_effects: Vec<CdrEffect>,
49}
50
51#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
52#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
53#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
54pub struct CooldownInfo {
55 pub duration_secs: f64,
56 pub max_charges: u8,
57 pub recharge_secs: f64,
58}
59
60#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
61#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
62#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
63pub struct CdrEffect {
64 pub target_spell_local: u8,
65 pub amount_ms: u32,
66 pub condition: CdrCondition,
67}
68
69#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
70#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
71#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
72#[serde(tag = "type")]
73#[non_exhaustive]
74pub enum CdrCondition {
75 Always,
76 ProcChance { chance: f64 },
77 WhileAuraActive { aura_local: u8 },
78 ResetWhileAura { aura_local: u8 },
79}
80
81impl fmt::Display for CdrCondition {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 Self::Always => write!(f, "Always"),
85 Self::ProcChance { chance } => write!(f, "ProcChance({chance:.0}%)"),
86 Self::WhileAuraActive { aura_local } => {
87 write!(f, "WhileAuraActive({aura_local})")
88 }
89 Self::ResetWhileAura { aura_local } => write!(f, "ResetWhileAura({aura_local})"),
90 }
91 }
92}
93
94#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
95#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
96#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
97pub struct DamageInfo {
98 pub kind: DamageKind,
99}
100
101#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
102#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
103#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
104#[serde(tag = "type")]
105#[non_exhaustive]
106pub enum DamageKind {
107 None,
108 Flat {
109 amount: f64,
110 },
111 ApCoefficient {
112 coef: f64,
113 is_physical: bool,
114 },
115 SpCoefficient {
116 coef: f64,
117 is_physical: bool,
118 },
119 Weapon {
120 multiplier: f64,
121 flat_bonus: f64,
122 normalized: bool,
123 is_physical: bool,
124 },
125}
126
127impl fmt::Display for DamageKind {
128 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129 match self {
130 Self::None => Ok(()),
131 Self::Flat { amount } => write!(f, "dmg=flat({amount:.1})"),
132 Self::ApCoefficient { coef, is_physical } => {
133 write!(f, "dmg=ap_coef({coef:.4}) physical={is_physical}")
134 }
135 Self::SpCoefficient { coef, is_physical } => {
136 write!(f, "dmg=sp_coef({coef:.4}) physical={is_physical}")
137 }
138 Self::Weapon {
139 multiplier,
140 flat_bonus,
141 normalized,
142 is_physical,
143 } => write!(
144 f,
145 "dmg=weapon(multiplier={multiplier:.4} flat_bonus={flat_bonus:.1}) normalized={normalized} physical={is_physical}"
146 ),
147 }
148 }
149}
150
151#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
152#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
153#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
154pub struct AuraInfo {
155 pub aura_id: u32,
156 pub name: String,
157 pub slug: String,
158 pub on: String,
159 pub base_duration_ms: u32,
160 pub max_stacks: u8,
161 pub pandemic: bool,
162 pub haste_buff_pct: f64,
163 pub damage_mult_pct: f64,
164 pub periodic: Option<PeriodicInfo>,
165}
166
167#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
168#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
169#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
170pub struct PeriodicInfo {
171 pub tick_ms: u32,
172 pub effect: PeriodicEffect,
173}
174
175#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
176#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
177#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
178#[serde(tag = "type")]
179#[non_exhaustive]
180pub enum PeriodicEffect {
181 FlatDamage { amount: f64, is_physical: bool },
182 Damage { ap_coef: f64, is_physical: bool },
183 SpDamage { sp_coef: f64, is_physical: bool },
184 MaxHealthDamage { percent: f64, is_physical: bool },
185 ResourceGain { amount: f64 },
186 ResourceDrain { amount: f64 },
187 ApplyAura { target_aura_local: u8 },
188 ResidualDamage,
189 RemoveStack,
190 Hook,
191}
192
193impl fmt::Display for PeriodicEffect {
194 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195 match self {
196 Self::FlatDamage {
197 amount,
198 is_physical,
199 } => write!(f, "amount={amount:.1} physical={is_physical}"),
200 Self::Damage {
201 ap_coef,
202 is_physical,
203 } => write!(f, "ap_coef={ap_coef:.4} physical={is_physical}"),
204 Self::SpDamage {
205 sp_coef,
206 is_physical,
207 } => write!(f, "sp_coef={sp_coef:.4} physical={is_physical}"),
208 Self::MaxHealthDamage {
209 percent,
210 is_physical,
211 } => write!(f, "max_health={percent:.4}% physical={is_physical}"),
212 Self::ResourceGain { amount } => write!(f, "resource_gain={amount:.1}"),
213 Self::ResourceDrain { amount } => write!(f, "resource_drain={amount:.1}"),
214 Self::ApplyAura { target_aura_local } => {
215 write!(f, "apply_aura={target_aura_local}")
216 }
217 Self::ResidualDamage => f.write_str("residual_damage"),
218 Self::RemoveStack => f.write_str("remove_stack"),
219 Self::Hook => f.write_str("hook"),
220 }
221 }
222}
223
224#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
225#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
226#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
227pub struct AutoAttackInfo {
228 pub spell_id: u32,
229 pub swing_ms: u32,
230 pub ap_coef: f64,
231 pub is_pet: bool,
232}
233
234#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
235#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
236#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
237pub struct HeroTalentTree {
238 pub name: String,
239 pub spells: Vec<(String, u32)>,
240 pub auras: Vec<(String, u32)>,
241}
242
243#[cfg(test)]
244mod tests {
245 use googletest::prelude::*;
246 use rstest::rstest;
247
248 use super::*;
249
250 #[gtest]
251 #[rstest]
252 #[case::always(CdrCondition::Always, "Always")]
253 #[case::proc_chance(CdrCondition::ProcChance { chance: 50.0 }, "ProcChance(50%)")]
254 #[case::proc_chance_round(CdrCondition::ProcChance { chance: 49.6 }, "ProcChance(50%)")]
255 #[case::while_aura(CdrCondition::WhileAuraActive { aura_local: 3 }, "WhileAuraActive(3)")]
256 #[case::reset_aura(CdrCondition::ResetWhileAura { aura_local: 7 }, "ResetWhileAura(7)")]
257 fn cdr_condition_display(#[case] cond: CdrCondition, #[case] expected: &str) -> Result<()> {
258 verify_that!(format!("{cond}"), eq(expected))
259 }
260
261 #[gtest]
262 fn cdr_effect_serde_shape_preserves_wasm_contract() -> Result<()> {
263 let effect = CdrEffect {
264 target_spell_local: 4,
265 amount_ms: 750,
266 condition: CdrCondition::WhileAuraActive { aura_local: 2 },
267 };
268 let json = serde_json::to_value(effect).or_fail()?;
269
270 verify_that!(
271 json,
272 eq(&serde_json::json!({
273 "target_spell_local": 4,
274 "amount_ms": 750,
275 "condition": {
276 "type": "WhileAuraActive",
277 "aura_local": 2,
278 },
279 }))
280 )?;
281 let round_trip: CdrEffect = serde_json::from_value(json).or_fail()?;
282
283 verify_that!(round_trip, eq(effect))
284 }
285
286 #[gtest]
287 #[rstest]
288 #[case::none(DamageKind::None, "")]
289 #[case::flat(DamageKind::Flat { amount: 12.5 }, "dmg=flat(12.5)")]
290 #[case::ap(
291 DamageKind::ApCoefficient { coef: 1.25, is_physical: true },
292 "dmg=ap_coef(1.2500) physical=true"
293 )]
294 #[case::sp(
295 DamageKind::SpCoefficient { coef: 0.75, is_physical: false },
296 "dmg=sp_coef(0.7500) physical=false"
297 )]
298 #[case::weapon(
299 DamageKind::Weapon {
300 multiplier: 1.5,
301 flat_bonus: 20.0,
302 normalized: true,
303 is_physical: true,
304 },
305 "dmg=weapon(multiplier=1.5000 flat_bonus=20.0) normalized=true physical=true"
306 )]
307 fn damage_kind_display(#[case] kind: DamageKind, #[case] expected: &str) -> Result<()> {
308 verify_that!(kind.to_string(), eq(expected))
309 }
310
311 #[gtest]
312 #[rstest]
313 #[case::flat(
314 PeriodicEffect::FlatDamage { amount: 12.5, is_physical: true },
315 "amount=12.5 physical=true"
316 )]
317 #[case::ap(
318 PeriodicEffect::Damage { ap_coef: 1.25, is_physical: false },
319 "ap_coef=1.2500 physical=false"
320 )]
321 #[case::sp(
322 PeriodicEffect::SpDamage { sp_coef: 0.75, is_physical: false },
323 "sp_coef=0.7500 physical=false"
324 )]
325 #[case::max_health(
326 PeriodicEffect::MaxHealthDamage { percent: 2.5, is_physical: true },
327 "max_health=2.5000% physical=true"
328 )]
329 #[case::gain(PeriodicEffect::ResourceGain { amount: 3.0 }, "resource_gain=3.0")]
330 #[case::drain(PeriodicEffect::ResourceDrain { amount: 4.0 }, "resource_drain=4.0")]
331 #[case::apply(PeriodicEffect::ApplyAura { target_aura_local: 7 }, "apply_aura=7")]
332 #[case::residual(PeriodicEffect::ResidualDamage, "residual_damage")]
333 #[case::remove_stack(PeriodicEffect::RemoveStack, "remove_stack")]
334 #[case::hook(PeriodicEffect::Hook, "hook")]
335 fn periodic_effect_display(
336 #[case] effect: PeriodicEffect,
337 #[case] expected: &str,
338 ) -> Result<()> {
339 verify_that!(effect.to_string(), eq(expected))
340 }
341}