mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[leaderboard] fix 2026 display at least
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user