Skip to main content

wowlab_engine_adapter_data/remote/supabase/
mod.rs

1//! Resolves game data through Supabase with memory and disk caching.
2
3mod wire;
4
5use std::sync::Arc;
6
7use wire::{RacialSpellRow, RotationScriptRow, SpecializationSpellIdRow, SpellOverrideRow};
8use wowlab_engine_domain::dbc::{ItemClass, item_matches_consumable_query};
9use wowlab_engine_ports::{
10    DataResolver, EnchantmentRow, ResolverError, SpellId, SpellSearchResult,
11};
12use wowlab_supabase::SupabaseClient;
13use wowlab_types::data::{
14    ChallengeModeHealthFlat, ContentTuningFlat, ContentTuningXDifficultyFlat,
15    ContentTuningXExpectedFlat, CreatureDifficultyFlat, CreatureFlat, ExpansionTraitTreeFlat,
16    ExpectedStatFlat, ExpectedStatModFlat, ItemDamageScalingFlat, ItemDataFlat, ItemScalingData,
17    PowerTypeFlat, SpecDataFlat, SpellDataFlat, SpellEffect, TraitTreeFlat,
18};
19
20use super::{
21    cache::GameDataCache,
22    query::{Query, Table},
23};
24use crate::rows::EnchantmentRowWire;
25
26const CONSUMABLE_NAME_PATTERN_CAPACITY_MULTIPLIER: usize = 2;
27
28fn backend_error<E>(op: &'static str) -> impl FnOnce(E) -> ResolverError
29where
30    E: std::error::Error + Send + Sync + 'static,
31{
32    move |source| ResolverError::backend(op, source)
33}
34
35fn consumable_name_pattern(name_token: &str) -> String {
36    let capacity = name_token
37        .len()
38        .saturating_mul(CONSUMABLE_NAME_PATTERN_CAPACITY_MULTIPLIER);
39    let mut pattern = String::with_capacity(capacity);
40
41    for character in name_token.chars().filter(char::is_ascii_alphanumeric) {
42        if !pattern.is_empty() {
43            pattern.push('*');
44        }
45
46        pattern.push(character);
47    }
48
49    pattern
50}
51
52fn consumable_items_path(name_token: &str, subclass: i32) -> String {
53    Query::from(Table::Items)
54        .eq("class_id", ItemClass::Consumable as i32)
55        .eq("subclass_id", subclass)
56        .ilike_contains("name", &consumable_name_pattern(name_token))
57        .into_path()
58}
59
60fn matching_consumable_items(
61    items: Vec<ItemDataFlat>,
62    name_token: &str,
63    subclass: i32,
64) -> Vec<ItemDataFlat> {
65    items
66        .into_iter()
67        .filter(|item| item_matches_consumable_query(item, name_token, subclass))
68        .collect()
69}
70
71/// Resolver backed by the Supabase REST API and [`GameDataCache`].
72#[derive(Debug)]
73pub struct SupabaseResolver {
74    cache: GameDataCache,
75}
76
77impl SupabaseResolver {
78    /// Wraps an initialized game-data cache as a resolver.
79    #[must_use]
80    pub fn new(cache: GameDataCache) -> Self {
81        Self { cache }
82    }
83
84    pub(crate) fn client(&self) -> &SupabaseClient {
85        self.cache.client()
86    }
87
88    async fn fetch_rotation(&self, path: &str, label: &str) -> Result<String, ResolverError> {
89        let rows: Vec<RotationScriptRow> = self
90            .client()
91            .get_json(path, "game")
92            .await
93            .map_err(backend_error("fetch rotation"))?;
94
95        rows.into_iter()
96            .next()
97            .map(|r| r.script.to_string())
98            .ok_or_else(|| ResolverError::rotation_script_not_found(label))
99    }
100}
101
102impl DataResolver for SupabaseResolver {
103    async fn get_spell(&self, spell_id: SpellId) -> Result<SpellDataFlat, ResolverError> {
104        self.cache
105            .get_spell(spell_id.as_i32())
106            .await
107            .map_err(backend_error("fetch spell"))
108    }
109
110    async fn get_spells(&self, spell_ids: &[SpellId]) -> Result<Vec<SpellDataFlat>, ResolverError> {
111        let raw_ids: Vec<i32> = spell_ids.iter().map(SpellId::as_i32).collect();
112
113        self.cache
114            .get_spells(&raw_ids)
115            .await
116            .map_err(backend_error("fetch spells"))
117    }
118
119    async fn get_spell_effect(
120        &self,
121        spell_id: SpellId,
122        effect_index: u8,
123    ) -> Result<SpellEffect, ResolverError> {
124        let spell = self.get_spell(spell_id).await?;
125
126        crate::rows::validate_effect_index(spell_id, effect_index, &spell.effects)
127    }
128
129    async fn get_spell_effects(
130        &self,
131        spell_id: SpellId,
132    ) -> Result<Vec<SpellEffect>, ResolverError> {
133        let spell = self.get_spell(spell_id).await?;
134
135        Ok(spell.effects.clone())
136    }
137
138    async fn get_item(&self, item_id: i32) -> Result<ItemDataFlat, ResolverError> {
139        self.cache
140            .get_item(item_id)
141            .await
142            .map_err(backend_error("fetch item"))
143    }
144
145    async fn find_consumable_items(
146        &self,
147        name_token: &str,
148        subclass: i32,
149    ) -> Result<Vec<ItemDataFlat>, ResolverError> {
150        let path = consumable_items_path(name_token, subclass);
151        let items: Vec<ItemDataFlat> = self
152            .client()
153            .get_json(&path, "game")
154            .await
155            .map_err(backend_error("find consumable items"))?;
156
157        Ok(matching_consumable_items(items, name_token, subclass))
158    }
159
160    async fn get_scaling_data(&self) -> Result<Arc<ItemScalingData>, ResolverError> {
161        Box::pin(self.cache.get_scaling_data())
162            .await
163            .map_err(backend_error("fetch scaling data"))
164    }
165
166    async fn get_power_types(&self) -> Result<Vec<PowerTypeFlat>, ResolverError> {
167        self.cache
168            .get_power_types()
169            .await
170            .map_err(backend_error("fetch power types"))
171    }
172
173    async fn get_spec(&self, spec_id: i32) -> Result<SpecDataFlat, ResolverError> {
174        self.cache
175            .get_spec(spec_id)
176            .await
177            .map_err(backend_error("fetch specialization"))
178    }
179
180    async fn get_trait_tree(&self, spec_id: i32) -> Result<TraitTreeFlat, ResolverError> {
181        self.cache
182            .get_trait_tree(spec_id)
183            .await
184            .map_err(backend_error("fetch trait tree"))
185    }
186
187    async fn get_expansion_trait_tree(
188        &self,
189        expansion_id: i32,
190        system: &str,
191    ) -> Result<ExpansionTraitTreeFlat, ResolverError> {
192        let path = Query::from(Table::ExpansionTraits)
193            .eq("expansion_id", expansion_id)
194            .eq("system", system)
195            .limit(1)
196            .into_path();
197        let mut rows: Vec<ExpansionTraitTreeFlat> = self
198            .client()
199            .get_json(&path, "game")
200            .await
201            .map_err(backend_error("fetch expansion trait tree"))?;
202
203        rows.pop()
204            .ok_or_else(|| ResolverError::expansion_trait_tree_not_found(expansion_id, system))
205    }
206
207    async fn get_rotation_script(&self, rotation_id: &str) -> Result<String, ResolverError> {
208        let path = Query::from(Table::Rotations)
209            .eq("id", rotation_id)
210            .select("script")
211            .into_path();
212
213        tracing::debug!(rotation_id, "Fetching rotation script");
214
215        self.fetch_rotation(&path, rotation_id).await
216    }
217
218    async fn get_spell_overrides(
219        &self,
220        spec_id: i32,
221    ) -> Result<Vec<(SpellId, SpellId)>, ResolverError> {
222        let path = Query::from(Table::SpecializationSpells)
223            .eq("spec_id", spec_id)
224            .gt("overrides_spell_id", 0)
225            .select("overrides_spell_id,spell_id")
226            .into_path();
227
228        let rows: Vec<SpellOverrideRow> = self
229            .client()
230            .get_json(&path, "game")
231            .await
232            .map_err(backend_error("fetch spell overrides"))?;
233
234        Ok(crate::rows::spell_override_pairs(
235            rows.into_iter().map(|r| (r.overrides_spell_id, r.spell_id)),
236        ))
237    }
238
239    async fn get_specialization_spells(&self, spec_id: i32) -> Result<Vec<SpellId>, ResolverError> {
240        let path = Query::from(Table::SpecializationSpells)
241            .eq("spec_id", spec_id)
242            .select("spell_id")
243            .into_path();
244
245        let rows: Vec<SpecializationSpellIdRow> = self
246            .client()
247            .get_json(&path, "game")
248            .await
249            .map_err(backend_error("fetch specialization spells"))?;
250
251        Ok(rows
252            .into_iter()
253            .map(|row| SpellId::new(row.spell_id))
254            .collect())
255    }
256
257    async fn get_racial_spells(
258        &self,
259        race_id: i32,
260        class_id: i32,
261    ) -> Result<Vec<SpellId>, ResolverError> {
262        let path = Query::from(Table::RacialSpells)
263            .eq("race_id", race_id)
264            .select("spell_id,class_mask")
265            .into_path();
266
267        let rows: Vec<RacialSpellRow> = self
268            .client()
269            .get_json(&path, "game")
270            .await
271            .map_err(backend_error("fetch racial spells"))?;
272
273        Ok(crate::rows::racial_spell_ids(
274            rows.into_iter().map(|row| (row.spell_id, row.class_mask)),
275            class_id,
276        ))
277    }
278
279    async fn get_expected_stats(
280        &self,
281        expansion_id: i32,
282        lvl: i32,
283    ) -> Result<ExpectedStatFlat, ResolverError> {
284        let path = Query::from(Table::ExpectedStats)
285            .eq("expansion_id", expansion_id)
286            .eq("lvl", lvl)
287            .limit(1)
288            .into_path();
289        let rows: Vec<ExpectedStatFlat> = self
290            .client()
291            .get_json(&path, "game")
292            .await
293            .map_err(backend_error("fetch expected stats"))?;
294
295        rows.into_iter()
296            .next()
297            .ok_or_else(|| ResolverError::expected_stats_not_found(expansion_id, lvl))
298    }
299
300    async fn get_item_damage_scaling(
301        &self,
302        item_level: i32,
303        weapon_type: &str,
304    ) -> Result<ItemDamageScalingFlat, ResolverError> {
305        let path = Query::from(Table::ItemDamageScaling)
306            .eq("item_level", item_level)
307            .eq("weapon_type", weapon_type)
308            .limit(1)
309            .into_path();
310        let rows: Vec<ItemDamageScalingFlat> = self
311            .client()
312            .get_json(&path, "game")
313            .await
314            .map_err(backend_error("fetch item damage scaling"))?;
315
316        rows.into_iter()
317            .next()
318            .ok_or_else(|| ResolverError::item_damage_scaling_not_found(weapon_type, item_level))
319    }
320
321    async fn get_enchantment(&self, enchantment_id: i32) -> Result<EnchantmentRow, ResolverError> {
322        let path = Query::from(Table::Enchantments)
323            .eq("id", enchantment_id)
324            .limit(1)
325            .into_path();
326        let rows: Vec<EnchantmentRowWire> = self
327            .client()
328            .get_json(&path, "game")
329            .await
330            .map_err(backend_error("fetch enchantment"))?;
331
332        rows.into_iter()
333            .next()
334            .map(crate::rows::enchantment_row)
335            .ok_or_else(|| ResolverError::enchantment_not_found(enchantment_id))
336    }
337
338    async fn search_spells(
339        &self,
340        query: &str,
341        limit: u32,
342    ) -> Result<Vec<SpellSearchResult>, ResolverError> {
343        let path = Query::from(Table::Spells)
344            .ilike_contains("name", query)
345            .select("id,name")
346            .limit(limit)
347            .into_path();
348        let results: Vec<SpellSearchResult> = self
349            .client()
350            .get_json(&path, "game")
351            .await
352            .map_err(backend_error("search spells"))?;
353
354        Ok(results)
355    }
356
357    async fn get_creature(&self, creature_id: i32) -> Result<CreatureFlat, ResolverError> {
358        let path = Query::from(Table::Creatures)
359            .eq("id", creature_id)
360            .limit(1)
361            .into_path();
362        let rows: Vec<CreatureFlat> = self
363            .client()
364            .get_json(&path, "game")
365            .await
366            .map_err(backend_error("fetch creature"))?;
367
368        rows.into_iter()
369            .next()
370            .ok_or_else(|| ResolverError::creature_not_found(creature_id))
371    }
372
373    async fn get_creature_difficulties(
374        &self,
375        creature_id: i32,
376    ) -> Result<Vec<CreatureDifficultyFlat>, ResolverError> {
377        let path = Query::from(Table::CreatureDifficulties)
378            .eq("creature_id", creature_id)
379            .into_path();
380
381        self.client()
382            .get_json(&path, "game")
383            .await
384            .map_err(backend_error("fetch creature difficulties"))
385    }
386
387    async fn get_content_tuning(
388        &self,
389        content_tuning_id: i32,
390    ) -> Result<ContentTuningFlat, ResolverError> {
391        let path = Query::from(Table::ContentTunings)
392            .eq("id", content_tuning_id)
393            .limit(1)
394            .into_path();
395        let rows: Vec<ContentTuningFlat> = self
396            .client()
397            .get_json(&path, "game")
398            .await
399            .map_err(backend_error("fetch content tuning"))?;
400
401        rows.into_iter()
402            .next()
403            .ok_or_else(|| ResolverError::content_tuning_not_found(content_tuning_id))
404    }
405
406    async fn get_content_tuning_x_difficulty(
407        &self,
408        content_tuning_id: i32,
409    ) -> Result<Vec<ContentTuningXDifficultyFlat>, ResolverError> {
410        let path = Query::from(Table::ContentTuningXDifficulty)
411            .eq("content_tuning_id", content_tuning_id)
412            .into_path();
413
414        self.client()
415            .get_json(&path, "game")
416            .await
417            .map_err(backend_error("fetch content tuning difficulties"))
418    }
419
420    async fn get_content_tuning_x_expected(
421        &self,
422        content_tuning_id: i32,
423    ) -> Result<Vec<ContentTuningXExpectedFlat>, ResolverError> {
424        let path = Query::from(Table::ContentTuningXExpected)
425            .eq("content_tuning_id", content_tuning_id)
426            .into_path();
427
428        self.client()
429            .get_json(&path, "game")
430            .await
431            .map_err(backend_error("fetch content tuning expected stats"))
432    }
433
434    async fn get_expected_stat_mod(
435        &self,
436        expected_stat_mod_id: i32,
437    ) -> Result<ExpectedStatModFlat, ResolverError> {
438        let path = Query::from(Table::ExpectedStatMods)
439            .eq("id", expected_stat_mod_id)
440            .limit(1)
441            .into_path();
442        let rows: Vec<ExpectedStatModFlat> = self
443            .client()
444            .get_json(&path, "game")
445            .await
446            .map_err(backend_error("fetch expected stat modifier"))?;
447
448        rows.into_iter()
449            .next()
450            .ok_or_else(|| ResolverError::expected_stat_mod_not_found(expected_stat_mod_id))
451    }
452
453    async fn get_challenge_mode_health(
454        &self,
455        keystone_level: i32,
456    ) -> Result<ChallengeModeHealthFlat, ResolverError> {
457        let path = Query::from(Table::ChallengeModeHealth)
458            .eq("challenge_level", keystone_level)
459            .limit(1)
460            .into_path();
461        let rows: Vec<ChallengeModeHealthFlat> = self
462            .client()
463            .get_json(&path, "game")
464            .await
465            .map_err(backend_error("fetch challenge mode health"))?;
466
467        rows.into_iter()
468            .next()
469            .ok_or_else(|| ResolverError::challenge_mode_health_not_found(keystone_level))
470    }
471}
472
473#[cfg(test)]
474mod tests;