[leaderboard] fix 2026 display at least

This commit is contained in:
Devin Zuczek
2026-10-22 14:35:21 -04:00
parent fbf7c923a6
commit 20a947dcad
3 changed files with 108 additions and 18 deletions
+28 -14
View File
@@ -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<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<{
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)
+18 -4
View File
@@ -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'),
@@ -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)