Skip to main content

wowlab_engine_ports/
content_catalog.rs

1//! Link-time catalog for generated engine content.
2
3use wowlab_types::{game::SpecId, sim::FastSet};
4
5use crate::{DeclaredSpecMetadataError, SpecDescriptor};
6
7/// One authored selector and the additional spell IDs it requires during resolution.
8#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9pub struct ResolveDependencies {
10    selector_id: u32,
11    spell_ids: &'static [u32],
12}
13
14impl ResolveDependencies {
15    /// Create a static resolve-dependency declaration.
16    #[must_use]
17    pub const fn new(selector_id: u32, spell_ids: &'static [u32]) -> Self {
18        Self {
19            selector_id,
20            spell_ids,
21        }
22    }
23
24    /// The item or trait spell that selects these dependencies.
25    #[must_use]
26    pub const fn selector_id(self) -> u32 {
27        self.selector_id
28    }
29
30    /// Additional spell IDs required by the selected content.
31    #[must_use]
32    pub const fn spell_ids(self) -> &'static [u32] {
33        self.spell_ids
34    }
35}
36
37/// Deterministic engine-content catalog injected through one link-time registration.
38#[derive(Debug)]
39pub struct ContentCatalog {
40    descriptors: &'static [SpecDescriptor],
41    item_dependencies: &'static [ResolveDependencies],
42    expansion_trait_dependencies: &'static [ResolveDependencies],
43}
44
45impl ContentCatalog {
46    /// Create a catalog from static generated and handwritten declarations.
47    #[must_use]
48    pub const fn new(
49        descriptors: &'static [SpecDescriptor],
50        item_dependencies: &'static [ResolveDependencies],
51        expansion_trait_dependencies: &'static [ResolveDependencies],
52    ) -> Self {
53        Self {
54            descriptors,
55            item_dependencies,
56            expansion_trait_dependencies,
57        }
58    }
59
60    /// Descriptors in canonical deterministic generation order.
61    #[must_use]
62    pub const fn descriptors(&self) -> &'static [SpecDescriptor] {
63        self.descriptors
64    }
65
66    /// Item dependency declarations in deterministic manifest order.
67    #[must_use]
68    pub const fn item_dependencies(&self) -> &'static [ResolveDependencies] {
69        self.item_dependencies
70    }
71
72    /// Expansion-trait dependency declarations in deterministic authored order.
73    #[must_use]
74    pub const fn expansion_trait_dependencies(&self) -> &'static [ResolveDependencies] {
75        self.expansion_trait_dependencies
76    }
77
78    /// Look up one specialization descriptor.
79    /// # Errors
80    /// Returns a typed error when the catalog is invalid or the specialization is absent.
81    pub fn descriptor(
82        &self,
83        spec_id: SpecId,
84    ) -> Result<&'static SpecDescriptor, ContentCatalogError> {
85        self.validate()?;
86
87        self.descriptors
88            .iter()
89            .find(|descriptor| descriptor.spec_id == spec_id)
90            .ok_or_else(|| {
91                ContentCatalogError::new(ContentCatalogErrorKind::SpecNotFound { spec_id })
92            })
93    }
94
95    /// Additional spell IDs required by an equipped item.
96    #[must_use]
97    pub fn item_resolve_ids(&self, item_id: u32) -> &'static [u32] {
98        resolve_ids(self.item_dependencies, item_id)
99    }
100
101    /// Additional spell IDs required by a selected expansion trait.
102    #[must_use]
103    pub fn expansion_trait_resolve_ids(&self, spell_id: u32) -> &'static [u32] {
104        resolve_ids(self.expansion_trait_dependencies, spell_id)
105    }
106
107    /// Validate catalog membership and every declaration.
108    /// # Errors
109    /// Returns a typed error for missing or duplicate specs, invalid descriptor metadata, or malformed resolve-dependency declarations.
110    pub fn validate(&self) -> Result<(), ContentCatalogError> {
111        if self.descriptors.is_empty() {
112            return Err(ContentCatalogError::new(
113                ContentCatalogErrorKind::EmptyDescriptors,
114            ));
115        }
116
117        let mut specs = FastSet::default();
118
119        for descriptor in self.descriptors {
120            if !specs.insert(descriptor.spec_id) {
121                return Err(ContentCatalogError::new(
122                    ContentCatalogErrorKind::DuplicateSpec {
123                        spec_id: descriptor.spec_id,
124                    },
125                ));
126            }
127
128            descriptor.metadata.validate().map_err(|source| {
129                ContentCatalogError::new(ContentCatalogErrorKind::InvalidSpecMetadata {
130                    spec_id: descriptor.spec_id,
131                    source,
132                })
133            })?;
134        }
135
136        validate_dependencies("item", self.item_dependencies)?;
137
138        validate_dependencies("expansion trait", self.expansion_trait_dependencies)
139    }
140}
141
142fn resolve_ids(entries: &[ResolveDependencies], selector_id: u32) -> &'static [u32] {
143    entries
144        .iter()
145        .find(|entry| entry.selector_id == selector_id)
146        .map_or(&[], |entry| entry.spell_ids)
147}
148
149fn validate_dependencies(
150    category: &'static str,
151    entries: &[ResolveDependencies],
152) -> Result<(), ContentCatalogError> {
153    let mut selectors = FastSet::default();
154
155    for entry in entries {
156        if entry.selector_id == 0 {
157            return Err(ContentCatalogError::new(
158                ContentCatalogErrorKind::ZeroSelectorId { category },
159            ));
160        }
161
162        if !selectors.insert(entry.selector_id) {
163            return Err(ContentCatalogError::new(
164                ContentCatalogErrorKind::DuplicateSelectorId {
165                    category,
166                    selector_id: entry.selector_id,
167                },
168            ));
169        }
170
171        if entry.spell_ids.is_empty() {
172            return Err(ContentCatalogError::new(
173                ContentCatalogErrorKind::EmptyResolveIds {
174                    category,
175                    selector_id: entry.selector_id,
176                },
177            ));
178        }
179
180        if entry.spell_ids.contains(&0) {
181            return Err(ContentCatalogError::new(
182                ContentCatalogErrorKind::ZeroResolveId {
183                    category,
184                    selector_id: entry.selector_id,
185                },
186            ));
187        }
188    }
189
190    Ok(())
191}
192
193wowlab_engine_macros::define_error! {
194/// Invalid or missing engine-content catalog data.
195#[derive(Debug)]
196pub struct ContentCatalogError {
197    #[source]
198    kind: ContentCatalogErrorKind,
199}
200
201#[derive(Debug, thiserror::Error)]
202enum ContentCatalogErrorKind {
203    #[error("no engine-content catalog is linked")]
204    MissingCatalog,
205    #[error("multiple engine-content catalogs are linked")]
206    MultipleCatalogs,
207    #[error("engine-content catalog contains no spec descriptors")]
208    EmptyDescriptors,
209    #[error("engine-content catalog contains duplicate descriptor for {spec_id:?}")]
210    DuplicateSpec { spec_id: SpecId },
211    #[error("engine-content catalog does not contain a descriptor for {spec_id:?}")]
212    SpecNotFound { spec_id: SpecId },
213    #[error("invalid metadata for {spec_id:?}: {source}")]
214    InvalidSpecMetadata {
215        spec_id: SpecId,
216        #[source]
217        source: DeclaredSpecMetadataError,
218    },
219    #[error("{category} resolve dependencies contain reserved selector ID 0")]
220    ZeroSelectorId { category: &'static str },
221    #[error("{category} resolve dependencies contain duplicate selector ID {selector_id}")]
222    DuplicateSelectorId {
223        category: &'static str,
224        selector_id: u32,
225    },
226    #[error("{category} selector {selector_id} declares no resolve IDs")]
227    EmptyResolveIds {
228        category: &'static str,
229        selector_id: u32,
230    },
231    #[error("{category} selector {selector_id} contains reserved resolve ID 0")]
232    ZeroResolveId {
233        category: &'static str,
234        selector_id: u32,
235    },
236}
237}
238
239impl ContentCatalogError {
240    fn new(kind: ContentCatalogErrorKind) -> Self {
241        Self { kind }
242    }
243
244    /// Return the absent specialization for a failed descriptor lookup.
245    #[must_use]
246    pub const fn missing_spec(&self) -> Option<SpecId> {
247        match &self.kind {
248            ContentCatalogErrorKind::SpecNotFound { spec_id } => Some(*spec_id),
249            _ => None,
250        }
251    }
252}
253
254inventory::collect!(&'static ContentCatalog);
255
256fn find_catalog_in(
257    catalogs: impl IntoIterator<Item = &'static ContentCatalog>,
258) -> Result<&'static ContentCatalog, ContentCatalogError> {
259    let mut catalogs = catalogs.into_iter();
260    let catalog = catalogs
261        .next()
262        .ok_or_else(|| ContentCatalogError::new(ContentCatalogErrorKind::MissingCatalog))?;
263
264    if catalogs.next().is_some() {
265        return Err(ContentCatalogError::new(
266            ContentCatalogErrorKind::MultipleCatalogs,
267        ));
268    }
269
270    catalog.validate()?;
271
272    Ok(catalog)
273}
274
275/// Return the single linked engine-content catalog.
276/// # Errors
277/// Returns a typed error when no catalog, multiple catalogs, or invalid catalog data is linked.
278pub fn content_catalog() -> Result<&'static ContentCatalog, ContentCatalogError> {
279    find_catalog_in(
280        inventory::iter::<&'static ContentCatalog>
281            .into_iter()
282            .copied(),
283    )
284}
285
286#[cfg(test)]
287mod tests;