at least we are in the dorm now

This commit is contained in:
Devin Zuczek
2026-06-14 21:32:37 -04:00
parent 768ca2a0a3
commit 2f70eab186
14 changed files with 310 additions and 28 deletions
+24 -9
View File
@@ -138,6 +138,7 @@ function dormRoomInstance() {
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId: DORM_PHOTON_ROOM_ID,
name: 'DormRoom',
@@ -163,6 +164,7 @@ function buildRoomInstance(roomName: string, isPrivate: boolean): RoomInstance {
eventId: 0,
clubId: 0,
roomCode: '',
photonRegion: 'us',
photonRegionId: 'us',
photonRoomId: crypto.randomUUID(),
name: roomName,
@@ -189,19 +191,20 @@ const app = new Hono<App>()
.notFound(withNotFound())
// ---- Player presence -----------------------------------------------------
// 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) => {
// Login/exclusivelogin are no-op acks (matching every reference server). They
// MUST NOT touch presence: the client calls exclusivelogin when going online,
// and clearing here would wipe the room matchmake just stored → empty KV →
// the heartbeat reports no room. Only logout clears presence.
.post('/player/login', (c) => c.body(null, 200))
.post('/player/exclusivelogin', (c) => c.json({ errorCode: 0 }))
// Logout: drop the player's presence (they're no longer in a room). Both
// reference servers expose this; returns 200.
.post('/player/logout', 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', async (c) => {
// Returns each requested player's presence. The C# reads the `id` query
@@ -312,6 +315,18 @@ const app = new Hono<App>()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
// The 2023 client uses a two-segment matchmake/room/{roomId}. Synthesize the
// room instance and store it as presence.
.post('/matchmake/room/:roomId', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = c.req.param('roomId')
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 = roomId === '1' ? dormRoomInstance() : buildRoomInstance(roomId, joinMode === 2)
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)
+30 -3
View File
@@ -85,6 +85,18 @@ describe('public endpoints', () => {
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/room/:roomId synthesizes an instance and stores presence', async () => {
const headers = await bearer('88')
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/42`, {
method: 'POST',
headers: { ...headers, '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 } }
expect(body.roomInstance).toMatchObject({ roomId: 42, isPrivate: true })
})
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)
@@ -244,10 +256,11 @@ describe('auth-gated endpoints', () => {
})
})
test('player/login clears presence (back to not-in-a-room)', async () => {
const headers = await bearer('9')
test('player/logout returns 200 and clears presence', async () => {
const headers = await bearer('77')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
await exports.default.fetch(`${ORIGIN}/player/login`, { method: 'POST', headers })
const out = await exports.default.fetch(`${ORIGIN}/player/logout`, { method: 'POST', headers })
expect(out.status).toBe(200)
const hb = (await (
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { roomInstance: unknown; isOnline: boolean }
@@ -255,6 +268,20 @@ describe('auth-gated endpoints', () => {
expect(hb.isOnline).toBe(false)
})
test('login/exclusivelogin do NOT clear presence (only logout does)', async () => {
const headers = await bearer('9')
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
// The client calls exclusivelogin when going online — it must not wipe the
// room matchmake just stored.
await exports.default.fetch(`${ORIGIN}/player/exclusivelogin`, { 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: { name: string } | null; isOnline: boolean }
expect(hb.isOnline).toBe(true)
expect(hb.roomInstance?.name).toBe('DormRoom')
})
test('GET /player?id reports stored presence per id', async () => {
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',