1use crate::util::sha256;
2use ddragon::models::Augment;
3use ddragon::models::champions::ChampionShort;
4use ddragon::models::items::Item;
5use ddragon::models::runes::RuneElement;
6use ddragon::{Client, ClientBuilder};
7use levenshtein::levenshtein;
8use lru::LruCache;
9use serde::de::DeserializeOwned;
10use std::cell::RefCell;
11use std::collections::HashMap;
12use std::num::NonZeroUsize;
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15use ugg_types::mappings::{self, Rank};
16use ugg_types::matchups::{MatchupData, Matchups};
17use ugg_types::overview::{ChampOverview, Overview};
18use ugg_types::rune::RuneExtended;
19use ureq::Agent;
20
21mod util;
22
23type UggAPIVersions = HashMap<String, HashMap<String, String>>;
24
25#[derive(Error, Debug)]
26pub enum UggError {
27 #[error("DDragon error")]
28 DDragonError(#[from] ddragon::ClientError),
29 #[error("HTTP request failed")]
30 RequestError(#[from] Box<ureq::Error>),
31 #[error("JSON parsing failed")]
32 ParseError(#[from] simd_json::Error),
33 #[error("Missing region or rank entry")]
34 MissingRegionOrRank,
35 #[error("Missing role entry")]
36 MissingRole,
37 #[error("Unknown error occurred")]
38 Unknown,
39}
40
41pub struct DataApi {
42 agent: Agent,
43 ddragon: Client,
44 overview_cache: RefCell<LruCache<String, ChampOverview>>,
45 matchup_cache: RefCell<LruCache<String, Matchups>>,
46}
47
48#[derive(Debug, Clone)]
49pub struct SupportedVersion {
50 pub ddragon: String,
51 pub ugg: String,
52}
53
54pub struct UggApi {
55 api: DataApi,
56 api_versions: UggAPIVersions,
57
58 pub current_version: String,
59 pub allowed_versions: Vec<SupportedVersion>,
60 pub patch_version: String,
61 pub champ_data: HashMap<String, ChampionShort>,
62 pub items: HashMap<String, Item>,
63 pub runes: HashMap<i64, RuneExtended<RuneElement>>,
64 pub summoner_spells: HashMap<i64, String>,
65 pub arena_augments: HashMap<i64, Augment>,
66}
67
68impl DataApi {
69 pub fn new(version: Option<String>, cache_dir: Option<PathBuf>) -> Result<Self, UggError> {
70 let mut client_builder = ClientBuilder::new();
71 let safe_dir = cache_dir.ok_or(UggError::Unknown)?;
72 if let Some(v) = version {
73 client_builder = client_builder.version(v.as_str());
74 }
75 if let Some(dir) = safe_dir.clone().to_str() {
76 client_builder = client_builder.cache(dir);
77 }
78
79 let cache_size = NonZeroUsize::new(50).unwrap_or(NonZeroUsize::MIN);
80 Ok(Self {
81 agent: Agent::new_with_defaults(),
82 ddragon: client_builder.build()?,
83 overview_cache: RefCell::new(LruCache::new(cache_size)),
84 matchup_cache: RefCell::new(LruCache::new(cache_size)),
85 })
86 }
87
88 fn get_data<T: DeserializeOwned>(&self, url: &str) -> Result<T, UggError> {
89 simd_json::serde::from_reader::<ureq::BodyReader<'_>, T>(
90 self.agent
91 .get(url)
92 .call()
93 .map_err(Box::new)?
94 .into_body()
95 .as_reader(),
96 )
97 .map_err(UggError::ParseError)
98 }
99
100 pub fn get_current_version(&mut self) -> String {
101 self.ddragon.version.clone()
102 }
103
104 pub fn get_supported_versions(&self) -> Result<Vec<String>, UggError> {
105 self.get_data("https://ddragon.leagueoflegends.com/api/versions.json")
106 }
107
108 pub fn get_champ_data(&self) -> Result<HashMap<String, ChampionShort>, UggError> {
109 Ok(self.ddragon.champions()?.data)
110 }
111
112 pub fn get_items(&self) -> Result<HashMap<String, Item>, UggError> {
113 Ok(self.ddragon.items()?.data)
114 }
115
116 pub fn get_runes(&self) -> Result<HashMap<i64, RuneExtended<RuneElement>>, UggError> {
117 let rune_data = self.ddragon.runes()?;
118
119 let mut processed_data = HashMap::new();
120 for class in rune_data {
121 for (slot_index, slot) in class.slots.iter().enumerate() {
122 for (index, rune) in slot.runes.iter().enumerate() {
123 let extended_rune = RuneExtended {
124 rune: (*rune).clone(),
125 slot: slot_index as u64,
126 index: index as u64,
127 siblings: slot.runes.len() as u64,
128 parent: class.name.clone(),
129 parent_id: class.id,
130 };
131 processed_data.insert(rune.id, extended_rune);
132 }
133 }
134 }
135 Ok(processed_data)
136 }
137
138 pub fn get_summoner_spells(&self) -> Result<HashMap<i64, String>, UggError> {
139 let summoner_data = self.ddragon.summoner_spells()?;
140
141 let mut reduced_data: HashMap<i64, String> = HashMap::new();
142 for (_spell, spell_info) in summoner_data.data {
143 reduced_data.insert(
144 spell_info.key.parse::<i64>().ok().unwrap_or(0),
145 spell_info.name,
146 );
147 }
148 Ok(reduced_data)
149 }
150
151 pub fn get_arena_augments(&self) -> Result<HashMap<i64, Augment>, UggError> {
152 let augment_data = self.ddragon.arena_augments()?;
153 let mut reduced_data: HashMap<i64, Augment> = HashMap::new();
154 for augment in augment_data {
155 reduced_data.insert(augment.id, augment);
156 }
157 Ok(reduced_data)
158 }
159
160 pub fn get_ugg_api_versions(&self) -> Result<UggAPIVersions, UggError> {
161 self.get_data::<UggAPIVersions>("https://static.bigbrain.gg/assets/lol/riot_patch_update/prod/ugg/ugg-api-versions.json")
162 }
163
164 #[allow(clippy::too_many_arguments)]
165 pub fn get_stats(
166 &self,
167 patch: &str,
168 champ: &ChampionShort,
169 role: mappings::Role,
170 region: mappings::Region,
171 mode: mappings::Mode,
172 build: mappings::Build,
173 api_versions: &HashMap<String, HashMap<String, String>>,
174 ) -> Result<(Overview, mappings::Role), UggError> {
175 let api_version =
176 if api_versions.contains_key(patch) && api_versions[patch].contains_key("overview") {
177 api_versions[patch]["overview"].as_str()
178 } else {
179 "1.5.0"
180 };
181 let data_path = &format!(
182 "{}/{}/{}/{}/{}",
183 build.to_api_string(),
184 patch,
185 mode.to_api_string(),
186 champ.key.as_str(),
187 api_version
188 );
189 let cache_path = format!("{data_path}-{region}-{role}");
190
191 let stats_data = if let Some(data) = self
192 .overview_cache
193 .try_borrow_mut()
194 .ok()
195 .and_then(|mut c| c.get(&sha256(&cache_path)).cloned())
196 {
197 Ok(data)
198 } else {
199 self.get_data::<ChampOverview>(&format!("https://stats2.u.gg/lol/1.5/{data_path}.json"))
200 }?;
201
202 if let Ok(mut c) = self.overview_cache.try_borrow_mut() {
203 c.put(sha256(&cache_path), stats_data.clone());
204 }
205
206 let data_by_role = Rank::preferred_order()
207 .iter()
208 .find_map(|rank| {
209 stats_data
210 .get(®ion)
211 .and_then(|region_data| region_data.get(rank))
212 })
213 .ok_or(UggError::MissingRegionOrRank)?;
214
215 data_by_role
216 .get_key_value(&role)
217 .or_else(|| {
218 data_by_role
219 .iter()
220 .max_by_key(|(_, data)| data.data.matches())
221 .map(|(role, _)| role)
222 .and_then(|r| data_by_role.get_key_value(r))
223 })
224 .map(|(role, data)| (data.data.clone(), *role))
225 .ok_or(UggError::MissingRole)
226 }
227
228 pub fn get_matchups(
229 &self,
230 patch: &str,
231 champ: &ChampionShort,
232 role: mappings::Role,
233 region: mappings::Region,
234 mode: mappings::Mode,
235 api_versions: &HashMap<String, HashMap<String, String>>,
236 ) -> Result<(MatchupData, mappings::Role), UggError> {
237 let api_version =
238 if api_versions.contains_key(patch) && api_versions[patch].contains_key("matchups") {
239 api_versions[patch]["matchups"].as_str()
240 } else {
241 "1.5.0"
242 };
243 let data_path = &format!(
244 "{}/{}/{}/{}",
245 patch,
246 mode.to_api_string(),
247 champ.key.as_str(),
248 api_version
249 );
250 let cache_path = format!("{data_path}-{region}-{role}");
251
252 let matchup_data = if let Some(data) = self
253 .matchup_cache
254 .try_borrow_mut()
255 .ok()
256 .and_then(|mut c| c.get(&sha256(&cache_path)).cloned())
257 {
258 Ok(data)
259 } else {
260 self.get_data::<Matchups>(&format!(
261 "https://stats2.u.gg/lol/1.5/matchups/{data_path}.json",
262 ))
263 }?;
264
265 let data_by_role = Rank::preferred_order()
266 .iter()
267 .find_map(|rank| {
268 matchup_data
269 .get(®ion)
270 .and_then(|region_data| region_data.get(rank))
271 })
272 .ok_or(UggError::MissingRegionOrRank)?;
273
274 data_by_role
275 .get_key_value(&role)
276 .or_else(|| {
277 data_by_role
278 .iter()
279 .max_by_key(|(_, data)| data.data.total_matches)
280 .map(|(role, _)| role)
281 .and_then(|r| data_by_role.get_key_value(r))
282 })
283 .map(|(role, data)| (data.data.clone(), *role))
284 .ok_or(UggError::MissingRole)
285 }
286}
287
288impl UggApi {
289 pub fn new(version: Option<String>, cache_dir: Option<PathBuf>) -> Result<Self, UggError> {
290 let mut inner_api = DataApi::new(version, cache_dir.clone())?;
291
292 let mut current_version = inner_api.get_current_version();
293 let allowed_versions = inner_api.get_supported_versions()?;
294 let ugg_api_versions = inner_api.get_ugg_api_versions()?;
295 let versions_ugg_supports = allowed_versions
296 .into_iter()
297 .map(|v| SupportedVersion {
298 ddragon: v.clone(),
299 ugg: (v.split('.').take(2).collect::<Vec<&str>>()).join("_"),
300 })
301 .filter(|v| ugg_api_versions.contains_key(&v.ugg))
302 .collect::<Vec<_>>();
303
304 if let Some(default_if_fails) = versions_ugg_supports.first() {
305 if !versions_ugg_supports
306 .iter()
307 .any(|v| v.ddragon == current_version)
308 {
309 inner_api = DataApi::new(Some(default_if_fails.ddragon.clone()), cache_dir)?;
310 current_version = inner_api.get_current_version();
311 }
312 } else {
313 return Err(UggError::Unknown);
314 }
315
316 let champ_data = inner_api.get_champ_data()?;
317 let items = inner_api.get_items()?;
318 let runes = inner_api.get_runes()?;
319 let summoner_spells = inner_api.get_summoner_spells()?;
320 let arena_augments = inner_api
321 .get_arena_augments()
322 .unwrap_or_else(|_| HashMap::new());
323
324 let mut patch_version_split = current_version.split('.').collect::<Vec<&str>>();
325 patch_version_split.remove(patch_version_split.len() - 1);
326 let patch_version = patch_version_split.join("_");
327
328 Ok(Self {
329 api: inner_api,
330 allowed_versions: versions_ugg_supports,
331 api_versions: ugg_api_versions,
332 current_version,
333 patch_version,
334 champ_data,
335 items,
336 runes,
337 summoner_spells,
338 arena_augments,
339 })
340 }
341
342 pub fn find_champ(&self, name: &str) -> &ChampionShort {
343 if self.champ_data.contains_key(name) {
344 &self.champ_data[name]
345 } else {
346 let mut lowest_distance = usize::MAX;
347 let mut closest_champ: &ChampionShort = &self.champ_data["Annie"];
348
349 let mut substring_lowest_dist = usize::MAX;
350 let mut substring_closest_champ: Option<&ChampionShort> = None;
351
352 for value in self.champ_data.values() {
353 let query_compare = name.to_ascii_lowercase();
354 let champ_compare = value.name.to_ascii_lowercase();
355 let distance = levenshtein(query_compare.as_str(), champ_compare.as_str());
357 if champ_compare.starts_with(&query_compare) {
358 if distance <= substring_lowest_dist {
359 substring_lowest_dist = distance;
360 substring_closest_champ = Some(value);
361 }
362 } else if distance <= lowest_distance {
363 lowest_distance = distance;
364 closest_champ = value;
365 }
366 }
367
368 substring_closest_champ.unwrap_or(closest_champ)
369 }
370 }
371
372 pub fn get_stats(
373 &self,
374 champ: &ChampionShort,
375 role: mappings::Role,
376 region: mappings::Region,
377 mode: mappings::Mode,
378 build: mappings::Build,
379 ) -> Result<(Overview, mappings::Role), UggError> {
380 self.api.get_stats(
381 &self.patch_version,
382 champ,
383 role,
384 region,
385 mode,
386 build,
387 &self.api_versions,
388 )
389 }
390
391 pub fn get_matchups(
392 &self,
393 champ: &ChampionShort,
394 role: mappings::Role,
395 region: mappings::Region,
396 mode: mappings::Mode,
397 ) -> Result<(MatchupData, mappings::Role), UggError> {
398 self.api.get_matchups(
399 &self.patch_version,
400 champ,
401 role,
402 region,
403 mode,
404 &self.api_versions,
405 )
406 }
407}
408
409pub struct UggApiBuilder {
410 version: Option<String>,
411 cache_dir: Option<PathBuf>,
412}
413
414impl UggApiBuilder {
415 #[must_use]
416 pub fn new() -> Self {
417 Self {
418 version: None,
419 cache_dir: None,
420 }
421 }
422
423 #[must_use]
424 pub fn version(mut self, version: &str) -> Self {
425 self.version = Some(version.to_owned());
426 self
427 }
428
429 #[must_use]
430 pub fn cache_dir(mut self, cache_dir: &Path) -> Self {
431 self.cache_dir = Some(cache_dir.to_path_buf());
432 self
433 }
434
435 pub fn build(self) -> Result<UggApi, UggError> {
436 UggApi::new(self.version, self.cache_dir)
437 }
438}
439
440impl Default for UggApiBuilder {
441 fn default() -> Self {
442 Self::new()
443 }
444}