wowlab_engine_combat/builder/combat_builder/build/
assemble.rs1use super::{
2 AuraSubtypeKind, BaseStats, CombatConfig, CombatDefs, CombatIndex, CombatRuntime, CombatState,
3 CombatSystemBuilder, CooldownCategoryId, EnemyActorDefinition, EnemyActorDefinitionInput,
4 EnemyHealthInput, EnemyState, EngineError, FastMap, HealthModel, IntMap, PoolIndex,
5 ResolvedEncounter, ResourceThresholdTrigger, ResourceType, RuntimeStateParts, SpellIdx,
6 resolve_resource_type,
7};
8
9pub(super) struct CombatStateAssemblyResult(Result<CombatState, EngineError>);
10
11impl CombatStateAssemblyResult {
12 pub(super) fn into_result(self) -> Result<CombatState, EngineError> {
13 self.0
14 }
15}
16
17fn category_members(
18 spells: &[crate::state::SpellData],
19 category: impl Fn(&crate::state::SpellData) -> Option<CooldownCategoryId>,
20) -> PoolIndex<CooldownCategoryId, crate::state::LocalSpellIdx> {
21 let mut members = PoolIndex::default();
22
23 for (index, spell) in spells.iter().enumerate() {
24 let Some(category) = category(spell) else {
25 continue;
26 };
27
28 members.register(
29 category,
30 crate::state::LocalSpellIdx::new(u8::try_from(index).expect("spell count fits in u8")),
31 );
32 }
33
34 members
35}
36
37fn mark_dynamic_max_charges(defs: &mut CombatDefs) {
39 let categories: Vec<i32> = defs
40 .auras
41 .iter()
42 .flat_map(|aura| aura.effects.iter().flatten())
43 .filter_map(|effect| match effect {
44 crate::BuffEffect::MaxChargesCategory(_, category) => Some(*category),
45 _ => None,
46 })
47 .collect();
48
49 if categories.is_empty() {
50 return;
51 }
52
53 for spell in &mut defs.spells {
54 let Some(charge_category) = spell.cooldown.charge_category else {
55 continue;
56 };
57
58 if categories.contains(&charge_category.raw()) {
59 spell.cooldown.dynamic_max_charges = true;
60 }
61 }
62}
63
64fn starts_active_at_encounter_start(
65 encounter: &ResolvedEncounter,
66 enemy: &wowlab_engine_ports::ResolvedEnemyEncounter,
67) -> bool {
68 if enemy.spawn_at_s().abs() > f64::EPSILON {
69 return false;
70 }
71
72 let definition = encounter.definition();
73 let Some(first_pull) = definition.pulls.first() else {
74 return false;
75 };
76 let Some(group) = definition
77 .groups
78 .get(enemy.group_id().as_usize())
79 .filter(|group| group.id == enemy.group_id())
80 else {
81 return false;
82 };
83 let Some(wave) = definition
84 .waves
85 .get(group.wave_id.as_usize())
86 .filter(|wave| wave.id == group.wave_id)
87 else {
88 return false;
89 };
90
91 group.enemy_ids.contains(&enemy.enemy())
92 && wave.group_ids.contains(&group.id)
93 && first_pull.wave_ids.contains(&wave.id)
94 && wave.pull_id == first_pull.id
95 && wave.minimum_activation_s.abs() <= f64::EPSILON
96 && wave.depends_on_groups.is_empty()
97}
98
99fn enemy_actor_definitions(encounter: &ResolvedEncounter) -> Vec<EnemyActorDefinition> {
100 encounter
101 .enemies()
102 .iter()
103 .map(|enemy| {
104 let health_model = match enemy.health() {
105 EnemyHealthInput::Auto => HealthModel::DamageDriven {
106 max_health: enemy.max_health(),
107 },
108 EnemyHealthInput::Fixed { max_health } => HealthModel::DamageDriven {
109 max_health: *max_health,
110 },
111 EnemyHealthInput::ScriptedLinear {
112 display_max_health,
113 death_at_s,
114 } => HealthModel::ScriptedLinear {
115 display_max_health: *display_max_health,
116 death_at_s: *death_at_s,
117 },
118 };
119
120 EnemyActorDefinition::new(EnemyActorDefinitionInput {
121 id: enemy.enemy(),
122 display_name: enemy.display_name().to_string(),
123 npc_id: enemy.npc_id(),
124 classification: enemy.classification(),
125 creature_type: enemy.creature_type(),
126 level: enemy.level(),
127 armor: enemy.creature_armor(),
128 armor_constant: enemy.armor_constant(),
129 auto_attack_dps: enemy.auto_attack_dps(),
130 spell_damage: enemy.spell_damage(),
131 creature_aoe_avoidance_pct: enemy.creature_aoe_avoidance_pct(),
132 attack_table: wowlab_engine_domain::encounter::EnemyAttackTable::default(),
133 health_model,
134 initial_transform: enemy.initial_transform(),
135 group_id: enemy.group_id(),
136 enemy_tags: enemy.enemy_tags().to_vec(),
137 group_tags: enemy.group_tags().to_vec(),
138 role: enemy.role(),
139 spawn_at_s: enemy.spawn_at_s(),
140 starts_active: starts_active_at_encounter_start(encounter, enemy),
141 })
142 })
143 .collect()
144}
145
146impl CombatSystemBuilder {
147 pub(super) fn assemble_state(
149 self,
150 parts: RuntimeStateParts,
151 selected_talent_spell_ids: &[u32],
152 encounter: ResolvedEncounter,
153 ) -> CombatStateAssemblyResult {
154 let primary_rt = resolve_resource_type(self.resource_type_id, &self.resource_name);
155 let secondary_rt = self
156 .secondary_resource_name
157 .as_deref()
158 .and_then(|name| resolve_resource_type(self.secondary_resource_type_id, name));
159
160 let mut resource_threshold_triggers = Vec::new();
161
162 for &talent_id in selected_talent_spell_ids {
163 let talent = SpellIdx::from_raw(talent_id);
164
165 for effect_index in 1..=self.game_data.max_effect_index(talent) {
166 if !wowlab_engine_domain::dbc::aura_subtype_is(
167 self.game_data.effect_aura(talent, effect_index),
168 AuraSubtypeKind::ResourceThresholdTrigger,
169 ) {
170 continue;
171 }
172
173 let raw_resource_type = self.game_data.effect_misc_value_0(talent, effect_index);
174 let Some(resource_type) = u8::try_from(raw_resource_type)
175 .ok()
176 .and_then(|value| ResourceType::try_from(value).ok())
177 .filter(|resource_type| {
178 Some(*resource_type) == primary_rt || Some(*resource_type) == secondary_rt
179 })
180 else {
181 continue;
182 };
183 let Some(triggered) = self.game_data.trigger_spell(talent, effect_index) else {
184 continue;
185 };
186 let Some(&spell_local) = parts.spell_by_id.get(&triggered.as_u32()) else {
187 continue;
188 };
189
190 resource_threshold_triggers.push(ResourceThresholdTrigger {
191 resource_type,
192 threshold: self.game_data.base_points(talent, effect_index),
193 spell_local,
194 });
195 }
196 }
197
198 let haste_regen = matches!(primary_rt, Some(ResourceType::Energy | ResourceType::Focus));
199
200 let impact_effect_count = parts.impact_effect_procs.len();
201 let spell_count = parts.spell_data.len();
202 let cooldown_categories =
203 category_members(&parts.spell_data, |spell| spell.cooldown.cooldown_category);
204 let charge_categories =
205 category_members(&parts.spell_data, |spell| spell.cooldown.charge_category);
206 let min_primary_cost = parts
207 .spell_data
208 .iter()
209 .map(|spell| {
210 spell.cost.resource_cost
211 + spell
212 .cost
213 .resource_cost_pct
214 .resolve(self.resource_max, self.resource_max)
215 })
216 .filter(|cost| *cost > 0.0)
217 .reduce(f64::min)
218 .unwrap_or(0.0);
219 let enemy_definitions = enemy_actor_definitions(&encounter);
220 let enemy_states = enemy_definitions
221 .iter()
222 .map(EnemyState::from_definition)
223 .collect();
224
225 let mut defs = CombatDefs::new(enemy_definitions);
226
227 defs.spells = parts.spell_data;
228 defs.empower_cast_times_ms = parts.empower_cast_times_ms;
229 defs.spell_effects = parts.spell_effects;
230 defs.auras = parts.aura_data;
231 mark_dynamic_max_charges(&mut defs);
232 defs.auto_attacks = parts.auto_attack_data;
233 defs.cast_hooks = self.cast_hooks;
234 defs.empower_release_hooks = self.empower_release_hooks;
235 defs.tick_hooks = self.tick_hooks;
236 defs.player_cast_hooks = self.player_cast_hooks;
237 defs.resource_threshold_triggers = resource_threshold_triggers;
238 defs.impact_procs = self.impact_procs;
239 defs.landed_impact_procs = self.landed_impact_procs;
240 defs.accumulating_impact_procs = self.accumulating_impact_procs;
241 defs.impact_effect_procs = parts.impact_effect_procs;
242 defs.talent_ranks = self.selected_talent_ranks;
243 defs.resource_gain_procs = self.resource_gain_procs;
244 defs.item_use_spells = self.item_use_spells;
245 defs.rppm_trackers = self.rppm_trackers;
246 defs.threshold_trackers = self.threshold_trackers;
247 defs.precombat_auras = self.precombat_auras;
248 defs.talent_aura_stacks = self.talent_aura_stacks;
249 defs.stealth_aura_id = self.stealth_aura_id;
250
251 let preferred_target = encounter
252 .definition()
253 .pulls
254 .first()
255 .expect("validated encounter has at least one pull")
256 .preferred_target;
257 let orchestration = crate::state::EncounterRuntime::new(encounter.definition());
258 let player_max_health = self.stats.stamina.floor()
259 * self
260 .game_data
261 .health_per_stamina(self.game_data.level())
262 .unwrap_or(0.0);
263 let mut runtime = CombatRuntime::new(
264 enemy_states,
265 crate::state::CombatRuntimeInit {
266 encounter: orchestration,
267 preferred_target,
268 player_transform: encounter.definition().initial_player_transform,
269 player_max_health,
270 has_pet: self.has_pet,
271 },
272 );
273
274 runtime.procs.impact_effect_ready_at = vec![0.0; impact_effect_count];
275 runtime.procs.impact_effect_target_ready_at = (0..impact_effect_count)
276 .map(|_| FastMap::default())
277 .collect();
278 runtime.procs.impact_effect_charges = defs
279 .impact_effect_procs
280 .iter()
281 .map(|proc| {
282 if proc.uses_stacks_for_charges {
283 0
284 } else {
285 proc.charges
286 }
287 })
288 .collect();
289 runtime.procs.impact_effect_rng = defs
290 .impact_effect_procs
291 .iter()
292 .map(crate::state::ImpactEffectRngState::from_proc)
293 .collect();
294 runtime.recharge_rate_mults = vec![1.0; spell_count];
295
296 CombatStateAssemblyResult(CombatState::new(
297 defs,
298 CombatIndex {
299 spell_by_id: parts.spell_by_id,
300 aura_by_id: parts.aura_by_id,
301 aura_by_identity: parts.aura_by_identity,
302 cooldown_categories,
303 charge_categories,
304 item_rppm_indices: self.item_rppm_indices,
305 system_rppm_indices: IntMap::default(),
306 enchant_rppm_indices: IntMap::default(),
307 enchant_buff_spell_ids: IntMap::default(),
308 },
309 runtime,
310 CombatConfig {
311 base_stats: BaseStats {
312 stats: self.stats,
313 primary_attribute: self
314 .spec_id
315 .map(wowlab_engine_domain::stats::primary_stat_for_spec),
316 base_regen: self.resource_regen,
317 resource_max: self.resource_max,
318 resource_start: self.resource_start,
319 resource_name: self.resource_name,
320 has_pet: self.has_pet,
321 secondary_resource_name: self.secondary_resource_name,
322 secondary_resource_max: self.secondary_resource_max,
323 resource_type: primary_rt,
324 secondary_resource_type: secondary_rt,
325 swing_resource_gain_mult: 1.0,
326 },
327 min_primary_cost,
328 haste_regen,
329 game_data: self.game_data,
330 encounter,
331 mastery_hook: self.mastery_hook,
332 mastery_crit_damage: self.mastery_crit_damage,
333 mastery_spell: self.mastery_spell,
334 spec_config: Box::new(()),
335 bugs: wowlab_engine_ports::BugToggles::default(),
336 cast_latency: wowlab_engine_ports::CastLatency {
337 queue_ms: 0,
338 gcd_ms: 0,
339 channel_ms: 0,
340 queue_window_ms: 0,
341 strict_gcd_queue: true,
342 },
343 raid_events: Vec::new(),
344 external_buffs: Vec::new(),
345 },
346 ))
347 }
348}