mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[leaderboard] update leaderboard for 2026 changes
This commit is contained in:
@@ -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
|
/** 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
|
* Two shapes name a board, told apart by which keys are PRESENT (see {@link board}). Up to
|
||||||
* 2026 client sends `PrimaryKey` (the room) + `SecondaryKey` (the channel) and adds
|
* 2025 the client sends `RoomId` + `StatChannel`. 20260323 renamed them `PrimaryKey` +
|
||||||
* `LeaderboardType`, which is 0 for a room board. Same room, same board, either way. */
|
* `SecondaryKey`, made both nullable, and added `LeaderboardType` (0 Room, 1 Event), which
|
||||||
|
* says what `PrimaryKey` is the id of. */
|
||||||
interface BoardBody {
|
interface BoardBody {
|
||||||
PlayerId?: number
|
PlayerId?: number
|
||||||
RoomId?: number
|
RoomId?: number
|
||||||
StatChannel?: number
|
StatChannel?: number
|
||||||
PrimaryKey?: number
|
PrimaryKey?: number | null
|
||||||
SecondaryKey?: number
|
SecondaryKey?: number | null
|
||||||
LeaderboardType?: number
|
LeaderboardType?: number
|
||||||
FilterType?: number
|
FilterType?: number
|
||||||
SortAscending?: boolean
|
SortAscending?: boolean
|
||||||
@@ -69,26 +70,46 @@ const enum FilterType {
|
|||||||
Friends = 1,
|
Friends = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The 2026 client's `LeaderboardType`: what kind of thing `PrimaryKey` names. Only the room
|
/** The 2026 client's `LeaderboardType`: what `PrimaryKey` is the id of. */
|
||||||
* board (0) has been observed. */
|
const enum LeaderboardType {
|
||||||
const ROOM_LEADERBOARD = 0
|
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`),
|
* The board a body names, or null when it names none, which every read answers as an empty
|
||||||
* each 0 when absent, seen through `PlayerId`'s friends when `FilterType` is Friends. A
|
* board (or an unranked player). Seen through `PlayerId`'s friends when `FilterType` is
|
||||||
* friends board with no `PlayerId` has nobody to be friends of, so it falls back to the
|
* Friends. A friends board with no `PlayerId` has nobody to be friends of, so it falls back
|
||||||
* global one rather than to nothing.
|
* to the global one rather than to nothing.
|
||||||
*
|
*
|
||||||
* A `LeaderboardType` other than a room board is one this server has never seen, and its
|
* 2025 body: `RoomId` + `StatChannel`, each 0 when absent, exactly as before.
|
||||||
* `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.
|
* 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 playerId = int(body.PlayerId, 0)
|
||||||
const type = int(body.LeaderboardType, ROOM_LEADERBOARD)
|
|
||||||
return {
|
return {
|
||||||
roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0,
|
roomId,
|
||||||
statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)),
|
statChannel,
|
||||||
...(int(body.FilterType, 0) === FilterType.Friends &&
|
...(int(body.FilterType, 0) === FilterType.Friends &&
|
||||||
playerId !== 0 && { friendsOf: playerId }),
|
playerId !== 0 && { friendsOf: playerId }),
|
||||||
}
|
}
|
||||||
@@ -161,9 +182,11 @@ const app = new Hono<App>()
|
|||||||
const body = await readBody<BoardBody>(c)
|
const body = await readBody<BoardBody>(c)
|
||||||
logger.info('GetNearbyScores', { body })
|
logger.info('GetNearbyScores', { body })
|
||||||
|
|
||||||
|
const target = board(body)
|
||||||
|
if (target === null) return c.json({ Rows: [] })
|
||||||
const rows = await getNearbyScores(
|
const rows = await getNearbyScores(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
board(body),
|
target,
|
||||||
int(body.PlayerId, 0),
|
int(body.PlayerId, 0),
|
||||||
int(body.WindowSize, MAX_WINDOW),
|
int(body.WindowSize, MAX_WINDOW),
|
||||||
body.SortAscending === true
|
body.SortAscending === true
|
||||||
@@ -207,9 +230,11 @@ const app = new Hono<App>()
|
|||||||
const body = await readBody<BoardBody>(c)
|
const body = await readBody<BoardBody>(c)
|
||||||
logger.info('GetRanks', { body })
|
logger.info('GetRanks', { body })
|
||||||
|
|
||||||
|
const target = board(body)
|
||||||
|
if (target === null) return c.json({ Rows: [] })
|
||||||
const rows = await getRanks(
|
const rows = await getRanks(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
board(body),
|
target,
|
||||||
int(body.RankStart, 0),
|
int(body.RankStart, 0),
|
||||||
int(body.RankEnd, 9),
|
int(body.RankEnd, 9),
|
||||||
body.SortAscending === true
|
body.SortAscending === true
|
||||||
@@ -259,11 +284,13 @@ const app = new Hono<App>()
|
|||||||
const body = await readBody<BoardBody>(c)
|
const body = await readBody<BoardBody>(c)
|
||||||
logger.info('GetPlayerRank', { body })
|
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)
|
const playerId = int(body.PlayerId, 0)
|
||||||
if (playerId === 0) return c.json({ PlayerId: 0, Score: NO_SCORE, Rank: UNRANKED })
|
const target = board(body)
|
||||||
return c.json(
|
if (playerId === 0 || target === null)
|
||||||
await getPlayerRank(c.env.DB, board(body), playerId, body.SortAscending === true)
|
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())
|
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
if (accountId === null) return c.body(null, 401)
|
if (accountId === null) return c.body(null, 401)
|
||||||
|
|
||||||
const body = await readBody<
|
// Deliberately its own body, NOT a BoardBody: 20260323 still posts `RoomId` +
|
||||||
BoardBody & { StatValue?: number; CurrentStatValue?: number | null }
|
// `StatChannel` here (byte-identical to 2025) even though the reads moved to
|
||||||
>(c)
|
// `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 })
|
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') {
|
if (target.roomId !== 0 && typeof body.StatValue === 'number') {
|
||||||
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
|
const expected = typeof body.CurrentStatValue === 'number' ? body.CurrentStatValue : null
|
||||||
const written = await checkAndSetStat(
|
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',
|
'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`.',
|
'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.',
|
'Only `CheckAndSetStat` needs a token — the stat belongs to whoever is calling.',
|
||||||
'Unreadable bodies are answered (empty board / unranked), never rejected.',
|
'Unreadable bodies are answered (empty board / unranked), never rejected.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
|||||||
@@ -89,37 +89,50 @@ export const CheckAndSetStatResponse = z
|
|||||||
// ---- Request schemas -------------------------------------------------------
|
// ---- Request schemas -------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body the client posts to `GetRanks`, e.g.
|
* The board selectors every read carries, in either client shape. A body is the 2026 shape
|
||||||
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
* 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
|
* - 2025: `{"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`
|
||||||
* `PlayerId` and their friends.
|
* - 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({
|
const BoardSelectors = z.object({
|
||||||
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
|
PlayerId: z.int().describe('The player reading the board, or whose rank is asked for'),
|
||||||
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
|
StatChannel: z.int().optional().describe('≤2025: which of the room’s tracked stats to rank on'),
|
||||||
PlayerId: z.int().describe('The player reading the board'),
|
RoomId: z.int().optional().describe('≤2025: the room whose board is being read'),
|
||||||
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'),
|
PrimaryKey: z
|
||||||
RoomId: z.int().describe('The room whose board is being read'),
|
.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'),
|
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
|
||||||
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
|
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body the client posts to `GetPlayerRank`, e.g.
|
* The body the client posts to `GetRanks`: {@link BoardSelectors} plus the slice, e.g.
|
||||||
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
|
* `{"RankStart":0,"RankEnd":9,"PlayerId":2,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
||||||
*
|
|
||||||
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
|
|
||||||
* one player rather than a page.
|
|
||||||
*/
|
*/
|
||||||
export const GetPlayerRankBody = z.object({
|
export const GetRanksBody = BoardSelectors.extend({
|
||||||
PlayerId: z.int().describe('The player whose rank is being asked for'),
|
RankStart: z.int().describe('First rank of the slice, 0-based and inclusive'),
|
||||||
StatChannel: z.int().describe('Which of the room’s tracked stats to rank on'),
|
RankEnd: z.int().describe('Last rank of the slice, inclusive — 0–9 is the first ten'),
|
||||||
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'),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** The body the client posts to `GetPlayerRank`: just {@link BoardSelectors}. */
|
||||||
|
export const GetPlayerRankBody = BoardSelectors
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body the client posts to `CheckAndSetStat`, e.g.
|
* The body the client posts to `CheckAndSetStat`, e.g.
|
||||||
* `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`.
|
* `{"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
|
* 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
|
* when it believes nothing is), and `StatValue` is what it wants stored. There is no
|
||||||
* `PlayerId` — the stat belongs to whoever is calling.
|
* `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({
|
export const CheckAndSetStatBody = z.object({
|
||||||
StatChannel: z.int().describe('Which of the room’s tracked stats is being written'),
|
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.
|
* The body posted to `GetNearbyScores`: {@link BoardSelectors} plus `WindowSize`, e.g.
|
||||||
* (2025) `{"WindowSize":10,"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
* `{"WindowSize":10,"PlayerId":205,"PrimaryKey":6,"SecondaryKey":1,"FilterType":0,"SortAscending":false,"LeaderboardType":0}`.
|
||||||
*
|
|
||||||
* 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({
|
export const GetNearbyScoresBody = BoardSelectors.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
|
WindowSize: z
|
||||||
.int()
|
.int()
|
||||||
.describe('How many rows either side of the player to return; default and maximum 10'),
|
.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', () => {
|
describe('the 2026 body shape', () => {
|
||||||
// 2026 renamed the selectors: `PrimaryKey` is the room, `SecondaryKey` the channel, and
|
// 20260323 renamed the read selectors: `PrimaryKey` is the room, `SecondaryKey` the
|
||||||
// `LeaderboardType` 0 says it's a room board. Same room, same board as the 2025 body.
|
// 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 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 () => {
|
beforeAll(async () => {
|
||||||
await setStat(51, ROOM, 7, null, 1)
|
await setStat(51, ROOM, 7, null, 1)
|
||||||
await setStat(52, ROOM, 9, null, 1)
|
await setStat(52, ROOM, 9, null, 1)
|
||||||
@@ -458,47 +474,71 @@ describe('the 2026 body shape', () => {
|
|||||||
FilterType: 0,
|
FilterType: 0,
|
||||||
SortAscending: false,
|
SortAscending: false,
|
||||||
})
|
})
|
||||||
const modern = await post('GetNearbyScores', {
|
expect(await legacy.json()).toEqual(ROWS)
|
||||||
WindowSize: 10,
|
const modern = await post('GetNearbyScores', body2026({ WindowSize: 10 }))
|
||||||
PlayerId: 51,
|
expect(await modern.json()).toEqual(ROWS)
|
||||||
PrimaryKey: ROOM,
|
const ranks = await post('GetRanks', body2026({ RankStart: 0, RankEnd: 9 }))
|
||||||
SecondaryKey: 1,
|
expect(await ranks.json()).toEqual(ROWS)
|
||||||
FilterType: 0,
|
const rank = await post('GetPlayerRank', body2026({}))
|
||||||
SortAscending: false,
|
expect(await rank.json()).toEqual({ PlayerId: 51, Score: 7, Rank: 1 })
|
||||||
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 () => {
|
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(
|
const res = await post(
|
||||||
'CheckAndSetStat',
|
'CheckAndSetStat',
|
||||||
{ PrimaryKey: ROOM, SecondaryKey: 1, StatValue: 12, CurrentStatValue: 7, LeaderboardType: 0 },
|
{ StatChannel: 1, RoomId: ROOM, StatValue: 12, CurrentStatValue: 7 },
|
||||||
await bearer(51)
|
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 res.text()).toBe('0')
|
||||||
expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
|
expect(await stored(51, ROOM, 1)).toEqual({ stat_value: 12 })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves an unknown LeaderboardType an empty board', async () => {
|
it('does not write CheckAndSetStat by the read-side PrimaryKey/SecondaryKey', async () => {
|
||||||
const res = await post('GetRanks', {
|
const res = await post(
|
||||||
RankStart: 0,
|
'CheckAndSetStat',
|
||||||
RankEnd: 9,
|
{ PrimaryKey: ROOM + 1, SecondaryKey: 1, StatValue: 5, CurrentStatValue: null },
|
||||||
PlayerId: 51,
|
await bearer(51)
|
||||||
PrimaryKey: ROOM,
|
)
|
||||||
SecondaryKey: 1,
|
expect(await res.text()).toBe('0')
|
||||||
FilterType: 0,
|
expect(await stored(51, ROOM + 1, 1)).toBeNull()
|
||||||
SortAscending: false,
|
|
||||||
LeaderboardType: 1,
|
|
||||||
})
|
|
||||||
expect(await res.json()).toEqual({ Rows: [] })
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1128,6 +1128,27 @@ function leaderboardEnvelope(c: Context<App>, error: string | null = null) {
|
|||||||
return c.json({ Success: error === null, Error: error, error_id: null })
|
return c.json({ Success: error === null, Error: error, error_id: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* From this build on, the leaderboard slot writes (`POST`/`DELETE
|
||||||
|
* /rooms/:id/leaderboards/:lid`) go through the client's non-generic send and never read the
|
||||||
|
* body. A `200 { Success: false }` therefore reads as SUCCESS, so a refusal has to be an
|
||||||
|
* HTTP status. Older builds parse the envelope and keep getting it on a 200.
|
||||||
|
*/
|
||||||
|
const STATUS_ONLY_LEADERBOARD_BUILD = '20260323'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refusal from a leaderboard slot write: `status` for a build at or past
|
||||||
|
* {@link STATUS_ONLY_LEADERBOARD_BUILD}, a 200 failure envelope for the builds before it.
|
||||||
|
* The same envelope rides along as the body either way. Nothing reads it in 2026, but it
|
||||||
|
* costs nothing and shows up in a capture.
|
||||||
|
*/
|
||||||
|
async function leaderboardRefusal(c: Context<App>, status: 404, error: string) {
|
||||||
|
const build = await authedGameVersion(c)
|
||||||
|
if (build !== null && build >= STATUS_ONLY_LEADERBOARD_BUILD)
|
||||||
|
return c.json({ Success: false, Error: error, error_id: null }, status)
|
||||||
|
return leaderboardEnvelope(c, error)
|
||||||
|
}
|
||||||
|
|
||||||
/** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */
|
/** The same bare envelope for the room progression write (`POST /rooms/:id/experience`). */
|
||||||
const experienceEnvelope = leaderboardEnvelope
|
const experienceEnvelope = leaderboardEnvelope
|
||||||
|
|
||||||
@@ -3374,14 +3395,22 @@ const app = new Hono<App>()
|
|||||||
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
|
'Answers a bare `{ Success, Error, error_id }` — PascalCase with a lowercase',
|
||||||
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
|
'`error_id`, like the unprefixed isBanned route, carrying no entity. NOT the room',
|
||||||
'mutations’ lowercase `{ success, error, value }`.',
|
'mutations’ lowercase `{ success, error, value }`.',
|
||||||
|
'',
|
||||||
|
'From build 20260323 the client never reads the body, so a `200` with `Success: false`',
|
||||||
|
'would read as success: a token for that build or later gets an unknown room as a 404',
|
||||||
|
'instead. Earlier builds still get the 200 failure envelope.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [roomIdParam, leaderboardIdParam],
|
parameters: [roomIdParam, leaderboardIdParam],
|
||||||
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
|
requestBody: form(LeaderboardRequest, 'The leaderboard configuration'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'),
|
200: json(
|
||||||
|
LeaderboardResultEnvelope,
|
||||||
|
'Stored, or (before 20260323) a rejection with `Success: false`'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: FORBIDDEN_RESPONSE,
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -3390,7 +3419,7 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
if (!room) return leaderboardRefusal(c, 404, 'This room does not exist!')
|
||||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||||
// already returned 401 for a missing/invalid token).
|
// already returned 401 for a missing/invalid token).
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
@@ -3431,7 +3460,10 @@ const app = new Hono<App>()
|
|||||||
'silent success — the caller asked to undo something that was not there. The client',
|
'silent success — the caller asked to undo something that was not there. The client',
|
||||||
'deletes slots blindly when tearing boards down and tolerates the refusal.',
|
'deletes slots blindly when tearing boards down and tolerates the refusal.',
|
||||||
'',
|
'',
|
||||||
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write.',
|
'Answers the same bare `{ Success, Error, error_id }` as the leaderboard write. An',
|
||||||
|
'unknown room is a 404 for build 20260323 and later, which never read the body. An',
|
||||||
|
'unconfigured slot stays a 200 for them: the slot is already gone, which is what',
|
||||||
|
'they asked for.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
parameters: [roomIdParam, leaderboardIdParam],
|
parameters: [roomIdParam, leaderboardIdParam],
|
||||||
@@ -3439,6 +3471,7 @@ const app = new Hono<App>()
|
|||||||
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
|
200: json(LeaderboardResultEnvelope, 'Removed, or a rejection with `Success: false`'),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: FORBIDDEN_RESPONSE,
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
@@ -3447,11 +3480,15 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||||
const room = await getRoomById(c.env.DB, roomId)
|
const room = await getRoomById(c.env.DB, roomId)
|
||||||
if (!room) return leaderboardEnvelope(c, 'This room does not exist!')
|
if (!room) return leaderboardRefusal(c, 404, 'This room does not exist!')
|
||||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||||
|
|
||||||
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
const leaderboardId = Number.parseInt(c.req.param('leaderboardId'), 10)
|
||||||
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
|
const removed = await deleteRoomLeaderboard(c.env.DB, roomId, leaderboardId)
|
||||||
|
// Deliberately NOT a 404 for 2026: the client tears boards down by deleting every
|
||||||
|
// slot blindly, and an unconfigured slot is already in the state it asked for. The
|
||||||
|
// 2026 client reads this 200 as success, which is the truth. Older builds get the
|
||||||
|
// envelope's refusal, which they tolerate.
|
||||||
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
|
if (!removed) return leaderboardEnvelope(c, 'This room has no such leaderboard!')
|
||||||
return leaderboardEnvelope(c)
|
return leaderboardEnvelope(c)
|
||||||
}
|
}
|
||||||
@@ -4949,9 +4986,13 @@ const app = new Hono<App>()
|
|||||||
parameters: [roomIdParam],
|
parameters: [roomIdParam],
|
||||||
requestBody: form(ExperienceRequest, 'The room’s XP settings'),
|
requestBody: form(ExperienceRequest, 'The room’s XP settings'),
|
||||||
responses: {
|
responses: {
|
||||||
200: json(LeaderboardResultEnvelope, 'Stored, or a rejection with `Success: false`'),
|
200: json(
|
||||||
|
LeaderboardResultEnvelope,
|
||||||
|
'Stored, or (before 20260323) a rejection with `Success: false`'
|
||||||
|
),
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
403: FORBIDDEN_RESPONSE,
|
403: FORBIDDEN_RESPONSE,
|
||||||
|
404: json(LeaderboardResultEnvelope, 'Unknown room (build 20260323 and later)'),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -2852,6 +2852,49 @@ describe('rooms endpoints', () => {
|
|||||||
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id IN (2, 3)').run()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('POST/DELETE /rooms/:id/leaderboards/:lid refuse with an HTTP status for 2026', async () => {
|
||||||
|
// 20260323 never reads these bodies, so a 200 `Success: false` would read as success.
|
||||||
|
const as2026 = async (sub: string) => ({
|
||||||
|
...(await bearer(sub, undefined, '20260323.14')),
|
||||||
|
})
|
||||||
|
const body = { leaderboardTitle: 'full name', statFormat: '1', sortAscending: 'False' }
|
||||||
|
const form = new URLSearchParams(body).toString()
|
||||||
|
const send = async (method: string, path: string, headers: Record<string, string>) =>
|
||||||
|
SELF.fetch(`${ORIGIN}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
...(method === 'POST' && { body: form }),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unknown room: 404 for 2026, both verbs, never 200 + Success:false.
|
||||||
|
const create = await send('POST', '/rooms/99999/leaderboards/1', await as2026('1'))
|
||||||
|
expect(create.status).toBe(404)
|
||||||
|
expect((await send('DELETE', '/rooms/99999/leaderboards/1', await as2026('1'))).status).toBe(
|
||||||
|
404
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2025 keeps the 200 failure envelope it parses.
|
||||||
|
const legacy = await send(
|
||||||
|
'POST',
|
||||||
|
'/rooms/99999/leaderboards/1',
|
||||||
|
await bearer('1', undefined, '20250718.01')
|
||||||
|
)
|
||||||
|
expect(legacy.status).toBe(200)
|
||||||
|
expect(await legacy.json()).toEqual({
|
||||||
|
Success: false,
|
||||||
|
Error: 'This room does not exist!',
|
||||||
|
error_id: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// A real create still succeeds for 2026, and the denial paths stay 401/403.
|
||||||
|
expect((await send('POST', '/rooms/2/leaderboards/1', await as2026('1'))).status).toBe(200)
|
||||||
|
expect((await send('POST', '/rooms/2/leaderboards/1', await as2026('999'))).status).toBe(403)
|
||||||
|
// An unconfigured slot is already gone, which is what the delete asked for.
|
||||||
|
expect((await send('DELETE', '/rooms/2/leaderboards/7', await as2026('1'))).status).toBe(200)
|
||||||
|
|
||||||
|
await env.DB.prepare('DELETE FROM room_leaderboard WHERE room_id = 2').run()
|
||||||
|
})
|
||||||
|
|
||||||
it('POST /rooms/:id/experience sets a room’s progression, which the GETs read back', async () => {
|
it('POST /rooms/:id/experience sets a room’s progression, which the GETs read back', async () => {
|
||||||
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
// RecCenter (room 2) is owned by account 1, with account 2 as co-owner.
|
||||||
const getXp = async (roomId: number, sub?: string) =>
|
const getXp = async (roomId: number, sub?: string) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user