Skip to main content

wowlab_manifest_schema/
validation.rs

1//! Symbolic reference validation for composed manifests.
2
3use std::fmt;
4
5use indexmap::IndexSet;
6
7use crate::{EventEffectDef, ImpactProcDef, ImpactProcRngDef, Manifest};
8
9/// Kind of manifest entry targeted by a symbolic reference.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11#[non_exhaustive]
12pub enum ManifestReferenceKind {
13    Aura,
14    Spell,
15    Talent,
16}
17
18/// Typed location of a structural manifest diagnostic.
19#[derive(Clone, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub enum ManifestDiagnosticLocation {
22    SpecPrecombatAuras,
23    SpecStealthAura,
24    Aura { name: String },
25    Spell { name: String },
26    AutoAttack { name: String },
27    TalentCompanionAuras { talent: String },
28    TalentGatedAuraEffect { index: usize },
29    SpellGroup { index: usize },
30    ImpactProc { index: usize },
31}
32
33impl fmt::Display for ManifestDiagnosticLocation {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Self::SpecPrecombatAuras => f.write_str("spec.precombat_auras"),
37            Self::SpecStealthAura => f.write_str("spec.stealth_aura"),
38            Self::Aura { name } => write!(f, "auras.{name}"),
39            Self::Spell { name } => write!(f, "spells.{name}"),
40            Self::AutoAttack { name } => write!(f, "auto_attacks.{name}"),
41            Self::TalentCompanionAuras { .. } => f.write_str("talent_companion_auras"),
42            Self::TalentGatedAuraEffect { .. } => f.write_str("talent_gated_aura_effects"),
43            Self::SpellGroup { .. } => f.write_str("spell_groups"),
44            Self::ImpactProc { .. } => f.write_str("impact_procs"),
45        }
46    }
47}
48
49/// Structural problem found while validating a composed manifest.
50#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
51#[non_exhaustive]
52pub enum ManifestDiagnostic {
53    #[error("{location} references missing `{reference}`")]
54    MissingReference {
55        location: ManifestDiagnosticLocation,
56        kind: ManifestReferenceKind,
57        reference: String,
58    },
59    #[error("{location} uses unknown auto_attack_hand `{hand}`")]
60    UnknownAutoAttackHand {
61        location: ManifestDiagnosticLocation,
62        hand: String,
63    },
64    #[error("exactly one of spell, auto_attack_hand, or proc_type_mask_spell_id must be set")]
65    InvalidImpactProcSource {
66        location: ManifestDiagnosticLocation,
67    },
68    #[error(
69        "{location} must set proc_type_mask_effect to a positive DBC effect index exactly when proc_type_mask_spell_id is set"
70    )]
71    InvalidImpactProcDriverCoordinate {
72        location: ManifestDiagnosticLocation,
73    },
74    #[error("{location} has an invalid shuffled RNG deck")]
75    InvalidImpactProcRng {
76        location: ManifestDiagnosticLocation,
77    },
78    #[error("{location} source spell {spell_id} is not a declared aura")]
79    UnknownAuraEffectSource {
80        location: ManifestDiagnosticLocation,
81        spell_id: u32,
82    },
83    #[error("{location} uses invalid DBC effect index 0")]
84    InvalidAuraEffectIndex {
85        location: ManifestDiagnosticLocation,
86    },
87}
88
89impl ManifestDiagnostic {
90    /// Location that produced this diagnostic.
91    #[must_use]
92    pub fn location(&self) -> &ManifestDiagnosticLocation {
93        match self {
94            Self::MissingReference { location, .. }
95            | Self::UnknownAutoAttackHand { location, .. }
96            | Self::InvalidImpactProcSource { location }
97            | Self::InvalidImpactProcDriverCoordinate { location }
98            | Self::InvalidImpactProcRng { location }
99            | Self::UnknownAuraEffectSource { location, .. }
100            | Self::InvalidAuraEffectIndex { location } => location,
101        }
102    }
103}
104
105impl Manifest {
106    /// Returns structural diagnostics for symbolic references in this composed spec.
107    #[must_use]
108    pub fn diagnostics(&self) -> Vec<ManifestDiagnostic> {
109        ManifestValidator::new(self).validate()
110    }
111}
112
113struct ManifestValidator<'a> {
114    manifest: &'a Manifest,
115    auras: IndexSet<&'a str>,
116    spells: IndexSet<&'a str>,
117    talents: IndexSet<&'a str>,
118    diagnostics: Vec<ManifestDiagnostic>,
119}
120
121impl<'a> ManifestValidator<'a> {
122    fn new(manifest: &'a Manifest) -> Self {
123        Self {
124            manifest,
125            auras: manifest.auras.keys().map(String::as_str).collect(),
126            spells: manifest.spells.keys().map(String::as_str).collect(),
127            talents: manifest.talents.keys().map(String::as_str).collect(),
128            diagnostics: Vec::with_capacity(manifest.auras.len() + manifest.spells.len()),
129        }
130    }
131
132    fn validate(mut self) -> Vec<ManifestDiagnostic> {
133        self.validate_spec_and_auras();
134        self.validate_spells_and_auto_attacks();
135        self.validate_collections();
136
137        self.diagnostics
138    }
139
140    // #t(fn: rust_clone_in_loop) each failing reference retains an owned diagnostic location
141    fn validate_spec_and_auras(&mut self) {
142        if let Some(precombat) = &self.manifest.spec.precombat_auras {
143            let location = ManifestDiagnosticLocation::SpecPrecombatAuras;
144
145            for aura in precombat {
146                require_reference(
147                    &self.auras,
148                    aura,
149                    ManifestReferenceKind::Aura,
150                    &location,
151                    &mut self.diagnostics,
152                );
153            }
154        }
155
156        if let Some(aura) = &self.manifest.spec.stealth_aura {
157            require_reference(
158                &self.auras,
159                aura,
160                ManifestReferenceKind::Aura,
161                &ManifestDiagnosticLocation::SpecStealthAura,
162                &mut self.diagnostics,
163            );
164        }
165
166        for (name, aura) in &self.manifest.auras {
167            let location = ManifestDiagnosticLocation::Aura { name: name.clone() };
168
169            if let Some(periodic) = &aura.periodic_apply_aura {
170                require_reference(
171                    &self.auras,
172                    &periodic.aura,
173                    ManifestReferenceKind::Aura,
174                    &location,
175                    &mut self.diagnostics,
176                );
177            }
178
179            validate_event_aura_references(
180                &aura.on_consume,
181                &self.auras,
182                &location,
183                &mut self.diagnostics,
184            );
185
186            for spell in aura.scoped_spell_names() {
187                require_reference(
188                    &self.spells,
189                    spell,
190                    ManifestReferenceKind::Spell,
191                    &location,
192                    &mut self.diagnostics,
193                );
194            }
195        }
196    }
197
198    // #t(fn: rust_clone_in_loop) each named manifest entry owns its public diagnostic context
199    fn validate_spells_and_auto_attacks(&mut self) {
200        for (name, spell) in &self.manifest.spells {
201            let location = ManifestDiagnosticLocation::Spell { name: name.clone() };
202
203            for aura in spell
204                .applies_aura
205                .iter()
206                .chain(spell.applies_auras.iter())
207                .chain(spell.extends_auras.iter().map(|extend| &extend.aura))
208                .chain(spell.cooldown_bypass_when_aura.iter())
209                .chain(spell.instant_when_aura.iter())
210                .chain(spell.override_when_aura.iter())
211                .chain(
212                    spell
213                        .empower
214                        .iter()
215                        .filter_map(|empower| empower.max_when_aura.as_ref()),
216                )
217            {
218                require_reference(
219                    &self.auras,
220                    aura,
221                    ManifestReferenceKind::Aura,
222                    &location,
223                    &mut self.diagnostics,
224                );
225            }
226
227            self.validate_spell_reference_fields(spell, &location);
228        }
229
230        for (name, auto_attack) in &self.manifest.auto_attacks {
231            if let Some(reduction) = &auto_attack.on_crit_reduce_charge {
232                require_reference(
233                    &self.spells,
234                    &reduction.target,
235                    ManifestReferenceKind::Spell,
236                    &ManifestDiagnosticLocation::AutoAttack { name: name.clone() },
237                    &mut self.diagnostics,
238                );
239            }
240        }
241    }
242
243    fn validate_spell_reference_fields(
244        &mut self,
245        spell: &crate::ManifestSpellDef,
246        location: &ManifestDiagnosticLocation,
247    ) {
248        if let Some(override_spell) = &spell.override_with_spell {
249            require_reference(
250                &self.spells,
251                override_spell,
252                ManifestReferenceKind::Spell,
253                location,
254                &mut self.diagnostics,
255            );
256        }
257
258        if let Some(reductions) = &spell.reduces_cd {
259            for reduction in reductions {
260                require_reference(
261                    &self.spells,
262                    &reduction.target,
263                    ManifestReferenceKind::Spell,
264                    location,
265                    &mut self.diagnostics,
266                );
267            }
268        }
269
270        if let Some(reduction) = &spell.reduces_cd_chance {
271            require_reference(
272                &self.spells,
273                &reduction.target,
274                ManifestReferenceKind::Spell,
275                location,
276                &mut self.diagnostics,
277            );
278        }
279
280        if let Some(reset) = &spell.resets_cd_while {
281            require_reference(
282                &self.spells,
283                &reset.target,
284                ManifestReferenceKind::Spell,
285                location,
286                &mut self.diagnostics,
287            );
288            require_reference(
289                &self.auras,
290                &reset.aura,
291                ManifestReferenceKind::Aura,
292                location,
293                &mut self.diagnostics,
294            );
295        }
296
297        if let Some(channel) = &spell.channel {
298            if let Some(alternative) = &channel.tick_cost_alt {
299                require_reference(
300                    &self.auras,
301                    &alternative.aura,
302                    ManifestReferenceKind::Aura,
303                    location,
304                    &mut self.diagnostics,
305                );
306            }
307
308            validate_event_aura_references(
309                &channel.tick_effects,
310                &self.auras,
311                location,
312                &mut self.diagnostics,
313            );
314            validate_event_aura_references(
315                &channel.complete_effects,
316                &self.auras,
317                location,
318                &mut self.diagnostics,
319            );
320        }
321    }
322
323    // #t(fn: rust_clone_in_loop) diagnostics own collection keys, hands, and indexed locations
324    fn validate_collections(&mut self) {
325        for (talent, companion_auras) in &self.manifest.talent_companion_auras {
326            let location = ManifestDiagnosticLocation::TalentCompanionAuras {
327                talent: talent.clone(),
328            };
329
330            require_reference(
331                &self.talents,
332                talent,
333                ManifestReferenceKind::Talent,
334                &location,
335                &mut self.diagnostics,
336            );
337
338            for aura in companion_auras {
339                require_reference(
340                    &self.auras,
341                    aura,
342                    ManifestReferenceKind::Aura,
343                    &location,
344                    &mut self.diagnostics,
345                );
346            }
347        }
348
349        self.validate_talent_gated_aura_effects();
350
351        for (index, group) in self.manifest.spell_groups.iter().enumerate() {
352            let location = ManifestDiagnosticLocation::SpellGroup { index };
353
354            for aura in &group.auras {
355                require_reference(
356                    &self.auras,
357                    aura,
358                    ManifestReferenceKind::Aura,
359                    &location,
360                    &mut self.diagnostics,
361                );
362            }
363        }
364
365        for (index, proc) in self.manifest.impact_procs.iter().enumerate() {
366            let location = ManifestDiagnosticLocation::ImpactProc { index };
367
368            if let Some(spell) = &proc.spell {
369                require_reference(
370                    &self.spells,
371                    spell,
372                    ManifestReferenceKind::Spell,
373                    &location,
374                    &mut self.diagnostics,
375                );
376            }
377
378            if let Some(talent) = &proc.talent {
379                require_reference(
380                    &self.talents,
381                    talent,
382                    ManifestReferenceKind::Talent,
383                    &location,
384                    &mut self.diagnostics,
385                );
386            }
387
388            if let Some(hand) = &proc.auto_attack_hand
389                && hand != "mainhand"
390                && hand != "offhand"
391            {
392                self.diagnostics
393                    .push(ManifestDiagnostic::UnknownAutoAttackHand {
394                        location: location.clone(),
395                        hand: hand.clone(),
396                    });
397            }
398
399            let source_count = [
400                proc.spell.is_some(),
401                proc.auto_attack_hand.is_some(),
402                proc.proc_type_mask_spell_id.is_some(),
403            ]
404            .into_iter()
405            .filter(|present| *present)
406            .count();
407
408            if source_count != 1 {
409                self.diagnostics
410                    .push(ManifestDiagnostic::InvalidImpactProcSource {
411                        location: location.clone(),
412                    });
413            }
414
415            validate_impact_proc_driver_coordinate(proc, &location, &mut self.diagnostics);
416
417            if matches!(
418                proc.rng_model,
419                ImpactProcRngDef::Shuffled {
420                    success_entries,
421                    total_entries,
422                } if total_entries == 0 || success_entries > total_entries
423            ) {
424                self.diagnostics
425                    .push(ManifestDiagnostic::InvalidImpactProcRng {
426                        location: location.clone(),
427                    });
428            }
429
430            validate_event_aura_references(
431                &proc.effects,
432                &self.auras,
433                &location,
434                &mut self.diagnostics,
435            );
436        }
437    }
438
439    fn validate_talent_gated_aura_effects(&mut self) {
440        for (index, gate) in self.manifest.talent_gated_aura_effects.iter().enumerate() {
441            let location = ManifestDiagnosticLocation::TalentGatedAuraEffect { index };
442
443            require_reference(
444                &self.talents,
445                &gate.talent,
446                ManifestReferenceKind::Talent,
447                &location,
448                &mut self.diagnostics,
449            );
450
451            if let Some(target_aura) = &gate.target_aura {
452                require_reference(
453                    &self.auras,
454                    target_aura,
455                    ManifestReferenceKind::Aura,
456                    &location,
457                    &mut self.diagnostics,
458                );
459            }
460
461            if !self
462                .manifest
463                .auras
464                .values()
465                .any(|aura| aura.id == gate.source.spell_id)
466            {
467                self.diagnostics
468                    .push(ManifestDiagnostic::UnknownAuraEffectSource {
469                        location: ManifestDiagnosticLocation::TalentGatedAuraEffect { index },
470                        spell_id: gate.source.spell_id,
471                    });
472            }
473
474            if gate.source.effect == 0 {
475                self.diagnostics
476                    .push(ManifestDiagnostic::InvalidAuraEffectIndex {
477                        location: ManifestDiagnosticLocation::TalentGatedAuraEffect { index },
478                    });
479            }
480        }
481    }
482}
483
484fn validate_impact_proc_driver_coordinate(
485    proc: &ImpactProcDef,
486    location: &ManifestDiagnosticLocation,
487    diagnostics: &mut Vec<ManifestDiagnostic>,
488) {
489    if matches!(
490        (proc.proc_type_mask_spell_id, proc.proc_type_mask_effect),
491        (None, None) | (Some(_), Some(1..))
492    ) {
493        return;
494    }
495
496    diagnostics.push(ManifestDiagnostic::InvalidImpactProcDriverCoordinate {
497        location: location.clone(),
498    });
499}
500
501fn require_reference(
502    available: &IndexSet<&str>,
503    reference: &str,
504    kind: ManifestReferenceKind,
505    location: &ManifestDiagnosticLocation,
506    diagnostics: &mut Vec<ManifestDiagnostic>,
507) {
508    if !available.contains(reference) {
509        diagnostics.push(ManifestDiagnostic::MissingReference {
510            location: location.clone(),
511            kind,
512            reference: reference.to_owned(),
513        });
514    }
515}
516
517fn validate_event_aura_references(
518    effects: &[EventEffectDef],
519    auras: &IndexSet<&str>,
520    location: &ManifestDiagnosticLocation,
521    diagnostics: &mut Vec<ManifestDiagnostic>,
522) {
523    for effect in effects {
524        if let EventEffectDef::ExtendAura { aura, .. } = effect {
525            require_reference(
526                auras,
527                aura,
528                ManifestReferenceKind::Aura,
529                location,
530                diagnostics,
531            );
532        }
533    }
534}