wowlab_engine_combat/builder/
built.rs1use 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#[derive(Debug)]
21#[must_use]
22pub struct BuiltCombatSystem {
24 pub state: CombatState,
25 pub rotation: RotationEngine,
26 pub buffer: DenseBuffer,
27 pub names: Vec<Box<str>>,
28}
29impl BuiltCombatSystem {
32 pub fn builder(stats: wowlab_engine_ports::CombatStats) -> CombatSystemBuilder {
34 CombatSystemBuilder::new(stats)
35 }
36
37 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 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 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 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 pub fn register_impact_proc(&mut self, proc: ImpactProc) {
162 self.state.defs.impact_procs.push(proc);
163 }
164
165 pub fn register_landed_impact_proc(&mut self, proc: LandedImpactProc) {
167 self.state.defs.landed_impact_procs.push(proc);
168 }
169
170 pub fn register_accumulating_impact_proc(&mut self, proc: AccumulatingImpactProc) {
172 self.state.defs.accumulating_impact_procs.push(proc);
173 }
174
175 pub fn register_resource_gain_proc(&mut self, proc: ResourceGainProc) {
177 self.state.defs.resource_gain_procs.push(proc);
178 }
179
180 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 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 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 pub fn push_precombat_aura(&mut self, aura: LocalAuraIdx) {
269 self.state.defs.precombat_auras.push(aura);
270 }
271
272 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 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 pub fn register_player_cast_hook(&mut self, hook: CastHookFn) {
300 self.state.defs.player_cast_hooks.push(hook);
301 }
302
303 #[must_use]
305 pub fn aura(&self, aura: LocalAuraIdx) -> Option<&AuraData> {
306 self.state.defs.auras.get(aura.as_usize())
307 }
308
309 #[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 #[must_use]
317 pub fn impact_proc(&self, index: usize) -> Option<&ImpactProc> {
318 self.state.defs.impact_procs.get(index)
319 }
320
321 #[must_use]
323 pub fn impact_proc_count(&self) -> usize {
324 self.state.defs.impact_procs.len()
325 }
326
327 #[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 #[must_use]
335 pub fn impact_effect_proc_count(&self) -> usize {
336 self.state.defs.impact_effect_procs.len()
337 }
338
339 #[must_use]
341 pub fn spell_effects(&self) -> &[SpellEffectData] {
342 &self.state.defs.spell_effects
343 }
344
345 #[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 #[must_use]
353 pub fn rppm_tracker_count(&self) -> usize {
354 self.state.defs.rppm_trackers.len()
355 }
356
357 #[must_use]
359 pub fn player_cast_hook_count(&self) -> usize {
360 self.state.defs.player_cast_hooks.len()
361 }
362
363 #[must_use]
365 pub fn spell_count(&self) -> usize {
366 self.state.defs.spells.len()
367 }
368
369 #[must_use]
371 pub fn first_auto_attack(&self) -> Option<&AutoAttackData> {
372 self.state.defs.auto_attacks.first()
373 }
374
375 pub fn patch_auto_attacks(&mut self, patch: impl FnOnce(&mut [AutoAttackData])) {
377 patch(&mut self.state.defs.auto_attacks);
378 }
379
380 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 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 #[must_use]
477 pub fn base_combat_stats(&self) -> &wowlab_engine_ports::CombatStats {
478 &self.state.config.base_stats.stats
479 }
480
481 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 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 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 pub fn register_driver_spell_modifier(&mut self, modifier: DriverSpellModifier) {
565 self.state.defs.driver_spell_modifiers.push(modifier);
566 }
567
568 pub fn register_passive_driver_effect(&mut self, effect: crate::PassiveDriverEffect) {
570 self.state.defs.passive_driver_effects.push(effect);
571 }
572
573 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 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;