Skip to main content

wowlab_engine_combat/builder/combat_builder/
mod.rs

1use wowlab_engine_domain::rotation::CatalogHints;
2use wowlab_engine_gamedata::ResolvedGameData;
3use wowlab_engine_ports::{CombatStats, HeroTalentTreeDesc, ResolvedEncounter};
4use wowlab_types::{
5    game::{GearSlot, SpecId},
6    sim::{FastMap, IntMap, SpellIdx},
7};
8
9use super::{
10    AutoAttackDefinitionDraft, BuilderError, CombatBuildError, ImpactEffectProcDefinition,
11    def::{BuilderAuraDef, BuilderSpellDef, DEFAULT_RESOURCE_MAX},
12};
13use crate::state::{
14    AccumulatingImpactProc, CastHookFn, EmpowerReleaseHookFn, ImpactProc, LandedImpactProc,
15    LocalAuraIdx, LocalRppmIdx, LocalThresholdIdx, ResourceGainProc, RppmTracker, ThresholdTracker,
16};
17
18mod build;
19mod entities;
20mod talents;
21
22/// DBC-selected multiplicative coordinates for an RPPM tracker.
23#[derive(Clone, Copy, Debug, Default)]
24pub struct RppmScalingOptions {
25    pub haste: bool,
26    pub crit: bool,
27    pub auto_attack_speed: bool,
28}
29
30/// Fluent builder for constructing a combat system.
31#[derive(Debug)]
32#[must_use]
33pub struct CombatSystemBuilder {
34    spec_id: Option<SpecId>,
35    stats: CombatStats,
36    spells: Vec<BuilderSpellDef>,
37    cast_hooks: Vec<Option<CastHookFn>>,
38    empower_release_hooks: Vec<Option<EmpowerReleaseHookFn>>,
39    tick_hooks: Vec<Option<CastHookFn>>,
40    player_cast_hooks: Vec<CastHookFn>,
41    aura_defs: Vec<BuilderAuraDef>,
42    auto_attacks: Vec<AutoAttackDefinitionDraft>,
43    resource_max: f64,
44    resource_regen: f64,
45    resource_start: f64,
46    resource_name: String,
47    resource_type_id: u8,
48    secondary_resource_name: Option<String>,
49    secondary_resource_max: f64,
50    secondary_resource_type_id: u8,
51    spell_ids: FastMap<String, u32>,
52    aura_ids: FastMap<String, u32>,
53    hints: CatalogHints,
54    has_pet: bool,
55    game_data: ResolvedGameData,
56    precombat_auras: Vec<LocalAuraIdx>,
57    talent_aura_stacks: Vec<(LocalAuraIdx, u8)>,
58    talent_spell_ids: Vec<u32>,
59    talent_names_by_id: IntMap<u32, String>,
60    selected_talent_spell_ids: Vec<u32>,
61    selected_replaced_spell_ids: Vec<u32>,
62    selected_talent_ranks: IntMap<u32, u8>,
63    selected_hero_trees: Vec<Box<str>>,
64    hero_talent_trees: &'static [HeroTalentTreeDesc],
65    stealth_aura_id: Option<u32>,
66    encounter: Option<ResolvedEncounter>,
67    item_use_spells: Vec<(GearSlot, u32)>,
68    rppm_trackers: Vec<RppmTracker>,
69    threshold_trackers: Vec<ThresholdTracker>,
70    item_rppm_indices: IntMap<u32, LocalRppmIdx>,
71    impact_procs: Vec<ImpactProc>,
72    landed_impact_procs: Vec<LandedImpactProc>,
73    accumulating_impact_procs: Vec<AccumulatingImpactProc>,
74    resource_gain_procs: Vec<ResourceGainProc>,
75    impact_effect_procs: Vec<ImpactEffectProcDefinition>,
76    mastery_hook: crate::systems::MasteryFn,
77    mastery_crit_damage: crate::systems::MasteryFn,
78    mastery_spell: SpellIdx,
79    pending_error: Option<BuilderError>,
80}
81
82fn resolve_resource_type(type_id: u8, name: &str) -> Option<wowlab_types::combat::ResourceType> {
83    if name.is_empty() {
84        return None;
85    }
86
87    wowlab_types::combat::ResourceType::try_from(type_id)
88        .ok()
89        .or_else(|| name.parse().ok())
90}
91
92impl CombatSystemBuilder {
93    pub(crate) fn new(stats: CombatStats) -> Self {
94        Self {
95            spec_id: None,
96            stats,
97            spells: Vec::new(),
98            cast_hooks: Vec::new(),
99            empower_release_hooks: Vec::new(),
100            tick_hooks: Vec::new(),
101            player_cast_hooks: Vec::new(),
102            aura_defs: Vec::new(),
103            auto_attacks: Vec::new(),
104            resource_max: DEFAULT_RESOURCE_MAX,
105            resource_regen: 0.0,
106            resource_start: -1.0,
107            resource_name: String::new(),
108            resource_type_id: 0,
109            secondary_resource_name: None,
110            secondary_resource_max: 0.0,
111            secondary_resource_type_id: 0,
112            spell_ids: FastMap::default(),
113            aura_ids: FastMap::default(),
114            hints: CatalogHints::default(),
115            has_pet: false,
116            game_data: ResolvedGameData::default(),
117            precombat_auras: Vec::new(),
118            talent_aura_stacks: Vec::new(),
119            talent_spell_ids: Vec::new(),
120            talent_names_by_id: IntMap::default(),
121            selected_talent_spell_ids: Vec::new(),
122            selected_replaced_spell_ids: Vec::new(),
123            selected_talent_ranks: IntMap::default(),
124            selected_hero_trees: Vec::new(),
125            hero_talent_trees: &[],
126            stealth_aura_id: None,
127            encounter: None,
128            item_use_spells: Vec::new(),
129            rppm_trackers: Vec::new(),
130            threshold_trackers: Vec::new(),
131            item_rppm_indices: IntMap::default(),
132            impact_procs: Vec::new(),
133            landed_impact_procs: Vec::new(),
134            accumulating_impact_procs: Vec::new(),
135            resource_gain_procs: Vec::new(),
136            impact_effect_procs: Vec::new(),
137            mastery_hook: crate::systems::no_damage_bonus,
138            mastery_crit_damage: crate::systems::no_crit_damage,
139            mastery_spell: SpellIdx(0),
140            pending_error: None,
141        }
142    }
143
144    #[cfg(test)]
145    pub(crate) fn new_for_test(stats: CombatStats) -> Self {
146        Self::new(stats).encounter(crate::test_support::default_encounter())
147    }
148
149    /// Validate and lower the definition into immutable combat state.
150    ///
151    /// # Errors
152    ///
153    /// Returns an engine construction error when the catalog is invalid or the rotation cannot be compiled.
154    pub fn build(
155        self,
156        rotation: impl std::borrow::Borrow<wowlab_types::sim::Rotation>,
157    ) -> Result<super::BuiltCombatSystem, CombatBuildError> {
158        self.finish_build(rotation.borrow()).into_result()
159    }
160
161    pub fn register_impact_proc(mut self, proc: ImpactProc) -> Self {
162        self.impact_procs.push(proc);
163
164        self
165    }
166
167    pub fn register_landed_impact_proc(mut self, proc: LandedImpactProc) -> Self {
168        self.landed_impact_procs.push(proc);
169
170        self
171    }
172
173    pub fn register_impact_effect_proc(mut self, proc: ImpactEffectProcDefinition) -> Self {
174        self.impact_effect_procs.push(proc);
175
176        self
177    }
178
179    pub fn register_impact_effect_proc_if_talent(
180        self,
181        talent_spell_id: u32,
182        proc: ImpactEffectProcDefinition,
183    ) -> Self {
184        if self.selected_talent_spell_ids.contains(&talent_spell_id) {
185            self.register_impact_effect_proc(proc)
186        } else {
187            self
188        }
189    }
190
191    pub fn mastery_hook(mut self, hook: crate::systems::MasteryFn) -> Self {
192        self.mastery_hook = hook;
193
194        self
195    }
196
197    pub fn mastery_crit_damage_hook(mut self, hook: crate::systems::MasteryFn) -> Self {
198        self.mastery_crit_damage = hook;
199
200        self
201    }
202
203    pub fn mastery_spell(mut self, spell_id: u32) -> Self {
204        self.mastery_spell = SpellIdx(spell_id);
205
206        self
207    }
208
209    // docref:start procs-register-item-rppm
210    pub fn register_item_rppm(mut self, item_id: u32, rppm: f64, haste_scales: bool) -> Self {
211        let idx = self.rppm(rppm, haste_scales);
212        self.item_rppm_indices.insert(item_id, idx);
213        self
214    }
215    /// Register an item's RPPM tracker from its resolved DBC driver.
216    pub fn register_item_rppm_from_data(mut self, item_id: u32, driver_spell_id: u32) -> Self {
217        if self.pending_error.is_some() {
218            return self;
219        }
220        let driver = SpellIdx::from_raw(driver_spell_id);
221        let Some((rppm, _)) = self
222            .game_data
223            .rppm(driver)
224            .filter(|(rppm, _)| rppm.is_finite() && *rppm > 0.0)
225        else {
226            if !self.game_data.is_empty() {
227                self.pending_error = Some(BuilderError::missing_item_rppm_data(
228                    item_id,
229                    driver_spell_id,
230                ));
231            }
232            return self;
233        };
234        let scaling = RppmScalingOptions {
235            haste: self.game_data.rppm_haste_scales(driver).unwrap_or(false),
236            crit: self.game_data.rppm_crit_scales(driver).unwrap_or(false),
237            auto_attack_speed: false,
238        };
239        let idx = self.scaled_rppm(rppm, scaling);
240        self.item_rppm_indices.insert(item_id, idx);
241        self
242    }
243    // docref:end procs-register-item-rppm
244
245    /// Registers an RPPM tracker.
246    ///
247    /// # Panics
248    ///
249    /// Panics if the tracker registry exceeds the local-index capacity.
250    pub fn rppm(&mut self, rppm: f64, haste_scales: bool) -> LocalRppmIdx {
251        let idx = u8::try_from(self.rppm_trackers.len()).expect("RPPM tracker count fits in u8");
252
253        self.rppm_trackers.push(RppmTracker {
254            rppm,
255            last_attempt_time: 0.0,
256            last_proc_time: 0.0,
257            accumulated_blp: 0.0,
258            haste_scales,
259            crit_scales: false,
260            auto_attack_speed_scales: false,
261            blp_enabled: true,
262        });
263
264        LocalRppmIdx::new(idx)
265    }
266
267    /// Registers an RPPM tracker with all supported rate-scaling coordinates.
268    ///
269    /// # Panics
270    ///
271    /// Panics if the tracker registry exceeds the local-index capacity.
272    pub fn scaled_rppm(&mut self, rppm: f64, scaling: RppmScalingOptions) -> LocalRppmIdx {
273        let idx = self.rppm(rppm, scaling.haste);
274        let tracker = self
275            .rppm_trackers
276            .get_mut(idx.as_usize())
277            .expect("newly registered RPPM tracker remains addressable");
278
279        tracker.crit_scales = scaling.crit;
280        tracker.auto_attack_speed_scales = scaling.auto_attack_speed;
281
282        idx
283    }
284
285    /// Registers an accumulated-threshold proc source.
286    ///
287    /// # Panics
288    ///
289    /// Panics if the threshold registry exceeds the local-index capacity.
290    pub fn threshold(&mut self, increment_max: f64, roll_over: bool) -> LocalThresholdIdx {
291        let idx = u8::try_from(self.threshold_trackers.len())
292            .expect("threshold tracker count fits in u8");
293
294        self.threshold_trackers.push(ThresholdTracker {
295            increment_max,
296            // NaN sentinel: the first roll randomizes the initial state.
297            accumulated: f64::NAN,
298            roll_over,
299        });
300
301        LocalThresholdIdx::new(idx)
302    }
303
304    pub fn spec_id(mut self, id: SpecId) -> Self {
305        self.spec_id = Some(id);
306
307        self
308    }
309
310    pub fn hero_talent_trees(mut self, trees: &'static [HeroTalentTreeDesc]) -> Self {
311        self.hero_talent_trees = trees;
312
313        self
314    }
315
316    pub fn resource(mut self, name: &str, max: f64, regen: f64) -> Self {
317        self.resource_name = name.to_string();
318        self.resource_max = max;
319        self.resource_regen = regen;
320
321        self
322    }
323
324    /// Set the starting resource amount. Negative means start at max.
325    pub fn resource_start(mut self, start: f64) -> Self {
326        self.resource_start = start;
327
328        self
329    }
330
331    pub fn resource_type_id(mut self, id: u8) -> Self {
332        self.resource_type_id = id;
333
334        self
335    }
336
337    pub fn secondary_resource(mut self, name: &str, max: f64) -> Self {
338        self.secondary_resource_name = Some(name.to_string());
339        self.secondary_resource_max = max;
340
341        self
342    }
343
344    pub fn secondary_resource_type_id(mut self, id: u8) -> Self {
345        self.secondary_resource_type_id = id;
346
347        self
348    }
349
350    pub fn has_pet(mut self, val: bool) -> Self {
351        self.has_pet = val;
352
353        self
354    }
355
356    pub fn game_data(mut self, data: ResolvedGameData) -> Self {
357        self.game_data = data;
358
359        self
360    }
361
362    pub fn precombat_aura(mut self, aura: LocalAuraIdx) -> Self {
363        self.precombat_auras.push(aura);
364
365        self
366    }
367
368    /// Register a precombat aura by game id, resolved against the auras registered so far (item auras have no codegen-time-stable global local index).
369    ///
370    /// # Panics
371    ///
372    /// Panics if the validated aura registry exceeds the local-index capacity.
373    pub fn precombat_aura_id(mut self, aura_id: u32) -> Self {
374        if self.pending_error.is_some() {
375            return self;
376        }
377
378        match self.aura_defs.iter().position(|a| a.aura_id == aura_id) {
379            Some(pos) => self.precombat_auras.push(LocalAuraIdx::new(
380                u8::try_from(pos).expect("aura count fits in u8"),
381            )),
382            None => {
383                self.pending_error = Some(
384                    crate::builder::BuilderErrorKind::GameData(
385                        wowlab_engine_ports::EngineError::spec_construction(format!(
386                            "precombat_aura_id: aura {aura_id} is not registered"
387                        )),
388                    )
389                    .into(),
390                );
391            }
392        }
393
394        self
395    }
396
397    pub fn stealth_aura(mut self, aura_id: u32) -> Self {
398        self.stealth_aura_id = Some(aura_id);
399
400        self
401    }
402
403    pub fn encounter(mut self, encounter: ResolvedEncounter) -> Self {
404        self.encounter = Some(encounter);
405
406        self
407    }
408
409    pub fn item_use_spell(mut self, gear_slot: GearSlot, spell_id: u32) -> Self {
410        self.item_use_spells.push((gear_slot, spell_id));
411
412        if let Some(name) = gear_slot.use_alias() {
413            self.spell_ids.insert(name.to_string(), spell_id);
414            self.hints.cooldown_spells.push(name.to_string());
415        }
416
417        self
418    }
419
420    /// Register a hook fired on every player cast, after the per-spell hook.
421    pub fn on_player_cast(mut self, hook: CastHookFn) -> Self {
422        self.player_cast_hooks.push(hook);
423
424        self
425    }
426}