1use crate::{
2 client_data::{
3 CacheStatsBuilder,
4 CacheStatsProvider,
5 },
6 util::{
7 TimedCache,
8 TimedCacheEntry,
9 },
10 ClientDataKey,
11};
12use anyhow::Context as _;
13use serenity::builder::{
14 CreateEmbed,
15 EditInteractionResponse,
16};
17use std::sync::Arc;
18use tracing::{
19 error,
20 info,
21};
22
23#[derive(Debug)]
25pub struct Stats {
26 overwolf_player: r6tracker::OverwolfPlayer,
27 profile: r6tracker::UserData,
28}
29
30impl Stats {
31 pub fn populate_embed(&self, mut embed_builder: CreateEmbed) -> CreateEmbed {
33 embed_builder = embed_builder
37 .title(self.overwolf_player.name.as_str())
38 .image(self.overwolf_player.avatar.as_str())
39 .field("Level", self.overwolf_player.level.to_string(), true)
40 .field(
41 "Suspected Cheater",
42 self.overwolf_player.suspected_cheater.to_string(),
43 true,
44 );
45
46 if let Some(season) = self.overwolf_player.current_season_best_region.as_ref() {
47 embed_builder = embed_builder
48 .field("Current Rank", &season.rank_name, true)
49 .field("Current MMR", season.mmr.to_string(), true)
50 .field("Seasonal Ranked K/D", format!("{:.2}", season.kd), true)
51 .field(
52 "Seasonal Ranked Win %",
53 format!("{:.2}", season.win_pct),
54 true,
55 )
56 .field(
57 "Seasonal # of Ranked Matches",
58 season.matches.to_string(),
59 true,
60 );
61 }
62
63 if let Some(season) = self.overwolf_player.get_current_casual_season() {
64 embed_builder = embed_builder
65 .field("Current Casual Rank", &season.rank_name, true)
66 .field("Current Casual MMR", season.mmr.to_string(), true)
67 .field("Seasonal Casual K/D", format!("{:.2}", season.kd), true)
68 .field(
69 "Seasonal Casual Win %",
70 format!("{:.2}", season.win_pct),
71 true,
72 )
73 .field(
74 "Seasonal # of Casual Matches",
75 season.matches.to_string(),
76 true,
77 );
78 }
79
80 let max_overwolf_season = self.overwolf_player.get_max_season();
87 let max_season = self.profile.get_max_season();
88 let overwolf_best_mmr = self.overwolf_player.lifetime_stats.best_mmr.as_ref();
89 let max_mmr = max_overwolf_season
90 .map(|season| season.max_mmr)
91 .or_else(|| max_season.and_then(|season| season.max_mmr()))
92 .or_else(|| overwolf_best_mmr.map(|best_mmr| best_mmr.mmr));
93 let max_rank = max_overwolf_season
94 .map(|season| season.max_rank.rank_name.as_str())
95 .or_else(|| {
96 max_season
97 .and_then(|season| season.max_rank())
98 .map(|rank| rank.name())
99 })
100 .or_else(|| overwolf_best_mmr.map(|best_mmr| best_mmr.name.as_str()));
101
102 if let Some(max_mmr) = max_mmr {
103 embed_builder = embed_builder.field("Best MMR", max_mmr.to_string(), true);
104 }
105
106 if let Some(max_rank) = max_rank {
107 embed_builder = embed_builder.field("Best Rank", max_rank, true);
108 }
109
110 if let Some(lifetime_ranked_kd) = self.overwolf_player.get_lifetime_ranked_kd() {
111 embed_builder = embed_builder.field(
112 "Lifetime Ranked K/D",
113 format!("{lifetime_ranked_kd:.2}"),
114 true,
115 );
116 }
117
118 if let Some(lifetime_ranked_win_pct) = self.overwolf_player.get_lifetime_ranked_win_pct() {
119 embed_builder = embed_builder.field(
120 "Lifetime Ranked Win %",
121 format!("{lifetime_ranked_win_pct:.2}"),
122 true,
123 );
124 }
125
126 embed_builder = embed_builder
127 .field(
128 "Lifetime K/D",
129 format!("{:.2}", self.overwolf_player.lifetime_stats.kd),
130 true,
131 )
132 .field(
133 "Lifetime Win %",
134 format!("{:.2}", self.overwolf_player.lifetime_stats.win_pct),
135 true,
136 );
137
138 if let Some(c) = self.profile.season_color_u32() {
142 embed_builder = embed_builder.color(c);
143 }
144
145 if let Some(thumb) = self.profile.current_mmr_image() {
147 embed_builder = embed_builder.thumbnail(thumb.as_str());
148 }
149
150 embed_builder
151 }
152}
153
154#[derive(Clone, Default, Debug)]
155pub struct R6TrackerClient {
156 client: r6tracker::Client,
157 search_cache: TimedCache<String, Option<Stats>>,
159}
160
161impl R6TrackerClient {
162 pub fn new() -> Self {
164 R6TrackerClient {
165 client: Default::default(),
166 search_cache: Default::default(),
167 }
168 }
169
170 pub async fn get_stats(
172 &self,
173 query: &str,
174 ) -> anyhow::Result<Arc<TimedCacheEntry<Option<Stats>>>> {
175 if let Some(entry) = self.search_cache.get_if_fresh(query) {
176 return Ok(entry);
177 }
178
179 let overwolf_client = self.client.clone();
180 let overwolf_query = query.to_string();
181 let overwolf_player_handle =
182 tokio::spawn(async move { overwolf_client.get_overwolf_player(&overwolf_query).await });
183
184 let profile_client = self.client.clone();
185 let profile_query = query.to_string();
186 let profile_handle = tokio::spawn(async move {
187 profile_client
188 .get_profile(&profile_query, r6tracker::Platform::Pc)
189 .await
190 });
191
192 let overwolf_player = overwolf_player_handle.await?;
193 let profile = profile_handle.await?;
194
195 let overwolf_player = match overwolf_player
206 .context("failed to get overwolf player data")?
207 .into_result()
208 {
209 Ok(overwolf_player) => Some(overwolf_player),
210 Err(response_err) if response_err.0.as_str() == "InvalidName" => None,
211 Err(e) => {
212 return Err(r6tracker::Error::from(e)).context("overwolf api response error");
213 }
214 };
215
216 let entry = overwolf_player
219 .map(|overwolf_player| {
220 let profile = profile
221 .context("failed to get profile data")?
222 .into_result()
223 .context("profile api response was invalid")?;
224 anyhow::Ok(Stats {
225 overwolf_player,
226 profile,
227 })
228 })
229 .transpose()?;
230
231 self.search_cache.insert(String::from(query), entry);
232
233 self.search_cache
234 .get_if_fresh(query)
235 .context("cache data expired")
236 }
237}
238
239impl CacheStatsProvider for R6TrackerClient {
240 fn publish_cache_stats(&self, cache_stats_builder: &mut CacheStatsBuilder) {
241 cache_stats_builder.publish_stat(
242 "r6tracker",
243 "search_cache",
244 self.search_cache.len() as f32,
245 );
246 }
247}
248
249#[derive(Debug, pikadick_slash_framework::FromOptions)]
251struct R6TrackerOptions {
252 name: String,
254}
255
256pub fn create_slash_command() -> anyhow::Result<pikadick_slash_framework::Command> {
258 pikadick_slash_framework::CommandBuilder::new()
259 .name("r6tracker")
260 .description("Get r6 stats for a user from r6tracker")
261 .argument(
262 pikadick_slash_framework::ArgumentParamBuilder::new()
263 .name("name")
264 .description("The name of the user")
265 .kind(pikadick_slash_framework::ArgumentKind::String)
266 .required(true)
267 .build()?,
268 )
269 .on_process(|ctx, interaction, args: R6TrackerOptions| async move {
270 let data_lock = ctx.data.read().await;
271 let client_data = data_lock
272 .get::<ClientDataKey>()
273 .expect("missing client data");
274 let client = client_data.r6tracker_client.clone();
275 drop(data_lock);
276
277 let name = args.name;
278
279 info!("Getting r6 stats for \"{name}\" using R6Tracker");
280
281 interaction.defer(&ctx.http).await?;
282
283 let result = client
284 .get_stats(&name)
285 .await
286 .with_context(|| format!("failed to get r6tracker stats for \"{name}\""));
287
288 let mut edit_response_builder = EditInteractionResponse::new();
289 match result.as_ref().map(|entry| entry.data()) {
290 Ok(Some(stats)) => {
291 let embed_builder = stats.populate_embed(CreateEmbed::new());
292 edit_response_builder = edit_response_builder.embed(embed_builder);
293 }
294 Ok(None) => {
295 edit_response_builder = edit_response_builder.content("No Results");
296 }
297 Err(error) => {
298 error!("{error:?}");
299 edit_response_builder = edit_response_builder.content(format!("{error:?}"));
300 }
301 }
302
303 interaction
304 .edit_response(&ctx.http, edit_response_builder)
305 .await?;
306
307 client.search_cache.trim();
308
309 Ok(())
310 })
311 .build()
312 .context("failed to build r6tracker command")
313}