/** * 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 { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds' import { Accessibility, Role } from './enums' import { countPlayersByRoom } from './presence-db' import { resyncInstanceCapacity } from './room-instance-db' import { bakedStudioUnityAssets, isMissingStudioAssetTable, listStudioUnityAssetFiles, } from './studio-unity-assets' import type { StudioUnityAssetFile } from './studio-unity-assets' /** 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 access (migrations/0021_interaction_room_index.sql): the primary key // serves the per-player reads, but the cheer/favorite aggregate every room read runs // (`getRoomStats`) filters by room_id, which without this scanned the whole table. // Covering — both counters are in the index, so the aggregate never touches the table. `CREATE INDEX IF NOT EXISTS idx_interaction_room ON interaction (room_id, cheered, favorited)`, // 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, -- migrations/0018_room_ban_reason_expiry.sql. NULL expires_at is a permanent ban. reason TEXT, expires_at TEXT, PRIMARY KEY (room_id, banned_player_id) )`, `CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`, // Room bans that have ENDED (migrations/0019_room_ban_history.sql). `room_ban` above holds // only the ban in force — one row per (room, player), deleted on unban and overwritten on // re-ban — so without this a lifted or lapsed ban leaves no trace. A row is written here // at the moment a ban stops being the live one: when it is lifted (`status` 2, Lifted, // `ended_at` the lift and `unbanned_by_account_id` who lifted it) or when a lapsed ban is // replaced by a new one (`status` 1, Elapsed, `ended_at` its expiry). Append-only. `CREATE TABLE IF NOT EXISTS room_ban_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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, reason TEXT, expires_at TEXT, status INTEGER NOT NULL, ended_at TEXT, unbanned_by_account_id INTEGER )`, `CREATE INDEX IF NOT EXISTS idx_room_ban_history_player ON room_ban_history (room_id, 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) )`, // Per-room, per-player variable data (migrations/0020_playerdata.sql) — what the client // saves through `PUT /rooms/:id/playerdata/me` and reads back from the GET. One row per // (room, player); `data` is the posted blob verbatim (a base64 string the server never // decodes), and a re-save overwrites it, so only each player's latest is kept. `CREATE TABLE IF NOT EXISTS playerdata ( room_id INTEGER NOT NULL, player_id INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY (room_id, player_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) )`, // Which inventions a room has in it (migrations/0023_room_invention.sql): one row per // (room, invention), re-derived on every room save from the `InventionUsage` each of the // room's subrooms last reported — see {@link syncRoomInventions}. The second index is // the reverse lookup, "which rooms use this invention" — what an invention's // `NumPlayersHaveUsedInRoom` is counted from. `CREATE TABLE IF NOT EXISTS room_invention ( room_id INTEGER NOT NULL, invention_id INTEGER NOT NULL, PRIMARY KEY (room_id, invention_id) )`, `CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id)`, ] /** 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). */ export interface RoomRole { AccountId: number Role: number LastChangedByAccountId: number | null InvitedRole: number } /** * A room's `Roles`, always as whole {@link RoomRole} records. Roles live inside the room * blob, and the older rooms either have no `Roles` key at all or carry entries written * before `LastChangedByAccountId`/`InvitedRole` existed; every reader wants the complete * shape, so the missing keys are filled in here rather than at each call site. Returns * copies, so a caller that edits an entry must write the returned array back to the room. */ export function roomRoles(room: Room): RoomRole[] { const roles = Array.isArray(room.Roles) ? (room.Roles as Array>) : [] return roles.map((r) => ({ AccountId: Number(r.AccountId ?? 0), Role: Number(r.Role ?? Role.None), LastChangedByAccountId: r.LastChangedByAccountId ?? null, InvitedRole: Number(r.InvitedRole ?? Role.None), })) } /** * 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 return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } /** * Whether an account may manage the room with this id — {@link canManageRoom} against a room * this reads for itself. Null when there is no such room, which a caller answers differently * (a 404) from a room somebody else runs (a 403). * * Reads the room BLOB only, with none of {@link getRoomById}'s hydration. Ownership lives in * `CreatorAccountId` and `Roles`, both of which are in the blob; the subrooms, tags and stats * that hydration attaches are three more tables a caller would otherwise have to have just to * ask who runs a room. That matters for the workers outside `rooms` that gate on room * ownership — `econ` minting a room currency does not know what a subroom is. */ export async function canManageRoomById( db: D1Database, roomId: number, accountId: number ): Promise { const room = parseOne( await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`) .bind(roomId) .first() ) if (!room) return null return canManageRoom(room, accountId) } /** * Account ids that co-own a room: its creator, and every `Roles` entry at Creator or * CoOwner. Null when the room does not exist. A pending invite is not a co-owner yet * (`Role` stays None until they accept), and Host / Moderator are not in this set — * the same ownership set as {@link canManageRoom}. Ordered by account id. * * Reads the blob only, like {@link canManageRoomById}. */ export async function getRoomCoOwnerIds(db: D1Database, roomId: number): Promise { const room = parseOne( await db .prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`) .bind(roomId) .first() ) if (!room) return null const ids = new Set() const creator = Number(room.CreatorAccountId) if (Number.isSafeInteger(creator) && creator > 0) ids.add(creator) for (const role of roomRoles(room)) { if (role.AccountId > 0 && MANAGE_ROLES.has(role.Role)) ids.add(role.AccountId) } return [...ids].sort((a, b) => a - b) } /** * 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 return roomRoles(room).some((r) => r.AccountId === accountId && r.Role >= Role.Moderator) } /** * Whether an account OWNS a room — its `CreatorAccountId`, or the holder of the Creator * (255) role. The narrowest of the three room gates: {@link canManageRoom} lets a CoOwner * through and {@link canModerateRoom} a Moderator, but handing out co-ownership is the * owner's alone, or a co-owner could quietly grow the set of people who can change the * room, an offer the invited player then accepts themselves. */ export function isRoomOwner(room: Room, accountId: number): boolean { if (room.CreatorAccountId === accountId) return true return roomRoles(room).some((r) => r.AccountId === accountId && r.Role === Role.Creator) } /** 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 /** Free text from whoever issued the ban. Null when none was given. */ Reason: string | null /** When the ban lapses (ISO-8601 UTC). Null is a permanent ban, lifted only by DELETE. */ ExpiresAt: string | null } interface RoomBanRow { room_id: number banned_player_id: number ban_mask: number banned_by_account_id: number created_at: string reason: string | null expires_at: string | null } 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, Reason: row.reason, ExpiresAt: row.expires_at, }) /** * The SQL that keeps a ban row in force: permanent, or not yet lapsed. An expired row is not * deleted when it lapses — nothing runs at that moment — so EVERY read that asks "is this * player banned" has to apply this, or a 30-minute ban would last forever. the time is bound * rather than using SQLite's clock so it compares in the same ISO-8601 format the rows are * written in (`datetime('now')` has no `T` or `Z` and would sort wrongly against them). * `now` names the placeholder the caller binds the current time to. */ const banInForce = (now: string): string => `(expires_at IS NULL OR expires_at > ${now})` /** Where a ban stands, as the client's `Status` enum numbers it. */ export const RoomBanStatus = { /** In force now. Only ever the `room_ban` row. */ Active: 0, /** Ran out on its own. */ Elapsed: 1, /** Ended early by someone unbanning the player. */ Lifted: 2, } as const /** * One ban in a player's history, whatever state it is in. Timestamps are ISO-8601 as stored. * `EndedAt` is when the ban stopped counting — its expiry if it lapsed, the moment it was * lifted if it was lifted — and for an Active ban the expiry it is scheduled to reach, null * when it is permanent. */ export interface RoomBanRecord { Status: (typeof RoomBanStatus)[keyof typeof RoomBanStatus] UnbannedByAccountId: number | null EndedAt: string | null Reason: string | null AccountId: number BannedByAccountId: number StartedAt: string } interface RoomBanHistoryRow { banned_player_id: number banned_by_account_id: number created_at: string reason: string | null status: RoomBanRecord['Status'] ended_at: string | null unbanned_by_account_id: number | null } /** * A player's ban history in one room: the ban in force, if any, and every ban that has ended, * newest first. * * A `room_ban` row that has LAPSED is not active — but nothing moves it into * `room_ban_history` until the player is banned or unbanned again, so it is reported here as * an Elapsed previous ban rather than dropped. Only the row still in force is `active`. */ export async function getRoomBanHistory( db: D1Database, roomId: number, playerId: number ): Promise<{ active: RoomBanRecord | null; previous: RoomBanRecord[] }> { const now = new Date().toISOString() const [current, ended] = await db.batch([ db .prepare('SELECT * FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2') .bind(roomId, playerId), db .prepare( `SELECT * FROM room_ban_history WHERE room_id = ?1 AND banned_player_id = ?2 ORDER BY created_at DESC, id DESC` ) .bind(roomId, playerId), ]) const previous = (ended.results as RoomBanHistoryRow[]).map((row): RoomBanRecord => ({ Status: row.status, UnbannedByAccountId: row.unbanned_by_account_id, EndedAt: row.ended_at, Reason: row.reason, AccountId: row.banned_player_id, BannedByAccountId: row.banned_by_account_id, StartedAt: row.created_at, })) const row = (current.results as RoomBanRow[])[0] if (!row) return { active: null, previous } const inForce = row.expires_at === null || row.expires_at > now const record: RoomBanRecord = { Status: inForce ? RoomBanStatus.Active : RoomBanStatus.Elapsed, UnbannedByAccountId: null, EndedAt: row.expires_at, Reason: row.reason, AccountId: row.banned_player_id, BannedByAccountId: row.banned_by_account_id, StartedAt: row.created_at, } if (inForce) return { active: record, previous } // Newer than anything already filed, since filing it is what a later ban would do. return { active: null, previous: [record, ...previous] } } /** Options for {@link banPlayerFromRoom}. */ export interface RoomBanOptions { /** Shown to nobody yet; kept on the row. Empty is stored as null. */ reason?: string | null /** Minutes until the ban lapses. Absent, null or 0 is permanent. */ durationMinutes?: number | null } /** * 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, issuer, reason and * expiry rather than appending a second one, so the call is idempotent. * * A re-ban REPLACES the duration rather than extending it, and the clock restarts from now: * banning someone for 30 minutes who has 5 left leaves them with 30, and re-banning with no * duration makes the ban permanent. The latest ban issued is the one in force. * * History: if the row being overwritten had already LAPSED, that ban is over and is filed in * `room_ban_history` as Elapsed first. A ban still in force is AMENDED rather than ended — the * player has been banned continuously — so it is overwritten without leaving a history row. * Both statements run as one batch, so the old ban is never lost between them. */ export async function banPlayerFromRoom( db: D1Database, roomId: number, bannedPlayerId: number, banMask: number, bannedByAccountId: number, options: RoomBanOptions = {} ): Promise { const now = new Date() const minutes = options.durationMinutes ?? 0 const expiresAt = minutes > 0 ? new Date(now.getTime() + minutes * 60_000).toISOString() : null const reason = options.reason?.trim() || null const [, upserted] = await db.batch([ db .prepare( `INSERT INTO room_ban_history (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at, status, ended_at, unbanned_by_account_id) SELECT room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at, ?4, expires_at, NULL FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 AND NOT ${banInForce('?3')}` ) .bind(roomId, bannedPlayerId, now.toISOString(), RoomBanStatus.Elapsed), db .prepare( `INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(room_id, banned_player_id) DO UPDATE SET ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5, reason = ?6, expires_at = ?7 RETURNING *` ) .bind( roomId, bannedPlayerId, banMask, bannedByAccountId, now.toISOString(), reason, expiresAt ), ]) // RETURNING always yields the upserted row. return toRoomBan((upserted.results as RoomBanRow[])[0]) } /** * 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. A ban that has * already LAPSED counts as not banned; its stale row is deleted all the same. * * History: the removed ban is filed in `room_ban_history` in the same batch as the delete. A * ban still in force is Lifted, ended now, by `unbannedByAccountId`. A lapsed one is Elapsed, * ended at its expiry, by nobody — deleting the stale row is housekeeping, not a lift. */ export async function unbanPlayerFromRoom( db: D1Database, roomId: number, bannedPlayerId: number, unbannedByAccountId: number ): Promise { const now = new Date().toISOString() const inForce = banInForce('?3') const [, deleted] = await db.batch([ db .prepare( `INSERT INTO room_ban_history (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at, status, ended_at, unbanned_by_account_id) SELECT room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at, CASE WHEN ${inForce} THEN ?5 ELSE ?6 END, CASE WHEN ${inForce} THEN ?3 ELSE expires_at END, CASE WHEN ${inForce} THEN ?4 ELSE NULL END FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2` ) .bind( roomId, bannedPlayerId, now, unbannedByAccountId, RoomBanStatus.Lifted, RoomBanStatus.Elapsed ), db .prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *') .bind(roomId, bannedPlayerId), ]) const row = (deleted.results as RoomBanRow[])[0] if (!row) return null const ban = toRoomBan(row) return ban.ExpiresAt !== null && ban.ExpiresAt <= new Date().toISOString() ? null : ban } /** Everyone CURRENTLY banned from a room, most recently banned first. Lapsed bans are left out. */ export async function getRoomBans(db: D1Database, roomId: number): Promise { const { results } = await db .prepare( `SELECT * FROM room_ban WHERE room_id = ?1 AND ${banInForce('?2')} ORDER BY created_at DESC` ) .bind(roomId, new Date().toISOString()) .all() return results.map(toRoomBan) } /** * Whether a player is banned from a room RIGHT NOW. A timed ban that has lapsed is not a ban. * This is the check `match` refuses matchmakes on, so the expiry has to be applied here. */ 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 AND ${banInForce('?3')}` ) .bind(roomId, playerId, new Date().toISOString()) .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 } /** * A player's saved data for one room — the blob the client posted, verbatim. Null when * the player has never saved anything there. */ export async function getPlayerData( db: D1Database, roomId: number, playerId: number ): Promise { const row = await db .prepare('SELECT data FROM playerdata WHERE room_id = ?1 AND player_id = ?2') .bind(roomId, playerId) .first<{ data: string }>() return row ? row.data : null } /** * Store a player's data for one room, returning what was stored. One row per (room, * player): a re-save overwrites the blob in place rather than keeping history, so the * call is idempotent. */ export async function setPlayerData( db: D1Database, roomId: number, playerId: number, data: string ): Promise { const row = await db .prepare( `INSERT INTO playerdata (room_id, player_id, data) VALUES (?1, ?2, ?3) ON CONFLICT(room_id, player_id) DO UPDATE SET data = ?3 RETURNING data` ) .bind(roomId, playerId, data) .first<{ data: string }>() // RETURNING always yields the upserted row. return row!.data } /** * 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 keeps only the source's SYSTEM tags (`Type` 1, * `RoomTagType.auto` — `beta`, `limitsv2`): the client posts those to describe what the * scene needs to run, so a copy of the scene needs them too. Every other tag (the owner's * `Type` 0 tags, the `base` template tag, the server-derived `rro`) is dropped, so the * owner tags the clone from scratch. `IsRRO` is cleared so the client doesn't render a * virtual "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats` * start at zero, 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: Role.None, }, ] // The system tags (`Type` 1: `beta`, `limitsv2`) carry over — the client derives them // from what the scene is built with and the clone is the same scene, so without them // the copy doesn't work. Nothing else does: no owner tags, no `base`, no `rro`, and no // primary-genre flag (an auto tag is never the genre). const sourceTags = Array.isArray(source.Tags) ? (source.Tags as RoomTag[]) : [] const systemTags: RoomTag[] = sourceTags .filter((t) => t.Type === RoomTagType.auto) .map((t) => ({ Tag: t.Tag, Type: RoomTagType.auto })) const cloned: Room = { ...source, RoomId: newRoomId, Name: name, CreatorAccountId: accountId, IsDorm: false, // Start fresh, except for the system tags the scene needs to run (see below). Tags: systemTags, // 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, // Neither is the cloner a developer, and a copy doesn't inherit the source's consent // to be copied: the new owner opts their room into cloning themselves. IsDeveloperOwned: false, CloningAllowed: 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 any of the source's // counters. `storedStats` alone would keep the blob-owned `VisitorCount`, which // `hotScore` ranks on. Stats: { ...ZERO_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() // Tags live in `room_tag`, not the blob: the system tags need their own write. if (systemTags.length > 0) await setRoomTags(db, newRoomId, systemTags) 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() } /** A room's progression settings, as `GET /rooms/:id/experience` serves them. */ export interface RoomProgression { Enabled: boolean DailyLimit: number } /** * Read a room's progression settings off its blob: `progressionEnabled` and * `progressionDailyLimit`. A room that has never had them set reads as off with a 0 cap. */ export function roomProgression(room: Room): RoomProgression { const limit = room.progressionDailyLimit return { Enabled: room.progressionEnabled === true, DailyLimit: typeof limit === 'number' && Number.isFinite(limit) ? limit : 0, } } /** * Set a room's progression settings in place (the caller checks ownership first). These * are `progressionEnabled` / `progressionDailyLimit` on the blob; the per-player XP is in * `room_xp` (see room-xp-db.ts). */ export async function setRoomProgression( db: D1Database, roomId: number, enabled: boolean, dailyLimit: number ): Promise { await db .prepare( `UPDATE room SET data = json_set(data, '$.progressionEnabled', json(?2), '$.progressionDailyLimit', ?3) WHERE room_id = ?1` ) .bind(roomId, enabled ? 'true' : 'false', dailyLimit) .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() } /** * Stamp `BecameRRStudioRoomAt` the first time a Rec Room Studio build is stored for * this room. A room that already carries a timestamp keeps it: a later local build * is another cloud build, not a new moment of becoming a Studio room. A missing key * and a JSON null both count as unset (`json_type` is not `text`). */ export async function markRoomAsRecRoomStudio( db: D1Database, roomId: number, at: string ): Promise { await db .prepare( `UPDATE room SET data = json_set(data, '$.BecameRRStudioRoomAt', ?2) WHERE room_id = ?1 AND json_type(data, '$.BecameRRStudioRoomAt') IS NOT 'text'` ) .bind(roomId, at) .run() } /** * Point one existing subroom save at a baked Unity asset, in place. Local Studio * builds attach to the save the room is already published from; they do not append * a new save row. Returns false when that save id is not this subroom's. */ export async function setSubRoomSaveUnityAssetId( db: D1Database, subRoomId: number, saveId: number, unityAssetId: string ): Promise { const result = await db .prepare( `UPDATE subroom_save SET data = json_set(data, '$.UnityAssetId', ?3) WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2` ) .bind(saveId, subRoomId, unityAssetId) .run() return (result.meta.changes ?? 0) > 0 } /** * 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 } /** * Whether a role tier can only be taken by INVITATION — offered with * {@link inviteRoomRole} and accepted by the player themselves via * {@link answerRoomRoleInvite} — rather than granted outright. * * {@link Role.CoOwner} is the tier this rule exists for: nobody can force-add a co-owner, * it is always an invite the player accepts. {@link Role.Creator} is included because it is * strictly more than co-ownership — it would make no sense for the bigger grant to be the * one that skips the ceremony. Expressed as `>=` so a tier added between or above them is * covered by default: a new high-privilege role should have to be accepted until someone * decides otherwise, not be grantable because nobody updated this list. */ export function roleRequiresInvite(role: number): boolean { return role >= Role.CoOwner } /** * GRANT a room role outright — updating the target account's existing `Roles` entry or * appending one — and stamp `LastChangedByAccountId` with the editor. This is the owner's * half of role management, and it takes effect immediately: the helper tiers * ({@link Role.Host}, {@link Role.Moderator} and whatever else the client hands out) are the * owner's to give. * * Returns null, writing nothing, for a tier that {@link roleRequiresInvite} — nobody can * force-add a co-owner, whoever is asking. The rule lives HERE rather than in the route that * currently calls this, because it is an invariant of the room's `Roles` and not a policy of * one endpoint: the only way a `Role` of {@link Role.CoOwner} may ever appear on an entry is * {@link answerRoomRoleInvite} promoting an `InvitedRole` the room's owner offered. * * The caller supplies the already-loaded room (after its gate) to avoid a re-read; the whole * room JSON is rewritten. Any pending `InvitedRole` on the entry is left standing — a granted * Host with a co-owner invite open still has it to answer. */ export async function setRoomRole( db: D1Database, roomId: number, targetAccountId: number, role: number, changedByAccountId: number, room: Room ): Promise { if (roleRequiresInvite(role)) return null const roles = roomRoles(room) 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: Role.None, }) } 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 } /** * REVOKE a room role — dropping the target's `Roles` entry outright. This is how a room's * owner or a co-owner takes back a role they handed out, and it is the counterpart to * {@link setRoomRole}: there is no "demote to nothing" tier, because {@link Role.None} is the * resting value of a PENDING invite rather than a role somebody holds. An entry that confers * nothing should not exist, so a revoke removes the record. * * Two entries are protected, and both refuse by returning null without writing: * * - The room's OWNER — `CreatorAccountId`, or a {@link Role.Creator} entry. Ownership is not * a role to be taken away by anyone standing beside it; it moves through * {@link transferRoomOwnership} and nowhere else. Without this a co-owner could remove the * creator's entry, and on an older room whose creator has no entry at all they could not — * so the check is on `CreatorAccountId` as well as on the tier. * - A CO-OWNER entry, unless the caller is the room's owner. Co-owners are peers: letting one * remove another means whichever of them asks first wins, and the room's owner is the only * person who is not a peer of theirs. A co-owner may still revoke the helper tiers. * * Removing a role a player does not have is NOT a failure — the room comes back unchanged and * nothing is written. The caller asked for this player to hold no role, and they hold none; * answering an error would make a client retrying a removal look broken. * * Takes any pending `InvitedRole` with it, since the whole entry goes. That is the same * trade {@link answerRoomRoleInvite} makes when declining, and it means revoking a role from * someone who has a co-owner invite standing also withdraws the invite. * * `removedByAccountId` is not stamped anywhere — the entry it would be stamped on is the one * being deleted — and is read only to decide which of the two protections apply. The caller * supplies the already-loaded room (after its gate) to avoid a re-read; the whole room JSON * is rewritten. */ export async function removeRoomRole( db: D1Database, roomId: number, targetAccountId: number, removedByAccountId: number, room: Room ): Promise { if (room.CreatorAccountId === targetAccountId) return null const roles = roomRoles(room) const index = roles.findIndex((r) => r.AccountId === targetAccountId) if (index === -1) return room const entry = roles[index] if (entry.Role === Role.Creator) return null if (entry.Role === Role.CoOwner && !isRoomOwner(room, removedByAccountId)) return null roles.splice(index, 1) 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 } /** * TRANSFER a room to a new owner: the recipient becomes {@link Role.Creator} and the * outgoing owner is left as {@link Role.CoOwner}, keeping their access to a room they built * without keeping the room. * * `CreatorAccountId` moves too, and it has to. It is the room's real owner field — every * gate short-circuits on it ({@link canManageRoom}, {@link isRoomOwner}), the "rooms I made" * lists select on it, and it is what the client shows. Moving only the `Roles` entries would * give the room two owners: the new one by role, the old one by `CreatorAccountId`, still * able to hand the room on again. * * Exempt from {@link roleRequiresInvite}, which otherwise means nobody is handed * {@link Role.Creator} without accepting it. This is not a grant made ABOUT someone by a * third party; it is the sole person who could already do anything at all to this room * giving it away. The recipient gets a room, not a liability, and requiring them to accept * would leave the room in a half-transferred state in the meantime. * * Returns null, writing nothing, for a DORM. A dorm is found by its owner * ({@link getDormRoom} selects on `creator_account_id AND is_dorm`), so transferring one * would hand somebody else's dorm to the recipient — who already has their own — and mint * the original owner a fresh empty one on next access, their build gone. * * The caller supplies the already-loaded room (after its owner check) to avoid a re-read; * the whole room JSON is rewritten. Pending invites on either entry are left standing. */ export async function transferRoomOwnership( db: D1Database, roomId: number, fromAccountId: number, toAccountId: number, room: Room ): Promise { if (room.IsDorm === true) return null const roles = roomRoles(room) // Both sides get an entry whether or not they had one: the outgoing owner may never have // been in `Roles` at all (the older rooms carry no entry for their creator), and the // recipient is usually a stranger to the room. const setRole = (accountId: number, role: number): void => { const existing = roles.find((r) => r.AccountId === accountId) if (existing) { existing.Role = role existing.LastChangedByAccountId = fromAccountId } else { roles.push({ AccountId: accountId, Role: role, LastChangedByAccountId: fromAccountId, InvitedRole: Role.None, }) } } setRole(toAccountId, Role.Creator) setRole(fromAccountId, Role.CoOwner) const updated: Room = { ...room, CreatorAccountId: toAccountId, Roles: roles } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** * ANSWER a standing room-role invite — accept it, or decline it with `role` 0. * * Accepting promotes the account's `Roles` entry from its pending `InvitedRole` to the real * `Role` and clears the pending one, so an accepted entry reads `Role: 30, InvitedRole: 0` * and the invite cannot be answered twice. `role` is what the ACCEPTING PLAYER asked for, and * it must equal the `InvitedRole` already on their entry. That equality is the whole security * of this call: the answer is made by the invited player themselves, so without it they could * answer an invite to Host by asking for Creator and award themselves the room. The invited * role is the server's own record of what the owner offered, written by * {@link inviteRoomRole}, and nothing the caller sends can move it. * * Declining (`role` 0 — not a real tier, so it can't be confused for one) REMOVES the entry * outright rather than just clearing `InvitedRole`: a declined invite should leave the room's * `Roles` as it was before the offer. Note that this takes any role the player already held * with it, so someone re-invited to a different tier who declines is left with nothing at * all. That is deliberate — an invite is answered as a whole — but it means a decline is not * a no-op for an existing member. * * Returns null — changing nothing — when there is no entry for the account, no invite * standing on it, or (when accepting) the requested role isn't the one offered. The three are * deliberately one answer: a caller probing for a higher role learns only that it didn't work. * * `LastChangedByAccountId` is left alone rather than restamped with the accepter: it names * who put this role on the entry, and that is the owner who offered it. The caller supplies * the already-loaded room to avoid a re-read; the whole room JSON is rewritten. */ export async function answerRoomRoleInvite( db: D1Database, roomId: number, accountId: number, role: number, room: Room ): Promise { const roles = roomRoles(room) const index = roles.findIndex((r) => r.AccountId === accountId) if (index === -1) return null const entry = roles[index] // `Role.None` is "nothing pending" — the resting state of every entry, and what an // already-answered invite leaves behind, so neither answer can be replayed. if (entry.InvitedRole === Role.None) return null if (role === Role.None) { roles.splice(index, 1) } else if (entry.InvitedRole !== role) { return null } else { entry.Role = entry.InvitedRole entry.InvitedRole = Role.None } 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 } /** * INVITE an account to a room role — setting `InvitedRole` on their `Roles` entry (adding * the entry when they have none) and stamping `LastChangedByAccountId` with the inviter. * * `InvitedRole` is the PENDING half of a role entry: an invited account holds it until * they accept, at which point {@link answerRoomRoleInvite} promotes it to the real `Role`. * So an entry added here starts at {@link Role.None} — no role yet, only an offer — and an * account who already holds a role keeps it while a higher one is pending. * * Like {@link answerRoomRoleInvite}, the caller supplies the already-loaded room to * avoid a re-read, and the whole room JSON is rewritten. Returns the updated room. */ export async function inviteRoomRole( db: D1Database, roomId: number, targetAccountId: number, invitedRole: number, changedByAccountId: number, room: Room ): Promise { const roles = roomRoles(room) const existing = roles.find((r) => r.AccountId === targetAccountId) if (existing) { existing.InvitedRole = invitedRole existing.LastChangedByAccountId = changedByAccountId } else { roles.push({ AccountId: targetAccountId, Role: Role.None, LastChangedByAccountId: changedByAccountId, InvitedRole: invitedRole, }) } 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 /** * `RoomData.Filename` — the uploaded ROOM-level metadata blob (a separate upload from the * scene). It lands on the ROOM as `DataBlob`, not on the subroom: the client's `Room` has * `DataBlob`/`DataBlobHash` and its `SubRoom` has no field for it (`RoomDataBlob`, where * this once went, is not a key the client knows, and it dropped it silently). */ roomDataFilename?: string /** `RoomData.Hash` — stored beside it as the room's `DataBlobHash`; null when unsent. */ roomDataHash?: 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 array fields start empty and must be PRESENT. `UnitySubAssets` is * filled in on read when this save's `UnityAssetId` has stored Studio bundles (see * {@link attachStudioUnityAssets}); referenced assets stay empty. `UnityAssetId` is * emitted only when the save actually carried one, exactly as the reference does. * There is deliberately no `DataBlobHash` of our own: the caller stores the hash the * client sent, and a save that carried none keeps it null. * * `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, }) } /** * The invention ids inside a room save's `InventionUsage`, in the order it lists them. * * The value is a base64 PROTOBUF message, recovered by reading real saves — there is no * schema, only field numbers: * * 1: varint — 1 on every save seen; a format version by the look of it * 2: repeated message — one per invention in the scene * 1: varint — the invention id * 2: varint — 1 on every save seen; probably how many times it is placed * * `CAESBgil2gEQARIFCIECEAE=` is `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`. * * Only the ids are read. Anything that doesn't parse — not base64, a truncated message, a * wire type this doesn't know — yields the ids found up to that point rather than * throwing: this is bookkeeping riding on a room save, and a format the client changes * must not be able to fail the save. */ export function decodeInventionUsage(inventionUsage: string): number[] { let bytes: Uint8Array try { bytes = Uint8Array.from(atob(inventionUsage), (ch) => ch.charCodeAt(0)) } catch { return [] } /** Walk one message's fields; false from `visit`, or anything malformed, stops the walk. */ const walk = ( buf: Uint8Array, visit: (field: number, value: number | Uint8Array) => void ): void => { let at = 0 const varint = (): number | null => { let value = 0 for (let shift = 0; at < buf.length; shift += 7) { const byte = buf[at++] // Multiplication, not `<<`: ids past 2^31 would wrap as a 32-bit shift. value += (byte & 0x7f) * 2 ** shift if ((byte & 0x80) === 0) return value } return null } while (at < buf.length) { const key = varint() if (key === null) return const field = Math.floor(key / 8) const wireType = key % 8 if (wireType === 0) { const value = varint() if (value === null) return visit(field, value) } else if (wireType === 2) { const length = varint() if (length === null || at + length > buf.length) return visit(field, buf.subarray(at, at + length)) at += length } else { return } } } const ids: number[] = [] walk(bytes, (field, entry) => { if (field !== 2 || typeof entry === 'number') return walk(entry, (inner, value) => { if (inner === 1 && typeof value === 'number' && Number.isSafeInteger(value)) ids.push(value) }) }) return ids } /** * Re-derive a room's `room_invention` rows from its subrooms: the union of the inventions * each subroom's last save reported in `InventionUsage`. An invention removed from the room * drops out on the next save — and the set is derived from EVERY subroom rather than the * one just saved, because the table is per room and a save of one subroom says nothing * about what the others still hold. * * A subroom's `InventionUsage` is whatever its latest save sent, staged or published, so * this is the room as its builders last left it, not strictly what players are loading. * * Every invention that entered or left the room then has its `NumPlayersHaveUsedInRoom` * resynced — the number of ROOMS it is in, which is what this server has to put behind * that field. Derived from the table, like `CheerCount` and `NumDownloads` from theirs, and * a `json_set` on the one key of the `invention` record (the `api` worker's table, same * database). Only the rows that changed are written, so the usual save — same inventions * as last time — costs one read. */ export async function syncRoomInventions( db: D1Database, roomId: number, room: Room ): Promise { const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] const now = new Set() for (const sub of subRooms) { if (typeof sub.InventionUsage !== 'string') continue for (const id of decodeInventionUsage(sub.InventionUsage)) now.add(id) } const { results } = await db .prepare('SELECT invention_id FROM room_invention WHERE room_id = ?1') .bind(roomId) .all<{ invention_id: number }>() const before = new Set(results.map((r) => r.invention_id)) const added = [...now].filter((id) => !before.has(id)) const removed = [...before].filter((id) => !now.has(id)) if (added.length === 0 && removed.length === 0) return // One batch, in order: the membership changes land before the counts are re-derived. await db.batch([ ...removed.map((id) => db .prepare('DELETE FROM room_invention WHERE room_id = ?1 AND invention_id = ?2') .bind(roomId, id) ), ...added.map((id) => db .prepare('INSERT OR IGNORE INTO room_invention (room_id, invention_id) VALUES (?1, ?2)') .bind(roomId, id) ), ...[...added, ...removed].map((id) => db .prepare( `UPDATE invention SET data = json_set(data, '$.NumPlayersHaveUsedInRoom', (SELECT COUNT(*) FROM room_invention WHERE invention_id = ?1)) WHERE id = ?1` ) .bind(id) ), ]) } /** How many rooms an invention is used in — the stat `room_invention` exists to answer. */ export async function countRoomsUsingInvention( db: D1Database, inventionId: number ): Promise { const row = await db .prepare('SELECT COUNT(*) AS n FROM room_invention WHERE invention_id = ?1') .bind(inventionId) .first<{ n: number }>() return row?.n ?? 0 } /** * 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) sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage // The ONE thing a save writes on the ROOM: `RoomData`, the room-level metadata blob the // client uploads beside the scene. The client's `Room` reads it as `DataBlob` (with // `DataBlobHash`), so that is where it goes — a targeted `json_set` of those two keys, // never a rewrite of the room row. It used to be put on the subroom as `RoomDataBlob`, // which the client has no field for, so no save ever reached it. // // Everything else the save carries describes the SUBROOM's 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). const roomWrites: D1PreparedStatement[] = [] if (input.roomDataFilename) { const hash = input.roomDataHash ?? null room.DataBlob = input.roomDataFilename room.DataBlobHash = hash roomWrites.push( db .prepare( "UPDATE room SET data = json_set(data, '$.DataBlob', ?2, '$.DataBlobHash', ?3) WHERE room_id = ?1" ) .bind(roomId, input.roomDataFilename, hash) ) } // 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([ ...roomWrites, 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]) // The one thing a save records OUTSIDE the subroom and its save row: which inventions // the room now has in it. Its own table, so the room row is still not rewritten. await syncRoomInventions(db, roomId, 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. * * A changed `MaxPlayers` is pushed onto the subroom's LIVE instances too (see * {@link resyncInstanceCapacity}): each instance carries a copy of the cap it enforces, * and this is the write that makes that copy stale. */ 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 const capacityChanged = input.maxPlayers !== undefined && input.maxPlayers !== sub.MaxPlayers if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers await updateSubRoom(db, sub) if (capacityChanged) await resyncInstanceCapacity(db, roomId, subRoomId, input.maxPlayers!) 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 } } /** * Move a subroom from one room to another (`POST …/subrooms/{subRoomId}/move`). A subroom * is its own row keyed by a globally-unique id, and its saves and permission overrides hang * off that id rather than the room's, so the move is one column: `room_id`. The saves, * the current/staged pointers and the permissions all travel with it untouched. The * subroom's `RoomId` is re-injected from the column on read, so the blob needs nothing. * * Refuses to move a room's only subroom, for the reason {@link deleteSubRoom} refuses to * delete it: the room would be left with no scene to load. The caller checks the target * room exists and that it may manage both rooms — this only checks the subroom's side. * * Returns the SOURCE room, hydrated, which no longer lists the subroom — that is what the * client re-renders from — or a reason: `not_found` (no such subroom in that room) / * `last_subroom`. */ export async function moveSubRoom( db: D1Database, roomId: number, subRoomId: number, newRoomId: 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 .prepare('UPDATE subroom SET room_id = ?3 WHERE room_id = ?1 AND sub_room_id = ?2') .bind(roomId, subRoomId, newRoomId) .run() 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' /** * The columns a scan-then-rank feed selects in place of {@link ROOM_COLUMNS}: a STUB room * holding only the fields the feeds filter and sort on, not the whole blob. The feeds rank * every listable room to serve one page, so reading the full blobs moved every listable * room's JSON over the wire and through `JSON.parse` for the handful that get served; the * page's full rooms are then read by id ({@link loadRankedPage}). * * `->` rather than `json_extract` because it hands back JSON, so `json_object` embeds each * value with its JSON type intact — `true` stays `true`, not 1, and a `"1"` stays a string. * That is what lets {@link isListable} and friends run on the stub unchanged and remain the * definition of listable. A missing key comes back as null, which every test below reads * the same as absent. A feed that starts reading a new field off the room has to add it * here, or it reads null. */ const RANK_COLUMNS = `json_object(${[ 'RoomId', 'IsDorm', 'Accessibility', 'ExcludeFromLists', 'CreatorAccountId', 'CreatedAt', 'IsRRO', 'Stats', ] .map((key) => `'${key}', data -> '$.${key}'`) .join(', ')}) AS data, visits` /** * The full rooms behind a page of {@link RANK_COLUMNS} stubs, in the stubs' order, parsed * like any room read (not yet hydrated). A room deleted between the two reads is dropped * rather than served as a stub. */ async function loadRankedPage(db: D1Database, stubs: Room[]): Promise { const ids = stubs.map(roomIdOf) if (ids.length === 0) return [] const rows = await selectInChunks( db, ids, (placeholders) => `SELECT ${ROOM_COLUMNS} FROM room WHERE room_id IN (${placeholders})` ) const byId = new Map(parseAll(rows).map((room) => [roomIdOf(room), room])) return ids.map((id) => byId.get(id)).filter((room): room is Room => room !== undefined) } /** * 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) /** * 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) }) const saves = subs .map((sub) => sub.CurrentSave) .filter((save): save is SubRoomDataSave => typeof save === 'object' && save !== null) await attachStudioUnityAssets(db, saves) } /** * Fill `UnitySubAssets` on saves that point at a stored Studio build. Maker-pen * saves have no `UnityAssetId` and are left untouched, including their empty * arrays. A missing studio table is the same answer: the scene blob still loads, * and the bundle list stays empty until the migration exists. * * Only main bundles are listed. Stripped bundles stay in the bucket. */ export async function attachStudioUnityAssets( db: D1Database, saves: SubRoomDataSave[] ): Promise { const ids = [ ...new Set( saves .map((save) => save.UnityAssetId) .filter((id): id is string => typeof id === 'string' && id !== '') ), ] if (ids.length === 0) return let files: StudioUnityAssetFile[] try { files = await listStudioUnityAssetFiles(db, ids) } catch (err) { if (isMissingStudioAssetTable(err)) return throw err } const byAsset = new Map() for (const file of files) { const list = byAsset.get(file.unityAssetId) ?? [] list.push(file) byAsset.set(file.unityAssetId, list) } for (const save of saves) { const id = save.UnityAssetId if (typeof id !== 'string') continue const baked = bakedStudioUnityAssets(byAsset.get(id) ?? []) if (baked.length === 0) continue save.UnitySubAssets = baked } } // ---- 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 = '', columns = ROOM_COLUMNS ): { 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 ${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` — in {@link ROOM_COLUMNS} or the ranking stub {@link RANK_COLUMNS} — // are unqualified but unambiguous: the joined subqueries expose only `room_id`. return { sql: `SELECT ${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 } /** 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` by a grouped query. * Restricted to `roomIds` when given (a feed page), otherwise covering every room. * Rooms nobody has interacted with are absent. * * The filtered form is an index range per id on `idx_interaction_room`, whose columns * cover the counters — the table is never read. That's why a page with more ids than * D1 lets us bind is split into batched queries rather than served by the unfiltered * scan: every room read runs this, and the scan cost the whole table per call. */ export async function getRoomStats( db: D1Database, roomIds?: number[] ): Promise> { const byRoom = new Map() if (roomIds && roomIds.length === 0) return byRoom const statement = (ids: number[]): D1PreparedStatement => { const where = ids.length > 0 ? `WHERE room_id IN (${bindPlaceholders(ids)})` : '' return db .prepare( `SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites FROM interaction ${where} GROUP BY room_id` ) .bind(...ids) } const chunks = roomIds ? chunkForBinds(roomIds) : [[]] const batches = chunks.length === 1 ? [await statement(chunks[0]!).all()] : await db.batch(chunks.map(statement)) for (const { results } of batches) { 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() const saves = results.map(parseSubRoomSaveRow) await attachStudioUnityAssets(db, saves) return saves } /** * 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() if (!row) return null const save = parseSubRoomSaveRow(row) await attachStudioUnityAssets(db, [save]) return save } // ---- 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 back `Override: true` at its own role; the * `false` rows in a manager's token table are defaults the `rooms` worker fills in around * them, never stored. */ 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), // Each player's saved data for the room: keyed by room, meaningless without it. db.prepare('DELETE FROM playerdata 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). * * This is the player's OWN history, so nothing is filtered on accessibility: their * unpublished builds and the private rooms they were invited into are theirs to see. * Anyone else reading it goes through {@link getPublicVisitedRooms}. */ export async function getVisitedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { return visitedRooms(db, playerId, skip, take, false) } /** * {@link getVisitedRooms} as SOMEONE ELSE sees it — a profile, which anyone can look at — * narrowed to the rooms that are {@link isListable}. A visit is recorded wherever the * player goes, so the unfiltered history names their unpublished builds and the private * rooms they were invited into; served whole, it leaked a room its owner hasn't released, * and `GET /rooms/{id}` is not gated, so the id alone was enough to open it. The same test * the public `ownedby/{accountId}` profile list applies, so a room that opted out of lists * stays off this one too. Paginated AFTER the filter, so a page is a page of what the * viewer may see rather than a page with holes in it. */ export async function getPublicVisitedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { return visitedRooms(db, playerId, skip, take, true) } async function visitedRooms( db: D1Database, playerId: number, skip: number, take: number, listableOnly: boolean ): Promise { // The listable predicate is pushed down (see LISTABLE_WHERE) so the blobs of the // rooms a friend may not see never leave D1; the in-memory filter remains the // definition, as everywhere else it is used. 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 ${listableOnly ? `AND r.${LISTABLE_WHERE.replaceAll(' AND ', ' AND r.')}` : ''} ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() const rooms = parseAll(results) const visible = listableOnly ? rooms.filter(isListable) : rooms return hydrateRooms(db, visible.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, RANK_COLUMNS ) 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, await loadRankedPage(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, await loadRankedPage(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 ${RANK_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, await loadRankedPage(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 ${RANK_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, await loadRankedPage(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 ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const stats = await getRoomStats(db) const page = parseAll(results) .filter(isListable) .sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take) return hydrateRooms(db, await loadRankedPage(db, page), 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 ${RANK_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, await loadRankedPage(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 ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`) .all() const candidates = parseAll(results).filter(isListable) // Fisher–Yates shuffle so the feed varies between requests. for (let i = candidates.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[candidates[i], candidates[j]] = [candidates[j], candidates[i]] } candidates.length = Math.min(candidates.length, FEATURED_ROOM_LIMIT) // The stubs carry none of the fields the group shows, so the sample is read in full. const rooms = await loadRankedPage(db, candidates) 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 ${RANK_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, await loadRankedPage(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: Role.None, }, ], // 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 }