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,
|
NO_SCORE,
|
||||||
UNRANKED,
|
UNRANKED,
|
||||||
} from './leaderboard-db'
|
} from './leaderboard-db'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckAndSetStatBody,
|
CheckAndSetStatBody,
|
||||||
CheckAndSetStatResponse,
|
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
|
/** 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 {
|
interface BoardBody {
|
||||||
PlayerId?: number
|
PlayerId?: number
|
||||||
RoomId?: number
|
RoomId?: number
|
||||||
StatChannel?: number
|
StatChannel?: number
|
||||||
|
PrimaryKey?: number
|
||||||
|
SecondaryKey?: number
|
||||||
|
LeaderboardType?: number
|
||||||
FilterType?: number
|
FilterType?: number
|
||||||
SortAscending?: boolean
|
SortAscending?: boolean
|
||||||
RankStart?: number
|
RankStart?: number
|
||||||
@@ -63,17 +69,28 @@ const enum FilterType {
|
|||||||
Friends = 1,
|
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
|
* The board a body names: `RoomId` + `StatChannel` (2026: `PrimaryKey` + `SecondaryKey`),
|
||||||
* `PlayerId`'s friends when `FilterType` is Friends. A friends board with no `PlayerId`
|
* each 0 when absent, seen through `PlayerId`'s friends when `FilterType` is Friends. A
|
||||||
* has nobody to be friends of, so it falls back to the global one rather than to nothing.
|
* 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 {
|
function board(body: BoardBody): Board {
|
||||||
const playerId = int(body.PlayerId, 0)
|
const playerId = int(body.PlayerId, 0)
|
||||||
|
const type = int(body.LeaderboardType, ROOM_LEADERBOARD)
|
||||||
return {
|
return {
|
||||||
roomId: int(body.RoomId, 0),
|
roomId: type === ROOM_LEADERBOARD ? int(body.RoomId, int(body.PrimaryKey, 0)) : 0,
|
||||||
statChannel: int(body.StatChannel, 0),
|
statChannel: int(body.StatChannel, int(body.SecondaryKey, 0)),
|
||||||
...(int(body.FilterType, 0) === FilterType.Friends && playerId !== 0 && { friendsOf: playerId }),
|
...(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())
|
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<{
|
const body = await readBody<
|
||||||
RoomId?: number
|
BoardBody & { StatValue?: number; CurrentStatValue?: number | null }
|
||||||
StatChannel?: number
|
>(c)
|
||||||
StatValue?: number
|
|
||||||
CurrentStatValue?: number | null
|
|
||||||
}>(c)
|
|
||||||
logger.info('CheckAndSetStat', { accountId, body })
|
logger.info('CheckAndSetStat', { accountId, body })
|
||||||
|
|
||||||
const target = board(body)
|
const target = board(body)
|
||||||
|
|||||||
@@ -139,12 +139,26 @@ export const CheckAndSetStatBody = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`. This
|
* The body posted to `GetNearbyScores`: {@link GetPlayerRankBody} plus `WindowSize`, e.g.
|
||||||
* is the reference servers' shape (the client's `GetNearbyScoresRequestDTO` extends its
|
* (2025) `{"WindowSize":10,"PlayerId":205,"StatChannel":1,"RoomId":6,"FilterType":0,"SortAscending":false}`.
|
||||||
* rank request with `WindowSize`); it has not yet been watched from a live client here,
|
*
|
||||||
* which is why the handler still logs it.
|
* 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 = 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
|
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'),
|
||||||
|
|||||||
@@ -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 () => {
|
it('serves an openapi spec with no dangling refs', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/openapi.json')
|
const res = await SELF.fetch('https://example.com/openapi.json')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
Reference in New Issue
Block a user