Files
recflare/packages/domain/src/rooms-db.ts
T
2026-09-03 11:04:54 -04:00

2933 lines
115 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 { Accessibility, Role } from './enums'
import { countPlayersByRoom } from './presence-db'
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
export const ROOM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL,
-- Lifetime visit counter (migrations/0011_room_visits.sql, which appends it here):
-- bumped once per successful matchmake into the room by {@link recordRoomVisit},
-- and served as the room's \`Stats.VisitCount\`. A real column rather than a field
-- in the blob so a visit is one atomic UPDATE that can't lose a concurrent
-- read-modify-write of the whole room.
visits INTEGER NOT NULL DEFAULT 0,
-- The two flags every public feed filters on, alongside \`is_dorm\`
-- (migrations/0014_room_listable.sql, which appends them here). Generated like the
-- rest so the blob stays the only copy; they exist to be INDEXED — see
-- {@link LISTABLE_WHERE}.
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`,
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`,
// PARTIAL index over the public, non-dorm rooms — the only rooms any feed can serve,
// and a small minority of the table (most rooms are dorms, one per account). Scanning
// it visits those rooms alone instead of every room in the database; see
// {@link LISTABLE_WHERE} for why the feeds select on it.
//
// Indexed on `room_id` because a partial index needs some column to key on and the
// feeds all order by it eventually; the WHERE clause is the point, not the key.
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
// `subroom_save` already use, so the two can't drift.
//
// `tag` is stored lowercased and is the lookup key, which is what lets a tag-filtered
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
// auto-derived ones like `rro` — echoed back as stored.
//
// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
`CREATE TABLE IF NOT EXISTS room_tag (
room_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
is_primary_genre INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
// Per-player interaction state with a room (cheered/favorited + last visit).
// One row per (player, room); cheer/favorite are toggled in place.
`CREATE TABLE IF NOT EXISTS interaction (
player_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
favorited INTEGER NOT NULL DEFAULT 0,
last_visited_at TEXT,
PRIMARY KEY (player_id, room_id)
)`,
// Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player),
// so re-banning someone already banned updates their row rather than appending.
// `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known
// yet (the client sends 0), so nothing interprets it.
//
// Deliberately NOT in the room's `data` blob: that blob is served to the client
// verbatim as the room, and a ban list is not something every reader of a room
// should receive.
`CREATE TABLE IF NOT EXISTS room_ban (
room_id INTEGER NOT NULL,
banned_player_id INTEGER NOT NULL,
ban_mask INTEGER NOT NULL DEFAULT 0,
banned_by_account_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (room_id, banned_player_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
// Per-room leaderboard definitions (migrations/0016_room_leaderboard.sql). One row per
// (room, leaderboard): `leaderboard_id` is the client's slot number — small ordinals
// (1, 2, 3…), unique only within the room — so the pair is the key, and re-posting a
// slot reconfigures it in place rather than appending.
//
// Deliberately NOT in the room's `data` blob, same reasoning as `room_ban`: the blob is
// served verbatim as the room and the client doesn't read leaderboards off it.
`CREATE TABLE IF NOT EXISTS room_leaderboard (
room_id INTEGER NOT NULL,
leaderboard_id INTEGER NOT NULL,
leaderboard_title TEXT NOT NULL,
stat_format INTEGER NOT NULL DEFAULT 0,
sort_ascending INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, leaderboard_id)
)`,
]
/**
* Subroom schema DDL (mirror of migrations/0007_subrooms.sql). Subrooms are
* first-class entities with their own globally-unique, autoincrementing id — the
* original game mints `SubRoomId` from a single sequence, not per-room — so they
* live in their own table rather than inside the room JSON blob. `data` holds the
* rest of the subroom's client shape; `sub_room_id`/`room_id` are the authoritative
* columns (re-injected over `data` on read). Rooms re-embed their `SubRooms` array
* on read (see {@link getRoomById}); nothing persists SubRooms back into the room blob.
*/
export const SUBROOM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS subroom (
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
data TEXT NOT NULL,
current_save_id INTEGER,
staged_save_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`,
// Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a
// globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got
// their own table in 0007. It HAS to be global because a subroom points at saves by
// bare id: `current_save_id` is the live/published save the loader downloads,
// `staged_save_id` the creator's unpublished one. Per-subroom numbering would make
// every subroom's first save id 1 and those pointers ambiguous.
//
// `data` holds the save's client shape minus its two id fields; the columns are
// authoritative and are re-injected on read, exactly how `subroom` treats its own ids.
// A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is
// never stored in the subroom blob.
//
// Part of this DDL rather than its own export: reading a subroom joins this table, so
// applying one without the other yields a schema that can't serve a room.
`CREATE TABLE IF NOT EXISTS subroom_save (
sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
sub_room_id INTEGER NOT NULL,
data TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
// owner's permission table for one subroom, keyed by (permission, role) — that pair is
// what the client's PUT addresses, and re-sending it overwrites the stored row rather
// than appending a second one.
//
// A row IS an override, which is why the client's `Override` flag is not a column: it's
// the checkbox next to the permission, so clearing it deletes the row and the pair falls
// back to its default. `value` is the client's string, stored verbatim.
//
// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
// verbatim as part of the room, and these overrides are read on one path only
// (`GET /photon_access_token`, where they overwrite the matching default entries).
`CREATE TABLE IF NOT EXISTS subroom_permission (
sub_room_id INTEGER NOT NULL,
permission TEXT NOT NULL,
role INTEGER NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
value TEXT NOT NULL,
PRIMARY KEY (sub_room_id, permission, role)
)`,
]
/** A stored room — the parsed JSON blob (full client-facing room response). */
export type Room = Record<string, unknown>
/** A room role assignment (the client's RoomRole shape). */
interface RoomRole {
AccountId: number
Role: number
LastChangedByAccountId: number | null
InvitedRole: number
}
/**
* Room roles that confer owner-level management of a room: Creator (255) and
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and
* Moderator are lower tiers and are deliberately excluded.)
*/
const MANAGE_ROLES: ReadonlySet<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
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
}
/**
* Whether an account may MODERATE a room — its creator, or the holder of a role at
* Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
* of: a moderator polices who is in the room right now (kicking someone out of an
* instance) without being trusted to change the room itself, while everyone who can
* manage a room can obviously also police it, so CoOwner and Creator pass here too.
*
* Host (10) is deliberately below the line: it is the "runs this session" tier, which the
* client hands out freely, and a kick is a moderation power rather than a hosting one.
*/
export function canModerateRoom(room: Room, accountId: number): boolean {
if (room.CreatorAccountId === accountId) return true
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
return roles.some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
}
/** A player banned from a room (a `room_ban` row). */
export interface RoomBan {
RoomId: number
BannedPlayerId: number
/** The client's `banMask`, stored verbatim — its meaning isn't known yet. */
BanMask: number
BannedByAccountId: number
CreatedAt: string
}
interface RoomBanRow {
room_id: number
banned_player_id: number
ban_mask: number
banned_by_account_id: number
created_at: string
}
const toRoomBan = (row: RoomBanRow): RoomBan => ({
RoomId: row.room_id,
BannedPlayerId: row.banned_player_id,
BanMask: row.ban_mask,
BannedByAccountId: row.banned_by_account_id,
CreatedAt: row.created_at,
})
/**
* Ban a player from a room, returning the stored ban. One row per (room, player):
* re-banning someone already banned rewrites their row with the new mask and issuer
* rather than appending a second one, so the call is idempotent.
*/
export async function banPlayerFromRoom(
db: D1Database,
roomId: number,
bannedPlayerId: number,
banMask: number,
bannedByAccountId: number
): Promise<RoomBan> {
const row = await db
.prepare(
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(room_id, banned_player_id) DO UPDATE SET
ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5
RETURNING *`
)
.bind(roomId, bannedPlayerId, banMask, bannedByAccountId, new Date().toISOString())
.first<RoomBanRow>()
// RETURNING always yields the upserted row.
return toRoomBan(row!)
}
/**
* Lift a player's ban on a room, returning the ban that was removed — or null when
* they weren't banned, which lets the caller tell a real unban from a no-op.
*/
export async function unbanPlayerFromRoom(
db: D1Database,
roomId: number,
bannedPlayerId: number
): Promise<RoomBan | null> {
const row = await db
.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *')
.bind(roomId, bannedPlayerId)
.first<RoomBanRow>()
return row ? toRoomBan(row) : null
}
/** Everyone banned from a room, most recently banned first. */
export async function getRoomBans(db: D1Database, roomId: number): Promise<RoomBan[]> {
const { results } = await db
.prepare('SELECT * FROM room_ban WHERE room_id = ?1 ORDER BY created_at DESC')
.bind(roomId)
.all<RoomBanRow>()
return results.map(toRoomBan)
}
/** Whether a player is banned from a room. */
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')
.bind(roomId, playerId)
.first<{ hit: number }>()
return row !== null
}
/** A room's leaderboard definition — one configured slot (`leaderboard_id` is per-room). */
export interface RoomLeaderboard {
RoomId: number
LeaderboardId: number
LeaderboardTitle: string
StatFormat: number
SortAscending: boolean
}
interface RoomLeaderboardRow {
room_id: number
leaderboard_id: number
leaderboard_title: string
stat_format: number
sort_ascending: number
}
const toRoomLeaderboard = (row: RoomLeaderboardRow): RoomLeaderboard => ({
RoomId: row.room_id,
LeaderboardId: row.leaderboard_id,
LeaderboardTitle: row.leaderboard_title,
StatFormat: row.stat_format,
SortAscending: row.sort_ascending === 1,
})
/**
* Create or reconfigure one of a room's leaderboard slots, returning the stored
* definition. One row per (room, leaderboard): re-posting a slot rewrites its title,
* format and direction rather than appending a second row, so the call is idempotent.
*/
export async function setRoomLeaderboard(
db: D1Database,
roomId: number,
leaderboardId: number,
leaderboardTitle: string,
statFormat: number,
sortAscending: boolean
): Promise<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
}
/**
* Clone an existing room into a new one owned by `accountId`. Copies the source
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
* name, and the new owner. The clone starts with an empty tag set — the source's
* tags (including the `base` template tag) do not carry over, so the owner tags the
* clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual
* "RRO" tag on it, and it starts PRIVATE rather than inheriting the source's
* visibility. Returns the new room, or null when the source isn't in D1 or disallows
* cloning.
*/
export async function cloneRoom(
db: D1Database,
sourceRoomId: number,
name: string,
accountId: number
): Promise<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: 0 },
]
const cloned: Room = {
...source,
RoomId: newRoomId,
Name: name,
CreatorAccountId: accountId,
IsDorm: false,
// Start fresh: drop every tag the source carried (including `base`).
Tags: [],
// A user clone is not a Rec Room Original — clear the inherited flag, or the
// client renders a virtual "RRO" tag on the clone.
IsRRO: false,
// A brand-new room is unpublished: the owner publishes it by setting the room's
// accessibility. Inheriting the source's would put the clone straight into the
// public feeds (hot/search/recommendations/similar all key on Accessibility === 1)
// the moment it was made — every clone of a PUBLIC source, template or player room.
Accessibility: Accessibility.Private,
Roles: roles,
// A fresh room has no engagement of its own — don't inherit the source's counters
// (the derived ones are recomputed per read, but the clone is returned as-is here).
Stats: storedStats(source.Stats),
CreatedAt: new Date().toISOString(),
}
// serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are
// inserted into the subroom table below with fresh globally-unique ids.
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run()
const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : []
const clonedSubRooms: SubRoom[] = []
for (const sub of sourceSubRooms) {
clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId }))
}
cloned.SubRooms = clonedSubRooms
// Inherited from the (parsed) source in practice; defaulted here too so a clone is
// never the one room shape missing them.
attachRoomDtoDefaults(cloned)
return cloned
}
/** Set a room's Description in place (the caller is responsible for the owner check). */
export async function setRoomDescription(
db: D1Database,
roomId: number,
description: string
): Promise<void> {
await db
.prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
.bind(roomId, description)
.run()
}
/** Set a room's Name in place (the caller checks ownership + name uniqueness first). */
export async function setRoomName(db: D1Database, roomId: number, name: string): Promise<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()
}
/**
* 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
}
/**
* Set a target account's room `Role` — updating their existing `Roles` entry or
* appending a new one — and stamp `LastChangedByAccountId` with the editor. The
* caller supplies the already-loaded room (after its owner/co-owner check) to avoid
* a re-read; the whole room JSON is rewritten. Returns the updated room.
*/
export async function setRoomRole(
db: D1Database,
roomId: number,
targetAccountId: number,
role: number,
changedByAccountId: number,
room: Room
): Promise<Room> {
const roles = Array.isArray(room.Roles) ? (room.Roles as RoomRole[]) : []
const existing = roles.find((r) => r.AccountId === targetAccountId)
if (existing) {
existing.Role = role
existing.LastChangedByAccountId = changedByAccountId
} else {
roles.push({
AccountId: targetAccountId,
Role: role,
LastChangedByAccountId: changedByAccountId,
InvitedRole: 0,
})
}
const updated: Room = { ...room, Roles: roles }
await db
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
.bind(roomId, serializeRoom(updated))
.run()
return updated
}
/**
* Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
* toggling one on clears any other main tag. Compared case-insensitively.
*
* Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the
* room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
*/
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
/**
* A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
*
* `user` is what a player types or picks. `auto` is what the client derives about the room
* and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
* A tag's category is orthogonal to whether it is the room's primary genre.
*/
export const RoomTagType = {
user: 0,
auto: 1,
derived: 2,
} as const
/**
* The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
* they compose: a single request may replace the user tags, add a derived one and move the
* genre, and it is applied as one write.
*/
export interface RoomTagEdit {
/**
* The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
* it has it, and adding one of {@link MAIN_TAGS} clears the others.
*/
toggle?: string
/**
* The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
* derived tags (`auto`, `derived`) are not the client's to send and are left alone.
*/
tags?: string[]
/**
* Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag,
* since the client posts the ones it wants rather than the full set. A tag already on
* the room is re-categorised rather than duplicated.
*/
autoTags?: string[]
/**
* The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
* every other tag keeps its place and loses the flag.
*/
primaryGenre?: string
}
/** A tag's name, lowercased — every comparison in here is case-insensitive. */
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()
/**
* Apply one request's worth of tag changes to a room and store the result. The caller
* supplies the already-loaded (owner-checked) room, so nothing is re-read.
*
* The changes are composed into ONE set and written once: a request naming tags, an auto
* tag and a genre is a single state for the room, and applying it in three writes would
* let a reader (or a failure) land between them.
*
* Only `room_tag` is written — the room blob carries no tags at all, so the room row is
* left alone.
*/
export async function applyRoomTagEdit(
db: D1Database,
roomId: number,
room: Room,
edit: RoomTagEdit
): Promise<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
/** Uploaded blob key for the room-level METADATA blob (a separate upload). */
roomDataFilename?: string
/**
* The save comment — a description of THIS revision, typed into the client's save box.
* It belongs to the save (and shows up in the `…/saves` history); it is not the room's
* public description, which only `PUT /rooms/:id/description` sets.
*/
description?: string
persistenceVersion?: number
inventionUsage?: string
/** Optional baked-asset id; emitted on the save only when present. */
unityAssetId?: string
/**
* The client's `AutoPublish`. True publishes the save outright (the author wants it
* live now); false/absent stages it for a manual `publish_save`. Dorms ignore this and
* always publish.
*/
autoPublish?: boolean
}
/**
* A subroom's `CurrentSave` — the `SubRoomDataSave` the client reads to find the scene
* data blob to download. The loader looks ONLY here: a subroom with no `CurrentSave`
* loads nothing, no matter what the (legacy, flat) `DataBlob` field says.
*/
export type SubRoomDataSave = Record<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 four array fields are always empty (we neither resolve nor record
* referenced Unity assets) but must be PRESENT, and `UnityAssetId` is emitted only when
* the save actually carried one, exactly as the reference does. There is deliberately no
* `DataBlobHash`: it is commented out of the reference DTO and absent from its output.
*
* `SavedOnPlatform`/`SavedOnDeviceClass` are 0 — the reference fills them from the saving
* player's live platform/device, which the save request doesn't carry and we don't track.
*
* Shared by the save path and the legacy-shape reconstruction so the two can't drift.
*/
function buildSubRoomSave(input: BuildSaveInput): SubRoomDataSave {
const save: SubRoomDataSave = {
UnitySubAssets: [],
ReferencedUnityAssets: [],
SubRoomId: input.subRoomId,
DataBlob: input.dataBlob,
// The client sends `SubRoomData.Hash` (usually null); the room-save response echoes
// it as `dataBlobHash`. One observed room payload carries it on `CurrentSave` and
// another omits it, so storing it and letting it ride along is the safe reading.
DataBlobHash: input.dataBlobHash,
ReferencedUnityAssetIds: [],
PersistenceVersion: input.persistenceVersion,
OMVersion: 0,
UgcSubVersion: 0,
SavedByAccountId: input.savedByAccountId,
SavedOnPlatform: 0,
SavedOnDeviceClass: 0,
Description: input.description,
Tags: [],
ModerationState: 0,
CreatedAt: input.createdAt,
}
if (input.unityAssetId) save.UnityAssetId = input.unityAssetId
return save
}
/**
* Build a save row from a subroom stored in the pre-`CurrentSave` shape, where the blob
* key sat in the flat `DataBlob`/`DataSavedAt`/`PersistenceVersion` fields. Those
* subrooms hold real saved content the client cannot see (it reads `CurrentSave` only),
* so they get a save of their own rather than reading as never-saved. Mirrors backfill 2
* of migration 0008 — keep the two in sync.
*
* Returns null when there is genuinely nothing saved, the honest answer for a fresh
* subroom.
*/
function legacySubRoomSave(sub: SubRoom): SubRoomDataSave | null {
const blob = sub.DataBlob
if (typeof blob !== 'string' || blob === '') return null
const savedAt = typeof sub.DataSavedAt === 'string' ? sub.DataSavedAt : new Date(0).toISOString()
return buildSubRoomSave({
subRoomId: sub.SubRoomId,
dataBlob: blob,
dataBlobHash: null,
persistenceVersion: typeof sub.PersistenceVersion === 'number' ? sub.PersistenceVersion : 0,
// The legacy shape never recorded who saved; the subroom's creator is the best
// available answer (the save path is owner/co-owner gated).
savedByAccountId: sub.CreatorAccountId ?? null,
description: '',
createdAt: savedAt,
})
}
/**
* Persist a room-save against a specific subroom. Everything the save carries belongs to
* that subroom's revision — nothing is written to the room. Returns the updated
* (hydrated) room AND the save that was just created — the route answers with both — or
* null when the room or subroom doesn't exist.
*
* Whether the save goes live is the client's call: `AutoPublish: true` publishes it
* outright, otherwise it becomes the subroom's `staged_save_id` with the live
* `current_save_id` untouched, so what players load doesn't change until the room's
* creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have
* no publish flow in the client.
*/
export async function saveSubRoomData(
db: D1Database,
roomId: number,
subRoomId: number,
accountId: number,
input: SaveSubRoomDataInput
): Promise<{ room: Room; save: SubRoomDataSave } | null> {
const room = await getRoomById(db, roomId)
if (!room) return null
// Read off the already-hydrated room rather than re-querying the subroom and its
// save — getRoomById has both, and this path is write-heavy enough already.
const sub = findSubRoom(room, subRoomId)
if (!sub) return null
// Populate the subroom's creator on first save — it starts null, and the
// client NREs on a null CreatorAccountId. Only the owner reaches this path.
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
// Append a new save row. The blob the loader downloads lives on the save — a subroom
// whose current_save_id resolves to nothing loads nothing — so this never touches the
// flat DataBlob field. Previous saves stay in the table as history.
//
// A staged save carries forward from the previous STAGED one when there is one, so a
// creator's second edit builds on their first rather than on what's live.
const staged =
typeof sub.StagedSubRoomDataSaveId === 'number'
? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId)
: null
const previous =
staged ??
(sub.CurrentSave && typeof sub.CurrentSave === 'object'
? (sub.CurrentSave as SubRoomDataSave)
: undefined)
const priorVersion = previous?.PersistenceVersion
const priorBlob = previous?.DataBlob
const save = await insertSubRoomSave(
db,
subRoomId,
buildSubRoomSave({
subRoomId,
// A save that carries no new blob (e.g. a description-only save) keeps the one
// the subroom already loads from.
dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
dataBlobHash: input.subRoomDataHash ?? null,
persistenceVersion:
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
savedByAccountId: accountId,
// The save comment — empty string, not null, when the save carries none (the
// reference's `roomDesc ?? ""`).
description: input.description ?? '',
createdAt: new Date().toISOString(),
unityAssetId: input.unityAssetId,
})
)
const saveId = Number(save.SubRoomDataSaveId)
if (input.roomDataFilename) sub.RoomDataBlob = input.roomDataFilename
sub.DataSavedAt = new Date().toISOString()
if (input.persistenceVersion !== undefined) sub.PersistenceVersion = input.persistenceVersion
if (input.inventionUsage !== undefined) sub.InventionUsage = input.inventionUsage
// Nothing here touches the ROOM. A room save is a revision of one SUBROOM, and every
// field it carries describes that revision: `Description` is the save comment shown in
// the `…/saves` history, `PersistenceVersion` and `InventionUsage` describe the scene
// just saved. They used to be copied onto the room as well, which meant each save
// silently replaced the room's public description with the save comment. The room's own
// fields are edited through their own routes (`PUT /rooms/:id/description` and
// friends), so the room row is not rewritten here at all.
// Publish outright when the client asked to (`AutoPublish`), or for a dorm — a dorm is
// the player's own private space with no publish step in the client, so staging one
// would leave their edits permanently invisible. Otherwise stage it and wait for
// `publish_save`. One round trip for the rest of the save.
const publishNow = input.autoPublish === true || room.IsDorm === true
await db.batch([
publishNow
? db
.prepare(
'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
)
.bind(subRoomId, saveId)
: db
.prepare('UPDATE subroom SET staged_save_id = ?2 WHERE sub_room_id = ?1')
.bind(subRoomId, saveId),
db
.prepare('UPDATE subroom SET data = ?2 WHERE sub_room_id = ?1')
.bind(subRoomId, serializeSubRoom(sub, roomId)),
])
// Re-hydrate so the returned room reflects the just-saved subroom.
await attachSubRooms(db, [room])
return { room, save }
}
/**
* Publish one of a subroom's saves by id: make it the `current_save_id` players load.
* This is the manual step every non-dorm room save waits on ({@link saveSubRoomData}
* only stages). Because it takes an explicit id it doubles as restore-a-save — the id
* can be any save in the subroom's history, not just the staged one.
*
* The staging slot is cleared only when the save being published IS the staged one, so
* restoring an older version doesn't silently discard newer unpublished work.
*
* The id is looked up scoped to the subroom, so one subroom can't publish another's save
* (ids are globally unique, so an unscoped lookup would happily resolve).
*
* Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) /
* `unknown_save` (no such save on this subroom).
*/
export async function publishSubRoomSave(
db: D1Database,
roomId: number,
subRoomId: number,
saveId: number
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> {
const sub = await getSubRoom(db, roomId, subRoomId)
if (!sub) return { ok: false, reason: 'not_found' }
if (!(await getSubRoomSaveById(db, subRoomId, saveId))) {
return { ok: false, reason: 'unknown_save' }
}
await db
.prepare(
`UPDATE subroom SET current_save_id = ?2,
staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END
WHERE sub_room_id = ?1`
)
.bind(subRoomId, saveId)
.run()
const room = await getRoomById(db, roomId)
if (!room) return { ok: false, reason: 'not_found' }
return { ok: true, room }
}
/** Fields from the client's subroom `modify` form (each applied only when supplied). */
export interface ModifySubRoomInput {
name?: string
accessibility?: number
maxPlayers?: number
}
/**
* Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers
* (the fields the client's subroom `modify` form carries). Only the supplied fields
* are changed; the subroom row is updated in the `subroom` table. Returns the updated
* (hydrated) room, or null when the room or subroom doesn't exist.
*/
export async function modifySubRoom(
db: D1Database,
roomId: number,
subRoomId: number,
input: ModifySubRoomInput
): Promise<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
if (input.maxPlayers !== undefined) sub.MaxPlayers = input.maxPlayers
await updateSubRoom(db, sub)
return getRoomById(db, roomId)
}
/**
* Clone an existing subroom into a new subroom of the same room, owned by
* `accountId`. The copy keeps the source's scene/settings (and its saved data
* blobs, so it loads identical content) but gets a fresh globally-unique SubRoomId
* minted from the `subroom` table's autoincrement sequence. Returns the updated
* (hydrated) room and the new subroom, or null when the room or source subroom
* doesn't exist.
*/
export async function cloneSubRoom(
db: D1Database,
roomId: number,
subRoomId: number,
accountId: number
): Promise<{ room: Room; subRoom: SubRoom } | null> {
const source = await getSubRoom(db, roomId, subRoomId)
if (!source) return null
const subRoom = await insertSubRoom(db, roomId, { ...source, CreatorAccountId: accountId })
const room = await getRoomById(db, roomId)
if (!room) return null
return { room, subRoom }
}
/** Fallback scene, used only when a room has no existing subroom to inherit from. */
const DEFAULT_SUBROOM_SCENE = '76d98498-60a1-430c-ab76-b54a29b7a163'
/**
* The scene a brand-new subroom inherits: the room's own first (existing) subroom —
* lowest SubRoomId — read from the subroom table. Falls back to the base sandbox scene
* only when the room has no subrooms yet.
*/
async function baseSubRoomScene(db: D1Database, roomId: number): Promise<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 }
}
interface RoomRow {
data: string
visits: number
}
/**
* The columns every room read selects. `visits` is authoritative for the room's
* `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to
* come back with the blob on every read; a join aliases them (`r.data AS data`).
*/
const ROOM_COLUMNS = 'data, visits'
/**
* The rooms a public feed may consider, as a SQL predicate: public, not a dorm, and not
* opted out of lists — the same test {@link isListable} makes in memory, pushed down so
* the blobs of the rooms that fail it never cross the wire. `IS NOT 1` rather than `= 0`
* because a blob missing the key extracts as NULL, which the JS `!== true` accepts.
*
* The feeds all scanned the whole table and threw most of it away: a server's rooms are
* mostly DORMS (one per account, private by construction), so a scan read megabytes of
* blob to rank a hundred rooms. `idx_room_public` covers the first two terms, so this
* visits only the rooms that can actually be served.
*
* The in-memory filter STAYS wherever this is used. It costs nothing once the set is
* small, and it — not the SQL — remains the definition of listable: a blob with a
* surprising type in one of these fields (`"1"` for `Accessibility`, say) would satisfy
* the column's integer affinity while failing `=== 1` in JS, and the feeds must agree
* with {@link isListable} rather than with SQLite.
*/
const LISTABLE_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_lists IS NOT 1'
/**
* The wider half of {@link LISTABLE_WHERE}: public and not a dorm, without the
* `ExcludeFromLists` term. What SEARCH considers — a room can opt out of the browse feeds
* and still be findable by name — so the two searching reads select on this instead.
*/
const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
/**
* Keys on the client's room DTO that nothing here stores, defaulted on every read so the
* key is PRESENT rather than absent — the seed blobs and every room written since predate
* them, so they can't come from the data:
*
* - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so
* it is 0 for every room.
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
* null, which is also what the reference serves for a room that has none.
* - `CCU` — concurrent users. No live-population counter exists here, so it is null, which
* is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
* is in here" in the browse feeds).
*
* Defaulted rather than assigned, so a stored value wins if any is ever really written
* (a blob keeps whatever `serializeRoom` last put in it).
*/
function attachRoomDtoDefaults(room: Room): void {
room.BoostCount ??= 0
room.CurrentSnapshotId ??= null
room.CCU ??= null
}
/**
* Parse a room row: the stored blob with the counters the columns own folded back in.
* `visits` is a real column, so a room read straight from the DB carries the live count.
*/
const parseRow = (row: RoomRow): Room => {
const room = JSON.parse(row.data) as Room
room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 }
attachRoomDtoDefaults(room)
return room
}
const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null)
const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow)
/**
* D1 caps a prepared statement at 100 bound parameters — binding more fails outright with
* "variable number must be between ?1 and ?100". Every `IN (…)` list built from a caller's
* array has to respect this, which is easy to miss: a seeded dev database has fewer than a
* hundred rooms, so an unchunked query works right up until it meets a real one.
*/
const MAX_BOUND_PARAMS = 100
/**
* Split values into chunks that fit {@link MAX_BOUND_PARAMS}, for the reads whose rows are
* too heavy to fetch wholesale (subroom and save blobs) and so have to page through an
* `IN (…)` rather than scan.
*/
function chunkForBinds<T>(values: T[]): T[][] {
const chunks: T[][] = []
for (let i = 0; i < values.length; i += MAX_BOUND_PARAMS) {
chunks.push(values.slice(i, i + MAX_BOUND_PARAMS))
}
return chunks
}
/**
* Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the
* placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each
* chunk is its own statement.
*
* Rows come back in chunk order, and each chunk is ordered by whatever `sql` says. Callers
* that group by a key stay correct as long as a key's rows can't straddle two chunks —
* true for both callers here, which chunk BY that key.
*/
async function selectInChunks<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)
})
}
// ---- 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 = ''): { sql: string; binds: string[] } {
// `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link PUBLIC_WHERE}) —
// unqualified, which is unambiguous under either shape below. It matters most when
// `tagSets` is EMPTY: that branch is the full scan every pseudo-tag feed still runs.
const filter = where === '' ? '' : ` WHERE ${where}`
if (tagSets.length === 0) return { sql: `SELECT ${ROOM_COLUMNS} FROM room${filter}`, binds: [] }
const binds: string[] = []
const joins = tagSets.map((tags, i) => {
const placeholders = tags.map((_, j) => `?${binds.length + j + 1}`).join(', ')
binds.push(...tags)
return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i}
ON f${i}.room_id = r.room_id`
})
// `data`/`visits` are unqualified but unambiguous: the joined subqueries expose only
// `room_id`.
return { sql: `SELECT ${ROOM_COLUMNS} FROM room r ${joins.join(' ')}${filter}`, binds }
}
/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<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 }
/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */
const STATS_ID_LIMIT = 90
/** A room's RoomId, or 0 for a blob without one. */
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
/**
* The `Stats` object to persist: whatever the room carried, with the counters the
* columns/tables own back at 0 so the blob never holds a stale copy of them.
*/
function storedStats(stats: unknown): Record<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` in ONE grouped query.
* Restricted to `roomIds` when given (a feed page), otherwise covering every room —
* which is also what a page too large to bind gets, since scanning the whole table is
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
*/
export async function getRoomStats(
db: D1Database,
roomIds?: number[]
): Promise<Map<number, RoomStats>> {
const byRoom = new Map<number, RoomStats>()
if (roomIds && roomIds.length === 0) return byRoom
const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : []
const where =
ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : ''
const { results } = await db
.prepare(
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
FROM interaction ${where} GROUP BY room_id`
)
.bind(...ids)
.all<RoomStatsRow>()
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>()
return results.map(parseSubRoomSaveRow)
}
/**
* A single save by its globally-unique id, scoped to the subroom that owns it (the
* restore-a-save lookup). Null when the id is unknown or belongs to another subroom.
*/
export async function getSubRoomSaveById(
db: D1Database,
subRoomId: number,
saveId: number
): Promise<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>()
return row ? parseSubRoomSaveRow(row) : null
}
// ---- Subroom permissions --------------------------------------------------
/**
* One entry of a subroom's permission table, in the client's own shape. `Value` is a
* STRING, not a boolean — usually `"True"`/`"False"`, but a permission whose UI isn't a
* True/False picker carries something else, so it is stored and served verbatim. `Role`
* is the tier the entry applies to (0 = everyone, 30 = co-owner, …). `Permission` + `Role`
* identify an entry: the client PUTs the pair it wants changed, and the same pair
* overwrites the matching default in the photon access token's table.
*
* `Override` is the row's own existence, not data: the client's UI is a checkbox ("is
* this permission overridden in this subroom?") plus a True/False picker for the value.
* Unchecking it means "fall back to the default", so an entry arriving with
* `Override: false` DELETES the stored row rather than storing anything. Every stored
* entry is therefore an override, and reads always serve `Override: true`.
*/
export interface RoomPermission {
Permission: string
Role: number
Override: boolean
Type: number
Value: string
}
interface RoomPermissionRow {
permission: string
role: number
type: number
value: string
}
const toRoomPermission = (row: RoomPermissionRow): RoomPermission => ({
// A stored row IS the override — the table holds nothing else (see RoomPermission).
Override: true,
Permission: row.permission,
Role: row.role,
Type: row.type,
Value: row.value,
})
/** The permission columns, in the order the read/copy statements use. */
const PERMISSION_COLUMNS = 'permission, role, type, value'
/**
* A subroom's stored permission overrides, in the order they were first set. Empty for a
* subroom whose owner has never overridden a permission — the photon access token then
* serves its defaults untouched.
*/
export async function getSubRoomPermissions(
db: D1Database,
subRoomId: number
): Promise<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),
])
}
/** 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).
*/
export async function getVisitedRooms(
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.last_visited_at IS NOT NULL
ORDER BY i.last_visited_at DESC`
)
.bind(playerId)
.all<RoomRow>()
return hydrateRooms(db, parseAll(results).slice(skip, skip + take))
}
/** A player's interaction state with a room. */
export interface Interaction {
Cheered: boolean
Favorited: boolean
}
interface InteractionRow {
cheered: number
favorited: number
}
const toInteraction = (row: InteractionRow | null): Interaction => ({
Cheered: row?.cheered === 1,
Favorited: row?.favorited === 1,
})
/** Read a player's interaction with a room (defaults to all-false if none). */
export async function getInteraction(
db: D1Database,
playerId: number,
roomId: number
): Promise<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
)
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, fresh.slice(skip, skip + take)),
TotalResults: fresh.length,
}
}
// `community` is a pseudo-tag: it filters on who MADE the room rather than on any tag,
// so it can't be pushed into the tag query above. A real tag already narrowed there.
if (t === COMMUNITY_TAG) {
rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID)
}
const players = await countPlayersByRoom(db)
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
const stats = await getRoomStats(db)
rooms.sort(
(a, b) =>
playerCount(b) - playerCount(a) ||
hotScore(b, stats) - hotScore(a, stats) ||
roomIdOf(a) - roomIdOf(b)
)
return {
Results: await hydrateRooms(db, rooms.slice(skip, skip + take), stats),
TotalResults: rooms.length,
}
}
/**
* When each room's live scene was last PUBLISHED, as epoch millis keyed by RoomId: the
* newest `CurrentSave.CreatedAt` across the room's subrooms.
*
* Published, not merely saved. A staged save bumps the subroom's `DataSavedAt` but changes
* nothing anyone else can load, so ordering by that would float rooms whose visible content
* never moved — only `current_save_id`, what the loader actually serves, counts here.
*
* A room with no published save is ABSENT from the map rather than mapped to 0, so the
* caller can tell "never published" from "published at the epoch" and choose its own
* fallback.
*/
async function lastPublishedAtByRoom(db: D1Database): Promise<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 ${ROOM_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, rooms.slice(skip, skip + take)),
TotalResults: rooms.length,
}
}
/**
* The "new" discovery row: listable, player-made rooms newest FIRST by creation time.
*
* Close to the browse screen's `tag=new` chip (see {@link NEW_TAG}) but not the same test:
* the chip drops Rec Room Originals, this drops the Coach account's rooms. Both mean
* "player-made" and agree on the data as it stands — the discovery rows deliberately all
* use ownership ({@link isPlayerMade}) so one row cannot include a room its sibling row
* excludes.
*
* Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. RoomId breaks
* ties, newest first, so rooms created in the same instant still page stably.
*/
export async function getNewRooms(
db: D1Database,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<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, 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 ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>()
const stats = await getRoomStats(db)
return hydrateRooms(
db,
parseAll(results)
.filter(isListable)
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
.slice(skip, skip + take),
stats
)
}
/**
* Trending ("rising") rooms — the listable rooms someone is standing in RIGHT NOW, busiest
* first. What the `rising` carousel is filled from.
*
* This is the one feed where live presence FILTERS rather than merely ranks: the hot feed
* sorts by head-count but still lists the empty rooms underneath it, and a carousel of
* rooms nobody is in is not trending. So a quiet server serves an EMPTY carousel rather
* than falling back to stored engagement — a room with no one in it has not risen.
*
* Ties break the way the hot feed's do (stored engagement, then RoomId), so equally busy
* rooms page stably.
*/
export async function getTrendingRooms(
db: D1Database,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const players = await countPlayersByRoom(db)
// Nobody anywhere: nothing can be trending, and the room table needn't be read at all.
if (players.size === 0) return { Results: [], TotalResults: 0 }
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<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, 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 ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<RoomRow>()
const rooms = parseAll(results).filter(isListable)
// FisherYates shuffle so the feed varies between requests.
for (let i = rooms.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[rooms[i], rooms[j]] = [rooms[j], rooms[i]]
}
rooms.length = Math.min(rooms.length, FEATURED_ROOM_LIMIT)
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
return {
FeaturedRoomGroupId: 1,
name: 'Featured Rooms',
StartAt: '2025-12-01T11:01:00Z',
EndAt: '9999-12-08T11:00:00Z',
Rooms: rooms.map((r) => ({
RoomId: num(r.RoomId),
RoomName: str(r.Name),
ImageName: str(r.ImageName),
IsRecRoomApproved: r.IsRecRoomApproved === true,
ExcludeFromLists: r.ExcludeFromLists === true,
ExcludeFromSearch: r.ExcludeFromSearch === true,
})),
}
}
/**
* Rooms similar to a target room: public, non-dorm rooms (excluding the target)
* that share at least one tag with it, ranked by shared-tag count then
* engagement. Returns a paginated `{ Results, TotalResults }` (the client's
* RoomSimilarity source expects an object, not a bare array); empty if the target
* isn't in D1 or is untagged. Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the
* tag ranking is in memory.
*/
export async function getSimilarRooms(
db: D1Database,
roomId: number,
skip: number,
take: number
): Promise<{ Results: Room[]; TotalResults: number }> {
const empty = { Results: [] as Room[], TotalResults: 0 }
const target = await getRoomById(db, roomId)
if (!target) return empty
const targetTags = new Set(roomTags(target))
if (targetTags.size === 0) return empty
const { results } = await db
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
.all<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, 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: 0 },
],
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
Stats: storedStats(template?.Stats),
CreatedAt: new Date().toISOString(),
}
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
// subroom is inserted into the subroom table below with a fresh globally-unique id.
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
room.SubRooms = [subRoom]
// The template carries these (it was parsed), but a dorm minted without one wouldn't.
attachRoomDtoDefaults(room)
return room
}