Skip to main content

wowlab_engine_ports/
resolver.rs

1use std::sync::Arc;
2
3use wowlab_types::data::{
4    ChallengeModeHealthFlat, ContentTuningFlat, ContentTuningXDifficultyFlat,
5    ContentTuningXExpectedFlat, CreatureDifficultyFlat, CreatureFlat, ExpansionTraitTreeFlat,
6    ExpectedStatFlat, ExpectedStatModFlat, ItemDamageScalingFlat, ItemDataFlat, ItemScalingData,
7    PermanentEnchantFlat, PowerTypeFlat, SpecDataFlat, SpellDataFlat, SpellEffect, TraitTreeFlat,
8    TraitTreeWithSelections,
9};
10
11use crate::types::SpellId;
12
13/// Tokenizes a display name for profile lookup.
14#[must_use]
15pub fn tokenize_name(name: &str) -> String {
16    let mut out = String::with_capacity(name.len());
17
18    for c in name.trim().chars() {
19        if c.is_ascii_alphanumeric() {
20            out.extend(c.to_lowercase());
21        } else if (c.is_whitespace() || c == '-' || c == '_') && !out.ends_with('_') {
22            out.push('_');
23        }
24    }
25
26    out.trim_end_matches('_').to_string()
27}
28
29/// Lightweight search result for spell lookups.
30#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
31pub struct SpellSearchResult {
32    #[serde(alias = "id")]
33    pub spell_id: SpellId,
34    pub name: String,
35}
36
37/// One effect slot on an enchantment. `effect_type == 3` means trigger spell.
38#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize)]
39pub struct EnchantmentEffect {
40    pub effect_type: i32,
41    pub effect_arg: i32,
42    #[serde(default)]
43    pub points_min: Option<f64>,
44    #[serde(default)]
45    pub scaling_points: Option<f64>,
46}
47
48/// Number of independent effect slots stored per enchantment row.
49pub(crate) const ENCHANTMENT_EFFECTS: usize = 3;
50
51/// Resolved enchantment row from `game.enchantments`.
52#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)]
53pub struct EnchantmentRow {
54    pub id: i32,
55    pub name: String,
56    pub duration_ms: u32,
57    pub effects: [EnchantmentEffect; ENCHANTMENT_EFFECTS],
58    pub scaling_class: i32,
59    pub item_level_min: i32,
60    pub item_level_max: i32,
61    /// Caps the player level used for scaled stat values (`MaxLevel`); 0 = uncapped.
62    pub max_level: i32,
63}
64
65/// Exact permanent-enchant lookup coordinates used by `SimC` profile decoding.
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct PermanentEnchantQuery {
68    pub tokenized_name: String,
69    pub rank: i32,
70    pub item_class: i32,
71    pub inventory_type: i32,
72    pub item_subclass: i32,
73}
74
75fn permanent_enchant_mask_allows(mask: i32, value: i32) -> bool {
76    value <= 0
77        || mask <= 0
78        || u32::try_from(value)
79            .ok()
80            .and_then(|shift| 1_i32.checked_shl(shift))
81            .is_some_and(|bit| mask & bit != 0)
82}
83
84/// Selects an entry using `SimC`'s verbose-name alias and item-mask rules.
85#[must_use]
86pub fn select_permanent_enchant(
87    entries: &[PermanentEnchantFlat],
88    query: &PermanentEnchantQuery,
89) -> Option<PermanentEnchantFlat> {
90    let canonical_name = entries
91        .iter()
92        .find(|entry| {
93            entry.tokenized_name.starts_with("enchant_")
94                && entry
95                    .tokenized_name
96                    .split_once("__")
97                    .is_some_and(|(_, alias)| alias == query.tokenized_name)
98        })
99        .map_or(query.tokenized_name.as_str(), |entry| {
100            entry.tokenized_name.as_str()
101        });
102
103    entries
104        .iter()
105        .find(|entry| {
106            entry.tokenized_name == canonical_name
107                && entry.rank == query.rank
108                && entry.item_class == query.item_class
109                && permanent_enchant_mask_allows(entry.inventory_type_mask, query.inventory_type)
110                && permanent_enchant_mask_allows(entry.subclass_mask, query.item_subclass)
111        })
112        .cloned()
113}
114
115type BoxedCause = Box<dyn std::error::Error + Send + Sync + 'static>;
116
117wowlab_engine_macros::define_error! {
118/// Resolver failure with stable, situation-oriented construction and inspection APIs.
119#[derive(Debug)]
120pub struct ResolverError {
121    #[source]
122    kind: ResolverErrorKind,
123}
124
125#[derive(Debug, thiserror::Error)]
126enum ResolverErrorKind {
127    #[error("spell {spell_id} not found")]
128    SpellNotFound { spell_id: SpellId },
129
130    #[error("spell effect not found: spell_id={spell_id}, effect_index={effect_index}")]
131    SpellEffectNotFound { spell_id: SpellId, effect_index: u8 },
132
133    #[error("item {item_id} not found")]
134    ItemNotFound { item_id: i32 },
135
136    #[error("trait tree for spec {spec_id} not found")]
137    TraitTreeNotFound { spec_id: i32 },
138
139    #[error("expansion trait tree {system:?} for expansion {expansion_id} not found")]
140    ExpansionTraitTreeNotFound { expansion_id: i32, system: String },
141
142    #[error("spec {spec_id} not found")]
143    SpecNotFound { spec_id: i32 },
144
145    #[error("rotation script {rotation_id} not found")]
146    RotationScriptNotFound { rotation_id: String },
147
148    #[error("expected stats not found for expansion {expansion_id}, lvl {lvl}")]
149    ExpectedStatsNotFound { expansion_id: i32, lvl: i32 },
150
151    #[error("creature {creature_id} not found")]
152    CreatureNotFound { creature_id: i32 },
153
154    #[error("content tuning {content_tuning_id} not found")]
155    ContentTuningNotFound { content_tuning_id: i32 },
156
157    #[error("expected stat mod {expected_stat_mod_id} not found")]
158    ExpectedStatModNotFound { expected_stat_mod_id: i32 },
159
160    #[error("challenge mode health not found for keystone level {keystone_level}")]
161    ChallengeModeHealthNotFound { keystone_level: i32 },
162
163    #[error("item_damage_scaling not found for weapon_type={weapon_type}, item_level={item_level}")]
164    ItemDamageScalingNotFound {
165        weapon_type: String,
166        item_level: i32,
167    },
168
169    #[error("enchantment {enchantment_id} not found")]
170    EnchantmentNotFound { enchantment_id: i32 },
171
172    #[error("scaling data not configured for this resolver")]
173    NoScalingData,
174
175    #[error("resolver does not support {method}")]
176    Unsupported { method: &'static str },
177
178    #[error("trait decode error: {0}")]
179    TraitDecode(#[source] wowlab_loadout::TraitError),
180
181    #[error("filesystem error: {source}")]
182    Filesystem {
183        #[source]
184        source: wowlab_fs::error::Error,
185    },
186
187    #[error("decode error in {op}")]
188    Decode {
189        op: &'static str,
190        #[source]
191        source: BoxedCause,
192    },
193
194    #[error("js bridge error in {method}: {message}")]
195    JsBridge { method: String, message: String },
196
197    #[error("backend error in {op}")]
198    Backend {
199        op: &'static str,
200        #[source]
201        source: BoxedCause,
202    },
203
204    #[error("environment variable error: {0}")]
205    EnvVar(String),
206}
207}
208
209impl ResolverError {
210    #[must_use]
211    pub fn spell_not_found(spell_id: SpellId) -> Self {
212        Self {
213            kind: ResolverErrorKind::SpellNotFound { spell_id },
214        }
215    }
216
217    #[must_use]
218    pub fn spell_effect_not_found(spell_id: SpellId, effect_index: u8) -> Self {
219        Self {
220            kind: ResolverErrorKind::SpellEffectNotFound {
221                spell_id,
222                effect_index,
223            },
224        }
225    }
226
227    #[must_use]
228    pub fn item_not_found(item_id: i32) -> Self {
229        Self {
230            kind: ResolverErrorKind::ItemNotFound { item_id },
231        }
232    }
233
234    #[must_use]
235    pub fn trait_tree_not_found(spec_id: i32) -> Self {
236        Self {
237            kind: ResolverErrorKind::TraitTreeNotFound { spec_id },
238        }
239    }
240
241    pub fn expansion_trait_tree_not_found(expansion_id: i32, system: impl Into<String>) -> Self {
242        Self {
243            kind: ResolverErrorKind::ExpansionTraitTreeNotFound {
244                expansion_id,
245                system: system.into(),
246            },
247        }
248    }
249
250    #[must_use]
251    pub fn spec_not_found(spec_id: i32) -> Self {
252        Self {
253            kind: ResolverErrorKind::SpecNotFound { spec_id },
254        }
255    }
256
257    pub fn rotation_script_not_found(rotation_id: impl Into<String>) -> Self {
258        Self {
259            kind: ResolverErrorKind::RotationScriptNotFound {
260                rotation_id: rotation_id.into(),
261            },
262        }
263    }
264
265    #[must_use]
266    pub fn expected_stats_not_found(expansion_id: i32, lvl: i32) -> Self {
267        Self {
268            kind: ResolverErrorKind::ExpectedStatsNotFound { expansion_id, lvl },
269        }
270    }
271
272    #[must_use]
273    pub fn creature_not_found(creature_id: i32) -> Self {
274        Self {
275            kind: ResolverErrorKind::CreatureNotFound { creature_id },
276        }
277    }
278
279    #[must_use]
280    pub fn content_tuning_not_found(content_tuning_id: i32) -> Self {
281        Self {
282            kind: ResolverErrorKind::ContentTuningNotFound { content_tuning_id },
283        }
284    }
285
286    #[must_use]
287    pub fn expected_stat_mod_not_found(expected_stat_mod_id: i32) -> Self {
288        Self {
289            kind: ResolverErrorKind::ExpectedStatModNotFound {
290                expected_stat_mod_id,
291            },
292        }
293    }
294
295    #[must_use]
296    pub fn challenge_mode_health_not_found(keystone_level: i32) -> Self {
297        Self {
298            kind: ResolverErrorKind::ChallengeModeHealthNotFound { keystone_level },
299        }
300    }
301
302    pub fn item_damage_scaling_not_found(weapon_type: impl Into<String>, item_level: i32) -> Self {
303        Self {
304            kind: ResolverErrorKind::ItemDamageScalingNotFound {
305                weapon_type: weapon_type.into(),
306                item_level,
307            },
308        }
309    }
310
311    #[must_use]
312    pub fn enchantment_not_found(enchantment_id: i32) -> Self {
313        Self {
314            kind: ResolverErrorKind::EnchantmentNotFound { enchantment_id },
315        }
316    }
317
318    #[must_use]
319    pub fn no_scaling_data() -> Self {
320        Self {
321            kind: ResolverErrorKind::NoScalingData,
322        }
323    }
324
325    #[must_use]
326    pub fn unsupported(method: &'static str) -> Self {
327        Self {
328            kind: ResolverErrorKind::Unsupported { method },
329        }
330    }
331
332    #[must_use]
333    pub fn filesystem(source: wowlab_fs::error::Error) -> Self {
334        Self {
335            kind: ResolverErrorKind::Filesystem { source },
336        }
337    }
338
339    pub fn decode(
340        op: &'static str,
341        source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
342    ) -> Self {
343        Self {
344            kind: ResolverErrorKind::Decode {
345                op,
346                source: source.into(),
347            },
348        }
349    }
350
351    pub fn js_bridge(method: impl Into<String>, message: impl Into<String>) -> Self {
352        Self {
353            kind: ResolverErrorKind::JsBridge {
354                method: method.into(),
355                message: message.into(),
356            },
357        }
358    }
359
360    pub fn backend(
361        op: &'static str,
362        source: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
363    ) -> Self {
364        Self {
365            kind: ResolverErrorKind::Backend {
366                op,
367                source: source.into(),
368            },
369        }
370    }
371
372    pub fn env_var(message: impl Into<String>) -> Self {
373        Self {
374            kind: ResolverErrorKind::EnvVar(message.into()),
375        }
376    }
377
378    #[must_use]
379    pub fn is_spell_not_found(&self) -> bool {
380        matches!(self.kind, ResolverErrorKind::SpellNotFound { .. })
381    }
382
383    #[must_use]
384    pub fn is_item_not_found(&self) -> bool {
385        matches!(self.kind, ResolverErrorKind::ItemNotFound { .. })
386    }
387
388    #[must_use]
389    pub fn is_trait_tree_not_found(&self) -> bool {
390        matches!(self.kind, ResolverErrorKind::TraitTreeNotFound { .. })
391    }
392
393    #[must_use]
394    pub fn is_spec_not_found(&self) -> bool {
395        matches!(self.kind, ResolverErrorKind::SpecNotFound { .. })
396    }
397
398    #[must_use]
399    pub fn is_rotation_script_not_found(&self) -> bool {
400        matches!(self.kind, ResolverErrorKind::RotationScriptNotFound { .. })
401    }
402
403    #[must_use]
404    pub fn is_expected_stats_not_found(&self) -> bool {
405        matches!(self.kind, ResolverErrorKind::ExpectedStatsNotFound { .. })
406    }
407
408    #[must_use]
409    pub fn is_creature_not_found(&self) -> bool {
410        matches!(self.kind, ResolverErrorKind::CreatureNotFound { .. })
411    }
412
413    #[must_use]
414    pub fn is_content_tuning_not_found(&self) -> bool {
415        matches!(self.kind, ResolverErrorKind::ContentTuningNotFound { .. })
416    }
417
418    #[must_use]
419    pub fn is_expected_stat_mod_not_found(&self) -> bool {
420        matches!(self.kind, ResolverErrorKind::ExpectedStatModNotFound { .. })
421    }
422
423    #[must_use]
424    pub fn is_challenge_mode_health_not_found(&self) -> bool {
425        matches!(
426            self.kind,
427            ResolverErrorKind::ChallengeModeHealthNotFound { .. }
428        )
429    }
430
431    #[must_use]
432    pub fn is_no_scaling_data(&self) -> bool {
433        matches!(self.kind, ResolverErrorKind::NoScalingData)
434    }
435
436    #[must_use]
437    pub fn spell_not_found_id(&self) -> Option<SpellId> {
438        match self.kind {
439            ResolverErrorKind::SpellNotFound { spell_id } => Some(spell_id),
440            _ => None,
441        }
442    }
443
444    #[must_use]
445    pub fn spell_effect_not_found_coordinate(&self) -> Option<(SpellId, u8)> {
446        match self.kind {
447            ResolverErrorKind::SpellEffectNotFound {
448                spell_id,
449                effect_index,
450            } => Some((spell_id, effect_index)),
451            _ => None,
452        }
453    }
454}
455
456impl From<wowlab_loadout::TraitError> for ResolverError {
457    fn from(source: wowlab_loadout::TraitError) -> Self {
458        Self {
459            kind: ResolverErrorKind::TraitDecode(source),
460        }
461    }
462}
463
464impl From<wowlab_fs::error::Error> for ResolverError {
465    fn from(source: wowlab_fs::error::Error) -> Self {
466        Self::filesystem(source)
467    }
468}
469
470macro_rules! unsupported_default {
471    ($method:ident; $($argument:expr),* $(,)?) => {{
472        let _ = ($($argument),*);
473
474        Err(ResolverError::unsupported(stringify!($method)))
475    }};
476}
477
478/// Supertrait bound: `Sync` off-wasm (keeps `&self` `Send`), unconstrained on single-threaded wasm.
479#[cfg(not(target_arch = "wasm32"))]
480pub trait ResolverSyncBound: Sync {}
481#[cfg(not(target_arch = "wasm32"))]
482impl<T> ResolverSyncBound for T where T: Sync + ?Sized {}
483/// Supertrait bound: unconstrained on wasm (single-threaded resolvers).
484#[cfg(target_arch = "wasm32")]
485pub trait ResolverSyncBound {}
486#[cfg(target_arch = "wasm32")]
487impl<T> ResolverSyncBound for T where T: ?Sized {}
488
489/// Unified data resolver trait (`DynDataResolver` restores dyn polymorphism); method futures are `Send` but not `Sync`.
490// wasm32 intentionally exposes !Send futures, hence the allowed async_fn_in_trait.
491// docref:start data-resolution-trait-macros
492#[cfg_attr(target_arch = "wasm32", allow(async_fn_in_trait))]
493#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
494// #t(rust_expect_over_allow) proc-macro expansion makes this lint configuration-dependent
495#[allow(
496    unreachable_pub,
497    reason = "the dynosaur macro exports DataResolver through the generated facade"
498)]
499#[dynosaur::dynosaur(pub DynDataResolver = dyn(box) DataResolver, bridge(dyn))]
500pub trait DataResolver: ResolverSyncBound {
501    // docref:end data-resolution-trait-macros
502    async fn get_spell(&self, spell_id: SpellId) -> Result<SpellDataFlat, ResolverError>;
503
504    fn get_spells(
505        &self,
506        spell_ids: &[SpellId],
507    ) -> impl Future<Output = Result<Vec<SpellDataFlat>, ResolverError>> {
508        async move {
509            let mut result = Vec::with_capacity(spell_ids.len());
510
511            for &id in spell_ids {
512                result.push(self.get_spell(id).await?);
513            }
514
515            Ok(result)
516        }
517    }
518    /// `effect_index` is 1-based (author-facing).
519    async fn get_spell_effect(
520        &self,
521        spell_id: SpellId,
522        effect_index: u8,
523    ) -> Result<SpellEffect, ResolverError>;
524    async fn get_spell_effects(&self, spell_id: SpellId)
525    -> Result<Vec<SpellEffect>, ResolverError>;
526    async fn get_item(&self, item_id: i32) -> Result<ItemDataFlat, ResolverError>;
527    async fn get_scaling_data(&self) -> Result<Arc<ItemScalingData>, ResolverError>;
528
529    async fn get_power_types(&self) -> Result<Vec<PowerTypeFlat>, ResolverError>;
530
531    async fn get_spec(&self, spec_id: i32) -> Result<SpecDataFlat, ResolverError>;
532    async fn get_trait_tree(&self, spec_id: i32) -> Result<TraitTreeFlat, ResolverError>;
533    /// Fetch a class-agnostic expansion progression tree, such as Midnight's Omnium Folio.
534    fn get_expansion_trait_tree(
535        &self,
536        expansion_id: i32,
537        system: &str,
538    ) -> impl Future<Output = Result<ExpansionTraitTreeFlat, ResolverError>> {
539        async move { unsupported_default!(get_expansion_trait_tree; expansion_id, system) }
540    }
541    async fn get_rotation_script(&self, rotation_id: &str) -> Result<String, ResolverError>;
542
543    /// Required for resolver parity: a backend with no override table returns `Ok(vec![])`.
544    async fn get_spell_overrides(
545        &self,
546        spec_id: i32,
547    ) -> Result<Vec<(SpellId, SpellId)>, ResolverError>;
548
549    /// Every spell id assigned to the spec in `SpecializationSpells`; a backend with no table returns `Ok(vec![])`.
550    async fn get_specialization_spells(&self, spec_id: i32) -> Result<Vec<SpellId>, ResolverError>;
551
552    /// Every racial ability the race grants the class; a backend with no table returns `Ok(vec![])`.
553    ///
554    /// `race_id` is `ChrRaces.ID` and `class_id` is `ChrClasses.ID`.
555    async fn get_racial_spells(
556        &self,
557        race_id: i32,
558        class_id: i32,
559    ) -> Result<Vec<SpellId>, ResolverError>;
560
561    /// Required for resolver parity: return `ExpectedStatsNotFound` when a backend cannot source it.
562    async fn get_expected_stats(
563        &self,
564        expansion_id: i32,
565        lvl: i32,
566    ) -> Result<ExpectedStatFlat, ResolverError>;
567
568    /// Required for resolver parity: return `ItemDamageScalingNotFound` when a backend cannot source it.
569    async fn get_item_damage_scaling(
570        &self,
571        item_level: i32,
572        weapon_type: &str,
573    ) -> Result<ItemDamageScalingFlat, ResolverError>;
574
575    /// Defaults to `Unsupported`; a backend with no search index fails loud rather than returning empty.
576    fn search_spells(
577        &self,
578        query: &str,
579        limit: u32,
580    ) -> impl Future<Output = Result<Vec<SpellSearchResult>, ResolverError>> {
581        async move { unsupported_default!(search_spells; query, limit) }
582    }
583
584    /// Finds consumable items whose tokenized names contain `name_token`.
585    fn find_consumable_items(
586        &self,
587        name_token: &str,
588        subclass: i32,
589    ) -> impl Future<Output = Result<Vec<ItemDataFlat>, ResolverError>> {
590        async move { unsupported_default!(find_consumable_items; name_token, subclass) }
591    }
592
593    /// Required for resolver parity: return `EnchantmentNotFound` when a backend cannot source it.
594    async fn get_enchantment(&self, enchantment_id: i32) -> Result<EnchantmentRow, ResolverError>;
595
596    /// Resolves a named permanent enchant through the profession-derived `SimC` index.
597    fn find_permanent_enchant(
598        &self,
599        query: &PermanentEnchantQuery,
600    ) -> impl Future<Output = Result<Option<PermanentEnchantFlat>, ResolverError>> {
601        async move { unsupported_default!(find_permanent_enchant; query) }
602    }
603
604    /// Creature identity row (`Creature` table) by NPC ID; defaults to `Unsupported` until a backend implements enemy resolution.
605    fn get_creature(
606        &self,
607        creature_id: i32,
608    ) -> impl Future<Output = Result<CreatureFlat, ResolverError>> {
609        async move { unsupported_default!(get_creature; creature_id) }
610    }
611
612    /// Every `CreatureDifficulty` variant for a creature; defaults to `Unsupported` until a backend implements enemy resolution.
613    fn get_creature_difficulties(
614        &self,
615        creature_id: i32,
616    ) -> impl Future<Output = Result<Vec<CreatureDifficultyFlat>, ResolverError>> {
617        async move { unsupported_default!(get_creature_difficulties; creature_id) }
618    }
619
620    /// `ContentTuning` row by ID; defaults to `Unsupported` until a backend implements enemy resolution.
621    fn get_content_tuning(
622        &self,
623        content_tuning_id: i32,
624    ) -> impl Future<Output = Result<ContentTuningFlat, ResolverError>> {
625        async move { unsupported_default!(get_content_tuning; content_tuning_id) }
626    }
627
628    /// Every `ContentTuningXDifficulty` mapping for a content tuning; defaults to `Unsupported` until a backend implements enemy resolution.
629    fn get_content_tuning_x_difficulty(
630        &self,
631        content_tuning_id: i32,
632    ) -> impl Future<Output = Result<Vec<ContentTuningXDifficultyFlat>, ResolverError>> {
633        async move { unsupported_default!(get_content_tuning_x_difficulty; content_tuning_id) }
634    }
635
636    /// Every `ContentTuningXExpected` mapping; row selection stays in the application layer.
637    fn get_content_tuning_x_expected(
638        &self,
639        content_tuning_id: i32,
640    ) -> impl Future<Output = Result<Vec<ContentTuningXExpectedFlat>, ResolverError>> {
641        async move { unsupported_default!(get_content_tuning_x_expected; content_tuning_id) }
642    }
643
644    /// `ExpectedStatMod` multiplier row by ID.
645    fn get_expected_stat_mod(
646        &self,
647        expected_stat_mod_id: i32,
648    ) -> impl Future<Output = Result<ExpectedStatModFlat, ResolverError>> {
649        async move { unsupported_default!(get_expected_stat_mod; expected_stat_mod_id) }
650    }
651
652    /// `ChallengeModeHealth` `GameTable` scalar by keystone level.
653    fn get_challenge_mode_health(
654        &self,
655        keystone_level: i32,
656    ) -> impl Future<Output = Result<ChallengeModeHealthFlat, ResolverError>> {
657        async move { unsupported_default!(get_challenge_mode_health; keystone_level) }
658    }
659
660    /// Decode a loadout string against this spec's tree into a raw selected tree; talent-tree interpretation is the caller's job.
661    fn decode_traits(
662        &self,
663        spec_id: i32,
664        trait_string: &str,
665    ) -> impl Future<Output = Result<TraitTreeWithSelections, ResolverError>> {
666        async move {
667            let tree = self.get_trait_tree(spec_id).await?;
668            let decoded = wowlab_loadout::decode_trait_loadout(trait_string)?;
669
670            Ok(wowlab_loadout::apply_decoded_traits(tree, &decoded))
671        }
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use std::error::Error as _;
678
679    use googletest::prelude::*;
680    use rstest::rstest;
681
682    use super::*;
683
684    #[cfg(not(target_arch = "wasm32"))]
685    fn assert_send_sync<T>()
686    where
687        T: Send + Sync + ?Sized,
688    {
689    }
690
691    #[cfg(not(target_arch = "wasm32"))]
692    #[gtest]
693    fn native_dynamic_resolver_preserves_send_sync_contract() {
694        assert_send_sync::<DynDataResolver<'static>>();
695    }
696
697    #[gtest]
698    #[rstest]
699    #[case::trim_and_lower("  Arcane Blast  ", "arcane_blast")]
700    #[case::collapse_separators("arcane-- blast___rank 2", "arcane_blast_rank_2")]
701    #[case::discard_punctuation("Nature's Swiftness!", "natures_swiftness")]
702    #[case::empty(" --- ", "")]
703    fn tokenize_name_normalizes_profile_lookup_keys(
704        #[case] input: &str,
705        #[case] expected: &str,
706    ) -> Result<()> {
707        verify_that!(tokenize_name(input), eq(expected))
708    }
709
710    #[gtest]
711    fn permanent_enchant_lookup_applies_alias_rank_and_masks_exactly() -> Result<()> {
712        let entries = vec![
713            PermanentEnchantFlat {
714                enchant_id: 7_966,
715                rank: 1,
716                item_class: 4,
717                inventory_type_mask: 1 << 11,
718                subclass_mask: 0x1f,
719                tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
720            },
721            PermanentEnchantFlat {
722                enchant_id: 7_967,
723                rank: 2,
724                item_class: 4,
725                inventory_type_mask: 1 << 11,
726                subclass_mask: 0x1f,
727                tokenized_name: "enchant_ring__eyes_of_the_eagle".to_string(),
728            },
729        ];
730        let query = PermanentEnchantQuery {
731            tokenized_name: "eyes_of_the_eagle".to_string(),
732            rank: 2,
733            item_class: 4,
734            inventory_type: 11,
735            item_subclass: 0,
736        };
737
738        verify_that!(
739            select_permanent_enchant(&entries, &query),
740            some(matches_pattern!(PermanentEnchantFlat {
741                enchant_id: eq(&7_967),
742                ..
743            }))
744        )?;
745
746        let wrong_rank = PermanentEnchantQuery {
747            rank: 3,
748            ..query.clone()
749        };
750
751        verify_that!(select_permanent_enchant(&entries, &wrong_rank), none())?;
752
753        let wrong_inventory = PermanentEnchantQuery {
754            inventory_type: 7,
755            ..query.clone()
756        };
757
758        verify_that!(select_permanent_enchant(&entries, &wrong_inventory), none())?;
759
760        let bit_31_entries = vec![PermanentEnchantFlat {
761            inventory_type_mask: i32::MIN,
762            ..entries[1].clone()
763        }];
764
765        let bit_31_inventory = PermanentEnchantQuery {
766            inventory_type: 31,
767            ..query
768        };
769
770        verify_that!(
771            select_permanent_enchant(&bit_31_entries, &bit_31_inventory),
772            some(matches_pattern!(PermanentEnchantFlat {
773                enchant_id: eq(&7_967),
774                ..
775            }))
776        )
777    }
778
779    #[gtest]
780    fn backend_error_preserves_context_and_source() -> Result<()> {
781        let error = ResolverError::backend(
782            "fetch spell",
783            std::io::Error::other("upstream request failed"),
784        );
785
786        verify_that!(error.to_string(), eq("backend error in fetch spell"))?;
787
788        verify_that!(
789            error
790                .source()
791                .and_then(std::error::Error::source)
792                .map(ToString::to_string)
793                .as_deref(),
794            some(eq("upstream request failed"))
795        )
796    }
797
798    #[gtest]
799    fn trait_decode_error_preserves_typed_source() -> Result<()> {
800        let error = wowlab_loadout::decode_trait_loadout("")
801            .err()
802            .or_fail()?
803            .into();
804        let error: ResolverError = error;
805
806        verify_that!(
807            error.to_string(),
808            eq("trait decode error: Trait string too short")
809        )?;
810        let source = error
811            .source()
812            .and_then(std::error::Error::source)
813            .and_then(|source| source.downcast_ref::<wowlab_loadout::TraitError>())
814            .or_fail()?;
815
816        verify_true!(source.is_too_short())
817    }
818
819    #[gtest]
820    #[rstest]
821    #[case::first_arm(0, Some(10.0))]
822    #[case::mid_arm(3, Some(40.0))]
823    #[case::last_valid_arm(6, Some(70.0))]
824    #[case::above_range(7, None)]
825    #[case::negative(-1, None)]
826    fn item_damage_scaling_quality_arms(
827        #[case] quality: i32,
828        #[case] expected: Option<f64>,
829    ) -> Result<()> {
830        let row = ItemDamageScalingFlat {
831            quality_0: 10.0,
832            quality_1: 20.0,
833            quality_2: 30.0,
834            quality_3: 40.0,
835            quality_4: 50.0,
836            quality_5: 60.0,
837            quality_6: 70.0,
838            ..ItemDamageScalingFlat::default()
839        };
840
841        if let Some(v) = expected {
842            verify_that!(row.quality(quality), some(near(v, 1e-9)))
843        } else {
844            verify_that!(row.quality(quality), none())
845        }
846    }
847}