Skip to main content

wowlab_engine_combat/builder/
built.rs

1use wowlab_engine_domain::rotation::{DenseBuffer, RotationEngine};
2use wowlab_engine_ports::EngineError;
3use wowlab_types::{
4    constants::MS_PER_SECOND,
5    sim::{AuraOn, SpellIdx},
6};
7
8use super::{BuilderError, CombatSystemBuilder};
9use crate::{
10    AccumulatingImpactProc, CastHookFn, DriverSpellModifier, ImpactProc, LandedImpactProc,
11    LocalAuraIdx, LocalSpellIdx, LocalThresholdIdx, ResourceGainProc, RppmTracker, SpellEffectData,
12    ThresholdTracker,
13    state::{
14        AuraData, AutoAttackData, BaseStats, BuffEffect, CombatState, ImpactEffectProc,
15        LocalRppmIdx, PetActionBar, ScratchBuffers, SpellData, WeaponImbueBinding, try_push_effect,
16    },
17};
18
19/// Output of a successful [`crate::CombatSystemBuilder::build`] call.
20#[derive(Debug)]
21#[must_use]
22// docref:start spec-handlers-built-combat-system
23pub struct BuiltCombatSystem {
24    pub state: CombatState,
25    pub rotation: RotationEngine,
26    pub buffer: DenseBuffer,
27    pub names: Vec<Box<str>>,
28}
29// docref:end spec-handlers-built-combat-system
30
31impl BuiltCombatSystem {
32    /// Start a combat-system definition for the supplied base stats.
33    pub fn builder(stats: wowlab_engine_ports::CombatStats) -> CombatSystemBuilder {
34        CombatSystemBuilder::new(stats)
35    }
36
37    /// Attach an action bar to the persistent pet identified by its creature id.
38    pub fn set_pet_action_bar(&mut self, npc_id: u32, action_bar: PetActionBar) -> bool {
39        let Some(attack) = self
40            .state
41            .defs
42            .auto_attacks
43            .iter_mut()
44            .find(|attack| attack.is_pet && attack.npc_id == Some(npc_id))
45        else {
46            return false;
47        };
48
49        attack.pet_action_bar = Some(action_bar);
50
51        true
52    }
53
54    pub fn disable_spell(&mut self, spell_id: u32) {
55        for spell in &self.state.defs.spells {
56            if spell.spell_id == spell_id {
57                if let Some(slot) = self.buffer.spell_mut(spell.idx()) {
58                    slot.is_enabled = 0;
59                }
60            }
61        }
62    }
63
64    /// Apply a build-time spell patch and atomically refresh its buffer projections.
65    pub fn patch_spell(
66        &mut self,
67        spell: LocalSpellIdx,
68        patch: impl FnOnce(&mut SpellData),
69    ) -> bool {
70        let Some(data) = self.state.defs.spells.get_mut(spell.as_usize()) else {
71            return false;
72        };
73        let identity = (data.spell_id, data.name_idx);
74
75        patch(data);
76        (data.spell_id, data.name_idx) = identity;
77        let data = *data;
78
79        if let Some(slot) = self.buffer.spell_mut(data.idx()) {
80            slot.cost = data.cost.resource_cost;
81            slot.secondary_cost = data.cost.secondary_resource_cost;
82            slot.cast_time = f64::from(data.cast_time_ms) / MS_PER_SECOND;
83            slot.travel_time = f64::from(super::buffer_init::projected_travel_time_ms(
84                &self.state,
85                &data,
86            )) / MS_PER_SECOND;
87            slot.gcd = f64::from(data.base_gcd_ms()) / MS_PER_SECOND;
88        }
89
90        if data.cooldown.has_cooldown || data.is_charged() {
91            crate::systems::reset_cooldown(&mut self.state, &mut self.buffer, spell);
92        }
93
94        true
95    }
96
97    /// Apply a build-time aura patch to the authoritative definition.
98    pub fn patch_aura(&mut self, aura: LocalAuraIdx, patch: impl FnOnce(&mut AuraData)) -> bool {
99        let Some(data) = self.state.defs.auras.get_mut(aura.as_usize()) else {
100            return false;
101        };
102        let identity = (data.aura_id, data.name_idx, data.on, data.propagates_to_pet);
103
104        patch(data);
105        (data.aura_id, data.name_idx, data.on, data.propagates_to_pet) = identity;
106
107        true
108    }
109
110    /// Register an aura added after the declarative combat system has been built.
111    ///
112    /// This synchronizes the name table, definition, spell-id index, and identity index.
113    /// Player auras also receive their persistent buffer slot immediately.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error when the runtime aura registry exceeds its local-index capacity.
118    pub fn register_aura(
119        &mut self,
120        name: impl Into<Box<str>>,
121        mut aura: AuraData,
122    ) -> Result<LocalAuraIdx, EngineError> {
123        aura.name_idx = u16::try_from(self.names.len()).map_err(|error| {
124            EngineError::spec_construction(format!(
125                "combat definition name registry exceeds u16 capacity: {error}"
126            ))
127        })?;
128        let local =
129            LocalAuraIdx::new(u8::try_from(self.state.defs.auras.len()).map_err(|error| {
130                EngineError::spec_construction(format!(
131                    "runtime aura registry exceeds u8 capacity: {error}"
132                ))
133            })?);
134        let aura_id = aura.aura_id;
135        let on = aura.on;
136        let propagates_to_pet = aura.propagates_to_pet;
137
138        self.names.push(name.into());
139        self.state.defs.auras.push(aura);
140        self.state.index.aura_by_id.insert(aura_id, local);
141        self.state
142            .index
143            .aura_by_identity
144            .entry((aura_id, on))
145            .or_insert(local);
146
147        if propagates_to_pet {
148            self.state
149                .index
150                .aura_by_identity
151                .entry((aura_id, AuraOn::Pet))
152                .or_insert(local);
153        }
154
155        super::buffer_init::ensure_aura_slots(&self.state, &mut self.buffer, aura_id, on);
156
157        Ok(local)
158    }
159
160    /// Registers a post-build impact proc.
161    pub fn register_impact_proc(&mut self, proc: ImpactProc) {
162        self.state.defs.impact_procs.push(proc);
163    }
164
165    /// Registers a post-build landed-impact proc.
166    pub fn register_landed_impact_proc(&mut self, proc: LandedImpactProc) {
167        self.state.defs.landed_impact_procs.push(proc);
168    }
169
170    /// Registers a post-build accumulating impact proc.
171    pub fn register_accumulating_impact_proc(&mut self, proc: AccumulatingImpactProc) {
172        self.state.defs.accumulating_impact_procs.push(proc);
173    }
174
175    /// Registers a post-build resource-gain proc.
176    pub fn register_resource_gain_proc(&mut self, proc: ResourceGainProc) {
177        self.state.defs.resource_gain_procs.push(proc);
178    }
179
180    /// Registers a post-build RPPM tracker.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error when the local tracker-index capacity is exhausted.
185    pub fn register_rppm_tracker(
186        &mut self,
187        tracker: RppmTracker,
188    ) -> Result<LocalRppmIdx, EngineError> {
189        let local = LocalRppmIdx::new(u8::try_from(self.state.defs.rppm_trackers.len()).map_err(
190            |error| {
191                EngineError::spec_construction(format!(
192                    "RPPM tracker registry exceeds u8 capacity: {error}"
193                ))
194            },
195        )?);
196
197        self.state.defs.rppm_trackers.push(tracker);
198
199        Ok(local)
200    }
201
202    /// Register one equipped weapon-enchant source and its shared RPPM tracker.
203    ///
204    /// Duplicate enchantment IDs return `false`; the first binding installs the impact dispatcher.
205    ///
206    /// # Errors
207    ///
208    /// Returns an error when the local tracker-index capacity is exhausted.
209    pub fn register_weapon_enchant_binding(
210        &mut self,
211        binding: crate::WeaponEnchantBinding,
212        tracker: RppmTracker,
213    ) -> Result<bool, EngineError> {
214        if self
215            .state
216            .index
217            .enchant_rppm_indices
218            .contains_key(&binding.enchantment_id)
219        {
220            return Ok(false);
221        }
222
223        let first = self.state.defs.weapon_enchant_bindings.is_empty();
224        let tracker = self.register_rppm_tracker(tracker)?;
225
226        self.state
227            .index
228            .enchant_rppm_indices
229            .insert(binding.enchantment_id, tracker);
230        self.state
231            .index
232            .enchant_buff_spell_ids
233            .insert(binding.enchantment_id, binding.buff_spell_id);
234        self.state.defs.weapon_enchant_bindings.push(binding);
235
236        if first {
237            self.register_impact_proc(ImpactProc::new(
238                crate::systems::weapon_enchants_on_player_impact,
239            ));
240        }
241
242        Ok(true)
243    }
244
245    /// Registers a post-build accumulated-threshold tracker.
246    ///
247    /// # Errors
248    ///
249    /// Returns an error when the local tracker-index capacity is exhausted.
250    pub fn register_threshold_tracker(
251        &mut self,
252        tracker: ThresholdTracker,
253    ) -> Result<LocalThresholdIdx, EngineError> {
254        let local = LocalThresholdIdx::new(
255            u8::try_from(self.state.defs.threshold_trackers.len()).map_err(|error| {
256                EngineError::spec_construction(format!(
257                    "threshold tracker registry exceeds u8 capacity: {error}"
258                ))
259            })?,
260        );
261
262        self.state.defs.threshold_trackers.push(tracker);
263
264        Ok(local)
265    }
266
267    /// Appends an aura to the precombat application set.
268    pub fn push_precombat_aura(&mut self, aura: LocalAuraIdx) {
269        self.state.defs.precombat_auras.push(aura);
270    }
271
272    /// Include or exclude an aura from the precombat application set.
273    pub fn set_precombat_aura(&mut self, aura: LocalAuraIdx, enabled: bool) {
274        if enabled {
275            if !self.state.defs.precombat_auras.contains(&aura) {
276                self.state.defs.precombat_auras.push(aura);
277            }
278        } else {
279            self.state
280                .defs
281                .precombat_auras
282                .retain(|candidate| *candidate != aura);
283        }
284    }
285
286    /// Removes an aura from every precombat talent projection.
287    pub fn remove_precombat_aura(&mut self, aura: LocalAuraIdx) {
288        self.state
289            .defs
290            .precombat_auras
291            .retain(|candidate| *candidate != aura);
292        self.state
293            .defs
294            .talent_aura_stacks
295            .retain(|(candidate, _)| *candidate != aura);
296    }
297
298    /// Registers a hook fired after every player cast.
299    pub fn register_player_cast_hook(&mut self, hook: CastHookFn) {
300        self.state.defs.player_cast_hooks.push(hook);
301    }
302
303    /// Returns a registered aura definition.
304    #[must_use]
305    pub fn aura(&self, aura: LocalAuraIdx) -> Option<&AuraData> {
306        self.state.defs.auras.get(aura.as_usize())
307    }
308
309    /// Returns a registered RPPM tracker.
310    #[must_use]
311    pub fn rppm_tracker(&self, tracker: LocalRppmIdx) -> Option<&RppmTracker> {
312        self.state.defs.rppm_trackers.get(tracker.as_usize())
313    }
314
315    /// Returns a registered impact proc.
316    #[must_use]
317    pub fn impact_proc(&self, index: usize) -> Option<&ImpactProc> {
318        self.state.defs.impact_procs.get(index)
319    }
320
321    /// Returns the number of registered impact procs.
322    #[must_use]
323    pub fn impact_proc_count(&self) -> usize {
324        self.state.defs.impact_procs.len()
325    }
326
327    /// Returns a registered lowered impact-effect proc.
328    #[must_use]
329    pub fn impact_effect_proc(&self, index: usize) -> Option<&ImpactEffectProc> {
330        self.state.defs.impact_effect_procs.get(index)
331    }
332
333    /// Returns the number of registered lowered impact-effect procs.
334    #[must_use]
335    pub fn impact_effect_proc_count(&self) -> usize {
336        self.state.defs.impact_effect_procs.len()
337    }
338
339    /// Returns the lowered spell-effect program arena for read-only inspection.
340    #[must_use]
341    pub fn spell_effects(&self) -> &[SpellEffectData] {
342        &self.state.defs.spell_effects
343    }
344
345    /// Returns the selected rank of a talent spell.
346    #[must_use]
347    pub fn talent_rank(&self, spell_id: u32) -> Option<u8> {
348        self.state.defs.talent_ranks.get(&spell_id).copied()
349    }
350
351    /// Returns the number of registered RPPM trackers.
352    #[must_use]
353    pub fn rppm_tracker_count(&self) -> usize {
354        self.state.defs.rppm_trackers.len()
355    }
356
357    /// Returns the number of registered player-cast hooks.
358    #[must_use]
359    pub fn player_cast_hook_count(&self) -> usize {
360        self.state.defs.player_cast_hooks.len()
361    }
362
363    /// Returns the number of registered spells.
364    #[must_use]
365    pub fn spell_count(&self) -> usize {
366        self.state.defs.spells.len()
367    }
368
369    /// Returns the first auto-attack definition.
370    #[must_use]
371    pub fn first_auto_attack(&self) -> Option<&AutoAttackData> {
372        self.state.defs.auto_attacks.first()
373    }
374
375    /// Applies a build-time patch to the registered auto-attack definitions.
376    pub fn patch_auto_attacks(&mut self, patch: impl FnOnce(&mut [AutoAttackData])) {
377        patch(&mut self.state.defs.auto_attacks);
378    }
379
380    /// Register a system-level RPPM source from its DBC driver spell.
381    ///
382    /// The definition and driver lookup are committed together.
383    /// Re-registering the same driver is idempotent and returns its existing local index.
384    ///
385    /// # Errors
386    ///
387    /// Returns a typed spec-construction error when the driver has no finite positive RPPM rate.
388    /// It also errors when the local tracker-index capacity is exhausted.
389    pub fn register_system_rppm_from_data(
390        &mut self,
391        driver_spell_id: u32,
392    ) -> Result<LocalRppmIdx, EngineError> {
393        if let Some(local) = self
394            .state
395            .index
396            .system_rppm_indices
397            .get(&driver_spell_id)
398            .copied()
399        {
400            return Ok(local);
401        }
402
403        let driver = SpellIdx::from_raw(driver_spell_id);
404        let Some((rppm, _)) = self.state.config.game_data.rppm(driver) else {
405            return Err(EngineError::spec_construction_source(
406                BuilderError::missing_system_rppm_data(driver_spell_id),
407            ));
408        };
409
410        if !rppm.is_finite() || rppm <= 0.0 {
411            return Err(EngineError::spec_construction_source(
412                BuilderError::invalid_system_rppm_data(driver_spell_id, rppm),
413            ));
414        }
415
416        let tracker = RppmTracker {
417            rppm,
418            last_attempt_time: 0.0,
419            last_proc_time: 0.0,
420            accumulated_blp: 0.0,
421            haste_scales: self
422                .state
423                .config
424                .game_data
425                .rppm_haste_scales(driver)
426                .unwrap_or(false),
427            crit_scales: self
428                .state
429                .config
430                .game_data
431                .rppm_crit_scales(driver)
432                .unwrap_or(false),
433            auto_attack_speed_scales: false,
434            blp_enabled: true,
435        };
436
437        let tracker_count = self.state.defs.rppm_trackers.len();
438
439        if let Err(source) = u8::try_from(tracker_count) {
440            return Err(EngineError::spec_construction_source(
441                BuilderError::system_rppm_registry_full(driver_spell_id, tracker_count, source),
442            ));
443        }
444
445        let local = self.register_rppm_tracker(tracker)?;
446
447        self.state
448            .index
449            .system_rppm_indices
450            .insert(driver_spell_id, local);
451
452        Ok(local)
453    }
454
455    /// Apply a build-time base-stat patch and refresh the primary resource projection.
456    pub fn patch_base_stats(&mut self, patch: impl FnOnce(&mut BaseStats)) {
457        patch(&mut self.state.config.base_stats);
458        let base = &self.state.config.base_stats;
459        let Some(resource_type) = base.resource_type else {
460            return;
461        };
462        let Some(resource) = self.buffer.resource_mut(resource_type) else {
463            return;
464        };
465
466        resource.max = base.resource_max;
467        resource.current = if base.resource_start >= 0.0 {
468            base.resource_start.min(base.resource_max)
469        } else {
470            base.resource_max
471        };
472        resource.regen_per_sec = base.base_regen;
473    }
474
475    /// Return the resolved baseline combat stats used by post-build content registration.
476    #[must_use]
477    pub fn base_combat_stats(&self) -> &wowlab_engine_ports::CombatStats {
478        &self.state.config.base_stats.stats
479    }
480
481    /// Appends a runtime effect to an already-built aura.
482    ///
483    /// # Panics
484    ///
485    /// Panics in debug builds if `aura` is not registered, or in all builds if its fixed-capacity effect list is full.
486    pub fn set_aura_effect(&mut self, aura: LocalAuraIdx, effect: BuffEffect) {
487        let Some(data) = self.state.defs.auras.get_mut(aura.0 as usize) else {
488            debug_assert!(false, "aura index {} out of bounds", aura.0);
489
490            return;
491        };
492        let result = try_push_effect(&mut data.effects, effect);
493
494        assert!(result.is_ok(), "no free effect slot for aura {}", aura.0);
495    }
496
497    /// Removes the rows an aura derived from one DBC spell effect, returning how many were cleared.
498    ///
499    /// The shared resolution consumes every modifier effect a live aura's spell declares.
500    ///   Some are scripted in game to apply only under a talent, or to be replaced by a different
501    ///   effect when one is picked, which is what `SimC` spells out per spec with
502    ///   `register_passive_effect_mask`.
503    ///   Content masks the effect here and then wires whatever its talent state calls for; leaving
504    ///   both in place makes every hit take the modifier twice.
505    pub fn mask_aura_dbc_effect(
506        &mut self,
507        aura: LocalAuraIdx,
508        source_spell_id: u32,
509        effect_index: u8,
510    ) -> usize {
511        let Some(data) = self.state.defs.auras.get_mut(aura.0 as usize) else {
512            debug_assert!(false, "aura index {} out of bounds", aura.0);
513
514            return 0;
515        };
516        let mut masked = 0;
517
518        for slot in &mut data.effects {
519            if slot
520                .as_ref()
521                .and_then(BuffEffect::dbc_source)
522                .is_some_and(|source| source == (source_spell_id, effect_index))
523            {
524                *slot = None;
525                masked += 1;
526            }
527        }
528
529        masked
530    }
531
532    /// Duplicates rows lowered from one DBC effect while preserving their resolved payload.
533    ///
534    /// Intended for modeled live-game bugs where the server applies the same modifier twice.
535    /// Content identifies only the source coordinate.
536    pub fn duplicate_aura_dbc_effect(
537        &mut self,
538        aura: LocalAuraIdx,
539        source_spell_id: u32,
540        effect_index: u8,
541    ) -> usize {
542        let Some(data) = self.state.defs.auras.get(aura.as_usize()) else {
543            debug_assert!(false, "aura index {} out of bounds", aura.raw());
544
545            return 0;
546        };
547        let effects: Vec<_> = data
548            .effects
549            .iter()
550            .flatten()
551            .copied()
552            .filter(|effect| effect.dbc_source() == Some((source_spell_id, effect_index)))
553            .collect();
554        let duplicated = effects.len();
555
556        for effect in effects {
557            self.set_aura_effect(aura, effect);
558        }
559
560        duplicated
561    }
562
563    /// Register an always-present spell modifier under its real spell/passive driver.
564    pub fn register_driver_spell_modifier(&mut self, modifier: DriverSpellModifier) {
565        self.state.defs.driver_spell_modifiers.push(modifier);
566    }
567
568    /// Register an always-present player effect under its real spell/passive driver.
569    pub fn register_passive_driver_effect(&mut self, effect: crate::PassiveDriverEffect) {
570        self.state.defs.passive_driver_effects.push(effect);
571    }
572
573    /// Register a real weapon-imbue action and initialize its persistent equipment state.
574    pub fn register_weapon_imbue(
575        &mut self,
576        spell_id: u32,
577        key: &'static str,
578        initially_active: bool,
579    ) {
580        self.state
581            .defs
582            .weapon_imbue_bindings
583            .push(WeaponImbueBinding { spell_id, key });
584        self.buffer.ensure_weapon_imbue_slot(key);
585
586        if let Some(slot) = self.buffer.weapon_imbue_mut(key) {
587            slot.is_active = i32::from(initially_active);
588            slot.is_inactive = i32::from(!initially_active);
589        }
590    }
591
592    /// Register encounter-lifetime periodic work under its real spell/passive driver.
593    pub fn register_periodic_driver(&mut self, driver: crate::PeriodicDriver) {
594        self.state.defs.periodic_drivers.push(driver);
595    }
596
597    pub(crate) fn size_scratch_buffers(&mut self) {
598        self.state.runtime.scratch = ScratchBuffers {
599            hooks: Vec::with_capacity(self.state.defs.player_cast_hooks.len()),
600            impacts: Vec::with_capacity(self.state.defs.impact_procs.len()),
601            accumulating_impacts: Vec::with_capacity(
602                self.state.defs.accumulating_impact_procs.len(),
603            ),
604            impact_effects: Vec::with_capacity(self.state.defs.impact_effect_procs.len()),
605            resource_gains: Vec::with_capacity(self.state.defs.resource_gain_procs.len()),
606        };
607
608        debug_assert!(
609            self.state.runtime.scratch.impacts.capacity() >= self.state.defs.impact_procs.len(),
610            "impact scratch capacity must cover every composed impact proc"
611        );
612    }
613}
614
615#[cfg(test)]
616mod tests;