mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix a couple room routes
This commit is contained in:
@@ -2,7 +2,12 @@ import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import { GAME_VERSION, seedRoomWithSubRooms, SUBROOM_SCHEMA_DDL } from '@repo/domain'
|
||||
import {
|
||||
GAME_VERSION,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
@@ -44,14 +49,9 @@ const TEST_ROOMS = [
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table now; getRoomById hydrates from it, so create it and
|
||||
// split each seeded room's subrooms into it (mirrors the rooms worker's 0007 migration).
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"CurrentAnnouncement": {
|
||||
"Message": "Server powered by RecFlare",
|
||||
"MoreInfoUrl": "https://github.com/djdevin/recflare"
|
||||
"MoreInfoUrl": "https://recflare.net"
|
||||
},
|
||||
"InstagramImages": [
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
@@ -60,12 +61,9 @@ beforeAll(async () => {
|
||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, passwordHash: hash }))
|
||||
.run()
|
||||
}
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table; seed the Orientation room and split its subroom into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getRoomInstance,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
@@ -83,15 +84,9 @@ const TEST_ROOMS = [
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS room (
|
||||
data TEXT NOT NULL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
|
||||
)`
|
||||
).run()
|
||||
// The rooms worker's schema (room + interaction) — reading a room aggregates its
|
||||
// cheer/favorite Stats from `interaction`, so both tables have to be here.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Subrooms live in their own table now; seed each room and split its subrooms into it.
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const r of TEST_ROOMS) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
@@ -132,7 +132,11 @@ export const RoomTagDto = z.object({
|
||||
Type: z.int().describe('0 = owner-set, 2 = auto'),
|
||||
})
|
||||
|
||||
/** A room's engagement counters. Nothing increments these yet, so they stay at 0. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const RoomStatsDto = z.object({
|
||||
CheerCount: z.int(),
|
||||
FavoriteCount: z.int(),
|
||||
|
||||
@@ -504,19 +504,27 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// "Hot" rooms feed — public, non-dorm rooms ordered by engagement, optionally
|
||||
// filtered to a single `tag` (e.g. `rro`). Paginated via skip/take (take
|
||||
// defaults to 100). Returns `{ Results, TotalResults }` like search.
|
||||
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
||||
// instances' presence), then stored engagement, optionally filtered to a single
|
||||
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
|
||||
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
|
||||
// to 100). Returns `{ Results, TotalResults }` like search.
|
||||
.get(
|
||||
'/rooms/hot',
|
||||
describeRoute({
|
||||
tags: ['Discovery'],
|
||||
summary: 'The “hot” rooms feed',
|
||||
description: [
|
||||
'Public, non-dorm rooms ordered by engagement, optionally narrowed to a single `tag`',
|
||||
'(the browse screen’s filter chips post one, e.g. `rro`).',
|
||||
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
||||
'presence summed across each room’s instances — falling back to stored engagement',
|
||||
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
|
||||
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
|
||||
].join(' '),
|
||||
parameters: [stringQuery('tag', 'Restrict to rooms carrying this tag'), ...pageParams(100)],
|
||||
parameters: [
|
||||
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||
}),
|
||||
async (c) => {
|
||||
|
||||
@@ -368,6 +368,49 @@ describe('rooms endpoints', () => {
|
||||
expect(body.TotalResults).toBeGreaterThan(body.Results.length)
|
||||
})
|
||||
|
||||
it('GET /rooms/hot ranks rooms by the live presence in their instances', async () => {
|
||||
const feed = async (): Promise<number[]> =>
|
||||
(
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?skip=0&take=100`)).json()) as {
|
||||
Results: Array<{ RoomId: number }>
|
||||
}
|
||||
).Results.map((r) => r.RoomId)
|
||||
|
||||
// Two rooms from the tail of the engagement-ordered feed, so any move to the
|
||||
// front can only come from presence.
|
||||
const before = await feed()
|
||||
const busiest = before[before.length - 1]
|
||||
const quieter = before[before.length - 2]
|
||||
|
||||
// Two players in two different instances of `busiest`, one in `quieter`, plus a
|
||||
// lobby presence (no instance) that must not count for anyone.
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + 900
|
||||
const seed = env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
await env.DB.batch(
|
||||
[
|
||||
{ accountId: 90001, roomInstance: { roomInstanceId: 1000901, roomId: busiest } },
|
||||
{ accountId: 90002, roomInstance: { roomInstanceId: 1000902, roomId: busiest } },
|
||||
{ accountId: 90003, roomInstance: { roomInstanceId: 1000903, roomId: quieter } },
|
||||
{ accountId: 90004, roomInstance: null },
|
||||
].map((p) => seed.bind(JSON.stringify({ ...p, expiresAt })))
|
||||
)
|
||||
|
||||
expect((await feed()).slice(0, 2)).toEqual([busiest, quieter])
|
||||
|
||||
// Expired presence doesn't count — the feed falls back to engagement order.
|
||||
await env.DB.prepare(
|
||||
`UPDATE presence SET data = json_set(data, '$.expiresAt', ?1)
|
||||
WHERE account_id IN (90001, 90002, 90003, 90004)`
|
||||
)
|
||||
.bind(Math.floor(Date.now() / 1000) - 1)
|
||||
.run()
|
||||
expect(await feed()).toEqual(before)
|
||||
|
||||
await env.DB.prepare(
|
||||
'DELETE FROM presence WHERE account_id IN (90001, 90002, 90003, 90004)'
|
||||
).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/hot aliases #recroomoriginal to the rro tag', async () => {
|
||||
const aliased = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=recroomoriginal`)
|
||||
@@ -379,6 +422,66 @@ describe('rooms endpoints', () => {
|
||||
expect(aliased.TotalResults).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('GET /rooms/hot?tag=new serves player-made rooms newest-first (pseudo-tag)', async () => {
|
||||
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||
const feed = async (): Promise<Feed> =>
|
||||
(await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed
|
||||
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||
|
||||
// No room carries a `new` tag, and every seeded room is a Rec Room Original — so
|
||||
// the feed is empty until a player makes something.
|
||||
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
|
||||
|
||||
const seeded: number[] = []
|
||||
const seed = async (room: Record<string, unknown>) => {
|
||||
seeded.push(Number(room.RoomId))
|
||||
await seedRoomWithSubRooms(env.DB, { Accessibility: 1, IsDorm: false, IsRRO: false, ...room })
|
||||
}
|
||||
|
||||
// Two player-made public rooms and one that isn't public.
|
||||
await seed({ RoomId: 9001, Name: 'OlderPlayerRoom', CreatedAt: '2026-07-01T00:00:00Z' })
|
||||
await seed({ RoomId: 9002, Name: 'NewerPlayerRoom', CreatedAt: '2026-07-02T00:00:00Z' })
|
||||
await seed({
|
||||
RoomId: 9003,
|
||||
Name: 'UnlistedPlayerRoom',
|
||||
CreatedAt: '2026-07-03T00:00:00Z',
|
||||
Accessibility: 2,
|
||||
})
|
||||
|
||||
// Newest first, and the non-public room is excluded as it is everywhere else.
|
||||
expect(await feed()).toMatchObject({
|
||||
Results: [{ Name: 'NewerPlayerRoom' }, { Name: 'OlderPlayerRoom' }],
|
||||
TotalResults: 2,
|
||||
})
|
||||
|
||||
// An RRO stays out even when it's the newest room in the database — by the flag,
|
||||
// or by the auto-derived `rro` tag alone.
|
||||
await seed({
|
||||
RoomId: 9004,
|
||||
Name: 'BrandNewRRO',
|
||||
CreatedAt: '2026-07-04T00:00:00Z',
|
||||
IsRRO: true,
|
||||
})
|
||||
await seed({
|
||||
RoomId: 9005,
|
||||
Name: 'TaggedRRO',
|
||||
CreatedAt: '2026-07-05T00:00:00Z',
|
||||
Tags: [{ Tag: 'rro', Type: 2 }],
|
||||
})
|
||||
expect(await names()).toEqual(['NewerPlayerRoom', 'OlderPlayerRoom'])
|
||||
|
||||
// Paging comes off the same order.
|
||||
const page = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=1&take=1`)
|
||||
).json()) as Feed
|
||||
expect(page).toMatchObject({ Results: [{ Name: 'OlderPlayerRoom' }], TotalResults: 2 })
|
||||
|
||||
// Leave the shared feeds as they were for the tests that follow.
|
||||
const ids = seeded.join(',')
|
||||
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1595,6 +1698,55 @@ describe('rooms endpoints', () => {
|
||||
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
|
||||
})
|
||||
|
||||
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
|
||||
type Stats = {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
VisitorCount: number
|
||||
VisitCount: number
|
||||
}
|
||||
// Room 15 (CrimsonCauldron) is untouched by the other interaction tests.
|
||||
const searched = async (): Promise<Stats> => {
|
||||
const body = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/search?query=crimsoncauldron`)
|
||||
).json()) as { Results: Array<{ Stats: Stats }> }
|
||||
return body.Results[0]!.Stats
|
||||
}
|
||||
const direct = async (): Promise<Stats> =>
|
||||
((await (await SELF.fetch(`${ORIGIN}/rooms/15`)).json()) as { Stats: Stats }).Stats
|
||||
const interact = async (player: string, action: string, method: string) =>
|
||||
SELF.fetch(`${ORIGIN}/rooms/15/interactionby/me/${action}`, {
|
||||
method,
|
||||
headers: await bearer(player),
|
||||
})
|
||||
|
||||
// Nobody has interacted with it yet.
|
||||
expect(await searched()).toEqual({
|
||||
CheerCount: 0,
|
||||
FavoriteCount: 0,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
|
||||
// Two players cheer it; one of them also favorites it.
|
||||
await interact('561', 'cheer', 'PUT')
|
||||
await interact('562', 'cheer', 'PUT')
|
||||
await interact('561', 'favorite', 'PUT')
|
||||
|
||||
// Both the search results and the room itself report the aggregate.
|
||||
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.
|
||||
await interact('562', 'cheer', 'DELETE')
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
FavoriteCount: 1,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
||||
type Interaction = { Cheered: boolean; Favorited: boolean }
|
||||
const headers = await bearer('557')
|
||||
|
||||
@@ -152,6 +152,28 @@ export async function countPlayersInInstance(
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Live head-count per ROOM, keyed by room id — the players standing in any of a
|
||||
* room's instances right now. One grouped query rather than a count per room, so
|
||||
* feeds that rank by "who's playing" (the hot feed) stay a single read. Counts
|
||||
* only unexpired presence; rooms nobody is in are simply absent from the map, and
|
||||
* lobby (null-instance) presence is excluded.
|
||||
*/
|
||||
export async function countPlayersByRoom(
|
||||
db: D1Database,
|
||||
now = nowSeconds()
|
||||
): Promise<Map<number, number>> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id AS roomId, COUNT(*) AS n FROM presence
|
||||
WHERE expires_at > ?1 AND room_instance_id IS NOT NULL AND room_id IS NOT NULL
|
||||
GROUP BY room_id`
|
||||
)
|
||||
.bind(now)
|
||||
.all<{ roomId: number; n: number }>()
|
||||
return new Map(results.map((r) => [r.roomId, r.n]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The room instances that expired presence rows still point at — the instances a
|
||||
* player was in when they stopped heartbeating (a crash or a hard quit, where no
|
||||
|
||||
+179
-28
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { Accessibility, Role } from './enums'
|
||||
import { countPlayersByRoom } from './presence-db'
|
||||
|
||||
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
||||
export const ROOM_SCHEMA_DDL: string[] = [
|
||||
@@ -177,6 +178,9 @@ export async function cloneRoom(
|
||||
// client renders a virtual "RRO" tag on the clone.
|
||||
IsRRO: false,
|
||||
Roles: roles,
|
||||
// A fresh room has no engagement of its own — don't inherit the source's counters
|
||||
// (the derived ones are recomputed per read, but the clone is returned as-is here).
|
||||
Stats: storedStats(source.Stats),
|
||||
CreatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
@@ -783,11 +787,13 @@ const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
||||
|
||||
/**
|
||||
* Serialize a room for a full-blob write, dropping any hydrated `SubRooms` so it never
|
||||
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job).
|
||||
* gets denormalized back into the room JSON (subrooms are the `subroom` table's job) and
|
||||
* zeroing the derived engagement counters (those are the `interaction` table's job — see
|
||||
* {@link attachStats}), so a write can't bake a snapshot of them into the blob.
|
||||
*/
|
||||
const serializeRoom = (room: Room): string => {
|
||||
const { SubRooms: _subRooms, ...rest } = room
|
||||
return JSON.stringify(rest)
|
||||
const { SubRooms: _subRooms, Stats: stats, ...rest } = room
|
||||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -854,15 +860,108 @@ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
||||
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
||||
}
|
||||
|
||||
/** Hydrate a single room's `SubRooms` (no-op for null). */
|
||||
// ---- Room stats -----------------------------------------------------------
|
||||
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
|
||||
// stored: they're recomputed on every read, so a cheer shows up immediately and the
|
||||
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
|
||||
// (see {@link serializeRoom}). `VisitorCount`/`VisitCount` are left as the blob has them
|
||||
// — nothing records a visit yet, and `interaction.last_visited_at` is only stamped by the
|
||||
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
|
||||
|
||||
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
|
||||
export interface RoomStats {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
}
|
||||
|
||||
interface RoomStatsRow {
|
||||
room_id: number
|
||||
cheers: number
|
||||
favorites: number
|
||||
}
|
||||
|
||||
/** The counters a room starts life with (and the shape the client expects). */
|
||||
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
|
||||
|
||||
/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
|
||||
const STATS_ID_LIMIT = 90
|
||||
|
||||
/** A room's RoomId, or 0 for a blob without one. */
|
||||
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
|
||||
|
||||
/**
|
||||
* The `Stats` object to persist: whatever the room carried, with the derived counters
|
||||
* back at 0 so the blob never holds a stale copy of them.
|
||||
*/
|
||||
function storedStats(stats: unknown): Record<string, unknown> {
|
||||
const stored =
|
||||
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
|
||||
return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
|
||||
* Restricted to `roomIds` when given (a feed page), otherwise covering every room —
|
||||
* which is also what a page too large to bind gets, since scanning the whole table is
|
||||
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
|
||||
*/
|
||||
export async function getRoomStats(
|
||||
db: D1Database,
|
||||
roomIds?: number[]
|
||||
): Promise<Map<number, RoomStats>> {
|
||||
const byRoom = new Map<number, RoomStats>()
|
||||
if (roomIds && roomIds.length === 0) return byRoom
|
||||
const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
|
||||
const where =
|
||||
ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
|
||||
FROM interaction ${where} GROUP BY room_id`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<RoomStatsRow>()
|
||||
for (const r of results) {
|
||||
byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
|
||||
}
|
||||
return byRoom
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite each room's derived counters from the interaction table, in one query for
|
||||
* the whole batch. Callers that already aggregated (the feeds rank by these counts, so
|
||||
* they need them before paging) pass their map in rather than paying for a second query.
|
||||
*/
|
||||
async function attachStats(
|
||||
db: D1Database,
|
||||
rooms: Room[],
|
||||
stats?: Map<number, RoomStats>
|
||||
): Promise<void> {
|
||||
if (rooms.length === 0) return
|
||||
const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
|
||||
for (const room of rooms) {
|
||||
const counts = byRoom.get(roomIdOf(room))
|
||||
room.Stats = {
|
||||
...storedStats(room.Stats),
|
||||
CheerCount: counts?.CheerCount ?? 0,
|
||||
FavoriteCount: counts?.FavoriteCount ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
|
||||
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
|
||||
if (room) await attachSubRooms(db, [room])
|
||||
if (room) await hydrateRooms(db, [room])
|
||||
return room
|
||||
}
|
||||
|
||||
/** Hydrate many rooms' `SubRooms` in one batched query. */
|
||||
async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
||||
await attachSubRooms(db, rooms)
|
||||
/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */
|
||||
async function hydrateRooms(
|
||||
db: D1Database,
|
||||
rooms: Room[],
|
||||
stats?: Map<number, RoomStats>
|
||||
): Promise<Room[]> {
|
||||
await Promise.all([attachSubRooms(db, rooms), attachStats(db, rooms, stats)])
|
||||
return rooms
|
||||
}
|
||||
|
||||
@@ -1472,20 +1571,51 @@ export async function searchRooms(
|
||||
}
|
||||
}
|
||||
|
||||
/** Engagement score used to order the hot feed (cheers weigh most, then favorites). */
|
||||
function hotScore(room: Room): number {
|
||||
const stats = room.Stats as Record<string, unknown> | null | undefined
|
||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount)
|
||||
/**
|
||||
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
|
||||
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
|
||||
* happens before hydration, so the room blob's copies are still zero at this point.
|
||||
*/
|
||||
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
|
||||
const counts = stats.get(roomIdOf(room))
|
||||
const stored = room.Stats as Record<string, unknown> | null | undefined
|
||||
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
|
||||
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
|
||||
}
|
||||
|
||||
/**
|
||||
* The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
|
||||
* PSEUDO-tag: no room carries it. It means "recently created by a player", so it
|
||||
* selects the non-RRO rooms and orders them newest-first instead of by population.
|
||||
*/
|
||||
const NEW_TAG = 'new'
|
||||
|
||||
/**
|
||||
* True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
|
||||
* virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
|
||||
* carries the tag isn't mistaken for player-made.
|
||||
*/
|
||||
function isRRO(room: Room): boolean {
|
||||
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
|
||||
}
|
||||
|
||||
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
|
||||
function createdAt(room: Room): number {
|
||||
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
|
||||
return Number.isNaN(ts) ? 0 : ts
|
||||
}
|
||||
|
||||
/**
|
||||
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
|
||||
* by engagement and optionally filtered to a single `tag` (with the same aliases
|
||||
* as search). Paginated via skip/take; returns `{ Results, TotalResults }` like
|
||||
* search. Ties (and the all-zero seed data) fall back to RoomId order so paging
|
||||
* is stable. The dataset is small, so this filters/sorts in memory rather than
|
||||
* in SQL.
|
||||
* by how many players are in them RIGHT NOW (live presence summed across the
|
||||
* room's instances), and optionally filtered to a single `tag` (with the same
|
||||
* aliases as search). "Hot" is a live-population feed, so current players lead;
|
||||
* rooms nobody is in — and the all-zero seed data — fall back to the stored
|
||||
* engagement score, then to RoomId order so paging stays stable. Paginated via
|
||||
* skip/take; returns `{ Results, TotalResults }` like search. The dataset is
|
||||
* small, so this filters/sorts in memory rather than in SQL.
|
||||
*
|
||||
* `tag=new` is the one filter that isn't a tag lookup — see {@link NEW_TAG}.
|
||||
*/
|
||||
export async function getHotRooms(
|
||||
db: D1Database,
|
||||
@@ -1499,15 +1629,34 @@ export async function getHotRooms(
|
||||
)
|
||||
|
||||
const t = tag.trim().toLowerCase()
|
||||
if (t === NEW_TAG) {
|
||||
// Newest player-made rooms first; RoomId (which is minted in creation order)
|
||||
// breaks ties so rooms created in the same instant still page stably.
|
||||
const fresh = rooms
|
||||
.filter((r) => !isRRO(r))
|
||||
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
|
||||
return {
|
||||
Results: await hydrateRooms(db, fresh.slice(skip, skip + take)),
|
||||
TotalResults: fresh.length,
|
||||
}
|
||||
}
|
||||
|
||||
if (t !== '') {
|
||||
const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])])
|
||||
rooms = rooms.filter((r) => roomHasAnyTag(r, accepted))
|
||||
}
|
||||
|
||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
||||
const players = await countPlayersByRoom(db)
|
||||
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
|
||||
const stats = await getRoomStats(db)
|
||||
rooms.sort(
|
||||
(a, b) =>
|
||||
playerCount(b) - playerCount(a) ||
|
||||
hotScore(b, stats) - hotScore(a, stats) ||
|
||||
roomIdOf(a) - roomIdOf(b)
|
||||
)
|
||||
return {
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||
TotalResults: rooms.length,
|
||||
}
|
||||
}
|
||||
@@ -1526,13 +1675,14 @@ export async function getRecommendedRooms(
|
||||
take: number
|
||||
): Promise<Room[]> {
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
const stats = await getRoomStats(db)
|
||||
return hydrateRooms(
|
||||
db,
|
||||
parseAll(results)
|
||||
.filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true)
|
||||
.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b))
|
||||
.slice(skip, skip + take)
|
||||
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
|
||||
.slice(skip, skip + take),
|
||||
stats
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1611,7 +1761,7 @@ export async function getSimilarRooms(
|
||||
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
const stats = await getRoomStats(db)
|
||||
|
||||
const scored = parseAll(results)
|
||||
.filter(
|
||||
@@ -1627,12 +1777,12 @@ export async function getSimilarRooms(
|
||||
scored.sort(
|
||||
(a, b) =>
|
||||
b.shared - a.shared ||
|
||||
hotScore(b.room) - hotScore(a.room) ||
|
||||
hotScore(b.room, stats) - hotScore(a.room, stats) ||
|
||||
roomIdOf(a.room) - roomIdOf(b.room)
|
||||
)
|
||||
const rooms = scored.map((x) => x.room)
|
||||
return {
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
||||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
|
||||
TotalResults: rooms.length,
|
||||
}
|
||||
}
|
||||
@@ -1647,7 +1797,6 @@ export async function getSimilarRooms(
|
||||
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
|
||||
const { results } = await db.prepare('SELECT data FROM room').all<RoomRow>()
|
||||
const base = new Set(['base'])
|
||||
const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0)
|
||||
return hydrateRooms(
|
||||
db,
|
||||
parseAll(results)
|
||||
@@ -1722,6 +1871,8 @@ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Pr
|
||||
Roles: [
|
||||
{ AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
],
|
||||
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
|
||||
Stats: storedStats(template?.Stats),
|
||||
CreatedAt: new Date().toISOString(),
|
||||
}
|
||||
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
|
||||
|
||||
Reference in New Issue
Block a user