[leaderboard] update leaderboard for 2026 changes

This commit is contained in:
Devin Zuczek
2026-10-22 14:58:29 -04:00
parent 20a947dcad
commit 16e5f61160
5 changed files with 269 additions and 107 deletions
+67 -29
View File
@@ -38,15 +38,16 @@ import type { Board } from './leaderboard-db'
/** A board selector as the client posts it. Every field is optional on the wire — a body
* that names nothing still gets an answer, it's just an empty one.
*
* Two shapes name the same board. Up to 2025 the client sends `RoomId` + `StatChannel`; the
* 2026 client sends `PrimaryKey` (the room) + `SecondaryKey` (the channel) and adds
* `LeaderboardType`, which is 0 for a room board. Same room, same board, either way. */
* Two shapes name a board, told apart by which keys are PRESENT (see {@link board}). Up to
* 2025 the client sends `RoomId` + `StatChannel`. 20260323 renamed them `PrimaryKey` +
* `SecondaryKey`, made both nullable, and added `LeaderboardType` (0 Room, 1 Event), which
* says what `PrimaryKey` is the id of. */
interface BoardBody {
PlayerId?: number
RoomId?: number
StatChannel?: number
PrimaryKey?: number
SecondaryKey?: number
PrimaryKey?: number | null
SecondaryKey?: number | null
LeaderboardType?: number
FilterType?: number
SortAscending?: boolean
@@ -69,26 +70,46 @@ const enum FilterType {
Friends = 1,
}
/** The 2026 client's `LeaderboardType`: what kind of thing `PrimaryKey` names. Only the room
* board (0) has been observed. */
const ROOM_LEADERBOARD = 0
/** The 2026 client's `LeaderboardType`: what `PrimaryKey` is the id of. */
const enum LeaderboardType {
Room = 0,
Event = 1,
}
/** Whether a body is the 2026 shape. Decided by key PRESENCE, not value, because the 2026
* keys can be null and a 2025 body never carries any of them. */
const is2026Body = (body: BoardBody) =>
'PrimaryKey' in body || 'SecondaryKey' in body || 'LeaderboardType' in body
/**
* The board a body names: `RoomId` + `StatChannel` (2026: `PrimaryKey` + `SecondaryKey`),
* each 0 when absent, seen through `PlayerId`'s friends when `FilterType` is Friends. A
* friends board with no `PlayerId` has nobody to be friends of, so it falls back to the
* global one rather than to nothing.
* The board a body names, or null when it names none, which every read answers as an empty
* board (or an unranked player). Seen through `PlayerId`'s friends when `FilterType` is
* Friends. A friends board with no `PlayerId` has nobody to be friends of, so it falls back
* to the global one rather than to nothing.
*
* A `LeaderboardType` other than a room board is one this server has never seen, and its
* `PrimaryKey` may not be a room id at all, so it reads as room 0: an empty board, and a
* write `CheckAndSetStat` skips. Better than filing it on some room's board.
* 2025 body: `RoomId` + `StatChannel`, each 0 when absent, exactly as before.
*
* 2026 body: `PrimaryKey` + `SecondaryKey`. A null key is UNSCOPED and names no board. It is
* not room 0 or channel 0. Only a Room board (`LeaderboardType` 0) reads rows: an Event
* board's `PrimaryKey` is an event id, and nothing stores event stats (`CheckAndSetStat`
* takes only a room), so it reads empty instead of landing on whichever room shares the id.
*/
function board(body: BoardBody): Board {
function board(body: BoardBody): Board | null {
let roomId: number
let statChannel: number
if (is2026Body(body)) {
if (int(body.LeaderboardType, LeaderboardType.Room) !== LeaderboardType.Room) return null
if (!Number.isInteger(body.PrimaryKey) || !Number.isInteger(body.SecondaryKey)) return null
roomId = body.PrimaryKey as number
statChannel = body.SecondaryKey as number
} else {
roomId = int(body.RoomId, 0)
statChannel = int(body.StatChannel, 0)
}
const playerId = int(body.PlayerId, 0)
const type = int(body.LeaderboardType, ROOM_LEADERBOARD)
return {
roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0,
statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)),
roomId,
statChannel,
...(int(body.FilterType, 0) === FilterType.Friends &&
playerId !== 0 && { friendsOf: playerId }),
}
@@ -161,9 +182,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
logger.info('GetNearbyScores', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getNearbyScores(
c.env.DB,
board(body),
target,
int(body.PlayerId, 0),
int(body.WindowSize, MAX_WINDOW),
body.SortAscending === true
@@ -207,9 +230,11 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
logger.info('GetRanks', { body })
const target = board(body)
if (target === null) return c.json({ Rows: [] })
const rows = await getRanks(
c.env.DB,
board(body),
target,
int(body.RankStart, 0),
int(body.RankEnd, 9),
body.SortAscending === true
@@ -259,11 +284,13 @@ const app = new Hono<App>()
const body = await readBody<BoardBody>(c)
logger.info('GetPlayerRank', { body })
// Every path answers the full three-key object. 20260323 throws "Response was
// empty" on a null or empty body, so there is no 204 here and no null.
const playerId = int(body.PlayerId, 0)
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED })
return c.json(
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true)
)
const target = board(body)
if (playerId === 0 || target === null)
return c.json({ PlayerId: playerId, Score: NO_SCORE, Rank: UNRANKED })
return c.json(await getPlayerRank(c.env.DB, target, playerId, body.SortAscending === true))
}
)
@@ -307,12 +334,18 @@ const app = new Hono<App>()
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const body = await readBody<
BoardBody & { StatValue?: number; CurrentStatValue?: number | null }
>(c)
// Deliberately its own body, NOT a BoardBody: 20260323 still posts `RoomId` +
// `StatChannel` here (byte-identical to 2025) even though the reads moved to
// `PrimaryKey`/`SecondaryKey`. Keep the two request models apart.
const body = await readBody<{
RoomId?: number
StatChannel?: number
StatValue?: number
CurrentStatValue?: number | null
}>(c)
logger.info('CheckAndSetStat', { accountId, body })
const target = board(body)
const target: Board = { roomId: int(body.RoomId, 0), statChannel: int(body.StatChannel, 0) }
if (target.roomId !== 0 && typeof body.StatValue === 'number') {
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
const written = await checkAndSetStat(
@@ -357,6 +390,11 @@ app.get(
'answers a player with no row a rank of 99999, the sentinel for unranked, with a',
'score of 0; and `CheckAndSetStat` answers the bare number `0`.',
'',
'A board is named two ways: up to 2025 by `RoomId` + `StatChannel`; from 20260323',
'by `PrimaryKey` + `SecondaryKey` + `LeaderboardType` (0 Room, 1 Event), told apart',
'by which keys are present. A null 2026 key names no board and reads empty.',
'`CheckAndSetStat` takes `RoomId` + `StatChannel` from every build.',
'',
'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.',
'Unreadable bodies are answered (empty board / unranked), never rejected.',
].join('\n'),
+40 -40
View File
@@ -89,37 +89,50 @@ export const CheckAndSetStatResponse = z
// ---- Request schemas -------------------------------------------------------
/**
* The body the client posts to `GetRanks`, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
* The board selectors every read carries, in either client shape. A body is the 2026 shape
* when ANY of `PrimaryKey`/`SecondaryKey`/`LeaderboardType` is present, and its `RoomId` and
* `StatChannel` are then ignored. Otherwise it is the 2025 shape, read exactly as before.
*
* Recovered from a live client, not from a spec. `FilterType` 1 restricts the board to
* `PlayerId` and their friends.
* - 2025: `{"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`
* - 2026: `{"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`
*
* Same room, same board. In 2026 a null key names no board (an empty answer, not room 0),
* and an Event board (`LeaderboardType` 1) is always empty because nothing stores event stats.
*/
export const GetRanksBody = z.object({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
PlayerId: z.int().describe('The player reading the board'),
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
const BoardSelectors = z.object({
PlayerId: z.int().describe('The player reading the board, or whose rank is asked for'),
StatChannel: z.int().optional().describe('≤2025: which of the room’s tracked stats to rank on'),
RoomId: z.int().optional().describe('≤2025: the room whose board is being read'),
PrimaryKey: z
.int()
.nullable()
.optional()
.describe('2026: the room id (`LeaderboardType` 0) or event id (1); null names no board'),
SecondaryKey: z
.int()
.nullable()
.optional()
.describe('2026: which of the room’s tracked stats to rank on; null names no board'),
LeaderboardType: z
.int()
.optional()
.describe('2026: 0 Room, 1 Event (always empty: no event stats are stored)'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
})
/**
* The body the client posts to `GetPlayerRank`, e.g.
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
*
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
* one player rather than a page.
* The body the client posts to `GetRanks`: {@link BoardSelectors} plus the slice, e.g.
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*/
export const GetPlayerRankBody = z.object({
PlayerId: z.int().describe('The player whose rank is being asked for'),
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
export const GetRanksBody = BoardSelectors.extend({
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
})
/** The body the client posts to `GetPlayerRank`: just {@link BoardSelectors}. */
export const GetPlayerRankBody = BoardSelectors
/**
* The body the client posts to `CheckAndSetStat`, e.g.
* `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`.
@@ -127,6 +140,9 @@ export const GetPlayerRankBody = z.object({
* A compare-and-set: `CurrentStatValue` is what the client believes is already stored (null
* when it believes nothing is), and `StatValue` is what it wants stored. There is no
* `PlayerId` — the stat belongs to whoever is calling.
*
* 20260323 still posts `RoomId` + `StatChannel` here, NOT the reads' `PrimaryKey` /
* `SecondaryKey`. Keep this model separate from {@link BoardSelectors}.
*/
export const CheckAndSetStatBody = z.object({
StatChannel: z.int().describe('Which of the room’s tracked stats is being written'),
@@ -139,26 +155,10 @@ export const CheckAndSetStatBody = z.object({
})
/**
* The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`, e.g.
* (2025) `{"WindowSize":10,"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
*
* The 2026 client names the same board differently:
* `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`
* — `PrimaryKey` is the room, `SecondaryKey` the channel. Every endpoint accepts either.
* The body posted to `GetNearbyScores`: {@link BoardSelectors} plus `WindowSize`, e.g.
* `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`.
*/
export const GetNearbyScoresBody = GetPlayerRankBody.extend({
PrimaryKey: z
.int()
.optional()
.describe('2026 client: the room, in place of `RoomId` (when `LeaderboardType` is 0)'),
SecondaryKey: z
.int()
.optional()
.describe('2026 client: the stat channel, in place of `StatChannel`'),
LeaderboardType: z
.int()
.optional()
.describe('2026 client: 0 is a room board, the only kind observed; others read as empty'),
export const GetNearbyScoresBody = BoardSelectors.extend({
WindowSize: z
.int()
.describe('How many rows either side of the player to return; default and maximum 10'),
@@ -441,9 +441,25 @@ describe('the nearby window', () => {
})
describe('the 2026 body shape', () => {
// 2026 renamed the selectors: `PrimaryKey` is the room, `SecondaryKey` the channel, and
// `LeaderboardType` 0 says it's a room board. Same room, same board as the 2025 body.
// 20260323 renamed the read selectors: `PrimaryKey` is the room, `SecondaryKey` the
// channel, and `LeaderboardType` 0 says it's a room board. Same room, same board as the
// 2025 body. `CheckAndSetStat` did NOT change.
const ROOM = 400
const body2026 = (extra: object) => ({
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 0,
...extra,
})
const ROWS = {
Rows: [
{ PlayerId: 52, Score: 9, Rank: 0 },
{ PlayerId: 51, Score: 7, Rank: 1 },
],
}
beforeAll(async () => {
await setStat(51, ROOM, 7, null, 1)
await setStat(52, ROOM, 9, null, 1)
@@ -458,47 +474,71 @@ describe('the 2026 body shape', () => {
FilterType: 0,
SortAscending: false,
})
const modern = await post('GetNearbyScores', {
WindowSize: 10,
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 0,
})
const rows = {
Rows: [
{ PlayerId: 52, Score: 9, Rank: 0 },
{ PlayerId: 51, Score: 7, Rank: 1 },
],
}
expect(await legacy.json()).toEqual(rows)
expect(await modern.json()).toEqual(rows)
expect(await legacy.json()).toEqual(ROWS)
const modern = await post('GetNearbyScores', body2026({ WindowSize: 10 }))
expect(await modern.json()).toEqual(ROWS)
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9 }))
expect(await ranks.json()).toEqual(ROWS)
const rank = await post('GetPlayerRank', body2026({}))
expect(await rank.json()).toEqual({ PlayerId: 51, Score: 7, Rank: 1 })
})
it('writes a stat through PrimaryKey + SecondaryKey', async () => {
it('ignores RoomId/StatChannel once a 2026 key is present', async () => {
// The 2026 keys name no board here, so the stale 2025 ones must not be used instead.
const res = await post(
'GetRanks',
body2026({ PrimaryKey: null, RoomId: ROOM, StatChannel: 1, RankStart: 0, RankEnd: 9 })
)
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers a null-keyed Event board on GetPlayerRank with the full object', async () => {
const res = await post(
'GetPlayerRank',
body2026({ PrimaryKey: null, SecondaryKey: null, LeaderboardType: 1 })
)
// 20260323 throws "Response was empty" on null/empty/204: always the three keys.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 51, Score: 0, Rank: 99999 })
})
it('answers an unranked player with the full object', async () => {
const res = await post('GetPlayerRank', body2026({ PlayerId: 53 }))
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 53, Score: 0, Rank: 99999 })
})
it('answers null keys and Event boards with an empty Rows, never a bare array', async () => {
for (const extra of [{ PrimaryKey: null }, { SecondaryKey: null }, { LeaderboardType: 1 }]) {
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9, ...extra }))
expect(ranks.status).toBe(200)
expect(await ranks.text()).toBe('{"Rows":[]}')
const nearby = await post('GetNearbyScores', body2026({ WindowSize: 10, ...extra }))
expect(nearby.status).toBe(200)
expect(await nearby.text()).toBe('{"Rows":[]}')
}
})
it('still writes CheckAndSetStat by RoomId + StatChannel, answering a bare 0', async () => {
const res = await post(
'CheckAndSetStat',
{ PrimaryKey: ROOM, SecondaryKey: 1, StatValue: 12, CurrentStatValue: 7, LeaderboardType: 0 },
{ StatChannel: 1, RoomId: ROOM, StatValue: 12, CurrentStatValue: 7 },
await bearer(51)
)
// The whole body is the number: not `{"result":0}`, not an envelope.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0')
expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
})
it('serves an unknown LeaderboardType an empty board', async () => {
const res = await post('GetRanks', {
RankStart: 0,
RankEnd: 9,
PlayerId: 51,
PrimaryKey: ROOM,
SecondaryKey: 1,
FilterType: 0,
SortAscending: false,
LeaderboardType: 1,
})
expect(await res.json()).toEqual({ Rows: [] })
it('does not write CheckAndSetStat by the read-side PrimaryKey/SecondaryKey', async () => {
const res = await post(
'CheckAndSetStat',
{ PrimaryKey: ROOM + 1, SecondaryKey: 1, StatValue: 5, CurrentStatValue: null },
await bearer(51)
)
expect(await res.text()).toBe('0')
expect(await stored(51, ROOM + 1, 1)).toBeNull()
})
})