Skip to main content

wowlab_engine_ports/
spec_metadata.rs

1//! Static spec declarations emitted from composed manifests.
2
3use wowlab_types::{combat::ResourceType, sim::FastSet};
4
5/// Static description of one hero talent tree.
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7pub struct HeroTalentTreeDesc {
8    pub name: &'static str,
9    pub spells: &'static [(&'static str, u32)],
10    pub auras: &'static [(&'static str, u32)],
11}
12
13/// One manifest-authored server `SpellProcEntry` overlay.
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub struct DeclaredServerProcEntry {
16    pub spell_id: u32,
17    pub attributes: u32,
18}
19
20/// The replacement value a passive effect applies when its stored value differs from live behavior.
21///
22/// The result is the `source` effect's post-rescale value times `multiplier`.
23///   With no source named it is `value` times `multiplier` instead.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DeclaredPassiveEffectOverride {
26    pub spell_id: u32,
27    pub effect: u8,
28    pub source: Option<(u32, u8)>,
29    pub value: f64,
30    pub multiplier: f64,
31}
32
33/// Complete static metadata declared by one composed spec manifest.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub struct DeclaredSpecMetadata {
36    pub primary_resource: ResourceType,
37    pub secondary_resource: Option<ResourceType>,
38    pub spell_ids: &'static [u32],
39    pub aura_ids: &'static [u32],
40    pub server_proc_entries: &'static [DeclaredServerProcEntry],
41    pub talents: &'static [(&'static str, u32)],
42    pub rotation_field_schema: &'static [&'static str],
43    pub metrics_plugin_id: Option<u32>,
44    pub hero_talent_trees: &'static [HeroTalentTreeDesc],
45    pub reported_spells: &'static [(&'static str, u32)],
46    /// Passive spell effects this specialization does not receive, as `(spell_id, effect_index)`.
47    /// The static passive fold skips these effects.
48    pub masked_passive_effects: &'static [(u32, u8)],
49    /// Replacement values for passive effects whose stored value the game never applies.
50    pub passive_effect_overrides: &'static [DeclaredPassiveEffectOverride],
51}
52
53wowlab_engine_macros::define_error! {
54/// Invalid static metadata supplied to a spec descriptor.
55#[derive(Debug, Eq, PartialEq)]
56pub struct DeclaredSpecMetadataError {
57    kind: DeclaredSpecMetadataErrorKind,
58}
59
60#[derive(Debug, thiserror::Error, Eq, PartialEq)]
61enum DeclaredSpecMetadataErrorKind {
62    #[error("declared spec metadata contains no spell IDs")]
63    EmptySpellIds,
64    #[error("declared {category} contains reserved spell ID 0")]
65    ZeroSpellId { category: &'static str },
66    #[error("declared aura ID {spell_id} is absent from the spell-resolution set")]
67    AuraMissingFromSpells { spell_id: u32 },
68    #[error("declared server proc spell ID {spell_id} is absent from the spell-resolution set")]
69    ServerProcMissingFromSpells { spell_id: u32 },
70    #[error("declared server proc spell ID {spell_id} appears more than once")]
71    DuplicateServerProc { spell_id: u32 },
72    #[error("declared server proc spell ID {spell_id} has no attributes")]
73    EmptyServerProcAttributes { spell_id: u32 },
74    #[error(
75        "declared {category} '{name}' with ID {spell_id} is absent from the spell-resolution set"
76    )]
77    NamedSpellMissingFromSpells {
78        category: &'static str,
79        name: &'static str,
80        spell_id: u32,
81    },
82    #[error(
83        "declared {category} '{name}' with ID {spell_id} is absent from the aura-resolution set"
84    )]
85    NamedAuraMissingFromAuras {
86        category: &'static str,
87        name: &'static str,
88        spell_id: u32,
89    },
90    #[error("declared {category} contains an empty symbolic name")]
91    EmptyName { category: &'static str },
92    #[error("declared {category} contains duplicate symbolic name '{name}'")]
93    DuplicateName {
94        category: &'static str,
95        name: &'static str,
96    },
97    #[error("primary and secondary resources are both {resource}")]
98    DuplicateResource { resource: ResourceType },
99}
100}
101
102impl DeclaredSpecMetadataError {
103    fn new(kind: DeclaredSpecMetadataErrorKind) -> Self {
104        Self { kind }
105    }
106}
107
108impl DeclaredSpecMetadata {
109    /// Validate cross-field declaration invariants (duplicate IDs are allowed; symbolic names must be unique per category).
110    /// # Errors
111    /// Returns a typed error when required IDs, names, or resource relationships are invalid.
112    pub fn validate(&self) -> Result<(), DeclaredSpecMetadataError> {
113        self.validate_resolution_sets()?;
114
115        self.validate_named_declarations()
116    }
117
118    fn validate_resolution_sets(&self) -> Result<(), DeclaredSpecMetadataError> {
119        if self.spell_ids.is_empty() {
120            return Err(DeclaredSpecMetadataError::new(
121                DeclaredSpecMetadataErrorKind::EmptySpellIds,
122            ));
123        }
124
125        ensure_nonzero_ids("spell IDs", self.spell_ids)?;
126        ensure_nonzero_ids("aura IDs", self.aura_ids)?;
127
128        if self.secondary_resource == Some(self.primary_resource) {
129            return Err(DeclaredSpecMetadataError::new(
130                DeclaredSpecMetadataErrorKind::DuplicateResource {
131                    resource: self.primary_resource,
132                },
133            ));
134        }
135
136        let spells: FastSet<u32> = self.spell_ids.iter().copied().collect();
137        let auras: FastSet<u32> = self.aura_ids.iter().copied().collect();
138
139        for &spell_id in self.aura_ids {
140            if !spells.contains(&spell_id) {
141                return Err(DeclaredSpecMetadataError::new(
142                    DeclaredSpecMetadataErrorKind::AuraMissingFromSpells { spell_id },
143                ));
144            }
145        }
146
147        validate_server_proc_entries(self.server_proc_entries, &spells)?;
148
149        validate_named_spells("reported spells", self.reported_spells, &spells)?;
150        validate_hero_talent_trees(self.hero_talent_trees, &spells, &auras)?;
151
152        Ok(())
153    }
154
155    fn validate_named_declarations(&self) -> Result<(), DeclaredSpecMetadataError> {
156        ensure_unique_names("talents", self.talents.iter().map(|&(name, _)| name))?;
157        ensure_nonzero_named_ids("talents", self.talents)?;
158        ensure_unique_names(
159            "rotation fields",
160            self.rotation_field_schema.iter().copied(),
161        )?;
162        ensure_unique_names(
163            "hero talent trees",
164            self.hero_talent_trees.iter().map(|tree| tree.name),
165        )?;
166
167        Ok(())
168    }
169}
170
171fn validate_server_proc_entries(
172    entries: &[DeclaredServerProcEntry],
173    spells: &FastSet<u32>,
174) -> Result<(), DeclaredSpecMetadataError> {
175    let mut seen = FastSet::default();
176
177    for entry in entries {
178        if !spells.contains(&entry.spell_id) {
179            return Err(DeclaredSpecMetadataError::new(
180                DeclaredSpecMetadataErrorKind::ServerProcMissingFromSpells {
181                    spell_id: entry.spell_id,
182                },
183            ));
184        }
185
186        if entry.attributes == 0 {
187            return Err(DeclaredSpecMetadataError::new(
188                DeclaredSpecMetadataErrorKind::EmptyServerProcAttributes {
189                    spell_id: entry.spell_id,
190                },
191            ));
192        }
193
194        if !seen.insert(entry.spell_id) {
195            return Err(DeclaredSpecMetadataError::new(
196                DeclaredSpecMetadataErrorKind::DuplicateServerProc {
197                    spell_id: entry.spell_id,
198                },
199            ));
200        }
201    }
202
203    Ok(())
204}
205
206fn validate_hero_talent_trees(
207    trees: &[HeroTalentTreeDesc],
208    spells: &FastSet<u32>,
209    auras: &FastSet<u32>,
210) -> Result<(), DeclaredSpecMetadataError> {
211    for tree in trees {
212        validate_named_spells("hero talent spells", tree.spells, spells)?;
213        validate_named_auras("hero talent auras", tree.auras, spells, auras)?;
214    }
215
216    Ok(())
217}
218
219fn ensure_nonzero_ids(
220    category: &'static str,
221    ids: &[u32],
222) -> Result<(), DeclaredSpecMetadataError> {
223    if ids.contains(&0) {
224        return Err(DeclaredSpecMetadataError::new(
225            DeclaredSpecMetadataErrorKind::ZeroSpellId { category },
226        ));
227    }
228
229    Ok(())
230}
231
232fn ensure_nonzero_named_ids(
233    category: &'static str,
234    entries: &[(&'static str, u32)],
235) -> Result<(), DeclaredSpecMetadataError> {
236    if entries.iter().any(|&(_, spell_id)| spell_id == 0) {
237        return Err(DeclaredSpecMetadataError::new(
238            DeclaredSpecMetadataErrorKind::ZeroSpellId { category },
239        ));
240    }
241
242    Ok(())
243}
244
245fn ensure_unique_names(
246    category: &'static str,
247    names: impl IntoIterator<Item = &'static str>,
248) -> Result<(), DeclaredSpecMetadataError> {
249    let mut seen = FastSet::default();
250
251    for name in names {
252        if name.is_empty() {
253            return Err(DeclaredSpecMetadataError::new(
254                DeclaredSpecMetadataErrorKind::EmptyName { category },
255            ));
256        }
257
258        if !seen.insert(name) {
259            return Err(DeclaredSpecMetadataError::new(
260                DeclaredSpecMetadataErrorKind::DuplicateName { category, name },
261            ));
262        }
263    }
264
265    Ok(())
266}
267
268fn validate_named_spells(
269    category: &'static str,
270    entries: &[(&'static str, u32)],
271    spells: &FastSet<u32>,
272) -> Result<(), DeclaredSpecMetadataError> {
273    ensure_unique_names(category, entries.iter().map(|&(name, _)| name))?;
274    ensure_nonzero_named_ids(category, entries)?;
275
276    for &(name, spell_id) in entries {
277        if !spells.contains(&spell_id) {
278            return Err(DeclaredSpecMetadataError::new(
279                DeclaredSpecMetadataErrorKind::NamedSpellMissingFromSpells {
280                    category,
281                    name,
282                    spell_id,
283                },
284            ));
285        }
286    }
287
288    Ok(())
289}
290
291fn validate_named_auras(
292    category: &'static str,
293    entries: &[(&'static str, u32)],
294    spells: &FastSet<u32>,
295    auras: &FastSet<u32>,
296) -> Result<(), DeclaredSpecMetadataError> {
297    validate_named_spells(category, entries, spells)?;
298
299    for &(name, spell_id) in entries {
300        if !auras.contains(&spell_id) {
301            return Err(DeclaredSpecMetadataError::new(
302                DeclaredSpecMetadataErrorKind::NamedAuraMissingFromAuras {
303                    category,
304                    name,
305                    spell_id,
306                },
307            ));
308        }
309    }
310
311    Ok(())
312}
313
314#[cfg(test)]
315mod tests;