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                if hand != "mainhand" && hand != "offhand" {
390                    self.diagnostics
391                        .push(ManifestDiagnostic::UnknownAutoAttackHand {
392                            location: location.clone(),
393                            hand: hand.clone(),
394                        });
395                }
396            }
397
398            let source_count = [
399                proc.spell.is_some(),
400                proc.auto_attack_hand.is_some(),
401                proc.proc_type_mask_spell_id.is_some(),
402            ]
403            .into_iter()
404            .filter(|present| *present)
405            .count();
406
407            if source_count != 1 {
408                self.diagnostics
409                    .push(ManifestDiagnostic::InvalidImpactProcSource {
410                        location: location.clone(),
411                    });
412            }
413
414            validate_impact_proc_driver_coordinate(proc, &location, &mut self.diagnostics);
415
416            if matches!(
417                proc.rng_model,
418                ImpactProcRngDef::Shuffled {
419                    success_entries,
420                    total_entries,
421                } if total_entries == 0 || success_entries > total_entries
422            ) {
423                self.diagnostics
424                    .push(ManifestDiagnostic::InvalidImpactProcRng {
425                        location: location.clone(),
426                    });
427            }
428
429            validate_event_aura_references(
430                &proc.effects,
431                &self.auras,
432                &location,
433                &mut self.diagnostics,
434            );
435        }
436    }
437
438    fn validate_talent_gated_aura_effects(&mut self) {
439        for (index, gate) in self.manifest.talent_gated_aura_effects.iter().enumerate() {
440            let location = ManifestDiagnosticLocation::TalentGatedAuraEffect { index };
441
442            require_reference(
443                &self.talents,
444                &gate.talent,
445                ManifestReferenceKind::Talent,
446                &location,
447                &mut self.diagnostics,
448            );
449
450            if let Some(target_aura) = &gate.target_aura {
451                require_reference(
452                    &self.auras,
453                    target_aura,
454                    ManifestReferenceKind::Aura,
455                    &location,
456                    &mut self.diagnostics,
457                );
458            }
459
460            if !self
461                .manifest
462                .auras
463                .values()
464                .any(|aura| aura.id == gate.source.spell_id)
465            {
466                self.diagnostics
467                    .push(ManifestDiagnostic::UnknownAuraEffectSource {
468                        location: ManifestDiagnosticLocation::TalentGatedAuraEffect { index },
469                        spell_id: gate.source.spell_id,
470                    });
471            }
472
473            if gate.source.effect == 0 {
474                self.diagnostics
475                    .push(ManifestDiagnostic::InvalidAuraEffectIndex {
476                        location: ManifestDiagnosticLocation::TalentGatedAuraEffect { index },
477                    });
478            }
479        }
480    }
481}
482
483fn validate_impact_proc_driver_coordinate(
484    proc: &ImpactProcDef,
485    location: &ManifestDiagnosticLocation,
486    diagnostics: &mut Vec<ManifestDiagnostic>,
487) {
488    if matches!(
489        (proc.proc_type_mask_spell_id, proc.proc_type_mask_effect),
490        (None, None) | (Some(_), Some(1..))
491    ) {
492        return;
493    }
494
495    diagnostics.push(ManifestDiagnostic::InvalidImpactProcDriverCoordinate {
496        location: location.clone(),
497    });
498}
499
500fn require_reference(
501    available: &IndexSet<&str>,
502    reference: &str,
503    kind: ManifestReferenceKind,
504    location: &ManifestDiagnosticLocation,
505    diagnostics: &mut Vec<ManifestDiagnostic>,
506) {
507    if !available.contains(reference) {
508        diagnostics.push(ManifestDiagnostic::MissingReference {
509            location: location.clone(),
510            kind,
511            reference: reference.to_owned(),
512        });
513    }
514}
515
516fn validate_event_aura_references(
517    effects: &[EventEffectDef],
518    auras: &IndexSet<&str>,
519    location: &ManifestDiagnosticLocation,
520    diagnostics: &mut Vec<ManifestDiagnostic>,
521) {
522    for effect in effects {
523        if let EventEffectDef::ExtendAura { aura, .. } = effect {
524            require_reference(
525                auras,
526                aura,
527                ManifestReferenceKind::Aura,
528                location,
529                diagnostics,
530            );
531        }
532    }
533}