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