fix a couple room routes

This commit is contained in:
Devin Zuczek
2026-08-03 19:43:38 -04:00
parent 339a91735b
commit af2a2a0683
9 changed files with 390 additions and 60 deletions
+9 -9
View File
@@ -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()
+1 -1
View File
@@ -12,7 +12,7 @@
},
"CurrentAnnouncement": {
"Message": "Server powered by RecFlare",
"MoreInfoUrl": "https://github.com/djdevin/recflare"
"MoreInfoUrl": "https://recflare.net"
},
"InstagramImages": [
{
+4 -6
View File
@@ -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, {
+4 -9
View File
@@ -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>)
+5 -1
View File
@@ -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(),
+14 -6
View File
@@ -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 screens 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 rooms instances — falling back to stored engagement',
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screens',
'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) => {
+152
View File
@@ -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')