eh not the prettiest, but visits is just an increment and we might have others later. room_columns is not user-provided

This commit is contained in:
Devin Zuczek
2026-08-08 14:30:54 -04:00
parent df2d2af75b
commit a3d9fdb8bf
6 changed files with 182 additions and 26 deletions
+19 -1
View File
@@ -26,6 +26,7 @@ import {
isClubMember,
isPlayerBannedFromRoom,
MessageType,
recordRoomVisit,
refreshInstanceFullness,
RoomInstanceType,
setPresence,
@@ -234,7 +235,8 @@ async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise
}
/**
* Store the room instance the player just matchmade into, preserving status.
* Store the room instance the player just matchmade into, preserving status, and count
* the visit against the room.
*
* With no live presence to carry forward (the player's first matchmake after login,
* or one after their presence lapsed) the device fields would otherwise default —
@@ -259,6 +261,22 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
// and the heartbeat can keep verifying against it.
loginLock: prev?.loginLock,
})
// Count the visit. Every matchmake route funnels through here with the instance the
// player landed in, and a matchmake is the only way into a room, so this is the one
// place a visit can be recorded once — whether they got here by room id, by subroom,
// by following a friend, from a club's clubhouse, or into their own dorm. Bumps the
// room's `visits` column, which is served as `Stats.VisitCount`. Best-effort: a
// counter is not worth failing the matchmake over.
try {
await recordRoomVisit(c.env.DB, roomInstance.roomId)
} catch (err) {
logger.error('failed to record room visit', {
roomId: roomInstance.roomId,
error: err instanceof Error ? err.message : String(err),
})
}
// Keep the destination instance's is_full flag in sync with live presence (the
// player's own presence, just written, is counted). Then re-evaluate the
// instance they left — its head-count dropped — so a full room frees up when
@@ -278,6 +278,46 @@ describe('public endpoints', () => {
})
})
test('a matchmake counts a visit against the room', async () => {
const visits = async (roomId: number): Promise<number> =>
(await env.DB.prepare('SELECT visits FROM room WHERE room_id = ?1')
.bind(roomId)
.first<{ visits: number }>())!.visits
const enter = async (path: string, player: string) => {
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
...(await bearer(player)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({ JoinMode: '2' }).toString(),
})
expect(res.status).toBe(200)
}
// Counted per matchmake, whichever route got the player there — the two-segment
// room form and the subroom form both land in room 77.
const before = await visits(77)
await enter('/matchmake/room/77', '94')
expect(await visits(77)).toBe(before + 1)
await enter('/matchmake/room/77/35', '95')
expect(await visits(77)).toBe(before + 2)
// Same player entering again is another visit (VisitCount is visits, not visitors),
// and it's the entered room that's counted — not every room.
const otherBefore = await visits(2)
await enter('/matchmake/room/77', '94')
expect(await visits(77)).toBe(before + 3)
expect(await visits(2)).toBe(otherBefore)
// A refused matchmake counts nothing: an unknown room has no row to bump.
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
method: 'POST',
headers: await bearer('96'),
})
expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20)
})
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
// A screen player (deviceClass 2, recorded by auth at login) matchmaking with no
// live presence: without the account fallback they'd enter the room as deviceClass