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    #[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/// Stat bucket a weapon-enchant proc buff grants.
104#[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/// Resolved TWW weapon-enchant proc ready for post-build registration.
118#[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    /// Raw stat value: rating for secondary-stat buckets, flat amount for primary stat.
128    pub buff_amount: f64,
129    pub name: String,
130}
131
132/// One equipped item carried into handler construction: resolved ilvl/quality (for item-budget-scaled buff magnitudes) plus its DBC use/equip effects.
133#[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/// One talent picked from a decoded loadout.
144#[derive(Clone, Debug)]
145pub struct TalentSelection {
146    pub spell_id: u32,
147    /// `TraitDefinition.VisibleSpellID`, zero when absent; `SimC`'s `id_override_spell`.
148    pub override_spell_id: u32,
149    /// `TraitDefinition.OverridesSpellID`, zero when absent; the action-bar spell this talent replaces.
150    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    /// Whether this selection is addressed by `spell_id`, through either of its two ids.
159    #[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    /// The node's override id when it has one, otherwise nothing.
166    #[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    /// The distinct action displaced by this selected node, when `OverridesSpellID` names one.
176    #[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/// Rank-specific adjustment for one effect on a selected talent spell.
187#[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/// Raid/consumable buffs toggled on for a sim run.
195#[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/// Deterministic mean input latency used by the cast scheduler.
208#[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/// Named parameters for building a spec handler.
230#[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    /// Validates handler parameters after struct-literal construction.
258    /// # Errors
259    /// Returns an error when encounter and game-data armor inputs disagree.
260    pub fn validate(self) -> Result<Self, EngineError> {
261        self.encounter.validate_game_data(&self.game_data)?;
262
263        Ok(self)
264    }
265
266    /// Reads base points for a named manifest effect binding.
267    #[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    /// Requires base points for a named manifest effect binding.
274    /// # Errors
275    /// Returns an error when populated game data lacks the requested effect.
276    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    /// Whether a talent is selected or granted as a baseline specialization spell.
283    #[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    /// Whether a ranked selection is active or the spell is granted by the specialization.
292    #[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    /// Whether a talent appears in the decoded talent selections, by node spell or override spell.
301    #[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    /// Whether any one of a set of equivalent talent spell IDs is selected.
309    #[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    /// Number of selected ranks for a talent, or zero when it is not selected.
317    #[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    /// Replace the run's live-game bug toggles.
326    #[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
353/// Per-spec registration record held by the engine-content catalog.
354pub 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}