1use wowlab_manifest_schema::{Manifest, ManifestAuraDef, ManifestSpellDef};
4
5use super::{
6 EXPLICIT_AURA_EDGE_EVIDENCE, base_row, damage_row, event_effect, hook_placeholder,
7 scalar_option,
8};
9use crate::manifest_ledger::types::{LedgerDisposition, LedgerRow, OperationCategory};
10
11pub(super) fn collect_spell(
12 rows: &mut Vec<LedgerRow>,
13 owner: &str,
14 path: &str,
15 name: &str,
16 spell: &ManifestSpellDef,
17 auras: &indexmap::IndexMap<String, ManifestAuraDef>,
18) {
19 let root = format!("spells.{name}");
20
21 collect_spell_scalars(rows, owner, path, &root, spell);
22 collect_spell_auras(rows, owner, path, &root, spell, auras);
23 spell_cooldowns(rows, owner, path, &root, spell);
24 spell_gates(rows, owner, path, &root, spell);
25
26 if spell.aoe.is_some() {
27 rows.push(
28 base_row(
29 owner,
30 path,
31 format!("{root}.aoe"),
32 OperationCategory::Targeting,
33 LedgerDisposition::GenericGap,
34 "authored target geometry/cap overrides",
35 )
36 .owner(spell.id),
37 );
38 }
39
40 collect_channel(rows, owner, path, &root, spell);
41
42 if spell.hook.is_some() {
43 hook_placeholder(
44 rows,
45 owner,
46 path,
47 &format!("{root}.hook"),
48 spell.id,
49 OperationCategory::OrderedContent,
50 );
51 }
52}
53
54fn collect_spell_scalars(
55 rows: &mut Vec<LedgerRow>,
56 owner: &str,
57 path: &str,
58 root: &str,
59 spell: &ManifestSpellDef,
60) {
61 for (field, scalar, category, core) in [
62 (
63 "cooldown",
64 spell.cooldown.as_ref(),
65 OperationCategory::Cooldown,
66 true,
67 ),
68 (
69 "cost",
70 spell.cost.as_ref(),
71 OperationCategory::Resource,
72 true,
73 ),
74 (
75 "gain",
76 spell.gain.as_ref(),
77 OperationCategory::Resource,
78 false,
79 ),
80 (
81 "travel_time_ms",
82 spell.travel_time_ms.as_ref(),
83 OperationCategory::OrderedContent,
84 true,
85 ),
86 (
87 "cast_time_ms",
88 spell.cast_time_ms.as_ref(),
89 OperationCategory::Gate,
90 true,
91 ),
92 (
93 "gcd_ms",
94 spell.gcd_ms.as_ref(),
95 OperationCategory::Gate,
96 true,
97 ),
98 (
99 "charges",
100 spell.charges.as_ref(),
101 OperationCategory::Cooldown,
102 true,
103 ),
104 (
105 "charge_cd",
106 spell.charge_cd.as_ref(),
107 OperationCategory::Cooldown,
108 true,
109 ),
110 ] {
111 scalar_option(
112 rows,
113 owner,
114 path,
115 &format!("{root}.{field}"),
116 spell.id,
117 scalar,
118 category,
119 core,
120 );
121 }
122
123 if let Some(damage) = &spell.damage {
124 rows.push(damage_row(
125 owner,
126 path,
127 format!("{root}.damage"),
128 spell.id,
129 damage,
130 ));
131 }
132
133 for (index, damage) in spell.damage_effects.iter().enumerate() {
134 rows.push(
135 base_row(
136 owner,
137 path,
138 format!("{root}.damage_effects[{index}]"),
139 OperationCategory::Damage,
140 LedgerDisposition::GenericGap,
141 "authored DBC child damage effect",
142 )
143 .owner(spell.id)
144 .child(damage.spell_id)
145 .effect(damage.spell_id, damage.effect),
146 );
147 }
148}
149
150fn collect_spell_auras(
151 rows: &mut Vec<LedgerRow>,
152 owner: &str,
153 path: &str,
154 root: &str,
155 spell: &ManifestSpellDef,
156 auras: &indexmap::IndexMap<String, ManifestAuraDef>,
157) {
158 if let Some(aura) = &spell.applies_aura {
159 rows.push(explicit_aura_edge(
160 owner,
161 path,
162 format!("{root}.applies_aura ({aura})"),
163 spell.id,
164 auras.get(aura).map(|definition| definition.id),
165 ));
166 }
167
168 for (index, aura) in spell.applies_auras.iter().enumerate() {
169 rows.push(explicit_aura_edge(
170 owner,
171 path,
172 format!("{root}.applies_auras[{index}] ({aura})"),
173 spell.id,
174 auras.get(aura).map(|definition| definition.id),
175 ));
176 }
177
178 for (index, extend) in spell.extends_auras.iter().enumerate() {
179 rows.push(
180 base_row(
181 owner,
182 path,
183 format!("{root}.extends_auras[{index}] ({})", extend.aura),
184 OperationCategory::AuraApply,
185 LedgerDisposition::ContentOrdering,
186 "ordered aura-duration mutation",
187 )
188 .owner(spell.id),
189 );
190 }
191}
192
193fn collect_channel(
194 rows: &mut Vec<LedgerRow>,
195 owner: &str,
196 path: &str,
197 root: &str,
198 spell: &ManifestSpellDef,
199) {
200 if let Some(channel) = &spell.channel {
201 rows.push(
202 base_row(
203 owner,
204 path,
205 format!("{root}.channel"),
206 OperationCategory::AuraTick,
207 LedgerDisposition::Unresolved,
208 "authored channel cadence and ordered tick program",
209 )
210 .owner(spell.id),
211 );
212
213 if let Some(damage) = &channel.tick_damage {
214 rows.push(damage_row(
215 owner,
216 path,
217 format!("{root}.channel.tick_damage"),
218 spell.id,
219 damage,
220 ));
221 }
222
223 if channel.tick_damage_alt.is_some() {
224 rows.push(
225 base_row(
226 owner,
227 path,
228 format!("{root}.channel.tick_damage_alt"),
229 OperationCategory::Damage,
230 LedgerDisposition::ContentOrdering,
231 "aura-conditional channel damage ordering",
232 )
233 .owner(spell.id),
234 );
235 }
236
237 for (index, effect) in channel.tick_effects.iter().enumerate() {
238 event_effect(
239 rows,
240 owner,
241 path,
242 &format!("{root}.channel.tick_effects[{index}]"),
243 spell.id,
244 effect,
245 );
246 }
247
248 if channel.tick_hook.is_some() {
249 hook_placeholder(
250 rows,
251 owner,
252 path,
253 &format!("{root}.channel.tick_hook"),
254 spell.id,
255 OperationCategory::AuraTick,
256 );
257 }
258 }
259}
260
261fn explicit_aura_edge(
262 owner: &str,
263 path: &str,
264 key: String,
265 spell_id: u32,
266 aura_id: Option<u32>,
267) -> LedgerRow {
268 let row = base_row(
269 owner,
270 path,
271 key,
272 OperationCategory::AuraApply,
273 LedgerDisposition::GenericGap,
274 EXPLICIT_AURA_EDGE_EVIDENCE,
275 )
276 .owner(spell_id);
277
278 if let Some(aura_id) = aura_id {
279 row.child(aura_id)
280 } else {
281 row
282 }
283}
284
285fn spell_cooldowns(
286 rows: &mut Vec<LedgerRow>,
287 owner: &str,
288 path: &str,
289 root: &str,
290 spell: &ManifestSpellDef,
291) {
292 for (index, _) in spell.reduces_cd.iter().flatten().enumerate() {
293 rows.push(
294 base_row(
295 owner,
296 path,
297 format!("{root}.reduces_cd[{index}]"),
298 OperationCategory::Cooldown,
299 LedgerDisposition::GenericGap,
300 "typed cooldown child operation",
301 )
302 .owner(spell.id),
303 );
304 }
305
306 if spell.reduces_cd_chance.is_some() {
307 rows.push(
308 base_row(
309 owner,
310 path,
311 format!("{root}.reduces_cd_chance"),
312 OperationCategory::Cooldown,
313 LedgerDisposition::ContentOrdering,
314 "chance-gated cooldown mutation",
315 )
316 .owner(spell.id),
317 );
318 }
319
320 if spell.resets_cd_while.is_some() {
321 rows.push(
322 base_row(
323 owner,
324 path,
325 format!("{root}.resets_cd_while"),
326 OperationCategory::Cooldown,
327 LedgerDisposition::GenericGap,
328 "aura-gated cooldown reset",
329 )
330 .owner(spell.id),
331 );
332 }
333}
334
335fn spell_gates(
336 rows: &mut Vec<LedgerRow>,
337 owner: &str,
338 path: &str,
339 root: &str,
340 spell: &ManifestSpellDef,
341) {
342 let gates = [
343 ("usable_while_casting", spell.usable_while_casting),
344 ("requires_aura_id", spell.requires_aura_id.is_some()),
345 (
346 "requires_aura_min_stacks",
347 spell.requires_aura_min_stacks.is_some(),
348 ),
349 ("cost_free_when_aura", spell.cost_free_when_aura.is_some()),
350 ("consume_aura_on_cast", spell.consume_aura_on_cast.is_some()),
351 (
352 "cooldown_bypass_when_aura",
353 spell.cooldown_bypass_when_aura.is_some(),
354 ),
355 ("execute_below_pct", spell.execute_below_pct.is_some()),
356 ("instant_when_aura", spell.instant_when_aura.is_some()),
357 ("guaranteed_crit", spell.guaranteed_crit),
358 ("cooldown_hasted", spell.cooldown_hasted.is_some()),
359 ("breaks_stealth", !spell.breaks_stealth),
360 ];
361
362 for (field, present) in gates {
363 if present {
364 rows.push(
365 base_row(
366 owner,
367 path,
368 format!("{root}.{field}"),
369 OperationCategory::Gate,
370 LedgerDisposition::GenericGap,
371 "authored spell gate/property override",
372 )
373 .owner(spell.id),
374 );
375 }
376 }
377
378 let replacements = [
379 ("override_when_aura", spell.override_when_aura.is_some()),
380 ("override_with_spell", spell.override_with_spell.is_some()),
381 (
382 "override_aura_min_stacks",
383 spell.override_aura_min_stacks.is_some(),
384 ),
385 (
386 "override_shares_base_cooldown",
387 spell.override_shares_base_cooldown,
388 ),
389 ("override_preserves_aura", spell.override_preserves_aura),
390 ];
391
392 for (field, present) in replacements {
393 if present {
394 rows.push(
395 base_row(
396 owner,
397 path,
398 format!("{root}.{field}"),
399 OperationCategory::Replacement,
400 LedgerDisposition::GenericGap,
401 "authored action replacement rule",
402 )
403 .owner(spell.id),
404 );
405 }
406 }
407}
408
409pub(super) fn collect_auto_attacks(
410 rows: &mut Vec<LedgerRow>,
411 slug: &str,
412 path: &str,
413 manifest: &Manifest,
414) {
415 for (name, attack) in &manifest.auto_attacks {
416 let root = format!("auto_attacks.{name}");
417
418 rows.push(
419 base_row(
420 slug,
421 path,
422 format!("{root}.damage"),
423 OperationCategory::Damage,
424 LedgerDisposition::GenericGap,
425 "authored swing damage payload",
426 )
427 .owner(attack.spell_id),
428 );
429
430 if attack.is_pet
431 || attack.npc_id.is_some()
432 || attack.uses_spell_power
433 || attack.pet_power_from_owner.is_some()
434 || attack.pet_weapon_speed_ms.is_some()
435 || attack.pet_weapon_multiplier.is_some()
436 {
437 rows.push(
438 base_row(
439 slug,
440 path,
441 format!("{root}.actor"),
442 OperationCategory::ActorOrPet,
443 LedgerDisposition::GenericGap,
444 "authored auto-attack actor/weapon ownership",
445 )
446 .owner(attack.spell_id),
447 );
448 }
449
450 if attack.on_crit_reduce_charge.is_some() {
451 rows.push(
452 base_row(
453 slug,
454 path,
455 format!("{root}.on_crit_reduce_charge"),
456 OperationCategory::Cooldown,
457 LedgerDisposition::ContentOrdering,
458 "critical-swing cooldown mutation",
459 )
460 .owner(attack.spell_id),
461 );
462 }
463
464 if attack.hook.is_some() {
465 hook_placeholder(
466 rows,
467 slug,
468 path,
469 &format!("{root}.hook"),
470 attack.spell_id,
471 OperationCategory::OrderedContent,
472 );
473 }
474 }
475}