mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[match/list] actually record room visits
This commit is contained in:
@@ -219,7 +219,7 @@ const ROW_FEEDS: Record<string, RowFeed> = {
|
|||||||
const PERSONAL_ROW_FEEDS: Record<string, (db: D1Database, accountId: number) => Promise<Room[]>> = {
|
const PERSONAL_ROW_FEEDS: Record<string, (db: D1Database, accountId: number) => Promise<Room[]>> = {
|
||||||
// Rooms the caller has been in, most recently visited first — the "Continue Playing"
|
// 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`,
|
// 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.
|
// row and `rooms` `GET /rooms/visitedby/me` can never disagree about where someone has been.
|
||||||
recentlyvisited: (db, accountId) => getVisitedRooms(db, accountId, 0, LIST_SIZE),
|
recentlyvisited: (db, accountId) => getVisitedRooms(db, accountId, 0, LIST_SIZE),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -687,10 +687,11 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
|||||||
// player landed in, and a matchmake is the only way into a room, so this is the one
|
// 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,
|
// 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
|
// 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
|
// room's `visits` column, which is served as `Stats.VisitCount`, and stamps the
|
||||||
// counter is not worth failing the matchmake over.
|
// 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 {
|
try {
|
||||||
await recordRoomVisit(c.env.DB, roomInstance.roomId)
|
await recordRoomVisit(c.env.DB, roomInstance.roomId, id)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to record room visit', {
|
logger.error('failed to record room visit', {
|
||||||
roomId: roomInstance.roomId,
|
roomId: roomInstance.roomId,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getRoomInstance,
|
getRoomInstance,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
|
recordRoomVisit,
|
||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
ROOM_INVITE_SCHEMA_DDL,
|
ROOM_INVITE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
@@ -756,6 +757,57 @@ describe('public endpoints', () => {
|
|||||||
headers: await bearer('96'),
|
headers: await bearer('96'),
|
||||||
})
|
})
|
||||||
expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20)
|
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 () => {
|
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
PRESENCE_SCHEMA_DDL,
|
PRESENCE_SCHEMA_DDL,
|
||||||
publicStudioBundleFilename,
|
publicStudioBundleFilename,
|
||||||
putUnityAsset,
|
putUnityAsset,
|
||||||
|
recordRoomVisit,
|
||||||
ROOM_INSTANCE_SCHEMA_DDL,
|
ROOM_INSTANCE_SCHEMA_DDL,
|
||||||
ROOM_INVITE_SCHEMA_DDL,
|
ROOM_INVITE_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
@@ -749,19 +750,25 @@ describe('rooms endpoints', () => {
|
|||||||
expect(other).toEqual([])
|
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')
|
const headers = await bearer('779')
|
||||||
|
|
||||||
// No interactions yet → empty array.
|
// No visits yet → empty array.
|
||||||
const empty = (await (
|
const empty = (await (
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers })
|
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me`, { headers })
|
||||||
).json()) as unknown[]
|
).json()) as unknown[]
|
||||||
expect(empty).toEqual([])
|
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/2/interactionby/me/cheer`, { method: 'PUT', headers })
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, { 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 (
|
const body = (await (
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me?skip=0&take=100`, { headers })
|
await SELF.fetch(`${ORIGIN}/rooms/visitedby/me?skip=0&take=100`, { headers })
|
||||||
).json()) as Array<{ RoomId: number }>
|
).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 () => {
|
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')
|
const subject = await bearer('790')
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/2/interactionby/me/cheer`, {
|
await recordRoomVisit(env.DB, 2, 790)
|
||||||
method: 'PUT',
|
await recordRoomVisit(env.DB, 12, 790)
|
||||||
headers: subject,
|
|
||||||
})
|
|
||||||
await SELF.fetch(`${ORIGIN}/rooms/12/interactionby/me/favorite`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: subject,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Anyone with a token reads it — a bare array. Friendship is not required: a
|
// 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.
|
// player's history is as public as the rooms on their profile.
|
||||||
|
|||||||
@@ -2561,8 +2561,9 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
|||||||
// (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no
|
// (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
|
// 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
|
// 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
|
// still left as the blob has it: `interaction.last_visited_at` is stamped per player by
|
||||||
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
|
// {@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). */
|
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
|
||||||
export interface RoomStats {
|
export interface RoomStats {
|
||||||
@@ -2597,9 +2598,30 @@ function storedStats(stats: unknown): Record<string, unknown> {
|
|||||||
* matchmake (see its `enterRoom`), which is the only way a player ever lands in a room.
|
* 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
|
* 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.
|
* 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<void> {
|
export async function recordRoomVisit(
|
||||||
await db.prepare('UPDATE room SET visits = visits + 1 WHERE room_id = ?1').bind(roomId).run()
|
db: D1Database,
|
||||||
|
roomId: number,
|
||||||
|
playerId: number
|
||||||
|
): Promise<void> {
|
||||||
|
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,
|
roomId: number,
|
||||||
column: 'cheered' | 'favorited'
|
column: 'cheered' | 'favorited'
|
||||||
): Promise<Interaction> {
|
): Promise<Interaction> {
|
||||||
const now = new Date().toISOString()
|
|
||||||
// First interaction defaults the toggled column to 1; subsequent calls flip it.
|
// 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(
|
return toInteraction(
|
||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at)
|
`INSERT INTO interaction (player_id, room_id, ${column})
|
||||||
VALUES (?1, ?2, 1, ?3)
|
VALUES (?1, ?2, 1)
|
||||||
ON CONFLICT(player_id, room_id)
|
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`
|
RETURNING cheered, favorited`
|
||||||
)
|
)
|
||||||
.bind(playerId, roomId, now)
|
.bind(playerId, roomId)
|
||||||
.first<InteractionRow>()
|
.first<InteractionRow>()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user