From 20a947dcadd0f05be5c0ee8f14994060a468fcd9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 22 Oct 2026 14:35:21 -0400 Subject: [PATCH] [leaderboard] fix 2026 display at least --- apps/leaderboard/src/leaderboard.app.ts | 42 ++++++++----- apps/leaderboard/src/openapi.ts | 22 +++++-- .../src/test/integration/api.test.ts | 62 +++++++++++++++++++ 3 files changed, 108 insertions(+), 18 deletions(-) diff --git a/apps/leaderboard/src/leaderboard.app.ts b/apps/leaderboard/src/leaderboard.app.ts index 53c56ed..9f10ec0 100644 --- a/apps/leaderboard/src/leaderboard.app.ts +++ b/apps/leaderboard/src/leaderboard.app.ts @@ -14,7 +14,6 @@ import { NO_SCORE, UNRANKED, } from './leaderboard-db' - import { CheckAndSetStatBody, CheckAndSetStatResponse, @@ -37,11 +36,18 @@ 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. */ + * 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. */ interface BoardBody { PlayerId?: number RoomId?: number StatChannel?: number + PrimaryKey?: number + SecondaryKey?: number + LeaderboardType?: number FilterType?: number SortAscending?: boolean RankStart?: number @@ -63,17 +69,28 @@ 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 board a body names: `RoomId` + `StatChannel`, 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: `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. + * + * 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. */ function board(body: BoardBody): Board { const playerId = int(body.PlayerId, 0) + const type = int(body.LeaderboardType, ROOM_LEADERBOARD) return { - roomId: int(body.RoomId, 0), - statChannel: int(body.StatChannel, 0), - ...(int(body.FilterType, 0) === FilterType.Friends && playerId !== 0 && { friendsOf: playerId }), + roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0, + statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)), + ...(int(body.FilterType, 0) === FilterType.Friends && + playerId !== 0 && { friendsOf: playerId }), } } @@ -290,12 +307,9 @@ const app = new Hono() 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<{ - RoomId?: number - StatChannel?: number - StatValue?: number - CurrentStatValue?: number | null - }>(c) + const body = await readBody< + BoardBody & { StatValue?: number; CurrentStatValue?: number | null } + >(c) logger.info('CheckAndSetStat', { accountId, body }) const target = board(body) diff --git a/apps/leaderboard/src/openapi.ts b/apps/leaderboard/src/openapi.ts index 978e4b6..d5fbf19 100644 --- a/apps/leaderboard/src/openapi.ts +++ b/apps/leaderboard/src/openapi.ts @@ -139,12 +139,26 @@ export const CheckAndSetStatBody = z.object({ }) /** - * The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`. This - * is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its - * rank request with `WindowSize`); it has not yet been watched from a live client here, - * which is why the handler still logs it. + * 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. */ 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'), WindowSize: z .int() .describe('How many rows either side of the player to return; default and maximum 10'), diff --git a/apps/leaderboard/src/test/integration/api.test.ts b/apps/leaderboard/src/test/integration/api.test.ts index 39618d7..b40ac2c 100644 --- a/apps/leaderboard/src/test/integration/api.test.ts +++ b/apps/leaderboard/src/test/integration/api.test.ts @@ -440,6 +440,68 @@ 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. + const ROOM = 400 + beforeAll(async () => { + await setStat(51, ROOM, 7, null, 1) + await setStat(52, ROOM, 9, null, 1) + }) + + it('reads the board 2025’s RoomId + StatChannel name', async () => { + const legacy = await post('GetNearbyScores', { + WindowSize: 10, + PlayerId: 51, + StatChannel: 1, + RoomId: ROOM, + 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) + }) + + it('writes a stat through PrimaryKey + SecondaryKey', async () => { + const res = await post( + 'CheckAndSetStat', + { PrimaryKey: ROOM, SecondaryKey: 1, StatValue: 12, CurrentStatValue: 7, LeaderboardType: 0 }, + await bearer(51) + ) + 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('serves an openapi spec with no dangling refs', async () => { const res = await SELF.fetch('https://example.com/openapi.json') expect(res.status).toBe(200)