1use serde::Deserialize;
2use wowlab_engine_gamedata::ResolvedGameData;
3use wowlab_types::{
4 data::ItemEffect,
5 game::{GearSlot, RaceId, SpecId},
6 sim::{Rotation, SpellIdx},
7};
8
9use crate::{
10 DeclaredSpecMetadata, EngineError, ResolvedEncounter, combat_stats::CombatStats,
11 spec_handler::SpecHandler,
12};
13
14const DEFAULT_QUEUE_LATENCY_MS: u32 = 5;
15const DEFAULT_GCD_LATENCY_MS: u32 = 150;
16const DEFAULT_CHANNEL_LATENCY_MS: u32 = 250;
17const DEFAULT_SPELL_QUEUE_WINDOW_MS: u32 = 400;
18
19#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
20pub struct TimedEventSchedule {
21 #[serde(default)]
22 pub first: f64,
23 pub last: Option<f64>,
24 pub interval: Option<f64>,
25 pub cooldown: Option<f64>,
26 #[serde(default)]
27 pub duration: f64,
28}
29
30impl TimedEventSchedule {
31 pub fn visit_occurrences(self, fight_duration_s: f64, mut visit: impl FnMut(f64)) {
32 let end = self.last.unwrap_or(fight_duration_s).min(fight_duration_s);
33 let cadence = self.interval.or(self.cooldown);
34
35 if self.first < 0.0 || self.first > end {
36 return;
37 }
38
39 let Some(cadence) = cadence else {
40 visit(self.first);
41
42 return;
43 };
44
45 if cadence <= 0.0 {
46 return;
47 }
48
49 let mut at = self.first;
50
51 while at <= end {
52 visit(at);
53 at += cadence;
54 }
55 }
56}
57
58#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
59#[serde(tag = "kind", rename_all = "snake_case")]
60#[non_exhaustive]
61pub enum RaidEventKind {
62 Movement,
63 Stun,
64 PlayerDamage {
65 amount: f64,
66 },
67 Invulnerable {
68 #[serde(default)]
69 target: usize,
70 },
71 Adds {
72 target: usize,
73 },
74 EnemyCasting {
75 #[serde(default)]
76 target: usize,
77 spell_id: u32,
78 school_mask: i32,
79 },
80 Interrupt {
81 #[serde(default)]
82 lockout_ms: u32,
83 },
84}
85
86#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
87pub struct RaidEventConfig {
88 #[serde(flatten)]
89 pub schedule: TimedEventSchedule,
90 #[serde(flatten)]
91 pub event: RaidEventKind,
92}
93
94#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
95pub struct ExternalBuffConfig {
96 pub spell_id: u32,
97 #[serde(flatten)]
98 pub schedule: TimedEventSchedule,
99}
100
101#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
103#[non_exhaustive]
104pub enum WeaponEnchantStat {
105 #[default]
106 None,
107 Crit,
108 Haste,
109 Mastery,
110 Versatility,
111 PrimaryStat,
112 PrimaryStatPercent,
113}
114
115#[derive(Clone, Debug)]
117pub struct WeaponEnchantProc {
118 pub enchantment_id: u32,
119 pub rppm: f64,
120 pub haste_scales: bool,
121 pub crit_scales: bool,
122 pub buff_spell_id: u32,
123 pub buff_duration_ms: u32,
124 pub buff_stat: WeaponEnchantStat,
125 pub buff_amount: f64,
127 pub name: String,
128}
129
130#[derive(Clone, Debug)]
132pub struct EquippedItem {
133 pub slot: GearSlot,
134 pub item_id: u32,
135 pub item_level: i32,
136 pub quality: i32,
137 pub enchant_id: Option<u32>,
138 pub effects: Vec<ItemEffect>,
139}
140
141#[derive(Clone, Debug)]
143pub struct TalentSelection {
144 pub spell_id: u32,
145 pub override_spell_id: u32,
147 pub replaces_spell_id: u32,
149 pub ranks: u8,
150 pub precombat_aura: bool,
151 pub hero_tree: Option<String>,
152 pub effect_overrides: Vec<TalentEffectOverride>,
153}
154
155impl TalentSelection {
156 #[must_use]
158 pub const fn addresses(&self, spell_id: u32) -> bool {
159 self.spell_id == spell_id
160 || (self.override_spell_id != 0 && self.override_spell_id == spell_id)
161 }
162
163 #[must_use]
165 pub const fn override_spell(&self) -> Option<u32> {
166 if self.override_spell_id == 0 || self.override_spell_id == self.spell_id {
167 None
168 } else {
169 Some(self.override_spell_id)
170 }
171 }
172
173 #[must_use]
175 pub const fn replaced_spell(&self) -> Option<u32> {
176 if self.replaces_spell_id == 0 || self.addresses(self.replaces_spell_id) {
177 None
178 } else {
179 Some(self.replaces_spell_id)
180 }
181 }
182}
183
184#[derive(Clone, Copy, Debug)]
186pub struct TalentEffectOverride {
187 pub effect_index: i32,
188 pub operation: wowlab_types::data::TraitEffectOperation,
189 pub value: f64,
190}
191
192#[derive(Clone, Copy, Debug, Default)]
194#[expect(
195 clippy::struct_excessive_bools,
196 reason = "independent consumable toggles form a compact configuration DTO"
197)]
198pub struct ConsumableFlags {
199 pub bloodlust: bool,
200 pub pre_pot_tempered: bool,
201 pub flask: bool,
202 pub augment_rune: bool,
203}
204
205#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub struct CastLatency {
208 pub queue_ms: u32,
209 pub gcd_ms: u32,
210 pub channel_ms: u32,
211 pub queue_window_ms: u32,
212 pub strict_gcd_queue: bool,
213}
214
215impl Default for CastLatency {
216 fn default() -> Self {
217 Self {
218 queue_ms: DEFAULT_QUEUE_LATENCY_MS,
219 gcd_ms: DEFAULT_GCD_LATENCY_MS,
220 channel_ms: DEFAULT_CHANNEL_LATENCY_MS,
221 queue_window_ms: DEFAULT_SPELL_QUEUE_WINDOW_MS,
222 strict_gcd_queue: false,
223 }
224 }
225}
226
227#[derive(Debug)]
229#[expect(
230 clippy::struct_excessive_bools,
231 reason = "handler construction preserves independent run configuration flags"
232)]
233pub struct HandlerParams<'a> {
234 pub game_data: ResolvedGameData,
235 pub rotation: &'a Rotation,
236 pub stats: &'a CombatStats,
237 pub talent_selections: &'a [TalentSelection],
238 pub encounter: &'a ResolvedEncounter,
239 pub fight_duration_secs: f64,
240 pub equipped_items: &'a [EquippedItem],
241 pub set_bonus_auras: &'a [u32],
242 pub bloodlust: bool,
243 pub pre_pot_tempered: bool,
244 pub flask: bool,
245 pub augment_rune: bool,
246 pub race: RaceId,
247 pub weapon_enchant_procs: &'a [WeaponEnchantProc],
248 pub bugs: crate::game_bugs::BugSettings,
249 pub cast_latency: CastLatency,
250 pub raid_events: &'a [RaidEventConfig],
251 pub external_buffs: &'a [ExternalBuffConfig],
252}
253
254impl<'a> HandlerParams<'a> {
255 pub fn validate(self) -> Result<Self, EngineError> {
259 self.encounter.validate_game_data(&self.game_data)?;
260
261 Ok(self)
262 }
263
264 #[must_use]
266 pub fn effect_base_points(&self, effect: (u32, u8)) -> f64 {
267 self.game_data
268 .base_points(SpellIdx::from_raw(effect.0), effect.1)
269 }
270
271 pub fn require_effect_base_points(&self, effect: (u32, u8)) -> Result<f64, EngineError> {
275 Ok(self
276 .game_data
277 .require_base_points(SpellIdx::from_raw(effect.0), effect.1)?)
278 }
279
280 #[must_use]
282 pub fn talent_selected(&self, spell_id: u32) -> bool {
283 self.talent_picked(spell_id)
284 || self
285 .game_data
286 .is_specialization_spell(SpellIdx::from_raw(spell_id))
287 }
288
289 #[must_use]
291 pub fn talent_ranked_or_specialization(&self, spell_id: u32) -> bool {
292 self.talent_ranks(spell_id) > 0
293 || self
294 .game_data
295 .is_specialization_spell(SpellIdx::from_raw(spell_id))
296 }
297
298 #[must_use]
300 pub fn talent_picked(&self, spell_id: u32) -> bool {
301 self.talent_selections
302 .iter()
303 .any(|talent| talent.addresses(spell_id))
304 }
305
306 #[must_use]
308 pub fn talent_picked_any(&self, spell_ids: &[u32]) -> bool {
309 self.talent_selections
310 .iter()
311 .any(|talent| spell_ids.iter().any(|&id| talent.addresses(id)))
312 }
313
314 #[must_use]
316 pub fn talent_ranks(&self, spell_id: u32) -> u8 {
317 self.talent_selections
318 .iter()
319 .find(|talent| talent.addresses(spell_id))
320 .map_or(0, |talent| talent.ranks)
321 }
322
323 #[must_use]
325 pub fn with_bugs(mut self, bugs: crate::game_bugs::BugSettings) -> Self {
326 self.bugs = bugs;
327
328 self
329 }
330
331 #[must_use]
332 pub fn with_cast_latency(mut self, cast_latency: CastLatency) -> Self {
333 self.cast_latency = cast_latency;
334
335 self
336 }
337
338 #[must_use]
339 pub fn with_timed_events(
340 mut self,
341 raid_events: &'a [RaidEventConfig],
342 external_buffs: &'a [ExternalBuffConfig],
343 ) -> Self {
344 self.raid_events = raid_events;
345 self.external_buffs = external_buffs;
346
347 self
348 }
349}
350
351pub struct SpecDescriptor {
353 pub spec_id: SpecId,
354 pub display_name: &'static str,
355 pub metadata: DeclaredSpecMetadata,
356 pub handler_factory: fn(HandlerParams<'_>) -> Result<Box<dyn SpecHandler>, EngineError>,
357}
358
359impl std::fmt::Debug for SpecDescriptor {
360 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
361 f.debug_struct("SpecDescriptor")
362 .field("spec_id", &self.spec_id)
363 .field("display_name", &self.display_name)
364 .field("metadata", &self.metadata)
365 .finish_non_exhaustive()
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use googletest::prelude::*;
372
373 use super::*;
374
375 const ROW_586_ARMOR: f64 = 377.0;
376 const ROW_586_ARMOR_CONSTANT: f64 = 980.543_f32 as f64;
377
378 fn combat_game_data(
379 creature_armor: f64,
380 armor_constant: f64,
381 armor_constant_mod: f64,
382 ) -> ResolvedGameData {
383 let mut builder = ResolvedGameData::builder();
384
385 builder.set_creature_armor(creature_armor);
386 builder.set_armor_constant(armor_constant);
387 builder.set_armor_constant_mod(armor_constant_mod);
388
389 builder.build()
390 }
391
392 fn test_handler_params<'a>(
393 game_data: ResolvedGameData,
394 rotation: &'a Rotation,
395 stats: &'a CombatStats,
396 encounter: &'a ResolvedEncounter,
397 ) -> HandlerParams<'a> {
398 HandlerParams {
399 game_data,
400 rotation,
401 stats,
402 talent_selections: &[],
403 encounter,
404 fight_duration_secs: encounter.fight_duration_secs(),
405 equipped_items: &[],
406 set_bonus_auras: &[],
407 bloodlust: false,
408 pre_pot_tempered: false,
409 flask: false,
410 augment_rune: false,
411 race: RaceId::Human,
412 weapon_enchant_procs: &[],
413 bugs: crate::BugSettings::default(),
414 cast_latency: CastLatency::default(),
415 raid_events: &[],
416 external_buffs: &[],
417 }
418 }
419
420 fn rejected_combat_game_data(
421 game_data: ResolvedGameData,
422 ) -> Result<crate::EncounterConstructionError> {
423 let encounter = crate::test_support::introspection_fixture(45.0).or_fail()?;
424 let stats = CombatStats::default();
425 let rotation = Rotation::empty();
426 let error = test_handler_params(game_data, &rotation, &stats, &encounter)
427 .validate()
428 .err()
429 .or_fail()?;
430
431 error.into_encounter_construction().or_fail()
432 }
433
434 #[gtest]
435 fn handler_params_accept_exact_row_586_combat_stats() -> Result<()> {
436 let encounter = crate::test_support::introspection_fixture(45.0).or_fail()?;
437 let game_data = crate::test_support::introspection_game_data(&encounter);
438 let stats = CombatStats::default();
439
440 let rotation = Rotation::empty();
441 let params = test_handler_params(game_data, &rotation, &stats, &encounter)
442 .validate()
443 .or_fail()?;
444
445 verify_true!(std::ptr::eq(params.encounter, &raw const encounter))?;
446
447 verify_that!(params.fight_duration_secs, eq(45.0))
448 }
449
450 #[gtest]
451 fn handler_params_reject_arbitrary_positive_creature_armor() -> Result<()> {
452 let error =
453 rejected_combat_game_data(combat_game_data(378.0, ROW_586_ARMOR_CONSTANT, 1.0))?;
454
455 verify_true!(error.is_combat_creature_armor_mismatch())
456 }
457
458 #[gtest]
459 fn handler_params_reject_zero_modifier_even_when_raw_constant_matches() -> Result<()> {
460 let error = rejected_combat_game_data(combat_game_data(
461 ROW_586_ARMOR,
462 ROW_586_ARMOR_CONSTANT,
463 0.0,
464 ))?;
465
466 verify_true!(error.is_invalid_combat_armor_constant_mod())
467 }
468
469 #[gtest]
470 fn handler_params_reject_nan_armor_constant() -> Result<()> {
471 let error = rejected_combat_game_data(combat_game_data(ROW_586_ARMOR, f64::NAN, 1.0))?;
472
473 verify_true!(error.is_invalid_combat_armor_constant())
474 }
475
476 #[gtest]
477 fn handler_params_reject_infinite_armor_constant_modifier() -> Result<()> {
478 let error = rejected_combat_game_data(combat_game_data(
479 ROW_586_ARMOR,
480 ROW_586_ARMOR_CONSTANT,
481 f64::INFINITY,
482 ))?;
483
484 verify_true!(error.is_invalid_combat_armor_constant_mod())
485 }
486
487 #[gtest]
488 fn handler_params_reject_infinite_effective_armor_constant() -> Result<()> {
489 let error = rejected_combat_game_data(combat_game_data(ROW_586_ARMOR, f64::MAX, 2.0))?;
490
491 verify_true!(error.is_combat_effective_armor_constant_mismatch())
492 }
493
494 #[gtest]
495 fn handler_params_reject_wrong_effective_armor_constant() -> Result<()> {
496 let error = rejected_combat_game_data(combat_game_data(
497 ROW_586_ARMOR,
498 ROW_586_ARMOR_CONSTANT,
499 1.25,
500 ))?;
501
502 verify_true!(error.is_combat_effective_armor_constant_mismatch())
503 }
504
505 #[gtest]
506 fn timed_event_occurrences_are_deterministic_and_bounded() -> Result<()> {
507 let schedule = TimedEventSchedule {
508 first: 2.0,
509 last: Some(9.0),
510 interval: Some(3.0),
511 cooldown: Some(99.0),
512 duration: 1.0,
513 };
514
515 let mut occurrences = Vec::new();
516
517 schedule.visit_occurrences(30.0, |at| occurrences.push(at));
518
519 verify_that!(occurrences, elements_are![eq(&2.0), eq(&5.0), eq(&8.0)])
520 }
521
522 #[gtest]
523 fn timed_event_cooldown_is_the_interval_fallback() -> Result<()> {
524 let schedule = TimedEventSchedule {
525 first: 1.0,
526 last: None,
527 interval: None,
528 cooldown: Some(4.0),
529 duration: 2.0,
530 };
531
532 let mut occurrences = Vec::new();
533
534 schedule.visit_occurrences(10.0, |at| occurrences.push(at));
535
536 verify_that!(occurrences, elements_are![eq(&1.0), eq(&5.0), eq(&9.0)])
537 }
538}