1#![expect(
5 clippy::cast_possible_wrap,
6 reason = "manifest spell IDs are constrained to the signed DBC identifier domain"
7)]
8
9use std::collections::BTreeMap;
10
11use wowlab_engine_domain::dbc::{
12 AuraSubtypeKind, SpellEffectKind, aura_subtype_modifier, spell_effect_kind,
13};
14use wowlab_engine_ports::{DataResolver, SpellId};
15
16use super::{
17 collect,
18 types::{LedgerDisposition, LedgerRow},
19};
20
21const MAX_EFFECT_SEMANTICS: usize = 3;
22
23pub(super) async fn attach(rows: &mut [LedgerRow], resolver: &impl DataResolver) {
24 let mut cache: BTreeMap<(u32, u8), Vec<String>> = BTreeMap::new();
25 let mut direct_aura_cache: BTreeMap<u32, Vec<String>> = BTreeMap::new();
26 let mut triggered_aura_cache: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
27 let mut direct_remove_cache: BTreeMap<(u32, u32), Vec<String>> = BTreeMap::new();
28 let mut periodic_energize_cache: BTreeMap<(u32, u8), Vec<String>> = BTreeMap::new();
29 let mut semantic_ids = Vec::with_capacity(MAX_EFFECT_SEMANTICS);
30
31 for row in rows {
32 for &(spell_id, effect_index) in &row.effect_coordinates {
33 let coordinate = (spell_id, effect_index);
34 let ids = if let Some(ids) = cache.get(&coordinate) {
35 ids.clone()
36 } else {
37 semantic_ids.clear();
38 let ids = resolver
39 .get_spell_effect(SpellId::new(spell_id as i32), effect_index)
40 .await
41 .map(|effect| {
42 semantic_ids.push(format!("SE{}", effect.effect));
43
44 if effect.effect == SpellEffectKind::ApplyAura as i32
45 || effect.aura != AuraSubtypeKind::None as i32
46 {
47 semantic_ids.push(format!("AU{}", effect.aura));
48 }
49
50 if aura_subtype_modifier(effect.aura).is_some() {
51 semantic_ids.push(format!("MP{}", effect.misc_value_0));
52 }
53
54 semantic_ids.clone()
55 })
56 .unwrap_or_default();
57
58 cache.insert(coordinate, ids.clone());
59
60 ids
61 };
62
63 row.semantic_ids
64 .extend(ids.into_iter().map(String::into_boxed_str));
65 }
66
67 attach_direct_aura_semantics(row, resolver, &mut direct_aura_cache).await;
68 attach_triggered_aura_semantics(row, resolver, &mut triggered_aura_cache).await;
69 attach_direct_remove_semantics(row, resolver, &mut direct_remove_cache).await;
70 attach_periodic_energize_semantics(row, resolver, &mut periodic_energize_cache).await;
71 row.semantic_ids.sort();
72 row.semantic_ids.dedup();
73 row.owning_spell_ids.sort_unstable();
74 row.owning_spell_ids.dedup();
75 row.child_spell_ids.sort_unstable();
76 row.child_spell_ids.dedup();
77 }
78}
79
80async fn attach_periodic_energize_semantics(
81 row: &mut LedgerRow,
82 resolver: &impl DataResolver,
83 cache: &mut BTreeMap<(u32, u8), Vec<String>>,
84) {
85 let candidate = matches!(
86 row.evidence.as_str(),
87 "DBC-backed periodic resource cadence" | "DBC-backed periodic resource amount"
88 ) && row.owning_spell_ids.len() == 1
89 && row.effect_coordinates.len() == 1;
90 let Some((_, coordinate)) = candidate
91 .then(|| (row.owning_spell_ids[0], row.effect_coordinates[0]))
92 .filter(|(owner, (effect_spell, _))| owner == effect_spell)
93 else {
94 return;
95 };
96 let semantics = if let Some(semantics) = cache.get(&coordinate) {
97 semantics.clone()
98 } else {
99 let semantics = resolver
100 .get_spell_effect(SpellId::new(coordinate.0 as i32), coordinate.1)
101 .await
102 .ok()
103 .filter(|effect| {
104 effect.effect == SpellEffectKind::ApplyAura as i32
105 && effect.aura == AuraSubtypeKind::PeriodicEnergize as i32
106 })
107 .map(|_| vec!["AU24".to_string(), "SE6".to_string()])
108 .unwrap_or_default();
109
110 cache.insert(coordinate, semantics.clone());
111
112 semantics
113 };
114
115 if semantics.is_empty() {
116 return;
117 }
118
119 row.semantic_ids
120 .extend(semantics.into_iter().map(String::into_boxed_str));
121 row.disposition = LedgerDisposition::RedundantGeneric;
122 row.evidence = format!(
123 "production DBC AU24 supplies periodic resource {} through shared aura lowering",
124 if row.manifest_key.ends_with(".tick_ms") {
125 "cadence"
126 } else {
127 "amount and power type"
128 }
129 );
130}
131
132async fn attach_direct_remove_semantics(
133 row: &mut LedgerRow,
134 resolver: &impl DataResolver,
135 cache: &mut BTreeMap<(u32, u32), Vec<String>>,
136) {
137 let edge = (row.disposition == LedgerDisposition::GenericGap
138 && row.evidence == "hook operation `expire_aura`"
139 && row.manifest_key.starts_with("spells.")
140 && row.owning_spell_ids.len() == 1
141 && row.child_spell_ids.len() == 1)
142 .then(|| (row.owning_spell_ids[0], row.child_spell_ids[0]));
143 let Some((owner, child)) = edge else {
144 return;
145 };
146 let semantics = if let Some(semantics) = cache.get(&(owner, child)) {
147 semantics.clone()
148 } else {
149 let mut semantics = resolver
150 .get_spell_effects(SpellId::new(owner as i32))
151 .await
152 .unwrap_or_default()
153 .into_iter()
154 .filter(|effect| {
155 effect.trigger_spell == child as i32
156 && matches!(
157 spell_effect_kind(effect.effect),
158 Some(SpellEffectKind::CancelAura | SpellEffectKind::RemoveAura)
159 )
160 })
161 .map(|effect| format!("SE{}", effect.effect))
162 .collect::<Vec<_>>();
163
164 semantics.sort();
165 semantics.dedup();
166 cache.insert((owner, child), semantics.clone());
167
168 semantics
169 };
170
171 if semantics.is_empty() {
172 return;
173 }
174
175 row.semantic_ids
176 .extend(semantics.into_iter().map(String::into_boxed_str));
177 row.disposition = LedgerDisposition::RedundantGeneric;
178 row.evidence =
179 "unconditional hook expiration duplicates production DBC aura-removal lowering".to_string();
180}
181
182async fn attach_direct_aura_semantics(
183 row: &mut LedgerRow,
184 resolver: &impl DataResolver,
185 cache: &mut BTreeMap<u32, Vec<String>>,
186) {
187 let explicit_edge = row.evidence == collect::EXPLICIT_AURA_EDGE_EVIDENCE;
188 let unconditional_hook_edge = row.disposition == LedgerDisposition::GenericGap
189 && row.evidence == "hook operation `apply_aura`"
190 && row.manifest_key.starts_with("spells.");
191 let Some(spell_id) = row.owning_spell_ids.first().copied().filter(|owner| {
192 (explicit_edge || unconditional_hook_edge)
193 && row.owning_spell_ids.len() == 1
194 && row.child_spell_ids.as_slice() == [*owner]
195 }) else {
196 return;
197 };
198 let semantics = if let Some(semantics) = cache.get(&spell_id) {
199 semantics.clone()
200 } else {
201 let mut semantics = resolver
202 .get_spell_effects(SpellId::new(spell_id as i32))
203 .await
204 .map(|effects| {
205 effects
206 .into_iter()
207 .filter(|effect| effect.effect == SpellEffectKind::ApplyAura as i32)
208 .flat_map(|effect| ["SE6".to_string(), format!("AU{}", effect.aura)])
209 .collect::<Vec<_>>()
210 })
211 .unwrap_or_default();
212
213 semantics.sort();
214 semantics.dedup();
215 cache.insert(spell_id, semantics.clone());
216
217 semantics
218 };
219
220 if semantics.iter().all(|semantic| semantic != "SE6") {
221 return;
222 }
223
224 row.semantic_ids
225 .extend(semantics.into_iter().map(String::into_boxed_str));
226 row.disposition = LedgerDisposition::RedundantGeneric;
227 row.evidence = if explicit_edge {
228 "production DBC SE6 applies the source spell aura through shared effect-program lowering"
229 } else {
230 "unconditional hook application duplicates production DBC SE6 source-aura lowering"
231 }
232 .to_string();
233}
234
235async fn attach_triggered_aura_semantics(
236 row: &mut LedgerRow,
237 resolver: &impl DataResolver,
238 cache: &mut BTreeMap<(u32, u32), Vec<String>>,
239) {
240 let edge = (row.disposition == LedgerDisposition::GenericGap
241 && row.evidence == "hook operation `apply_aura`"
242 && row.manifest_key.starts_with("spells.")
243 && row.owning_spell_ids.len() == 1
244 && row.child_spell_ids.len() == 1)
245 .then(|| (row.owning_spell_ids[0], row.child_spell_ids[0]));
246 let Some((owner, child)) = edge.filter(|(owner, child)| owner != child) else {
247 return;
248 };
249 let semantics = if let Some(semantics) = cache.get(&(owner, child)) {
250 semantics.clone()
251 } else {
252 let semantics = pure_triggered_aura_semantics(resolver, owner, child).await;
253
254 cache.insert((owner, child), semantics.clone());
255
256 semantics
257 };
258
259 if semantics.is_empty() {
260 return;
261 }
262
263 row.semantic_ids
264 .extend(semantics.into_iter().map(String::into_boxed_str));
265 row.disposition = LedgerDisposition::RedundantGeneric;
266 row.evidence =
267 "unconditional hook application duplicates production DBC trigger child-aura lowering"
268 .to_string();
269}
270
271async fn pure_triggered_aura_semantics(
272 resolver: &impl DataResolver,
273 owner: u32,
274 child: u32,
275) -> Vec<String> {
276 let owner_effects = resolver
277 .get_spell_effects(SpellId::new(owner as i32))
278 .await
279 .unwrap_or_default();
280 let mut semantics: Vec<_> = owner_effects
281 .into_iter()
282 .filter(|effect| {
283 effect.trigger_spell == child as i32
284 && matches!(
285 spell_effect_kind(effect.effect),
286 Some(
287 SpellEffectKind::TriggerMissile
288 | SpellEffectKind::TriggerSpell
289 | SpellEffectKind::TriggerSpellWithValue
290 | SpellEffectKind::TriggerSpell2
291 )
292 )
293 })
294 .map(|effect| format!("SE{}", effect.effect))
295 .collect();
296
297 if semantics.is_empty() {
298 return semantics;
299 }
300
301 let child_effects = resolver
302 .get_spell_effects(SpellId::new(child as i32))
303 .await
304 .unwrap_or_default();
305
306 if child_effects.is_empty()
307 || child_effects
308 .iter()
309 .any(|effect| effect.effect != SpellEffectKind::ApplyAura as i32)
310 {
311 return Vec::new();
312 }
313
314 semantics.extend(
315 child_effects
316 .into_iter()
317 .flat_map(|effect| ["SE6".to_string(), format!("AU{}", effect.aura)]),
318 );
319 semantics.sort();
320 semantics.dedup();
321
322 semantics
323}