1mod disk;
4mod error;
5#[cfg(test)]
6mod tests;
7
8use std::sync::Arc;
9
10use moka::future::Cache;
11use serde::{Serialize, de::DeserializeOwned};
12use wowlab_fs::{directory, path::PathBuf};
13use wowlab_supabase::SupabaseClient;
14use wowlab_types::data::{
15 CombatRatingsFlat, CombatRatingsMultByIlvlFlat, CurveFlat, CurvePointFlat, GemPropertiesFlat,
16 HpPerStaFlat, ItemBonusFlat, ItemDataFlat, ItemOffsetCurveFlat, ItemScalingConfigFlat,
17 ItemScalingData, ItemSquishEraFlat, PowerTypeFlat, RandPropPointsFlat, SpecDataFlat,
18 SpellDataFlat, SpellScalingFlat, TraitTreeFlat,
19};
20
21use super::query::{Query, Table};
22
23#[rustfmt::skip]
24pub use error::{CacheError, CacheOperation};
25
26const CACHE_MAX_SPELLS: u64 = 50_000;
27const CACHE_MAX_TRAITS: u64 = 1_000;
28const CACHE_MAX_ITEMS: u64 = 50_000;
29const CACHE_MAX_SPECS: u64 = 128;
30
31const CACHE_TTL_SECS: u64 = 3_600;
33const CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(CACHE_TTL_SECS);
34
35const FETCH_ALL_LIMIT: u32 = 200_000;
36const POWER_TYPE_FETCH_LIMIT: u32 = 10_000;
37
38const DISK_CATEGORY_SPELLS: &str = "spells";
39const DISK_CATEGORY_TRAITS: &str = "traits";
40const DISK_CATEGORY_ITEMS: &str = "items";
41const DISK_CATEGORY_SPECS: &str = "specs";
42const DISK_CATEGORY_SCALING: &str = "scaling_data";
43
44#[rustfmt::skip]
45const DISK_CATEGORIES: [&str; 5] = [
46 DISK_CATEGORY_SPELLS,
47 DISK_CATEGORY_TRAITS,
48 DISK_CATEGORY_ITEMS,
49 DISK_CATEGORY_SPECS,
50 DISK_CATEGORY_SCALING,
51];
52
53fn single_by(table: Table, col: &str, val: i32) -> String {
54 Query::from(table).eq(col, val).into_path()
55}
56
57fn select_all(table: Table, limit: u32) -> String {
58 Query::from(table).limit(limit).into_path()
59}
60
61fn ordered_spells(
62 ids: &[i32],
63 by_id: &mut wowlab_types::sim::IntMap<i32, SpellDataFlat>,
64) -> Result<Vec<SpellDataFlat>, CacheError> {
65 ids.iter()
66 .map(|id| {
67 by_id
68 .remove(id)
69 .ok_or_else(|| CacheError::missing(Table::Spells.into_str(), "id", id.to_string()))
70 })
71 .collect()
72}
73
74fn entry_key(category: &str, key: impl std::fmt::Display) -> String {
75 format!("{category}:{key}")
76}
77
78pub struct GameDataCache {
80 client: SupabaseClient,
81 patch: String,
82 cache_dir: PathBuf,
83
84 spells: Cache<i32, SpellDataFlat>,
85 traits: Cache<i32, TraitTreeFlat>,
86 items: Cache<i32, ItemDataFlat>,
87 specs: Cache<i32, SpecDataFlat>,
88 scaling_data: Cache<(), Arc<ItemScalingData>>,
89}
90
91impl GameDataCache {
92 pub fn new(
97 client: SupabaseClient,
98 patch: impl Into<String>,
99 cache_dir: impl Into<PathBuf>,
100 ) -> Result<Self, CacheError> {
101 let patch = patch.into();
102 let cache_dir = cache_dir.into();
103
104 directory::ensure(&cache_dir).map_err(|source| {
105 CacheError::filesystem(CacheOperation::CreateDirectory, None, source)
106 })?;
107
108 let cache = Self {
109 client,
110 patch: patch.clone(),
111 cache_dir,
112 spells: Cache::builder()
113 .max_capacity(CACHE_MAX_SPELLS)
114 .time_to_live(CACHE_TTL)
115 .build(),
116 traits: Cache::builder()
117 .max_capacity(CACHE_MAX_TRAITS)
118 .time_to_live(CACHE_TTL)
119 .build(),
120 items: Cache::builder()
121 .max_capacity(CACHE_MAX_ITEMS)
122 .time_to_live(CACHE_TTL)
123 .build(),
124 specs: Cache::builder()
125 .max_capacity(CACHE_MAX_SPECS)
126 .time_to_live(CACHE_TTL)
127 .build(),
128 scaling_data: Cache::builder()
129 .max_capacity(1)
130 .time_to_live(CACHE_TTL)
131 .build(),
132 };
133
134 if !cache.patch_matches()? {
135 tracing::info!(%patch, "Patch changed; clearing disk cache");
136 cache.clear_disk()?;
137 cache.write_patch_version()?;
138 }
139
140 Ok(cache)
141 }
142
143 pub(crate) async fn get_spell(&self, id: i32) -> Result<SpellDataFlat, CacheError> {
144 self.get_cached(&self.spells, DISK_CATEGORY_SPELLS, id, |key| async move {
145 let path = single_by(Table::Spells, "id", key);
146
147 self.get_single::<SpellDataFlat>(&path, Table::Spells.into_str(), "id", key)
148 .await
149 })
150 .await
151 }
152
153 pub(crate) async fn get_spells(&self, ids: &[i32]) -> Result<Vec<SpellDataFlat>, CacheError> {
154 if ids.is_empty() {
155 return Ok(vec![]);
156 }
157
158 let mut by_id = wowlab_types::sim::IntMap::with_capacity_and_hasher(
159 ids.len(),
160 std::hash::BuildHasherDefault::default(),
161 );
162 let mut missing = Vec::with_capacity(ids.len());
163
164 for &id in ids {
165 match self
166 .read_memory_or_disk(&self.spells, DISK_CATEGORY_SPELLS, id)
167 .await
168 {
169 Some(v) => {
170 by_id.insert(id, v);
171 }
172 None => missing.push(id),
173 }
174 }
175
176 if !missing.is_empty() {
177 let path = Query::from(Table::Spells)
178 .r#in("id", missing.iter().copied())
179 .into_path();
180 let fetched: Vec<SpellDataFlat> =
181 self.client
182 .get_json(&path, "game")
183 .await
184 .map_err(|source| {
185 CacheError::remote(entry_key(DISK_CATEGORY_SPELLS, "batch"), source)
186 })?;
187
188 for spell in fetched {
189 self.write_disk(DISK_CATEGORY_SPELLS, spell.id, &spell)?;
190 self.spells.insert(spell.id, spell.clone()).await;
192 by_id.insert(spell.id, spell);
193 }
194 }
195
196 ordered_spells(ids, &mut by_id)
197 }
198
199 pub(crate) async fn get_trait_tree(&self, spec_id: i32) -> Result<TraitTreeFlat, CacheError> {
200 self.get_cached(
201 &self.traits,
202 DISK_CATEGORY_TRAITS,
203 spec_id,
204 |key| async move {
205 let path = single_by(Table::SpecsTraits, "spec_id", key);
206
207 self.get_single::<TraitTreeFlat>(
208 &path,
209 Table::SpecsTraits.into_str(),
210 "spec_id",
211 key,
212 )
213 .await
214 },
215 )
216 .await
217 }
218
219 pub(crate) async fn get_item(&self, id: i32) -> Result<ItemDataFlat, CacheError> {
220 self.get_cached(&self.items, DISK_CATEGORY_ITEMS, id, |key| async move {
221 let path = single_by(Table::Items, "id", key);
222
223 self.get_single::<ItemDataFlat>(&path, Table::Items.into_str(), "id", key)
224 .await
225 })
226 .await
227 }
228
229 pub(crate) async fn get_spec(&self, id: i32) -> Result<SpecDataFlat, CacheError> {
230 self.get_cached(&self.specs, DISK_CATEGORY_SPECS, id, |key| async move {
231 let path = single_by(Table::Specs, "id", key);
232
233 self.get_single::<SpecDataFlat>(&path, Table::Specs.into_str(), "id", key)
234 .await
235 })
236 .await
237 }
238
239 pub(crate) async fn get_scaling_data(&self) -> Result<Arc<ItemScalingData>, CacheError> {
240 let value = Box::pin(self.scaling_data.try_get_with((), self.load_scaling_data()))
241 .await
242 .map_err(|source| CacheError::shared("scaling_data:all", source))?;
243
244 Ok(value)
245 }
246
247 pub(crate) async fn get_power_types(&self) -> Result<Vec<PowerTypeFlat>, CacheError> {
248 self.client
249 .get_json::<Vec<PowerTypeFlat>>(
250 &select_all(Table::PowerTypes, POWER_TYPE_FETCH_LIMIT),
251 "game",
252 )
253 .await
254 .map_err(|source| CacheError::remote("power_types:all", source))
255 }
256
257 pub(crate) fn client(&self) -> &SupabaseClient {
258 &self.client
259 }
260
261 #[cfg(test)]
262 pub(crate) fn clear_all(&self) -> Result<(), CacheError> {
263 self.spells.invalidate_all();
264 self.traits.invalidate_all();
265 self.items.invalidate_all();
266 self.specs.invalidate_all();
267 self.scaling_data.invalidate_all();
268 self.clear_disk()?;
269 tracing::info!("All caches cleared");
270
271 Ok(())
272 }
273
274 #[cfg(test)]
275 pub(crate) async fn invalidate_spell(&self, id: i32) {
276 self.spells.invalidate(&id).await;
277 self.log_remove(DISK_CATEGORY_SPELLS, id);
278 }
279
280 async fn read_memory_or_disk<V>(
281 &self,
282 mem_cache: &Cache<i32, V>,
283 disk_category: &str,
284 key: i32,
285 ) -> Option<V>
286 where
287 V: Clone + DeserializeOwned + Send + Sync + 'static,
288 {
289 if let Some(v) = mem_cache.get(&key).await {
290 return Some(v);
291 }
292
293 let v: V = self.read_disk_or_miss(disk_category, key)?;
294
295 mem_cache.insert(key, v.clone()).await;
296
297 Some(v)
298 }
299
300 async fn get_cached<'a, V, F, Fut>(
301 &'a self,
302 mem_cache: &Cache<i32, V>,
303 disk_category: &str,
304 key: i32,
305 fetch: F,
306 ) -> Result<V, CacheError>
307 where
308 V: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
309 F: FnOnce(i32) -> Fut,
310 Fut: Future<Output = Result<V, CacheError>> + 'a,
311 {
312 let value = mem_cache
314 .try_get_with(key, async {
315 if let Some(v) = self.read_disk_or_miss::<V>(disk_category, key) {
316 return Ok(v);
317 }
318 let v: V = fetch(key).await?;
319 self.write_disk(disk_category, key, &v)?;
320 Ok(v)
321 })
322 .await
323 .map_err(|source| CacheError::shared(entry_key(disk_category, key), source))?;
324 Ok(value)
325 }
327
328 async fn load_scaling_data(&self) -> Result<Arc<ItemScalingData>, CacheError> {
329 if let Some(data) = self.read_scaling_data_disk_or_miss() {
330 return Ok(Arc::new(data));
331 }
332
333 let item_bonuses_path = select_all(Table::ItemBonuses, FETCH_ALL_LIMIT);
334 let curves_path = select_all(Table::Curves, FETCH_ALL_LIMIT);
335 let curve_points_path = select_all(Table::CurvePoints, FETCH_ALL_LIMIT);
336 let rand_prop_points_path = select_all(Table::RandPropPoints, FETCH_ALL_LIMIT);
337 let item_scaling_configs_path = select_all(Table::ItemScalingConfigs, FETCH_ALL_LIMIT);
338 let item_offset_curves_path = select_all(Table::ItemOffsetCurves, FETCH_ALL_LIMIT);
339 let item_squish_eras_path = select_all(Table::ItemSquishEras, FETCH_ALL_LIMIT);
340 let combat_ratings_path = select_all(Table::CombatRatings, FETCH_ALL_LIMIT);
341 let hp_per_sta_path = select_all(Table::HpPerSta, FETCH_ALL_LIMIT);
342 let spell_scaling_path = select_all(Table::SpellScaling, FETCH_ALL_LIMIT);
343 let combat_ratings_mult_path = select_all(Table::CombatRatingsMultByIlvl, FETCH_ALL_LIMIT);
344 let gem_properties_path = select_all(Table::GemProperties, FETCH_ALL_LIMIT);
345
346 let item_bonuses_fut = self
347 .client
348 .get_json::<Vec<ItemBonusFlat>>(&item_bonuses_path, "game");
349 let curves_fut = self.client.get_json::<Vec<CurveFlat>>(&curves_path, "game");
350 let curve_points_fut = self
351 .client
352 .get_json::<Vec<CurvePointFlat>>(&curve_points_path, "game");
353 let rand_prop_points_fut = self
354 .client
355 .get_json::<Vec<RandPropPointsFlat>>(&rand_prop_points_path, "game");
356 let item_scaling_configs_fut = self
357 .client
358 .get_json::<Vec<ItemScalingConfigFlat>>(&item_scaling_configs_path, "game");
359 let item_offset_curves_fut = self
360 .client
361 .get_json::<Vec<ItemOffsetCurveFlat>>(&item_offset_curves_path, "game");
362 let item_squish_eras_fut = self
363 .client
364 .get_json::<Vec<ItemSquishEraFlat>>(&item_squish_eras_path, "game");
365 let combat_ratings_fut = self
366 .client
367 .get_json::<Vec<CombatRatingsFlat>>(&combat_ratings_path, "game");
368 let hp_per_sta_fut = self
369 .client
370 .get_json::<Vec<HpPerStaFlat>>(&hp_per_sta_path, "game");
371 let spell_scaling_fut = self
372 .client
373 .get_json::<Vec<SpellScalingFlat>>(&spell_scaling_path, "game");
374 let combat_ratings_mult_fut = self
375 .client
376 .get_json::<Vec<CombatRatingsMultByIlvlFlat>>(&combat_ratings_mult_path, "game");
377 let gem_properties_fut = self
378 .client
379 .get_json::<Vec<GemPropertiesFlat>>(&gem_properties_path, "game");
380
381 let (
382 item_bonuses,
383 curves,
384 curve_points,
385 rand_prop_points,
386 item_scaling_configs,
387 item_offset_curves,
388 item_squish_eras,
389 combat_ratings,
390 hp_per_sta,
391 spell_scaling,
392 combat_ratings_mult_by_ilvl,
393 gem_properties,
394 ) = futures::try_join!(
395 item_bonuses_fut,
396 curves_fut,
397 curve_points_fut,
398 rand_prop_points_fut,
399 item_scaling_configs_fut,
400 item_offset_curves_fut,
401 item_squish_eras_fut,
402 combat_ratings_fut,
403 hp_per_sta_fut,
404 spell_scaling_fut,
405 combat_ratings_mult_fut,
406 gem_properties_fut
407 )
408 .map_err(|source| CacheError::remote("scaling_data:all", source))?;
409
410 let data = Arc::new(ItemScalingData::from_flat(
411 item_bonuses,
412 curves,
413 curve_points,
414 rand_prop_points,
415 item_scaling_configs,
416 item_offset_curves,
417 item_squish_eras,
418 combat_ratings,
419 hp_per_sta,
420 spell_scaling,
421 combat_ratings_mult_by_ilvl,
422 gem_properties,
423 ));
424
425 self.write_scaling_data_disk(data.as_ref())?;
426
427 Ok(data)
428 }
429
430 #[cfg(test)]
431 fn log_remove(&self, category: &str, key: i32) {
432 if let Err(e) = self.remove_disk(category, key) {
433 tracing::warn!(category, key, error = %e, "Failed to invalidate disk cache entry");
434 }
435 }
436
437 async fn get_single<T>(
438 &self,
439 path: &str,
440 resource: &str,
441 key: &str,
442 value: i32,
443 ) -> Result<T, CacheError>
444 where
445 T: DeserializeOwned,
446 {
447 let entry_key = format!("{resource}:{key}={value}");
448 let items: Vec<T> = self
449 .client
450 .get_json(path, "game")
451 .await
452 .map_err(|source| CacheError::remote(&entry_key, source))?;
453
454 items
455 .into_iter()
456 .next()
457 .ok_or_else(|| CacheError::missing(resource, key, value.to_string()))
458 }
459}
460impl std::fmt::Debug for GameDataCache {
461 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462 f.debug_struct("GameDataCache")
463 .field("client", &"<SupabaseClient>")
464 .field("patch", &self.patch)
465 .field("cache_dir", &self.cache_dir)
466 .field("spells", &self.spells.entry_count())
467 .field("traits", &self.traits.entry_count())
468 .field("items", &self.items.entry_count())
469 .field("specs", &self.specs.entry_count())
470 .field("scaling_data", &self.scaling_data.entry_count())
471 .finish()
472 }
473}