Files
recflare/packages/domain/src/rooms-db.ts
T
2026-10-08 11:45:12 -04:00

4016 lines
158 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>
/** 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<Partial<RoomRole>>) : []
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<number> = 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<boolean | null> {
const room = parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
.bind(roomId)
.first<RoomRow>()
)
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<number[] | null> {
const room = parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
.bind(roomId)
.first<RoomRow>()
)
if (!room) return null
const ids = new Set<number>()
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<RoomBan> {
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<RoomBan | null> {
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<RoomBan[]> {
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<RoomBanRow>()
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<boolean> {
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<RoomLeaderboard> {
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<RoomLeaderboardRow>()
// 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<RoomLeaderboard | null> {
const row = await db
.prepare('DELETE FROM room_leaderboard WHERE room_id = ?1 AND leaderboard_id = ?2 RETURNING *')
.bind(roomId, leaderboardId)
.first<RoomLeaderboardRow>()
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<string | null> {
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<string> {
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<Room | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<string, unknown>
): Promise<Room> {
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<Room | null> {
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<Room | null> {
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<Room | null> {
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<Room | null> {
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<Room> {
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<Room> {
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<Room> {
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<string, unknown>
/**
* 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<void> {
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
const now = new Set<number>()
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<number> {
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<Room | null> {
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<string> {
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<Room[]> {
const ids = stubs.map(roomIdOf)
if (ids.length === 0) return []
const rows = await selectInChunks<RoomRow>(
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<Row>(
db: D1Database,
ids: number[],
sql: (placeholders: string) => string
): Promise<Row[]> {
const pages = await Promise.all(
chunkForBinds(ids).map((chunk) =>
db
.prepare(sql(chunk.map((_, i) => `?${i + 1}`).join(',')))
.bind(...chunk)
.all<Row>()
)
)
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<string, unknown>
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<void> {
const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
const byId = new Map<number, SubRoomDataSave>()
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<SubRoomSaveRow>(
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<void> {
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<string, StudioUnityAssetFile[]>()
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<number, RoomTag[]> {
const byRoom = new Map<number, RoomTag[]>()
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<Map<number, RoomTag[]>> {
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<RoomTagRow>()
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<RoomTagRow>()
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<void> {
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<Room[]> {
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<void> {
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<SubRoom[]> {
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<void> {
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<SubRoomRow>(
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<number, SubRoom[]>()
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<string, unknown> {
const stored =
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
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<void> {
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<Map<number, RoomStats>> {
const byRoom = new Map<number, RoomStats>()
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<RoomStatsRow>()]
: await db.batch<RoomStatsRow>(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<number, RoomStats>
): Promise<void> {
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<string, unknown>
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<Room | null> {
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<number, RoomStats>
): Promise<Room[]> {
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<SubRoom | null> {
const row = await db
.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`)
.bind(roomId, subRoomId)
.first<SubRoomRow>()
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<SubRoom[]> {
const { results } = await db
.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`)
.bind(roomId)
.all<SubRoomRow>()
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<SubRoomDataSave> {
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<SubRoomDataSave[]> {
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<SubRoomSaveRow>()
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<SubRoomDataSave | null> {
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<SubRoomSaveRow>()
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<RoomPermission[]> {
const { results } = await db
.prepare(
`SELECT ${PERMISSION_COLUMNS} FROM subroom_permission WHERE sub_room_id = ?1 ORDER BY rowid`
)
.bind(subRoomId)
.all<RoomPermissionRow>()
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<void> {
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<void> {
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<SubRoom> {
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<void> {
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<void> {
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<void> {
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<Room | null> {
return hydrateRoom(
db,
parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
.bind(roomId)
.first<RoomRow>()
)
)
}
/**
* 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<void> {
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<Room | null> {
return hydrateRoom(
db,
parseOne(
await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE name_lower = ?1`)
.bind(name.toLowerCase())
.first<RoomRow>()
)
)
}
/**
* 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<Room[]> {
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<RoomRow>()
return hydrateRooms(db, parseAll(results))
}
/** All rooms created by an account (e.g. their dorm). */
export async function getRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1`)
.bind(accountId)
.all<RoomRow>()
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<number> {
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<Room[]> {
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<RoomRow>()
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
}
/**
* An account's public, non-dorm rooms — the publicly viewable "rooms owned by
* <player>" list (excludes private rooms, dorms, and list-excluded rooms).
*/
export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
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<Room[]> {
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<RoomRow>()
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<Room[]> {
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<Room[]> {
return visitedRooms(db, playerId, skip, take, true)
}
async function visitedRooms(
db: D1Database,
playerId: number,
skip: number,
take: number,
listableOnly: boolean
): Promise<Room[]> {
// 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<RoomRow>()
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<Interaction> {
return toInteraction(
await db
.prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2')
.bind(playerId, roomId)
.first<InteractionRow>()
)
}
/** Upsert+toggle a single boolean column, returning the resulting interaction. */
async function toggleInteraction(
db: D1Database,
playerId: number,
roomId: number,
column: 'cheered' | 'favorited'
): Promise<Interaction> {
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<InteractionRow>()
)
}
/** Toggle the player's cheer on a room, returning the resulting interaction. */
export async function toggleCheer(
db: D1Database,
playerId: number,
roomId: number
): Promise<Interaction> {
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<Interaction> {
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<Interaction> {
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<Interaction> {
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<Interaction> {
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<string, string[]> = {
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<string, unknown> | 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<string>): 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<RoomRow>()
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<string[]> {
const q = query.trim().toLowerCase()
if (q === '' || take <= 0) return []
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${PUBLIC_WHERE}`)
.all<RoomRow>()
// 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<string, [number, string]>()
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, RoomStats>): number {
const counts = stats.get(roomIdOf(room))
const stored = room.Stats as Record<string, unknown> | 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<RoomRow>()
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<Map<number, number>> {
// `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<number, number>()
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<RoomRow>()
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<RoomRow>()
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<Room[]> {
const { results } = await db
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>()
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<RoomRow>()
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<FeaturedRoomGroup> {
const { results } = await db
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>()
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<RoomRow>()
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<Room[]> {
// 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<RoomRow>()
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<string | null> {
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<Room | null> {
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<RoomRow>()
)
)
}
/**
* 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<Room> {
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<string, unknown>)
: { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 }
// Named after the owner: `@<username>'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
}