mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add rooms
This commit is contained in:
@@ -2,7 +2,10 @@ import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// add additional Bindings here
|
||||
// Per-player presence (the room instance they're currently in). Written by
|
||||
// matchmake/goto, read by the heartbeat, cleared on login — mirrors the
|
||||
// reference server's HeartbeatDB.
|
||||
MATCH_PRESENCE: KVNamespace
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
+217
-88
@@ -66,6 +66,114 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** A synthesized room instance (same shape for dorm and other rooms). */
|
||||
type RoomInstance = ReturnType<typeof dormRoomInstance>
|
||||
|
||||
/**
|
||||
* Stored presence for a player — the room instance they matchmade into plus the
|
||||
* status fields the heartbeat echoes back. Mirrors the reference server's
|
||||
* HeartbeatDB row.
|
||||
*/
|
||||
interface Presence {
|
||||
roomInstance: RoomInstance | null
|
||||
statusVisibility: number
|
||||
deviceClass: number
|
||||
vrMovementMode: number
|
||||
platform: number
|
||||
appVersion: string
|
||||
}
|
||||
|
||||
/** Presence is kept this long (s) after the last matchmake/heartbeat refresh. */
|
||||
const PRESENCE_TTL = 900
|
||||
|
||||
const presenceKey = (id: number) => `presence:${id}`
|
||||
|
||||
/** Persist the player's presence (room instance + status), refreshing the TTL. */
|
||||
async function setPresence(c: Context<App>, id: number, presence: Presence): Promise<void> {
|
||||
await c.env.MATCH_PRESENCE.put(presenceKey(id), JSON.stringify(presence), {
|
||||
expirationTtl: PRESENCE_TTL,
|
||||
})
|
||||
}
|
||||
|
||||
/** Read the player's stored presence, or null when they aren't in a room. */
|
||||
async function getPresence(c: Context<App>, id: number): Promise<Presence | null> {
|
||||
return c.env.MATCH_PRESENCE.get<Presence>(presenceKey(id), 'json')
|
||||
}
|
||||
|
||||
/** Store the room instance the player just matchmade into, preserving status. */
|
||||
async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance): Promise<void> {
|
||||
const prev = await getPresence(c, id)
|
||||
await setPresence(c, id, {
|
||||
roomInstance,
|
||||
statusVisibility: prev?.statusVisibility ?? 0,
|
||||
deviceClass: prev?.deviceClass ?? 0,
|
||||
vrMovementMode: prev?.vrMovementMode ?? 1,
|
||||
platform: prev?.platform ?? 0,
|
||||
appVersion: prev?.appVersion ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed Photon room id for the dorm. With no RoomInstance DB we can't persist
|
||||
* the GUID minted at matchmake time, and the client's presence check compares
|
||||
* the *whole* instance (photonRoomId included). Every dorm entry point
|
||||
* (matchmake/goto, matchmake/none, the heartbeat) must therefore return the
|
||||
* exact same instance, so the id is a constant rather than random/per-account.
|
||||
*/
|
||||
const DORM_PHOTON_ROOM_ID = '00000000-0000-4000-8000-000000000001'
|
||||
|
||||
/**
|
||||
* The canonical dorm room instance (room 1, instance 1.1). Returned identically
|
||||
* by every dorm entry point and the presence heartbeat so the client's local
|
||||
* presence never reads as out-of-sync.
|
||||
*/
|
||||
function dormRoomInstance() {
|
||||
return {
|
||||
roomInstanceId: 1,
|
||||
roomId: 1,
|
||||
subRoomId: 1,
|
||||
roomInstanceType: 2,
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: DORM_PHOTON_ROOM_ID,
|
||||
name: 'DormRoom',
|
||||
maxCapacity: 4,
|
||||
isFull: false,
|
||||
isPrivate: true,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize a non-dorm room instance. No Rooms DB, so location is empty and
|
||||
* the photon id is freshly minted — it's persisted as presence and replayed by
|
||||
* the heartbeat, so it stays consistent for the session. */
|
||||
function buildRoomInstance(roomName: string, isPrivate: boolean): RoomInstance {
|
||||
return {
|
||||
roomInstanceId: 1,
|
||||
roomId: Number.parseInt(roomName, 10) || 1,
|
||||
subRoomId: 0,
|
||||
roomInstanceType: 2,
|
||||
location: '',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: roomName,
|
||||
maxCapacity: 4,
|
||||
isFull: false,
|
||||
isPrivate,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -81,40 +189,54 @@ const app = new Hono<App>()
|
||||
.notFound(withNotFound())
|
||||
|
||||
// ---- Player presence -----------------------------------------------------
|
||||
// Login is a no-op ack in the C# (`Results.Ok()`).
|
||||
.post('/player/login', (c) => c.body(null, 200))
|
||||
// Login/exclusivelogin: the player isn't in a room yet, so clear any stale
|
||||
// presence (mirrors the C# connect/token removing the player's RoomInstance).
|
||||
// The first heartbeat after this reports roomInstance=null until matchmake.
|
||||
.post('/player/login', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) await c.env.MATCH_PRESENCE.delete(presenceKey(id))
|
||||
return c.body(null, 200)
|
||||
})
|
||||
.post('/player/exclusivelogin', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) await c.env.MATCH_PRESENCE.delete(presenceKey(id))
|
||||
return c.json({ errorCode: 0 })
|
||||
})
|
||||
|
||||
.get('/player', (c) => {
|
||||
// The C# reads the `id` query param: a valid id for an existing account
|
||||
// returns that player's payload; a missing/invalid id (or unknown account)
|
||||
// falls back to the static JSON/getplayer.json default. With no DB we treat
|
||||
// any account as existing and synthesize its payload.
|
||||
const idParam = c.req.query('id')
|
||||
const accountId = idParam ? Number.parseInt(idParam, 10) : Number.NaN
|
||||
if (Number.isNaN(accountId)) return c.json(DEFAULT_GET_PLAYER)
|
||||
.get('/player', async (c) => {
|
||||
// Returns each requested player's presence. The C# reads the `id` query
|
||||
// param(s); with none it serves the static getplayer.json default.
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
if (!ids || ids.length === 0) return c.json(DEFAULT_GET_PLAYER)
|
||||
|
||||
// No RoomInstances binding → roomInstance null / isOnline false, matching
|
||||
// the C# branch where the account exists but owns no room instance.
|
||||
// TODO: look up the player's active RoomInstance once a DB binding exists.
|
||||
return c.json([
|
||||
{
|
||||
playerId: accountId,
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 1,
|
||||
roomInstance: null,
|
||||
isOnline: false,
|
||||
appVersion: '',
|
||||
platform: 0,
|
||||
},
|
||||
])
|
||||
const players = await Promise.all(
|
||||
ids.map(async (playerId) => {
|
||||
const p = await getPresence(c, playerId)
|
||||
return {
|
||||
playerId,
|
||||
statusVisibility: p?.statusVisibility ?? 0,
|
||||
deviceClass: p?.deviceClass ?? 0,
|
||||
vrMovementMode: p?.vrMovementMode ?? 1,
|
||||
roomInstance: p?.roomInstance ?? null,
|
||||
isOnline: p?.roomInstance != null,
|
||||
appVersion: p?.appVersion ?? '',
|
||||
platform: p?.platform ?? 0,
|
||||
}
|
||||
})
|
||||
)
|
||||
return c.json(players)
|
||||
})
|
||||
|
||||
.post('/player/heartbeat', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// Body may be a JSON HeartbeatRequest or a form post; only JSON is read.
|
||||
// Body may be a JSON HeartbeatRequest or a form post (LoginLock); only JSON
|
||||
// carries presence/status fields.
|
||||
const raw = await c.req.text().catch(() => '')
|
||||
let hb: HeartbeatRequest = {}
|
||||
if (raw.trimStart().startsWith('{')) {
|
||||
@@ -125,85 +247,92 @@ const app = new Hono<App>()
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: look up the player's active RoomInstance once a DB binding exists.
|
||||
// Return the player's stored presence (set by matchmake/goto), mirroring the
|
||||
// reference server's HeartbeatDB.GetPlayerHeartbeat. No presence → the player
|
||||
// isn't in a room yet, so roomInstance=null / isOnline=false. Posted status
|
||||
// fields are merged back and the TTL refreshed so presence stays alive.
|
||||
const presence = await getPresence(c, id)
|
||||
if (presence) {
|
||||
if (hb.statusVisibility !== undefined) presence.statusVisibility = hb.statusVisibility
|
||||
if (hb.deviceClass !== undefined) presence.deviceClass = hb.deviceClass
|
||||
if (hb.vrMovementMode !== undefined) presence.vrMovementMode = hb.vrMovementMode
|
||||
if (hb.platform !== undefined) presence.platform = hb.platform
|
||||
if (hb.appVersion != null) presence.appVersion = hb.appVersion
|
||||
await setPresence(c, id, presence)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
playerId: hb.playerId ? hb.playerId : id,
|
||||
statusVisibility: hb.statusVisibility ?? 0,
|
||||
deviceClass: hb.deviceClass ?? 0,
|
||||
vrMovementMode: hb.vrMovementMode ? hb.vrMovementMode : 1,
|
||||
roomInstance: null,
|
||||
isOnline: false,
|
||||
appVersion: hb.appVersion ?? '',
|
||||
platform: hb.platform ?? 0,
|
||||
statusVisibility: presence?.statusVisibility ?? hb.statusVisibility ?? 0,
|
||||
deviceClass: presence?.deviceClass ?? hb.deviceClass ?? 0,
|
||||
vrMovementMode: presence?.vrMovementMode ?? (hb.vrMovementMode ? hb.vrMovementMode : 1),
|
||||
roomInstance: presence?.roomInstance ?? null,
|
||||
isOnline: presence?.roomInstance != null,
|
||||
appVersion: presence?.appVersion ?? hb.appVersion ?? '',
|
||||
platform: presence?.platform ?? hb.platform ?? 0,
|
||||
})
|
||||
})
|
||||
|
||||
.put('/player/statusvisibility', (c) => c.body(null, 200)) // TODO: add functionality
|
||||
.put('/player/statusvisibility', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const sv =
|
||||
typeof body.statusVisibility === 'string' ? Number.parseInt(body.statusVisibility, 10) : NaN
|
||||
const presence = await getPresence(c, id)
|
||||
if (presence && !Number.isNaN(sv)) {
|
||||
presence.statusVisibility = sv
|
||||
await setPresence(c, id, presence)
|
||||
}
|
||||
}
|
||||
return c.body(null, 200)
|
||||
})
|
||||
|
||||
// ---- Room navigation -----------------------------------------------------
|
||||
// Each matchmake/goto persists the resulting instance as the player's presence
|
||||
// so the heartbeat can replay it (keeping client presence in sync).
|
||||
.post('/goto/room/:room', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const room = c.req.param('room')
|
||||
const isDorm = room.toLowerCase() === 'dormroom'
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0
|
||||
const isPrivate = joinMode === 2 || isDorm
|
||||
|
||||
// No Rooms/SubRooms/RoomInstances bindings yet, so synthesize the instance
|
||||
// the C# would build. The dorm scene id is known; other rooms get an empty
|
||||
// location until there's real Room data.
|
||||
// TODO: resolve the Room + SubRoom and upsert a RoomInstance once a DB binding exists.
|
||||
return c.json({
|
||||
errorCode: 0,
|
||||
roomInstance: {
|
||||
roomInstanceId: 1,
|
||||
roomId: isDorm ? 1 : Number.parseInt(room, 10) || 1,
|
||||
subRoomId: isDorm ? 1 : 0,
|
||||
roomInstanceType: 2,
|
||||
location: isDorm ? '76d98498-60a1-430c-ab76-b54a29b7a163' : '',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: isDorm ? 'DormRoom' : room,
|
||||
maxCapacity: 4,
|
||||
isFull: false,
|
||||
isPrivate,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
},
|
||||
})
|
||||
const instance = isDorm ? dormRoomInstance() : buildRoomInstance(room, joinMode === 2)
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
})
|
||||
|
||||
.post('/goto/none', (c) =>
|
||||
// Offline dorm — fully static in the C# source.
|
||||
c.json({
|
||||
errorCode: 0,
|
||||
roomInstance: {
|
||||
roomInstanceId: 1,
|
||||
roomId: 1,
|
||||
subRoomId: 1,
|
||||
roomInstanceType: 2,
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
dataBlob: '',
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
photonRegionId: 'us',
|
||||
photonRoomId: crypto.randomUUID(),
|
||||
name: 'DormRoom',
|
||||
maxCapacity: 4,
|
||||
isFull: false,
|
||||
isPrivate: true,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
// Register the static `none` route before the `:room` param route so it
|
||||
// isn't swallowed by the auth-gated matchmake handler.
|
||||
.post('/matchmake/none', async (c) => {
|
||||
const id = await authedId(c)
|
||||
const instance = dormRoomInstance()
|
||||
if (id !== null) await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
})
|
||||
.post('/matchmake/:room', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const room = c.req.param('room')
|
||||
// Identical to goto/room/:room except the C# dorm check here is "dorm".
|
||||
const isDorm = room.toLowerCase() === 'dorm'
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const joinMode = typeof body.JoinMode === 'string' ? Number.parseInt(body.JoinMode, 10) || 0 : 0
|
||||
const instance = isDorm ? dormRoomInstance() : buildRoomInstance(room, joinMode === 2)
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
})
|
||||
|
||||
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
|
||||
.post('/goto/none', async (c) => {
|
||||
const id = await authedId(c)
|
||||
const instance = dormRoomInstance()
|
||||
if (id !== null) await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
})
|
||||
|
||||
// ---- Room instance -------------------------------------------------------
|
||||
.post('/roominstance/:id/reportjoinresult', (c) => c.body(null, 200))
|
||||
|
||||
@@ -39,6 +39,12 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
test('POST /player/exclusivelogin returns { errorCode: 0 }', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ errorCode: 0 })
|
||||
})
|
||||
|
||||
test('GET /player?id=N synthesizes a player payload for that id', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player?id=99`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -79,6 +85,22 @@ describe('public endpoints', () => {
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('POST /matchmake/none returns the offline dorm', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/none`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { name: string; location: string; isPrivate: boolean; photonRoomId: string }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
name: 'DormRoom',
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
isPrivate: true,
|
||||
})
|
||||
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
test('PUT /player/statusvisibility returns 200', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -130,26 +152,117 @@ describe('auth-gated endpoints', () => {
|
||||
expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true, name: '42' })
|
||||
})
|
||||
|
||||
test('POST /matchmake/:room 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /matchmake/dorm returns the dorm instance', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { name: string; location: string; isPrivate: boolean; roomId: number }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
name: 'DormRoom',
|
||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||
isPrivate: true,
|
||||
roomId: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/:id synthesizes an instance and honors JoinMode', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/42`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
roomInstance: { roomId: number; isPrivate: boolean; name: string }
|
||||
}
|
||||
expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true, name: '42' })
|
||||
})
|
||||
|
||||
test('POST /player/heartbeat 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /player/heartbeat echoes the body and defaults playerId to the token id', async () => {
|
||||
test('POST /player/heartbeat reports no presence before matchmake', async () => {
|
||||
// Fresh token (sub 7) with no stored presence → not in a room.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusVisibility: 2, platform: 5, appVersion: '20210129' }),
|
||||
headers: { ...(await bearer('7')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusVisibility: 2, platform: 5 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({
|
||||
playerId: 42,
|
||||
statusVisibility: 2,
|
||||
vrMovementMode: 1,
|
||||
playerId: 7,
|
||||
roomInstance: null,
|
||||
isOnline: false,
|
||||
appVersion: '20210129',
|
||||
platform: 5,
|
||||
})
|
||||
})
|
||||
|
||||
test('matchmake then heartbeat replays the stored instance (in sync)', async () => {
|
||||
const headers = await bearer()
|
||||
const mm = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
).json()) as { roomInstance: Record<string, unknown> }
|
||||
// LoginLock form heartbeat (no presence fields) still gets the stored room.
|
||||
const hb = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'LoginLock=abc',
|
||||
})
|
||||
).json()) as { roomInstance: Record<string, unknown>; isOnline: boolean }
|
||||
expect(hb.isOnline).toBe(true)
|
||||
expect(hb.roomInstance).toEqual(mm.roomInstance)
|
||||
})
|
||||
|
||||
test('heartbeat merges posted status fields into stored presence', async () => {
|
||||
const headers = await bearer('8')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
const hb = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusVisibility: 2, platform: 5, appVersion: '20210129' }),
|
||||
})
|
||||
).json()) as { statusVisibility: number; platform: number; appVersion: string; isOnline: boolean }
|
||||
expect(hb).toMatchObject({
|
||||
statusVisibility: 2,
|
||||
platform: 5,
|
||||
appVersion: '20210129',
|
||||
isOnline: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('player/login clears presence (back to not-in-a-room)', async () => {
|
||||
const headers = await bearer('9')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers })
|
||||
const hb = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
|
||||
).json()) as { roomInstance: unknown; isOnline: boolean }
|
||||
expect(hb.roomInstance).toBeNull()
|
||||
expect(hb.isOnline).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /player?id reports stored presence per id', async () => {
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('55'),
|
||||
})
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player?id=55`)
|
||||
expect(res.status).toBe(200)
|
||||
const players = (await res.json()) as Array<{ playerId: number; isOnline: boolean }>
|
||||
expect(players[0]).toMatchObject({ playerId: 55, isOnline: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
// Per-player presence store (room instance the player is currently in).
|
||||
// Create with `wrangler kv namespace create MATCH_PRESENCE` and set the id.
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "MATCH_PRESENCE",
|
||||
"id": "9f53f04b7dd244658d59f515a14748b6"
|
||||
}
|
||||
],
|
||||
"logpush": false,
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
|
||||
Reference in New Issue
Block a user