diff --git a/CLAUDE.md b/CLAUDE.md index 03ffff9..e4d8686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,12 @@ inconsistency here without checking the client first. - The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success. That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use; the two live side by side in the same worker and must not be unified. +- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is + 0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second + place and the top of a board must be 0. Its own slice says the same: it asks for the first + ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also + serves nine rows starting at the runner-up. The unranked sentinel stays a big number + (99999) precisely because 0 is now a real rank, first place. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/SERVICES.md b/SERVICES.md index 4741e0c..6b578f5 100644 --- a/SERVICES.md +++ b/SERVICES.md @@ -40,7 +40,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own | GameLogs | `gamelogs` | — | Not yet implemented | | Geo | `geo` | — | Not yet implemented | | Images | `img` | `img` | Image storage & signed delivery (R2) | -| Leaderboard | `leaderboard` | `leaderboard` | Stub — deploys and answers, no leaderboard endpoints yet | +| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) | | Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet | | Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) | | Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) | diff --git a/apps/leaderboard/src/leaderboard-db.ts b/apps/leaderboard/src/leaderboard-db.ts index 64937a8..34b0199 100644 --- a/apps/leaderboard/src/leaderboard-db.ts +++ b/apps/leaderboard/src/leaderboard-db.ts @@ -11,10 +11,14 @@ * the room's tracked stats this is, and `stat_value` is what it posts as `StatValue`. What a * channel counts (wins, laps, a time) is the room's business; the server only orders on it. * - * Ranks are 1-based and total: the board is ordered by `stat_value` (highest first unless + * Ranks are 0-BASED and total: the board is ordered by `stat_value` (highest first unless * the client asks for ascending) with ties broken on the lower `player_id`, so two players * with the same score never share a rank and a rank is stable between two reads. * + * Zero-based because the client adds one before it draws — it renders `Rank` 0 as "#1", so a + * `Rank` of 1 shows up in the game as second place. This is the same convention the client's + * own `GetRanks` body uses: it asks for the first ten rows as `RankStart` 0, `RankEnd` 9. + * * A board can be read through a FRIENDS filter (the client's `FilterType` 1): the same rows * restricted to the viewer and the people they are friends with, ranked among themselves — * so a player who is 40th globally can be 2nd among friends. Friendship is the `api` @@ -42,10 +46,10 @@ export const SCHEMA_DDL: string[] = [ ] /** - * The rank a player who isn't on the board gets. `Rank` is 1-based: a 0 would render as - * first place and a negative one may not render at all. A number far past the end of any - * real board reads as last, which is what an unscored player is, and is recognisable in a - * log or a screenshot as a sentinel rather than a real standing. + * The rank a player who isn't on the board gets. Ranks are 0-based, so 0 IS first place and + * a negative one may not render at all — the sentinel has to be a big number. One far past + * the end of any real board reads as last, which is what an unscored player is, and is + * recognisable in a log or a screenshot as a sentinel rather than a real standing. */ export const UNRANKED = 99999 @@ -111,8 +115,9 @@ function order(sortAscending: boolean): string { } /** - * A page of a board: ranks `rankStart`..`rankEnd`, both 1-based and inclusive. - * A `rankStart` below 1 is clamped to the top; an empty or inverted range is an empty page. + * A page of a board: ranks `rankStart`..`rankEnd`, both 0-based and inclusive — 0..9 is the + * first ten rows, the slice the client actually asks for. + * A `rankStart` below 0 is clamped to the top; an empty or inverted range is an empty page. */ export async function getRanks( db: D1Database, @@ -121,7 +126,7 @@ export async function getRanks( rankEnd: number, sortAscending: boolean ): Promise { - const start = Math.max(1, rankStart) + const start = Math.max(0, rankStart) const limit = rankEnd - start + 1 if (limit <= 0) return [] const s = scope(board) @@ -130,17 +135,17 @@ export async function getRanks( `SELECT player_id, stat_value FROM leaderboard WHERE ${s.where} ORDER BY ${order(sortAscending)} LIMIT ?${s.next} OFFSET ?${s.next + 1}` ) - .bind(...s.binds, limit, start - 1) + .bind(...s.binds, limit, start) .all() return results.map((r, i) => ({ PlayerId: r.player_id, Score: r.stat_value, Rank: start + i })) } /** - * One player's standing on a board: their score and 1-based rank, or + * One player's standing on a board: their score and 0-based rank, or * {@link UNRANKED} with {@link NO_SCORE} when they have no row there. * - * The rank is one more than the count of players placed ahead — a higher score, or the - * same score and a lower id — so it matches the position {@link getRanks} would give. + * The rank is the count of players placed ahead — a higher score, or the same score and a + * lower id — so it matches the position {@link getRanks} would give. */ export async function getPlayerRank( db: D1Database, @@ -162,7 +167,7 @@ export async function getPlayerRank( .prepare(`SELECT COUNT(*) AS n FROM leaderboard WHERE ${s.where} AND ${ahead}`) .bind(...s.binds, playerId, mine.stat_value) .first<{ n: number }>() - return { PlayerId: playerId, Score: mine.stat_value, Rank: (count?.n ?? 0) + 1 } + return { PlayerId: playerId, Score: mine.stat_value, Rank: count?.n ?? 0 } } /** The most rows either side of a player `GetNearbyScores` will serve, whatever it asks. */ @@ -182,7 +187,7 @@ export async function getNearbyScores( ): Promise { const window = Math.min(Math.max(windowSize, 1), MAX_WINDOW) const mine = await getPlayerRank(db, board, playerId, sortAscending) - if (mine.Rank === UNRANKED) return getRanks(db, board, 1, window * 2 + 1, sortAscending) + if (mine.Rank === UNRANKED) return getRanks(db, board, 0, window * 2, sortAscending) return getRanks(db, board, mine.Rank - window, mine.Rank + window, sortAscending) } diff --git a/apps/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 8ac2fdb..53c56ed 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -162,9 +162,10 @@ const app = new Hono() // // Same answer and same rules as GetNearbyScores: `{ Rows: [...] }`, where an EMPTY // `Rows` is a complete answer meaning "this leaderboard has no scores" and the key must - // be present. Ranks are 1-based; a `RankStart` of 0 is read as the top. `FilterType` 1 - // ranks the viewer and their friends among themselves. An unreadable body is answered - // with an empty board, never an error. + // be present. Ranks are 0-based, as the client's own slice is (it asks for the first ten + // as `RankStart` 0, `RankEnd` 9) and as it renders them (it draws `Rank` 0 as "#1"). + // `FilterType` 1 ranks the viewer and their friends among themselves. An unreadable body + // is answered with an empty board, never an error. .post( '/leaderboard/GetRanks', describeRoute({ @@ -177,8 +178,8 @@ const app = new Hono() '`SortAscending`).', '', 'Answers the rows ranked `RankStart`..`RankEnd` on the board `RoomId` + `StatChannel`', - 'names (1-based; 0 is read as the top), highest value first unless `SortAscending`. An', - 'empty `Rows` means "this leaderboard has no scores"; the key is always present.', + 'names (0-based, so 0..9 is the first ten), highest value first unless `SortAscending`.', + 'An empty `Rows` means "this leaderboard has no scores"; the key is always present.', '`FilterType` 1 (Friends) restricts the board to `PlayerId` and their friends, ranked', 'among themselves.', ].join(' '), @@ -192,8 +193,8 @@ const app = new Hono() const rows = await getRanks( c.env.DB, board(body), - int(body.RankStart, 1), - int(body.RankEnd, 10), + int(body.RankStart, 0), + int(body.RankEnd, 9), body.SortAscending === true ) return c.json({ Rows: rows }) @@ -223,8 +224,10 @@ const app = new Hono() '`FilterType`: Global 0, Friends 1).', '', '`Score` is the player’s value on the board `RoomId` + `StatChannel` names and `Rank`', - `their 1-based position on it. A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`, - 'unranked (ranks are 1-based, so a 0 would render as first place), and `Score` 0.', + `their 0-based position on it — the client adds one before it draws, so \`Rank\` 0 is`, + `shown as "#1". A player with no row there answers \`Rank\` ${UNRANKED}, a sentinel meaning`, + 'unranked (0 being a real rank, first place, the sentinel has to be a big number), and', + '`Score` 0.', '', '`FilterType` 1 (Friends) ranks the player among their friends only.', '', @@ -329,7 +332,8 @@ app.get( '', 'One board per (room, stat channel): `CheckAndSetStat` stores the caller’s value on', 'one, and the reads rank them — highest first unless `SortAscending`, ties broken on', - 'the lower player id, ranks 1-based. `FilterType` 1 reads a board as the viewer and', + 'the lower player id, ranks 0-based (the client adds one before it draws, so `Rank` 0', + 'is shown as "#1"). `FilterType` 1 reads a board as the viewer and', 'their friends only (the `api` worker’s `relationship` table), ranked among', 'themselves.', '', diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts index b1dc46b..978e4b6 100644 --- a/apps/leaderboard/src/openapi.ts +++ b/apps/leaderboard/src/openapi.ts @@ -58,17 +58,21 @@ export const LeaderboardRows = z.object({ /** * `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g. - * `{"PlayerId":205,"Score":4200,"Rank":17}`. + * `{"PlayerId":205,"Score":4200,"Rank":16}` for the 17th place the client draws. * * Three fields only: none of the board selectors the request names are echoed back, so the * client matches the answer to the question by having asked it. A player with no row gets * the 99999 sentinel with a zero score — see the route for why that rather than a rank of - * 0, which would read as "first place". + * 0, which IS first place: the client adds one before it draws. */ export const PlayerRank = z.object({ PlayerId: z.int().describe('Echoed from the request — whose rank this is'), Score: z.int().describe('The player’s value on the board; 0 when they have no row there'), - Rank: z.int().describe('1-based position on the board; 99999 when the player isn’t on it'), + Rank: z + .int() + .describe( + '0-based position on the board — the client adds one to draw it, so 0 is “#1”; 99999 when the player isn’t on it' + ), }) /** diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index dc6eb81..39618d7 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -115,8 +115,8 @@ describe('an empty board', () => { }) expect(res.status).toBe(200) // Three fields, no board selectors: the client pairs the answer with its own question. - // Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would - // render the unranked caller as first place. + // Ranks are 0-based — the client adds one to draw them — so 0 IS first place and the + // sentinel has to be a big number rather than 0. expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 }) }) @@ -205,7 +205,7 @@ describe('a scored board', () => { expect(await res.json()).toEqual({ Rows: [] }) }) - it('ranks highest value first with 1-based ranks', async () => { + it('ranks highest value first with 0-based ranks', async () => { const res = await post('GetRanks', { RankStart: 0, RankEnd: 9, @@ -217,10 +217,10 @@ describe('a scored board', () => { }) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 2, Score: 30, Rank: 1 }, - { PlayerId: 3, Score: 20, Rank: 2 }, - { PlayerId: 4, Score: 20, Rank: 3 }, - { PlayerId: 1, Score: 10, Rank: 4 }, + { PlayerId: 2, Score: 30, Rank: 0 }, + { PlayerId: 3, Score: 20, Rank: 1 }, + { PlayerId: 4, Score: 20, Rank: 2 }, + { PlayerId: 1, Score: 10, Rank: 3 }, ], }) }) @@ -237,8 +237,8 @@ describe('a scored board', () => { }) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 3, Score: 20, Rank: 2 }, - { PlayerId: 4, Score: 20, Rank: 3 }, + { PlayerId: 4, Score: 20, Rank: 2 }, + { PlayerId: 1, Score: 10, Rank: 3 }, ], }) }) @@ -255,8 +255,8 @@ describe('a scored board', () => { }) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 1, Score: 10, Rank: 1 }, - { PlayerId: 3, Score: 20, Rank: 2 }, + { PlayerId: 3, Score: 20, Rank: 1 }, + { PlayerId: 4, Score: 20, Rank: 2 }, ], }) }) @@ -269,7 +269,7 @@ describe('a scored board', () => { FilterType: 0, SortAscending: false, }) - expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 3 }) + expect(await res.json()).toEqual({ PlayerId: 4, Score: 20, Rank: 2 }) }) it('answers a player with no row in the room as unranked', async () => { @@ -294,9 +294,9 @@ describe('a scored board', () => { }) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 3, Score: 20, Rank: 2 }, - { PlayerId: 4, Score: 20, Rank: 3 }, - { PlayerId: 1, Score: 10, Rank: 4 }, + { PlayerId: 3, Score: 20, Rank: 1 }, + { PlayerId: 4, Score: 20, Rank: 2 }, + { PlayerId: 1, Score: 10, Rank: 3 }, ], }) }) @@ -312,9 +312,9 @@ describe('a scored board', () => { }) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 2, Score: 30, Rank: 1 }, - { PlayerId: 3, Score: 20, Rank: 2 }, - { PlayerId: 4, Score: 20, Rank: 3 }, + { PlayerId: 2, Score: 30, Rank: 0 }, + { PlayerId: 3, Score: 20, Rank: 1 }, + { PlayerId: 4, Score: 20, Rank: 2 }, ], }) }) @@ -323,7 +323,7 @@ describe('a scored board', () => { describe('the friends filter', () => { // Room 200, channel 2: players 11..15 score 50, 40, 30, 20, 10. Player 14 is friends // with 11 (14 requested) and 15 (15 requested); 12 and 13 are strangers, and 16 is a - // friend with no score. Globally 14 is 4th; among friends 2nd. + // friend with no score. Globally 14 is 4th (`Rank` 3); among friends 2nd (`Rank` 1). const ROOM = 200 const board = (extra: object) => ({ StatChannel: 2, @@ -350,42 +350,43 @@ describe('the friends filter', () => { }) it('ranks the viewer among their friends on GetRanks', async () => { - const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 1, RankEnd: 10 })) + const res = await post('GetRanks', board({ PlayerId: 14, RankStart: 0, RankEnd: 9 })) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 11, Score: 50, Rank: 1 }, - { PlayerId: 14, Score: 20, Rank: 2 }, - { PlayerId: 15, Score: 10, Rank: 3 }, + { PlayerId: 11, Score: 50, Rank: 0 }, + { PlayerId: 14, Score: 20, Rank: 1 }, + { PlayerId: 15, Score: 10, Rank: 2 }, ], }) }) it('gives the friends rank on GetPlayerRank', async () => { const res = await post('GetPlayerRank', board({ PlayerId: 14 })) - expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 2 }) + expect(await res.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 1 }) const global = await post('GetPlayerRank', board({ PlayerId: 14, FilterType: 0 })) - expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 4 }) + expect(await global.json()).toEqual({ PlayerId: 14, Score: 20, Rank: 3 }) }) it('centres GetNearbyScores on the viewer within their friends', async () => { const res = await post('GetNearbyScores', board({ PlayerId: 14, WindowSize: 10 })) expect(await res.json()).toEqual({ Rows: [ - { PlayerId: 11, Score: 50, Rank: 1 }, - { PlayerId: 14, Score: 20, Rank: 2 }, - { PlayerId: 15, Score: 10, Rank: 3 }, + { PlayerId: 11, Score: 50, Rank: 0 }, + { PlayerId: 14, Score: 20, Rank: 1 }, + { PlayerId: 15, Score: 10, Rank: 2 }, ], }) }) it('shows a player with no friends only themself', async () => { - const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 1, RankEnd: 10 })) - expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 1 }] }) + const res = await post('GetRanks', board({ PlayerId: 13, RankStart: 0, RankEnd: 9 })) + expect(await res.json()).toEqual({ Rows: [{ PlayerId: 13, Score: 30, Rank: 0 }] }) }) }) describe('the nearby window', () => { - // Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 13). + // Room 300: players 21..45 score 25..1, so 25 rows with player 33 in the middle (rank 12, + // the 13th row — ranks are 0-based). const ROOM = 300 beforeAll(async () => { for (let p = 21; p <= 45; p++) await setStat(p, ROOM, 46 - p) @@ -402,8 +403,28 @@ describe('the nearby window', () => { }) const { Rows } = (await res.json()) as { Rows: { Rank: number }[] } expect(Rows).toHaveLength(21) - expect(Rows[0]?.Rank).toBe(3) - expect(Rows[20]?.Rank).toBe(23) + expect(Rows[0]?.Rank).toBe(2) + expect(Rows[20]?.Rank).toBe(22) + }) + + // The slice the client actually asks for: 0..9 is TEN rows, starting at the top. Read as + // 1-based this served nine rows starting at the second one. + it('serves ten rows for the client’s RankStart 0, RankEnd 9', async () => { + const res = await post('GetRanks', { + RankStart: 0, + RankEnd: 9, + PlayerId: 33, + StatChannel: 2, + RoomId: ROOM, + FilterType: 0, + SortAscending: false, + }) + const { Rows } = (await res.json()) as { + Rows: Array<{ PlayerId: number; Score: number; Rank: number }> + } + expect(Rows).toHaveLength(10) + expect(Rows[0]).toEqual({ PlayerId: 21, Score: 25, Rank: 0 }) + expect(Rows[9]?.Rank).toBe(9) }) it('defaults WindowSize to 10 when absent', async () => {