1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4
5use anyhow::{Context, Result, bail};
6use wowlab_common::output;
7use wowlab_manifest_schema::{ContentEffectCoverage, ContentEffectStatus, Manifest};
8use wowlab_parsers::{DbcData, transform_spell};
9use wowlab_types::game::SpecId;
10
11use super::TalentSnapshot;
12use crate::{
13 talent_attribute_summary::AttributeSummaries,
14 talent_aura_summary::AuraSummaries,
15 talent_effect_summary::EffectSummaries,
16 talent_modifier_summary::ModifierSummaries,
17 talent_target_flag_summary::TargetFlagSummaries,
18 talent_target_plan_summary::{TargetPlanAxisFilter, TargetPlanSummaries},
19};
20
21mod target;
22
23use target::{
24 TargetPlanAuditContext, audit_effect_target_plan, audit_spell_attributes,
25 audit_spell_target_flags, effect_audit_row,
26};
27
28#[derive(Default)]
29struct EffectAuditCounts {
30 spells: usize,
31 unresolved_spells: usize,
32 effects: usize,
33 generic: usize,
34 partial: usize,
35 content_required: usize,
36 content_modeled: usize,
37 content_ignored: usize,
38 content_undeclared: usize,
39 ignored: usize,
40 unsupported: usize,
41 attributes: usize,
42 attribute_generic: usize,
43 attribute_partial: usize,
44 attribute_ignored: usize,
45 attribute_unsupported: usize,
46 target_plans: usize,
47 target_plan_generic: usize,
48 target_plan_overlaid: usize,
49 target_plan_unsupported: usize,
50 target_flags: usize,
51 target_flag_generic: usize,
52 target_flag_partial: usize,
53 target_flag_ignored: usize,
54 target_flag_unsupported: usize,
55}
56
57#[derive(Clone, Copy)]
58struct ContentEffectDeclaration<'a> {
59 name: &'a str,
60 status: ContentEffectStatus,
61 reason: &'a str,
62}
63
64struct ContentEffectLedger<'a> {
65 declarations: BTreeMap<(i32, i32), ContentEffectDeclaration<'a>>,
66}
67
68impl<'a> ContentEffectLedger<'a> {
69 fn from_manifest(manifest: &'a Manifest) -> Result<Self> {
70 let mut declarations = BTreeMap::new();
71
72 for (name, declaration) in &manifest.content_effects {
73 validate_content_effect_declaration(name, declaration)?;
74 let spell_id = i32::try_from(declaration.spell_id)
75 .with_context(|| format!("content effect {name} spell ID exceeds i32"))?;
76 let coordinate = (spell_id, i32::from(declaration.effect));
77 let entry = ContentEffectDeclaration {
78 name,
79 status: declaration.status,
80 reason: declaration.reason.trim(),
81 };
82
83 if let Some(previous) = declarations.insert(coordinate, entry) {
84 bail!(
85 "content effects {} and {name} both declare spell {} effect {}",
86 previous.name,
87 declaration.spell_id,
88 declaration.effect
89 );
90 }
91 }
92
93 Ok(Self { declarations })
94 }
95
96 fn get(&self, spell_id: i32, effect_index: i32) -> Option<ContentEffectDeclaration<'a>> {
97 self.declarations.get(&(spell_id, effect_index)).copied()
98 }
99}
100
101fn validate_content_effect_declaration(
102 name: &str,
103 declaration: &ContentEffectCoverage,
104) -> Result<()> {
105 if declaration.spell_id == 0 {
106 bail!("content effect {name} has spell_id 0");
107 }
108
109 if declaration.effect == 0 {
110 bail!("content effect {name} has effect 0; effect indices are one-based");
111 }
112
113 if declaration.reason.trim().is_empty() {
114 bail!("content effect {name} must explain how the effect is accounted for");
115 }
116
117 Ok(())
118}
119
120#[derive(tabled::Tabled)]
121struct EffectAuditRow {
122 #[tabled(rename = "Spell")]
123 spell: String,
124 #[tabled(rename = "Effect")]
125 effect_index: i32,
126 #[tabled(rename = "Type")]
127 effect_type: i32,
128 #[tabled(rename = "Aura")]
129 aura_subtype: i32,
130 #[tabled(rename = "Property")]
131 modifier_property: i32,
132 #[tabled(rename = "Trigger")]
133 trigger_spell: i32,
134 #[tabled(rename = "Coverage")]
135 coverage: &'static str,
136 #[tabled(rename = "Handler / gap")]
137 handler: String,
138}
139
140#[derive(tabled::Tabled)]
141struct AttributeAuditRow {
142 #[tabled(rename = "Spell")]
143 spell: String,
144 #[tabled(rename = "Attribute")]
145 attribute: u16,
146 #[tabled(rename = "Name")]
147 name: &'static str,
148 #[tabled(rename = "Coverage")]
149 coverage: &'static str,
150 #[tabled(rename = "Handler / gap")]
151 handler: &'static str,
152}
153
154#[derive(tabled::Tabled)]
155struct TargetPlanAuditRow {
156 #[tabled(rename = "Spell")]
157 spell: String,
158 #[tabled(rename = "Effect")]
159 effect_index: i32,
160 #[tabled(rename = "TargetA / TargetB")]
161 selectors: String,
162 #[tabled(rename = "Program")]
163 program: String,
164 #[tabled(rename = "Provenance")]
165 provenance: String,
166 #[tabled(rename = "Coverage")]
167 coverage: &'static str,
168 #[tabled(rename = "Handler / gap")]
169 handler: String,
170}
171
172#[derive(tabled::Tabled)]
173struct TargetFlagAuditRow {
174 #[tabled(rename = "Spell")]
175 spell: String,
176 #[tabled(rename = "Bit")]
177 bit: u32,
178 #[tabled(rename = "Name")]
179 name: &'static str,
180 #[tabled(rename = "Required")]
181 required: bool,
182 #[tabled(rename = "Coverage")]
183 coverage: &'static str,
184 #[tabled(rename = "Handler / gap")]
185 handler: &'static str,
186}
187
188#[derive(tabled::Tabled)]
189struct UnresolvedSpellAuditRow {
190 #[tabled(rename = "Spell ID")]
191 spell_id: i32,
192 #[tabled(rename = "Gap")]
193 gap: String,
194}
195
196#[expect(
199 clippy::too_many_arguments,
200 reason = "the audit pipeline passes independent summary accumulators explicitly"
201)]
202pub(super) fn audit_effect_semantics(
203 dbc: &DbcData,
204 spec: SpecId,
205 snapshot: &TalentSnapshot,
206 manifest: &Manifest,
207 attribute_filter: &[u16],
208 attribute_summaries: &mut AttributeSummaries,
209 aura_filter: &[i32],
210 aura_summaries: &mut AuraSummaries,
211 effect_filter: &[i32],
212 effect_summaries: &mut EffectSummaries,
213 modifier_filter: &[i32],
214 modifier_aura_filter: &[i32],
215 modifier_summaries: &mut ModifierSummaries,
216 target_plan_filter: &[TargetPlanAxisFilter],
217 target_plan_summaries: &mut TargetPlanSummaries,
218 target_flag_filter: &[u8],
219 target_flag_summaries: &mut TargetFlagSummaries,
220) -> Result<()> {
221 let content_effects = ContentEffectLedger::from_manifest(manifest)?;
222 let mut pending: VecDeque<i32> = snapshot
223 .effective_records
224 .iter()
225 .map(|record| record.spell_id)
226 .filter(|spell_id| *spell_id > 0)
227 .collect();
228
229 pending.extend(manifest_semantic_roots(manifest));
230 let mut visited = BTreeSet::new();
231 let mut counts = EffectAuditCounts::default();
232 let mut rows = Vec::new();
233 let mut attribute_rows = Vec::new();
234 let mut target_plan_rows = Vec::new();
235 let mut target_flag_rows = Vec::new();
236 let mut unresolved_spell_rows = Vec::new();
237
238 while let Some(spell_id) = pending.pop_front() {
239 if !visited.insert(spell_id) {
240 continue;
241 }
242
243 let spell = match transform_spell(dbc, spell_id, None) {
244 Ok(spell) => spell,
245 Err(error) => {
246 counts.unresolved_spells += 1;
247 unresolved_spell_rows.push(UnresolvedSpellAuditRow {
248 spell_id,
249 gap: error.to_string(),
250 });
251 continue;
252 }
253 };
254
255 counts.spells += 1;
256 attribute_summaries.observe(
257 spec,
258 spell_id,
259 &spell.name,
260 spell
261 .attributes
262 .iter()
263 .copied()
264 .enumerate()
265 .flat_map(|(block, value)| {
266 (0..i32::BITS).filter_map(move |bit| {
267 (value & 1_i32.wrapping_shl(bit) != 0)
268 .then_some(block * i32::BITS as usize + bit as usize)
269 .and_then(|raw| u16::try_from(raw).ok())
270 })
271 }),
272 attribute_filter,
273 );
274 aura_summaries.observe(spec, &spell, aura_filter);
275 effect_summaries.observe(spec, &spell, effect_filter);
276 modifier_summaries.observe(spec, &spell, modifier_filter, modifier_aura_filter);
277 target_flag_summaries.observe(spec, &spell, target_flag_filter);
278 audit_spell_attributes(
279 spell_id,
280 &spell.name,
281 &spell.attributes,
282 &mut attribute_rows,
283 &mut counts,
284 );
285 audit_spell_target_flags(
286 spell_id,
287 &spell.name,
288 &spell,
289 &mut target_flag_rows,
290 &mut counts,
291 );
292
293 for effect in &spell.effects {
294 counts.effects += 1;
295 audit_effect_target_plan(
296 spell_id,
297 &spell.name,
298 effect,
299 &mut TargetPlanAuditContext {
300 spec,
301 rows: &mut target_plan_rows,
302 counts: &mut counts,
303 filter: target_plan_filter,
304 summaries: target_plan_summaries,
305 },
306 );
307 rows.push(effect_audit_row(
308 spell_id,
309 &spell.name,
310 effect,
311 &content_effects,
312 &mut counts,
313 ));
314
315 if effect.trigger_spell > 0 {
316 pending.push_back(effect.trigger_spell);
317 }
318 }
319
320 pending.extend(
321 spell
322 .learn_spells
323 .iter()
324 .map(|learned| learned.learn_spell_id)
325 .filter(|spell_id| *spell_id > 0),
326 );
327 }
328
329 if attribute_filter.is_empty()
330 && aura_filter.is_empty()
331 && effect_filter.is_empty()
332 && modifier_filter.is_empty()
333 && target_plan_filter.is_empty()
334 && target_flag_filter.is_empty()
335 {
336 print_effect_audit(
337 spec,
338 rows,
339 attribute_rows,
340 target_plan_rows,
341 target_flag_rows,
342 unresolved_spell_rows,
343 &counts,
344 );
345 }
346
347 if counts.unsupported > 0
348 || counts.attribute_unsupported > 0
349 || counts.content_undeclared > 0
350 || counts.target_plan_unsupported > 0
351 || counts.target_flag_unsupported > 0
352 || counts.unresolved_spells > 0
353 {
354 bail!(
355 "{} effects, {} spell attributes, {} target plans, and {} target flags have unsupported semantics; {} content-required effects are undeclared; {} registered spells are unresolved",
356 counts.unsupported,
357 counts.attribute_unsupported,
358 counts.target_plan_unsupported,
359 counts.target_flag_unsupported,
360 counts.content_undeclared,
361 counts.unresolved_spells,
362 );
363 }
364
365 Ok(())
366}
367
368fn print_effect_audit(
370 spec: SpecId,
371 rows: Vec<EffectAuditRow>,
372 attribute_rows: Vec<AttributeAuditRow>,
373 target_plan_rows: Vec<TargetPlanAuditRow>,
374 target_flag_rows: Vec<TargetFlagAuditRow>,
375 unresolved_spell_rows: Vec<UnresolvedSpellAuditRow>,
376 counts: &EffectAuditCounts,
377) {
378 output::blank();
379 output::header("Spec effect semantics");
380 output::table(rows);
381 output::blank();
382 output::header("Spec spell attributes");
383 output::table(attribute_rows);
384 output::blank();
385 output::header("Spec compiled target plans");
386 output::table(target_plan_rows);
387 output::blank();
388 output::header("Spec cast target flags");
389 output::table(target_flag_rows);
390 output::blank();
391 output::header("Unresolved registered spells");
392 output::table(unresolved_spell_rows);
393 output::kv("Spec", spec.slug());
394 output::kv_fmt("Spells", counts.spells);
395 output::kv_fmt("Unresolved spells", counts.unresolved_spells);
396 output::kv_fmt("Effects", counts.effects);
397 output::kv_fmt("Generic", counts.generic);
398 output::kv_fmt("Partial", counts.partial);
399 output::kv_fmt("Content required", counts.content_required);
400 output::kv_fmt("Content modeled", counts.content_modeled);
401 output::kv_fmt("Content ignored", counts.content_ignored);
402 output::kv_fmt("Content undeclared", counts.content_undeclared);
403 output::kv_fmt("Ignored", counts.ignored);
404 output::kv_fmt("Unsupported", counts.unsupported);
405 output::kv_fmt("Spell attributes", counts.attributes);
406 output::kv_fmt("Attribute generic", counts.attribute_generic);
407 output::kv_fmt("Attribute partial", counts.attribute_partial);
408 output::kv_fmt("Attribute ignored", counts.attribute_ignored);
409 output::kv_fmt("Attribute unsupported", counts.attribute_unsupported);
410 output::kv_fmt("Target plans", counts.target_plans);
411 output::kv_fmt("Target plan generic", counts.target_plan_generic);
412 output::kv_fmt("Target plan overlaid", counts.target_plan_overlaid);
413 output::kv_fmt("Target plan unsupported", counts.target_plan_unsupported);
414 output::kv_fmt("Target flags", counts.target_flags);
415 output::kv_fmt("Target flag generic", counts.target_flag_generic);
416 output::kv_fmt("Target flag partial", counts.target_flag_partial);
417 output::kv_fmt("Target flag ignored", counts.target_flag_ignored);
418 output::kv_fmt("Target flag unsupported", counts.target_flag_unsupported);
419}
420
421fn manifest_semantic_roots(manifest: &Manifest) -> impl Iterator<Item = i32> + '_ {
422 std::iter::once(manifest.mastery.spell_id)
423 .chain(manifest.spells.values().map(|spell| spell.id))
424 .chain(manifest.auras.values().map(|aura| aura.id))
425 .chain(manifest.talents.values().copied())
426 .chain(manifest.set_bonuses.values().copied())
427 .chain(manifest.auto_attacks.values().map(|attack| attack.spell_id))
428 .chain(manifest.reported_spells.values().copied())
429 .filter_map(|spell_id| i32::try_from(spell_id).ok())
430 .filter(|spell_id| *spell_id > 0)
431}