/** * Room storage on the shared `recflare` D1 database. Each room is a single JSON * blob in the `data` column; queryable fields (RoomId, Name, CreatorAccountId, * IsDorm) are SQLite generated (virtual) columns extracted from that JSON and * indexed. This keeps the room shape flexible while still allowing fast lookups * by id/name/creator — the same JSON-blob pattern `accounts-db` uses. * * `ROOM_SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql` * created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); the * room data is seeded from `apps/rooms/static/ImportRooms.json` by * `migrations/0002_import_rooms.sql`. Tests apply `ROOM_SCHEMA_DDL` then seed the * imported rooms directly. * * This module is the single source of truth for the helpers: the `rooms` worker * (which owns the schema/migrations) uses the read/write set; the `match` worker * uses the room lookups plus the dorm helpers; the `api` worker binds the same * database read-only and uses `getRoomById`. Each imports the subset it needs. */ 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[] = [ `CREATE TABLE IF NOT EXISTS room ( data TEXT NOT NULL, room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) 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, -- Lifetime visit counter (migrations/0011_room_visits.sql, which appends it here): -- bumped once per successful matchmake into the room by {@link recordRoomVisit}, -- and served as the room's \`Stats.VisitCount\`. A real column rather than a field -- in the blob so a visit is one atomic UPDATE that can't lose a concurrent -- read-modify-write of the whole room. visits INTEGER NOT NULL DEFAULT 0, -- The two flags every public feed filters on, alongside \`is_dorm\` -- (migrations/0014_room_listable.sql, which appends them here). Generated like the -- rest so the blob stays the only copy; they exist to be INDEXED — see -- {@link LISTABLE_WHERE}. accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL, exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`, `CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`, `CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`, // PARTIAL index over the public, non-dorm rooms — the only rooms any feed can serve, // and a small minority of the table (most rooms are dorms, one per account). Scanning // it visits those rooms alone instead of every room in the database; see // {@link LISTABLE_WHERE} for why the feeds select on it. // // Indexed on `room_id` because a partial index needs some column to key on and the // feeds all order by it eventually; the WHERE clause is the point, not the key. `CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id) WHERE is_dorm IS NOT 1 AND accessibility = 1`, // A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the // `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips // `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and // `subroom_save` already use, so the two can't drift. // // `tag` is stored lowercased and is the lookup key, which is what lets a tag-filtered // feed (a discovery category row, a `#tag` search) select in SQL instead of parsing // every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the // auto-derived ones like `rro` — echoed back as stored. // // `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag // that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and // draws differently from the rest. It is orthogonal to `type`: the flagged tag is // still an ordinary Type 0 user tag, and a room carries other tags alongside it. `CREATE TABLE IF NOT EXISTS room_tag ( room_id INTEGER NOT NULL, tag TEXT NOT NULL, type INTEGER NOT NULL DEFAULT 0, is_primary_genre INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (room_id, tag) )`, `CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`, // Per-player interaction state with a room (cheered/favorited + last visit). // One row per (player, room); cheer/favorite are toggled in place. `CREATE TABLE IF NOT EXISTS interaction ( player_id INTEGER NOT NULL, room_id INTEGER NOT NULL, cheered INTEGER NOT NULL DEFAULT 0, favorited INTEGER NOT NULL DEFAULT 0, last_visited_at TEXT, PRIMARY KEY (player_id, room_id) )`, // Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player), // so re-banning someone already banned updates their row rather than appending. // `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known // yet (the client sends 0), so nothing interprets it. // // Deliberately NOT in the room's `data` blob: that blob is served to the client // verbatim as the room, and a ban list is not something every reader of a room // should receive. `CREATE TABLE IF NOT EXISTS room_ban ( room_id INTEGER NOT NULL, banned_player_id INTEGER NOT NULL, ban_mask INTEGER NOT NULL DEFAULT 0, banned_by_account_id INTEGER NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (room_id, banned_player_id) )`, `CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`, // Per-room leaderboard definitions (migrations/0016_room_leaderboard.sql). One row per // (room, leaderboard): `leaderboard_id` is the client's slot number — small ordinals // (1, 2, 3…), unique only within the room — so the pair is the key, and re-posting a // slot reconfigures it in place rather than appending. // // Deliberately NOT in the room's `data` blob, same reasoning as `room_ban`: the blob is // served verbatim as the room and the client doesn't read leaderboards off it. `CREATE TABLE IF NOT EXISTS room_leaderboard ( room_id INTEGER NOT NULL, leaderboard_id INTEGER NOT NULL, leaderboard_title TEXT NOT NULL, stat_format INTEGER NOT NULL DEFAULT 0, sort_ascending INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (room_id, leaderboard_id) )`, ] /** * Subroom schema DDL (mirror of migrations/0007_subrooms.sql). Subrooms are * first-class entities with their own globally-unique, autoincrementing id — the * original game mints `SubRoomId` from a single sequence, not per-room — so they * live in their own table rather than inside the room JSON blob. `data` holds the * rest of the subroom's client shape; `sub_room_id`/`room_id` are the authoritative * columns (re-injected over `data` on read). Rooms re-embed their `SubRooms` array * on read (see {@link getRoomById}); nothing persists SubRooms back into the room blob. */ export const SUBROOM_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS subroom ( sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT, room_id INTEGER NOT NULL, data TEXT NOT NULL, current_save_id INTEGER, staged_save_id INTEGER )`, `CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`, // Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a // globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got // their own table in 0007. It HAS to be global because a subroom points at saves by // bare id: `current_save_id` is the live/published save the loader downloads, // `staged_save_id` the creator's unpublished one. Per-subroom numbering would make // every subroom's first save id 1 and those pointers ambiguous. // // `data` holds the save's client shape minus its two id fields; the columns are // authoritative and are re-injected on read, exactly how `subroom` treats its own ids. // A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is // never stored in the subroom blob. // // Part of this DDL rather than its own export: reading a subroom joins this table, so // applying one without the other yields a schema that can't serve a room. `CREATE TABLE IF NOT EXISTS subroom_save ( sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT, sub_room_id INTEGER NOT NULL, data TEXT NOT NULL )`, `CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`, // Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room // owner's permission table for one subroom, keyed by (permission, role) — that pair is // what the client's PUT addresses, and re-sending it overwrites the stored row rather // than appending a second one. // // A row IS an override, which is why the client's `Override` flag is not a column: it's // the checkbox next to the permission, so clearing it deletes the row and the pair falls // back to its default. `value` is the client's string, stored verbatim. // // Deliberately NOT in the subroom's `data` blob: that blob is served to the client // verbatim as part of the room, and these overrides are read on one path only // (`GET /photon_access_token`, where they overwrite the matching default entries). `CREATE TABLE IF NOT EXISTS subroom_permission ( sub_room_id INTEGER NOT NULL, permission TEXT NOT NULL, role INTEGER NOT NULL, type INTEGER NOT NULL DEFAULT 0, value TEXT NOT NULL, PRIMARY KEY (sub_room_id, permission, role) )`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ export type Room = Record /** A room role assignment (the client's RoomRole shape). */ interface RoomRole { AccountId: number Role: number LastChangedByAccountId: number | null InvitedRole: number } /** * Room roles that confer owner-level management of a room: Creator (255) and * CoOwner (30). The reference gates its room-admin actions on this set. (Host and * Moderator are lower tiers and are deliberately excluded.) */ const MANAGE_ROLES: ReadonlySet = new Set([Role.Creator, Role.CoOwner]) /** * Whether an account may manage a room — its creator, or the holder of a * Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate * the reference applies to room-admin actions (editing room data, viewing a room's * live instances). Shared so the `rooms` and `match` workers apply the same check * rather than each re-deriving the role set. */ export function canManageRoom(room: Room, accountId: number): boolean { if (room.CreatorAccountId === accountId) return true const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } /** * Whether an account may MODERATE a room — its creator, or the holder of a role at * Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one * of: a moderator polices who is in the room right now (kicking someone out of an * instance) without being trusted to change the room itself, while everyone who can * manage a room can obviously also police it, so CoOwner and Creator pass here too. * * Host (10) is deliberately below the line: it is the "runs this session" tier, which the * client hands out freely, and a kick is a moderation power rather than a hosting one. */ export function canModerateRoom(room: Room, accountId: number): boolean { if (room.CreatorAccountId === accountId) return true const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator) } /** A player banned from a room (a `room_ban` row). */ export interface RoomBan { RoomId: number BannedPlayerId: number /** The client's `banMask`, stored verbatim — its meaning isn't known yet. */ BanMask: number BannedByAccountId: number CreatedAt: string } interface RoomBanRow { room_id: number banned_player_id: number ban_mask: number banned_by_account_id: number created_at: string } const toRoomBan = (row: RoomBanRow): RoomBan => ({ RoomId: row.room_id, BannedPlayerId: row.banned_player_id, BanMask: row.ban_mask, BannedByAccountId: row.banned_by_account_id, CreatedAt: row.created_at, }) /** * Ban a player from a room, returning the stored ban. One row per (room, player): * re-banning someone already banned rewrites their row with the new mask and issuer * rather than appending a second one, so the call is idempotent. */ export async function banPlayerFromRoom( db: D1Database, roomId: number, bannedPlayerId: number, banMask: number, bannedByAccountId: number ): Promise { const row = await db .prepare( `INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(room_id, banned_player_id) DO UPDATE SET ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5 RETURNING *` ) .bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString()) .first() // RETURNING always yields the upserted row. return toRoomBan(row!) } /** * Lift a player's ban on a room, returning the ban that was removed — or null when * they weren't banned, which lets the caller tell a real unban from a no-op. */ export async function unbanPlayerFromRoom( db: D1Database, roomId: number, bannedPlayerId: number ): Promise { const row = await db .prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *') .bind(roomId, bannedPlayerId) .first() return row ? toRoomBan(row) : null } /** Everyone banned from a room, most recently banned first. */ export async function getRoomBans(db: D1Database, roomId: number): Promise { const { results } = await db .prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC') .bind(roomId) .all() return results.map(toRoomBan) } /** Whether a player is banned from a room. */ export async function isPlayerBannedFromRoom( db: D1Database, roomId: number, playerId: number ): Promise { const row = await db .prepare('SELECT 1 AS hit FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2') .bind(roomId, playerId) .first<{ hit: number }>() return row !== null } /** A room's leaderboard definition — one configured slot (`leaderboard_id` is per-room). */ export interface RoomLeaderboard { RoomId: number LeaderboardId: number LeaderboardTitle: string StatFormat: number SortAscending: boolean } interface RoomLeaderboardRow { room_id: number leaderboard_id: number leaderboard_title: string stat_format: number sort_ascending: number } const toRoomLeaderboard = (row: RoomLeaderboardRow): RoomLeaderboard => ({ RoomId: row.room_id, LeaderboardId: row.leaderboard_id, LeaderboardTitle: row.leaderboard_title, StatFormat: row.stat_format, SortAscending: row.sort_ascending === 1, }) /** * Create or reconfigure one of a room's leaderboard slots, returning the stored * definition. One row per (room, leaderboard): re-posting a slot rewrites its title, * format and direction rather than appending a second row, so the call is idempotent. */ export async function setRoomLeaderboard( db: D1Database, roomId: number, leaderboardId: number, leaderboardTitle: string, statFormat: number, sortAscending: boolean ): Promise { const row = await db .prepare( `INSERT INTO room_leaderboard (room_id, leaderboard_id, leaderboard_title, stat_format, sort_ascending) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(room_id, leaderboard_id) DO UPDATE SET leaderboard_title = ?3, stat_format = ?4, sort_ascending = ?5 RETURNING *` ) .bind(roomId, leaderboardId, leaderboardTitle, statFormat, sortAscending ? 1 : 0) .first() // RETURNING always yields the upserted row. return toRoomLeaderboard(row!) } /** * Remove one of a room's leaderboard slots, returning the definition that was removed — * or null when the slot wasn't configured, which lets the caller tell a real delete * from a no-op. */ export async function deleteRoomLeaderboard( db: D1Database, roomId: number, leaderboardId: number ): Promise { const row = await db .prepare('DELETE FROM room_leaderboard WHERE room_id = ?1 AND leaderboard_id = ?2 RETURNING *') .bind(roomId, leaderboardId) .first() return row ? toRoomLeaderboard(row) : null } /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * name, and the new owner. The clone starts with an empty tag set — the source's * tags (including the `base` template tag) do not carry over, so the owner tags the * clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual * "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's * visibility. Returns the new room, or null when the source isn't in D1 or disallows * cloning. */ export async function cloneRoom( db: D1Database, sourceRoomId: number, name: string, accountId: number ): Promise { const source = await getRoomById(db, sourceRoomId) if (!source || source.CloningAllowed === false) return null const row = await db .prepare('SELECT MAX(room_id) AS maxId FROM room') .first<{ maxId: number | null }>() const newRoomId = (row?.maxId ?? 0) + 1 // Ownership is reset to the cloner — the source room's Roles (its creator and // any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT // carry over, or the clone would still list the template's owner as owner. const roles: RoomRole[] = [ { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, ] const cloned: Room = { ...source, RoomId: newRoomId, Name: name, CreatorAccountId: accountId, IsDorm: false, // Start fresh: drop every tag the source carried (including `base`). Tags: [], // A user clone is not a Rec Room Original — clear the inherited flag, or the // client renders a virtual "RRO" tag on the clone. IsRRO: false, // A brand-new room is unpublished: the owner publishes it by setting the room's // accessibility. Inheriting the source's would put the clone straight into the // public feeds (hot/search/recommendations/similar all key on Accessibility === 1) // the moment it was made — every clone of a PUBLIC source, template or player room. Accessibility: Accessibility.Private, 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(), } // serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are // inserted into the subroom table below with fresh globally-unique ids. await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run() const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : [] const clonedSubRooms: SubRoom[] = [] for (const sub of sourceSubRooms) { clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId })) } cloned.SubRooms = clonedSubRooms // Inherited from the (parsed) source in practice; defaulted here too so a clone is // never the one room shape missing them. attachRoomDtoDefaults(cloned) return cloned } /** Set a room's Description in place (the caller is responsible for the owner check). */ export async function setRoomDescription( db: D1Database, roomId: number, description: string ): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1") .bind(roomId, description) .run() } /** Set a room's Name in place (the caller checks ownership + name uniqueness first). */ export async function setRoomName(db: D1Database, roomId: number, name: string): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1") .bind(roomId, name) .run() } /** Set a room's ImageName in place (the caller is responsible for the owner check). */ export async function setRoomImage( db: D1Database, roomId: number, imageName: string ): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1") .bind(roomId, imageName) .run() } /** * Merge a set of top-level fields into a room's JSON blob and write it back. Used by * the room-settings mutations whose values include booleans (cloning, platform * restrictions) — rewriting the whole blob preserves proper JSON booleans, whereas a * `json_set` bind would store `true`/`false` as `1`/`0`. The caller supplies the * already-loaded, permission-checked room. Returns the updated room. */ export async function updateRoomFields( db: D1Database, roomId: number, room: Room, patch: Record ): Promise { const updated: Room = { ...room, ...patch } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** * Set a target account's room `Role` — updating their existing `Roles` entry or * appending a new one — and stamp `LastChangedByAccountId` with the editor. The * caller supplies the already-loaded room (after its owner/co-owner check) to avoid * a re-read; the whole room JSON is rewritten. Returns the updated room. */ export async function setRoomRole( db: D1Database, roomId: number, targetAccountId: number, role: number, changedByAccountId: number, room: Room ): Promise { const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] const existing = roles.find((r) => r.AccountId === targetAccountId) if (existing) { existing.Role = role existing.LastChangedByAccountId = changedByAccountId } else { roles.push({ AccountId: targetAccountId, Role: role, LastChangedByAccountId: changedByAccountId, InvitedRole: 0, }) } const updated: Room = { ...room, Roles: roles } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** * Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so * toggling one on clears any other main tag. Compared case-insensitively. * * Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the * room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set. */ const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art']) /** * A tag's `Type` — the client's tag CATEGORY, echoed back as stored. * * `user` is what a player types or picks. `auto` is what the client derives about the room * and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`). * A tag's category is orthogonal to whether it is the room's primary genre. */ export const RoomTagType = { user: 0, auto: 1, derived: 2, } as const /** * The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and * they compose: a single request may replace the user tags, add a derived one and move the * genre, and it is applied as one write. */ export interface RoomTagEdit { /** * The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when * it has it, and adding one of {@link MAIN_TAGS} clears the others. */ toggle?: string /** * The whole set of USER tags, replacing every `Type: 0` tag the room carries. The * derived tags (`auto`, `derived`) are not the client's to send and are left alone. */ tags?: string[] /** * Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag, * since the client posts the ones it wants rather than the full set. A tag already on * the room is re-categorised rather than duplicated. */ autoTags?: string[] /** * The tag to flag as the room's genre. Added (as a user tag) when the room lacks it; * every other tag keeps its place and loses the flag. */ primaryGenre?: string } /** A tag's name, lowercased — every comparison in here is case-insensitive. */ const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase() /** * Apply one request's worth of tag changes to a room and store the result. The caller * supplies the already-loaded (owner-checked) room, so nothing is re-read. * * The changes are composed into ONE set and written once: a request naming tags, an auto * tag and a genre is a single state for the room, and applying it in three writes would * let a reader (or a failure) land between them. * * Only `room_tag` is written — the room blob carries no tags at all, so the room row is * left alone. */ export async function applyRoomTagEdit( db: D1Database, roomId: number, room: Room, edit: RoomTagEdit ): Promise { const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : [] // Copies throughout: the room handed in is answered to the client, and the steps below // mutate what they build. let next: RoomTag[] = current.map((t) => ({ ...t })) if (edit.tags !== undefined) { // A SET, not a merge. The posted list is exactly the room's user tags afterwards; a // tag already there keeps its row (and its genre flag, until the genre step below // says otherwise) rather than being deleted and re-added. const posted = new Set(edit.tags.map((t) => t.toLowerCase())) next = [ ...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))), ...edit.tags.map( (tag) => next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user } ), ] } else if (edit.toggle !== undefined) { // The 2023 client has no delete/patch endpoint, so the same call toggles: remove the // tag if present, add it otherwise. Adding a main tag is a radio pick, so it also // clears any other main tag. Removing the flagged tag takes the genre with it, which // is right — the room's genre WAS that tag. const lower = edit.toggle.toLowerCase() const existing = next.findIndex((t) => tagKey(t) === lower) if (existing !== -1) { next = next.filter((_, i) => i !== existing) } else { const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }] } } for (const auto of edit.autoTags ?? []) { const existing = next.find((t) => tagKey(t) === auto.toLowerCase()) // A tag the room already carries is re-categorised in place rather than duplicated — // `tag` is the table's key, so there is only ever one row per name anyway. if (existing) existing.Type = RoomTagType.auto else next.push({ Tag: auto, Type: RoomTagType.auto }) } if (edit.primaryGenre !== undefined) { const lower = edit.primaryGenre.toLowerCase() for (const tag of next) delete tag.IsPrimaryGenre const chosen = next.find((t) => tagKey(t) === lower) // A tag the room already carries keeps its category and simply becomes the genre; // one it doesn't is added as an ordinary user tag. if (chosen) chosen.IsPrimaryGenre = true else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true }) } return storeRoomTags(db, roomId, room, next) } /** * Write a room's whole tag set and answer the room carrying it, lowercased the way the * table holds it — so the caller replies with exactly what a re-read would give, without * paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays * absent (not false) everywhere else. */ async function storeRoomTags( db: D1Database, roomId: number, room: Room, tags: RoomTag[] ): Promise { await setRoomTags(db, roomId, tags) return { ...room, Tags: tags.map((t) => { const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type } if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true return stored }), } } /** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */ export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined { const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] return subRooms.find((s) => s.SubRoomId === subRoomId) } /** Fields from the client's room-save POST body. */ export interface SaveSubRoomDataInput { /** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */ subRoomDataFilename?: string /** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */ subRoomDataHash?: string /** Uploaded blob key for the room-level METADATA blob (a separate upload). */ roomDataFilename?: string /** * The save comment — a description of THIS revision, typed into the client's save box. * It belongs to the save (and shows up in the `…/saves` history); it is not the room's * public description, which only `PUT /rooms/:id/description` sets. */ description?: string persistenceVersion?: number inventionUsage?: string /** Optional baked-asset id; emitted on the save only when present. */ unityAssetId?: string /** * The client's `AutoPublish`. True publishes the save outright (the author wants it * live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and * always publish. */ autoPublish?: boolean } /** * A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene * data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave` * loads nothing, no matter what the (legacy, flat) `DataBlob` field says. */ export type SubRoomDataSave = Record /** * The scene-data blob key the client should download for a subroom. Prefers the * authoritative `CurrentSave.DataBlob` and falls back to the flat `DataBlob` that * subrooms written before `CurrentSave` existed (and the `0001_init.sql` dorm seed) * still carry. Shared so the `match` and `auth` room-instance payloads resolve the * blob the same way the client's own loader does. */ export function subRoomDataBlob(sub: SubRoom | undefined | null): string { const save = sub?.CurrentSave if (save && typeof save === 'object') { const blob = (save as SubRoomDataSave).DataBlob if (typeof blob === 'string' && blob !== '') return blob } return typeof sub?.DataBlob === 'string' ? sub.DataBlob : '' } /** Fields that vary between a real save and one reconstructed from the legacy shape. */ interface BuildSaveInput { subRoomId: unknown dataBlob: string dataBlobHash: string | null persistenceVersion: number savedByAccountId: unknown description: string createdAt: string unityAssetId?: string } /** * Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave` * projection. The four array fields are always empty (we neither resolve nor record * referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when * the save actually carried one, exactly as the reference does. There is deliberately no * `DataBlobHash`: it is commented out of the reference DTO and absent from its output. * * `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving * player's live platform/device, which the save request doesn't carry and we don't track. * * Shared by the save path and the legacy-shape reconstruction so the two can't drift. */ function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave { const save: SubRoomDataSave = { UnitySubAssets: [], ReferencedUnityAssets: [], SubRoomId: input.subRoomId, DataBlob: input.dataBlob, // The client sends `SubRoomData.Hash` (usually null); the room-save response echoes // it as `dataBlobHash`. One observed room payload carries it on `CurrentSave` and // another omits it, so storing it and letting it ride along is the safe reading. DataBlobHash: input.dataBlobHash, ReferencedUnityAssetIds: [], PersistenceVersion: input.persistenceVersion, OMVersion: 0, UgcSubVersion: 0, SavedByAccountId: input.savedByAccountId, SavedOnPlatform: 0, SavedOnDeviceClass: 0, Description: input.description, Tags: [], ModerationState: 0, CreatedAt: input.createdAt, } if (input.unityAssetId) save.UnityAssetId = input.unityAssetId return save } /** * Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob * key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those * subrooms hold real saved content the client cannot see (it reads `CurrentSave` only), * so they get a save of their own rather than reading as never-saved. Mirrors backfill 2 * of migration 0008 — keep the two in sync. * * Returns null when there is genuinely nothing saved, the honest answer for a fresh * subroom. */ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null { const blob = sub.DataBlob if (typeof blob !== 'string' || blob === '') return null const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString() return buildSubRoomSave({ subRoomId: sub.SubRoomId, dataBlob: blob, dataBlobHash: null, persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0, // The legacy shape never recorded who saved; the subroom's creator is the best // available answer (the save path is owner/co-owner gated). savedByAccountId: sub.CreatorAccountId ?? null, description: '', createdAt: savedAt, }) } /** * Persist a room-save against a specific subroom. Everything the save carries belongs to * that subroom's revision — nothing is written to the room. Returns the updated * (hydrated) room AND the save that was just created — the route answers with both — or * null when the room or subroom doesn't exist. * * Whether the save goes live is the client's call: `AutoPublish: true` publishes it * outright, otherwise it becomes the subroom's `staged_save_id` with the live * `current_save_id` untouched, so what players load doesn't change until the room's * creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have * no publish flow in the client. */ export async function saveSubRoomData( db: D1Database, roomId: number, subRoomId: number, accountId: number, input: SaveSubRoomDataInput ): Promise<{ room: Room; save: SubRoomDataSave } | null> { const room = await getRoomById(db, roomId) if (!room) return null // Read off the already-hydrated room rather than re-querying the subroom and its // save — getRoomById has both, and this path is write-heavy enough already. const sub = findSubRoom(room, subRoomId) if (!sub) return null // Populate the subroom's creator on first save — it starts null, and the // client NREs on a null CreatorAccountId. Only the owner reaches this path. if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId // Append a new save row. The blob the loader downloads lives on the save — a subroom // whose current_save_id resolves to nothing loads nothing — so this never touches the // flat DataBlob field. Previous saves stay in the table as history. // // A staged save carries forward from the previous STAGED one when there is one, so a // creator's second edit builds on their first rather than on what's live. const staged = typeof sub.StagedSubRoomDataSaveId === 'number' ? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId) : null const previous = staged ?? (sub.CurrentSave && typeof sub.CurrentSave === 'object' ? (sub.CurrentSave as SubRoomDataSave) : undefined) const priorVersion = previous?.PersistenceVersion const priorBlob = previous?.DataBlob const save = await insertSubRoomSave( db, subRoomId, buildSubRoomSave({ subRoomId, // A save that carries no new blob (e.g. a description-only save) keeps the one // the subroom already loads from. dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''), dataBlobHash: input.subRoomDataHash ?? null, persistenceVersion: input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0), savedByAccountId: accountId, // The save comment — empty string, not null, when the save carries none (the // reference's `roomDesc ?? ""`). description: input.description ?? '', createdAt: new Date().toISOString(), unityAssetId: input.unityAssetId, }) ) const saveId = Number(save.SubRoomDataSaveId) if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage // Nothing here touches the ROOM. A room save is a revision of one SUBROOM, and every // field it carries describes that revision: `Description` is the save comment shown in // the `…/saves` history, `PersistenceVersion` and `InventionUsage` describe the scene // just saved. They used to be copied onto the room as well, which meant each save // silently replaced the room's public description with the save comment. The room's own // fields are edited through their own routes (`PUT /rooms/:id/description` and // friends), so the room row is not rewritten here at all. // Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is // the player's own private space with no publish step in the client, so staging one // would leave their edits permanently invisible. Otherwise stage it and wait for // `publish_save`. One round trip for the rest of the save. const publishNow = input.autoPublish === true || room.IsDorm === true await db.batch([ publishNow ? db .prepare( 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1' ) .bind(subRoomId, saveId) : db .prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1') .bind(subRoomId, saveId), db .prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1') .bind(subRoomId, serializeSubRoom(sub, roomId)), ]) // Re-hydrate so the returned room reflects the just-saved subroom. await attachSubRooms(db, [room]) return { room, save } } /** * Publish one of a subroom's saves by id: make it the `current_save_id` players load. * This is the manual step every non-dorm room save waits on ({@link saveSubRoomData} * only stages). Because it takes an explicit id it doubles as restore-a-save — the id * can be any save in the subroom's history, not just the staged one. * * The staging slot is cleared only when the save being published IS the staged one, so * restoring an older version doesn't silently discard newer unpublished work. * * The id is looked up scoped to the subroom, so one subroom can't publish another's save * (ids are globally unique, so an unscoped lookup would happily resolve). * * Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) / * `unknown_save` (no such save on this subroom). */ export async function publishSubRoomSave( db: D1Database, roomId: number, subRoomId: number, saveId: number ): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> { const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return { ok: false, reason: 'not_found' } if (!(await getSubRoomSaveById(db, subRoomId, saveId))) { return { ok: false, reason: 'unknown_save' } } await db .prepare( `UPDATE subroom SET current_save_id = ?2, staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END WHERE sub_room_id = ?1` ) .bind(subRoomId, saveId) .run() const room = await getRoomById(db, roomId) if (!room) return { ok: false, reason: 'not_found' } return { ok: true, room } } /** Fields from the client's subroom `modify` form (each applied only when supplied). */ export interface ModifySubRoomInput { name?: string accessibility?: number maxPlayers?: number } /** * Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers * (the fields the client's subroom `modify` form carries). Only the supplied fields * are changed; the subroom row is updated in the `subroom` table. Returns the updated * (hydrated) room, or null when the room or subroom doesn't exist. */ export async function modifySubRoom( db: D1Database, roomId: number, subRoomId: number, input: ModifySubRoomInput ): Promise { const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return null if (input.name !== undefined) sub.Name = input.name if (input.accessibility !== undefined) sub.Accessibility = input.accessibility if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers await updateSubRoom(db, sub) return getRoomById(db, roomId) } /** * Clone an existing subroom into a new subroom of the same room, owned by * `accountId`. The copy keeps the source's scene/settings (and its saved data * blobs, so it loads identical content) but gets a fresh globally-unique SubRoomId * minted from the `subroom` table's autoincrement sequence. Returns the updated * (hydrated) room and the new subroom, or null when the room or source subroom * doesn't exist. */ export async function cloneSubRoom( db: D1Database, roomId: number, subRoomId: number, accountId: number ): Promise<{ room: Room; subRoom: SubRoom } | null> { const source = await getSubRoom(db, roomId, subRoomId) if (!source) return null const subRoom = await insertSubRoom(db, roomId, { ...source, CreatorAccountId: accountId }) const room = await getRoomById(db, roomId) if (!room) return null return { room, subRoom } } /** Fallback scene, used only when a room has no existing subroom to inherit from. */ const DEFAULT_SUBROOM_SCENE = '76d98498-60a1-430c-ab76-b54a29b7a163' /** * The scene a brand-new subroom inherits: the room's own first (existing) subroom — * lowest SubRoomId — read from the subroom table. Falls back to the base sandbox scene * only when the room has no subrooms yet. */ async function baseSubRoomScene(db: D1Database, roomId: number): Promise { const row = await db .prepare('SELECT data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id LIMIT 1') .bind(roomId) .first<{ data: string }>() const scene = row ? (JSON.parse(row.data) as SubRoom).UnitySceneId : undefined return typeof scene === 'string' ? scene : DEFAULT_SUBROOM_SCENE } /** * Create a new (empty) subroom in a room, owned by `accountId` and named `name`. It * inherits the room's existing subroom scene (see {@link baseSubRoomScene}) with a clean * save, and gets a fresh globally-unique SubRoomId. Returns the updated (hydrated) room * and the new subroom, or null when the room doesn't exist. */ export async function createSubRoom( db: D1Database, roomId: number, accountId: number, name: string ): Promise<{ room: Room; subRoom: SubRoom } | null> { const room = await getRoomById(db, roomId) if (!room) return null const subRoom = await insertSubRoom(db, roomId, { Name: name, CreatorAccountId: accountId, UnitySceneId: await baseSubRoomScene(db, roomId), MaxPlayers: 4, Accessibility: Accessibility.Unlisted, IsSandbox: true, LastModeratedSaveModerationState: 0, ShouldAutoStageSaves: true, // Nothing saved yet — the first room save mints one and points current_save_id // at it. Until then the subroom reads with `CurrentSave: null`. }) // Refresh the hydrated SubRooms so the returned room includes the one just inserted. await attachSubRooms(db, [room]) return { room, subRoom } } /** * Delete a subroom from a room. Refuses to remove a room's only subroom (that would * leave it with no scene to load). Any saved-data blob the subroom pointed at is left in * R2 (like {@link deleteRoom} leaves a room's images). Returns the updated (hydrated) * room on success, or a reason: `not_found` (no such subroom) / `last_subroom`. */ export async function deleteSubRoom( db: D1Database, roomId: number, subRoomId: number ): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> { const subRooms = await getSubRooms(db, roomId) if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' } if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' } await db.batch([ db .prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') .bind(roomId, subRoomId), // The saves go with it — nothing can reference them once the subroom is gone. // The blobs they point at are left in R2, like a deleted room's images. db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId), // So do its permission overrides — subroom ids are minted from one global // sequence, but leaving orphans would still be dead rows nothing can reach. db.prepare('DELETE FROM subroom_permission WHERE sub_room_id = ?1').bind(subRoomId), ]) const room = await getRoomById(db, roomId) if (!room) return { ok: false, reason: 'not_found' } return { ok: true, room } } interface RoomRow { data: string visits: number } /** * The columns every room read selects. `visits` is authoritative for the room's * `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to * come back with the blob on every read; a join aliases them (`r.data AS data`). */ const ROOM_COLUMNS = 'data, visits' /** * The rooms a public feed may consider, as a SQL predicate: public, not a dorm, and not * opted out of lists — the same test {@link isListable} makes in memory, pushed down so * the blobs of the rooms that fail it never cross the wire. `IS NOT 1` rather than `= 0` * because a blob missing the key extracts as NULL, which the JS `!== true` accepts. * * The feeds all scanned the whole table and threw most of it away: a server's rooms are * mostly DORMS (one per account, private by construction), so a scan read megabytes of * blob to rank a hundred rooms. `idx_room_public` covers the first two terms, so this * visits only the rooms that can actually be served. * * The in-memory filter STAYS wherever this is used. It costs nothing once the set is * small, and it — not the SQL — remains the definition of listable: a blob with a * surprising type in one of these fields (`"1"` for `Accessibility`, say) would satisfy * the column's integer affinity while failing `=== 1` in JS, and the feeds must agree * with {@link isListable} rather than with SQLite. */ const LISTABLE_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_lists IS NOT 1' /** * The wider half of {@link LISTABLE_WHERE}: public and not a dorm, without the * `ExcludeFromLists` term. What SEARCH considers — a room can opt out of the browse feeds * and still be findable by name — so the two searching reads select on this instead. */ const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1' /** * Keys on the client's room DTO that nothing here stores, defaulted on every read so the * key is PRESENT rather than absent — the seed blobs and every room written since predate * them, so they can't come from the data: * * - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so * it is 0 for every room. * - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is * null, which is also what the reference serves for a room that has none. * - `CCU` — concurrent users. No live-population counter exists here, so it is null, which * is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody * is in here" in the browse feeds). * * Defaulted rather than assigned, so a stored value wins if any is ever really written * (a blob keeps whatever `serializeRoom` last put in it). */ function attachRoomDtoDefaults(room: Room): void { room.BoostCount ??= 0 room.CurrentSnapshotId ??= null room.CCU ??= null } /** * Parse a room row: the stored blob with the counters the columns own folded back in. * `visits` is a real column, so a room read straight from the DB carries the live count. */ const parseRow = (row: RoomRow): Room => { const room = JSON.parse(row.data) as Room room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 } attachRoomDtoDefaults(room) return room } const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null) const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow) /** * D1 caps a prepared statement at 100 bound parameters — binding more fails outright with * "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's * array has to respect this, which is easy to miss: a seeded dev database has fewer than a * hundred rooms, so an unchunked query works right up until it meets a real one. */ const MAX_BOUND_PARAMS = 100 /** * Split values into chunks that fit {@link MAX_BOUND_PARAMS}, for the reads whose rows are * too heavy to fetch wholesale (subroom and save blobs) and so have to page through an * `IN (…)` rather than scan. */ function chunkForBinds(values: T[]): T[][] { const chunks: T[][] = [] for (let i = 0; i < values.length; i += MAX_BOUND_PARAMS) { chunks.push(values.slice(i, i + MAX_BOUND_PARAMS)) } return chunks } /** * Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the * placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each * chunk is its own statement. * * Rows come back in chunk order, and each chunk is ordered by whatever `sql` says. Callers * that group by a key stay correct as long as a key's rows can't straddle two chunks — * true for both callers here, which chunk BY that key. */ async function selectInChunks( db: D1Database, ids: number[], sql: (placeholders: string) => string ): Promise { const pages = await Promise.all( chunkForBinds(ids).map((chunk) => db .prepare(sql(chunk.map((_, i) => `?${i + 1}`).join(','))) .bind(...chunk) .all() ) ) return pages.flatMap((page) => page.results) } // ---- Subrooms ------------------------------------------------------------- // Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a // room's `SubRooms` array is reconstructed on read and never stored in the room blob. /** A stored subroom — the parsed JSON blob (its client shape). */ export type SubRoom = Record interface SubRoomRow { sub_room_id: number room_id: number data: string current_save_id: number | null staged_save_id: number | null } /** The columns every subroom read needs — the blob plus its two save pointers. */ const SUBROOM_COLUMNS = 'sub_room_id, room_id, data, current_save_id, staged_save_id' /** * Materialize a subroom row into its client shape, with the columns authoritative. * `CurrentSave` is left undefined here and filled in by {@link attachCurrentSaves} — it * lives in `subroom_save`, and resolving it per row would be a query each. Callers must * go through the helpers below so the key is never missing: the client reads the scene * blob from `CurrentSave` and nowhere else, so a subroom without one loads nothing. */ const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({ ...(JSON.parse(row.data) as SubRoom), SubRoomId: row.sub_room_id, RoomId: row.room_id, // Served from the column, not the blob — the creator's unpublished save (unused for // now, but the client expects the key present). StagedSubRoomDataSaveId: row.staged_save_id, }) /** * Serialize a subroom for storage — drop the id/room columns and the save fields that * are columns or their own table, so the blob never holds a stale copy of either. */ const serializeSubRoom = (sub: SubRoom, roomId: number): string => { const { SubRoomId: _id, RoomId: _room, CurrentSave: _save, StagedSubRoomDataSaveId: _staged, ...rest } = sub return JSON.stringify({ ...rest, RoomId: roomId }) } /** * Serialize a room for a full-blob write, dropping the parts that belong to another table * so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job), * `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement * counters, which are zeroed rather than dropped so the key stays present (the * `interaction` table's job — see {@link attachStats}). * * Because `Tags` is dropped here, a write that means to CHANGE a room's tags has to write * the table itself; passing a room with a new `Tags` array through this silently discards * it. */ const serializeRoom = (room: Room): string => { const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room return JSON.stringify({ ...rest, Stats: storedStats(stats) }) } /** * Fill in each subroom's `CurrentSave` from `subroom_save`, in ONE query for the whole * batch. Every subroom ends up with the key present — null when it points at no save * (never saved) or the pointer dangles — because the client's loader reads it directly. * * `rows` must line up with `subs` positionally; the pointer lives on the row, not the * parsed blob. */ async function attachCurrentSaves( db: D1Database, subs: SubRoom[], rows: SubRoomRow[] ): Promise { const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))] const byId = new Map() if (saveIds.length > 0) { // Chunked: a save row carries a whole scene, so these are fetched by id rather than // scanned, and the id list can exceed D1's bound-parameter cap once enough rooms are // hydrated at once. const results = await selectInChunks( db, saveIds, (placeholders) => `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_data_save_id IN (${placeholders})` ) for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r)) } subs.forEach((sub, i) => { const id = rows[i]!.current_save_id sub.CurrentSave = id == null ? null : (byId.get(id) ?? null) }) } // ---- Room tags ------------------------------------------------------------ // A room's tags live in `room_tag`, not in the room blob (see ROOM_SCHEMA_DDL). The blob // is stripped on write and the array is re-attached on read, so there is exactly one // place a tag is stored — and a tag lookup is an indexed query rather than a scan that // parses every room to ask. /** * One of a room's tags, as the client's room DTO carries it. * * `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre — the key is * left off the others rather than sent as false, which is the shape the client sends and * reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}. */ export interface RoomTag { Tag: string Type: number IsPrimaryGenre?: boolean } interface RoomTagRow { room_id: number tag: string type: number is_primary_genre: number } /** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */ function toRoomTag(row: RoomTagRow): RoomTag { const tag: RoomTag = { Tag: row.tag, Type: row.type } if (row.is_primary_genre) tag.IsPrimaryGenre = true return tag } /** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */ function groupTags(rows: RoomTagRow[]): Map { const byRoom = new Map() for (const row of rows) { const list = byRoom.get(row.room_id) ?? [] list.push(toRoomTag(row)) byRoom.set(row.room_id, list) } return byRoom } /** * Every tag of the given rooms, keyed by RoomId, in ONE query however many rooms are * asked about. An empty `roomIds` reads nothing rather than every tag in the table — an * empty `IN ()` isn't valid SQL, and "no rooms asked about" must not come to mean "all of * them". * * Two shapes, because the callers are two different questions. A page slice fits D1's * 100-parameter cap and is fetched by id. Attaching tags to EVERY room does not fit — and * chunking it would mean ceil(n/100) round trips to answer what one unfiltered read * answers, on a table of three small columns. So past the cap this reads the whole table * and narrows in memory. * * This is what the D1 error "variable number must be between ?1 and ?100" was: the hot feed * attaches tags to every room, so the bound list grew with the database and the query blew * up the moment a server had more than a hundred rooms. * * Tags come back alphabetical, so a room's array is stable between reads. */ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise> { const ids = [...new Set(roomIds)] if (ids.length === 0) return new Map() if (ids.length > MAX_BOUND_PARAMS) { const { results } = await db .prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag') .all() const wanted = new Set(ids) return groupTags(results.filter((row) => wanted.has(row.room_id))) } const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare( `SELECT room_id, tag, type, is_primary_genre FROM room_tag WHERE room_id IN (${placeholders}) ORDER BY tag` ) .bind(...ids) .all() return groupTags(results) } /** * Fill in each room's `Tags` from `room_tag`. Every room ends up with the key PRESENT — * an empty array when it carries none — because the client's DTO has a non-nullable * `Tags` and the blob no longer supplies one. */ async function attachTags(db: D1Database, rooms: Room[]): Promise { const byRoom = await tagsByRoom(db, [...new Set(rooms.map(roomIdOf))]) for (const room of rooms) room.Tags = byRoom.get(roomIdOf(room)) ?? [] } /** * Parse room rows AND attach their tags — the read every scan-then-rank feed starts from. * * Those feeds filter and sort BEFORE they hydrate (ranking a room doesn't need its * subrooms), but several of them rank ON tags, so the tags have to be present earlier than * {@link hydrateRooms} would put them. One extra query for the whole batch. */ async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise { const rooms = parseAll(rows) await attachTags(db, rooms) return rooms } /** * Replace a room's tags with the given set, in one batch. A replace and not a merge: the * only writer ({@link toggleRoomTag}) computes the whole set it wants, so a removed tag is * a write with that tag left out. * * Tags are stored LOWERCASED, which is what makes the index a usable lookup key — every * comparison in this module was already case-insensitive, so nothing downstream can tell * the difference. A room tagged `Horror` reads back `horror`. */ export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise { const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)] for (const { Tag, Type, IsPrimaryGenre } of tags) { statements.push( db .prepare( `INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4` ) .bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0) ) } await db.batch(statements) } /** * A room read narrowed to the rooms carrying EVERY one of `tagSets` — one set per tag the * caller requires, and a room matches a set by carrying ANY tag in it (which is how a * term's aliases work: `#recroomoriginal` accepts `rro`). An empty `tagSets` reads every * room. * * A JOIN driven from `room_tag`, not a `WHERE EXISTS`, and the difference is the whole * point of the table. EXPLAIN QUERY PLAN on the seeded database: * * WHERE EXISTS … SCAN room · SEARCH room_tag USING COVERING INDEX (room_id=? AND tag=?) * JOIN from tags SEARCH room_tag USING INDEX idx_room_tag_tag (tag=?) * SEARCH room USING INDEX idx_rooms_room_id (room_id=?) * * The EXISTS form still walks every room and probes the index once per room, so it costs * what the in-memory filter it replaced cost. The join searches the tag index FIRST and * then looks up only the rooms that matched, which is what makes a category row cheap. */ function roomsByTagsQuery(tagSets: string[][], where = ''): { sql: string; binds: string[] } { // `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link PUBLIC_WHERE}) — // unqualified, which is unambiguous under either shape below. It matters most when // `tagSets` is EMPTY: that branch is the full scan every pseudo-tag feed still runs. const filter = where === '' ? '' : ` WHERE ${where}` if (tagSets.length === 0) return { sql: `SELECT ${ROOM_COLUMNS} FROM room${filter}`, binds: [] } const binds: string[] = [] const joins = tagSets.map((tags, i) => { const placeholders = tags.map((_, j) => `?${binds.length + j + 1}`).join(', ') binds.push(...tags) return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i} ON f${i}.room_id = r.room_id` }) // `data`/`visits` are unqualified but unambiguous: the joined subqueries expose only // `room_id`. return { sql: `SELECT ${ROOM_COLUMNS} FROM room r ${joins.join(' ')}${filter}`, binds } } /** Parse subroom rows and resolve their `CurrentSave` in one batched query. */ async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise { const subs = rows.map(parseSubRoomRow) await attachCurrentSaves(db, subs, rows) return subs } /** Attach each room's `SubRooms` array from the subroom table (one batched query). */ async function attachSubRooms(db: D1Database, rooms: Room[]): Promise { const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n)) if (ids.length === 0) { for (const room of rooms) room.SubRooms = [] return } // Chunked by ROOM, so all of a room's subrooms land in one chunk and stay ordered // relative to each other — the grouping below depends on that. const results = await selectInChunks( db, ids, (placeholders) => `SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` ) const subs = await parseSubRoomRows(db, results) const byRoom = new Map() results.forEach((r, i) => { const list = byRoom.get(r.room_id) ?? [] list.push(subs[i]!) byRoom.set(r.room_id, list) }) for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? [] } // ---- 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}). // // `VisitCount` is neither stored in the blob nor derived: it's the `room.visits` column, // incremented by {@link recordRoomVisit} on each matchmake and read back with the blob // (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. /** 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 counters the * columns/tables own 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, VisitCount: 0 } } /** * Count one visit to a room — the `match` worker calls this on every successful * 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. */ 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() } /** * 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)) // `storedStats` zeroes VisitCount (the blob doesn't own it), so carry over the // value `parseRow` folded in from the `visits` column rather than losing it here. const stats = (room.Stats ?? {}) as Record room.Stats = { ...storedStats(stats), VisitCount: typeof stats.VisitCount === 'number' ? stats.VisitCount : 0, 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 hydrateRooms(db, [room]) return room } /** * Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each). * * `Tags` is re-attached even for the feeds that already did so before ranking * ({@link parseAllWithTags}) — it is one query for the page slice and it guarantees the key * is present on every room this module hands out, whichever path produced it. */ async function hydrateRooms( db: D1Database, rooms: Room[], stats?: Map ): Promise { await Promise.all([ attachSubRooms(db, rooms), attachTags(db, rooms), attachStats(db, rooms, stats), ]) return rooms } /** A single subroom of a room (columns authoritative), or null if it doesn't exist. */ export async function getSubRoom( db: D1Database, roomId: number, subRoomId: number ): Promise { const row = await db .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`) .bind(roomId, subRoomId) .first() if (!row) return null return (await parseSubRoomRows(db, [row]))[0]! } /** All of a room's subrooms, ordered by SubRoomId. */ export async function getSubRooms(db: D1Database, roomId: number): Promise { const { results } = await db .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`) .bind(roomId) .all() return parseSubRoomRows(db, results) } // ---- Subroom saves -------------------------------------------------------- interface SubRoomSaveRow { sub_room_data_save_id: number sub_room_id: number data: string } /** Materialize a save row, with its two id columns authoritative over the blob. */ const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({ ...(JSON.parse(row.data) as SubRoomDataSave), SubRoomDataSaveId: row.sub_room_data_save_id, SubRoomId: row.sub_room_id, }) /** Serialize a save for storage — the id columns own those two fields, not the blob. */ const serializeSubRoomSave = (save: SubRoomDataSave): string => { const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save return JSON.stringify(rest) } /** * Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from * the table's autoincrement sequence. Returns the stored save with its new id. */ async function insertSubRoomSave( db: D1Database, subRoomId: number, save: SubRoomDataSave ): Promise { const row = await db .prepare( 'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id' ) .bind(subRoomId, serializeSubRoomSave(save)) .first<{ sub_room_data_save_id: number }>() return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId } } /** * A subroom's save history, newest first. Unlike the old inline model this is real * history: every save is its own row and none are overwritten. */ export async function getSubRoomSaves( db: D1Database, subRoomId: number ): Promise { const { results } = await db .prepare( `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC` ) .bind(subRoomId) .all() return results.map(parseSubRoomSaveRow) } /** * A single save by its globally-unique id, scoped to the subroom that owns it (the * restore-a-save lookup). Null when the id is unknown or belongs to another subroom. */ export async function getSubRoomSaveById( db: D1Database, subRoomId: number, saveId: number ): Promise { const row = await db .prepare( `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2` ) .bind(saveId, subRoomId) .first() return row ? parseSubRoomSaveRow(row) : null } // ---- Subroom permissions -------------------------------------------------- /** * One entry of a subroom's permission table, in the client's own shape. `Value` is a * STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a * True/False picker carries something else, so it is stored and served verbatim. `Role` * is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role` * identify an entry: the client PUTs the pair it wants changed, and the same pair * overwrites the matching default in the photon access token's table. * * `Override` is the row's own existence, not data: the client's UI is a checkbox ("is * this permission overridden in this subroom?") plus a True/False picker for the value. * Unchecking it means "fall back to the default", so an entry arriving with * `Override: false` DELETES the stored row rather than storing anything. Every stored * entry is therefore an override, and reads always serve `Override: true`. */ export interface RoomPermission { Permission: string Role: number Override: boolean Type: number Value: string } interface RoomPermissionRow { permission: string role: number type: number value: string } const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({ // A stored row IS the override — the table holds nothing else (see RoomPermission). Override: true, Permission: row.permission, Role: row.role, Type: row.type, Value: row.value, }) /** The permission columns, in the order the read/copy statements use. */ const PERMISSION_COLUMNS = 'permission, role, type, value' /** * A subroom's stored permission overrides, in the order they were first set. Empty for a * subroom whose owner has never overridden a permission — the photon access token then * serves its defaults untouched. */ export async function getSubRoomPermissions( db: D1Database, subRoomId: number ): Promise { const { results } = await db .prepare( `SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid` ) .bind(subRoomId) .all() return results.map(toRoomPermission) } /** * Apply permission changes to a subroom, keyed by (`Permission`, `Role`). Only the pairs * supplied are touched; every other stored entry is left alone. * * `Override` decides which way an entry goes, mirroring the checkbox the client draws * next to each permission: true STORES the `Value` for that pair (overwriting whatever * was there), false CLEARS it, so the pair falls back to the photon access token's * default. Clearing a pair that was never overridden is a no-op. */ export async function setSubRoomPermissions( db: D1Database, subRoomId: number, permissions: RoomPermission[] ): Promise { if (permissions.length === 0) return const upsert = db.prepare( `INSERT INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT (sub_room_id, permission, role) DO UPDATE SET type = excluded.type, value = excluded.value` ) const clear = db.prepare( 'DELETE FROM subroom_permission WHERE sub_room_id = ?1 AND permission = ?2 AND role = ?3' ) await db.batch( permissions.map((p) => p.Override ? upsert.bind(subRoomId, p.Permission, p.Role, p.Type, p.Value) : clear.bind(subRoomId, p.Permission, p.Role) ) ) } /** * Copy a subroom's permission overrides onto another subroom — a clone inherits the * source's permission table along with its scene and settings. Replaces any entry the * destination already holds for the same (permission, role). */ async function copySubRoomPermissions( db: D1Database, fromSubRoomId: number, toSubRoomId: number ): Promise { await db .prepare( `INSERT OR REPLACE INTO subroom_permission (sub_room_id, ${PERMISSION_COLUMNS}) SELECT ?2, ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1` ) .bind(fromSubRoomId, toSubRoomId) .run() } /** * Insert a subroom for a room, minting a fresh globally-unique SubRoomId from the * table's autoincrement sequence. Returns the created subroom (with its new id). */ export async function insertSubRoom( db: D1Database, roomId: number, sub: SubRoom ): Promise { const row = await db .prepare('INSERT INTO subroom (room_id, data) VALUES (?1, ?2) RETURNING sub_room_id') .bind(roomId, serializeSubRoom(sub, roomId)) .first<{ sub_room_id: number }>() const subRoomId = row!.sub_room_id const created: SubRoom = { ...sub, SubRoomId: subRoomId, RoomId: roomId, CurrentSave: null, StagedSubRoomDataSaveId: null, } // The permission overrides follow the copy too — they live in their own table (keyed by // the id the caller is cloning FROM), so unlike the rest of the settings they aren't // carried by the blob. A fresh subroom (`createSubRoom`) passes no id and copies nothing. if (typeof sub.SubRoomId === 'number') { await copySubRoomPermissions(db, sub.SubRoomId, subRoomId) } // A copied subroom (room clone, subroom clone) carries the source's save. It gets its // OWN row — a save belongs to exactly one subroom, so sharing the source's id would // make the copy's content follow the source's future saves. if (sub.CurrentSave && typeof sub.CurrentSave === 'object') { const copy = await insertSubRoomSave(db, subRoomId, sub.CurrentSave as SubRoomDataSave) await setCurrentSave(db, subRoomId, Number(copy.SubRoomDataSaveId)) created.CurrentSave = copy } return created } /** Overwrite a subroom's stored data blob in place. */ async function updateSubRoom(db: D1Database, sub: SubRoom): Promise { await db .prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1') .bind(sub.SubRoomId, serializeSubRoom(sub, Number(sub.RoomId))) .run() } /** Point a subroom at its live/published save, clearing any staged one. */ async function setCurrentSave(db: D1Database, subRoomId: number, saveId: number): Promise { await db .prepare( 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1' ) .bind(subRoomId, saveId) .run() } /** * Seed a room together with its subrooms — inserts the room (SubRooms stripped from the * blob) and each embedded subroom into the `subroom` table, preserving explicit ids. Any * subroom carrying a `CurrentSave` gets it inserted into `subroom_save` and pointed at, * mirroring 0008's backfill the way this mirrors 0007's. */ export async function seedRoomWithSubRooms(db: D1Database, room: Room): Promise { const roomId = Number(room.RoomId) const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] await db.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() // `serializeRoom` drops `Tags`, so a seeded room's tags have to go to their own table or // they'd vanish — the same step the migration's backfill takes for the imported rooms. if (Array.isArray(room.Tags) && room.Tags.length > 0) { await setRoomTags(db, roomId, room.Tags as RoomTag[]) } for (const sub of subRooms) { const subRoomId = Number(sub.SubRoomId) await db .prepare('INSERT INTO subroom (sub_room_id, room_id, data) VALUES (?1, ?2, ?3)') .bind(subRoomId, roomId, serializeSubRoom(sub, roomId)) .run() const seeded = sub.CurrentSave ?? legacySubRoomSave(sub) if (seeded && typeof seeded === 'object') { const save = await insertSubRoomSave(db, subRoomId, seeded as SubRoomDataSave) await setCurrentSave(db, subRoomId, Number(save.SubRoomDataSaveId)) } } } /** Look up a single room by its RoomId. */ export async function getRoomById(db: D1Database, roomId: number): Promise { return hydrateRoom( db, parseOne( await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`) .bind(roomId) .first() ) ) } /** * Delete a room and every player's interaction (cheer/favorite/visit) with it, in one * batch. Deliberately leaves transient `room_instance`/`presence` rows (they expire on * their own) and any images taken in the room (those live in the api/img world and * outlast the room). Authorization and removing the room image from the CDN bucket are * the caller's responsibility (see the DELETE /rooms/:id route). */ export async function deleteRoom(db: D1Database, roomId: number): Promise { await db.batch([ db.prepare('DELETE FROM room WHERE room_id = ?1').bind(roomId), db.prepare('DELETE FROM interaction WHERE room_id = ?1').bind(roomId), // Tag rows outlive the blob otherwise, and would keep answering `#tag` searches and // category rows for a room nobody can open. db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId), // Saves and permission overrides first — both are keyed by subroom, so they'd be // unreachable once the subrooms themselves are gone. db .prepare( 'DELETE FROM subroom_save WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)' ) .bind(roomId), db .prepare( 'DELETE FROM subroom_permission WHERE sub_room_id IN (SELECT sub_room_id FROM subroom WHERE room_id = ?1)' ) .bind(roomId), db.prepare('DELETE FROM subroom WHERE room_id = ?1').bind(roomId), ]) } /** Look up a single room by name (case-insensitive exact match). */ export async function getRoomByName(db: D1Database, name: string): Promise { return hydrateRoom( db, parseOne( await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE name_lower = ?1`) .bind(name.toLowerCase()) .first() ) ) } /** * Look up multiple rooms by RoomId. * * Every id is bound into one query, so the CALLER must keep the list within D1's cap of 100 * bound parameters — `/rooms/bulk` rejects a longer request with a 400 rather than have this * split it, since a client asking about more than a hundred rooms at once is asking the * wrong question. */ export async function getRoomsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id IN (${placeholders})`) .bind(...ids) .all() return hydrateRooms(db, parseAll(results)) } /** All rooms created by an account (e.g. their dorm). */ export async function getRoomsByCreator(db: D1Database, accountId: number): Promise { const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1`) .bind(accountId) .all() return hydrateRooms(db, parseAll(results)) } /** * How many rooms an account has made, for the per-account room cap. Dorms don't * count: every player gets one auto-provisioned, so counting it would silently cost * them a slot they never asked for. */ export async function countRoomsByCreator(db: D1Database, accountId: number): Promise { const row = await db .prepare( `SELECT COUNT(*) AS n FROM room WHERE creator_account_id = ?1 AND COALESCE(is_dorm, 0) = 0` ) .bind(accountId) .first<{ n: number }>() return row?.n ?? 0 } /** * Every room an account works on: the ones it CREATED plus the ones whose `Roles` name it * (Host, Moderator or CoOwner). Every role tier counts, unlike {@link canManageRoom}'s * owner-or-co-owner gate: this is "you have a job in this room", not "you may administer * it". * * The creator half used to be excluded — a room's `Roles` carries its creator too, and the * client shows "rooms you own" and "rooms you contribute to" as separate lists, so the * exclusion kept this from repeating `createdby/me`. It also made the list EMPTY for every * account that had only ever built its own rooms, which is most of them, so the screen * behind it showed nothing at all. Repeating `createdby/me` is the better failure, and * overlap is what a client that renders one list wants anyway. * * The dorm stays out, on `ownedby/me`'s reasoning: it is auto-provisioned rather than a * room the player made. A room matching BOTH halves appears once — the roles half is an * EXISTS, not a join. * * Roles live inside the room blob rather than in a table of their own, so the match is a * `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no * rows there rather than erroring, so it only reaches the list if the account created it. */ export async function getContributedRooms(db: D1Database, accountId: number): Promise { const { results } = await db .prepare( `SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1 OR EXISTS ( SELECT 1 FROM json_each(room.data, '$.Roles') AS role WHERE json_extract(role.value, '$.AccountId') = ?1 )` ) .bind(accountId) .all() return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true) } /** * An account's public, non-dorm rooms — the publicly viewable "rooms owned by * " list (excludes private rooms, dorms, and list-excluded rooms). */ export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise { return (await getRoomsByCreator(db, accountId)).filter(isListable) } /** * Rooms the player has favorited (interaction.favorited = 1), most recently * interacted first. Joins the `interaction` table to `rooms`, so a favorited room * no longer in D1 is simply absent. Paginated via skip/take; returns a bare array * of rooms (the client's room-source loaders expect a plain list). */ export async function getFavoritedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { const { results } = await db .prepare( `SELECT r.data AS data, r.visits AS visits FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.favorited = 1 ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** * Rooms the player has visited (an interaction row with a `last_visited_at`), * most recent first. Like favorites, it joins `interaction` to `rooms`, so a * visited room no longer in D1 is simply absent. Paginated via skip/take; returns * a bare array of rooms (the client's room-source loaders expect a plain list). */ export async function getVisitedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { const { results } = await db .prepare( `SELECT r.data AS data, r.visits AS visits FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** A player's interaction state with a room. */ export interface Interaction { Cheered: boolean Favorited: boolean } interface InteractionRow { cheered: number favorited: number } const toInteraction = (row: InteractionRow | null): Interaction => ({ Cheered: row?.cheered === 1, Favorited: row?.favorited === 1, }) /** Read a player's interaction with a room (defaults to all-false if none). */ export async function getInteraction( db: D1Database, playerId: number, roomId: number ): Promise { return toInteraction( await db .prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2') .bind(playerId, roomId) .first() ) } /** Upsert+toggle a single boolean column, returning the resulting interaction. */ async function toggleInteraction( db: D1Database, playerId: number, roomId: number, column: 'cheered' | 'favorited' ): Promise { const now = new Date().toISOString() // First interaction defaults the toggled column to 1; subsequent calls flip it. return toInteraction( await db .prepare( `INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at) VALUES (?1, ?2, 1, ?3) ON CONFLICT(player_id, room_id) DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3 RETURNING cheered, favorited` ) .bind(playerId, roomId, now) .first() ) } /** Toggle the player's cheer on a room, returning the resulting interaction. */ export async function toggleCheer( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'cheered') } /** Toggle the player's favorite on a room, returning the resulting interaction. */ export async function toggleFavorite( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'favorited') } /** * Explicitly clear a single interaction flag on a room (the DELETE counterpart to * the cheer/favorite toggles). Idempotent: only clears an existing interaction row * and never creates one, so clearing a flag on a room the player never interacted * with doesn't add a spurious visited/favorited entry. Returns the interaction. */ async function clearInteraction( db: D1Database, playerId: number, roomId: number, column: 'cheered' | 'favorited' ): Promise { await db .prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) .bind(playerId, roomId) .run() return getInteraction(db, playerId, roomId) } /** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */ export async function removeCheer( db: D1Database, playerId: number, roomId: number ): Promise { return clearInteraction(db, playerId, roomId, 'cheered') } /** Clear the player's favorite on a room (DELETE favorite), returning the interaction. */ export async function removeFavorite( db: D1Database, playerId: number, roomId: number ): Promise { return clearInteraction(db, playerId, roomId, 'favorited') } /** * Search-tag aliases: a queried `#tag` also matches these stored tag names. * The client's pinned filters don't always match how rooms are tagged (e.g. it * searches `recroomoriginal`, but rooms are tagged `rro`). */ const TAG_ALIASES: Record = { recroomoriginal: ['rro'], } /** A room's tag names, lowercased (empty when it has no Tags array). */ function roomTags(room: Room): string[] { const tags = room.Tags if (!Array.isArray(tags)) return [] return tags .map((t) => (t as Record | null)?.Tag) .filter((v): v is string => typeof v === 'string') .map((v) => v.toLowerCase()) } /** True if the room carries any of the given (lowercased) tags. */ function roomHasAnyTag(room: Room, tags: Set): boolean { return roomTags(room).some((t) => tags.has(t)) } /** * Search public, non-dorm rooms. The query is split into terms (space/`+`): * `#tag` terms match the room's Tags; plain terms match the room name * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`. * The rows narrow in SQL — public, non-dorm ({@link PUBLIC_WHERE}) — and the name terms * match in memory over what comes back. * * `#community` is the one tag term that isn't a tag lookup — see {@link COMMUNITY_TAG}. */ export async function searchRooms( db: D1Database, query: string, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const q = query.trim().toLowerCase() if (q === '') return { Results: [], TotalResults: 0 } const terms = q.split(/[\s+]+/).filter(Boolean) // The `#tag` terms narrow in SQL — one EXISTS per term, because a room has to carry // EVERY tag asked for, and each term expands to its aliases (`#recroomoriginal` accepts // `rro`). Only the rooms that survive have their blobs read, which is what `room_tag` is // for: a tag search no longer parses every room in the database to ask. // // `#community` is held out of that query: no room CARRIES the tag (the browse chip posts // it to the hot feed as a pseudo-tag, and the search box sends the same term), so asking // `room_tag` for it matches nothing and the whole search comes back empty. It filters on // who MADE the room instead, below. const tagTerms = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1)) const communityOnly = tagTerms.includes(COMMUNITY_TAG) const tagSets = tagTerms .filter((tag) => tag !== COMMUNITY_TAG) .map((tag) => [tag, ...(TAG_ALIASES[tag] ?? [])]) const { sql, binds } = roomsByTagsQuery(tagSets, PUBLIC_WHERE) const { results } = await db .prepare(sql) .bind(...binds) .all() let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) // The same test the hot feed's `community` chip applies: every room a player made, which // is every room the Coach account doesn't own. It narrows the other terms rather than // replacing them, so `#community horror` is still a name search within player-made rooms. if (communityOnly) rooms = rooms.filter(isPlayerMade) // The plain terms still match in memory: they are substring matches on the name, which // no index helps with. for (const term of terms) { if (term.startsWith('#')) continue rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term)) } return { Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), TotalResults: rooms.length, } } /** * Search suggestions for the box the player is typing in * (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms. * * Everything suggested is something the follow-up `/rooms/search` will actually find, * which is the whole point of the endpoint: a suggestion that returns nothing is worse * than no suggestion. So the candidates are drawn from the two things that search matches * — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public, * non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the * suggestion verbatim searches by tag rather than for a room called "horror". * * A query starting with `#` is asking for tags, so only tags are suggested. Otherwise * names come first (the likelier intent), then tags, and within each, matches that START * with the query come before ones that merely contain it. Ties break alphabetically, so * the same query always suggests the same things in the same order. * * Matching is case-insensitive and suggestions are de-duplicated case-insensitively, but * each is returned in its stored casing — search doesn't care, and the player reads these. * Narrowed in SQL to the same rooms {@link searchRooms} considers; the matching itself is * in memory, like search's. */ export async function autocompleteRoomSearch( db: D1Database, query: string, take: number ): Promise { const q = query.trim().toLowerCase() if (q === '' || take <= 0) return [] const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${PUBLIC_WHERE}`) .all() // Tags attached up front: suggestions are drawn from them, and this reads every candidate // room for its NAME regardless, so the tags cost one extra query rather than a second scan. const rooms = (await parseAllWithTags(db, results)).filter( (r) => r.IsDorm !== true && r.Accessibility === 1 ) const tagQuery = q.startsWith('#') const term = tagQuery ? q.slice(1) : q if (term === '') return [] // Lowercased suggestion → [rank, the casing to serve it in]. Lower rank sorts first. const found = new Map() const offer = (value: string, rank: number) => { const key = value.toLowerCase() const existing = found.get(key) if (existing === undefined || existing[0] > rank) found.set(key, [rank, value]) } for (const room of rooms) { if (!tagQuery && typeof room.Name === 'string') { const name = room.Name.toLowerCase() if (name.startsWith(term)) offer(room.Name, 0) else if (name.includes(term)) offer(room.Name, 1) } for (const tag of roomTags(room)) { if (tag.startsWith(term)) offer(`#${tag}`, tagQuery ? 0 : 2) else if (tag.includes(term)) offer(`#${tag}`, tagQuery ? 1 : 3) } } return [...found.entries()] .sort(([aKey, [aRank]], [bKey, [bRank]]) => aRank - bRank || aKey.localeCompare(bKey)) .slice(0, take) .map(([, [, value]]) => value) } /** * 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' /** * The browse screen's "Community" chip posts `tag=community`, another PSEUDO-tag no * room carries. It means "made by a player", which here is every room whose creator * isn't the Coach account — the system account that owns the seeded Rec Room rooms. * Unlike {@link NEW_TAG} it only filters: the page keeps the feed's normal * live-population ordering. * * The chip reaches {@link searchRooms} too, as the tag term `#community` — the search box * carries the same word — so both feeds have to know it names no tag. */ const COMMUNITY_TAG = 'community' /** The system account (`Coach`) that owns the seeded first-party rooms. */ const COACH_ACCOUNT_ID = 1 /** * 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'])) } /** * True when the room may appear in a browse or discovery feed at all: public, not a dorm, * and not opted out of lists. Every feed below starts from this, so a room that opts out * cannot come back through a row that forgot to check. */ function isListable(room: Room): boolean { return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromLists !== true } /** * True when the room is a PLAYER's rather than one of this server's stock ones — the same * test {@link COMMUNITY_TAG} applies, since the Coach account owns every seeded room. * * A different question from {@link isRRO}, which asks whether a room is a Rec Room * Original. The two agree on the data as it stands (every seeded room is Coach-owned AND * flagged `rro`), but the discovery rows ask this one: a stock room that was never flagged * still isn't something a player built. */ function isPlayerMade(room: Room): boolean { return room.CreatorAccountId !== COACH_ACCOUNT_ID } /** 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 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 listable filter runs in * SQL ({@link LISTABLE_WHERE}); the ranking is in memory. * * `tag=new` and `tag=community` are the filters that aren't tag lookups — see * {@link NEW_TAG} and {@link COMMUNITY_TAG}. */ export async function getHotRooms( db: D1Database, tag: string, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const t = tag.trim().toLowerCase() // A REAL tag narrows in SQL — this is the query `room_tag` exists for, and the one a // discovery category row runs: only the rooms carrying the tag have their blobs read. // The two pseudo-tags below name no tag at all, so they still scan. const isPseudo = t === '' || t === NEW_TAG || t === COMMUNITY_TAG const { sql, binds } = roomsByTagsQuery( isPseudo ? [] : [[t, ...(TAG_ALIASES[t] ?? [])]], LISTABLE_WHERE ) const { results } = await db .prepare(sql) .bind(...binds) .all() let rooms = (await parseAllWithTags(db, results)).filter(isListable) 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, } } // `community` is a pseudo-tag: it filters on who MADE the room rather than on any tag, // so it can't be pushed into the tag query above. A real tag already narrowed there. if (t === COMMUNITY_TAG) { rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID) } 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), stats), TotalResults: rooms.length, } } /** * When each room's live scene was last PUBLISHED, as epoch millis keyed by RoomId: the * newest `CurrentSave.CreatedAt` across the room's subrooms. * * Published, not merely saved. A staged save bumps the subroom's `DataSavedAt` but changes * nothing anyone else can load, so ordering by that would float rooms whose visible content * never moved — only `current_save_id`, what the loader actually serves, counts here. * * A room with no published save is ABSENT from the map rather than mapped to 0, so the * caller can tell "never published" from "published at the epoch" and choose its own * fallback. */ async function lastPublishedAtByRoom(db: D1Database): Promise> { // `json_extract` rather than parsing the row: a save blob carries the whole scene and // only its timestamp is wanted, so the DataBlob never has to cross the wire. const { results } = await db .prepare( `SELECT s.room_id AS room_id, json_extract(sv.data, '$.CreatedAt') AS created_at FROM subroom s JOIN subroom_save sv ON sv.sub_room_data_save_id = s.current_save_id` ) .all<{ room_id: number; created_at: string | null }>() const latest = new Map() for (const row of results) { const ts = typeof row.created_at === 'string' ? Date.parse(row.created_at) : NaN if (Number.isNaN(ts)) continue const seen = latest.get(row.room_id) if (seen === undefined || ts > seen) latest.set(row.room_id, ts) } return latest } /** * The "recently updated" discovery row: listable, player-made rooms ordered by when their * live scene was last PUBLISHED, newest first. * * The Coach account's rooms are left out for the reason {@link COMMUNITY_TAG} leaves them * out — they are this server's stock rooms, and a row about what people have been building * should not be a row about the seed data. * * A room that has never published a save falls back to its own `CreatedAt`: creating a room * IS its first update, and on a fresh server that is the only timestamp any room has, so * dropping them would leave the row empty. RoomId — minted in creation order — breaks ties * newest-first so paging stays stable. * * Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. The listable * filter runs in SQL ({@link LISTABLE_WHERE}); the ranking is in memory. */ export async function getRecentlyUpdatedRooms( db: D1Database, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const rooms = parseAll(results).filter((r) => isListable(r) && isPlayerMade(r)) const published = await lastPublishedAtByRoom(db) const updatedAt = (r: Room): number => published.get(roomIdOf(r)) ?? createdAt(r) rooms.sort((a, b) => updatedAt(b) - updatedAt(a) || roomIdOf(b) - roomIdOf(a)) return { Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), TotalResults: rooms.length, } } /** * The "new" discovery row: listable, player-made rooms newest FIRST by creation time. * * Close to the browse screen's `tag=new` chip (see {@link NEW_TAG}) but not the same test: * the chip drops Rec Room Originals, this drops the Coach account's rooms. Both mean * "player-made" and agree on the data as it stands — the discovery rows deliberately all * use ownership ({@link isPlayerMade}) so one row cannot include a room its sibling row * excludes. * * Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. RoomId breaks * ties, newest first, so rooms created in the same instant still page stably. */ export async function getNewRooms( db: D1Database, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const rooms = parseAll(results) .filter((r) => isListable(r) && isPlayerMade(r)) .sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a)) return { Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), TotalResults: rooms.length, } } /** * Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked * by engagement (same score as the hot feed). Unlike the hot feed this returns a * bare array — the client's recommendation room-source loader expects a plain * list, like the other `*by/me`/base sources. The `splitTest*` A/B params the * client passes don't change the result. Paginated via skip/take; the listable filter runs * in SQL ({@link LISTABLE_WHERE}); the ranking is in memory. */ export async function getRecommendedRooms( db: D1Database, skip: number, take: number ): Promise { const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const stats = await getRoomStats(db) return hydrateRooms( db, parseAll(results) .filter(isListable) .sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take), stats ) } /** * Trending ("rising") rooms — the listable rooms someone is standing in RIGHT NOW, busiest * first. What the `rising` carousel is filled from. * * This is the one feed where live presence FILTERS rather than merely ranks: the hot feed * sorts by head-count but still lists the empty rooms underneath it, and a carousel of * rooms nobody is in is not trending. So a quiet server serves an EMPTY carousel rather * than falling back to stored engagement — a room with no one in it has not risen. * * Ties break the way the hot feed's do (stored engagement, then RoomId), so equally busy * rooms page stably. */ export async function getTrendingRooms( db: D1Database, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const players = await countPlayersByRoom(db) // Nobody anywhere: nothing can be trending, and the room table needn't be read at all. if (players.size === 0) return { Results: [], TotalResults: 0 } const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const stats = await getRoomStats(db) const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0 const rooms = parseAll(results) .filter((r) => isListable(r) && playerCount(r) > 0) .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), stats), TotalResults: rooms.length, } } /** Compact room projection carried by a featured-room group. */ export interface FeaturedRoom { RoomId: number RoomName: string ImageName: string IsRecRoomApproved: boolean ExcludeFromLists: boolean ExcludeFromSearch: boolean } /** A time-boxed group of featured rooms, as returned by `/featuredrooms/current`. */ export interface FeaturedRoomGroup { FeaturedRoomGroupId: number name: string StartAt: string EndAt: string Rooms: FeaturedRoom[] } /** How many rooms one featured group carries. See {@link getFeaturedRooms}. */ export const FEATURED_ROOM_LIMIT = 10 /** * Featured rooms group: public, non-dorm rooms not excluded from lists, in random * order. There's no editorial curation behind this yet, so "featured" is just a * random shuffle of the eligible rooms wrapped in a single always-active group. * Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the rest is in memory. * * At most {@link FEATURED_ROOM_LIMIT} rooms — a featured group is a short editorial * selection, not the whole room list. The cap is applied AFTER the shuffle, so it is a * random SAMPLE that varies between requests; a `LIMIT` in the SQL would instead pin the * same handful of rooms forever and make the shuffle cosmetic. */ export async function getFeaturedRooms(db: D1Database): Promise { const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const rooms = parseAll(results).filter(isListable) // Fisher–Yates shuffle so the feed varies between requests. for (let i = rooms.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[rooms[i], rooms[j]] = [rooms[j], rooms[i]] } rooms.length = Math.min(rooms.length, FEATURED_ROOM_LIMIT) const str = (v: unknown): string => (typeof v === 'string' ? v : '') const num = (v: unknown): number => (typeof v === 'number' ? v : 0) return { FeaturedRoomGroupId: 1, name: 'Featured Rooms', StartAt: '2025-12-01T11:01:00Z', EndAt: '9999-12-08T11:00:00Z', Rooms: rooms.map((r) => ({ RoomId: num(r.RoomId), RoomName: str(r.Name), ImageName: str(r.ImageName), IsRecRoomApproved: r.IsRecRoomApproved === true, ExcludeFromLists: r.ExcludeFromLists === true, ExcludeFromSearch: r.ExcludeFromSearch === true, })), } } /** * Rooms similar to a target room: public, non-dorm rooms (excluding the target) * that share at least one tag with it, ranked by shared-tag count then * engagement. Returns a paginated `{ Results, TotalResults }` (the client's * RoomSimilarity source expects an object, not a bare array); empty if the target * isn't in D1 or is untagged. Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the * tag ranking is in memory. */ export async function getSimilarRooms( db: D1Database, roomId: number, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const empty = { Results: [] as Room[], TotalResults: 0 } const target = await getRoomById(db, roomId) if (!target) return empty const targetTags = new Set(roomTags(target)) if (targetTags.size === 0) return empty const { results } = await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length const stats = await getRoomStats(db) // Ranking is by SHARED TAG COUNT, so every candidate needs its tags before the sort — // not a filter one tag can narrow, since "shares any tag with the target" is the whole // candidate set. const scored = (await parseAllWithTags(db, results)) .filter( (r) => roomIdOf(r) !== roomId && r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) .map((room) => ({ room, shared: sharedCount(room) })) .filter((x) => x.shared > 0) scored.sort( (a, b) => b.shared - a.shared || 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), stats), TotalResults: rooms.length, } } /** * "Base" rooms — the template rooms tagged `base` that the client offers as * starting points when creating a room. Unlike the public feeds these are * returned regardless of accessibility (most base rooms aren't publicly listed). * Ordered by RoomId for stable paging. Paginated via skip/take; returns a bare * array. Small dataset, so done in memory. */ export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise { // Selected in SQL off the tag index — a handful of rooms out of the whole table, so this // is the clearest case for narrowing before the blobs are read. const { sql, binds } = roomsByTagsQuery([['base']]) const { results } = await db .prepare(sql) .bind(...binds) .all() return hydrateRooms( db, parseAll(results) .sort((a, b) => roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take) ) } /** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */ const DORM_TEMPLATE_ROOM_ID = 1 /** A player's username from the shared accounts table (for naming their dorm), or null. */ export async function getUsername(db: D1Database, accountId: number): Promise { const row = await db .prepare('SELECT data FROM account WHERE account_id = ?1') .bind(accountId) .first<{ data: string }>() if (!row) return null const account = JSON.parse(row.data) as { username?: string } return typeof account.username === 'string' ? account.username : null } /** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */ export async function getDormRoom(db: D1Database, accountId: number): Promise { return hydrateRoom( db, parseOne( await db .prepare( `SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1` ) .bind(accountId) .first() ) ) } /** * The player's personal dorm room, created on first access. Cloned from the * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so * matchmaking routes them into their own dorm and they can save it via the * owner-gated room-save. Idempotent: returns the existing dorm once created. * * NOTE: this is the one place the match worker writes to the rooms table (the * `rooms` worker otherwise owns the schema). */ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise { const existing = await getDormRoom(db, accountId) if (existing) return existing const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID) const idRow = await db .prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room') .first<{ next: number }>() const roomId = idRow?.next ?? 2 // Reuse the template's subroom (scene/capacity), owned by the player, starting // from a clean save. Fall back to the base dorm scene if the template is absent. const templateSub = template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0 ? (template.SubRooms[0] as Record) : { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 } // Named after the owner: `@'s Dorm` (falls back to the account id). const username = (await getUsername(db, accountId)) ?? `Player${accountId}` const room: Room = { ...(template ?? { Accessibility: Accessibility.Unlisted }), RoomId: roomId, Name: `@${username}'s Dorm`, CreatorAccountId: accountId, IsDorm: true, 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 // subroom is inserted into the subroom table below with a fresh globally-unique id. await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run() const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId }) room.SubRooms = [subRoom] // The template carries these (it was parsed), but a dorm minted without one wouldn't. attachRoomDtoDefaults(room) return room }