From c52b37037e84e3509e239857e61cc66256179d25 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 15 Oct 2026 23:24:45 -0400 Subject: [PATCH] [match/list] actually record room visits --- apps/lists/src/lists.app.ts | 2 +- apps/match/src/match.app.ts | 7 +-- apps/match/src/test/integration/api.test.ts | 52 +++++++++++++++++++++ apps/rooms/src/test/integration/api.test.ts | 25 +++++----- packages/domain/src/rooms-db.ts | 41 ++++++++++++---- 5 files changed, 102 insertions(+), 25 deletions(-) diff --git a/apps/lists/src/lists.app.ts b/apps/lists/src/lists.app.ts index 86aed7e..f07cb6e 100644 --- a/apps/lists/src/lists.app.ts +++ b/apps/lists/src/lists.app.ts @@ -219,7 +219,7 @@ const ROW_FEEDS: Record = { const PERSONAL_ROW_FEEDS: Record Promise> = { // Rooms the caller has been in, most recently visited first — the "Continue Playing" // carousel as an algorithmic row. Backed by the `interaction` table's `last_visited_at`, - // which the `match` heartbeat stamps, and served straight from `getVisitedRooms` so this + // which every `match` matchmake stamps, and served straight from `getVisitedRooms` so this // row and `rooms` `GET /rooms/visitedby/me` can never disagree about where someone has been. recentlyvisited: (db, accountId) => getVisitedRooms(db, accountId, 0, LIST_SIZE), } diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 1579b67..e67695a 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -687,10 +687,11 @@ async function enterRoom(c: Context, id: number, roomInstance: RoomInstance // 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. + // room's `visits` column, which is served as `Stats.VisitCount`, and stamps the + // player's `interaction.last_visited_at`, which puts the room on their visited + // history (dorms excepted). Best-effort: neither is worth failing the matchmake over. try { - await recordRoomVisit(c.env.DB, roomInstance.roomId) + await recordRoomVisit(c.env.DB, roomInstance.roomId, id) } catch (err) { logger.error('failed to record room visit', { roomId: roomInstance.roomId, diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index e603b31..ee63438 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -16,6 +16,7 @@ import { getRoomInstance, modifySubRoom, PRESENCE_SCHEMA_DDL, + recordRoomVisit, ROOM_INSTANCE_SCHEMA_DDL, ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, @@ -756,6 +757,57 @@ describe('public endpoints', () => { headers: await bearer('96'), }) expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20) + expect( + await env.DB.prepare('SELECT 1 FROM interaction WHERE player_id = 96').first() + ).toBeNull() + }) + + test('a matchmake stamps the room on the player’s visited history', async () => { + const enter = async (player: string) => { + const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, { + method: 'POST', + headers: { + ...(await bearer(player)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ JoinMode: '2' }).toString(), + }) + expect(res.status).toBe(200) + } + const row = () => + env.DB.prepare( + 'SELECT cheered, favorited, last_visited_at FROM interaction WHERE player_id = 97 AND room_id = 77' + ).first<{ cheered: number; favorited: number; last_visited_at: string | null }>() + + // First visit creates the row with no cheer/favorite. + await enter('97') + const first = await row() + expect(first).toMatchObject({ cheered: 0, favorited: 0 }) + expect(first!.last_visited_at).not.toBeNull() + + // A later visit moves the timestamp and leaves an existing favorite alone. + await env.DB.prepare( + "UPDATE interaction SET favorited = 1, last_visited_at = '2000-01-01T00:00:00.000Z' WHERE player_id = 97 AND room_id = 77" + ).run() + await enter('97') + const second = await row() + expect(second!.favorited).toBe(1) + expect(second!.last_visited_at! > '2000-01-01T00:00:00.000Z').toBe(true) + + // A dorm is counted as a visit but kept off the history. Seeded as a bare row and + // visited directly: matchmaking into a fresh dorm would take subroom ids other + // tests seed by hand. + await env.DB.prepare('INSERT INTO room (data) VALUES (?1)') + .bind(JSON.stringify({ RoomId: 98_000, Name: 'DormVisitTest', IsDorm: true })) + .run() + await recordRoomVisit(env.DB, 98_000, 98) + const visits = await env.DB.prepare('SELECT visits FROM room WHERE room_id = 98000').first<{ + visits: number + }>() + expect(visits!.visits).toBe(1) + expect( + await env.DB.prepare('SELECT 1 FROM interaction WHERE player_id = 98').first() + ).toBeNull() }) test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index a78fdac..749b177 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -19,6 +19,7 @@ import { PRESENCE_SCHEMA_DDL, publicStudioBundleFilename, putUnityAsset, + recordRoomVisit, ROOM_INSTANCE_SCHEMA_DDL, ROOM_INVITE_SCHEMA_DDL, ROOM_SCHEMA_DDL, @@ -749,19 +750,25 @@ describe('rooms endpoints', () => { expect(other).toEqual([]) }) - it('GET /rooms/visitedby/me returns a bare array of rooms the caller has interacted with (auth-scoped)', async () => { + it('GET /rooms/visitedby/me returns a bare array of rooms the caller has visited (auth-scoped)', async () => { const headers = await bearer('779') - // No interactions yet → empty array. + // No visits yet → empty array. const empty = (await ( await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers }) ).json()) as unknown[] expect(empty).toEqual([]) - // Interacting (cheer/favorite) records a last-visit on those rooms. + // Cheering/favoriting is not a visit — only `match` entering the room stamps one. await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { method: 'PUT', headers }) await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { method: 'PUT', headers }) + const afterToggles = (await ( + await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers }) + ).json()) as unknown[] + expect(afterToggles).toEqual([]) + await recordRoomVisit(env.DB, 2, 779) + await recordRoomVisit(env.DB, 12, 779) const body = (await ( await SELF.fetch(`${ORIGIN}/rooms/visitedby/me?skip=0&take=100`, { headers }) ).json()) as Array<{ RoomId: number }> @@ -782,16 +789,10 @@ describe('rooms endpoints', () => { }) it('GET /rooms/visitedby/:playerId serves any player’s visited rooms to any authed caller', async () => { - // Give 790 a visit history (cheering/favoriting stamps a last-visit). + // Give 790 a visit history. const subject = await bearer('790') - await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, { - method: 'PUT', - headers: subject, - }) - await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { - method: 'PUT', - headers: subject, - }) + await recordRoomVisit(env.DB, 2, 790) + await recordRoomVisit(env.DB, 12, 790) // Anyone with a token reads it — a bare array. Friendship is not required: a // player's history is as public as the rooms on their profile. diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 4d931c1..b568a80 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -2561,8 +2561,9 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise { // (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no // per-player row to count — and it can't live in the blob, where a read-modify-write of // the whole room would drop concurrent visits. `VisitorCount` (distinct visitors) is -// still left as the blob has it: `interaction.last_visited_at` is only stamped by the -// cheer/favorite toggles, so counting those rows would report cheerers as visitors. +// still left as the blob has it: `interaction.last_visited_at` is stamped per player by +// {@link recordRoomVisit}, but not for dorms, so counting those rows would under-report +// a dorm's visitors. /** One room's derived engagement counters (the aggregate maps below key these by RoomId). */ export interface RoomStats { @@ -2597,9 +2598,30 @@ function storedStats(stats: unknown): Record { * matchmake (see its `enterRoom`), which is the only way a player ever lands in a room. * A blind `visits = visits + 1` UPDATE: it's the whole write, so simultaneous visitors * can't clobber each other, and an unknown room id simply matches nothing. + * + * Also stamps the player's `interaction.last_visited_at`, which is what puts the room on + * their visited history ({@link getVisitedRooms}, `lists`' "recently visited" row). This + * is the ONLY writer of `last_visited_at` — cheering or favoriting a room is not a visit. + * Dorms are left off the history (they are counted in `visits`, but nobody needs their + * own dorm, or a friend's, listed as somewhere they went). The upsert leaves + * `cheered`/`favorited` alone; selecting from `room` keeps an unknown id from leaving + * an orphan row behind. */ -export async function recordRoomVisit(db: D1Database, roomId: number): Promise { - await db.prepare('UPDATE room SET visits = visits + 1 WHERE room_id = ?1').bind(roomId).run() +export async function recordRoomVisit( + db: D1Database, + roomId: number, + playerId: number +): Promise { + await db.batch([ + db.prepare('UPDATE room SET visits = visits + 1 WHERE room_id = ?1').bind(roomId), + db + .prepare( + `INSERT INTO interaction (player_id, room_id, last_visited_at) + SELECT ?1, room_id, ?3 FROM room WHERE room_id = ?2 AND is_dorm IS NOT 1 + ON CONFLICT(player_id, room_id) DO UPDATE SET last_visited_at = excluded.last_visited_at` + ) + .bind(playerId, roomId, new Date().toISOString()), + ]) } /** @@ -3268,18 +3290,19 @@ async function toggleInteraction( roomId: number, column: 'cheered' | 'favorited' ): Promise { - const now = new Date().toISOString() // First interaction defaults the toggled column to 1; subsequent calls flip it. + // `last_visited_at` is left alone: only entering the room ({@link recordRoomVisit}) + // is a visit, so a room cheered from a feed stays off the visited history. return toInteraction( await db .prepare( - `INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at) - VALUES (?1, ?2, 1, ?3) + `INSERT INTO interaction (player_id, room_id, ${column}) + VALUES (?1, ?2, 1) ON CONFLICT(player_id, room_id) - DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3 + DO UPDATE SET ${column} = NOT ${column} RETURNING cheered, favorited` ) - .bind(playerId, roomId, now) + .bind(playerId, roomId) .first() ) }