diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index f20342c..dd4bfe8 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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() diff --git a/apps/api/static/community-board.json b/apps/api/static/community-board.json index 3a34737..37ba422 100644 --- a/apps/api/static/community-board.json +++ b/apps/api/static/community-board.json @@ -12,7 +12,7 @@ }, "CurrentAnnouncement": { "Message": "Server powered by RecFlare", - "MoreInfoUrl": "https://github.com/djdevin/recflare" + "MoreInfoUrl": "https://recflare.net" }, "InstagramImages": [ { diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index b26b799..94f1be2 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -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, { diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 591e3e3..9f1dd0d 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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) diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 1c5b02f..5abd9f2 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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(), diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 4d6e72a..701fd22 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -504,19 +504,27 @@ const app = new Hono() } ) - // "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) => { diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index af54642..b3a41ec 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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 => + ( + (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 => + (await (await SELF.fetch(`${ORIGIN}/rooms/hot?tag=new&skip=0&take=100`)).json()) as Feed + const names = async (): Promise => (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) => { + 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 => { + 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 => + ((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') diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index bb71e2c..0111d27 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -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> { + 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 diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 567098b..e1a92d0 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -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 { 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 { + const stored = + typeof stats === 'object' && stats !== null ? (stats as Record) : {} + 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> { + const byRoom = new Map() + 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() + 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 +): Promise { + 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 { - 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 { - await attachSubRooms(db, rooms) +/** Hydrate many rooms' `SubRooms` and derived `Stats` (one batched query each). */ +async function hydrateRooms( + db: D1Database, + rooms: Room[], + stats?: Map +): Promise { + 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 | 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 { + const counts = stats.get(roomIdOf(room)) + const stored = room.Stats as Record | 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 { const { results } = await db.prepare('SELECT data FROM room').all() - 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() 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 { const { results } = await db.prepare('SELECT data FROM room').all() 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