mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Lifetime visit counter on `room`. The `match` worker bumps it once per successful
|
||||
-- matchmake into the room (see recordRoomVisit, called from match's enterRoom), which
|
||||
-- is the only way a player ever lands in a room, and every room read serves it as the
|
||||
-- room's `Stats.VisitCount`.
|
||||
--
|
||||
-- A real column rather than a field in the `data` blob: a visit has to be one atomic
|
||||
-- `visits = visits + 1` UPDATE. Writing it into the blob would mean reading the whole
|
||||
-- room, editing the JSON and writing it back, so two players entering at once would
|
||||
-- lose one of the visits — and would race every other writer of the room besides.
|
||||
--
|
||||
-- Unlike CheerCount/FavoriteCount it can't be derived on read either: a visit leaves
|
||||
-- no per-player row to count (`interaction.last_visited_at` is only stamped by the
|
||||
-- cheer/favorite toggles). Existing rooms start from 0 — the count begins now.
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
|
||||
ALTER TABLE room ADD COLUMN visits INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -140,8 +140,10 @@ export const RoomTagDto = z.object({
|
||||
|
||||
/**
|
||||
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
|
||||
* per-player `interaction` rows on every read; nothing records visits yet, so
|
||||
* `VisitorCount`/`VisitCount` stay at 0.
|
||||
* per-player `interaction` rows on every read. `VisitCount` is the room's lifetime
|
||||
* visits — the `room.visits` column, bumped by the `match` worker on every successful
|
||||
* matchmake into the room. Nothing records distinct visitors, so `VisitorCount` stays
|
||||
* at 0.
|
||||
*/
|
||||
export const RoomStatsDto = z.object({
|
||||
CheerCount: z.int(),
|
||||
|
||||
@@ -2066,7 +2066,8 @@ describe('rooms endpoints', () => {
|
||||
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
|
||||
// Clearing a cheer decrements it. Nothing records visits, so those stay 0.
|
||||
// Clearing a cheer decrements it. Visits are counted by the `match` worker on
|
||||
// matchmake and nobody has entered this room, so those stay 0.
|
||||
await interact('562', 'cheer', 'DELETE')
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
@@ -2074,6 +2075,35 @@ describe('rooms endpoints', () => {
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
|
||||
// VisitCount is the `room.visits` column (what match bumps on each matchmake),
|
||||
// served on every read of the room — here and in the search results — and it
|
||||
// survives the cheer/favorite aggregation rather than being zeroed by it.
|
||||
await env.DB.prepare('UPDATE room SET visits = 7 WHERE room_id = 15').run()
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
FavoriteCount: 1,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 7,
|
||||
})
|
||||
expect(await searched()).toMatchObject({ VisitCount: 7 })
|
||||
|
||||
// A write to the room doesn't bake the count into the blob (nor reset it).
|
||||
// Account 1 created room 15, so the description write is allowed.
|
||||
const wrote = await SELF.fetch(`${ORIGIN}/rooms/15/description`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer('1')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ description: 'counted' }).toString(),
|
||||
})
|
||||
expect(wrote.status).toBe(200)
|
||||
const blob = await env.DB.prepare('SELECT data FROM room WHERE room_id = 15').first<{
|
||||
data: string
|
||||
}>()
|
||||
expect((JSON.parse(blob!.data) as { Stats: Stats }).Stats.VisitCount).toBe(0)
|
||||
expect(await direct()).toMatchObject({ VisitCount: 7 })
|
||||
})
|
||||
|
||||
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
||||
|
||||
Reference in New Issue
Block a user