Skip to main content

wowlab_engine_ports/
spec_registry.rs

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/// Stat bucket a weapon-enchant proc buff grants.
102#[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/// Resolved TWW weapon-enchant proc ready for draft composition before finalization.
116#[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    /// Raw stat value: rating for secondary-stat buckets, flat amount for primary stat.
126    pub buff_amount: f64,
127    pub name: String,
128}
129
130/// One equipped item carried into handler construction: resolved ilvl/quality (for item-budget-scaled buff magnitudes) plus its DBC use/equip effects.
131#[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/// One talent picked from a decoded loadout.
142#[derive(Clone, Debug)]
143pub struct TalentSelection {
144    pub spell_id: u32,
145    /// `TraitDefinition.VisibleSpellID`, zero when absent.
146    pub override_spell_id: u32,
147    /// `TraitDefinition.OverridesSpellID`, zero when absent; the action-bar spell this talent replaces.
148    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    /// Whether this selection is addressed by `spell_id`, through either of its two ids.
157    #[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    /// The node's override id when it has one, otherwise nothing.
164    #[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    /// The distinct action displaced by this selected node, when `OverridesSpellID` names one.
174    #[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/// Rank-specific adjustment for one effect on a selected talent spell.
185#[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/// Raid/consumable buffs toggled on for a sim run.
193#[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/// Deterministic mean input latency used by the cast scheduler.
206#[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/// Named parameters for building a spec handler.
228#[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    /// Validates handler parameters after struct-literal construction.
256    /// # Errors
257    /// Returns an error when encounter and game-data armor inputs disagree.
258    pub fn validate(self) -> Result<Self, EngineError> {
259        self.encounter.validate_game_data(&self.game_data)?;
260
261        Ok(self)
262    }
263
264    /// Reads base points for a named manifest effect binding.
265    #[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    /// Requires base points for a named manifest effect binding.
272    /// # Errors
273    /// Returns an error when populated game data lacks the requested effect.
274    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    /// Whether a talent is selected or granted as a baseline specialization spell.
281    #[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    /// Whether a ranked selection is active or the spell is granted by the specialization.
290    #[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    /// Whether a talent appears in the decoded talent selections, by node spell or override spell.
299    #[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    /// Whether any one of a set of equivalent talent spell IDs is selected.
307    #[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    /// Number of selected ranks for a talent, or zero when it is not selected.
315    #[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    /// Replace the run's live-game bug toggles.
324    #[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
351/// Per-spec registration record held by the engine-content catalog.
352pub 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}