Skip to main content

wowlab_engine_combat/builder/
buffer_init.rs

1//! Initial dense-buffer population from the assembled combat state.
2
3use 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        // Force-allocate so cost enforcement works even when the rotation never references it.
124        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        // Cooldown enforcement is a runtime invariant, independent of APL reads.
156
157        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    // docref:start pets-seed-slot
324    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    // docref:end pets-seed-slot
332
333    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;