steamworks/steam/
user_stats.rs

1use std::convert::TryFrom;
2
3use crate::Client;
4use crate::steam::SteamId;
5use crate::steam::remote_storage::UgcHandle;
6use futures::Future;
7use futures::lock::Mutex;
8use futures_intrusive::sync::Semaphore;
9use once_cell::sync::Lazy;
10use snafu::{ResultExt, ensure};
11use std::convert::TryInto;
12use std::error::Error;
13use std::ffi::CString;
14use std::fmt::{self, Display};
15use std::mem::MaybeUninit;
16use std::{cmp, ptr};
17use steamworks_sys as sys;
18
19/// A handle to a Steam leaderboard
20///
21/// The functions on this handle wrap the
22/// [`DownloadLeaderboardEntries()`](https://partner.steamgames.com/doc/api/ISteamUserStats#DownloadLeaderboardEntries)
23/// and
24/// [`GetDownloadedLeaderboardEntry()`](https://partner.steamgames.com/doc/api/ISteamUserStats#GetDownloadedLeaderboardEntry)
25/// Steamworks API functions.
26#[derive(Debug, Clone)]
27pub struct LeaderboardHandle {
28    pub(crate) client: Client,
29    pub(crate) handle: sys::SteamLeaderboard_t,
30}
31
32impl LeaderboardHandle {
33    /// Fetches a sequential range of leaderboard entries by global rank.
34    ///
35    /// `range_start` and `range_end` are both inclusive. `max_details` should be 64 or less; higher
36    /// values will be clamped.
37    ///
38    /// # Panics
39    ///
40    /// Panics if `range_start < 1` or `range_end < range_start`.
41    pub fn download_global(
42        &self,
43        range_start: u32,
44        range_end: u32,
45        max_details: u8,
46    ) -> impl Future<Output = Vec<LeaderboardEntry>> + Send + '_ {
47        assert!(range_start > 0);
48        assert!(range_end >= range_start);
49
50        self.download_entry_range(
51            sys::ELeaderboardDataRequest_k_ELeaderboardDataRequestGlobal,
52            range_start.try_into().unwrap_or(i32::MAX),
53            range_end.try_into().unwrap_or(i32::MAX),
54            max_details,
55        )
56    }
57
58    /// Fetches a sequential range of leaderboard entries by position relative to the current user's
59    /// rank.
60    ///
61    /// `range_start` and `range_end` are both inclusive. `max_details` should be 64 or less; higher
62    /// values will be clamped.
63    ///
64    /// # Panics
65    ///
66    /// Panics if `range_end < range_start`.
67    pub fn download_global_around_user(
68        &self,
69        range_start: i32,
70        range_end: i32,
71        max_details: u8,
72    ) -> impl Future<Output = Vec<LeaderboardEntry>> + Send + '_ {
73        assert!(range_end >= range_start);
74
75        self.download_entry_range(
76            sys::ELeaderboardDataRequest_k_ELeaderboardDataRequestGlobalAroundUser,
77            range_start,
78            range_end,
79            max_details,
80        )
81    }
82
83    /// Fetches all leaderboard entries for friends of the current user.
84    ///
85    /// `max_details` should be 64 or less; higher values will be clamped.
86    pub fn download_friends(
87        &self,
88        max_details: u8,
89    ) -> impl Future<Output = Vec<LeaderboardEntry>> + Send + '_ {
90        self.download_entry_range(
91            sys::ELeaderboardDataRequest_k_ELeaderboardDataRequestFriends,
92            0,
93            0,
94            max_details,
95        )
96    }
97
98    /// Uploads a score to the leaderboard.
99    ///
100    /// `details` is optional game-specific information to upload along with the score. If
101    /// `force_update` is `true`, the user's score is updated to the new value, even if the new
102    /// score is not better than the already existing score (where "better" is defined by the
103    /// leaderboard sort method).
104    ///
105    /// # Panics
106    ///
107    /// Panics if `details`, if provided, has a length greater than `64`.
108    pub fn upload_leaderboard_score<'a>(
109        &'a self,
110        score: i32,
111        details: Option<&'a [i32]>,
112        force_update: bool,
113    ) -> impl Future<Output = Result<LeaderboardScoreUploaded, UploadLeaderboardScoreError>> + Send + 'a
114    {
115        // Steamworks API: "you may only have one outstanding call to this function at a time"
116        static LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
117
118        let leaderboard_upload_score_method = if force_update {
119            sys::ELeaderboardUploadScoreMethod_k_ELeaderboardUploadScoreMethodForceUpdate
120        } else {
121            sys::ELeaderboardUploadScoreMethod_k_ELeaderboardUploadScoreMethodKeepBest
122        };
123
124        let details_count = match details {
125            Some(xs) => {
126                let len = xs.len();
127                assert!(
128                    len <= 64,
129                    "The details passed in to 'upload_leaderboard_score' has a length of {len}, but the limit is 64"
130                );
131                i32::try_from(len).unwrap()
132            }
133            None => 0,
134        };
135
136        async move {
137            let _guard = LOCK.lock().await;
138
139            let response: sys::LeaderboardScoreUploaded_t = unsafe {
140                let handle = sys::SteamAPI_ISteamUserStats_UploadLeaderboardScore(
141                    *self.client.0.user_stats,
142                    self.handle,
143                    leaderboard_upload_score_method,
144                    score,
145                    details.map(|xs| xs.as_ptr()).unwrap_or(ptr::null()),
146                    details_count,
147                );
148
149                self.client.register_for_call_result(handle).await
150            };
151
152            if response.m_bSuccess == 1 {
153                Ok(LeaderboardScoreUploaded {
154                    score_changed: response.m_bScoreChanged != 0,
155                    global_rank_new: response.m_nGlobalRankNew,
156                    global_rank_previous: response.m_nGlobalRankPrevious,
157                })
158            } else {
159                Err(UploadLeaderboardScoreError)
160            }
161        }
162    }
163
164    fn download_entry_range(
165        &self,
166        request_type: sys::ELeaderboardDataRequest,
167        range_start: i32,
168        range_end: i32,
169        max_details: u8,
170    ) -> impl Future<Output = Vec<LeaderboardEntry>> + Send + '_ {
171        let max_details = cmp::min(max_details, 64);
172        async move {
173            let response: sys::LeaderboardScoresDownloaded_t = unsafe {
174                let handle = sys::SteamAPI_ISteamUserStats_DownloadLeaderboardEntries(
175                    *self.client.0.user_stats,
176                    self.handle,
177                    request_type,
178                    range_start,
179                    range_end,
180                );
181
182                self.client.register_for_call_result(handle).await
183            };
184
185            let mut entries: Vec<LeaderboardEntry> =
186                Vec::with_capacity(response.m_cEntryCount as usize);
187            for i in 0..response.m_cEntryCount {
188                let mut raw_entry: MaybeUninit<sys::LeaderboardEntry_t> = MaybeUninit::uninit();
189                let mut details = vec![0; max_details as usize];
190                let success = unsafe {
191                    sys::SteamAPI_ISteamUserStats_GetDownloadedLeaderboardEntry(
192                        *self.client.0.user_stats,
193                        response.m_hSteamLeaderboardEntries,
194                        i,
195                        raw_entry.as_mut_ptr(),
196                        details.as_mut_ptr(),
197                        max_details.into(),
198                    )
199                };
200
201                assert!(success, "GetDownloadedLeaderboardEntry failed");
202                let raw_entry = unsafe { raw_entry.assume_init() };
203
204                details.truncate(raw_entry.m_cDetails as usize);
205                entries.push(LeaderboardEntry {
206                    steam_id: raw_entry.m_steamIDUser.into(),
207                    global_rank: raw_entry.m_nGlobalRank,
208                    score: raw_entry.m_nScore,
209                    details,
210                    ugc: UgcHandle::from_inner(raw_entry.m_hUGC),
211                });
212            }
213
214            entries
215        }
216    }
217}
218
219#[derive(Debug, Clone, Hash, Eq, PartialEq, Ord, PartialOrd)]
220pub struct LeaderboardEntry {
221    pub steam_id: SteamId,
222    pub global_rank: i32,
223    pub score: i32,
224    pub details: Vec<i32>,
225    pub ugc: Option<UgcHandle>,
226}
227
228#[derive(Debug, Copy, Clone, Default, Hash, Eq, PartialEq, Ord, PartialOrd)]
229pub struct LeaderboardScoreUploaded {
230    pub score_changed: bool,
231    pub global_rank_new: i32,
232    pub global_rank_previous: i32,
233}
234
235#[derive(Debug, Clone, Eq, PartialEq, snafu::Snafu)]
236pub enum FindLeaderboardError {
237    /// The leaderboard name contains nul byte(s)
238    #[snafu(display("The leaderboard name contains nul byte(s): {}", source))]
239    Nul { source: std::ffi::NulError },
240
241    /// The leaderboard name is too long
242    #[snafu(display(
243        "The leaderboard name has a length of {} bytes, which is over the {} byte limit",
244        length,
245        steamworks_sys::k_cchLeaderboardNameMax
246    ))]
247    TooLong { length: usize },
248
249    /// The specified leaderboard was not found
250    #[snafu(display("The leaderboard {:?} was not found", leaderboard_name))]
251    NotFound { leaderboard_name: CString },
252}
253
254#[derive(Debug, Copy, Clone, Default, Hash, Eq, PartialEq, Ord, PartialOrd)]
255pub struct UploadLeaderboardScoreError;
256
257impl Display for UploadLeaderboardScoreError {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> {
259        write!(
260            f,
261            "A call to the Steamworks function 'UploadLeaderboardScore()' failed"
262        )
263    }
264}
265
266impl Error for UploadLeaderboardScoreError {}
267
268pub(crate) fn find_leaderboard(
269    client: &Client,
270    leaderboard_name: Vec<u8>,
271) -> impl Future<Output = Result<LeaderboardHandle, FindLeaderboardError>> + Send + '_ {
272    // The Steamworks API seems to have an undocumented limit on the number of concurrent calls
273    // to the `FindLeaderboard()` function, after which it starts returning leaderboard-not-found
274    // errors. So we limit the number of concurrent calls to an experimentally-determined value.
275    static SEMAPHORE: Lazy<Semaphore> = Lazy::new(|| Semaphore::new(false, 256));
276
277    let leaderboard_name = CString::new(leaderboard_name);
278    async move {
279        let leaderboard_name = leaderboard_name.context(NulSnafu)?;
280        let leaderboard_name_bytes = leaderboard_name.as_bytes_with_nul();
281        ensure!(
282            leaderboard_name_bytes.len() - 1 <= sys::k_cchLeaderboardNameMax as usize,
283            TooLongSnafu {
284                length: leaderboard_name_bytes.len() - 1
285            }
286        );
287
288        let _releaser = SEMAPHORE.acquire(1).await;
289        let response: sys::LeaderboardFindResult_t = unsafe {
290            let handle = sys::SteamAPI_ISteamUserStats_FindLeaderboard(
291                *client.0.user_stats,
292                leaderboard_name_bytes.as_ptr() as *const i8,
293            );
294
295            client.register_for_call_result(handle).await
296        };
297
298        ensure!(
299            response.m_bLeaderboardFound != 0,
300            NotFoundSnafu { leaderboard_name }
301        );
302
303        Ok(LeaderboardHandle {
304            client: client.clone(),
305            handle: response.m_hSteamLeaderboard,
306        })
307    }
308}