1use 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#[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#[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 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 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 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 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 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 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 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 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 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 accumulated: f64::NAN,
337 roll_over,
338 });
339
340 LocalThresholdIdx::new(idx)
341 }
342
343 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 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 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 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 pub fn on_player_cast(mut self, hook: CastHookFn) -> Self {
488 self.player_cast_hooks.push(hook);
489
490 self
491 }
492}