/** * Room storage on the shared `recflare` D1 database. Each room is a single JSON * blob in the `data` column; queryable fields (RoomId, Name, CreatorAccountId, * IsDorm) are SQLite generated (virtual) columns extracted from that JSON and * indexed. This keeps the room shape flexible while still allowing fast lookups * by id/name/creator — the same JSON-blob pattern `accounts-db` uses. * * `ROOM_SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql` * created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); the * room data is seeded from `apps/rooms/static/ImportRooms.json` by * `migrations/0002_import_rooms.sql`. Tests apply `ROOM_SCHEMA_DDL` then seed the * imported rooms directly. * * This module is the single source of truth for the helpers: the `rooms` worker * (which owns the schema/migrations) uses the read/write set; the `match` worker * uses the room lookups plus the dorm helpers; the `api` worker binds the same * database read-only and uses `getRoomById`. Each imports the subset it needs. */ import { Accessibility, Role } from './enums' /** 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 )`, `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)`, // 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) )`, ] /** * 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)`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ export type Room = Record /** A room role assignment (the client's RoomRole shape). */ interface RoomRole { AccountId: number Role: number LastChangedByAccountId: number | null InvitedRole: number } /** * Room roles that confer owner-level management of a room: Creator (255) and * CoOwner (30). The reference gates its room-admin actions on this set. (Host and * Moderator are lower tiers and are deliberately excluded.) */ const MANAGE_ROLES: ReadonlySet = new Set([Role.Creator, Role.CoOwner]) /** * Whether an account may manage a room — its creator, or the holder of a * Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate * the reference applies to room-admin actions (editing room data, viewing a room's * live instances). Shared so the `rooms` and `match` workers apply the same check * rather than each re-deriving the role set. */ export function canManageRoom(room: Room, accountId: number): boolean { if (room.CreatorAccountId === accountId) return true const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role)) } /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * name, and the new owner. The clone starts with an empty tag set — the source's * tags (including the `base` template tag) do not carry over, so the owner tags the * clone from scratch — and `IsRRO` is cleared so the client doesn't render a virtual * "RRO" tag on it. Returns the new room, or null when the source isn't in D1 or * disallows cloning. */ export async function cloneRoom( db: D1Database, sourceRoomId: number, name: string, accountId: number ): Promise { const source = await getRoomById(db, sourceRoomId) if (!source || source.CloningAllowed === false) return null const row = await db .prepare('SELECT MAX(room_id) AS maxId FROM room') .first<{ maxId: number | null }>() const newRoomId = (row?.maxId ?? 0) + 1 // Ownership is reset to the cloner — the source room's Roles (its creator and // any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT // carry over, or the clone would still list the template's owner as owner. const roles: RoomRole[] = [ { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, ] const cloned: Room = { ...source, RoomId: newRoomId, Name: name, CreatorAccountId: accountId, IsDorm: false, // Start fresh: drop every tag the source carried (including `base`). Tags: [], // A user clone is not a Rec Room Original — clear the inherited flag, or the // client renders a virtual "RRO" tag on the clone. IsRRO: false, Roles: roles, CreatedAt: new Date().toISOString(), } // serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are // inserted into the subroom table below with fresh globally-unique ids. await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run() const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : [] const clonedSubRooms: SubRoom[] = [] for (const sub of sourceSubRooms) { clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId })) } cloned.SubRooms = clonedSubRooms return cloned } /** Set a room's Description in place (the caller is responsible for the owner check). */ export async function setRoomDescription( db: D1Database, roomId: number, description: string ): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1") .bind(roomId, description) .run() } /** Set a room's Name in place (the caller checks ownership + name uniqueness first). */ export async function setRoomName(db: D1Database, roomId: number, name: string): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1") .bind(roomId, name) .run() } /** Set a room's ImageName in place (the caller is responsible for the owner check). */ export async function setRoomImage( db: D1Database, roomId: number, imageName: string ): Promise { await db .prepare("UPDATE room SET data = json_set(data, '$.ImageName', ?2) WHERE room_id = ?1") .bind(roomId, imageName) .run() } /** * Merge a set of top-level fields into a room's JSON blob and write it back. Used by * the room-settings mutations whose values include booleans (cloning, platform * restrictions) — rewriting the whole blob preserves proper JSON booleans, whereas a * `json_set` bind would store `true`/`false` as `1`/`0`. The caller supplies the * already-loaded, permission-checked room. Returns the updated room. */ export async function updateRoomFields( db: D1Database, roomId: number, room: Room, patch: Record ): Promise { const updated: Room = { ...room, ...patch } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** * Set a target account's room `Role` — updating their existing `Roles` entry or * appending a new one — and stamp `LastChangedByAccountId` with the editor. The * caller supplies the already-loaded room (after its owner/co-owner check) to avoid * a re-read; the whole room JSON is rewritten. Returns the updated room. */ export async function setRoomRole( db: D1Database, roomId: number, targetAccountId: number, role: number, changedByAccountId: number, room: Room ): Promise { const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : [] const existing = roles.find((r) => r.AccountId === targetAccountId) if (existing) { existing.Role = role existing.LastChangedByAccountId = changedByAccountId } else { roles.push({ AccountId: targetAccountId, Role: role, LastChangedByAccountId: changedByAccountId, InvitedRole: 0, }) } const updated: Room = { ...room, Roles: roles } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** * Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so * setting one clears any other main tag. Compared case-insensitively. */ const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art']) /** * Add a user tag (`Type: 0`) to a room's `Tags`, skipping it when already present * (case-insensitive). The caller supplies the already-loaded room (owner-checked) * to avoid a re-read; the whole room JSON is rewritten. Returns the updated room. */ export async function toggleRoomTag( db: D1Database, roomId: number, room: Room, tag: string ): Promise { const tags = Array.isArray(room.Tags) ? (room.Tags as Array>) : [] const lower = tag.toLowerCase() const tagLower = (t: Record): string => String(t?.Tag).toLowerCase() const existing = tags.findIndex((t) => tagLower(t) === lower) // The client has no delete/patch endpoint — the same call toggles a tag: remove // it if already present, add it otherwise. Adding a main tag is a radio pick, so // it also clears any other main tag already set. let nextTags: Array> if (existing !== -1) { nextTags = tags.filter((_, i) => i !== existing) } else if (MAIN_TAGS.has(lower)) { nextTags = [...tags.filter((t) => !MAIN_TAGS.has(tagLower(t))), { Tag: tag, Type: 0 }] } else { nextTags = [...tags, { Tag: tag, Type: 0 }] } const updated: Room = { ...room, Tags: nextTags } await db .prepare('UPDATE room SET data = ?2 WHERE room_id = ?1') .bind(roomId, serializeRoom(updated)) .run() return updated } /** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */ export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined { const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : [] return subRooms.find((s) => s.SubRoomId === subRoomId) } /** Fields from the client's room-save POST body. */ export interface SaveSubRoomDataInput { /** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */ subRoomDataFilename?: string /** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */ subRoomDataHash?: string /** Uploaded blob key for the room-level METADATA blob (a separate upload). */ roomDataFilename?: string description?: string persistenceVersion?: number inventionUsage?: string /** Optional baked-asset id; emitted on the save only when present. */ unityAssetId?: string /** * The client's `AutoPublish`. True publishes the save outright (the author wants it * live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and * always publish. */ autoPublish?: boolean } /** * A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene * data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave` * loads nothing, no matter what the (legacy, flat) `DataBlob` field says. */ export type SubRoomDataSave = Record /** * The scene-data blob key the client should download for a subroom. Prefers the * authoritative `CurrentSave.DataBlob` and falls back to the flat `DataBlob` that * subrooms written before `CurrentSave` existed (and the `0001_init.sql` dorm seed) * still carry. Shared so the `match` and `auth` room-instance payloads resolve the * blob the same way the client's own loader does. */ export function subRoomDataBlob(sub: SubRoom | undefined | null): string { const save = sub?.CurrentSave if (save && typeof save === 'object') { const blob = (save as SubRoomDataSave).DataBlob if (typeof blob === 'string' && blob !== '') return blob } return typeof sub?.DataBlob === 'string' ? sub.DataBlob : '' } /** Fields that vary between a real save and one reconstructed from the legacy shape. */ interface BuildSaveInput { subRoomId: unknown dataBlob: string dataBlobHash: string | null persistenceVersion: number savedByAccountId: unknown description: string createdAt: string unityAssetId?: string } /** * Build a `SubRoomDataSave` in the shape the client parses — the reference's `MapSave` * projection. The four array fields are always empty (we neither resolve nor record * referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when * the save actually carried one, exactly as the reference does. There is deliberately no * `DataBlobHash`: it is commented out of the reference DTO and absent from its output. * * `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving * player's live platform/device, which the save request doesn't carry and we don't track. * * Shared by the save path and the legacy-shape reconstruction so the two can't drift. */ function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave { const save: SubRoomDataSave = { UnitySubAssets: [], ReferencedUnityAssets: [], SubRoomId: input.subRoomId, DataBlob: input.dataBlob, // The client sends `SubRoomData.Hash` (usually null); the room-save response echoes // it as `dataBlobHash`. One observed room payload carries it on `CurrentSave` and // another omits it, so storing it and letting it ride along is the safe reading. DataBlobHash: input.dataBlobHash, ReferencedUnityAssetIds: [], PersistenceVersion: input.persistenceVersion, OMVersion: 0, UgcSubVersion: 0, SavedByAccountId: input.savedByAccountId, SavedOnPlatform: 0, SavedOnDeviceClass: 0, Description: input.description, Tags: [], ModerationState: 0, CreatedAt: input.createdAt, } if (input.unityAssetId) save.UnityAssetId = input.unityAssetId return save } /** * Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob * key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those * subrooms hold real saved content the client cannot see (it reads `CurrentSave` only), * so they get a save of their own rather than reading as never-saved. Mirrors backfill 2 * of migration 0008 — keep the two in sync. * * Returns null when there is genuinely nothing saved, the honest answer for a fresh * subroom. */ function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null { const blob = sub.DataBlob if (typeof blob !== 'string' || blob === '') return null const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString() return buildSubRoomSave({ subRoomId: sub.SubRoomId, dataBlob: blob, dataBlobHash: null, persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0, // The legacy shape never recorded who saved; the subroom's creator is the best // available answer (the save path is owner/co-owner gated). savedByAccountId: sub.CreatorAccountId ?? null, description: '', createdAt: savedAt, }) } /** * Persist a room-save against a specific subroom and record the room-level fields the * save carries. 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 ?? ""`). Also written to the room below. description: input.description ?? '', createdAt: new Date().toISOString(), unityAssetId: input.unityAssetId, }) ) const saveId = Number(save.SubRoomDataSaveId) if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename sub.DataSavedAt = new Date().toISOString() if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion // Room-level fields carried by the save. if (typeof input.description === 'string') room.Description = input.description if (input.persistenceVersion !== undefined) room.PersistenceVersion = input.persistenceVersion if (input.inventionUsage !== undefined) room.InventionUsage = input.inventionUsage // Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is // the player's own private space with no publish step in the client, so staging one // would leave their edits permanently invisible. Otherwise stage it and wait for // `publish_save`. One round trip for the rest of the save. const publishNow = input.autoPublish === true || room.IsDorm === true await db.batch([ publishNow ? db .prepare( 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1' ) .bind(subRoomId, saveId) : db .prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1') .bind(subRoomId, saveId), db .prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1') .bind(subRoomId, serializeSubRoom(sub, roomId)), db.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1').bind(roomId, serializeRoom(room)), ]) // Re-hydrate so the returned room reflects the just-saved subroom. await attachSubRooms(db, [room]) return { room, save } } /** * Publish one of a subroom's saves by id: make it the `current_save_id` players load. * This is the manual step every non-dorm room save waits on ({@link saveSubRoomData} * only stages). Because it takes an explicit id it doubles as restore-a-save — the id * can be any save in the subroom's history, not just the staged one. * * The staging slot is cleared only when the save being published IS the staged one, so * restoring an older version doesn't silently discard newer unpublished work. * * The id is looked up scoped to the subroom, so one subroom can't publish another's save * (ids are globally unique, so an unscoped lookup would happily resolve). * * Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) / * `unknown_save` (no such save on this subroom). */ export async function publishSubRoomSave( db: D1Database, roomId: number, subRoomId: number, saveId: number ): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> { const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return { ok: false, reason: 'not_found' } if (!(await getSubRoomSaveById(db, subRoomId, saveId))) { return { ok: false, reason: 'unknown_save' } } await db .prepare( `UPDATE subroom SET current_save_id = ?2, staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END WHERE sub_room_id = ?1` ) .bind(subRoomId, saveId) .run() const room = await getRoomById(db, roomId) if (!room) return { ok: false, reason: 'not_found' } return { ok: true, room } } /** Fields from the client's subroom `modify` form (each applied only when supplied). */ export interface ModifySubRoomInput { name?: string accessibility?: number maxPlayers?: number } /** * Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers * (the fields the client's subroom `modify` form carries). Only the supplied fields * are changed; the subroom row is updated in the `subroom` table. Returns the updated * (hydrated) room, or null when the room or subroom doesn't exist. */ export async function modifySubRoom( db: D1Database, roomId: number, subRoomId: number, input: ModifySubRoomInput ): Promise { const sub = await getSubRoom(db, roomId, subRoomId) if (!sub) return null if (input.name !== undefined) sub.Name = input.name if (input.accessibility !== undefined) sub.Accessibility = input.accessibility if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers await updateSubRoom(db, sub) return getRoomById(db, roomId) } /** * Clone an existing subroom into a new subroom of the same room, owned by * `accountId`. The copy keeps the source's scene/settings (and its saved data * blobs, so it loads identical content) but gets a fresh globally-unique SubRoomId * minted from the `subroom` table's autoincrement sequence. Returns the updated * (hydrated) room and the new subroom, or null when the room or source subroom * doesn't exist. */ export async function cloneSubRoom( db: D1Database, roomId: number, subRoomId: number, accountId: number ): Promise<{ room: Room; subRoom: SubRoom } | null> { const source = await getSubRoom(db, roomId, subRoomId) if (!source) return null const subRoom = await insertSubRoom(db, roomId, { ...source, CreatorAccountId: accountId }) const room = await getRoomById(db, roomId) if (!room) return null return { room, subRoom } } /** Fallback scene, used only when a room has no existing subroom to inherit from. */ const DEFAULT_SUBROOM_SCENE = '76d98498-60a1-430c-ab76-b54a29b7a163' /** * The scene a brand-new subroom inherits: the room's own first (existing) subroom — * lowest SubRoomId — read from the subroom table. Falls back to the base sandbox scene * only when the room has no subrooms yet. */ async function baseSubRoomScene(db: D1Database, roomId: number): Promise { const row = await db .prepare('SELECT data FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id LIMIT 1') .bind(roomId) .first<{ data: string }>() const scene = row ? (JSON.parse(row.data) as SubRoom).UnitySceneId : undefined return typeof scene === 'string' ? scene : DEFAULT_SUBROOM_SCENE } /** * Create a new (empty) subroom in a room, owned by `accountId` and named `name`. It * inherits the room's existing subroom scene (see {@link baseSubRoomScene}) with a clean * save, and gets a fresh globally-unique SubRoomId. Returns the updated (hydrated) room * and the new subroom, or null when the room doesn't exist. */ export async function createSubRoom( db: D1Database, roomId: number, accountId: number, name: string ): Promise<{ room: Room; subRoom: SubRoom } | null> { const room = await getRoomById(db, roomId) if (!room) return null const subRoom = await insertSubRoom(db, roomId, { Name: name, CreatorAccountId: accountId, UnitySceneId: await baseSubRoomScene(db, roomId), MaxPlayers: 4, Accessibility: Accessibility.Unlisted, IsSandbox: true, LastModeratedSaveModerationState: 0, ShouldAutoStageSaves: true, // Nothing saved yet — the first room save mints one and points current_save_id // at it. Until then the subroom reads with `CurrentSave: null`. }) // Refresh the hydrated SubRooms so the returned room includes the one just inserted. await attachSubRooms(db, [room]) return { room, subRoom } } /** * Delete a subroom from a room. Refuses to remove a room's only subroom (that would * leave it with no scene to load). Any saved-data blob the subroom pointed at is left in * R2 (like {@link deleteRoom} leaves a room's images). Returns the updated (hydrated) * room on success, or a reason: `not_found` (no such subroom) / `last_subroom`. */ export async function deleteSubRoom( db: D1Database, roomId: number, subRoomId: number ): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> { const subRooms = await getSubRooms(db, roomId) if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' } if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' } await db.batch([ db .prepare('DELETE FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2') .bind(roomId, subRoomId), // The saves go with it — nothing can reference them once the subroom is gone. // The blobs they point at are left in R2, like a deleted room's images. db.prepare('DELETE FROM subroom_save WHERE sub_room_id = ?1').bind(subRoomId), ]) const room = await getRoomById(db, roomId) if (!room) return { ok: false, reason: 'not_found' } return { ok: true, room } } interface RoomRow { data: string } const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) const parseAll = (rows: RoomRow[]): Room[] => rows.map((r) => JSON.parse(r.data) as Room) // ---- 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 any hydrated `SubRooms` so it never * gets denormalized back into the room JSON (subrooms are the `subroom` table's job). */ const serializeRoom = (room: Room): string => { const { SubRooms: _subRooms, ...rest } = room return JSON.stringify(rest) } /** * 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) { const placeholders = saveIds.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare( `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_data_save_id IN (${placeholders})` ) .bind(...saveIds) .all() 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) }) } /** 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 } const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare( `SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id IN (${placeholders}) ORDER BY sub_room_id` ) .bind(...ids) .all() 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)) ?? [] } /** Hydrate a single room's `SubRooms` (no-op for null). */ async function hydrateRoom(db: D1Database, room: Room | null): Promise { if (room) await attachSubRooms(db, [room]) return room } /** Hydrate many rooms' `SubRooms` in one batched query. */ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise { await attachSubRooms(db, rooms) return rooms } /** A single subroom of a room (columns authoritative), or null if it doesn't exist. */ export async function getSubRoom( db: D1Database, roomId: number, subRoomId: number ): Promise { const row = await db .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`) .bind(roomId, subRoomId) .first() if (!row) return null return (await parseSubRoomRows(db, [row]))[0]! } /** All of a room's subrooms, ordered by SubRoomId. */ export async function getSubRooms(db: D1Database, roomId: number): Promise { const { results } = await db .prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`) .bind(roomId) .all() return parseSubRoomRows(db, results) } // ---- Subroom saves -------------------------------------------------------- interface SubRoomSaveRow { sub_room_data_save_id: number sub_room_id: number data: string } /** Materialize a save row, with its two id columns authoritative over the blob. */ const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({ ...(JSON.parse(row.data) as SubRoomDataSave), SubRoomDataSaveId: row.sub_room_data_save_id, SubRoomId: row.sub_room_id, }) /** Serialize a save for storage — the id columns own those two fields, not the blob. */ const serializeSubRoomSave = (save: SubRoomDataSave): string => { const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save return JSON.stringify(rest) } /** * Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from * the table's autoincrement sequence. Returns the stored save with its new id. */ async function insertSubRoomSave( db: D1Database, subRoomId: number, save: SubRoomDataSave ): Promise { const row = await db .prepare( 'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id' ) .bind(subRoomId, serializeSubRoomSave(save)) .first<{ sub_room_data_save_id: number }>() return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId } } /** * A subroom's save history, newest first. Unlike the old inline model this is real * history: every save is its own row and none are overwritten. */ export async function getSubRoomSaves( db: D1Database, subRoomId: number ): Promise { const { results } = await db .prepare( `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC` ) .bind(subRoomId) .all() return results.map(parseSubRoomSaveRow) } /** * A single save by its globally-unique id, scoped to the subroom that owns it (the * restore-a-save lookup). Null when the id is unknown or belongs to another subroom. */ export async function getSubRoomSaveById( db: D1Database, subRoomId: number, saveId: number ): Promise { const row = await db .prepare( `SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save WHERE sub_room_data_save_id = ?1 AND sub_room_id = ?2` ) .bind(saveId, subRoomId) .first() return row ? parseSubRoomSaveRow(row) : null } /** * 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, } // 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() 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 data 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), // Saves first — they're keyed by subroom, so they'd be unreachable afterwards. 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 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 data FROM room WHERE name_lower = ?1') .bind(name.toLowerCase()) .first() ) ) } /** Look up multiple rooms by RoomId. */ 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 data 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 data 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 } /** * 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( (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) } /** * 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 FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.favorited = 1 ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** * Rooms the player has visited (an interaction row with a `last_visited_at`), * most recent first. Like favorites, it joins `interaction` to `rooms`, so a * visited room no longer in D1 is simply absent. Paginated via skip/take; returns * a bare array of rooms (the client's room-source loaders expect a plain list). */ export async function getVisitedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { const { results } = await db .prepare( `SELECT r.data AS data FROM interaction i JOIN room r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return hydrateRooms(db, parseAll(results).slice(skip, skip + take)) } /** A player's interaction state with a room. */ export interface Interaction { Cheered: boolean Favorited: boolean } interface InteractionRow { cheered: number favorited: number } const toInteraction = (row: InteractionRow | null): Interaction => ({ Cheered: row?.cheered === 1, Favorited: row?.favorited === 1, }) /** Read a player's interaction with a room (defaults to all-false if none). */ export async function getInteraction( db: D1Database, playerId: number, roomId: number ): Promise { return toInteraction( await db .prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2') .bind(playerId, roomId) .first() ) } /** Upsert+toggle a single boolean column, returning the resulting interaction. */ async function toggleInteraction( db: D1Database, playerId: number, roomId: number, column: 'cheered' | 'favorited' ): Promise { const now = new Date().toISOString() // First interaction defaults the toggled column to 1; subsequent calls flip it. return toInteraction( await db .prepare( `INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at) VALUES (?1, ?2, 1, ?3) ON CONFLICT(player_id, room_id) DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3 RETURNING cheered, favorited` ) .bind(playerId, roomId, now) .first() ) } /** Toggle the player's cheer on a room, returning the resulting interaction. */ export async function toggleCheer( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'cheered') } /** Toggle the player's favorite on a room, returning the resulting interaction. */ export async function toggleFavorite( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'favorited') } /** * Explicitly clear a single interaction flag on a room (the DELETE counterpart to * the cheer/favorite toggles). Idempotent: only clears an existing interaction row * and never creates one, so clearing a flag on a room the player never interacted * with doesn't add a spurious visited/favorited entry. Returns the interaction. */ async function clearInteraction( db: D1Database, playerId: number, roomId: number, column: 'cheered' | 'favorited' ): Promise { await db .prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`) .bind(playerId, roomId) .run() return getInteraction(db, playerId, roomId) } /** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */ export async function removeCheer( db: D1Database, playerId: number, roomId: number ): Promise { return clearInteraction(db, playerId, roomId, 'cheered') } /** Clear the player's favorite on a room (DELETE favorite), returning the interaction. */ export async function removeFavorite( db: D1Database, playerId: number, roomId: number ): Promise { return clearInteraction(db, playerId, roomId, 'favorited') } /** * Search-tag aliases: a queried `#tag` also matches these stored tag names. * The client's pinned filters don't always match how rooms are tagged (e.g. it * searches `recroomoriginal`, but rooms are tagged `rro`). */ const TAG_ALIASES: Record = { recroomoriginal: ['rro'], } /** A room's tag names, lowercased (empty when it has no Tags array). */ function roomTags(room: Room): string[] { const tags = room.Tags if (!Array.isArray(tags)) return [] return tags .map((t) => (t as Record | null)?.Tag) .filter((v): v is string => typeof v === 'string') .map((v) => v.toLowerCase()) } /** True if the room carries any of the given (lowercased) tags. */ function roomHasAnyTag(room: Room, tags: Set): boolean { return roomTags(room).some((t) => tags.has(t)) } /** * Search public, non-dorm rooms. The query is split into terms (space/`+`): * `#tag` terms match the room's Tags; plain terms match the room name * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`. * The dataset is small, so this filters in memory rather than in SQL. */ 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) const { results } = await db.prepare('SELECT data FROM room').all() let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) for (const term of terms) { if (term.startsWith('#')) { const tag = term.slice(1) const accepted = new Set([tag, ...(TAG_ALIASES[tag] ?? [])]) rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) } else { 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, } } /** Engagement score used to order the hot feed (cheers weigh most, then favorites). */ function hotScore(room: Room): number { const stats = room.Stats as Record | null | undefined const n = (v: unknown): number => (typeof v === 'number' ? v : 0) return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount) } /** * The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered * by engagement and optionally filtered to a single `tag` (with the same aliases * as search). Paginated via skip/take; returns `{ Results, TotalResults }` like * search. Ties (and the all-zero seed data) fall back to RoomId order so paging * is stable. The dataset is small, so this filters/sorts in memory rather than * in SQL. */ export async function getHotRooms( db: D1Database, tag: string, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const { results } = await db.prepare('SELECT data FROM room').all() let rooms = parseAll(results).filter( (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) const t = tag.trim().toLowerCase() if (t !== '') { const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])]) rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) } const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) return { Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), TotalResults: rooms.length, } } /** * Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked * by engagement (same score as the hot feed). Unlike the hot feed this returns a * bare array — the client's recommendation room-source loader expects a plain * list, like the other `*by/me`/base sources. The `splitTest*` A/B params the * client passes don't change the result. Paginated via skip/take; the dataset is * small, so this filters/sorts in memory rather than in SQL. */ export async function getRecommendedRooms( db: D1Database, skip: number, take: number ): Promise { const { results } = await db.prepare('SELECT data FROM room').all() const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) return hydrateRooms( db, parseAll(results) .filter((r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true) .sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) .slice(skip, skip + take) ) } /** 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[] } /** * 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. * Small dataset, so done in memory. */ export async function getFeaturedRooms(db: D1Database): Promise { const { results } = await db.prepare('SELECT data FROM room').all() const rooms = parseAll(results).filter( (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) // Fisher–Yates shuffle so the feed varies between requests. for (let i = rooms.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[rooms[i], rooms[j]] = [rooms[j], rooms[i]] } 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. Small dataset, so done 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 data FROM room').all() const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) const scored = parseAll(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) - hotScore(a.room) || roomIdOf(a.room) - roomIdOf(b.room) ) const rooms = scored.map((x) => x.room) return { Results: await hydrateRooms(db, rooms.slice(skip, skip + take)), 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 { const { results } = await db.prepare('SELECT data FROM room').all() const base = new Set(['base']) const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) return hydrateRooms( db, parseAll(results) .filter((r) => roomHasAnyTag(r, base)) .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 data FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') .bind(accountId) .first() ) ) } /** * The player's personal dorm room, created on first access. Cloned from the * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so * matchmaking routes them into their own dorm and they can save it via the * owner-gated room-save. Idempotent: returns the existing dorm once created. * * NOTE: this is the one place the match worker writes to the rooms table (the * `rooms` worker otherwise owns the schema). */ export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise { const existing = await getDormRoom(db, accountId) if (existing) return existing const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID) const idRow = await db .prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room') .first<{ next: number }>() const roomId = idRow?.next ?? 2 // Reuse the template's subroom (scene/capacity), owned by the player, starting // from a clean save. Fall back to the base dorm scene if the template is absent. const templateSub = template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0 ? (template.SubRooms[0] as Record) : { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 } // Named after the owner: `@'s Dorm` (falls back to the account id). const username = (await getUsername(db, accountId)) ?? `Player${accountId}` const room: Room = { ...(template ?? { Accessibility: Accessibility.Unlisted }), RoomId: roomId, Name: `@${username}'s Dorm`, CreatorAccountId: accountId, IsDorm: true, Roles: [ { AccountId: accountId, Role: Role.Creator, LastChangedByAccountId: null, InvitedRole: 0 }, ], 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] return room }