From 9cec129467006f7636699e7574c40b1fa96501a6 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:35:01 +0100 Subject: [PATCH 1/3] Leaderboards: ADR 0040, design, bridge messages 0x0601/0x0602, cloud and portal API types and specs --- CONTEXT.md | 14 +- crates/api/src/lib.rs | 205 ++++++++++++++++++ crates/api/src/path.rs | 15 ++ crates/bridge-protocol/src/lib.rs | 156 +++++++++++++ crates/bridge-protocol/src/payload.rs | 13 ++ crates/bridge-protocol/src/tests.rs | 187 ++++++++++++++++ crates/net/src/service.rs | 5 +- docs/DESIGN.md | 39 +++- ...boards-of-best-scores-named-by-the-game.md | 39 ++++ spec/bridge-protocol.md | 23 ++ spec/cloud-api.md | 67 ++++++ spec/portal-api.md | 21 +- 12 files changed, 780 insertions(+), 4 deletions(-) create mode 100644 docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md diff --git a/CONTEXT.md b/CONTEXT.md index e5bc359..ba6bc31 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -203,7 +203,19 @@ _Avoid_: Rank, account level **Trophy comparison**: Two players' trophy records in one title side by side (ID, grade and time each), with their totals, points and levels. It shows only what each player's trophy visibility allows; ids only, never names or art. -_Avoid_: Leaderboard (that ranks many players) +_Avoid_: Leaderboard (see Board: that ranks many players) + +**Board**: +The ranking of one title's scores for one thing a game ranks, named by the title ID and the board ID the game passes. Has an order (which end is better), bounds for plausible scores and a status. Its name, art and units belong to the game. +_Avoid_: Leaderboard (alone, when one board is meant), table, chart + +**Score entry**: +One account's best score on one board and when it was reached. Only the best is kept; a better score replaces it. Seen by its owner, by friends unless the owner chose nobody, and by others only if the owner chose everyone ([ADR 0040](docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). +_Avoid_: Record (a trophy record is meant by that), result + +**Board rank**: +The place of an entry among the entries a viewer may see on a board. Equal scores share a rank. Counted per viewer, so it never shows a hidden player. +_Avoid_: Level (trophy level), position (a position is the zero-based index of a line in a read) **Secret trophy**: A trophy record that the owner's launcher marked as secret because the game hides it. Nobody but the owner sees, counts or hears of it until the owner reveals it; it then behaves like any record. diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index a4c3edf..bf93261 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -233,6 +233,168 @@ impl Default for RememberPlayedWith { } } +// Leaderboards + +/// The most entries one read of a board returns. +pub const BOARD_PAGE_MAX: u32 = 100; +/// The entries a read returns when it says nothing. +pub const BOARD_PAGE_DEFAULT: u32 = 25; +/// The most boards one title may have. The cloud makes a board when the first score comes in, so +/// the number bounds what a client can make up. +pub const BOARDS_PER_TITLE_MAX: u32 = 64; +/// The most boards one account may make. +pub const BOARDS_PER_ACCOUNT_MAKE: u32 = 16; +/// The most boards one account keeps a score on. +pub const SCORES_PER_ACCOUNT_MAX: u32 = 1_000; +/// The lowest score a board takes until a moderator sets other bounds. +pub const SCORE_MIN_DEFAULT: i64 = 0; +/// The highest score a board takes until a moderator sets other bounds: 2^53 - 1, so every JSON +/// parser reads a score exactly. +pub const SCORE_MAX_DEFAULT: i64 = 9_007_199_254_740_991; + +/// True for a board ID: what the game passes to name a board, 1 to 64 characters from +/// `A-Z a-z 0-9 _ -`. It has the shape of a title ID. +pub fn is_board_id(text: &str) -> bool { + is_title_id(text) +} + +/// Which end of a board is better. A moderator sets it from what the game does; a new board +/// ranks the highest score first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoardOrder { + #[default] + High, + Low, +} + +/// Whether a board takes new scores. A closed board stays readable. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoardStatus { + #[default] + Open, + Closed, +} + +/// Which entries of a board a read lists. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BoardView { + /// The best entries the viewer may see. + Top, + /// The entries around the viewer's own. + Around, + /// The viewer and their friends. + Friends, +} + +/// The body of `PUT /v1/me/scores/{title}/{board}`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScoreSubmit { + pub score: i64, +} + +/// What became of a submitted score. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScoreSaved { + /// True when the score is the account's new best; false when the account's best on the board + /// was as good or better and stays. + pub accepted: bool, + /// The account's best on the board now. + pub score: i64, + /// When that best was reached, in Unix seconds. + pub achieved: u64, + /// The account's place among the entries it may see, counting from 1. + pub rank: u32, + /// The number of those entries. + pub total: u32, +} + +/// One line of a board. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardEntry { + /// Counts from 1. Equal scores share a rank, and the next rank skips the places they take. + pub rank: u32, + pub account: Account, + pub score: i64, + /// When the account reached the score, in Unix seconds. + pub achieved: u64, +} + +/// A range of a board as one viewer sees it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardPage { + pub title_id: String, + pub board: String, + pub order: BoardOrder, + pub status: BoardStatus, + pub view: BoardView, + /// The number of entries the viewer may see in this view. + pub total: u32, + /// The position of the first entry of `entries` in the view, counting from 0. + pub start: u32, + pub entries: Vec, + /// The viewer's own entry in this view, absent for a viewer without one or not signed in. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub me: Option, +} + +/// A board in the list of a title. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardSummary { + pub board: String, + pub order: BoardOrder, + pub status: BoardStatus, + /// The number of entries the viewer may see. + pub entries: u32, +} + +/// The answer of `GET /v1/titles/{title}/boards`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TitleBoards { + pub title_id: String, + pub boards: Vec, +} + +/// Who may see the account's scores on boards: the body of `PUT /v1/me/score-privacy` and its +/// answer. A part of the profile's privacy in kind, a call of its own in form (ADR 0040). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScorePrivacy { + pub scores: Audience, +} + +/// A board with the data a moderator sets: the answer of +/// `PUT /v1/moderation/boards/{title}/{board}`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardSettings { + pub title_id: String, + pub board: String, + pub order: BoardOrder, + pub status: BoardStatus, + pub min_score: i64, + pub max_score: i64, +} + +/// The body of `PUT /v1/moderation/boards/{title}/{board}`: what to change. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct BoardChange { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_score: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_score: Option, +} + +/// The body of the moderator's removal of an entry. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryRemoval { + pub reason: String, +} + /// The tokens of one signed-in device. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Tokens { @@ -3066,6 +3228,49 @@ mod tests { assert!(!text.contains("title"), "{text}"); } + #[test] + fn boards_rank_the_high_end_first_and_start_open() { + assert_eq!(BoardOrder::default(), BoardOrder::High); + assert_eq!(BoardStatus::default(), BoardStatus::Open); + assert_eq!(ScorePrivacy::default().scores, Audience::Friends); + assert_eq!( + serde_json::to_string(&BoardView::Around).unwrap(), + r#""around""# + ); + assert_eq!(SCORE_MAX_DEFAULT, (1_i64 << 53) - 1); + } + + #[test] + fn board_ids_have_the_shape_of_title_ids() { + for good in ["7", "weekly-1", "A_b-9", &"x".repeat(64)] { + assert!(is_board_id(good), "{good}"); + } + for bad in ["", "a b", "a/b", "a.b", "\u{e4}", &"x".repeat(65)] { + assert!(!is_board_id(bad), "{bad}"); + } + } + + #[test] + fn a_board_page_leaves_out_an_absent_entry_and_reads_without_it() { + let page = BoardPage { + title_id: "GAME00001".into(), + board: "7".into(), + order: BoardOrder::Low, + status: BoardStatus::Closed, + view: BoardView::Friends, + total: 0, + start: 0, + entries: Vec::new(), + me: None, + }; + let text = serde_json::to_string(&page).unwrap(); + assert!(!text.contains("\"me\""), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), page); + let change: BoardChange = serde_json::from_str(r#"{"order":"low"}"#).unwrap(); + assert_eq!(change.order, Some(BoardOrder::Low)); + assert!(change.status.is_none() && change.min_score.is_none()); + } + #[test] fn privacy_starts_at_friends() { assert_eq!(Privacy::default().trophies, Audience::Friends); diff --git a/crates/api/src/path.rs b/crates/api/src/path.rs index c2c0f5c..865cf06 100644 --- a/crates/api/src/path.rs +++ b/crates/api/src/path.rs @@ -45,6 +45,16 @@ pub const APPEAR_OFFLINE: &str = "/v1/me/appear-offline"; pub const PUBLIC_PROFILE: &str = "/v1/me/public-profile"; pub const PLAYED_WITH: &str = "/v1/me/played-with"; pub const PLAYED_WITH_REMEMBER: &str = "/v1/me/played-with/remember"; +pub const MY_SCORE: &str = "/v1/me/scores/{id}/{board}"; +pub const SCORE_PRIVACY: &str = "/v1/me/score-privacy"; +pub const TITLE_BOARDS: &str = "/v1/titles/{id}/boards"; +pub const BOARD: &str = "/v1/boards/{id}/{board}"; +pub const BOARD_AROUND: &str = "/v1/boards/{id}/{board}/around"; +pub const BOARD_FRIENDS: &str = "/v1/boards/{id}/{board}/friends"; +pub const MOD_BOARD: &str = "/v1/moderation/boards/{id}/{board}"; +pub const MOD_BOARD_ENTRY: &str = "/v1/moderation/boards/{id}/{board}/entries/{account}"; +pub const MOD_BOARD_ENTRY_RESTORE: &str = + "/v1/moderation/boards/{id}/{board}/entries/{account}/restore"; pub const MY_PROFILE: &str = "/v1/me/profile"; pub const ACCOUNT_PROFILE: &str = "/v1/accounts/{id}/profile"; pub const FEED: &str = "/v1/feed"; @@ -125,6 +135,11 @@ pub fn fill_title(path: &str, id: impl std::fmt::Display, title: impl std::fmt:: fill(path, id).replacen("{title}", &title.to_string(), 1) } +/// The path with `{id}` and `{board}` replaced. +pub fn fill_board(path: &str, id: impl std::fmt::Display, board: impl std::fmt::Display) -> String { + fill(path, id).replacen("{board}", &board.to_string(), 1) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/bridge-protocol/src/lib.rs b/crates/bridge-protocol/src/lib.rs index 90e774c..23bde92 100644 --- a/crates/bridge-protocol/src/lib.rs +++ b/crates/bridge-protocol/src/lib.rs @@ -61,6 +61,8 @@ impl Kind { pub const SHARE_PAD: Kind = Kind(0x0504); pub const OVERRIDE: Kind = Kind(0x0505); pub const SHARE_STATUS: Kind = Kind(0x0506); + pub const SCORE_RECORD: Kind = Kind(0x0601); + pub const BOARD_READ: Kind = Kind(0x0602); /// The kind of the reply to a request of this kind. pub const fn reply(self) -> Kind { @@ -267,6 +269,10 @@ pub enum Message { SharePad(SharePad), Override { action: OverrideAction }, ShareStatus(ShareStatus), + ScoreRecord(ScoreRecord), + ScoreRecorded(ScoreRecorded), + BoardRead(BoardRead), + BoardPage(BoardPage), } impl Message { @@ -304,6 +310,10 @@ impl Message { Message::SharePad(_) => Kind::SHARE_PAD, Message::Override { .. } => Kind::OVERRIDE, Message::ShareStatus(_) => Kind::SHARE_STATUS, + Message::ScoreRecord(_) => Kind::SCORE_RECORD, + Message::ScoreRecorded(_) => Kind::SCORE_RECORD.reply(), + Message::BoardRead(_) => Kind::BOARD_READ, + Message::BoardPage(_) => Kind::BOARD_READ.reply(), } } @@ -446,6 +456,38 @@ impl Message { w.u32(m.dropped); w.u32(m.audio_underruns); } + Message::ScoreRecord(m) => { + w.i32(m.user_id); + w.str(&m.board)?; + w.i64(m.score); + } + Message::ScoreRecorded(m) => { + w.u8(m.result as u8); + w.i64(m.score); + w.u32(m.rank); + w.u32(m.total); + } + Message::BoardRead(m) => { + w.i32(m.user_id); + w.str(&m.board)?; + w.u8(m.view as u8); + w.u32(m.start); + w.u16(m.count); + } + Message::BoardPage(m) => { + w.u8(m.result as u8); + w.u32(m.total); + w.u32(m.me_rank); + w.i64(m.me_score); + w.u16(u16::try_from(m.entries.len()).map_err(|_| Error::TooLong)?); + for row in &m.entries { + w.u32(row.rank); + w.u64(row.account_id); + w.str(&row.handle)?; + w.i64(row.score); + w.u64(row.achieved); + } + } } Ok(w.into_inner()) } @@ -604,6 +646,48 @@ impl Message { dropped: r.u32()?, audio_underruns: r.u32()?, }), + Kind::SCORE_RECORD => Message::ScoreRecord(ScoreRecord { + user_id: r.i32()?, + board: r.str()?, + score: r.i64()?, + }), + k if k == Kind::SCORE_RECORD.reply() => Message::ScoreRecorded(ScoreRecorded { + result: ScoreResult::from(r.u8()?), + score: r.i64()?, + rank: r.u32()?, + total: r.u32()?, + }), + Kind::BOARD_READ => Message::BoardRead(BoardRead { + user_id: r.i32()?, + board: r.str()?, + view: BoardView::from(r.u8()?), + start: r.u32()?, + count: r.u16()?, + }), + k if k == Kind::BOARD_READ.reply() => { + let result = BoardResult::from(r.u8()?); + let total = r.u32()?; + let me_rank = r.u32()?; + let me_score = r.i64()?; + let count = r.u16()? as usize; + let mut entries = Vec::with_capacity(count.min(r.remaining() / BoardRow::MIN_LEN)); + for _ in 0..count { + entries.push(BoardRow { + rank: r.u32()?, + account_id: r.u64()?, + handle: r.str()?, + score: r.i64()?, + achieved: r.u64()?, + }); + } + Message::BoardPage(BoardPage { + result, + total, + me_rank, + me_score, + entries, + }) + } _ => return Ok(None), }; Ok(Some(message)) @@ -823,6 +907,65 @@ pub struct ShareStatus { pub audio_underruns: u32, } +/// The game records a score for one of its boards (`spec/bridge-protocol.md#leaderboards-0x06xx`). +/// The title is the one of the running game start, so the board is named by what the game passes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScoreRecord { + pub user_id: i32, + /// The board as the game names it, 1 to 64 characters from `A-Z a-z 0-9 _ -`. + pub board: String, + pub score: i64, +} + +/// What became of a recorded score. `score` is the player's best on the board now (0 when they +/// have none), `rank` their place among the entries they may see (0 when they have none) and +/// `total` the number of those entries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScoreRecorded { + pub result: ScoreResult, + pub score: i64, + pub rank: u32, + pub total: u32, +} + +/// The game reads a range of one board. `start` counts from 0 and is used by `Top` and +/// `Friends`; `Around` takes `count` entries with the player in the middle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoardRead { + pub user_id: i32, + pub board: String, + pub view: BoardView, + pub start: u32, + pub count: u16, +} + +/// One line of a board. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoardRow { + pub rank: u32, + pub account_id: u64, + pub handle: String, + pub score: i64, + /// Unix seconds. + pub achieved: u64, +} + +impl BoardRow { + /// The size of a row with an empty handle. + pub const MIN_LEN: usize = 4 + 8 + 2 + 8 + 8; +} + +/// The answer to a board read. `total` counts the entries the view holds, `me_rank` and +/// `me_score` are the player's own line in it (0 and 0 when they have none). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoardPage { + pub result: BoardResult, + pub total: u32, + pub me_rank: u32, + pub me_score: i64, + pub entries: Vec, +} + macro_rules! byte_enum { ($(#[$meta:meta])* $name:ident { $($variant:ident = $value:literal),+ $(,)? }) => { $(#[$meta])* @@ -851,6 +994,19 @@ byte_enum!(PeerState { Connecting = 1, Connected = 2, Disconnected = 3, Failed = byte_enum!(Transport { Datagram = 1, Stream = 2 }); byte_enum!(BindResult { Bound = 0, InUse = 1 }); byte_enum!(DialogResult { Sent = 0, Cancelled = 1 }); +byte_enum!( + /// What became of a recorded score. + ScoreResult { Stored = 0, Kept = 1, OutOfRange = 2, Refused = 3, Limited = 4, SignedOut = 5 } +); +byte_enum!( + /// Which entries of a board a read asks for: the best, those around the player, or the + /// player and their friends. + BoardView { Top = 1, Around = 2, Friends = 3 } +); +byte_enum!( + /// The answer to a board read. + BoardResult { Done = 0, UnknownBoard = 1, Refused = 2, Limited = 3, SignedOut = 4 } +); byte_enum!( /// What the guest may do: watch only, play as the host's pad, or play a second pad. ShareMode { Watch = 0, HandOver = 1, Together = 2 } diff --git a/crates/bridge-protocol/src/payload.rs b/crates/bridge-protocol/src/payload.rs index 94b7154..1fd659e 100644 --- a/crates/bridge-protocol/src/payload.rs +++ b/crates/bridge-protocol/src/payload.rs @@ -39,6 +39,10 @@ impl Writer { self.out.extend_from_slice(&value.to_le_bytes()); } + pub fn i64(&mut self, value: i64) { + self.out.extend_from_slice(&value.to_le_bytes()); + } + pub fn addr(&mut self, value: Ipv4Addr) { self.u32(u32::from(value)); } @@ -123,10 +127,19 @@ impl<'a> Reader<'a> { Ok(i32::from_le_bytes(self.array()?)) } + pub fn i64(&mut self) -> Result { + Ok(i64::from_le_bytes(self.array()?)) + } + pub fn addr(&mut self) -> Result { Ok(Ipv4Addr::from(self.u32()?)) } + /// How many bytes are not read yet. + pub fn remaining(&self) -> usize { + self.rest.len() + } + /// Everything not read yet. pub fn rest(&mut self) -> &'a [u8] { std::mem::take(&mut self.rest) diff --git a/crates/bridge-protocol/src/tests.rs b/crates/bridge-protocol/src/tests.rs index 04e961b..1cdc19b 100644 --- a/crates/bridge-protocol/src/tests.rs +++ b/crates/bridge-protocol/src/tests.rs @@ -320,9 +320,196 @@ fn samples() -> Vec { dropped: 12, audio_underruns: 1, }), + Message::ScoreRecord(ScoreRecord { + user_id: -1, + board: "weekly-1".into(), + score: -9_007_199_254_740_991, + }), + Message::ScoreRecorded(ScoreRecorded { + result: ScoreResult::Stored, + score: 9_007_199_254_740_991, + rank: 17, + total: 4_021, + }), + Message::BoardRead(BoardRead { + user_id: 0, + board: "7".into(), + view: BoardView::Around, + start: 0, + count: 11, + }), + Message::BoardPage(BoardPage { + result: BoardResult::Done, + total: 3, + me_rank: 2, + me_score: 450, + entries: vec![ + BoardRow { + rank: 1, + account_id: 9, + handle: "Lumen".into(), + score: 900, + achieved: 1_790_000_000, + }, + BoardRow { + rank: 2, + account_id: 4, + handle: "Ember".into(), + score: 450, + achieved: 1_790_000_100, + }, + ], + }), + Message::BoardPage(BoardPage { + result: BoardResult::UnknownBoard, + total: 0, + me_rank: 0, + me_score: 0, + entries: Vec::new(), + }), ] } +#[test] +fn leaderboard_kinds_are_the_ones_in_the_spec() { + let kinds: Vec = [ + Kind::SCORE_RECORD, + Kind::SCORE_RECORD.reply(), + Kind::BOARD_READ, + Kind::BOARD_READ.reply(), + ] + .iter() + .map(|k| k.0) + .collect(); + assert_eq!(kinds, [0x0601, 0x8601, 0x0602, 0x8602]); +} + +#[test] +fn leaderboard_payloads_have_the_layout_of_the_spec() { + let record = Message::ScoreRecord(ScoreRecord { + user_id: 1, + board: "b".into(), + score: -2, + }); + assert_eq!( + record.payload().unwrap(), + [ + 1, 0, 0, 0, // user_id + 1, 0, b'b', // board: str + 0xFE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // score: i64 + ] + ); + let recorded = Message::ScoreRecorded(ScoreRecorded { + result: ScoreResult::Kept, + score: 0x0102, + rank: 3, + total: 4, + }); + assert_eq!( + recorded.payload().unwrap(), + [ + 1, // result + 2, 1, 0, 0, 0, 0, 0, 0, // score + 3, 0, 0, 0, // rank + 4, 0, 0, 0, // total + ] + ); + let read = Message::BoardRead(BoardRead { + user_id: 0, + board: "x".into(), + view: BoardView::Friends, + start: 5, + count: 25, + }); + assert_eq!( + read.payload().unwrap(), + [ + 0, 0, 0, 0, // user_id + 1, 0, b'x', // board + 3, // view + 5, 0, 0, 0, // start + 25, 0, // count + ] + ); + let page = Message::BoardPage(BoardPage { + result: BoardResult::Done, + total: 9, + me_rank: 2, + me_score: 7, + entries: vec![BoardRow { + rank: 1, + account_id: 0x0102, + handle: "ab".into(), + score: 5, + achieved: 6, + }], + }); + assert_eq!( + page.payload().unwrap(), + [ + 0, // result + 9, 0, 0, 0, // total + 2, 0, 0, 0, // me_rank + 7, 0, 0, 0, 0, 0, 0, 0, // me_score + 1, 0, // entry count + 1, 0, 0, 0, // rank + 2, 1, 0, 0, 0, 0, 0, 0, // account_id + 2, 0, b'a', b'b', // handle + 5, 0, 0, 0, 0, 0, 0, 0, // score + 6, 0, 0, 0, 0, 0, 0, 0, // achieved + ] + ); +} + +#[test] +fn a_board_page_cut_short_is_truncated_and_a_huge_count_costs_nothing() { + let page = Message::BoardPage(BoardPage { + result: BoardResult::Done, + total: 1, + me_rank: 0, + me_score: 0, + entries: vec![BoardRow { + rank: 1, + account_id: 1, + handle: "ab".into(), + score: 1, + achieved: 1, + }], + }); + let payload = page.payload().unwrap(); + for cut in 0..payload.len() { + assert_eq!( + Message::parse(Kind::BOARD_READ.reply(), &payload[..cut]), + Err(Error::Truncated), + "cut at {cut}" + ); + } + // A count of 65,535 rows with none behind it is refused without a large allocation. + let mut lying = payload[..17].to_vec(); + lying.extend_from_slice(&u16::MAX.to_le_bytes()); + assert_eq!( + Message::parse(Kind::BOARD_READ.reply(), &lying), + Err(Error::Truncated) + ); +} + +#[test] +fn unknown_leaderboard_values_are_tolerated() { + let Ok(Some(Message::ScoreRecorded(m))) = Message::parse( + Kind::SCORE_RECORD.reply(), + &[77, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ) else { + panic!("not read"); + }; + assert_eq!(m.result, ScoreResult::Unknown); + let Ok(Some(Message::BoardRead(m))) = + Message::parse(Kind::BOARD_READ, &[0, 0, 0, 0, 0, 0, 9, 0, 0, 0, 0, 1, 0]) + else { + panic!("not read"); + }; + assert_eq!(m.view, BoardView::Unknown); +} + #[test] fn share_play_kinds_are_the_ones_in_the_spec() { let kinds: Vec = [ diff --git a/crates/net/src/service.rs b/crates/net/src/service.rs index b72984d..6a86da8 100644 --- a/crates/net/src/service.rs +++ b/crates/net/src/service.rs @@ -4266,7 +4266,10 @@ impl Service { | Message::ShareOpen(_) | Message::ShareClose | Message::ShareMode { .. } - | Message::SharePad(_) => { + | Message::SharePad(_) + // The leaderboard replies travel from the service to the game. + | Message::ScoreRecorded(_) + | Message::BoardPage(_) => { if request != 0 { self.bridge_reply(connection, request, Message::Unsupported); } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3a85684..5d877c4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -43,7 +43,7 @@ A separate process that the launcher starts and watches ([ADR 0004](adr/0004-net - **Account and device.** The refresh token is kept in the operating system's credential store, with the key of the device that it is bound to. The player signs in and manages the account on the cloud's web pages in their browser ([ADR 0016](adr/0016-accounts-are-managed-on-the-web.md)). - **A lasting connection to the cloud** for presence, invitations, friend requests and session events. -- **Translation.** It turns the platform-shaped web API requests from the bridge into the cloud's own API, with one handler per API group. `userProfile` and `communicationRestrictionStatus` are answered today ([spec/web-api](../spec/web-api/README.md)); sessions, matchmaking and the profanity filter come next, leaderboards and title storage later. Anything unknown is refused and written to the observation log ([ADR 0001](adr/0001-bridge-forwards-net-service-translates.md)). +- **Translation.** It turns the platform-shaped web API requests from the bridge into the cloud's own API, with one handler per API group. `userProfile` and `communicationRestrictionStatus` are answered today ([spec/web-api](../spec/web-api/README.md)); sessions, matchmaking and the profanity filter come next, leaderboards ([below](#leaderboards)) and title storage later. Anything unknown is refused and written to the observation log ([ADR 0001](adr/0001-bridge-forwards-net-service-translates.md)). - **Tunnels** on [iroh](https://www.iroh.computer/) 1.0: QUIC, hole punching, relays as the fallback, encrypted end to end ([ADR 0002](adr/0002-tunnels-on-iroh.md)). Each time a tunnel settles on a direct or a relayed path, the service logs one line on standard error with target `anyplay::path`, the kind of path and running totals (`direct`, `relay`, `upgrades`). The line names no one and nothing leaves the PC: it measures our own test machines and probes. Measuring real players needs a report to the cloud, which the specification does not have yet. - **Party voice** ([`voice.rs`](../crates/net/src/voice.rs), [spec](../spec/voice.md), [ADR 0010](adr/0010-voice-and-shared-play-go-through-the-tunnels.md), [ADR 0014](adr/0014-voice-is-an-engine-without-a-transport.md), [ADR 0017](adr/0017-voice-has-a-connection-of-its-own.md)). When the player has switched voice on and is in a party, one task, the **hub**, owns the voice engine and with it the microphone and speakers; with voice off or no party nothing is opened. For each other member who takes part it holds a second QUIC connection on the tunnels' own iroh endpoint, with the ALPN `anyplay/voice/0`, because game datagrams carry no tag byte to tell them from voice. It is a connection of its own but not a path of its own: relay-only clears the endpoint's direct addresses, so voice goes through the relay whenever the tunnels do, and a restart of the tunnels (relay-only changed, new tickets) hangs voice up and joins it again on the new endpoint. The hub pumps the engine every 5 ms, sends each frame it produces as a datagram to every member, and gives each member's datagrams to the engine under that member's account. - **Who may connect** is the party's member list and nothing else. The cloud's `Party` carries each member's tunnel key, relay and a `voice` flag, which each net service announces with `PUT /v1/party/endpoint` ([cloud API](../spec/cloud-api.md#parties)); sessions do the same with `Member`. A connection stays only if the key the handshake proves is a current member's key; anything else is closed at once and never reaches the engine. A key that two members name belongs to neither. As for tunnels, the smaller key of a pair dials and the larger accepts. @@ -91,6 +91,43 @@ A link such as `anyps5://v1/join/…` never starts a game by itself; it opens th | Friends, presence, profiles, block list | From the cloud. | | Connect to another player | A tunnel with a virtual address. | | Invite friends | A friend picker in the launcher; the invitation goes through the cloud. | +| Record a score, read a ranking | A board of the cloud, keyed by the title and what the game passes ([Leaderboards](#leaderboards)). | + +## Leaderboards + +A game that ranks its players records a score for a board and reads a range of it. The network answers both for AnyPlay accounts, from the cloud, for every title the same way ([ADR 0040](adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). + +**Domain.** + +| Term | Meaning | +|---|---| +| Board | A title ID plus a board ID, the text the game passes. Has an order (`high` or `low` is better), bounds for plausible scores and a status (`open` or `closed`). | +| Entry | One account's best score on one board and when it was reached. No history. | +| Range | Which lines a read returns: `top` (the best the viewer may see), `around` (the lines around the viewer's own), `friends` (the viewer and their friends). | +| Rank | The place among the entries the viewer may see in that range; equal scores share a rank. | +| Audience | Who sees an account's entries: friends (default), everyone or nobody. A block hides both ways. | + +**Flow.** + +```text +game ──record score / read range──▶ bridge ──0x0601 / 0x0602──▶ net service ──PUT /v1/me/scores, GET /v1/boards──▶ cloud + │ +launcher (later), web page ◀──────────────────────────────── reads ─────────────────────────────────────────────────────┘ +``` + +- The bridge forwards a call as one request and waits for its reply, like a web request. It sends no title: the net service knows the title of the game start and puts it into the cloud path. The board ID is the game's own number or name as text. +- The net service checks nothing about a score beyond its shape. The cloud applies bounds, limits and privacy and answers with the result codes of the [bridge protocol](../spec/bridge-protocol.md#leaderboards-0x06xx). +- The web shows a read-only page per board at `/boards/{title}/{board}`, from the [portal API](../spec/portal-api.md#leaderboards). A visitor who is not signed in sees only the entries whose owners chose `everyone`. +- The net service does not handle the two requests yet and answers `unsupported`; the cloud side is built and tested on its own. +- Everything is behind the cloud's `ANYPLAY_CLOUD_LEADERBOARDS_ENABLED` (off by default). A bridge that talks to a cloud with the flag off gets `refused` in the replies and the game sees its ranking service as not available. + +**What the engine will need later** (a note for the engine owners, not engine code): + +1. Find, by observation of games we own, which library calls a game uses to record a score and to read a ranking, and what the game does with the replies. Write what is found into `spec/web-api/` or a new file under `spec/`, from observation only ([OBSERVING.md](OBSERVING.md)); no names, formats or documents from the platform's own material. +2. Map each call to `0x0601` (record) or `0x0602` (read) and fill the reply in the shape the game accepts. A game that reads "by account IDs" has no view for it yet: that is a new `view` value (additive), asked for when a game needs it. +3. Turn the board the game names (a number or a name) into the text board ID and pass it as it is; a game that has a communication ID or a service label per title set needs no extra field, since the title is the game start's. +4. Report `unsupported` and `refused` replies to the game as it reports a ranking service that is down. Never invent a rank locally. +5. Then the net service needs a handler for the two requests (calls to `PUT /v1/me/scores/{title}/{board}` and `GET /v1/boards/...`, mapping the cloud's error codes to the result codes). It is small and waits for step 1 so that it is built against a real game. ## Limits diff --git a/docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md b/docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md new file mode 100644 index 0000000..6e22088 --- /dev/null +++ b/docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md @@ -0,0 +1,39 @@ +# Leaderboards are boards of best scores, named by what the game passes + +Games on the platform rank their players: a time trial, a high score, a weekly challenge. The game records a score for a board and later reads a range of it. Both are online calls of the platform, so AnyPlay answers them for AnyPlay accounts ([ADR 0021](0021-the-line-is-drawn-at-the-law.md)); nothing is sent to a platform's servers, and no publisher's own ranking service is replaced. This is the first of the platform services after sessions that [the roadmap](../DESIGN.md#roadmap) lists as "More". + +## Decision + +- **A board is a title and what the game passes.** The network keys a board by the title ID of the running game start and a **board ID**: a text of 1 to 64 characters from `A-Z a-z 0-9 _ -`. The engine writes the game's own board number or name into that text and never interprets it. No title ID and no board ID appears in network or cloud code; the data (which boards exist, which end is better, which scores are plausible) is per-board rows in the cloud, set by moderators. +- **An entry is the best score of one account on one board.** A submitted score replaces the account's entry only when it is better by the board's order; an equal score keeps the earlier time. The cloud keeps no history of scores, only the best and when it was reached. A score is a signed 64-bit integer; times, points and distances are the game's to scale into one. +- **The board knows its order and bounds.** Each board has an order (`high` or `low` is better, `high` for a new board), a lowest and a highest score it takes (0 and 2^53 - 1 for a new board) and a status (`open` or `closed`). The bridge cannot know the order, because the platform's library reads it from the publisher's setup, not from the call. So a board is made on the first accepted score with the defaults, and a moderator sets the real order and bounds from observation. Until then a `low` board ranks wrong and the answer says so in nothing but its order: the data fixes it, not a release. +- **A board has a name for the game only.** Names, art and descriptions of boards stay in the game's files, as trophy texts do ([ADR 0009](0009-trophy-records-not-trophy-texts.md)). The web page shows `Board 7`, a launcher shows what its own copy of the game says. +- **Three views, one rank rule.** A viewer reads the **top** (the best entries they may see), the entries **around** their own, or **friends** (themselves and their friends). Rank is the place among the entries the viewer may see in that view, and equal scores share a rank (1, 2, 2, 4). Ordering inside a tie is earlier time first, then account ID, so a page is stable. +- **Scores are shown to friends unless the player says otherwise.** A new setting `scores` has the three audiences of the profile parts (friends by default, everyone, nobody) and follows [ADR 0012](0012-private-by-default.md): a stranger sees an entry only if its owner chose `everyone`, a friend sees it unless the owner chose `nobody`, a block in either direction hides it both ways, and the owner sees their own entry always. A hidden entry is not a gap: ranks are counted among what the viewer may see, so no rank reveals a hidden player. +- **The setting is a call of its own,** `PUT /v1/me/score-privacy`, like [appearing offline](0033-appearing-offline-hides-presence-from-everyone.md) and "remember people I play with" ([ADR 0038](0038-played-with-recently-is-a-private-list-the-cloud-keeps-for-30-days.md)): `PUT /v1/me/privacy` replaces all audiences with defaults for what is left out, and the privacy object is built by struct literal in clients that would stop compiling with a new field. It can join `Privacy` when no client depends on the shape. +- **Anti-abuse is data and limits, not detection.** + - A cloud takes 60 submissions per account per 10 minutes, reads at the usual account limit, and the public reads on the web at the public-profile limit. + - A title has at most 64 boards, an account may make at most 16 boards and keeps a score on at most 1,000. A game cannot flood the cloud with made-up boards, and an account that is not playing a title can make no more than 16 junk boards in all. + - A score outside the bounds of its board is refused, not clamped. + - A moderator removes an entry with a reason. The removal stays as a mark on the row, so the account cannot put a score back by submitting again, until a moderator restores it. A moderator can close a board, which keeps its entries and takes no more. + - Banned accounts are left out of every view; their rows stay for the length of the ban's retention and go with the account. + - No cheat detection and no check of the game: a score is what the engine reports, and the network neither inspects nor repairs a game's anti-cheat, in either direction. +- **Off by default.** The feature is behind `ANYPLAY_CLOUD_LEADERBOARDS_ENABLED`. Off, every route answers `forbidden` and the web reads `not_found`. A public cloud turns it on when the privacy notice names scores ([ADR 0021](0021-the-line-is-drawn-at-the-law.md) asks for review before answering platform calls publicly). +- **Protocol 1 stays compatible.** Four new message kinds in a group of their own, `0x06xx`: record a score and read a range, each a request with a reply. An older net service answers `unsupported` and the bridge treats that as the platform's "service not available" ([spec](../../spec/bridge-protocol.md#leaderboards-0x06xx)). The bridge never sends a title: the net service knows it from the game start. + +## Considered options + +- **History of every score.** Lets a client draw a progress curve and recount a board after removing a cheat. It also stores every attempt of every player, which grows without bound and records a player's whole play. The best score and its time are what a board shows. +- **Time-boxed boards (daily, weekly, seasons).** Games that want a weekly board pass a board ID that changes (`weekly-43`), so a board is already time-boxed by its name, and the cloud needs no calendar of its own. Resets the cloud runs itself would need a rule per game. +- **Taking the order from the first score or from the game.** Neither is knowable: a first score says nothing about whether lower is better. The platform's library takes it from the publisher's configuration, which we do not have and do not copy. +- **Global top that shows everyone regardless of privacy.** Fills a new board at once and makes every launcher a place where names are listed without being asked. [ADR 0012](0012-private-by-default.md) decided against it. A board of friends is full from the first game; the public top fills as players choose to appear. +- **One rank for everyone (counting hidden players).** A stable number, but a hidden player's score would show as a gap in the ranks and could be located by anyone willing to count. A rank among what the viewer may see costs a window function per read and leaks nothing. +- **Anti-cheat of our own (replay checks, statistical outliers).** An arms race against games we do not control, and a wrong ban costs more than a wrong score. Bounds, rate limits and removal by a moderator are the whole of it. +- **A new `Part` in `Privacy`.** Shorter in the settings page, but see "A setting of its own" above. + +## Consequences + +- A new section in the [cloud API](../../spec/cloud-api.md#leaderboards) and the [portal API](../../spec/portal-api.md#leaderboards), the bridge group, and a section in [the design](../DESIGN.md#leaderboards). +- One more table family in the cloud's data inventory (boards, scores, the `scores` audience), deleted with the account and part of its export; one more line in the privacy notice before the flag goes on. +- The net service needs a handler for the two requests; the engine needs the library calls ([follow-up for the engine owners](../DESIGN.md#leaderboards)). +- A score reported by a game is trusted as much as the game: a modified client can post any score within the bounds. Boards are a convenience between players, not a competition; a tournament would need more and is not planned. diff --git a/spec/bridge-protocol.md b/spec/bridge-protocol.md index bbd8521..452bcd1 100644 --- a/spec/bridge-protocol.md +++ b/spec/bridge-protocol.md @@ -262,6 +262,29 @@ Version 0, implemented in `crates/bridge-protocol` (the net service's side) and - `sequence` counts the guest's states, wrapping; the service has ordered them already and the bridge need not check it. - `share status` is sent once a second while the ring is in use. The service sends nothing of this group before the bridge has answered a `share open` with 0, except `share close`. +### Leaderboards (`0x06xx`) + +Added in version 1 without changing it: four new kinds, so a net service that does not know them answers a request with **unsupported** and a bridge that does not know them never sends them. Decision: [ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md); the cloud side: [Leaderboards](cloud-api.md#leaderboards). + +| Kind | Name | Direction | Payload | +|---|---|---|---| +| `0x0601` | score record | bridge → service, request | `user_id: i32`, `board: str`, `score: i64` | +| `0x8601` | score recorded | service → bridge | `result: u8`, `score: i64`, `rank: u32`, `total: u32` | +| `0x0602` | board read | bridge → service, request | `user_id: i32`, `board: str`, `view: u8`, `start: u32`, `count: u16` | +| `0x8602` | board page | service → bridge | `result: u8`, `total: u32`, `me_rank: u32`, `me_score: i64`, then `entries`: a `u16` count and that many of `rank: u32`, `account_id: u64`, `handle: str`, `score: i64`, `achieved: u64` | + +- `i64` is a little-endian signed 64-bit integer. +- **board** is the game's own name for the board as text, 1 to 64 characters from `A-Z a-z 0-9 _ -`: a number is written in decimal. The bridge sends no title. The net service uses the title ID of the running game start, so the same game always reaches the same boards, and the bridge cannot name another title's board. The bridge passes the text as it is and never interprets it. +- **score record** records the player's score on the board. `score` is the game's integer (a time is the game's units, a count is a count). The cloud keeps the player's best only, so a worse score changes nothing. + - `result`: 0 stored (a new best), 1 kept (the player's best was as good or better and stays), 2 out of range (the board does not take this score), 3 refused (the board is closed, the cloud does not offer leaderboards, the entry was removed by a moderator, or the board's name is not allowed), 4 limited (too many records; try later), 5 signed out. + - `score` is the player's best on the board after the call (0 when there is none), `rank` their place among the entries they may see (0 when they have none) and `total` the number of those entries. All three are 0 for a result of 2 or above. +- **board read** asks for a range of a board as the player sees it. + - `view`: 1 top (the best entries, from place `start` counting from 0), 2 around (`count` entries with the player's own in the middle: `count / 2` above it and the rest below; `start` is ignored; empty while the player has no entry), 3 friends (the player and their friends, from `start`). `count` is 1 to 100; the net service asks the cloud for no more. + - `result`: 0 done, 1 unknown board (no entries, or a board name that is not allowed), 2 refused (the cloud does not offer leaderboards), 3 limited, 4 signed out. With a `result` other than 0 the page is empty and the numbers are 0. + - `total` is the number of entries in this view. `me_rank` and `me_score` are the player's own line in this view (0 and 0 when they have none). Each entry carries its own rank; equal scores share a rank. `achieved` is Unix seconds. `handle` is the account's handle. +- A rank is counted among the entries the player may see, so it can differ between two players reading the same board. The engine passes ranks on as they come and never computes one. +- Only the bridge sends requests, as for every group. The bridge waits at most 30 seconds for a reply, as for a web reply; on **unsupported** or a timeout the game gets what it gets when a ranking service is not available. + ## Observation A bridge writes an [observation log](observation-log.md) when `ANYPS5_NET_OBSERVE` names a file, with or without a net service. diff --git a/spec/cloud-api.md b/spec/cloud-api.md index 776d0c4..8ffc54e 100644 --- a/spec/cloud-api.md +++ b/spec/cloud-api.md @@ -299,6 +299,72 @@ The cloud keeps a **trophy record** per trophy a player earned: the title, the t - `players` counts the accounts with at least one record for the title. Rarity is only given once 10 players have the title; below that `players` is 0 and `trophies` empty, so it cannot tell which trophies one person earned. - A gamer card's `trophies` counts the account's records by grade, or is null when its trophies are hidden from the caller. Its `avatar` is the hash of the account's avatar, absent when there is none or the caller may not see it. Nothing else is on a card. (Points and level are on the profile and the comparison, not on the card.) +## Leaderboards + +The cloud keeps boards of best scores, one per title and board ID, for every game the same way ([ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). A game's own names and art for a board never reach the cloud. Off by default: with `leaderboards_enabled` unset every route below answers `forbidden` (a read on the portal answers `not_found`). + +- A **board** is `title_id` plus a `board` ID (`is_board_id`: 1 to 64 characters from `A-Z a-z 0-9 _ -`). It is made by the first accepted score, with `order: "high"`, `status: "open"` and the bounds `0` to `9007199254740991` (`SCORE_MIN_DEFAULT`, `SCORE_MAX_DEFAULT`). A moderator sets the real order, the bounds and the status ([below](#boards-moderation)). A title has at most 64 boards, an account may make at most 16 and keeps a score on at most 1,000. +- An **entry** is an account's best score on a board and the time it was reached (`achieved`). A score is an integer; there is no history. A better score replaces the entry by the board's order (higher for `high`, lower for `low`); an equal or worse score changes nothing. +- A score outside the bounds of the board is refused with `bad_request`, not clamped. + +### Reading + +| Request | Answer | +|---|---| +| `GET /v1/boards/{title_id}/{board}?start=&count=` | `BoardPage` with `view: "top"`: the best entries the caller may see, from position `start` (0 by default), `count` of them (25 by default, at most 100). | +| `GET /v1/boards/{title_id}/{board}/around?count=` | `BoardPage` with `view: "around"`: `count` entries (25 by default, at most 100) with the caller's own in the middle, `count / 2` above it and the rest below (a window that would reach above the first place starts at the first place). Empty entries and no `me` when the caller has no entry. `start` in the answer is the position of the first line. | +| `GET /v1/boards/{title_id}/{board}/friends?start=&count=` | `BoardPage` with `view: "friends"`: the caller and the friends who show their scores to friends or to everyone. | +| `GET /v1/titles/{title_id}/boards` | `TitleBoards`: the boards of the title that have at least one entry the caller may see, ordered by ID, with `order`, `status` and the number of entries the caller may see. | + +- `BoardPage` is `{title_id, board, order, status, view, total, start, entries, me}`. `entries` are `BoardEntry` (`rank`, `account`, `score`, `achieved`) in rank order; `me` is the caller's own line in this view, absent when they have none. `total` is the number of entries the caller may see in this view. +- **Rank** is the place among the entries the caller may see in the view, counting from 1. Equal scores share a rank and the next rank skips the places they take (1, 2, 2, 4). Equal scores are listed earlier time first, then lower account ID, so a page is stable. Because rank counts only what the caller may see, two callers can read different ranks for the same entry, and no rank shows that someone is hidden. +- A board that does not exist answers `not_found`; a board of which the caller sees no entry answers an empty page. A closed board is read like an open one. + +### Who sees an entry + +An entry is seen by: + +- its owner, always; +- a friend of the owner, unless the owner's audience is `nobody`; +- anyone else, only when the owner's audience is `everyone`. + +A block in either direction hides the entry from the other account, and a banned account's entries are in no view. The audience is the account's `scores` setting: `friends` for a new account ([ADR 0012](../docs/adr/0012-private-by-default.md)). + +| Request | Body | Answer | +|---|---|---| +| `GET /v1/me/score-privacy` | | `ScorePrivacy`: `{scores: "friends" \| "everyone" \| "nobody"}` | +| `PUT /v1/me/score-privacy` | `ScorePrivacy` | the same object | + +The setting is a call of its own, like [Appearing offline](#appearing-offline): it is not part of `Privacy`, so a client that saves the six audiences never resets it. Changing it changes every entry of the account at once, and takes effect on the next read. + +### Submitting + +`PUT /v1/me/scores/{title_id}/{board}` with `ScoreSubmit` (`{score}`) answers `ScoreSaved`: `{accepted, score, achieved, rank, total}`. `accepted` is true when the score is the account's new best; `score` and `achieved` are the best after the call; `rank` and `total` are those of the caller's `top` view. + +| Code | Status | When | +|---|---|---| +| `bad_request` | 400 | A title or board ID with a wrong shape, or a score outside the bounds. | +| `forbidden` | 403 | The cloud does not offer leaderboards, the board is closed, or a moderator removed the account's entry (it stays removed until restored). | +| `limit` | 409 | The title has 64 boards already, the account made 16 boards, or it keeps a score on 1,000. | +| `rate_limited` | 429 | More than 60 submissions in 10 minutes. | + +The call is idempotent: the same score again is `accepted: false` with the same numbers. A new board costs the account one of its 16. + +### Boards: moderation + +A moderator (see [Who may moderate](#who-may-moderate)) calls: + +| Request | Body | Answer | +|---|---|---| +| `PUT /v1/moderation/boards/{title_id}/{board}` | `BoardChange`: any of `order`, `status`, `min_score`, `max_score` | `BoardSettings`. Makes the board if it does not exist. `bad_request` when `min_score` is above `max_score` or a bound is outside ±(2^53 - 1). Changing the order re-ranks at once, the stored scores are untouched. | +| `DELETE /v1/moderation/boards/{title_id}/{board}/entries/{account}` | `EntryRemoval` (`{reason}`, 1 to 500 characters) | 204. Takes the entry out of every view and stops the account from submitting to this board. `not_found` when there is no entry. | +| `POST /v1/moderation/boards/{title_id}/{board}/entries/{account}/restore` | `{}` | 204. Puts the removed entry back; the account may submit again. `not_found` when it is not removed. | + +A removal keeps the moderator's name, the reason and the time on the entry; the other changes go to the cloud's log with the moderator's name ([ADR 0015](../docs/adr/0015-moderators-are-accounts.md)). + +### Data + +The cloud keeps, per account, its entries (board, score, time, removal mark) and its audience; per board the data above. All of it is deleted with the account and is part of `GET /v1/me/export` (`scores`). Boards stay when their last entry goes. ## Run reports A **run report** is a player's answer to "How did it run?" after a session: the title, the game version, the toolkit version, a verdict and the player's system. Nothing else leaves the player's machine: no paths, hardware IDs or game data ([ADR 0021](../docs/adr/0021-the-line-is-drawn-at-the-law.md) draws the line; a run report is an opinion, not a game file). @@ -707,6 +773,7 @@ DiscordTokens { access, refresh, expires_in, scope } expires_in: seconds; scop | conversations with strangers | 10 per account per hour | | friend requests of a new account | 10 in its first day (`new_account_friend_requests_per_day`) | | `PUT /v1/me/run-reports/{title_id}` | 30 per account per hour | +| `PUT /v1/me/scores/{title_id}/{board}` | 60 per account per 10 minutes | | `PUT/DELETE /v1/feed/{id}/reaction` | 60 per account per minute | | `join-codes` | 10 new codes and 30 redemptions per account per hour | | `me/discord/token` and `me/discord/refresh` | 10 per account per hour, together | diff --git a/spec/portal-api.md b/spec/portal-api.md index 5469a4d..c15740c 100644 --- a/spec/portal-api.md +++ b/spec/portal-api.md @@ -81,7 +81,7 @@ Counted by the cloud's settings (`docs/configuration.md` of the cloud), per wind - Asking for a code (`sign-in/email`, `sign-in/code/resend`, `step-up/email`, `account/email`): per address and per client address, as for `/v1/auth/email`. Typing a code and `passkey/*`: per client address, as for `/v1/auth/code`. - A new browser session (`GET /session` without a live cookie): 300 per 15 minutes per client address, a bucket of its own, so many page loads behind one address (a shared network) cannot lock out signing in. A request that has its session is not counted. Every new session is a row in the store, because the CSRF token lives in it; the row ends with the session's idle time. - Every other request of a signed-in session: per account, shared with `/v1` (120 per minute). -- Public profiles (`users/{name}...`), `compatibility` and join pages (`join/{code}`): 120 per minute per client address. +- Public profiles (`users/{name}...`), `compatibility`, boards (`boards/...`, `titles/{title_id}/boards`) and join pages (`join/{code}`): 120 per minute per client address. - Avatar uploads: 5 per hour per account. - Starting a connection (`me/links/{provider}/start`): 10 per hour per account. - Notifications (`push`, `push/subscription`) count with every other request of a signed-in session. The cloud itself sends at most 16 requests at a time to push services from one node, and drops a notification when 2000 wait. @@ -429,6 +429,25 @@ CompatTitle { titleId, verdict, reports, counts, versions } A title with fewer than 3 players is not listed. The cloud knows no titles, so there is no `title`; the web page adds names from its own list. Counted per client address with the public profiles. +## Leaderboards + +Read-only pages of the boards of [Leaderboards](cloud-api.md#leaderboards). Both routes need no session; a signed-in browser sees more (its friends, its own line), a visitor sees only the entries whose owners show their scores to everyone. Off, like the whole feature, both answer `not_found`. They count with the public profiles (120 per minute per client address). + +| Request | Answer | +|---|---| +| `GET /titles/{title_id}/boards` | `{titleId, boards: [{board, order, status, entries}]}`: the boards of the title with at least one entry the viewer may see, by ID, at most 64. | +| `GET /boards/{title_id}/{board}?view=&start=&count=` | `BoardPage`. `view` is `top` (default), `around` or `friends`; the last two need a session and answer `unauthorized` without one. `start` and `count` as in the cloud API (`count` 25 by default, at most 100). | + +```text +BoardPage { titleId, board, order, status, view, total, start, entries, me } + order "high" or "low": which end is better + status "open" or "closed" + entries [{rank, handle, score, achieved}], in rank order; equal scores share a rank + me the viewer's own line {rank, handle, score, achieved}, absent without a session or an entry +``` + +Entries name a player by handle only, as the public profile does; the page links the handle to `/u/{handle}`, which answers `not_found` for a player who has no public page. The portal shows a board as `Board {board}` and a score as a plain number: the cloud holds no names, units or art of a game ([ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). + ## Serving the portal The cloud setting `portal_dir` (environment variable `ANYPLAY_CLOUD_PORTAL_DIR`) names the folder of the built app. It needs `public_url`, and the folder must hold `index.html`. Without it the cloud serves no portal, and `/web/authorize` sends the app's browser to the HTML pages. From f78f28f478556642b534969b3376ebf4de7fa6f7 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:45:59 +0100 Subject: [PATCH 2/3] net: answer the leaderboard requests with unsupported until a handler exists --- crates/net/src/service.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/net/src/service.rs b/crates/net/src/service.rs index 6a86da8..901439e 100644 --- a/crates/net/src/service.rs +++ b/crates/net/src/service.rs @@ -4267,7 +4267,10 @@ impl Service { | Message::ShareClose | Message::ShareMode { .. } | Message::SharePad(_) - // The leaderboard replies travel from the service to the game. + // The net service has no handler for the leaderboard requests yet: a game that sends + // one is answered `unsupported`. Their replies travel from the service to the game. + | Message::ScoreRecord(_) + | Message::BoardRead(_) | Message::ScoreRecorded(_) | Message::BoardPage(_) => { if request != 0 { From 653fb294a81ffafaa5b56096b7b8a0ce39852940 Mon Sep 17 00:00:00 2001 From: AlxcBTW <187763517+AlxcBTW@users.noreply.github.com> Date: Tue, 27 Oct 2026 21:01:24 +0100 Subject: [PATCH 3/3] Merge main; leaderboards ADR is 0041 (0040 is taken by the echo cancellation ADR) --- CONTEXT.md | 2 +- crates/api/src/lib.rs | 2 +- docs/DESIGN.md | 2 +- ...leaderboards-are-boards-of-best-scores-named-by-the-game.md} | 0 spec/bridge-protocol.md | 2 +- spec/cloud-api.md | 2 +- spec/portal-api.md | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename docs/adr/{0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md => 0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md} (100%) diff --git a/CONTEXT.md b/CONTEXT.md index ba6bc31..b34d4c0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -210,7 +210,7 @@ The ranking of one title's scores for one thing a game ranks, named by the title _Avoid_: Leaderboard (alone, when one board is meant), table, chart **Score entry**: -One account's best score on one board and when it was reached. Only the best is kept; a better score replaces it. Seen by its owner, by friends unless the owner chose nobody, and by others only if the owner chose everyone ([ADR 0040](docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). +One account's best score on one board and when it was reached. Only the best is kept; a better score replaces it. Seen by its owner, by friends unless the owner chose nobody, and by others only if the owner chose everyone ([ADR 0041](docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). _Avoid_: Record (a trophy record is meant by that), result **Board rank**: diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index bf93261..a81db92 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -358,7 +358,7 @@ pub struct TitleBoards { } /// Who may see the account's scores on boards: the body of `PUT /v1/me/score-privacy` and its -/// answer. A part of the profile's privacy in kind, a call of its own in form (ADR 0040). +/// answer. A part of the profile's privacy in kind, a call of its own in form (ADR 0041). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ScorePrivacy { pub scores: Audience, diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 5d877c4..8caad69 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -95,7 +95,7 @@ A link such as `anyps5://v1/join/…` never starts a game by itself; it opens th ## Leaderboards -A game that ranks its players records a score for a board and reads a range of it. The network answers both for AnyPlay accounts, from the cloud, for every title the same way ([ADR 0040](adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). +A game that ranks its players records a score for a board and reads a range of it. The network answers both for AnyPlay accounts, from the cloud, for every title the same way ([ADR 0041](adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). **Domain.** diff --git a/docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md b/docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md similarity index 100% rename from docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md rename to docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md diff --git a/spec/bridge-protocol.md b/spec/bridge-protocol.md index 452bcd1..d696ac7 100644 --- a/spec/bridge-protocol.md +++ b/spec/bridge-protocol.md @@ -264,7 +264,7 @@ Version 0, implemented in `crates/bridge-protocol` (the net service's side) and ### Leaderboards (`0x06xx`) -Added in version 1 without changing it: four new kinds, so a net service that does not know them answers a request with **unsupported** and a bridge that does not know them never sends them. Decision: [ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md); the cloud side: [Leaderboards](cloud-api.md#leaderboards). +Added in version 1 without changing it: four new kinds, so a net service that does not know them answers a request with **unsupported** and a bridge that does not know them never sends them. Decision: [ADR 0041](../docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md); the cloud side: [Leaderboards](cloud-api.md#leaderboards). | Kind | Name | Direction | Payload | |---|---|---|---| diff --git a/spec/cloud-api.md b/spec/cloud-api.md index 8ffc54e..796df66 100644 --- a/spec/cloud-api.md +++ b/spec/cloud-api.md @@ -301,7 +301,7 @@ The cloud keeps a **trophy record** per trophy a player earned: the title, the t ## Leaderboards -The cloud keeps boards of best scores, one per title and board ID, for every game the same way ([ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). A game's own names and art for a board never reach the cloud. Off by default: with `leaderboards_enabled` unset every route below answers `forbidden` (a read on the portal answers `not_found`). +The cloud keeps boards of best scores, one per title and board ID, for every game the same way ([ADR 0041](../docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). A game's own names and art for a board never reach the cloud. Off by default: with `leaderboards_enabled` unset every route below answers `forbidden` (a read on the portal answers `not_found`). - A **board** is `title_id` plus a `board` ID (`is_board_id`: 1 to 64 characters from `A-Z a-z 0-9 _ -`). It is made by the first accepted score, with `order: "high"`, `status: "open"` and the bounds `0` to `9007199254740991` (`SCORE_MIN_DEFAULT`, `SCORE_MAX_DEFAULT`). A moderator sets the real order, the bounds and the status ([below](#boards-moderation)). A title has at most 64 boards, an account may make at most 16 and keeps a score on at most 1,000. - An **entry** is an account's best score on a board and the time it was reached (`achieved`). A score is an integer; there is no history. A better score replaces the entry by the board's order (higher for `high`, lower for `low`); an equal or worse score changes nothing. diff --git a/spec/portal-api.md b/spec/portal-api.md index c15740c..e4aec39 100644 --- a/spec/portal-api.md +++ b/spec/portal-api.md @@ -446,7 +446,7 @@ BoardPage { titleId, board, order, status, view, total, start, entries, me } me the viewer's own line {rank, handle, score, achieved}, absent without a session or an entry ``` -Entries name a player by handle only, as the public profile does; the page links the handle to `/u/{handle}`, which answers `not_found` for a player who has no public page. The portal shows a board as `Board {board}` and a score as a plain number: the cloud holds no names, units or art of a game ([ADR 0040](../docs/adr/0040-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). +Entries name a player by handle only, as the public profile does; the page links the handle to `/u/{handle}`, which answers `not_found` for a player who has no public page. The portal shows a board as `Board {board}` and a score as a plain number: the cloud holds no names, units or art of a game ([ADR 0041](../docs/adr/0041-leaderboards-are-boards-of-best-scores-named-by-the-game.md)). ## Serving the portal