pikadick/commands/
r6tracker.rs

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/// R6Tracker stats for a user
24#[derive(Debug)]
25pub struct Stats {
26    overwolf_player: r6tracker::OverwolfPlayer,
27    profile: r6tracker::UserData,
28}
29
30impl Stats {
31    /// Populate an embed with data.
32    pub fn populate_embed(&self, mut embed_builder: CreateEmbed) -> CreateEmbed {
33        // We mix overwolf and non-overwolf data to get what we want.
34
35        // Overwolf Api
36        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        // Best Rank/MMR lifetime stats are bugged in Overwolf.
81        // It shows the max ending stats.
82        //
83        // Try manual calculation based on the Overwolf API Season stats,
84        // falling back to manual calculation based on the overlay stats,
85        // falling back to the Overwolf API lifetime value, which is bugged.
86        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        // Old Non-Overwolf API
139
140        // Overwolf API does not send season colors
141        if let Some(c) = self.profile.season_color_u32() {
142            embed_builder = embed_builder.color(c);
143        }
144
145        // Overwolf API does not send non-svg rank thumbnails
146        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    /// The value is `None` if the user could not be found
158    search_cache: TimedCache<String, Option<Stats>>,
159}
160
161impl R6TrackerClient {
162    /// Make a new r6 client with caching
163    pub fn new() -> Self {
164        R6TrackerClient {
165            client: Default::default(),
166            search_cache: Default::default(),
167        }
168    }
169
170    /// Get R6Tracker stats
171    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        // This returns "No results" to the user when an InvalidName Overwolf API Error occurs.
196        // This works because we check for errors in the Overwolf response first,
197        // so non-existent users are always predictably caught there.
198        //
199        // However, it may be beneficial to add a case for other API errors to catch edge cases,
200        // such as UserData erroring while Overwolf.
201        // This isn't a high priortiy however as this is entirely cosmetic;
202        // the user will simply get an ugly error if we fail to special-case it here.
203        //
204        // TODO: Add case for UserData
205        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        // Open profile in the map so that we only validate the profile if we got overwolf data
217        // This is because profile will fail in strange ways if the player does not exist.
218        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/// Options for r6tracker
250#[derive(Debug, pikadick_slash_framework::FromOptions)]
251struct R6TrackerOptions {
252    /// The user name
253    name: String,
254}
255
256/// Create a slash command
257pub 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}