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#[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#[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 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 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 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 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 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 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 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 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 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 pub fn on_player_cast(mut self, hook: CastHookFn) -> Self {
422 self.player_cast_hooks.push(hook);
423
424 self
425 }
426}