Skip to main content

wowlab_engine_combat/builder/combat_builder/
mod.rs

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