[leaderboard] fix ranks

This commit is contained in:
Devin Zuczek
2026-08-26 12:12:44 -04:00
parent 35350ffa19
commit e2ced3e797
6 changed files with 101 additions and 61 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -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) |
+19 -14
View File
@@ -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<LeaderboardEntry[]> {
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<LeaderboardRow>()
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<LeaderboardEntry[]> {
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)
}
+14 -10
View File
@@ -162,9 +162,10 @@ const app = new Hono<App>()
//
// 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<App>()
'`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<App>()
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<App>()
'`FilterType`: Global 0, Friends 1).',
'',
'`Score` is the players 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 callers 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` workers `relationship` table), ranked among',
'themselves.',
'',
+7 -3
View File
@@ -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 players 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 isnt 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 isnt on it'
),
})
/**
@@ -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 clients 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 () => {