Skip to main content

wowlab_engine_domain/rotation/
spec_catalog.rs

1//! Maps spell/aura/talent display names to their IDs.
2
3use wowlab_types::{
4    game::{SpecId, SpecIntrospection},
5    sim::{AuraIdx, FastMap, IntMap, SpellIdx},
6};
7
8use crate::rotation::{SpecResolver, TalentInfo};
9
10/// Hints about spell/aura properties that can't be inferred from name+ID alone.
11#[derive(Clone, Debug, Default)]
12pub struct CatalogHints {
13    pub primary_resource: Option<String>,
14    pub cooldown_spells: Vec<String>,
15    pub charged_spells: Vec<String>,
16    pub dot_auras: Vec<String>,
17    pub talents: Vec<String>,
18    pub talent_ranks: Vec<(String, u8)>,
19}
20
21/// Display names are converted to `snake_case` keys for rotation compilation.
22#[derive(Debug)]
23pub struct SpecCatalog {
24    spells_by_name: FastMap<String, SpellIdx>,
25    spells_by_id: IntMap<u32, SpellIdx>,
26    auras_by_name: FastMap<String, AuraIdx>,
27    dots_by_name: FastMap<String, AuraIdx>,
28    talents_by_name: FastMap<String, TalentInfo>,
29    charged_cooldowns: FastMap<String, SpellIdx>,
30    cooldowns_by_name: FastMap<String, SpellIdx>,
31    primary_resource: Option<String>,
32    secondary_resource: Option<String>,
33}
34
35impl SpecCatalog {
36    /// Richer metadata (cooldowns, charges, `DoTs`, talents) is supplied via `CatalogHints`.
37    // #t(fn: rust_clone_in_loop) building owned maps from borrowed hint data requires cloning keys
38    #[must_use]
39    pub fn new(
40        spell_ids: &FastMap<String, u32>,
41        aura_ids: &FastMap<String, u32>,
42        hints: &CatalogHints,
43    ) -> Self {
44        let mut catalog = Self {
45            spells_by_name: FastMap::default(),
46            spells_by_id: IntMap::default(),
47            auras_by_name: FastMap::default(),
48            dots_by_name: FastMap::default(),
49            talents_by_name: FastMap::default(),
50            charged_cooldowns: FastMap::default(),
51            cooldowns_by_name: FastMap::default(),
52            primary_resource: hints.primary_resource.clone(),
53            secondary_resource: None,
54        };
55
56        for (display_name, &id) in spell_ids {
57            let key = to_simc_key(display_name);
58            let idx = SpellIdx(id);
59
60            catalog.spells_by_name.insert(key.clone(), idx);
61            catalog.spells_by_id.insert(id, idx);
62        }
63
64        for (display_name, &id) in aura_ids {
65            let key = to_simc_key(display_name);
66
67            catalog.auras_by_name.insert(key.clone(), AuraIdx(id));
68        }
69
70        for name in &hints.cooldown_spells {
71            if let Some(&idx) = catalog.spells_by_name.get(name.as_str()) {
72                catalog.cooldowns_by_name.insert(name.clone(), idx);
73            }
74        }
75
76        for name in &hints.charged_spells {
77            if let Some(&idx) = catalog.spells_by_name.get(name.as_str()) {
78                catalog.charged_cooldowns.insert(name.clone(), idx);
79            }
80        }
81
82        for name in &hints.dot_auras {
83            if let Some(&idx) = catalog.auras_by_name.get(name.as_str()) {
84                catalog.dots_by_name.insert(name.clone(), idx);
85            }
86        }
87
88        for name in &hints.talents {
89            catalog
90                .talents_by_name
91                .entry(name.clone())
92                .or_insert(TalentInfo::enabled());
93        }
94
95        for (name, rank) in &hints.talent_ranks {
96            catalog.talents_by_name.insert(
97                name.clone(),
98                TalentInfo::ranked(i32::from(*rank), i32::from((*rank).max(1))),
99            );
100        }
101
102        catalog
103    }
104
105    #[must_use]
106    pub fn spell_by_name(&self, name: &str) -> Option<SpellIdx> {
107        self.spells_by_name.get(name).copied()
108    }
109
110    #[must_use]
111    pub fn spell_by_id(&self, id: u32) -> Option<SpellIdx> {
112        self.spells_by_id.get(&id).copied()
113    }
114
115    #[must_use]
116    pub fn has_spell(&self, name: &str) -> bool {
117        self.spells_by_name.contains_key(name)
118    }
119
120    #[must_use]
121    pub fn aura_by_name(&self, name: &str) -> Option<AuraIdx> {
122        self.auras_by_name.get(name).copied()
123    }
124
125    #[must_use]
126    pub fn has_aura(&self, name: &str) -> bool {
127        self.auras_by_name.contains_key(name)
128    }
129
130    #[must_use]
131    pub fn dot_by_name(&self, name: &str) -> Option<AuraIdx> {
132        self.dots_by_name.get(name).copied()
133    }
134
135    #[must_use]
136    pub fn has_dot(&self, name: &str) -> bool {
137        self.dots_by_name.contains_key(name)
138    }
139
140    #[must_use]
141    pub fn talent(&self, name: &str) -> Option<TalentInfo> {
142        self.talents_by_name.get(name).copied()
143    }
144
145    #[must_use]
146    pub fn has_talent(&self, name: &str) -> bool {
147        self.talents_by_name.get(name).is_some_and(|t| t.enabled)
148    }
149
150    #[must_use]
151    pub fn knows_talent(&self, name: &str) -> bool {
152        self.talents_by_name.contains_key(name)
153    }
154
155    pub fn set_talent(&mut self, name: &str, rank: TalentInfo) {
156        self.talents_by_name.insert(name.to_string(), rank);
157    }
158
159    #[must_use]
160    pub fn is_charged(&self, name: &str) -> bool {
161        self.charged_cooldowns.contains_key(name)
162    }
163
164    #[must_use]
165    pub fn cooldown_by_name(&self, name: &str) -> Option<SpellIdx> {
166        self.cooldowns_by_name.get(name).copied()
167    }
168
169    #[must_use]
170    pub fn primary_resource(&self) -> Option<&str> {
171        self.primary_resource.as_deref()
172    }
173
174    pub fn register_secondary_resource(&mut self, name: &str) {
175        self.secondary_resource = Some(name.to_string());
176    }
177
178    #[must_use]
179    pub fn secondary_resource(&self) -> Option<&str> {
180        self.secondary_resource.as_deref()
181    }
182
183    #[must_use]
184    pub fn to_resolver(&self, name: &str) -> SpecResolver {
185        let mut resolver = SpecResolver::new(name);
186
187        if let Some(resource) = self.primary_resource() {
188            resolver = resolver.resource(resource);
189        }
190
191        if let Some(sec_resource) = self.secondary_resource() {
192            resolver = resolver.secondary_resource(sec_resource);
193        }
194
195        for (spell_name, spell_idx) in self.spells() {
196            resolver = resolver.spell(spell_name, spell_idx.0);
197        }
198
199        for (aura_name, aura_idx) in self.auras() {
200            resolver = resolver.aura(aura_name, aura_idx.0);
201        }
202
203        for (dot_name, dot_idx) in self.dots() {
204            resolver = resolver.dot(dot_name, dot_idx.0);
205        }
206
207        for (talent_name, talent_rank) in self.talents() {
208            if talent_rank.max_rank > 1 {
209                resolver =
210                    resolver.talent_ranked(talent_name, talent_rank.rank, talent_rank.max_rank);
211            } else {
212                resolver = resolver.talent(talent_name, talent_rank.enabled);
213            }
214        }
215
216        for cd_name in self.charged_cooldowns() {
217            resolver = resolver.charged_cooldown(cd_name);
218        }
219
220        resolver
221    }
222
223    pub(crate) fn spells(&self) -> impl Iterator<Item = (&str, SpellIdx)> {
224        self.spells_by_name.iter().map(|(k, v)| (k.as_str(), *v))
225    }
226
227    // DoTs are yielded separately by `dots`.
228    pub(crate) fn auras(&self) -> impl Iterator<Item = (&str, AuraIdx)> {
229        self.auras_by_name
230            .iter()
231            .filter(|(k, _)| !self.dots_by_name.contains_key(k.as_str()))
232            .map(|(k, v)| (k.as_str(), *v))
233    }
234
235    pub(crate) fn dots(&self) -> impl Iterator<Item = (&str, AuraIdx)> {
236        self.dots_by_name.iter().map(|(k, v)| (k.as_str(), *v))
237    }
238
239    pub(crate) fn talents(&self) -> impl Iterator<Item = (&str, TalentInfo)> {
240        self.talents_by_name.iter().map(|(k, v)| (k.as_str(), *v))
241    }
242
243    pub(crate) fn charged_cooldowns(&self) -> impl Iterator<Item = &str> {
244        self.charged_cooldowns.keys().map(String::as_str)
245    }
246}
247
248/// Converts a display name to its normalized identifier token.
249#[must_use]
250pub fn to_simc_key(name: &str) -> String {
251    let mut result = String::with_capacity(name.len());
252    let name = name.trim_start_matches(['_', '+']);
253
254    for ch in name.chars() {
255        match ch {
256            ' ' => result.push('_'),
257            '_' | '+' | '.' | '%' | '0'..='9' | 'a'..='z' => result.push(ch),
258            'A'..='Z' => result.push(ch.to_ascii_lowercase()),
259            _ => {}
260        }
261    }
262
263    result
264}
265
266/// Builds a name-resolving [`SpecResolver`] from a spec's introspection snapshot and talent names.
267#[must_use]
268pub fn build_spec_resolver(
269    intro: &SpecIntrospection,
270    spec_id: SpecId,
271    talent_names: &[&str],
272) -> SpecResolver {
273    let spell_ids: FastMap<String, u32> = intro
274        .spells
275        .iter()
276        .map(|s| (s.slug.clone(), s.spell_id))
277        .collect();
278    let aura_ids: FastMap<String, u32> = intro
279        .auras
280        .iter()
281        .map(|a| (a.slug.clone(), a.aura_id))
282        .collect();
283    let resource_name = spec_id.primary_resource().to_string().to_lowercase();
284    let hints = CatalogHints {
285        primary_resource: Some(resource_name.clone()),
286        talents: talent_names.iter().map(|t| (*t).to_string()).collect(),
287        ..Default::default()
288    };
289    let catalog = SpecCatalog::new(&spell_ids, &aura_ids, &hints);
290
291    catalog.to_resolver(spec_id.slug())
292}
293
294#[cfg(test)]
295mod tests;