wowlab_engine_combat/builder/
buffer_init.rs1use wowlab_engine_domain::{
4 encounter::EnemyMovementState,
5 rotation::{CombatSlot, CooldownSlot, DenseBuffer, PetSlot, ResourceSlot, SwingSlot},
6};
7use wowlab_engine_gamedata::ResolvedGameData;
8use wowlab_types::{
9 constants::MS_PER_SECOND,
10 sim::{ActorId, AuraIdx, AuraKey, AuraOn, EnemyRole, IntSet, SimTime, SpellIdx},
11};
12
13use crate::{
14 keys::{UnitKey, auto_attack_key},
15 state::{CombatState, SpellData},
16};
17
18pub(crate) fn projected_travel_time_ms(state: &CombatState, spell: &SpellData) -> u32 {
19 let distance = state
20 .current_target()
21 .and_then(|target| {
22 Some((
23 state.actor_transform(ActorId::Player)?,
24 state.actor_transform(ActorId::Enemy(target))?,
25 ))
26 })
27 .map_or(0.0, |(source, target)| {
28 source.position.distance(target.position)
29 });
30
31 crate::travel::impact_offset_ms(spell.travel, distance)
32}
33
34pub(crate) fn ensure_aura_slots(
35 state: &CombatState,
36 buf: &mut DenseBuffer,
37 aura_id: u32,
38 on: AuraOn,
39) {
40 match on {
41 AuraOn::Target => {
42 for enemy in state.enemy_definitions() {
43 buf.ensure_aura_slot(AuraKey::new(
44 AuraIdx(aura_id),
45 ActorId::Player,
46 ActorId::Enemy(enemy.id()),
47 on,
48 ));
49 }
50 }
51 AuraOn::Player => {
52 buf.ensure_aura_slot(AuraKey::new(
53 AuraIdx(aura_id),
54 ActorId::Player,
55 ActorId::Player,
56 on,
57 ));
58 }
59 AuraOn::Pet => {}
60 }
61}
62
63pub(crate) fn expand_spell_override_chains(ids: &mut Vec<u32>, data: &ResolvedGameData) {
64 let seeds = ids.clone();
65
66 for seed in seeds {
67 let mut current = SpellIdx::from_raw(seed);
68 let mut seen = IntSet::<SpellIdx>::default();
69
70 while seen.insert(current) {
71 let Some(replacement) = data.spell_override(current) else {
72 break;
73 };
74
75 if !ids.contains(&replacement.as_u32()) {
76 ids.push(replacement.as_u32());
77 }
78
79 current = replacement;
80 }
81 }
82}
83
84pub(crate) fn expand_spell_learn_chains(ids: &mut Vec<u32>, data: &ResolvedGameData) {
85 let mut pending = ids.clone();
86 let mut cursor = 0;
87
88 while let Some(&teacher) = pending.get(cursor) {
89 cursor += 1;
90
91 for learned in data.learned_spells(SpellIdx::from_raw(teacher)) {
92 let spell_id = learned.as_u32();
93
94 if !ids.contains(&spell_id) {
95 ids.push(spell_id);
96 pending.push(spell_id);
97 }
98 }
99 }
100}
101
102fn resource_type_id_i32(rt: Option<wowlab_types::combat::ResourceType>) -> i32 {
103 rt.map_or(0, |t| i32::from(u8::from(t)))
104}
105
106pub(crate) struct InitialBufferConfig<'a> {
107 pub(crate) resource_max: f64,
108 pub(crate) talent_ranks: &'a [(String, u8)],
109 pub(crate) talent_spell_ids: &'a [u32],
110 pub(crate) selected_talent_spell_ids: &'a [u32],
111 pub(crate) selected_replaced_spell_ids: &'a [u32],
112}
113
114fn init_resource_slots(state: &CombatState, buf: &mut DenseBuffer, resource_max: f64) {
115 let base = &state.config.base_stats;
116 let resource_start = if base.resource_start >= 0.0 {
117 base.resource_start
118 } else {
119 resource_max
120 };
121
122 if let Some(rt) = base.resource_type {
123 buf.ensure_resource_slot(rt);
125
126 if let Some(res) = buf.resource_mut(rt) {
127 *res = ResourceSlot {
128 current: resource_start,
129 max: resource_max,
130 regen_per_sec: base.base_regen,
131 };
132 }
133 }
134
135 if let Some(rt) = base.secondary_resource_type {
136 buf.ensure_resource_slot(rt);
137
138 if let Some(res) = buf.resource_mut(rt) {
139 *res = ResourceSlot {
140 current: 0.0,
141 max: base.secondary_resource_max,
142 regen_per_sec: 0.0,
143 };
144 }
145 }
146}
147
148fn init_cooldown_slots(state: &CombatState, buf: &mut DenseBuffer) {
149 for spell in &state.defs.spells {
150 if !spell.cooldown.has_cooldown {
151 continue;
152 }
153
154 let idx = spell.idx();
155 buf.ensure_cooldown_slot(idx);
158
159 if let Some(cd) = buf.cooldown_mut(idx) {
160 if spell.cooldown.max_charges > 0 {
161 *cd = CooldownSlot {
162 ready_at: 0.0,
163 duration: f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND,
164 current_charges: i32::from(spell.cooldown.max_charges),
165 max_charges: i32::from(spell.cooldown.max_charges),
166 next_charge_at: 0.0,
167 recharge_time: f64::from(spell.cooldown.recharge_ms) / MS_PER_SECOND,
168 };
169 } else {
170 cd.ready_at = 0.0;
171 cd.duration = f64::from(spell.cooldown.cooldown_duration_ms) / MS_PER_SECOND;
172 }
173 }
174 }
175}
176
177fn init_spell_slots(
178 state: &CombatState,
179 buf: &mut DenseBuffer,
180 talent_spell_ids: &[u32],
181 selected_talent_spell_ids: &[u32],
182 selected_replaced_spell_ids: &[u32],
183) {
184 let rt_id = resource_type_id_i32(state.config.base_stats.resource_type);
185 let sec_rt_id = resource_type_id_i32(state.config.base_stats.secondary_resource_type);
186
187 for spell in &state.defs.spells {
188 let idx = spell.idx();
189 let primary_cost = crate::systems::base_primary_spell_cost(state, buf, spell);
190
191 buf.ensure_spell_slot(idx);
192
193 if let Some(s) = buf.spell_mut(idx) {
194 let is_unselected_talent_spell = talent_spell_ids.contains(&spell.spell_id)
195 && !selected_talent_spell_ids.contains(&spell.spell_id);
196 let is_replaced_spell = selected_replaced_spell_ids.contains(&spell.spell_id);
197
198 s.cost = primary_cost;
199 s.secondary_cost = spell.cost.secondary_resource_cost;
200 s.cast_time = f64::from(spell.cast_time_ms) / MS_PER_SECOND;
201 s.travel_time = f64::from(projected_travel_time_ms(state, spell)) / MS_PER_SECOND;
202 s.gcd = f64::from(spell.base_gcd_ms()) / MS_PER_SECOND;
203 s.is_enabled = i32::from(!is_unselected_talent_spell && !is_replaced_spell);
204 s.range = state
205 .config
206 .game_data
207 .hostile_max_range(SpellIdx::from_raw(spell.spell_id))
208 .unwrap_or(0.0);
209 let required_targets =
210 wowlab_engine_domain::dbc::SpellCastTargetFlags::from_bits_retain(
211 spell.targeting.required_explicit_target_mask,
212 );
213
214 s.is_in_range = i32::from(
215 !required_targets.requires_hostile_unit()
216 || state.current_target().is_some_and(|target| {
217 state.spell_target_in_range(SpellIdx::from_raw(spell.spell_id), target)
218 }),
219 );
220 s.resource_type_id = rt_id;
221 s.secondary_resource_type_id = sec_rt_id;
222 }
223 }
224}
225
226fn init_swing_slots(state: &CombatState, buf: &mut DenseBuffer) {
227 for (i, aa) in state.defs.auto_attacks.iter().enumerate() {
228 let key = auto_attack_key(i);
229
230 buf.ensure_swing_slot(&key);
231
232 if let Some(sw) = buf.swing_mut(&key) {
233 *sw = SwingSlot {
234 next_swing_at: if aa.starts_active { 0.0 } else { f64::INFINITY },
235 speed: f64::from(aa.swing_ms) / MS_PER_SECOND,
236 swing_ms: f64::from(aa.swing_ms),
237 };
238 }
239 }
240}
241
242fn project_current_enemy(state: &CombatState, buf: &mut DenseBuffer, now: SimTime) {
243 let Some(enemy) = state.current_target() else {
244 return;
245 };
246 let Some(definition) = state.enemy_definition(enemy) else {
247 return;
248 };
249 let Some(actor) = state.enemy(enemy) else {
250 return;
251 };
252 let distance = state
253 .runtime
254 .player_transform
255 .position
256 .distance(actor.current_transform().position);
257
258 buf.ensure_unit_slot(UnitKey::TARGET);
259
260 if let Some(unit) = buf.unit_mut(UnitKey::TARGET) {
261 unit.health = actor.current_health();
262 unit.max_health = definition.health_model().max_health();
263 unit.is_boss = i32::from(definition.role() == EnemyRole::Boss);
264 unit.time_to_die = state.enemy_time_to_die(enemy, now).unwrap_or(-1.0);
265 unit.is_casting = 0;
266 unit.cast_end = 0.0;
267 unit.is_moving = i32::from(matches!(
268 actor.movement(),
269 EnemyMovementState::Moving { .. }
270 ));
271 unit.is_add = i32::from(definition.role() == EnemyRole::Add);
272 unit.distance = distance;
273 }
274}
275
276pub(crate) fn project_encounter(state: &CombatState, buf: &mut DenseBuffer, now: SimTime) {
277 let fight_duration_secs = state.config.encounter.fight_duration_secs();
278 let enemy_count = state.active_alive_enemy_count();
279
280 {
281 let combat = buf.combat_mut();
282
283 *combat = CombatSlot {
284 combat_start: 0.0,
285 combat_duration: fight_duration_secs,
286 enemy_count,
287 enemy_spell_targets_hit: enemy_count,
288 }
289 };
290
291 project_current_enemy(state, buf, now);
292
293 for spell in &state.defs.spells {
294 if let Some(slot) = buf.spell_mut(SpellIdx::from_raw(spell.spell_id)) {
295 slot.travel_time = f64::from(projected_travel_time_ms(state, spell)) / MS_PER_SECOND;
296 slot.range = state
297 .config
298 .game_data
299 .hostile_max_range(SpellIdx::from_raw(spell.spell_id))
300 .unwrap_or(0.0);
301 slot.is_in_range = i32::from(
302 !wowlab_engine_domain::dbc::SpellCastTargetFlags::from_bits_retain(
303 spell.targeting.required_explicit_target_mask,
304 )
305 .requires_hostile_unit()
306 || state.current_target().is_some_and(|target| {
307 state.spell_target_in_range(SpellIdx::from_raw(spell.spell_id), target)
308 }),
309 );
310 }
311 }
312
313 crate::systems::refresh_aura_projections(buf, state.current_target());
314}
315
316pub(crate) fn populate_initial_buffer(
317 state: &CombatState,
318 buf: &mut DenseBuffer,
319 cfg: &InitialBufferConfig<'_>,
320) {
321 buf.reset_and_defaults();
322
323 if state.config.base_stats.has_pet {
325 *buf.pet_mut() = PetSlot {
326 is_active: 1,
327 count: 0,
328 expires_at: 0.0,
329 };
330 }
331 init_resource_slots(state, buf, cfg.resource_max);
334 init_cooldown_slots(state, buf);
335 init_spell_slots(
336 state,
337 buf,
338 cfg.talent_spell_ids,
339 cfg.selected_talent_spell_ids,
340 cfg.selected_replaced_spell_ids,
341 );
342
343 for (name, rank) in cfg.talent_ranks {
344 if let Some(talent) = buf.talent_mut(name) {
345 talent.is_enabled = i32::from(*rank > 0);
346 talent.rank = i32::from(*rank);
347 talent.max_rank = i32::from((*rank).max(1));
348 }
349 }
350
351 init_swing_slots(state, buf);
352
353 for &(gear_slot, _spell_id) in &state.defs.item_use_spells {
354 let slot_name = gear_slot.slug();
355
356 if let Some(item_slot) = buf.item_mut(slot_name) {
357 item_slot.is_item = 1;
358 item_slot.has_cooldown = 1;
359 }
360 }
361
362 project_encounter(state, buf, SimTime::ZERO);
363
364 write_initial_player_stats(state, buf);
365}
366
367fn write_initial_player_stats(combat: &CombatState, buf: &mut DenseBuffer) {
368 let stats = &combat.config.base_stats.stats;
369 let p = buf.player_mut();
370
371 p.attack_power = stats.attack_power;
372 p.spell_power = stats.spell_power;
373 p.crit = stats.crit_chance;
374 p.set_haste(stats.haste);
375 p.mastery = stats.mastery;
376 p.versatility = stats.versatility;
377 p.armor = stats.armor;
378 p.stamina = stats.stamina;
379 p.primary_stat = stats.intellect.max(stats.agility).max(stats.strength);
380 p.is_alive = 1;
381 p.in_combat = 1;
382}
383
384#[cfg(test)]
385#[allow(
386 clippy::float_cmp,
387 reason = "buffer projection tests assert exact copied fixture values"
388)]
389mod tests;