use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{ApiError, ValidationDetails};
use crate::ids::UserId;
use crate::page::Cursor;
use crate::time::UnixMillis;
pub const MAX_BOARD_KEY_BYTES: usize = 64;
pub const DEFAULT_MAX_METADATA_BYTES: usize = 1024;
pub const DEFAULT_AROUND: u32 = 5;
pub const MAX_AROUND: u32 = 50;
const DAY_MILLIS: i64 = 86_400_000;
pub fn is_valid_board_key(key: &str) -> bool {
let bytes = key.as_bytes();
bytes.len() <= MAX_BOARD_KEY_BYTES
&& bytes.first().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
&& bytes.iter().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-' | b'.'))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ScoreMode {
#[default]
Best,
Latest,
Sum,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ScoreOrder {
#[default]
Desc,
Asc,
#[serde(other)]
Unknown,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Period {
#[default]
AllTime,
Daily,
Weekly,
#[serde(other)]
Unknown,
}
impl Period {
pub fn start_of(self, at: UnixMillis) -> Option<UnixMillis> {
let day = at.get().div_euclid(DAY_MILLIS);
match self {
Period::Daily => Some(UnixMillis(day.saturating_mul(DAY_MILLIS))),
Period::Weekly => Some(UnixMillis((day + 3).div_euclid(7).saturating_mul(7).saturating_sub(3).saturating_mul(DAY_MILLIS))),
_ => None,
}
}
pub fn end_of(self, at: UnixMillis) -> Option<UnixMillis> {
let length = match self {
Period::Daily => DAY_MILLIS,
Period::Weekly => 7 * DAY_MILLIS,
_ => return None,
};
self.start_of(at).map(|start| start.saturating_add_millis(length))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BoardInfo {
pub key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub mode: ScoreMode,
pub order: ScoreOrder,
pub period: Period,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_start: Option<UnixMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_end: Option<UnixMillis>,
#[serde(default = "yes")]
pub client_submit: bool,
}
fn yes() -> bool {
true
}
impl BoardInfo {
pub fn new(key: impl Into<String>, mode: ScoreMode, order: ScoreOrder, period: Period) -> Self {
Self { key: key.into(), name: None, mode, order, period, period_start: None, period_end: None, client_submit: true }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_period_bounds(mut self, start: Option<UnixMillis>, end: Option<UnixMillis>) -> Self {
self.period_start = start;
self.period_end = end;
self
}
pub fn with_client_submit(mut self, allowed: bool) -> Self {
self.client_submit = allowed;
self
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Boards {
pub boards: Vec<BoardInfo>,
}
impl Boards {
pub fn new(boards: Vec<BoardInfo>) -> Self {
Self { boards }
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SubmitScore {
pub score: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl SubmitScore {
pub fn new(score: i64) -> Self {
Self { score, metadata: None }
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
pub fn validate(&self, max_metadata_bytes: usize) -> Result<(), ApiError> {
let mut details = ValidationDetails::new();
if self.score == i64::MIN {
details.add("score", "must be greater than -9223372036854775808");
}
if let Some(metadata) = &self.metadata {
if crate::storage::value_bytes(metadata) > max_metadata_bytes {
details.add("metadata", format!("is larger than {max_metadata_bytes} bytes"));
}
}
details.into_result()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ScoreAck {
pub board: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_start: Option<UnixMillis>,
pub score: i64,
pub submitted: i64,
pub changed: bool,
pub rank: u64,
}
impl ScoreAck {
pub fn new(board: impl Into<String>, period_start: Option<UnixMillis>, score: i64, submitted: i64, changed: bool, rank: u64) -> Self {
Self { board: board.into(), period_start, score, submitted, changed, rank }
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LeaderboardEntry {
pub rank: u64,
pub user: UserId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub score: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub achieved_at: UnixMillis,
}
impl LeaderboardEntry {
pub fn new(rank: u64, user: UserId, score: i64, achieved_at: UnixMillis) -> Self {
Self { rank, user, name: None, score, metadata: None, achieved_at }
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn with_metadata(mut self, metadata: Value) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LeaderboardPage {
pub board: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_start: Option<UnixMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_end: Option<UnixMillis>,
pub items: Vec<LeaderboardEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
}
impl LeaderboardPage {
pub fn new(board: impl Into<String>, period_start: Option<UnixMillis>, period_end: Option<UnixMillis>, items: Vec<LeaderboardEntry>) -> Self {
Self { board: board.into(), period_start, period_end, items, next_cursor: None }
}
pub fn with_next_cursor(mut self, cursor: Cursor) -> Self {
self.next_cursor = Some(cursor);
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct MyRank {
pub board: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_start: Option<UnixMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_end: Option<UnixMillis>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry: Option<LeaderboardEntry>,
pub total: u64,
}
impl MyRank {
pub fn new(
board: impl Into<String>,
period_start: Option<UnixMillis>,
period_end: Option<UnixMillis>,
entry: Option<LeaderboardEntry>,
total: u64,
) -> Self {
Self { board: board.into(), period_start, period_end, entry, total }
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct TopQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<UnixMillis>,
}
impl TopQuery {
pub fn new() -> Self {
Self::default()
}
pub fn after(mut self, cursor: Cursor) -> Self {
self.cursor = Some(cursor);
self
}
pub fn with_limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn at(mut self, at: UnixMillis) -> Self {
self.at = Some(at);
self
}
pub fn limit_or_default(&self) -> u32 {
self.limit.map_or(crate::page::DEFAULT_PAGE_LIMIT, |limit| limit.clamp(1, crate::page::MAX_PAGE_LIMIT))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct RankQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<UnixMillis>,
}
impl RankQuery {
pub fn new() -> Self {
Self::default()
}
pub fn at(mut self, at: UnixMillis) -> Self {
self.at = Some(at);
self
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AroundQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub above: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub below: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at: Option<UnixMillis>,
}
impl AroundQuery {
pub fn new() -> Self {
Self::default()
}
pub fn with_counts(mut self, above: u32, below: u32) -> Self {
self.above = Some(above);
self.below = Some(below);
self
}
pub fn at(mut self, at: UnixMillis) -> Self {
self.at = Some(at);
self
}
pub fn counts(&self) -> (u32, u32) {
(self.above.unwrap_or(DEFAULT_AROUND).min(MAX_AROUND), self.below.unwrap_or(DEFAULT_AROUND).min(MAX_AROUND))
}
}
mod calls {
use super::*;
use crate::http_call::{HttpCall, NoPayload, PathParams, PayloadKind, NO_PAYLOAD};
use crate::routes::{self, HttpMethod, Route};
const NOT_A_KEY: &str = "is not a valid board key";
fn board(params: &PathParams) -> Result<String, ApiError> {
params.checked("board", is_valid_board_key, NOT_A_KEY)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ListBoards {}
impl ListBoards {
pub fn new() -> Self {
Self {}
}
}
impl HttpCall for ListBoards {
type Payload = NoPayload;
type Response = Boards;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::leaderboards::BOARDS, true);
const PAYLOAD: PayloadKind = PayloadKind::Empty;
fn payload(&self) -> &NoPayload {
&NO_PAYLOAD
}
fn from_parts(_params: &PathParams, _payload: NoPayload) -> Result<Self, ApiError> {
Ok(Self::new())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetLeaderboard {
pub board: String,
pub query: TopQuery,
}
impl GetLeaderboard {
pub fn new(board: impl Into<String>) -> Self {
Self { board: board.into(), query: TopQuery::new() }
}
pub fn with_query(mut self, query: TopQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for GetLeaderboard {
type Payload = TopQuery;
type Response = LeaderboardPage;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::leaderboards::BOARD, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &TopQuery {
&self.query
}
fn path_params(&self) -> PathParams {
PathParams::new().with("board", &self.board)
}
fn from_parts(params: &PathParams, query: TopQuery) -> Result<Self, ApiError> {
Ok(Self::new(board(params)?).with_query(query))
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct PostScore {
pub board: String,
pub submit: SubmitScore,
}
impl PostScore {
pub fn new(board: impl Into<String>, submit: SubmitScore) -> Self {
Self { board: board.into(), submit }
}
}
impl HttpCall for PostScore {
type Payload = SubmitScore;
type Response = ScoreAck;
const ROUTE: Route = Route::new(HttpMethod::Post, routes::leaderboards::SCORES, true);
const PAYLOAD: PayloadKind = PayloadKind::Json;
fn payload(&self) -> &SubmitScore {
&self.submit
}
fn path_params(&self) -> PathParams {
PathParams::new().with("board", &self.board)
}
fn from_parts(params: &PathParams, submit: SubmitScore) -> Result<Self, ApiError> {
Ok(Self::new(board(params)?, submit))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetMyRank {
pub board: String,
pub query: RankQuery,
}
impl GetMyRank {
pub fn new(board: impl Into<String>) -> Self {
Self { board: board.into(), query: RankQuery::new() }
}
pub fn with_query(mut self, query: RankQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for GetMyRank {
type Payload = RankQuery;
type Response = MyRank;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::leaderboards::ME, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &RankQuery {
&self.query
}
fn path_params(&self) -> PathParams {
PathParams::new().with("board", &self.board)
}
fn from_parts(params: &PathParams, query: RankQuery) -> Result<Self, ApiError> {
Ok(Self::new(board(params)?).with_query(query))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct GetAroundMe {
pub board: String,
pub query: AroundQuery,
}
impl GetAroundMe {
pub fn new(board: impl Into<String>) -> Self {
Self { board: board.into(), query: AroundQuery::new() }
}
pub fn with_query(mut self, query: AroundQuery) -> Self {
self.query = query;
self
}
}
impl HttpCall for GetAroundMe {
type Payload = AroundQuery;
type Response = LeaderboardPage;
const ROUTE: Route = Route::new(HttpMethod::Get, routes::leaderboards::AROUND, true);
const PAYLOAD: PayloadKind = PayloadKind::Query;
fn payload(&self) -> &AroundQuery {
&self.query
}
fn path_params(&self) -> PathParams {
PathParams::new().with("board", &self.board)
}
fn from_parts(params: &PathParams, query: AroundQuery) -> Result<Self, ApiError> {
Ok(Self::new(board(params)?).with_query(query))
}
}
}
pub use calls::{GetAroundMe, GetLeaderboard, GetMyRank, ListBoards, PostScore};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys() {
for good in ["highscore", "race.track-2", "0", &"k".repeat(MAX_BOARD_KEY_BYTES)] {
assert!(is_valid_board_key(good), "{good}");
}
for bad in ["", "High", "-x", "a b", "a/b", &"k".repeat(MAX_BOARD_KEY_BYTES + 1), "é"] {
assert!(!is_valid_board_key(bad), "{bad}");
}
}
#[test]
fn periods() {
const DAY: i64 = DAY_MILLIS;
assert_eq!(Period::Weekly.start_of(UnixMillis(4 * DAY)), Some(UnixMillis(4 * DAY)));
assert_eq!(Period::Weekly.start_of(UnixMillis(11 * DAY - 1)), Some(UnixMillis(4 * DAY)));
assert_eq!(Period::Weekly.start_of(UnixMillis(11 * DAY)), Some(UnixMillis(11 * DAY)));
assert_eq!(Period::Weekly.start_of(UnixMillis(0)), Some(UnixMillis(-3 * DAY)));
assert_eq!(Period::Weekly.start_of(UnixMillis(-1)), Some(UnixMillis(-3 * DAY)));
assert_eq!(Period::Daily.start_of(UnixMillis(DAY + 5)), Some(UnixMillis(DAY)));
assert_eq!(Period::Daily.start_of(UnixMillis(-1)), Some(UnixMillis(-DAY)));
assert_eq!(Period::Daily.end_of(UnixMillis(DAY + 5)), Some(UnixMillis(2 * DAY)));
assert_eq!(Period::Weekly.end_of(UnixMillis(4 * DAY)), Some(UnixMillis(11 * DAY)));
assert_eq!((Period::AllTime.start_of(UnixMillis(9)), Period::AllTime.end_of(UnixMillis(9))), (None, None));
assert!(Period::Daily.start_of(UnixMillis(i64::MIN)).is_some() && Period::Weekly.end_of(UnixMillis(i64::MAX)).is_some());
assert_eq!(serde_json::from_str::<Period>("\"monthly\"").ok(), Some(Period::Unknown));
assert_eq!(serde_json::to_string(&Period::AllTime).ok().as_deref(), Some("\"all_time\""));
}
#[test]
fn json_and_rules() {
assert_eq!(serde_json::to_string(&SubmitScore::new(1200)).ok().as_deref(), Some(r#"{"score":1200}"#));
assert!(SubmitScore::new(i64::MIN).validate(DEFAULT_MAX_METADATA_BYTES).is_err());
assert!(SubmitScore::new(-i64::MAX).validate(DEFAULT_MAX_METADATA_BYTES).is_ok());
assert!(SubmitScore::new(1).with_metadata(serde_json::json!("x".repeat(2000))).validate(DEFAULT_MAX_METADATA_BYTES).is_err());
let info = BoardInfo::new("race", ScoreMode::Best, ScoreOrder::Asc, Period::Weekly);
let json = serde_json::to_string(&info).unwrap_or_default();
assert_eq!(json, r#"{"key":"race","mode":"best","order":"asc","period":"weekly","client_submit":true}"#);
let old: Option<BoardInfo> = serde_json::from_str(r#"{"key":"race","mode":"best","order":"asc","period":"weekly"}"#).ok();
assert_eq!(old.map(|b| b.client_submit), Some(true));
assert_eq!(serde_json::from_str::<ScoreMode>("\"median\"").ok(), Some(ScoreMode::Unknown));
assert_eq!(AroundQuery::new().counts(), (DEFAULT_AROUND, DEFAULT_AROUND));
assert_eq!(AroundQuery::new().with_counts(0, 500).counts(), (0, MAX_AROUND));
assert_eq!(TopQuery::new().with_limit(1000).limit_or_default(), 100);
}
}