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