mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
3817 lines
151 KiB
TypeScript
3817 lines
151 KiB
TypeScript
/**
|
||
* Room storage on the shared `recflare` D1 database. Each room is a single JSON
|
||
* blob in the `data` column; queryable fields (RoomId, Name, CreatorAccountId,
|
||
* IsDorm) are SQLite generated (virtual) columns extracted from that JSON and
|
||
* indexed. This keeps the room shape flexible while still allowing fast lookups
|
||
* by id/name/creator — the same JSON-blob pattern `accounts-db` uses.
|
||
*
|
||
* `ROOM_SCHEMA_DDL` mirrors the head schema after all migrations (`0001_init.sql`
|
||
* created the table as `rooms`; `0005_rename_room.sql` renamed it to `room`); the
|
||
* room data is seeded from `apps/rooms/static/ImportRooms.json` by
|
||
* `migrations/0002_import_rooms.sql`. Tests apply `ROOM_SCHEMA_DDL` then seed the
|
||
* imported rooms directly.
|
||
*
|
||
* This module is the single source of truth for the helpers: the `rooms` worker
|
||
* (which owns the schema/migrations) uses the read/write set; the `match` worker
|
||
* uses the room lookups plus the dorm helpers; the `api` worker binds the same
|
||
* database read-only and uses `getRoomById`. Each imports the subset it needs.
|
||
*/
|
||
|
||
import { bindPlaceholders, chunkForBinds, MAX_BOUND_PARAMS } from './d1-binds'
|
||
import { Accessibility, Role } from './enums'
|
||
import { countPlayersByRoom } from './presence-db'
|
||
|
||
/** Schema DDL (mirror of the head migration schema, sans the seed INSERT). */
|
||
export const ROOM_SCHEMA_DDL: string[] = [
|
||
`CREATE TABLE IF NOT EXISTS room (
|
||
data TEXT NOT NULL,
|
||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL,
|
||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL,
|
||
-- Lifetime visit counter (migrations/0011_room_visits.sql, which appends it here):
|
||
-- bumped once per successful matchmake into the room by {@link recordRoomVisit},
|
||
-- and served as the room's \`Stats.VisitCount\`. A real column rather than a field
|
||
-- in the blob so a visit is one atomic UPDATE that can't lose a concurrent
|
||
-- read-modify-write of the whole room.
|
||
visits INTEGER NOT NULL DEFAULT 0,
|
||
-- The two flags every public feed filters on, alongside \`is_dorm\`
|
||
-- (migrations/0014_room_listable.sql, which appends them here). Generated like the
|
||
-- rest so the blob stays the only copy; they exist to be INDEXED — see
|
||
-- {@link LISTABLE_WHERE}.
|
||
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||
exclude_from_lists INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ExcludeFromLists')) VIRTUAL
|
||
)`,
|
||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON room (room_id)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON room (name_lower)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_rooms_creator ON room (creator_account_id)`,
|
||
// PARTIAL index over the public, non-dorm rooms — the only rooms any feed can serve,
|
||
// and a small minority of the table (most rooms are dorms, one per account). Scanning
|
||
// it visits those rooms alone instead of every room in the database; see
|
||
// {@link LISTABLE_WHERE} for why the feeds select on it.
|
||
//
|
||
// Indexed on `room_id` because a partial index needs some column to key on and the
|
||
// feeds all order by it eventually; the WHERE clause is the point, not the key.
|
||
`CREATE INDEX IF NOT EXISTS idx_room_public ON room (room_id)
|
||
WHERE is_dorm IS NOT 1 AND accessibility = 1`,
|
||
// A room's tags, one row per tag (migrations/0013_room_tag.sql). Modelled on the
|
||
// `api` worker's `event_tag`, and the table is AUTHORITATIVE: `serializeRoom` strips
|
||
// `Tags` from the blob and the reads re-attach it, the same arrangement `subroom` and
|
||
// `subroom_save` already use, so the two can't drift.
|
||
//
|
||
// `tag` is stored lowercased and is the lookup key, which is what lets a tag-filtered
|
||
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
|
||
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
|
||
// auto-derived ones like `rro` — echoed back as stored.
|
||
//
|
||
// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
|
||
// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
|
||
// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
|
||
// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
|
||
`CREATE TABLE IF NOT EXISTS room_tag (
|
||
room_id INTEGER NOT NULL,
|
||
tag TEXT NOT NULL,
|
||
type INTEGER NOT NULL DEFAULT 0,
|
||
is_primary_genre INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (room_id, tag)
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
|
||
// Per-player interaction state with a room (cheered/favorited + last visit).
|
||
// One row per (player, room); cheer/favorite are toggled in place.
|
||
`CREATE TABLE IF NOT EXISTS interaction (
|
||
player_id INTEGER NOT NULL,
|
||
room_id INTEGER NOT NULL,
|
||
cheered INTEGER NOT NULL DEFAULT 0,
|
||
favorited INTEGER NOT NULL DEFAULT 0,
|
||
last_visited_at TEXT,
|
||
PRIMARY KEY (player_id, room_id)
|
||
)`,
|
||
// Per-ROOM access (migrations/0021_interaction_room_index.sql): the primary key
|
||
// serves the per-player reads, but the cheer/favorite aggregate every room read runs
|
||
// (`getRoomStats`) filters by room_id, which without this scanned the whole table.
|
||
// Covering — both counters are in the index, so the aggregate never touches the table.
|
||
`CREATE INDEX IF NOT EXISTS idx_interaction_room ON interaction (room_id, cheered, favorited)`,
|
||
// Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player),
|
||
// so re-banning someone already banned updates their row rather than appending.
|
||
// `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known
|
||
// yet (the client sends 0), so nothing interprets it.
|
||
//
|
||
// Deliberately NOT in the room's `data` blob: that blob is served to the client
|
||
// verbatim as the room, and a ban list is not something every reader of a room
|
||
// should receive.
|
||
`CREATE TABLE IF NOT EXISTS room_ban (
|
||
room_id INTEGER NOT NULL,
|
||
banned_player_id INTEGER NOT NULL,
|
||
ban_mask INTEGER NOT NULL DEFAULT 0,
|
||
banned_by_account_id INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
-- migrations/0018_room_ban_reason_expiry.sql. NULL expires_at is a permanent ban.
|
||
reason TEXT,
|
||
expires_at TEXT,
|
||
PRIMARY KEY (room_id, banned_player_id)
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id)`,
|
||
// Room bans that have ENDED (migrations/0019_room_ban_history.sql). `room_ban` above holds
|
||
// only the ban in force — one row per (room, player), deleted on unban and overwritten on
|
||
// re-ban — so without this a lifted or lapsed ban leaves no trace. A row is written here
|
||
// at the moment a ban stops being the live one: when it is lifted (`status` 2, Lifted,
|
||
// `ended_at` the lift and `unbanned_by_account_id` who lifted it) or when a lapsed ban is
|
||
// replaced by a new one (`status` 1, Elapsed, `ended_at` its expiry). Append-only.
|
||
`CREATE TABLE IF NOT EXISTS room_ban_history (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
room_id INTEGER NOT NULL,
|
||
banned_player_id INTEGER NOT NULL,
|
||
ban_mask INTEGER NOT NULL DEFAULT 0,
|
||
banned_by_account_id INTEGER NOT NULL,
|
||
created_at TEXT NOT NULL,
|
||
reason TEXT,
|
||
expires_at TEXT,
|
||
status INTEGER NOT NULL,
|
||
ended_at TEXT,
|
||
unbanned_by_account_id INTEGER
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_room_ban_history_player
|
||
ON room_ban_history (room_id, banned_player_id)`,
|
||
// Per-room leaderboard definitions (migrations/0016_room_leaderboard.sql). One row per
|
||
// (room, leaderboard): `leaderboard_id` is the client's slot number — small ordinals
|
||
// (1, 2, 3…), unique only within the room — so the pair is the key, and re-posting a
|
||
// slot reconfigures it in place rather than appending.
|
||
//
|
||
// Deliberately NOT in the room's `data` blob, same reasoning as `room_ban`: the blob is
|
||
// served verbatim as the room and the client doesn't read leaderboards off it.
|
||
`CREATE TABLE IF NOT EXISTS room_leaderboard (
|
||
room_id INTEGER NOT NULL,
|
||
leaderboard_id INTEGER NOT NULL,
|
||
leaderboard_title TEXT NOT NULL,
|
||
stat_format INTEGER NOT NULL DEFAULT 0,
|
||
sort_ascending INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (room_id, leaderboard_id)
|
||
)`,
|
||
// Per-room, per-player variable data (migrations/0020_playerdata.sql) — what the client
|
||
// saves through `PUT /rooms/:id/playerdata/me` and reads back from the GET. One row per
|
||
// (room, player); `data` is the posted blob verbatim (a base64 string the server never
|
||
// decodes), and a re-save overwrites it, so only each player's latest is kept.
|
||
`CREATE TABLE IF NOT EXISTS playerdata (
|
||
room_id INTEGER NOT NULL,
|
||
player_id INTEGER NOT NULL,
|
||
data TEXT NOT NULL,
|
||
PRIMARY KEY (room_id, player_id)
|
||
)`,
|
||
]
|
||
|
||
/**
|
||
* Subroom schema DDL (mirror of migrations/0007_subrooms.sql). Subrooms are
|
||
* first-class entities with their own globally-unique, autoincrementing id — the
|
||
* original game mints `SubRoomId` from a single sequence, not per-room — so they
|
||
* live in their own table rather than inside the room JSON blob. `data` holds the
|
||
* rest of the subroom's client shape; `sub_room_id`/`room_id` are the authoritative
|
||
* columns (re-injected over `data` on read). Rooms re-embed their `SubRooms` array
|
||
* on read (see {@link getRoomById}); nothing persists SubRooms back into the room blob.
|
||
*/
|
||
export const SUBROOM_SCHEMA_DDL: string[] = [
|
||
`CREATE TABLE IF NOT EXISTS subroom (
|
||
sub_room_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
room_id INTEGER NOT NULL,
|
||
data TEXT NOT NULL,
|
||
current_save_id INTEGER,
|
||
staged_save_id INTEGER
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_subroom_room ON subroom (room_id)`,
|
||
// Room saves (migrations/0008_subroom_saves.sql). A save is its own entity with a
|
||
// globally-unique, autoincrementing `SubRoomDataSaveId` — the same reason subrooms got
|
||
// their own table in 0007. It HAS to be global because a subroom points at saves by
|
||
// bare id: `current_save_id` is the live/published save the loader downloads,
|
||
// `staged_save_id` the creator's unpublished one. Per-subroom numbering would make
|
||
// every subroom's first save id 1 and those pointers ambiguous.
|
||
//
|
||
// `data` holds the save's client shape minus its two id fields; the columns are
|
||
// authoritative and are re-injected on read, exactly how `subroom` treats its own ids.
|
||
// A subroom's `CurrentSave` is inlined from `current_save_id` on every read and is
|
||
// never stored in the subroom blob.
|
||
//
|
||
// Part of this DDL rather than its own export: reading a subroom joins this table, so
|
||
// applying one without the other yields a schema that can't serve a room.
|
||
`CREATE TABLE IF NOT EXISTS subroom_save (
|
||
sub_room_data_save_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
sub_room_id INTEGER NOT NULL,
|
||
data TEXT NOT NULL
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_subroom_save_sub ON subroom_save (sub_room_id)`,
|
||
// Per-subroom permission overrides (migrations/0009_subroom_permissions.sql). The room
|
||
// owner's permission table for one subroom, keyed by (permission, role) — that pair is
|
||
// what the client's PUT addresses, and re-sending it overwrites the stored row rather
|
||
// than appending a second one.
|
||
//
|
||
// A row IS an override, which is why the client's `Override` flag is not a column: it's
|
||
// the checkbox next to the permission, so clearing it deletes the row and the pair falls
|
||
// back to its default. `value` is the client's string, stored verbatim.
|
||
//
|
||
// Deliberately NOT in the subroom's `data` blob: that blob is served to the client
|
||
// verbatim as part of the room, and these overrides are read on one path only
|
||
// (`GET /photon_access_token`, where they overwrite the matching default entries).
|
||
`CREATE TABLE IF NOT EXISTS subroom_permission (
|
||
sub_room_id INTEGER NOT NULL,
|
||
permission TEXT NOT NULL,
|
||
role INTEGER NOT NULL,
|
||
type INTEGER NOT NULL DEFAULT 0,
|
||
value TEXT NOT NULL,
|
||
PRIMARY KEY (sub_room_id, permission, role)
|
||
)`,
|
||
// Which inventions a room has in it (migrations/0023_room_invention.sql): one row per
|
||
// (room, invention), re-derived on every room save from the `InventionUsage` each of the
|
||
// room's subrooms last reported — see {@link syncRoomInventions}. The second index is
|
||
// the reverse lookup, "which rooms use this invention" — what an invention's
|
||
// `NumPlayersHaveUsedInRoom` is counted from.
|
||
`CREATE TABLE IF NOT EXISTS room_invention (
|
||
room_id INTEGER NOT NULL,
|
||
invention_id INTEGER NOT NULL,
|
||
PRIMARY KEY (room_id, invention_id)
|
||
)`,
|
||
`CREATE INDEX IF NOT EXISTS idx_room_invention_invention ON room_invention (invention_id)`,
|
||
]
|
||
|
||
/** A stored room — the parsed JSON blob (full client-facing room response). */
|
||
export type Room = Record<string, unknown>
|
||
|
||
/** A room role assignment (the client's RoomRole shape). */
|
||
export interface RoomRole {
|
||
AccountId: number
|
||
Role: number
|
||
LastChangedByAccountId: number | null
|
||
InvitedRole: number
|
||
}
|
||
|
||
/**
|
||
* A room's `Roles`, always as whole {@link RoomRole} records. Roles live inside the room
|
||
* blob, and the older rooms either have no `Roles` key at all or carry entries written
|
||
* before `LastChangedByAccountId`/`InvitedRole` existed; every reader wants the complete
|
||
* shape, so the missing keys are filled in here rather than at each call site. Returns
|
||
* copies, so a caller that edits an entry must write the returned array back to the room.
|
||
*/
|
||
export function roomRoles(room: Room): RoomRole[] {
|
||
const roles = Array.isArray(room.Roles) ? (room.Roles as Array<Partial<RoomRole>>) : []
|
||
return roles.map((r) => ({
|
||
AccountId: Number(r.AccountId ?? 0),
|
||
Role: Number(r.Role ?? Role.None),
|
||
LastChangedByAccountId: r.LastChangedByAccountId ?? null,
|
||
InvitedRole: Number(r.InvitedRole ?? Role.None),
|
||
}))
|
||
}
|
||
|
||
/**
|
||
* Room roles that confer owner-level management of a room: Creator (255) and
|
||
* CoOwner (30). The reference gates its room-admin actions on this set. (Host and
|
||
* Moderator are lower tiers and are deliberately excluded.)
|
||
*/
|
||
const MANAGE_ROLES: ReadonlySet<number> = new Set([Role.Creator, Role.CoOwner])
|
||
|
||
/**
|
||
* Whether an account may manage a room — its creator, or the holder of a
|
||
* Creator/CoOwner role on the room's `Roles`. This is the owner-or-co-owner gate
|
||
* the reference applies to room-admin actions (editing room data, viewing a room's
|
||
* live instances). Shared so the `rooms` and `match` workers apply the same check
|
||
* rather than each re-deriving the role set.
|
||
*/
|
||
export function canManageRoom(room: Room, accountId: number): boolean {
|
||
if (room.CreatorAccountId === accountId) return true
|
||
return roomRoles(room).some((r) => r.AccountId === accountId && MANAGE_ROLES.has(r.Role))
|
||
}
|
||
|
||
/**
|
||
* Whether an account may manage the room with this id — {@link canManageRoom} against a room
|
||
* this reads for itself. Null when there is no such room, which a caller answers differently
|
||
* (a 404) from a room somebody else runs (a 403).
|
||
*
|
||
* Reads the room BLOB only, with none of {@link getRoomById}'s hydration. Ownership lives in
|
||
* `CreatorAccountId` and `Roles`, both of which are in the blob; the subrooms, tags and stats
|
||
* that hydration attaches are three more tables a caller would otherwise have to have just to
|
||
* ask who runs a room. That matters for the workers outside `rooms` that gate on room
|
||
* ownership — `econ` minting a room currency does not know what a subroom is.
|
||
*/
|
||
export async function canManageRoomById(
|
||
db: D1Database,
|
||
roomId: number,
|
||
accountId: number
|
||
): Promise<boolean | null> {
|
||
const room = parseOne(
|
||
await db
|
||
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id = ?1`)
|
||
.bind(roomId)
|
||
.first<RoomRow>()
|
||
)
|
||
if (!room) return null
|
||
return canManageRoom(room, accountId)
|
||
}
|
||
|
||
/**
|
||
* Whether an account may MODERATE a room — its creator, or the holder of a role at
|
||
* Moderator (20) or above. The wider gate that {@link canManageRoom} is the narrow one
|
||
* of: a moderator polices who is in the room right now (kicking someone out of an
|
||
* instance) without being trusted to change the room itself, while everyone who can
|
||
* manage a room can obviously also police it, so CoOwner and Creator pass here too.
|
||
*
|
||
* Host (10) is deliberately below the line: it is the "runs this session" tier, which the
|
||
* client hands out freely, and a kick is a moderation power rather than a hosting one.
|
||
*/
|
||
export function canModerateRoom(room: Room, accountId: number): boolean {
|
||
if (room.CreatorAccountId === accountId) return true
|
||
return roomRoles(room).some((r) => r.AccountId === accountId && r.Role >= Role.Moderator)
|
||
}
|
||
|
||
/**
|
||
* Whether an account OWNS a room — its `CreatorAccountId`, or the holder of the Creator
|
||
* (255) role. The narrowest of the three room gates: {@link canManageRoom} lets a CoOwner
|
||
* through and {@link canModerateRoom} a Moderator, but handing out co-ownership is the
|
||
* owner's alone, or a co-owner could quietly grow the set of people who can change the
|
||
* room, an offer the invited player then accepts themselves.
|
||
*/
|
||
export function isRoomOwner(room: Room, accountId: number): boolean {
|
||
if (room.CreatorAccountId === accountId) return true
|
||
return roomRoles(room).some((r) => r.AccountId === accountId && r.Role === Role.Creator)
|
||
}
|
||
|
||
/** A player banned from a room (a `room_ban` row). */
|
||
export interface RoomBan {
|
||
RoomId: number
|
||
BannedPlayerId: number
|
||
/** The client's `banMask`, stored verbatim — its meaning isn't known yet. */
|
||
BanMask: number
|
||
BannedByAccountId: number
|
||
CreatedAt: string
|
||
/** Free text from whoever issued the ban. Null when none was given. */
|
||
Reason: string | null
|
||
/** When the ban lapses (ISO-8601 UTC). Null is a permanent ban, lifted only by DELETE. */
|
||
ExpiresAt: string | null
|
||
}
|
||
|
||
interface RoomBanRow {
|
||
room_id: number
|
||
banned_player_id: number
|
||
ban_mask: number
|
||
banned_by_account_id: number
|
||
created_at: string
|
||
reason: string | null
|
||
expires_at: string | null
|
||
}
|
||
|
||
const toRoomBan = (row: RoomBanRow): RoomBan => ({
|
||
RoomId: row.room_id,
|
||
BannedPlayerId: row.banned_player_id,
|
||
BanMask: row.ban_mask,
|
||
BannedByAccountId: row.banned_by_account_id,
|
||
CreatedAt: row.created_at,
|
||
Reason: row.reason,
|
||
ExpiresAt: row.expires_at,
|
||
})
|
||
|
||
/**
|
||
* The SQL that keeps a ban row in force: permanent, or not yet lapsed. An expired row is not
|
||
* deleted when it lapses — nothing runs at that moment — so EVERY read that asks "is this
|
||
* player banned" has to apply this, or a 30-minute ban would last forever. the time is bound
|
||
* rather than using SQLite's clock so it compares in the same ISO-8601 format the rows are
|
||
* written in (`datetime('now')` has no `T` or `Z` and would sort wrongly against them).
|
||
* `now` names the placeholder the caller binds the current time to.
|
||
*/
|
||
const banInForce = (now: string): string => `(expires_at IS NULL OR expires_at > ${now})`
|
||
|
||
/** Where a ban stands, as the client's `Status` enum numbers it. */
|
||
export const RoomBanStatus = {
|
||
/** In force now. Only ever the `room_ban` row. */
|
||
Active: 0,
|
||
/** Ran out on its own. */
|
||
Elapsed: 1,
|
||
/** Ended early by someone unbanning the player. */
|
||
Lifted: 2,
|
||
} as const
|
||
|
||
/**
|
||
* One ban in a player's history, whatever state it is in. Timestamps are ISO-8601 as stored.
|
||
* `EndedAt` is when the ban stopped counting — its expiry if it lapsed, the moment it was
|
||
* lifted if it was lifted — and for an Active ban the expiry it is scheduled to reach, null
|
||
* when it is permanent.
|
||
*/
|
||
export interface RoomBanRecord {
|
||
Status: (typeof RoomBanStatus)[keyof typeof RoomBanStatus]
|
||
UnbannedByAccountId: number | null
|
||
EndedAt: string | null
|
||
Reason: string | null
|
||
AccountId: number
|
||
BannedByAccountId: number
|
||
StartedAt: string
|
||
}
|
||
|
||
interface RoomBanHistoryRow {
|
||
banned_player_id: number
|
||
banned_by_account_id: number
|
||
created_at: string
|
||
reason: string | null
|
||
status: RoomBanRecord['Status']
|
||
ended_at: string | null
|
||
unbanned_by_account_id: number | null
|
||
}
|
||
|
||
/**
|
||
* A player's ban history in one room: the ban in force, if any, and every ban that has ended,
|
||
* newest first.
|
||
*
|
||
* A `room_ban` row that has LAPSED is not active — but nothing moves it into
|
||
* `room_ban_history` until the player is banned or unbanned again, so it is reported here as
|
||
* an Elapsed previous ban rather than dropped. Only the row still in force is `active`.
|
||
*/
|
||
export async function getRoomBanHistory(
|
||
db: D1Database,
|
||
roomId: number,
|
||
playerId: number
|
||
): Promise<{ active: RoomBanRecord | null; previous: RoomBanRecord[] }> {
|
||
const now = new Date().toISOString()
|
||
const [current, ended] = await db.batch([
|
||
db
|
||
.prepare('SELECT * FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2')
|
||
.bind(roomId, playerId),
|
||
db
|
||
.prepare(
|
||
`SELECT * FROM room_ban_history WHERE room_id = ?1 AND banned_player_id = ?2
|
||
ORDER BY created_at DESC, id DESC`
|
||
)
|
||
.bind(roomId, playerId),
|
||
])
|
||
|
||
const previous = (ended.results as RoomBanHistoryRow[]).map((row): RoomBanRecord => ({
|
||
Status: row.status,
|
||
UnbannedByAccountId: row.unbanned_by_account_id,
|
||
EndedAt: row.ended_at,
|
||
Reason: row.reason,
|
||
AccountId: row.banned_player_id,
|
||
BannedByAccountId: row.banned_by_account_id,
|
||
StartedAt: row.created_at,
|
||
}))
|
||
|
||
const row = (current.results as RoomBanRow[])[0]
|
||
if (!row) return { active: null, previous }
|
||
const inForce = row.expires_at === null || row.expires_at > now
|
||
const record: RoomBanRecord = {
|
||
Status: inForce ? RoomBanStatus.Active : RoomBanStatus.Elapsed,
|
||
UnbannedByAccountId: null,
|
||
EndedAt: row.expires_at,
|
||
Reason: row.reason,
|
||
AccountId: row.banned_player_id,
|
||
BannedByAccountId: row.banned_by_account_id,
|
||
StartedAt: row.created_at,
|
||
}
|
||
if (inForce) return { active: record, previous }
|
||
// Newer than anything already filed, since filing it is what a later ban would do.
|
||
return { active: null, previous: [record, ...previous] }
|
||
}
|
||
|
||
/** Options for {@link banPlayerFromRoom}. */
|
||
export interface RoomBanOptions {
|
||
/** Shown to nobody yet; kept on the row. Empty is stored as null. */
|
||
reason?: string | null
|
||
/** Minutes until the ban lapses. Absent, null or 0 is permanent. */
|
||
durationMinutes?: number | null
|
||
}
|
||
|
||
/**
|
||
* Ban a player from a room, returning the stored ban. One row per (room, player):
|
||
* re-banning someone already banned rewrites their row with the new mask, issuer, reason and
|
||
* expiry rather than appending a second one, so the call is idempotent.
|
||
*
|
||
* A re-ban REPLACES the duration rather than extending it, and the clock restarts from now:
|
||
* banning someone for 30 minutes who has 5 left leaves them with 30, and re-banning with no
|
||
* duration makes the ban permanent. The latest ban issued is the one in force.
|
||
*
|
||
* History: if the row being overwritten had already LAPSED, that ban is over and is filed in
|
||
* `room_ban_history` as Elapsed first. A ban still in force is AMENDED rather than ended — the
|
||
* player has been banned continuously — so it is overwritten without leaving a history row.
|
||
* Both statements run as one batch, so the old ban is never lost between them.
|
||
*/
|
||
export async function banPlayerFromRoom(
|
||
db: D1Database,
|
||
roomId: number,
|
||
bannedPlayerId: number,
|
||
banMask: number,
|
||
bannedByAccountId: number,
|
||
options: RoomBanOptions = {}
|
||
): Promise<RoomBan> {
|
||
const now = new Date()
|
||
const minutes = options.durationMinutes ?? 0
|
||
const expiresAt = minutes > 0 ? new Date(now.getTime() + minutes * 60_000).toISOString() : null
|
||
const reason = options.reason?.trim() || null
|
||
|
||
const [, upserted] = await db.batch([
|
||
db
|
||
.prepare(
|
||
`INSERT INTO room_ban_history
|
||
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason,
|
||
expires_at, status, ended_at, unbanned_by_account_id)
|
||
SELECT room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason,
|
||
expires_at, ?4, expires_at, NULL
|
||
FROM room_ban
|
||
WHERE room_id = ?1 AND banned_player_id = ?2 AND NOT ${banInForce('?3')}`
|
||
)
|
||
.bind(roomId, bannedPlayerId, now.toISOString(), RoomBanStatus.Elapsed),
|
||
db
|
||
.prepare(
|
||
`INSERT INTO room_ban
|
||
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason, expires_at)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||
ON CONFLICT(room_id, banned_player_id) DO UPDATE SET
|
||
ban_mask = ?3, banned_by_account_id = ?4, created_at = ?5, reason = ?6, expires_at = ?7
|
||
RETURNING *`
|
||
)
|
||
.bind(
|
||
roomId,
|
||
bannedPlayerId,
|
||
banMask,
|
||
bannedByAccountId,
|
||
now.toISOString(),
|
||
reason,
|
||
expiresAt
|
||
),
|
||
])
|
||
// RETURNING always yields the upserted row.
|
||
return toRoomBan((upserted.results as RoomBanRow[])[0])
|
||
}
|
||
|
||
/**
|
||
* Lift a player's ban on a room, returning the ban that was removed — or null when
|
||
* they weren't banned, which lets the caller tell a real unban from a no-op. A ban that has
|
||
* already LAPSED counts as not banned; its stale row is deleted all the same.
|
||
*
|
||
* History: the removed ban is filed in `room_ban_history` in the same batch as the delete. A
|
||
* ban still in force is Lifted, ended now, by `unbannedByAccountId`. A lapsed one is Elapsed,
|
||
* ended at its expiry, by nobody — deleting the stale row is housekeeping, not a lift.
|
||
*/
|
||
export async function unbanPlayerFromRoom(
|
||
db: D1Database,
|
||
roomId: number,
|
||
bannedPlayerId: number,
|
||
unbannedByAccountId: number
|
||
): Promise<RoomBan | null> {
|
||
const now = new Date().toISOString()
|
||
const inForce = banInForce('?3')
|
||
const [, deleted] = await db.batch([
|
||
db
|
||
.prepare(
|
||
`INSERT INTO room_ban_history
|
||
(room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason,
|
||
expires_at, status, ended_at, unbanned_by_account_id)
|
||
SELECT room_id, banned_player_id, ban_mask, banned_by_account_id, created_at, reason,
|
||
expires_at,
|
||
CASE WHEN ${inForce} THEN ?5 ELSE ?6 END,
|
||
CASE WHEN ${inForce} THEN ?3 ELSE expires_at END,
|
||
CASE WHEN ${inForce} THEN ?4 ELSE NULL END
|
||
FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2`
|
||
)
|
||
.bind(
|
||
roomId,
|
||
bannedPlayerId,
|
||
now,
|
||
unbannedByAccountId,
|
||
RoomBanStatus.Lifted,
|
||
RoomBanStatus.Elapsed
|
||
),
|
||
db
|
||
.prepare('DELETE FROM room_ban WHERE room_id = ?1 AND banned_player_id = ?2 RETURNING *')
|
||
.bind(roomId, bannedPlayerId),
|
||
])
|
||
const row = (deleted.results as RoomBanRow[])[0]
|
||
if (!row) return null
|
||
const ban = toRoomBan(row)
|
||
return ban.ExpiresAt !== null && ban.ExpiresAt <= new Date().toISOString() ? null : ban
|
||
}
|
||
|
||
/** Everyone CURRENTLY banned from a room, most recently banned first. Lapsed bans are left out. */
|
||
export async function getRoomBans(db: D1Database, roomId: number): Promise<RoomBan[]> {
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT * FROM room_ban WHERE room_id = ?1 AND ${banInForce('?2')} ORDER BY created_at DESC`
|
||
)
|
||
.bind(roomId, new Date().toISOString())
|
||
.all<RoomBanRow>()
|
||
return results.map(toRoomBan)
|
||
}
|
||
|
||
/**
|
||
* Whether a player is banned from a room RIGHT NOW. A timed ban that has lapsed is not a ban.
|
||
* This is the check `match` refuses matchmakes on, so the expiry has to be applied here.
|
||
*/
|
||
export async function isPlayerBannedFromRoom(
|
||
db: D1Database,
|
||
roomId: number,
|
||
playerId: number
|
||
): Promise<boolean> {
|
||
const row = await db
|
||
.prepare(
|
||
`SELECT 1 AS hit FROM room_ban
|
||
WHERE room_id = ?1 AND banned_player_id = ?2 AND ${banInForce('?3')}`
|
||
)
|
||
.bind(roomId, playerId, new Date().toISOString())
|
||
.first<{ hit: number }>()
|
||
return row !== null
|
||
}
|
||
|
||
/** A room's leaderboard definition — one configured slot (`leaderboard_id` is per-room). */
|
||
export interface RoomLeaderboard {
|
||
RoomId: number
|
||
LeaderboardId: number
|
||
LeaderboardTitle: string
|
||
StatFormat: number
|
||
SortAscending: boolean
|
||
}
|
||
|
||
interface RoomLeaderboardRow {
|
||
room_id: number
|
||
leaderboard_id: number
|
||
leaderboard_title: string
|
||
stat_format: number
|
||
sort_ascending: number
|
||
}
|
||
|
||
const toRoomLeaderboard = (row: RoomLeaderboardRow): RoomLeaderboard => ({
|
||
RoomId: row.room_id,
|
||
LeaderboardId: row.leaderboard_id,
|
||
LeaderboardTitle: row.leaderboard_title,
|
||
StatFormat: row.stat_format,
|
||
SortAscending: row.sort_ascending === 1,
|
||
})
|
||
|
||
/**
|
||
* Create or reconfigure one of a room's leaderboard slots, returning the stored
|
||
* definition. One row per (room, leaderboard): re-posting a slot rewrites its title,
|
||
* format and direction rather than appending a second row, so the call is idempotent.
|
||
*/
|
||
export async function setRoomLeaderboard(
|
||
db: D1Database,
|
||
roomId: number,
|
||
leaderboardId: number,
|
||
leaderboardTitle: string,
|
||
statFormat: number,
|
||
sortAscending: boolean
|
||
): Promise<RoomLeaderboard> {
|
||
const row = await db
|
||
.prepare(
|
||
`INSERT INTO room_leaderboard (room_id, leaderboard_id, leaderboard_title, stat_format, sort_ascending)
|
||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||
ON CONFLICT(room_id, leaderboard_id) DO UPDATE SET
|
||
leaderboard_title = ?3, stat_format = ?4, sort_ascending = ?5
|
||
RETURNING *`
|
||
)
|
||
.bind(roomId, leaderboardId, leaderboardTitle, statFormat, sortAscending ? 1 : 0)
|
||
.first<RoomLeaderboardRow>()
|
||
// RETURNING always yields the upserted row.
|
||
return toRoomLeaderboard(row!)
|
||
}
|
||
|
||
/**
|
||
* Remove one of a room's leaderboard slots, returning the definition that was removed —
|
||
* or null when the slot wasn't configured, which lets the caller tell a real delete
|
||
* from a no-op.
|
||
*/
|
||
export async function deleteRoomLeaderboard(
|
||
db: D1Database,
|
||
roomId: number,
|
||
leaderboardId: number
|
||
): Promise<RoomLeaderboard | null> {
|
||
const row = await db
|
||
.prepare('DELETE FROM room_leaderboard WHERE room_id = ?1 AND leaderboard_id = ?2 RETURNING *')
|
||
.bind(roomId, leaderboardId)
|
||
.first<RoomLeaderboardRow>()
|
||
return row ? toRoomLeaderboard(row) : null
|
||
}
|
||
|
||
/**
|
||
* A player's saved data for one room — the blob the client posted, verbatim. Null when
|
||
* the player has never saved anything there.
|
||
*/
|
||
export async function getPlayerData(
|
||
db: D1Database,
|
||
roomId: number,
|
||
playerId: number
|
||
): Promise<string | null> {
|
||
const row = await db
|
||
.prepare('SELECT data FROM playerdata WHERE room_id = ?1 AND player_id = ?2')
|
||
.bind(roomId, playerId)
|
||
.first<{ data: string }>()
|
||
return row ? row.data : null
|
||
}
|
||
|
||
/**
|
||
* Store a player's data for one room, returning what was stored. One row per (room,
|
||
* player): a re-save overwrites the blob in place rather than keeping history, so the
|
||
* call is idempotent.
|
||
*/
|
||
export async function setPlayerData(
|
||
db: D1Database,
|
||
roomId: number,
|
||
playerId: number,
|
||
data: string
|
||
): Promise<string> {
|
||
const row = await db
|
||
.prepare(
|
||
`INSERT INTO playerdata (room_id, player_id, data) VALUES (?1, ?2, ?3)
|
||
ON CONFLICT(room_id, player_id) DO UPDATE SET data = ?3
|
||
RETURNING data`
|
||
)
|
||
.bind(roomId, playerId, data)
|
||
.first<{ data: string }>()
|
||
// RETURNING always yields the upserted row.
|
||
return row!.data
|
||
}
|
||
|
||
/**
|
||
* Clone an existing room into a new one owned by `accountId`. Copies the source
|
||
* room's content (scene/subrooms/settings), assigning a fresh RoomId, the given
|
||
* name, and the new owner. The clone keeps only the source's SYSTEM tags (`Type` 1,
|
||
* `RoomTagType.auto` — `beta`, `limitsv2`): the client posts those to describe what the
|
||
* scene needs to run, so a copy of the scene needs them too. Every other tag (the owner's
|
||
* `Type` 0 tags, the `base` template tag, the server-derived `rro`) is dropped, so the
|
||
* owner tags the clone from scratch. `IsRRO` is cleared so the client doesn't render a
|
||
* virtual "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats`
|
||
* start at zero, and it starts PRIVATE rather than inheriting the source's visibility.
|
||
* Returns the new room, or null when the source isn't in D1 or disallows cloning.
|
||
*/
|
||
export async function cloneRoom(
|
||
db: D1Database,
|
||
sourceRoomId: number,
|
||
name: string,
|
||
accountId: number
|
||
): Promise<Room | null> {
|
||
const source = await getRoomById(db, sourceRoomId)
|
||
if (!source || source.CloningAllowed === false) return null
|
||
|
||
const row = await db
|
||
.prepare('SELECT MAX(room_id) AS maxId FROM room')
|
||
.first<{ maxId: number | null }>()
|
||
const newRoomId = (row?.maxId ?? 0) + 1
|
||
|
||
// Ownership is reset to the cloner — the source room's Roles (its creator and
|
||
// any co-owners, e.g. the seeded base-room roles for accounts 1/2) must NOT
|
||
// carry over, or the clone would still list the template's owner as owner.
|
||
const roles: RoomRole[] = [
|
||
{
|
||
AccountId: accountId,
|
||
Role: Role.Creator,
|
||
LastChangedByAccountId: null,
|
||
InvitedRole: Role.None,
|
||
},
|
||
]
|
||
|
||
// The system tags (`Type` 1: `beta`, `limitsv2`) carry over — the client derives them
|
||
// from what the scene is built with and the clone is the same scene, so without them
|
||
// the copy doesn't work. Nothing else does: no owner tags, no `base`, no `rro`, and no
|
||
// primary-genre flag (an auto tag is never the genre).
|
||
const sourceTags = Array.isArray(source.Tags) ? (source.Tags as RoomTag[]) : []
|
||
const systemTags: RoomTag[] = sourceTags
|
||
.filter((t) => t.Type === RoomTagType.auto)
|
||
.map((t) => ({ Tag: t.Tag, Type: RoomTagType.auto }))
|
||
|
||
const cloned: Room = {
|
||
...source,
|
||
RoomId: newRoomId,
|
||
Name: name,
|
||
CreatorAccountId: accountId,
|
||
IsDorm: false,
|
||
// Start fresh, except for the system tags the scene needs to run (see below).
|
||
Tags: systemTags,
|
||
// A user clone is not a Rec Room Original — clear the inherited flag, or the
|
||
// client renders a virtual "RRO" tag on the clone.
|
||
IsRRO: false,
|
||
// Neither is the cloner a developer, and a copy doesn't inherit the source's consent
|
||
// to be copied: the new owner opts their room into cloning themselves.
|
||
IsDeveloperOwned: false,
|
||
CloningAllowed: false,
|
||
// A brand-new room is unpublished: the owner publishes it by setting the room's
|
||
// accessibility. Inheriting the source's would put the clone straight into the
|
||
// public feeds (hot/search/recommendations/similar all key on Accessibility === 1)
|
||
// the moment it was made — every clone of a PUBLIC source, template or player room.
|
||
Accessibility: Accessibility.Private,
|
||
Roles: roles,
|
||
// A fresh room has no engagement of its own — don't inherit any of the source's
|
||
// counters. `storedStats` alone would keep the blob-owned `VisitorCount`, which
|
||
// `hotScore` ranks on.
|
||
Stats: { ...ZERO_STATS },
|
||
CreatedAt: new Date().toISOString(),
|
||
}
|
||
|
||
// serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are
|
||
// inserted into the subroom table below with fresh globally-unique ids.
|
||
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run()
|
||
// Tags live in `room_tag`, not the blob: the system tags need their own write.
|
||
if (systemTags.length > 0) await setRoomTags(db, newRoomId, systemTags)
|
||
const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : []
|
||
const clonedSubRooms: SubRoom[] = []
|
||
for (const sub of sourceSubRooms) {
|
||
clonedSubRooms.push(await insertSubRoom(db, newRoomId, { ...sub, CreatorAccountId: accountId }))
|
||
}
|
||
cloned.SubRooms = clonedSubRooms
|
||
// Inherited from the (parsed) source in practice; defaulted here too so a clone is
|
||
// never the one room shape missing them.
|
||
attachRoomDtoDefaults(cloned)
|
||
return cloned
|
||
}
|
||
|
||
/** Set a room's Description in place (the caller is responsible for the owner check). */
|
||
export async function setRoomDescription(
|
||
db: D1Database,
|
||
roomId: number,
|
||
description: string
|
||
): Promise<void> {
|
||
await db
|
||
.prepare("UPDATE room SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1")
|
||
.bind(roomId, description)
|
||
.run()
|
||
}
|
||
|
||
/** 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
|
||
}
|
||
|
||
/**
|
||
* Whether a role tier can only be taken by INVITATION — offered with
|
||
* {@link inviteRoomRole} and accepted by the player themselves via
|
||
* {@link answerRoomRoleInvite} — rather than granted outright.
|
||
*
|
||
* {@link Role.CoOwner} is the tier this rule exists for: nobody can force-add a co-owner,
|
||
* it is always an invite the player accepts. {@link Role.Creator} is included because it is
|
||
* strictly more than co-ownership — it would make no sense for the bigger grant to be the
|
||
* one that skips the ceremony. Expressed as `>=` so a tier added between or above them is
|
||
* covered by default: a new high-privilege role should have to be accepted until someone
|
||
* decides otherwise, not be grantable because nobody updated this list.
|
||
*/
|
||
export function roleRequiresInvite(role: number): boolean {
|
||
return role >= Role.CoOwner
|
||
}
|
||
|
||
/**
|
||
* GRANT a room role outright — updating the target account's existing `Roles` entry or
|
||
* appending one — and stamp `LastChangedByAccountId` with the editor. This is the owner's
|
||
* half of role management, and it takes effect immediately: the helper tiers
|
||
* ({@link Role.Host}, {@link Role.Moderator} and whatever else the client hands out) are the
|
||
* owner's to give.
|
||
*
|
||
* Returns null, writing nothing, for a tier that {@link roleRequiresInvite} — nobody can
|
||
* force-add a co-owner, whoever is asking. The rule lives HERE rather than in the route that
|
||
* currently calls this, because it is an invariant of the room's `Roles` and not a policy of
|
||
* one endpoint: the only way a `Role` of {@link Role.CoOwner} may ever appear on an entry is
|
||
* {@link answerRoomRoleInvite} promoting an `InvitedRole` the room's owner offered.
|
||
*
|
||
* The caller supplies the already-loaded room (after its gate) to avoid a re-read; the whole
|
||
* room JSON is rewritten. Any pending `InvitedRole` on the entry is left standing — a granted
|
||
* Host with a co-owner invite open still has it to answer.
|
||
*/
|
||
export async function setRoomRole(
|
||
db: D1Database,
|
||
roomId: number,
|
||
targetAccountId: number,
|
||
role: number,
|
||
changedByAccountId: number,
|
||
room: Room
|
||
): Promise<Room | null> {
|
||
if (roleRequiresInvite(role)) return null
|
||
|
||
const roles = roomRoles(room)
|
||
const existing = roles.find((r) => r.AccountId === targetAccountId)
|
||
if (existing) {
|
||
existing.Role = role
|
||
existing.LastChangedByAccountId = changedByAccountId
|
||
} else {
|
||
roles.push({
|
||
AccountId: targetAccountId,
|
||
Role: role,
|
||
LastChangedByAccountId: changedByAccountId,
|
||
InvitedRole: Role.None,
|
||
})
|
||
}
|
||
|
||
const updated: Room = { ...room, Roles: roles }
|
||
await db
|
||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||
.bind(roomId, serializeRoom(updated))
|
||
.run()
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* REVOKE a room role — dropping the target's `Roles` entry outright. This is how a room's
|
||
* owner or a co-owner takes back a role they handed out, and it is the counterpart to
|
||
* {@link setRoomRole}: there is no "demote to nothing" tier, because {@link Role.None} is the
|
||
* resting value of a PENDING invite rather than a role somebody holds. An entry that confers
|
||
* nothing should not exist, so a revoke removes the record.
|
||
*
|
||
* Two entries are protected, and both refuse by returning null without writing:
|
||
*
|
||
* - The room's OWNER — `CreatorAccountId`, or a {@link Role.Creator} entry. Ownership is not
|
||
* a role to be taken away by anyone standing beside it; it moves through
|
||
* {@link transferRoomOwnership} and nowhere else. Without this a co-owner could remove the
|
||
* creator's entry, and on an older room whose creator has no entry at all they could not —
|
||
* so the check is on `CreatorAccountId` as well as on the tier.
|
||
* - A CO-OWNER entry, unless the caller is the room's owner. Co-owners are peers: letting one
|
||
* remove another means whichever of them asks first wins, and the room's owner is the only
|
||
* person who is not a peer of theirs. A co-owner may still revoke the helper tiers.
|
||
*
|
||
* Removing a role a player does not have is NOT a failure — the room comes back unchanged and
|
||
* nothing is written. The caller asked for this player to hold no role, and they hold none;
|
||
* answering an error would make a client retrying a removal look broken.
|
||
*
|
||
* Takes any pending `InvitedRole` with it, since the whole entry goes. That is the same
|
||
* trade {@link answerRoomRoleInvite} makes when declining, and it means revoking a role from
|
||
* someone who has a co-owner invite standing also withdraws the invite.
|
||
*
|
||
* `removedByAccountId` is not stamped anywhere — the entry it would be stamped on is the one
|
||
* being deleted — and is read only to decide which of the two protections apply. The caller
|
||
* supplies the already-loaded room (after its gate) to avoid a re-read; the whole room JSON
|
||
* is rewritten.
|
||
*/
|
||
export async function removeRoomRole(
|
||
db: D1Database,
|
||
roomId: number,
|
||
targetAccountId: number,
|
||
removedByAccountId: number,
|
||
room: Room
|
||
): Promise<Room | null> {
|
||
if (room.CreatorAccountId === targetAccountId) return null
|
||
|
||
const roles = roomRoles(room)
|
||
const index = roles.findIndex((r) => r.AccountId === targetAccountId)
|
||
if (index === -1) return room
|
||
|
||
const entry = roles[index]
|
||
if (entry.Role === Role.Creator) return null
|
||
if (entry.Role === Role.CoOwner && !isRoomOwner(room, removedByAccountId)) return null
|
||
|
||
roles.splice(index, 1)
|
||
const updated: Room = { ...room, Roles: roles }
|
||
await db
|
||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||
.bind(roomId, serializeRoom(updated))
|
||
.run()
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* TRANSFER a room to a new owner: the recipient becomes {@link Role.Creator} and the
|
||
* outgoing owner is left as {@link Role.CoOwner}, keeping their access to a room they built
|
||
* without keeping the room.
|
||
*
|
||
* `CreatorAccountId` moves too, and it has to. It is the room's real owner field — every
|
||
* gate short-circuits on it ({@link canManageRoom}, {@link isRoomOwner}), the "rooms I made"
|
||
* lists select on it, and it is what the client shows. Moving only the `Roles` entries would
|
||
* give the room two owners: the new one by role, the old one by `CreatorAccountId`, still
|
||
* able to hand the room on again.
|
||
*
|
||
* Exempt from {@link roleRequiresInvite}, which otherwise means nobody is handed
|
||
* {@link Role.Creator} without accepting it. This is not a grant made ABOUT someone by a
|
||
* third party; it is the sole person who could already do anything at all to this room
|
||
* giving it away. The recipient gets a room, not a liability, and requiring them to accept
|
||
* would leave the room in a half-transferred state in the meantime.
|
||
*
|
||
* Returns null, writing nothing, for a DORM. A dorm is found by its owner
|
||
* ({@link getDormRoom} selects on `creator_account_id AND is_dorm`), so transferring one
|
||
* would hand somebody else's dorm to the recipient — who already has their own — and mint
|
||
* the original owner a fresh empty one on next access, their build gone.
|
||
*
|
||
* The caller supplies the already-loaded room (after its owner check) to avoid a re-read;
|
||
* the whole room JSON is rewritten. Pending invites on either entry are left standing.
|
||
*/
|
||
export async function transferRoomOwnership(
|
||
db: D1Database,
|
||
roomId: number,
|
||
fromAccountId: number,
|
||
toAccountId: number,
|
||
room: Room
|
||
): Promise<Room | null> {
|
||
if (room.IsDorm === true) return null
|
||
|
||
const roles = roomRoles(room)
|
||
// Both sides get an entry whether or not they had one: the outgoing owner may never have
|
||
// been in `Roles` at all (the older rooms carry no entry for their creator), and the
|
||
// recipient is usually a stranger to the room.
|
||
const setRole = (accountId: number, role: number): void => {
|
||
const existing = roles.find((r) => r.AccountId === accountId)
|
||
if (existing) {
|
||
existing.Role = role
|
||
existing.LastChangedByAccountId = fromAccountId
|
||
} else {
|
||
roles.push({
|
||
AccountId: accountId,
|
||
Role: role,
|
||
LastChangedByAccountId: fromAccountId,
|
||
InvitedRole: Role.None,
|
||
})
|
||
}
|
||
}
|
||
setRole(toAccountId, Role.Creator)
|
||
setRole(fromAccountId, Role.CoOwner)
|
||
|
||
const updated: Room = { ...room, CreatorAccountId: toAccountId, Roles: roles }
|
||
await db
|
||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||
.bind(roomId, serializeRoom(updated))
|
||
.run()
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* ANSWER a standing room-role invite — accept it, or decline it with `role` 0.
|
||
*
|
||
* Accepting promotes the account's `Roles` entry from its pending `InvitedRole` to the real
|
||
* `Role` and clears the pending one, so an accepted entry reads `Role: 30, InvitedRole: 0`
|
||
* and the invite cannot be answered twice. `role` is what the ACCEPTING PLAYER asked for, and
|
||
* it must equal the `InvitedRole` already on their entry. That equality is the whole security
|
||
* of this call: the answer is made by the invited player themselves, so without it they could
|
||
* answer an invite to Host by asking for Creator and award themselves the room. The invited
|
||
* role is the server's own record of what the owner offered, written by
|
||
* {@link inviteRoomRole}, and nothing the caller sends can move it.
|
||
*
|
||
* Declining (`role` 0 — not a real tier, so it can't be confused for one) REMOVES the entry
|
||
* outright rather than just clearing `InvitedRole`: a declined invite should leave the room's
|
||
* `Roles` as it was before the offer. Note that this takes any role the player already held
|
||
* with it, so someone re-invited to a different tier who declines is left with nothing at
|
||
* all. That is deliberate — an invite is answered as a whole — but it means a decline is not
|
||
* a no-op for an existing member.
|
||
*
|
||
* Returns null — changing nothing — when there is no entry for the account, no invite
|
||
* standing on it, or (when accepting) the requested role isn't the one offered. The three are
|
||
* deliberately one answer: a caller probing for a higher role learns only that it didn't work.
|
||
*
|
||
* `LastChangedByAccountId` is left alone rather than restamped with the accepter: it names
|
||
* who put this role on the entry, and that is the owner who offered it. The caller supplies
|
||
* the already-loaded room to avoid a re-read; the whole room JSON is rewritten.
|
||
*/
|
||
export async function answerRoomRoleInvite(
|
||
db: D1Database,
|
||
roomId: number,
|
||
accountId: number,
|
||
role: number,
|
||
room: Room
|
||
): Promise<Room | null> {
|
||
const roles = roomRoles(room)
|
||
const index = roles.findIndex((r) => r.AccountId === accountId)
|
||
if (index === -1) return null
|
||
|
||
const entry = roles[index]
|
||
// `Role.None` is "nothing pending" — the resting state of every entry, and what an
|
||
// already-answered invite leaves behind, so neither answer can be replayed.
|
||
if (entry.InvitedRole === Role.None) return null
|
||
|
||
if (role === Role.None) {
|
||
roles.splice(index, 1)
|
||
} else if (entry.InvitedRole !== role) {
|
||
return null
|
||
} else {
|
||
entry.Role = entry.InvitedRole
|
||
entry.InvitedRole = Role.None
|
||
}
|
||
|
||
const updated: Room = { ...room, Roles: roles }
|
||
await db
|
||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||
.bind(roomId, serializeRoom(updated))
|
||
.run()
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* INVITE an account to a room role — setting `InvitedRole` on their `Roles` entry (adding
|
||
* the entry when they have none) and stamping `LastChangedByAccountId` with the inviter.
|
||
*
|
||
* `InvitedRole` is the PENDING half of a role entry: an invited account holds it until
|
||
* they accept, at which point {@link answerRoomRoleInvite} promotes it to the real `Role`.
|
||
* So an entry added here starts at {@link Role.None} — no role yet, only an offer — and an
|
||
* account who already holds a role keeps it while a higher one is pending.
|
||
*
|
||
* Like {@link answerRoomRoleInvite}, the caller supplies the already-loaded room to
|
||
* avoid a re-read, and the whole room JSON is rewritten. Returns the updated room.
|
||
*/
|
||
export async function inviteRoomRole(
|
||
db: D1Database,
|
||
roomId: number,
|
||
targetAccountId: number,
|
||
invitedRole: number,
|
||
changedByAccountId: number,
|
||
room: Room
|
||
): Promise<Room> {
|
||
const roles = roomRoles(room)
|
||
const existing = roles.find((r) => r.AccountId === targetAccountId)
|
||
if (existing) {
|
||
existing.InvitedRole = invitedRole
|
||
existing.LastChangedByAccountId = changedByAccountId
|
||
} else {
|
||
roles.push({
|
||
AccountId: targetAccountId,
|
||
Role: Role.None,
|
||
LastChangedByAccountId: changedByAccountId,
|
||
InvitedRole: invitedRole,
|
||
})
|
||
}
|
||
const updated: Room = { ...room, Roles: roles }
|
||
await db
|
||
.prepare('UPDATE room SET data = ?2 WHERE room_id = ?1')
|
||
.bind(roomId, serializeRoom(updated))
|
||
.run()
|
||
return updated
|
||
}
|
||
|
||
/**
|
||
* Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
|
||
* toggling one on clears any other main tag. Compared case-insensitively.
|
||
*
|
||
* Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the
|
||
* room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
|
||
*/
|
||
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
|
||
|
||
/**
|
||
* A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
|
||
*
|
||
* `user` is what a player types or picks. `auto` is what the client derives about the room
|
||
* and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
|
||
* A tag's category is orthogonal to whether it is the room's primary genre.
|
||
*/
|
||
export const RoomTagType = {
|
||
user: 0,
|
||
auto: 1,
|
||
derived: 2,
|
||
} as const
|
||
|
||
/**
|
||
* The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
|
||
* they compose: a single request may replace the user tags, add a derived one and move the
|
||
* genre, and it is applied as one write.
|
||
*/
|
||
export interface RoomTagEdit {
|
||
/**
|
||
* The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
|
||
* it has it, and adding one of {@link MAIN_TAGS} clears the others.
|
||
*/
|
||
toggle?: string
|
||
/**
|
||
* The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
|
||
* derived tags (`auto`, `derived`) are not the client's to send and are left alone.
|
||
*/
|
||
tags?: string[]
|
||
/**
|
||
* Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag,
|
||
* since the client posts the ones it wants rather than the full set. A tag already on
|
||
* the room is re-categorised rather than duplicated.
|
||
*/
|
||
autoTags?: string[]
|
||
/**
|
||
* The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
|
||
* every other tag keeps its place and loses the flag.
|
||
*/
|
||
primaryGenre?: string
|
||
}
|
||
|
||
/** A tag's name, lowercased — every comparison in here is case-insensitive. */
|
||
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()
|
||
|
||
/**
|
||
* Apply one request's worth of tag changes to a room and store the result. The caller
|
||
* supplies the already-loaded (owner-checked) room, so nothing is re-read.
|
||
*
|
||
* The changes are composed into ONE set and written once: a request naming tags, an auto
|
||
* tag and a genre is a single state for the room, and applying it in three writes would
|
||
* let a reader (or a failure) land between them.
|
||
*
|
||
* Only `room_tag` is written — the room blob carries no tags at all, so the room row is
|
||
* left alone.
|
||
*/
|
||
export async function applyRoomTagEdit(
|
||
db: D1Database,
|
||
roomId: number,
|
||
room: Room,
|
||
edit: RoomTagEdit
|
||
): Promise<Room> {
|
||
const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
|
||
// Copies throughout: the room handed in is answered to the client, and the steps below
|
||
// mutate what they build.
|
||
let next: RoomTag[] = current.map((t) => ({ ...t }))
|
||
|
||
if (edit.tags !== undefined) {
|
||
// A SET, not a merge. The posted list is exactly the room's user tags afterwards; a
|
||
// tag already there keeps its row (and its genre flag, until the genre step below
|
||
// says otherwise) rather than being deleted and re-added.
|
||
const posted = new Set(edit.tags.map((t) => t.toLowerCase()))
|
||
next = [
|
||
...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))),
|
||
...edit.tags.map(
|
||
(tag) =>
|
||
next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user }
|
||
),
|
||
]
|
||
} else if (edit.toggle !== undefined) {
|
||
// The 2023 client has no delete/patch endpoint, so the same call toggles: remove the
|
||
// tag if present, add it otherwise. Adding a main tag is a radio pick, so it also
|
||
// clears any other main tag. Removing the flagged tag takes the genre with it, which
|
||
// is right — the room's genre WAS that tag.
|
||
const lower = edit.toggle.toLowerCase()
|
||
const existing = next.findIndex((t) => tagKey(t) === lower)
|
||
if (existing !== -1) {
|
||
next = next.filter((_, i) => i !== existing)
|
||
} else {
|
||
const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next
|
||
next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }]
|
||
}
|
||
}
|
||
|
||
for (const auto of edit.autoTags ?? []) {
|
||
const existing = next.find((t) => tagKey(t) === auto.toLowerCase())
|
||
// A tag the room already carries is re-categorised in place rather than duplicated —
|
||
// `tag` is the table's key, so there is only ever one row per name anyway.
|
||
if (existing) existing.Type = RoomTagType.auto
|
||
else next.push({ Tag: auto, Type: RoomTagType.auto })
|
||
}
|
||
|
||
if (edit.primaryGenre !== undefined) {
|
||
const lower = edit.primaryGenre.toLowerCase()
|
||
for (const tag of next) delete tag.IsPrimaryGenre
|
||
const chosen = next.find((t) => tagKey(t) === lower)
|
||
// A tag the room already carries keeps its category and simply becomes the genre;
|
||
// one it doesn't is added as an ordinary user tag.
|
||
if (chosen) chosen.IsPrimaryGenre = true
|
||
else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true })
|
||
}
|
||
|
||
return storeRoomTags(db, roomId, room, next)
|
||
}
|
||
|
||
/**
|
||
* Write a room's whole tag set and answer the room carrying it, lowercased the way the
|
||
* table holds it — so the caller replies with exactly what a re-read would give, without
|
||
* paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays
|
||
* absent (not false) everywhere else.
|
||
*/
|
||
async function storeRoomTags(
|
||
db: D1Database,
|
||
roomId: number,
|
||
room: Room,
|
||
tags: RoomTag[]
|
||
): Promise<Room> {
|
||
await setRoomTags(db, roomId, tags)
|
||
return {
|
||
...room,
|
||
Tags: tags.map((t) => {
|
||
const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type }
|
||
if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true
|
||
return stored
|
||
}),
|
||
}
|
||
}
|
||
|
||
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
|
||
export function findSubRoom(room: Room, subRoomId: number): SubRoom | undefined {
|
||
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
|
||
return subRooms.find((s) => s.SubRoomId === subRoomId)
|
||
}
|
||
|
||
/** Fields from the client's room-save POST body. */
|
||
export interface SaveSubRoomDataInput {
|
||
/** Uploaded blob key for this subroom's scene data (becomes `CurrentSave.DataBlob`). */
|
||
subRoomDataFilename?: string
|
||
/** `SubRoomData.Hash` — echoed back as the save response's `dataBlobHash`. */
|
||
subRoomDataHash?: string
|
||
/** 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,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* The invention ids inside a room save's `InventionUsage`, in the order it lists them.
|
||
*
|
||
* The value is a base64 PROTOBUF message, recovered by reading real saves — there is no
|
||
* schema, only field numbers:
|
||
*
|
||
* 1: varint — 1 on every save seen; a format version by the look of it
|
||
* 2: repeated message — one per invention in the scene
|
||
* 1: varint — the invention id
|
||
* 2: varint — 1 on every save seen; probably how many times it is placed
|
||
*
|
||
* `CAESBgil2gEQARIFCIECEAE=` is `{ 1: 1, 2: [{ 1: 27941, 2: 1 }, { 1: 257, 2: 1 }] }`.
|
||
*
|
||
* Only the ids are read. Anything that doesn't parse — not base64, a truncated message, a
|
||
* wire type this doesn't know — yields the ids found up to that point rather than
|
||
* throwing: this is bookkeeping riding on a room save, and a format the client changes
|
||
* must not be able to fail the save.
|
||
*/
|
||
export function decodeInventionUsage(inventionUsage: string): number[] {
|
||
let bytes: Uint8Array
|
||
try {
|
||
bytes = Uint8Array.from(atob(inventionUsage), (ch) => ch.charCodeAt(0))
|
||
} catch {
|
||
return []
|
||
}
|
||
|
||
/** Walk one message's fields; false from `visit`, or anything malformed, stops the walk. */
|
||
const walk = (
|
||
buf: Uint8Array,
|
||
visit: (field: number, value: number | Uint8Array) => void
|
||
): void => {
|
||
let at = 0
|
||
const varint = (): number | null => {
|
||
let value = 0
|
||
for (let shift = 0; at < buf.length; shift += 7) {
|
||
const byte = buf[at++]
|
||
// Multiplication, not `<<`: ids past 2^31 would wrap as a 32-bit shift.
|
||
value += (byte & 0x7f) * 2 ** shift
|
||
if ((byte & 0x80) === 0) return value
|
||
}
|
||
return null
|
||
}
|
||
while (at < buf.length) {
|
||
const key = varint()
|
||
if (key === null) return
|
||
const field = Math.floor(key / 8)
|
||
const wireType = key % 8
|
||
if (wireType === 0) {
|
||
const value = varint()
|
||
if (value === null) return
|
||
visit(field, value)
|
||
} else if (wireType === 2) {
|
||
const length = varint()
|
||
if (length === null || at + length > buf.length) return
|
||
visit(field, buf.subarray(at, at + length))
|
||
at += length
|
||
} else {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
const ids: number[] = []
|
||
walk(bytes, (field, entry) => {
|
||
if (field !== 2 || typeof entry === 'number') return
|
||
walk(entry, (inner, value) => {
|
||
if (inner === 1 && typeof value === 'number' && Number.isSafeInteger(value)) ids.push(value)
|
||
})
|
||
})
|
||
return ids
|
||
}
|
||
|
||
/**
|
||
* Re-derive a room's `room_invention` rows from its subrooms: the union of the inventions
|
||
* each subroom's last save reported in `InventionUsage`. An invention removed from the room
|
||
* drops out on the next save — and the set is derived from EVERY subroom rather than the
|
||
* one just saved, because the table is per room and a save of one subroom says nothing
|
||
* about what the others still hold.
|
||
*
|
||
* A subroom's `InventionUsage` is whatever its latest save sent, staged or published, so
|
||
* this is the room as its builders last left it, not strictly what players are loading.
|
||
*
|
||
* Every invention that entered or left the room then has its `NumPlayersHaveUsedInRoom`
|
||
* resynced — the number of ROOMS it is in, which is what this server has to put behind
|
||
* that field. Derived from the table, like `CheerCount` and `NumDownloads` from theirs, and
|
||
* a `json_set` on the one key of the `invention` record (the `api` worker's table, same
|
||
* database). Only the rows that changed are written, so the usual save — same inventions
|
||
* as last time — costs one read.
|
||
*/
|
||
export async function syncRoomInventions(
|
||
db: D1Database,
|
||
roomId: number,
|
||
room: Room
|
||
): Promise<void> {
|
||
const subRooms = Array.isArray(room.SubRooms) ? (room.SubRooms as SubRoom[]) : []
|
||
const now = new Set<number>()
|
||
for (const sub of subRooms) {
|
||
if (typeof sub.InventionUsage !== 'string') continue
|
||
for (const id of decodeInventionUsage(sub.InventionUsage)) now.add(id)
|
||
}
|
||
|
||
const { results } = await db
|
||
.prepare('SELECT invention_id FROM room_invention WHERE room_id = ?1')
|
||
.bind(roomId)
|
||
.all<{ invention_id: number }>()
|
||
const before = new Set(results.map((r) => r.invention_id))
|
||
const added = [...now].filter((id) => !before.has(id))
|
||
const removed = [...before].filter((id) => !now.has(id))
|
||
if (added.length === 0 && removed.length === 0) return
|
||
|
||
// One batch, in order: the membership changes land before the counts are re-derived.
|
||
await db.batch([
|
||
...removed.map((id) =>
|
||
db
|
||
.prepare('DELETE FROM room_invention WHERE room_id = ?1 AND invention_id = ?2')
|
||
.bind(roomId, id)
|
||
),
|
||
...added.map((id) =>
|
||
db
|
||
.prepare('INSERT OR IGNORE INTO room_invention (room_id, invention_id) VALUES (?1, ?2)')
|
||
.bind(roomId, id)
|
||
),
|
||
...[...added, ...removed].map((id) =>
|
||
db
|
||
.prepare(
|
||
`UPDATE invention SET data = json_set(data, '$.NumPlayersHaveUsedInRoom',
|
||
(SELECT COUNT(*) FROM room_invention WHERE invention_id = ?1))
|
||
WHERE id = ?1`
|
||
)
|
||
.bind(id)
|
||
),
|
||
])
|
||
}
|
||
|
||
/** How many rooms an invention is used in — the stat `room_invention` exists to answer. */
|
||
export async function countRoomsUsingInvention(
|
||
db: D1Database,
|
||
inventionId: number
|
||
): Promise<number> {
|
||
const row = await db
|
||
.prepare('SELECT COUNT(*) AS n FROM room_invention WHERE invention_id = ?1')
|
||
.bind(inventionId)
|
||
.first<{ n: number }>()
|
||
return row?.n ?? 0
|
||
}
|
||
|
||
/**
|
||
* Persist a room-save against a specific subroom. Everything the save carries belongs to
|
||
* that subroom's revision — nothing is written to the room. Returns the updated
|
||
* (hydrated) room AND the save that was just created — the route answers with both — or
|
||
* null when the room or subroom doesn't exist.
|
||
*
|
||
* Whether the save goes live is the client's call: `AutoPublish: true` publishes it
|
||
* outright, otherwise it becomes the subroom's `staged_save_id` with the live
|
||
* `current_save_id` untouched, so what players load doesn't change until the room's
|
||
* creator publishes (see {@link publishSubRoomSave}). Dorms always publish — they have
|
||
* no publish flow in the client.
|
||
*/
|
||
export async function saveSubRoomData(
|
||
db: D1Database,
|
||
roomId: number,
|
||
subRoomId: number,
|
||
accountId: number,
|
||
input: SaveSubRoomDataInput
|
||
): Promise<{ room: Room; save: SubRoomDataSave } | null> {
|
||
const room = await getRoomById(db, roomId)
|
||
if (!room) return null
|
||
// Read off the already-hydrated room rather than re-querying the subroom and its
|
||
// save — getRoomById has both, and this path is write-heavy enough already.
|
||
const sub = findSubRoom(room, subRoomId)
|
||
if (!sub) return null
|
||
|
||
// Populate the subroom's creator on first save — it starts null, and the
|
||
// client NREs on a null CreatorAccountId. Only the owner reaches this path.
|
||
if (sub.CreatorAccountId == null) sub.CreatorAccountId = accountId
|
||
|
||
// Append a new save row. The blob the loader downloads lives on the save — a subroom
|
||
// whose current_save_id resolves to nothing loads nothing — so this never touches the
|
||
// flat DataBlob field. Previous saves stay in the table as history.
|
||
//
|
||
// A staged save carries forward from the previous STAGED one when there is one, so a
|
||
// creator's second edit builds on their first rather than on what's live.
|
||
const staged =
|
||
typeof sub.StagedSubRoomDataSaveId === 'number'
|
||
? await getSubRoomSaveById(db, subRoomId, sub.StagedSubRoomDataSaveId)
|
||
: null
|
||
const previous =
|
||
staged ??
|
||
(sub.CurrentSave && typeof sub.CurrentSave === 'object'
|
||
? (sub.CurrentSave as SubRoomDataSave)
|
||
: undefined)
|
||
const priorVersion = previous?.PersistenceVersion
|
||
const priorBlob = previous?.DataBlob
|
||
const save = await insertSubRoomSave(
|
||
db,
|
||
subRoomId,
|
||
buildSubRoomSave({
|
||
subRoomId,
|
||
// A save that carries no new blob (e.g. a description-only save) keeps the one
|
||
// the subroom already loads from.
|
||
dataBlob: input.subRoomDataFilename ?? (typeof priorBlob === 'string' ? priorBlob : ''),
|
||
dataBlobHash: input.subRoomDataHash ?? null,
|
||
persistenceVersion:
|
||
input.persistenceVersion ?? (typeof priorVersion === 'number' ? priorVersion : 0),
|
||
savedByAccountId: accountId,
|
||
// The save comment — empty string, not null, when the save carries none (the
|
||
// reference's `roomDesc ?? ""`).
|
||
description: input.description ?? '',
|
||
createdAt: new Date().toISOString(),
|
||
unityAssetId: input.unityAssetId,
|
||
})
|
||
)
|
||
const saveId = Number(save.SubRoomDataSaveId)
|
||
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])
|
||
// The one thing a save records OUTSIDE the subroom and its save row: which inventions
|
||
// the room now has in it. Its own table, so the room row is still not rewritten.
|
||
await syncRoomInventions(db, roomId, room)
|
||
return { room, save }
|
||
}
|
||
|
||
/**
|
||
* Publish one of a subroom's saves by id: make it the `current_save_id` players load.
|
||
* This is the manual step every non-dorm room save waits on ({@link saveSubRoomData}
|
||
* only stages). Because it takes an explicit id it doubles as restore-a-save — the id
|
||
* can be any save in the subroom's history, not just the staged one.
|
||
*
|
||
* The staging slot is cleared only when the save being published IS the staged one, so
|
||
* restoring an older version doesn't silently discard newer unpublished work.
|
||
*
|
||
* The id is looked up scoped to the subroom, so one subroom can't publish another's save
|
||
* (ids are globally unique, so an unscoped lookup would happily resolve).
|
||
*
|
||
* Returns the updated (hydrated) room, or a reason: `not_found` (no such room/subroom) /
|
||
* `unknown_save` (no such save on this subroom).
|
||
*/
|
||
export async function publishSubRoomSave(
|
||
db: D1Database,
|
||
roomId: number,
|
||
subRoomId: number,
|
||
saveId: number
|
||
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'unknown_save' }> {
|
||
const sub = await getSubRoom(db, roomId, subRoomId)
|
||
if (!sub) return { ok: false, reason: 'not_found' }
|
||
if (!(await getSubRoomSaveById(db, subRoomId, saveId))) {
|
||
return { ok: false, reason: 'unknown_save' }
|
||
}
|
||
|
||
await db
|
||
.prepare(
|
||
`UPDATE subroom SET current_save_id = ?2,
|
||
staged_save_id = CASE WHEN staged_save_id = ?2 THEN NULL ELSE staged_save_id END
|
||
WHERE sub_room_id = ?1`
|
||
)
|
||
.bind(subRoomId, saveId)
|
||
.run()
|
||
|
||
const room = await getRoomById(db, roomId)
|
||
if (!room) return { ok: false, reason: 'not_found' }
|
||
return { ok: true, room }
|
||
}
|
||
|
||
/** Fields from the client's subroom `modify` form (each applied only when supplied). */
|
||
export interface ModifySubRoomInput {
|
||
name?: string
|
||
accessibility?: number
|
||
maxPlayers?: number
|
||
}
|
||
|
||
/**
|
||
* Modify a subroom's settings in place — its Name, Accessibility, and MaxPlayers
|
||
* (the fields the client's subroom `modify` form carries). Only the supplied fields
|
||
* are changed; the subroom row is updated in the `subroom` table. Returns the updated
|
||
* (hydrated) room, or null when the room or subroom doesn't exist.
|
||
*/
|
||
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 }
|
||
}
|
||
|
||
/**
|
||
* Move a subroom from one room to another (`POST …/subrooms/{subRoomId}/move`). A subroom
|
||
* is its own row keyed by a globally-unique id, and its saves and permission overrides hang
|
||
* off that id rather than the room's, so the move is one column: `room_id`. The saves,
|
||
* the current/staged pointers and the permissions all travel with it untouched. The
|
||
* subroom's `RoomId` is re-injected from the column on read, so the blob needs nothing.
|
||
*
|
||
* Refuses to move a room's only subroom, for the reason {@link deleteSubRoom} refuses to
|
||
* delete it: the room would be left with no scene to load. The caller checks the target
|
||
* room exists and that it may manage both rooms — this only checks the subroom's side.
|
||
*
|
||
* Returns the SOURCE room, hydrated, which no longer lists the subroom — that is what the
|
||
* client re-renders from — or a reason: `not_found` (no such subroom in that room) /
|
||
* `last_subroom`.
|
||
*/
|
||
export async function moveSubRoom(
|
||
db: D1Database,
|
||
roomId: number,
|
||
subRoomId: number,
|
||
newRoomId: number
|
||
): Promise<{ ok: true; room: Room } | { ok: false; reason: 'not_found' | 'last_subroom' }> {
|
||
const subRooms = await getSubRooms(db, roomId)
|
||
if (!subRooms.some((s) => s.SubRoomId === subRoomId)) return { ok: false, reason: 'not_found' }
|
||
if (subRooms.length <= 1) return { ok: false, reason: 'last_subroom' }
|
||
|
||
await db
|
||
.prepare('UPDATE subroom SET room_id = ?3 WHERE room_id = ?1 AND sub_room_id = ?2')
|
||
.bind(roomId, subRoomId, newRoomId)
|
||
.run()
|
||
|
||
const room = await getRoomById(db, roomId)
|
||
if (!room) return { ok: false, reason: 'not_found' }
|
||
return { ok: true, room }
|
||
}
|
||
|
||
interface RoomRow {
|
||
data: string
|
||
visits: number
|
||
}
|
||
|
||
/**
|
||
* The columns every room read selects. `visits` is authoritative for the room's
|
||
* `Stats.VisitCount` (the blob keeps it at 0 — see {@link storedStats}), so it has to
|
||
* come back with the blob on every read; a join aliases them (`r.data AS data`).
|
||
*/
|
||
const ROOM_COLUMNS = 'data, visits'
|
||
|
||
/**
|
||
* The rooms a public feed may consider, as a SQL predicate: public, not a dorm, and not
|
||
* opted out of lists — the same test {@link isListable} makes in memory, pushed down so
|
||
* the blobs of the rooms that fail it never cross the wire. `IS NOT 1` rather than `= 0`
|
||
* because a blob missing the key extracts as NULL, which the JS `!== true` accepts.
|
||
*
|
||
* The feeds all scanned the whole table and threw most of it away: a server's rooms are
|
||
* mostly DORMS (one per account, private by construction), so a scan read megabytes of
|
||
* blob to rank a hundred rooms. `idx_room_public` covers the first two terms, so this
|
||
* visits only the rooms that can actually be served.
|
||
*
|
||
* The in-memory filter STAYS wherever this is used. It costs nothing once the set is
|
||
* small, and it — not the SQL — remains the definition of listable: a blob with a
|
||
* surprising type in one of these fields (`"1"` for `Accessibility`, say) would satisfy
|
||
* the column's integer affinity while failing `=== 1` in JS, and the feeds must agree
|
||
* with {@link isListable} rather than with SQLite.
|
||
*/
|
||
const LISTABLE_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1 AND exclude_from_lists IS NOT 1'
|
||
|
||
/**
|
||
* The wider half of {@link LISTABLE_WHERE}: public and not a dorm, without the
|
||
* `ExcludeFromLists` term. What SEARCH considers — a room can opt out of the browse feeds
|
||
* and still be findable by name — so the two searching reads select on this instead.
|
||
*/
|
||
const PUBLIC_WHERE = 'is_dorm IS NOT 1 AND accessibility = 1'
|
||
|
||
/**
|
||
* The columns a scan-then-rank feed selects in place of {@link ROOM_COLUMNS}: a STUB room
|
||
* holding only the fields the feeds filter and sort on, not the whole blob. The feeds rank
|
||
* every listable room to serve one page, so reading the full blobs moved every listable
|
||
* room's JSON over the wire and through `JSON.parse` for the handful that get served; the
|
||
* page's full rooms are then read by id ({@link loadRankedPage}).
|
||
*
|
||
* `->` rather than `json_extract` because it hands back JSON, so `json_object` embeds each
|
||
* value with its JSON type intact — `true` stays `true`, not 1, and a `"1"` stays a string.
|
||
* That is what lets {@link isListable} and friends run on the stub unchanged and remain the
|
||
* definition of listable. A missing key comes back as null, which every test below reads
|
||
* the same as absent. A feed that starts reading a new field off the room has to add it
|
||
* here, or it reads null.
|
||
*/
|
||
const RANK_COLUMNS = `json_object(${[
|
||
'RoomId',
|
||
'IsDorm',
|
||
'Accessibility',
|
||
'ExcludeFromLists',
|
||
'CreatorAccountId',
|
||
'CreatedAt',
|
||
'IsRRO',
|
||
'Stats',
|
||
]
|
||
.map((key) => `'${key}', data -> '$.${key}'`)
|
||
.join(', ')}) AS data, visits`
|
||
|
||
/**
|
||
* The full rooms behind a page of {@link RANK_COLUMNS} stubs, in the stubs' order, parsed
|
||
* like any room read (not yet hydrated). A room deleted between the two reads is dropped
|
||
* rather than served as a stub.
|
||
*/
|
||
async function loadRankedPage(db: D1Database, stubs: Room[]): Promise<Room[]> {
|
||
const ids = stubs.map(roomIdOf)
|
||
if (ids.length === 0) return []
|
||
const rows = await selectInChunks<RoomRow>(
|
||
db,
|
||
ids,
|
||
(placeholders) => `SELECT ${ROOM_COLUMNS} FROM room WHERE room_id IN (${placeholders})`
|
||
)
|
||
const byId = new Map(parseAll(rows).map((room) => [roomIdOf(room), room]))
|
||
return ids.map((id) => byId.get(id)).filter((room): room is Room => room !== undefined)
|
||
}
|
||
|
||
/**
|
||
* Keys on the client's room DTO that nothing here stores, defaulted on every read so the
|
||
* key is PRESENT rather than absent — the seed blobs and every room written since predate
|
||
* them, so they can't come from the data:
|
||
*
|
||
* - `BoostCount` — how many boosts the room is carrying. No boost feature exists here, so
|
||
* it is 0 for every room.
|
||
* - `CurrentSnapshotId` — the room's published snapshot. Nothing takes snapshots, so it is
|
||
* null, which is also what the reference serves for a room that has none.
|
||
* - `CCU` — concurrent users. No live-population counter exists here, so it is null, which
|
||
* is what the reference serves when it has no number rather than 0 (a 0 reads as "nobody
|
||
* is in here" in the browse feeds).
|
||
*
|
||
* Defaulted rather than assigned, so a stored value wins if any is ever really written
|
||
* (a blob keeps whatever `serializeRoom` last put in it).
|
||
*/
|
||
function attachRoomDtoDefaults(room: Room): void {
|
||
room.BoostCount ??= 0
|
||
room.CurrentSnapshotId ??= null
|
||
room.CCU ??= null
|
||
}
|
||
|
||
/**
|
||
* Parse a room row: the stored blob with the counters the columns own folded back in.
|
||
* `visits` is a real column, so a room read straight from the DB carries the live count.
|
||
*/
|
||
const parseRow = (row: RoomRow): Room => {
|
||
const room = JSON.parse(row.data) as Room
|
||
room.Stats = { ...storedStats(room.Stats), VisitCount: row.visits ?? 0 }
|
||
attachRoomDtoDefaults(room)
|
||
return room
|
||
}
|
||
|
||
const parseOne = (row: RoomRow | null): Room | null => (row ? parseRow(row) : null)
|
||
const parseAll = (rows: RoomRow[]): Room[] => rows.map(parseRow)
|
||
|
||
/**
|
||
* Run one `… IN (…)` query per chunk and concatenate the rows. `sql` is handed the
|
||
* placeholder list for its chunk (`?1,?2,…`), which always restarts at `?1` because each
|
||
* chunk is its own statement.
|
||
*
|
||
* Rows come back in chunk order, and each chunk is ordered by whatever `sql` says. Callers
|
||
* that group by a key stay correct as long as a key's rows can't straddle two chunks —
|
||
* true for both callers here, which chunk BY that key.
|
||
*/
|
||
async function selectInChunks<Row>(
|
||
db: D1Database,
|
||
ids: number[],
|
||
sql: (placeholders: string) => string
|
||
): Promise<Row[]> {
|
||
const pages = await Promise.all(
|
||
chunkForBinds(ids).map((chunk) =>
|
||
db
|
||
.prepare(sql(chunk.map((_, i) => `?${i + 1}`).join(',')))
|
||
.bind(...chunk)
|
||
.all<Row>()
|
||
)
|
||
)
|
||
return pages.flatMap((page) => page.results)
|
||
}
|
||
|
||
// ---- Subrooms -------------------------------------------------------------
|
||
// Subrooms are their own table (globally-unique autoincrement `sub_room_id`); a
|
||
// room's `SubRooms` array is reconstructed on read and never stored in the room blob.
|
||
|
||
/** A stored subroom — the parsed JSON blob (its client shape). */
|
||
export type SubRoom = Record<string, unknown>
|
||
|
||
interface SubRoomRow {
|
||
sub_room_id: number
|
||
room_id: number
|
||
data: string
|
||
current_save_id: number | null
|
||
staged_save_id: number | null
|
||
}
|
||
|
||
/** The columns every subroom read needs — the blob plus its two save pointers. */
|
||
const SUBROOM_COLUMNS = 'sub_room_id, room_id, data, current_save_id, staged_save_id'
|
||
|
||
/**
|
||
* Materialize a subroom row into its client shape, with the columns authoritative.
|
||
* `CurrentSave` is left undefined here and filled in by {@link attachCurrentSaves} — it
|
||
* lives in `subroom_save`, and resolving it per row would be a query each. Callers must
|
||
* go through the helpers below so the key is never missing: the client reads the scene
|
||
* blob from `CurrentSave` and nowhere else, so a subroom without one loads nothing.
|
||
*/
|
||
const parseSubRoomRow = (row: SubRoomRow): SubRoom => ({
|
||
...(JSON.parse(row.data) as SubRoom),
|
||
SubRoomId: row.sub_room_id,
|
||
RoomId: row.room_id,
|
||
// Served from the column, not the blob — the creator's unpublished save (unused for
|
||
// now, but the client expects the key present).
|
||
StagedSubRoomDataSaveId: row.staged_save_id,
|
||
})
|
||
|
||
/**
|
||
* Serialize a subroom for storage — drop the id/room columns and the save fields that
|
||
* are columns or their own table, so the blob never holds a stale copy of either.
|
||
*/
|
||
const serializeSubRoom = (sub: SubRoom, roomId: number): string => {
|
||
const {
|
||
SubRoomId: _id,
|
||
RoomId: _room,
|
||
CurrentSave: _save,
|
||
StagedSubRoomDataSaveId: _staged,
|
||
...rest
|
||
} = sub
|
||
return JSON.stringify({ ...rest, RoomId: roomId })
|
||
}
|
||
|
||
/**
|
||
* Serialize a room for a full-blob write, dropping the parts that belong to another table
|
||
* so the blob can never hold a stale copy: hydrated `SubRooms` (the `subroom` table's job),
|
||
* `Tags` (the `room_tag` table's job — see {@link setRoomTags}), and the derived engagement
|
||
* counters, which are zeroed rather than dropped so the key stays present (the
|
||
* `interaction` table's job — see {@link attachStats}).
|
||
*
|
||
* Because `Tags` is dropped here, a write that means to CHANGE a room's tags has to write
|
||
* the table itself; passing a room with a new `Tags` array through this silently discards
|
||
* it.
|
||
*/
|
||
const serializeRoom = (room: Room): string => {
|
||
const { SubRooms: _subRooms, Tags: _tags, Stats: stats, ...rest } = room
|
||
return JSON.stringify({ ...rest, Stats: storedStats(stats) })
|
||
}
|
||
|
||
/**
|
||
* Fill in each subroom's `CurrentSave` from `subroom_save`, in ONE query for the whole
|
||
* batch. Every subroom ends up with the key present — null when it points at no save
|
||
* (never saved) or the pointer dangles — because the client's loader reads it directly.
|
||
*
|
||
* `rows` must line up with `subs` positionally; the pointer lives on the row, not the
|
||
* parsed blob.
|
||
*/
|
||
async function attachCurrentSaves(
|
||
db: D1Database,
|
||
subs: SubRoom[],
|
||
rows: SubRoomRow[]
|
||
): Promise<void> {
|
||
const saveIds = [...new Set(rows.map((r) => r.current_save_id).filter((id) => id != null))]
|
||
const byId = new Map<number, SubRoomDataSave>()
|
||
if (saveIds.length > 0) {
|
||
// Chunked: a save row carries a whole scene, so these are fetched by id rather than
|
||
// scanned, and the id list can exceed D1's bound-parameter cap once enough rooms are
|
||
// hydrated at once.
|
||
const results = await selectInChunks<SubRoomSaveRow>(
|
||
db,
|
||
saveIds,
|
||
(placeholders) =>
|
||
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
|
||
WHERE sub_room_data_save_id IN (${placeholders})`
|
||
)
|
||
for (const r of results) byId.set(r.sub_room_data_save_id, parseSubRoomSaveRow(r))
|
||
}
|
||
subs.forEach((sub, i) => {
|
||
const id = rows[i]!.current_save_id
|
||
sub.CurrentSave = id == null ? null : (byId.get(id) ?? null)
|
||
})
|
||
}
|
||
|
||
// ---- Room tags ------------------------------------------------------------
|
||
// A room's tags live in `room_tag`, not in the room blob (see ROOM_SCHEMA_DDL). The blob
|
||
// is stripped on write and the array is re-attached on read, so there is exactly one
|
||
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
|
||
// parses every room to ask.
|
||
|
||
/**
|
||
* One of a room's tags, as the client's room DTO carries it.
|
||
*
|
||
* `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre — the key is
|
||
* left off the others rather than sent as false, which is the shape the client sends and
|
||
* reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}.
|
||
*/
|
||
export interface RoomTag {
|
||
Tag: string
|
||
Type: number
|
||
IsPrimaryGenre?: boolean
|
||
}
|
||
|
||
interface RoomTagRow {
|
||
room_id: number
|
||
tag: string
|
||
type: number
|
||
is_primary_genre: number
|
||
}
|
||
|
||
/** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */
|
||
function toRoomTag(row: RoomTagRow): RoomTag {
|
||
const tag: RoomTag = { Tag: row.tag, Type: row.type }
|
||
if (row.is_primary_genre) tag.IsPrimaryGenre = true
|
||
return tag
|
||
}
|
||
|
||
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
|
||
function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
|
||
const byRoom = new Map<number, RoomTag[]>()
|
||
for (const row of rows) {
|
||
const list = byRoom.get(row.room_id) ?? []
|
||
list.push(toRoomTag(row))
|
||
byRoom.set(row.room_id, list)
|
||
}
|
||
return byRoom
|
||
}
|
||
|
||
/**
|
||
* Every tag of the given rooms, keyed by RoomId, in ONE query however many rooms are
|
||
* asked about. An empty `roomIds` reads nothing rather than every tag in the table — an
|
||
* empty `IN ()` isn't valid SQL, and "no rooms asked about" must not come to mean "all of
|
||
* them".
|
||
*
|
||
* Two shapes, because the callers are two different questions. A page slice fits D1's
|
||
* 100-parameter cap and is fetched by id. Attaching tags to EVERY room does not fit — and
|
||
* chunking it would mean ceil(n/100) round trips to answer what one unfiltered read
|
||
* answers, on a table of three small columns. So past the cap this reads the whole table
|
||
* and narrows in memory.
|
||
*
|
||
* This is what the D1 error "variable number must be between ?1 and ?100" was: the hot feed
|
||
* attaches tags to every room, so the bound list grew with the database and the query blew
|
||
* up the moment a server had more than a hundred rooms.
|
||
*
|
||
* Tags come back alphabetical, so a room's array is stable between reads.
|
||
*/
|
||
async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number, RoomTag[]>> {
|
||
const ids = [...new Set(roomIds)]
|
||
if (ids.length === 0) return new Map()
|
||
|
||
if (ids.length > MAX_BOUND_PARAMS) {
|
||
const { results } = await db
|
||
.prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag')
|
||
.all<RoomTagRow>()
|
||
const wanted = new Set(ids)
|
||
return groupTags(results.filter((row) => wanted.has(row.room_id)))
|
||
}
|
||
|
||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT room_id, tag, type, is_primary_genre FROM room_tag
|
||
WHERE room_id IN (${placeholders}) ORDER BY tag`
|
||
)
|
||
.bind(...ids)
|
||
.all<RoomTagRow>()
|
||
return groupTags(results)
|
||
}
|
||
|
||
/**
|
||
* Fill in each room's `Tags` from `room_tag`. Every room ends up with the key PRESENT —
|
||
* an empty array when it carries none — because the client's DTO has a non-nullable
|
||
* `Tags` and the blob no longer supplies one.
|
||
*/
|
||
async function attachTags(db: D1Database, rooms: Room[]): Promise<void> {
|
||
const byRoom = await tagsByRoom(db, [...new Set(rooms.map(roomIdOf))])
|
||
for (const room of rooms) room.Tags = byRoom.get(roomIdOf(room)) ?? []
|
||
}
|
||
|
||
/**
|
||
* Parse room rows AND attach their tags — the read every scan-then-rank feed starts from.
|
||
*
|
||
* Those feeds filter and sort BEFORE they hydrate (ranking a room doesn't need its
|
||
* subrooms), but several of them rank ON tags, so the tags have to be present earlier than
|
||
* {@link hydrateRooms} would put them. One extra query for the whole batch.
|
||
*/
|
||
async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]> {
|
||
const rooms = parseAll(rows)
|
||
await attachTags(db, rooms)
|
||
return rooms
|
||
}
|
||
|
||
/**
|
||
* Replace a room's tags with the given set, in one batch. A replace and not a merge: the
|
||
* only writer ({@link toggleRoomTag}) computes the whole set it wants, so a removed tag is
|
||
* a write with that tag left out.
|
||
*
|
||
* Tags are stored LOWERCASED, which is what makes the index a usable lookup key — every
|
||
* comparison in this module was already case-insensitive, so nothing downstream can tell
|
||
* the difference. A room tagged `Horror` reads back `horror`.
|
||
*/
|
||
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
|
||
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
|
||
for (const { Tag, Type, IsPrimaryGenre } of tags) {
|
||
statements.push(
|
||
db
|
||
.prepare(
|
||
`INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4)
|
||
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4`
|
||
)
|
||
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0)
|
||
)
|
||
}
|
||
await db.batch(statements)
|
||
}
|
||
|
||
/**
|
||
* A room read narrowed to the rooms carrying EVERY one of `tagSets` — one set per tag the
|
||
* caller requires, and a room matches a set by carrying ANY tag in it (which is how a
|
||
* term's aliases work: `#recroomoriginal` accepts `rro`). An empty `tagSets` reads every
|
||
* room.
|
||
*
|
||
* A JOIN driven from `room_tag`, not a `WHERE EXISTS`, and the difference is the whole
|
||
* point of the table. EXPLAIN QUERY PLAN on the seeded database:
|
||
*
|
||
* WHERE EXISTS … SCAN room · SEARCH room_tag USING COVERING INDEX (room_id=? AND tag=?)
|
||
* JOIN from tags SEARCH room_tag USING INDEX idx_room_tag_tag (tag=?)
|
||
* SEARCH room USING INDEX idx_rooms_room_id (room_id=?)
|
||
*
|
||
* The EXISTS form still walks every room and probes the index once per room, so it costs
|
||
* what the in-memory filter it replaced cost. The join searches the tag index FIRST and
|
||
* then looks up only the rooms that matched, which is what makes a category row cheap.
|
||
*/
|
||
function roomsByTagsQuery(
|
||
tagSets: string[][],
|
||
where = '',
|
||
columns = ROOM_COLUMNS
|
||
): { sql: string; binds: string[] } {
|
||
// `where` is the caller's row filter ({@link LISTABLE_WHERE} or {@link PUBLIC_WHERE}) —
|
||
// unqualified, which is unambiguous under either shape below. It matters most when
|
||
// `tagSets` is EMPTY: that branch is the full scan every pseudo-tag feed still runs.
|
||
const filter = where === '' ? '' : ` WHERE ${where}`
|
||
if (tagSets.length === 0) return { sql: `SELECT ${columns} FROM room${filter}`, binds: [] }
|
||
|
||
const binds: string[] = []
|
||
const joins = tagSets.map((tags, i) => {
|
||
const placeholders = tags.map((_, j) => `?${binds.length + j + 1}`).join(', ')
|
||
binds.push(...tags)
|
||
return `JOIN (SELECT DISTINCT room_id FROM room_tag WHERE tag IN (${placeholders})) f${i}
|
||
ON f${i}.room_id = r.room_id`
|
||
})
|
||
// `data`/`visits` — in {@link ROOM_COLUMNS} or the ranking stub {@link RANK_COLUMNS} —
|
||
// are unqualified but unambiguous: the joined subqueries expose only `room_id`.
|
||
return { sql: `SELECT ${columns} FROM room r ${joins.join(' ')}${filter}`, binds }
|
||
}
|
||
|
||
/** Parse subroom rows and resolve their `CurrentSave` in one batched query. */
|
||
async function parseSubRoomRows(db: D1Database, rows: SubRoomRow[]): Promise<SubRoom[]> {
|
||
const subs = rows.map(parseSubRoomRow)
|
||
await attachCurrentSaves(db, subs, rows)
|
||
return subs
|
||
}
|
||
|
||
/** Attach each room's `SubRooms` array from the subroom table (one batched query). */
|
||
async function attachSubRooms(db: D1Database, rooms: Room[]): Promise<void> {
|
||
const ids = rooms.map((r) => Number(r.RoomId)).filter((n) => Number.isFinite(n))
|
||
if (ids.length === 0) {
|
||
for (const room of rooms) room.SubRooms = []
|
||
return
|
||
}
|
||
// Chunked by ROOM, so all of a room's subrooms land in one chunk and stay ordered
|
||
// relative to each other — the grouping below depends on that.
|
||
const results = await selectInChunks<SubRoomRow>(
|
||
db,
|
||
ids,
|
||
(placeholders) =>
|
||
`SELECT ${SUBROOM_COLUMNS} FROM subroom
|
||
WHERE room_id IN (${placeholders}) ORDER BY sub_room_id`
|
||
)
|
||
const subs = await parseSubRoomRows(db, results)
|
||
const byRoom = new Map<number, SubRoom[]>()
|
||
results.forEach((r, i) => {
|
||
const list = byRoom.get(r.room_id) ?? []
|
||
list.push(subs[i]!)
|
||
byRoom.set(r.room_id, list)
|
||
})
|
||
for (const room of rooms) room.SubRooms = byRoom.get(Number(room.RoomId)) ?? []
|
||
}
|
||
|
||
// ---- Room stats -----------------------------------------------------------
|
||
// A room's cheer/favorite counters are DERIVED from the `interaction` table rather than
|
||
// stored: they're recomputed on every read, so a cheer shows up immediately and the
|
||
// counts can't drift from the per-player rows they're made of. The blob keeps them at 0
|
||
// (see {@link serializeRoom}).
|
||
//
|
||
// `VisitCount` is neither stored in the blob nor derived: it's the `room.visits` column,
|
||
// incremented by {@link recordRoomVisit} on each matchmake and read back with the blob
|
||
// (see {@link parseRow}). It can't be derived the way cheers are — a visit leaves no
|
||
// per-player row to count — and it can't live in the blob, where a read-modify-write of
|
||
// the whole room would drop concurrent visits. `VisitorCount` (distinct visitors) is
|
||
// still left as the blob has it: `interaction.last_visited_at` is only stamped by the
|
||
// cheer/favorite toggles, so counting those rows would report cheerers as visitors.
|
||
|
||
/** One room's derived engagement counters (the aggregate maps below key these by RoomId). */
|
||
export interface RoomStats {
|
||
CheerCount: number
|
||
FavoriteCount: number
|
||
}
|
||
|
||
interface RoomStatsRow {
|
||
room_id: number
|
||
cheers: number
|
||
favorites: number
|
||
}
|
||
|
||
/** The counters a room starts life with (and the shape the client expects). */
|
||
const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 }
|
||
|
||
/** A room's RoomId, or 0 for a blob without one. */
|
||
const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0)
|
||
|
||
/**
|
||
* The `Stats` object to persist: whatever the room carried, with the counters the
|
||
* columns/tables own back at 0 so the blob never holds a stale copy of them.
|
||
*/
|
||
function storedStats(stats: unknown): Record<string, unknown> {
|
||
const stored =
|
||
typeof stats === 'object' && stats !== null ? (stats as Record<string, unknown>) : {}
|
||
return { ...ZERO_STATS, ...stored, CheerCount: 0, FavoriteCount: 0, VisitCount: 0 }
|
||
}
|
||
|
||
/**
|
||
* Count one visit to a room — the `match` worker calls this on every successful
|
||
* matchmake (see its `enterRoom`), which is the only way a player ever lands in a room.
|
||
* A blind `visits = visits + 1` UPDATE: it's the whole write, so simultaneous visitors
|
||
* can't clobber each other, and an unknown room id simply matches nothing.
|
||
*/
|
||
export async function recordRoomVisit(db: D1Database, roomId: number): Promise<void> {
|
||
await db.prepare('UPDATE room SET visits = visits + 1 WHERE room_id = ?1').bind(roomId).run()
|
||
}
|
||
|
||
/**
|
||
* Cheer/favorite counts per room, aggregated from `interaction` by a grouped query.
|
||
* Restricted to `roomIds` when given (a feed page), otherwise covering every room.
|
||
* Rooms nobody has interacted with are absent.
|
||
*
|
||
* The filtered form is an index range per id on `idx_interaction_room`, whose columns
|
||
* cover the counters — the table is never read. That's why a page with more ids than
|
||
* D1 lets us bind is split into batched queries rather than served by the unfiltered
|
||
* scan: every room read runs this, and the scan cost the whole table per call.
|
||
*/
|
||
export async function getRoomStats(
|
||
db: D1Database,
|
||
roomIds?: number[]
|
||
): Promise<Map<number, RoomStats>> {
|
||
const byRoom = new Map<number, RoomStats>()
|
||
if (roomIds && roomIds.length === 0) return byRoom
|
||
const statement = (ids: number[]): D1PreparedStatement => {
|
||
const where = ids.length > 0 ? `WHERE room_id IN (${bindPlaceholders(ids)})` : ''
|
||
return db
|
||
.prepare(
|
||
`SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites
|
||
FROM interaction ${where} GROUP BY room_id`
|
||
)
|
||
.bind(...ids)
|
||
}
|
||
const chunks = roomIds ? chunkForBinds(roomIds) : [[]]
|
||
const batches =
|
||
chunks.length === 1
|
||
? [await statement(chunks[0]!).all<RoomStatsRow>()]
|
||
: await db.batch<RoomStatsRow>(chunks.map(statement))
|
||
for (const { results } of batches) {
|
||
for (const r of results) {
|
||
byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 })
|
||
}
|
||
}
|
||
return byRoom
|
||
}
|
||
|
||
/**
|
||
* Overwrite each room's derived counters from the interaction table, in one query for
|
||
* the whole batch. Callers that already aggregated (the feeds rank by these counts, so
|
||
* they need them before paging) pass their map in rather than paying for a second query.
|
||
*/
|
||
async function attachStats(
|
||
db: D1Database,
|
||
rooms: Room[],
|
||
stats?: Map<number, RoomStats>
|
||
): Promise<void> {
|
||
if (rooms.length === 0) return
|
||
const byRoom = stats ?? (await getRoomStats(db, [...new Set(rooms.map(roomIdOf))]))
|
||
for (const room of rooms) {
|
||
const counts = byRoom.get(roomIdOf(room))
|
||
// `storedStats` zeroes VisitCount (the blob doesn't own it), so carry over the
|
||
// value `parseRow` folded in from the `visits` column rather than losing it here.
|
||
const stats = (room.Stats ?? {}) as Record<string, unknown>
|
||
room.Stats = {
|
||
...storedStats(stats),
|
||
VisitCount: typeof stats.VisitCount === 'number' ? stats.VisitCount : 0,
|
||
CheerCount: counts?.CheerCount ?? 0,
|
||
FavoriteCount: counts?.FavoriteCount ?? 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Hydrate a single room's `SubRooms` and derived `Stats` (no-op for null). */
|
||
async function hydrateRoom(db: D1Database, room: Room | null): Promise<Room | null> {
|
||
if (room) await hydrateRooms(db, [room])
|
||
return room
|
||
}
|
||
|
||
/**
|
||
* Hydrate many rooms' `SubRooms`, `Tags` and derived `Stats` (one batched query each).
|
||
*
|
||
* `Tags` is re-attached even for the feeds that already did so before ranking
|
||
* ({@link parseAllWithTags}) — it is one query for the page slice and it guarantees the key
|
||
* is present on every room this module hands out, whichever path produced it.
|
||
*/
|
||
async function hydrateRooms(
|
||
db: D1Database,
|
||
rooms: Room[],
|
||
stats?: Map<number, RoomStats>
|
||
): Promise<Room[]> {
|
||
await Promise.all([
|
||
attachSubRooms(db, rooms),
|
||
attachTags(db, rooms),
|
||
attachStats(db, rooms, stats),
|
||
])
|
||
return rooms
|
||
}
|
||
|
||
/** A single subroom of a room (columns authoritative), or null if it doesn't exist. */
|
||
export async function getSubRoom(
|
||
db: D1Database,
|
||
roomId: number,
|
||
subRoomId: number
|
||
): Promise<SubRoom | null> {
|
||
const row = await db
|
||
.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 AND sub_room_id = ?2`)
|
||
.bind(roomId, subRoomId)
|
||
.first<SubRoomRow>()
|
||
if (!row) return null
|
||
return (await parseSubRoomRows(db, [row]))[0]!
|
||
}
|
||
|
||
/** All of a room's subrooms, ordered by SubRoomId. */
|
||
export async function getSubRooms(db: D1Database, roomId: number): Promise<SubRoom[]> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${SUBROOM_COLUMNS} FROM subroom WHERE room_id = ?1 ORDER BY sub_room_id`)
|
||
.bind(roomId)
|
||
.all<SubRoomRow>()
|
||
return parseSubRoomRows(db, results)
|
||
}
|
||
|
||
// ---- Subroom saves --------------------------------------------------------
|
||
|
||
interface SubRoomSaveRow {
|
||
sub_room_data_save_id: number
|
||
sub_room_id: number
|
||
data: string
|
||
}
|
||
|
||
/** Materialize a save row, with its two id columns authoritative over the blob. */
|
||
const parseSubRoomSaveRow = (row: SubRoomSaveRow): SubRoomDataSave => ({
|
||
...(JSON.parse(row.data) as SubRoomDataSave),
|
||
SubRoomDataSaveId: row.sub_room_data_save_id,
|
||
SubRoomId: row.sub_room_id,
|
||
})
|
||
|
||
/** Serialize a save for storage — the id columns own those two fields, not the blob. */
|
||
const serializeSubRoomSave = (save: SubRoomDataSave): string => {
|
||
const { SubRoomDataSaveId: _id, SubRoomId: _sub, ...rest } = save
|
||
return JSON.stringify(rest)
|
||
}
|
||
|
||
/**
|
||
* Insert a save for a subroom, minting a fresh globally-unique `SubRoomDataSaveId` from
|
||
* the table's autoincrement sequence. Returns the stored save with its new id.
|
||
*/
|
||
async function insertSubRoomSave(
|
||
db: D1Database,
|
||
subRoomId: number,
|
||
save: SubRoomDataSave
|
||
): Promise<SubRoomDataSave> {
|
||
const row = await db
|
||
.prepare(
|
||
'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id'
|
||
)
|
||
.bind(subRoomId, serializeSubRoomSave(save))
|
||
.first<{ sub_room_data_save_id: number }>()
|
||
return { ...save, SubRoomDataSaveId: row!.sub_room_data_save_id, SubRoomId: subRoomId }
|
||
}
|
||
|
||
/**
|
||
* A subroom's save history, newest first. Unlike the old inline model this is real
|
||
* history: every save is its own row and none are overwritten.
|
||
*/
|
||
export async function getSubRoomSaves(
|
||
db: D1Database,
|
||
subRoomId: number
|
||
): Promise<SubRoomDataSave[]> {
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT sub_room_data_save_id, sub_room_id, data FROM subroom_save
|
||
WHERE sub_room_id = ?1 ORDER BY sub_room_data_save_id DESC`
|
||
)
|
||
.bind(subRoomId)
|
||
.all<SubRoomSaveRow>()
|
||
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),
|
||
// Each player's saved data for the room: keyed by room, meaningless without it.
|
||
db.prepare('DELETE FROM playerdata WHERE room_id = ?1').bind(roomId),
|
||
])
|
||
}
|
||
|
||
/** Look up a single room by name (case-insensitive exact match). */
|
||
export async function getRoomByName(db: D1Database, name: string): Promise<Room | null> {
|
||
return hydrateRoom(
|
||
db,
|
||
parseOne(
|
||
await db
|
||
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE name_lower = ?1`)
|
||
.bind(name.toLowerCase())
|
||
.first<RoomRow>()
|
||
)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Look up multiple rooms by RoomId.
|
||
*
|
||
* Every id is bound into one query, so the CALLER must keep the list within D1's cap of 100
|
||
* bound parameters — `/rooms/bulk` rejects a longer request with a 400 rather than have this
|
||
* split it, since a client asking about more than a hundred rooms at once is asking the
|
||
* wrong question.
|
||
*/
|
||
export async function getRoomsByIds(db: D1Database, ids: number[]): Promise<Room[]> {
|
||
if (ids.length === 0) return []
|
||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
||
const { results } = await db
|
||
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE room_id IN (${placeholders})`)
|
||
.bind(...ids)
|
||
.all<RoomRow>()
|
||
return hydrateRooms(db, parseAll(results))
|
||
}
|
||
|
||
/** All rooms created by an account (e.g. their dorm). */
|
||
export async function getRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1`)
|
||
.bind(accountId)
|
||
.all<RoomRow>()
|
||
return hydrateRooms(db, parseAll(results))
|
||
}
|
||
|
||
/**
|
||
* How many rooms an account has made, for the per-account room cap. Dorms don't
|
||
* count: every player gets one auto-provisioned, so counting it would silently cost
|
||
* them a slot they never asked for.
|
||
*/
|
||
export async function countRoomsByCreator(db: D1Database, accountId: number): Promise<number> {
|
||
const row = await db
|
||
.prepare(
|
||
`SELECT COUNT(*) AS n FROM room
|
||
WHERE creator_account_id = ?1
|
||
AND COALESCE(is_dorm, 0) = 0`
|
||
)
|
||
.bind(accountId)
|
||
.first<{ n: number }>()
|
||
return row?.n ?? 0
|
||
}
|
||
|
||
/**
|
||
* Every room an account works on: the ones it CREATED plus the ones whose `Roles` name it
|
||
* (Host, Moderator or CoOwner). Every role tier counts, unlike {@link canManageRoom}'s
|
||
* owner-or-co-owner gate: this is "you have a job in this room", not "you may administer
|
||
* it".
|
||
*
|
||
* The creator half used to be excluded — a room's `Roles` carries its creator too, and the
|
||
* client shows "rooms you own" and "rooms you contribute to" as separate lists, so the
|
||
* exclusion kept this from repeating `createdby/me`. It also made the list EMPTY for every
|
||
* account that had only ever built its own rooms, which is most of them, so the screen
|
||
* behind it showed nothing at all. Repeating `createdby/me` is the better failure, and
|
||
* overlap is what a client that renders one list wants anyway.
|
||
*
|
||
* The dorm stays out, on `ownedby/me`'s reasoning: it is auto-provisioned rather than a
|
||
* room the player made. A room matching BOTH halves appears once — the roles half is an
|
||
* EXISTS, not a join.
|
||
*
|
||
* Roles live inside the room blob rather than in a table of their own, so the match is a
|
||
* `json_each` over `$.Roles`. A room with no `Roles` key (or a null one) simply yields no
|
||
* rows there rather than erroring, so it only reaches the list if the account created it.
|
||
*/
|
||
export async function getContributedRooms(db: D1Database, accountId: number): Promise<Room[]> {
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT ${ROOM_COLUMNS} FROM room
|
||
WHERE creator_account_id = ?1
|
||
OR EXISTS (
|
||
SELECT 1 FROM json_each(room.data, '$.Roles') AS role
|
||
WHERE json_extract(role.value, '$.AccountId') = ?1
|
||
)`
|
||
)
|
||
.bind(accountId)
|
||
.all<RoomRow>()
|
||
return (await hydrateRooms(db, parseAll(results))).filter((r) => r.IsDorm !== true)
|
||
}
|
||
|
||
/**
|
||
* An account's public, non-dorm rooms — the publicly viewable "rooms owned by
|
||
* <player>" list (excludes private rooms, dorms, and list-excluded rooms).
|
||
*/
|
||
export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise<Room[]> {
|
||
return (await getRoomsByCreator(db, accountId)).filter(isListable)
|
||
}
|
||
|
||
/**
|
||
* Rooms the player has favorited (interaction.favorited = 1), most recently
|
||
* interacted first. Joins the `interaction` table to `rooms`, so a favorited room
|
||
* no longer in D1 is simply absent. Paginated via skip/take; returns a bare array
|
||
* of rooms (the client's room-source loaders expect a plain list).
|
||
*/
|
||
export async function getFavoritedRooms(
|
||
db: D1Database,
|
||
playerId: number,
|
||
skip: number,
|
||
take: number
|
||
): Promise<Room[]> {
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT r.data AS data, r.visits AS visits
|
||
FROM interaction i
|
||
JOIN room r ON r.room_id = i.room_id
|
||
WHERE i.player_id = ?1 AND i.favorited = 1
|
||
ORDER BY i.last_visited_at DESC`
|
||
)
|
||
.bind(playerId)
|
||
.all<RoomRow>()
|
||
return hydrateRooms(db, parseAll(results).slice(skip, skip + take))
|
||
}
|
||
|
||
/**
|
||
* Rooms the player has visited (an interaction row with a `last_visited_at`),
|
||
* most recent first. Like favorites, it joins `interaction` to `rooms`, so a
|
||
* visited room no longer in D1 is simply absent. Paginated via skip/take; returns
|
||
* a bare array of rooms (the client's room-source loaders expect a plain list).
|
||
*
|
||
* This is the player's OWN history, so nothing is filtered on accessibility: their
|
||
* unpublished builds and the private rooms they were invited into are theirs to see.
|
||
* Anyone else reading it goes through {@link getPublicVisitedRooms}.
|
||
*/
|
||
export async function getVisitedRooms(
|
||
db: D1Database,
|
||
playerId: number,
|
||
skip: number,
|
||
take: number
|
||
): Promise<Room[]> {
|
||
return visitedRooms(db, playerId, skip, take, false)
|
||
}
|
||
|
||
/**
|
||
* {@link getVisitedRooms} as SOMEONE ELSE sees it — a profile, which anyone can look at —
|
||
* narrowed to the rooms that are {@link isListable}. A visit is recorded wherever the
|
||
* player goes, so the unfiltered history names their unpublished builds and the private
|
||
* rooms they were invited into; served whole, it leaked a room its owner hasn't released,
|
||
* and `GET /rooms/{id}` is not gated, so the id alone was enough to open it. The same test
|
||
* the public `ownedby/{accountId}` profile list applies, so a room that opted out of lists
|
||
* stays off this one too. Paginated AFTER the filter, so a page is a page of what the
|
||
* viewer may see rather than a page with holes in it.
|
||
*/
|
||
export async function getPublicVisitedRooms(
|
||
db: D1Database,
|
||
playerId: number,
|
||
skip: number,
|
||
take: number
|
||
): Promise<Room[]> {
|
||
return visitedRooms(db, playerId, skip, take, true)
|
||
}
|
||
|
||
async function visitedRooms(
|
||
db: D1Database,
|
||
playerId: number,
|
||
skip: number,
|
||
take: number,
|
||
listableOnly: boolean
|
||
): Promise<Room[]> {
|
||
// The listable predicate is pushed down (see LISTABLE_WHERE) so the blobs of the
|
||
// rooms a friend may not see never leave D1; the in-memory filter remains the
|
||
// definition, as everywhere else it is used.
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT r.data AS data, r.visits AS visits
|
||
FROM interaction i
|
||
JOIN room r ON r.room_id = i.room_id
|
||
WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL
|
||
${listableOnly ? `AND r.${LISTABLE_WHERE.replaceAll(' AND ', ' AND r.')}` : ''}
|
||
ORDER BY i.last_visited_at DESC`
|
||
)
|
||
.bind(playerId)
|
||
.all<RoomRow>()
|
||
const rooms = parseAll(results)
|
||
const visible = listableOnly ? rooms.filter(isListable) : rooms
|
||
return hydrateRooms(db, visible.slice(skip, skip + take))
|
||
}
|
||
|
||
/** A player's interaction state with a room. */
|
||
export interface Interaction {
|
||
Cheered: boolean
|
||
Favorited: boolean
|
||
}
|
||
|
||
interface InteractionRow {
|
||
cheered: number
|
||
favorited: number
|
||
}
|
||
|
||
const toInteraction = (row: InteractionRow | null): Interaction => ({
|
||
Cheered: row?.cheered === 1,
|
||
Favorited: row?.favorited === 1,
|
||
})
|
||
|
||
/** Read a player's interaction with a room (defaults to all-false if none). */
|
||
export async function getInteraction(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number
|
||
): Promise<Interaction> {
|
||
return toInteraction(
|
||
await db
|
||
.prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2')
|
||
.bind(playerId, roomId)
|
||
.first<InteractionRow>()
|
||
)
|
||
}
|
||
|
||
/** Upsert+toggle a single boolean column, returning the resulting interaction. */
|
||
async function toggleInteraction(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number,
|
||
column: 'cheered' | 'favorited'
|
||
): Promise<Interaction> {
|
||
const now = new Date().toISOString()
|
||
// First interaction defaults the toggled column to 1; subsequent calls flip it.
|
||
return toInteraction(
|
||
await db
|
||
.prepare(
|
||
`INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at)
|
||
VALUES (?1, ?2, 1, ?3)
|
||
ON CONFLICT(player_id, room_id)
|
||
DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3
|
||
RETURNING cheered, favorited`
|
||
)
|
||
.bind(playerId, roomId, now)
|
||
.first<InteractionRow>()
|
||
)
|
||
}
|
||
|
||
/** Toggle the player's cheer on a room, returning the resulting interaction. */
|
||
export async function toggleCheer(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number
|
||
): Promise<Interaction> {
|
||
return toggleInteraction(db, playerId, roomId, 'cheered')
|
||
}
|
||
|
||
/** Toggle the player's favorite on a room, returning the resulting interaction. */
|
||
export async function toggleFavorite(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number
|
||
): Promise<Interaction> {
|
||
return toggleInteraction(db, playerId, roomId, 'favorited')
|
||
}
|
||
|
||
/**
|
||
* Explicitly clear a single interaction flag on a room (the DELETE counterpart to
|
||
* the cheer/favorite toggles). Idempotent: only clears an existing interaction row
|
||
* and never creates one, so clearing a flag on a room the player never interacted
|
||
* with doesn't add a spurious visited/favorited entry. Returns the interaction.
|
||
*/
|
||
async function clearInteraction(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number,
|
||
column: 'cheered' | 'favorited'
|
||
): Promise<Interaction> {
|
||
await db
|
||
.prepare(`UPDATE interaction SET ${column} = 0 WHERE player_id = ?1 AND room_id = ?2`)
|
||
.bind(playerId, roomId)
|
||
.run()
|
||
return getInteraction(db, playerId, roomId)
|
||
}
|
||
|
||
/** Clear the player's cheer on a room (DELETE cheer), returning the interaction. */
|
||
export async function removeCheer(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number
|
||
): Promise<Interaction> {
|
||
return clearInteraction(db, playerId, roomId, 'cheered')
|
||
}
|
||
|
||
/** Clear the player's favorite on a room (DELETE favorite), returning the interaction. */
|
||
export async function removeFavorite(
|
||
db: D1Database,
|
||
playerId: number,
|
||
roomId: number
|
||
): Promise<Interaction> {
|
||
return clearInteraction(db, playerId, roomId, 'favorited')
|
||
}
|
||
|
||
/**
|
||
* Search-tag aliases: a queried `#tag` also matches these stored tag names.
|
||
* The client's pinned filters don't always match how rooms are tagged (e.g. it
|
||
* searches `recroomoriginal`, but rooms are tagged `rro`).
|
||
*/
|
||
const TAG_ALIASES: Record<string, string[]> = {
|
||
recroomoriginal: ['rro'],
|
||
}
|
||
|
||
/** A room's tag names, lowercased (empty when it has no Tags array). */
|
||
function roomTags(room: Room): string[] {
|
||
const tags = room.Tags
|
||
if (!Array.isArray(tags)) return []
|
||
return tags
|
||
.map((t) => (t as Record<string, unknown> | null)?.Tag)
|
||
.filter((v): v is string => typeof v === 'string')
|
||
.map((v) => v.toLowerCase())
|
||
}
|
||
|
||
/** True if the room carries any of the given (lowercased) tags. */
|
||
function roomHasAnyTag(room: Room, tags: Set<string>): boolean {
|
||
return roomTags(room).some((t) => tags.has(t))
|
||
}
|
||
|
||
/**
|
||
* Search public, non-dorm rooms. The query is split into terms (space/`+`):
|
||
* `#tag` terms match the room's Tags; plain terms match the room name
|
||
* (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`.
|
||
* The rows narrow in SQL — public, non-dorm ({@link PUBLIC_WHERE}) — and the name terms
|
||
* match in memory over what comes back.
|
||
*
|
||
* `#community` is the one tag term that isn't a tag lookup — see {@link COMMUNITY_TAG}.
|
||
*/
|
||
export async function searchRooms(
|
||
db: D1Database,
|
||
query: string,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const q = query.trim().toLowerCase()
|
||
if (q === '') return { Results: [], TotalResults: 0 }
|
||
const terms = q.split(/[\s+]+/).filter(Boolean)
|
||
|
||
// The `#tag` terms narrow in SQL — one EXISTS per term, because a room has to carry
|
||
// EVERY tag asked for, and each term expands to its aliases (`#recroomoriginal` accepts
|
||
// `rro`). Only the rooms that survive have their blobs read, which is what `room_tag` is
|
||
// for: a tag search no longer parses every room in the database to ask.
|
||
//
|
||
// `#community` is held out of that query: no room CARRIES the tag (the browse chip posts
|
||
// it to the hot feed as a pseudo-tag, and the search box sends the same term), so asking
|
||
// `room_tag` for it matches nothing and the whole search comes back empty. It filters on
|
||
// who MADE the room instead, below.
|
||
const tagTerms = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
|
||
const communityOnly = tagTerms.includes(COMMUNITY_TAG)
|
||
const tagSets = tagTerms
|
||
.filter((tag) => tag !== COMMUNITY_TAG)
|
||
.map((tag) => [tag, ...(TAG_ALIASES[tag] ?? [])])
|
||
const { sql, binds } = roomsByTagsQuery(tagSets, PUBLIC_WHERE)
|
||
const { results } = await db
|
||
.prepare(sql)
|
||
.bind(...binds)
|
||
.all<RoomRow>()
|
||
let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1)
|
||
|
||
// The same test the hot feed's `community` chip applies: every room a player made, which
|
||
// is every room the Coach account doesn't own. It narrows the other terms rather than
|
||
// replacing them, so `#community horror` is still a name search within player-made rooms.
|
||
if (communityOnly) rooms = rooms.filter(isPlayerMade)
|
||
|
||
// The plain terms still match in memory: they are substring matches on the name, which
|
||
// no index helps with.
|
||
for (const term of terms) {
|
||
if (term.startsWith('#')) continue
|
||
rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term))
|
||
}
|
||
|
||
return {
|
||
Results: await hydrateRooms(db, rooms.slice(skip, skip + take)),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Search suggestions for the box the player is typing in
|
||
* (`GET /rooms/autocomplete_search`) — a list of plain STRINGS, not rooms.
|
||
*
|
||
* Everything suggested is something the follow-up `/rooms/search` will actually find,
|
||
* which is the whole point of the endpoint: a suggestion that returns nothing is worse
|
||
* than no suggestion. So the candidates are drawn from the two things that search matches
|
||
* — room NAMES for a plain term, and TAGS for a `#tag` term — over the same public,
|
||
* non-dorm rooms search itself considers. A tag comes back with its `#` so submitting the
|
||
* suggestion verbatim searches by tag rather than for a room called "horror".
|
||
*
|
||
* A query starting with `#` is asking for tags, so only tags are suggested. Otherwise
|
||
* names come first (the likelier intent), then tags, and within each, matches that START
|
||
* with the query come before ones that merely contain it. Ties break alphabetically, so
|
||
* the same query always suggests the same things in the same order.
|
||
*
|
||
* Matching is case-insensitive and suggestions are de-duplicated case-insensitively, but
|
||
* each is returned in its stored casing — search doesn't care, and the player reads these.
|
||
* Narrowed in SQL to the same rooms {@link searchRooms} considers; the matching itself is
|
||
* in memory, like search's.
|
||
*/
|
||
export async function autocompleteRoomSearch(
|
||
db: D1Database,
|
||
query: string,
|
||
take: number
|
||
): Promise<string[]> {
|
||
const q = query.trim().toLowerCase()
|
||
if (q === '' || take <= 0) return []
|
||
|
||
const { results } = await db
|
||
.prepare(`SELECT ${ROOM_COLUMNS} FROM room WHERE ${PUBLIC_WHERE}`)
|
||
.all<RoomRow>()
|
||
// Tags attached up front: suggestions are drawn from them, and this reads every candidate
|
||
// room for its NAME regardless, so the tags cost one extra query rather than a second scan.
|
||
const rooms = (await parseAllWithTags(db, results)).filter(
|
||
(r) => r.IsDorm !== true && r.Accessibility === 1
|
||
)
|
||
|
||
const tagQuery = q.startsWith('#')
|
||
const term = tagQuery ? q.slice(1) : q
|
||
if (term === '') return []
|
||
|
||
// Lowercased suggestion → [rank, the casing to serve it in]. Lower rank sorts first.
|
||
const found = new Map<string, [number, string]>()
|
||
const offer = (value: string, rank: number) => {
|
||
const key = value.toLowerCase()
|
||
const existing = found.get(key)
|
||
if (existing === undefined || existing[0] > rank) found.set(key, [rank, value])
|
||
}
|
||
|
||
for (const room of rooms) {
|
||
if (!tagQuery && typeof room.Name === 'string') {
|
||
const name = room.Name.toLowerCase()
|
||
if (name.startsWith(term)) offer(room.Name, 0)
|
||
else if (name.includes(term)) offer(room.Name, 1)
|
||
}
|
||
for (const tag of roomTags(room)) {
|
||
if (tag.startsWith(term)) offer(`#${tag}`, tagQuery ? 0 : 2)
|
||
else if (tag.includes(term)) offer(`#${tag}`, tagQuery ? 1 : 3)
|
||
}
|
||
}
|
||
|
||
return [...found.entries()]
|
||
.sort(([aKey, [aRank]], [bKey, [bRank]]) => aRank - bRank || aKey.localeCompare(bKey))
|
||
.slice(0, take)
|
||
.map(([, [, value]]) => value)
|
||
}
|
||
|
||
/**
|
||
* Engagement score used to order the hot feed (cheers weigh most, then favorites).
|
||
* Cheers/favorites come from the caller's aggregated {@link getRoomStats} map — ranking
|
||
* happens before hydration, so the room blob's copies are still zero at this point.
|
||
*/
|
||
function hotScore(room: Room, stats: Map<number, RoomStats>): number {
|
||
const counts = stats.get(roomIdOf(room))
|
||
const stored = room.Stats as Record<string, unknown> | null | undefined
|
||
const visitors = typeof stored?.VisitorCount === 'number' ? stored.VisitorCount : 0
|
||
return (counts?.CheerCount ?? 0) * 3 + (counts?.FavoriteCount ?? 0) * 2 + visitors
|
||
}
|
||
|
||
/**
|
||
* The browse screen's "New" chip posts `tag=new` to the hot feed, but `new` is a
|
||
* PSEUDO-tag: no room carries it. It means "recently created by a player", so it
|
||
* selects the non-RRO rooms and orders them newest-first instead of by population.
|
||
*/
|
||
const NEW_TAG = 'new'
|
||
|
||
/**
|
||
* The browse screen's "Community" chip posts `tag=community`, another PSEUDO-tag no
|
||
* room carries. It means "made by a player", which here is every room whose creator
|
||
* isn't the Coach account — the system account that owns the seeded Rec Room rooms.
|
||
* Unlike {@link NEW_TAG} it only filters: the page keeps the feed's normal
|
||
* live-population ordering.
|
||
*
|
||
* The chip reaches {@link searchRooms} too, as the tag term `#community` — the search box
|
||
* carries the same word — so both feeds have to know it names no tag.
|
||
*/
|
||
const COMMUNITY_TAG = 'community'
|
||
|
||
/** The system account (`Coach`) that owns the seeded first-party rooms. */
|
||
const COACH_ACCOUNT_ID = 1
|
||
|
||
/**
|
||
* True if the room is a Rec Room Original. `IsRRO` is the flag the client renders a
|
||
* virtual "RRO" tag from; the auto-derived `rro` tag is checked too so a room that only
|
||
* carries the tag isn't mistaken for player-made.
|
||
*/
|
||
function isRRO(room: Room): boolean {
|
||
return room.IsRRO === true || roomHasAnyTag(room, new Set(['rro']))
|
||
}
|
||
|
||
/**
|
||
* True when the room may appear in a browse or discovery feed at all: public, not a dorm,
|
||
* and not opted out of lists. Every feed below starts from this, so a room that opts out
|
||
* cannot come back through a row that forgot to check.
|
||
*/
|
||
function isListable(room: Room): boolean {
|
||
return room.IsDorm !== true && room.Accessibility === 1 && room.ExcludeFromLists !== true
|
||
}
|
||
|
||
/**
|
||
* True when the room is a PLAYER's rather than one of this server's stock ones — the same
|
||
* test {@link COMMUNITY_TAG} applies, since the Coach account owns every seeded room.
|
||
*
|
||
* A different question from {@link isRRO}, which asks whether a room is a Rec Room
|
||
* Original. The two agree on the data as it stands (every seeded room is Coach-owned AND
|
||
* flagged `rro`), but the discovery rows ask this one: a stock room that was never flagged
|
||
* still isn't something a player built.
|
||
*/
|
||
function isPlayerMade(room: Room): boolean {
|
||
return room.CreatorAccountId !== COACH_ACCOUNT_ID
|
||
}
|
||
|
||
/** A room's CreatedAt as epoch millis; 0 (i.e. oldest) when it's missing or unparseable. */
|
||
function createdAt(room: Room): number {
|
||
const ts = typeof room.CreatedAt === 'string' ? Date.parse(room.CreatedAt) : NaN
|
||
return Number.isNaN(ts) ? 0 : ts
|
||
}
|
||
|
||
/**
|
||
* The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered
|
||
* by how many players are in them RIGHT NOW (live presence summed across the
|
||
* room's instances), and optionally filtered to a single `tag` (with the same
|
||
* aliases as search). "Hot" is a live-population feed, so current players lead;
|
||
* rooms nobody is in — and the all-zero seed data — fall back to the stored
|
||
* engagement score, then to RoomId order so paging stays stable. Paginated via
|
||
* skip/take; returns `{ Results, TotalResults }` like search. The listable filter runs in
|
||
* SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
|
||
*
|
||
* `tag=new` and `tag=community` are the filters that aren't tag lookups — see
|
||
* {@link NEW_TAG} and {@link COMMUNITY_TAG}.
|
||
*/
|
||
export async function getHotRooms(
|
||
db: D1Database,
|
||
tag: string,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const t = tag.trim().toLowerCase()
|
||
|
||
// A REAL tag narrows in SQL — this is the query `room_tag` exists for, and the one a
|
||
// discovery category row runs: only the rooms carrying the tag have their blobs read.
|
||
// The two pseudo-tags below name no tag at all, so they still scan.
|
||
const isPseudo = t === '' || t === NEW_TAG || t === COMMUNITY_TAG
|
||
const { sql, binds } = roomsByTagsQuery(
|
||
isPseudo ? [] : [[t, ...(TAG_ALIASES[t] ?? [])]],
|
||
LISTABLE_WHERE,
|
||
RANK_COLUMNS
|
||
)
|
||
const { results } = await db
|
||
.prepare(sql)
|
||
.bind(...binds)
|
||
.all<RoomRow>()
|
||
let rooms = (await parseAllWithTags(db, results)).filter(isListable)
|
||
|
||
if (t === NEW_TAG) {
|
||
// Newest player-made rooms first; RoomId (which is minted in creation order)
|
||
// breaks ties so rooms created in the same instant still page stably.
|
||
const fresh = rooms
|
||
.filter((r) => !isRRO(r))
|
||
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
|
||
return {
|
||
Results: await hydrateRooms(db, await loadRankedPage(db, fresh.slice(skip, skip + take))),
|
||
TotalResults: fresh.length,
|
||
}
|
||
}
|
||
|
||
// `community` is a pseudo-tag: it filters on who MADE the room rather than on any tag,
|
||
// so it can't be pushed into the tag query above. A real tag already narrowed there.
|
||
if (t === COMMUNITY_TAG) {
|
||
rooms = rooms.filter((r) => r.CreatorAccountId !== COACH_ACCOUNT_ID)
|
||
}
|
||
|
||
const players = await countPlayersByRoom(db)
|
||
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
|
||
const stats = await getRoomStats(db)
|
||
rooms.sort(
|
||
(a, b) =>
|
||
playerCount(b) - playerCount(a) ||
|
||
hotScore(b, stats) - hotScore(a, stats) ||
|
||
roomIdOf(a) - roomIdOf(b)
|
||
)
|
||
return {
|
||
Results: await hydrateRooms(
|
||
db,
|
||
await loadRankedPage(db, rooms.slice(skip, skip + take)),
|
||
stats
|
||
),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* When each room's live scene was last PUBLISHED, as epoch millis keyed by RoomId: the
|
||
* newest `CurrentSave.CreatedAt` across the room's subrooms.
|
||
*
|
||
* Published, not merely saved. A staged save bumps the subroom's `DataSavedAt` but changes
|
||
* nothing anyone else can load, so ordering by that would float rooms whose visible content
|
||
* never moved — only `current_save_id`, what the loader actually serves, counts here.
|
||
*
|
||
* A room with no published save is ABSENT from the map rather than mapped to 0, so the
|
||
* caller can tell "never published" from "published at the epoch" and choose its own
|
||
* fallback.
|
||
*/
|
||
async function lastPublishedAtByRoom(db: D1Database): Promise<Map<number, number>> {
|
||
// `json_extract` rather than parsing the row: a save blob carries the whole scene and
|
||
// only its timestamp is wanted, so the DataBlob never has to cross the wire.
|
||
const { results } = await db
|
||
.prepare(
|
||
`SELECT s.room_id AS room_id, json_extract(sv.data, '$.CreatedAt') AS created_at
|
||
FROM subroom s JOIN subroom_save sv ON sv.sub_room_data_save_id = s.current_save_id`
|
||
)
|
||
.all<{ room_id: number; created_at: string | null }>()
|
||
|
||
const latest = new Map<number, number>()
|
||
for (const row of results) {
|
||
const ts = typeof row.created_at === 'string' ? Date.parse(row.created_at) : NaN
|
||
if (Number.isNaN(ts)) continue
|
||
const seen = latest.get(row.room_id)
|
||
if (seen === undefined || ts > seen) latest.set(row.room_id, ts)
|
||
}
|
||
return latest
|
||
}
|
||
|
||
/**
|
||
* The "recently updated" discovery row: listable, player-made rooms ordered by when their
|
||
* live scene was last PUBLISHED, newest first.
|
||
*
|
||
* The Coach account's rooms are left out for the reason {@link COMMUNITY_TAG} leaves them
|
||
* out — they are this server's stock rooms, and a row about what people have been building
|
||
* should not be a row about the seed data.
|
||
*
|
||
* A room that has never published a save falls back to its own `CreatedAt`: creating a room
|
||
* IS its first update, and on a fresh server that is the only timestamp any room has, so
|
||
* dropping them would leave the row empty. RoomId — minted in creation order — breaks ties
|
||
* newest-first so paging stays stable.
|
||
*
|
||
* Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. The listable
|
||
* filter runs in SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
|
||
*/
|
||
export async function getRecentlyUpdatedRooms(
|
||
db: D1Database,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const rooms = parseAll(results).filter((r) => isListable(r) && isPlayerMade(r))
|
||
|
||
const published = await lastPublishedAtByRoom(db)
|
||
const updatedAt = (r: Room): number => published.get(roomIdOf(r)) ?? createdAt(r)
|
||
rooms.sort((a, b) => updatedAt(b) - updatedAt(a) || roomIdOf(b) - roomIdOf(a))
|
||
|
||
return {
|
||
Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The "new" discovery row: listable, player-made rooms newest FIRST by creation time.
|
||
*
|
||
* Close to the browse screen's `tag=new` chip (see {@link NEW_TAG}) but not the same test:
|
||
* the chip drops Rec Room Originals, this drops the Coach account's rooms. Both mean
|
||
* "player-made" and agree on the data as it stands — the discovery rows deliberately all
|
||
* use ownership ({@link isPlayerMade}) so one row cannot include a room its sibling row
|
||
* excludes.
|
||
*
|
||
* Paginated via skip/take, `{ Results, TotalResults }` like the hot feed. RoomId breaks
|
||
* ties, newest first, so rooms created in the same instant still page stably.
|
||
*/
|
||
export async function getNewRooms(
|
||
db: D1Database,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const rooms = parseAll(results)
|
||
.filter((r) => isListable(r) && isPlayerMade(r))
|
||
.sort((a, b) => createdAt(b) - createdAt(a) || roomIdOf(b) - roomIdOf(a))
|
||
|
||
return {
|
||
Results: await hydrateRooms(db, await loadRankedPage(db, rooms.slice(skip, skip + take))),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Recommended rooms feed: public, non-dorm rooms not excluded from lists, ranked
|
||
* by engagement (same score as the hot feed). Unlike the hot feed this returns a
|
||
* bare array — the client's recommendation room-source loader expects a plain
|
||
* list, like the other `*by/me`/base sources. The `splitTest*` A/B params the
|
||
* client passes don't change the result. Paginated via skip/take; the listable filter runs
|
||
* in SQL ({@link LISTABLE_WHERE}); the ranking is in memory.
|
||
*/
|
||
export async function getRecommendedRooms(
|
||
db: D1Database,
|
||
skip: number,
|
||
take: number
|
||
): Promise<Room[]> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const stats = await getRoomStats(db)
|
||
const page = parseAll(results)
|
||
.filter(isListable)
|
||
.sort((a, b) => hotScore(b, stats) - hotScore(a, stats) || roomIdOf(a) - roomIdOf(b))
|
||
.slice(skip, skip + take)
|
||
return hydrateRooms(db, await loadRankedPage(db, page), stats)
|
||
}
|
||
|
||
/**
|
||
* Trending ("rising") rooms — the listable rooms someone is standing in RIGHT NOW, busiest
|
||
* first. What the `rising` carousel is filled from.
|
||
*
|
||
* This is the one feed where live presence FILTERS rather than merely ranks: the hot feed
|
||
* sorts by head-count but still lists the empty rooms underneath it, and a carousel of
|
||
* rooms nobody is in is not trending. So a quiet server serves an EMPTY carousel rather
|
||
* than falling back to stored engagement — a room with no one in it has not risen.
|
||
*
|
||
* Ties break the way the hot feed's do (stored engagement, then RoomId), so equally busy
|
||
* rooms page stably.
|
||
*/
|
||
export async function getTrendingRooms(
|
||
db: D1Database,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const players = await countPlayersByRoom(db)
|
||
// Nobody anywhere: nothing can be trending, and the room table needn't be read at all.
|
||
if (players.size === 0) return { Results: [], TotalResults: 0 }
|
||
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const stats = await getRoomStats(db)
|
||
const playerCount = (r: Room): number => players.get(roomIdOf(r)) ?? 0
|
||
const rooms = parseAll(results)
|
||
.filter((r) => isListable(r) && playerCount(r) > 0)
|
||
.sort(
|
||
(a, b) =>
|
||
playerCount(b) - playerCount(a) ||
|
||
hotScore(b, stats) - hotScore(a, stats) ||
|
||
roomIdOf(a) - roomIdOf(b)
|
||
)
|
||
|
||
return {
|
||
Results: await hydrateRooms(
|
||
db,
|
||
await loadRankedPage(db, rooms.slice(skip, skip + take)),
|
||
stats
|
||
),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/** Compact room projection carried by a featured-room group. */
|
||
export interface FeaturedRoom {
|
||
RoomId: number
|
||
RoomName: string
|
||
ImageName: string
|
||
IsRecRoomApproved: boolean
|
||
ExcludeFromLists: boolean
|
||
ExcludeFromSearch: boolean
|
||
}
|
||
|
||
/** A time-boxed group of featured rooms, as returned by `/featuredrooms/current`. */
|
||
export interface FeaturedRoomGroup {
|
||
FeaturedRoomGroupId: number
|
||
name: string
|
||
StartAt: string
|
||
EndAt: string
|
||
Rooms: FeaturedRoom[]
|
||
}
|
||
|
||
/** How many rooms one featured group carries. See {@link getFeaturedRooms}. */
|
||
export const FEATURED_ROOM_LIMIT = 10
|
||
|
||
/**
|
||
* Featured rooms group: public, non-dorm rooms not excluded from lists, in random
|
||
* order. There's no editorial curation behind this yet, so "featured" is just a
|
||
* random shuffle of the eligible rooms wrapped in a single always-active group.
|
||
* Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the rest is in memory.
|
||
*
|
||
* At most {@link FEATURED_ROOM_LIMIT} rooms — a featured group is a short editorial
|
||
* selection, not the whole room list. The cap is applied AFTER the shuffle, so it is a
|
||
* random SAMPLE that varies between requests; a `LIMIT` in the SQL would instead pin the
|
||
* same handful of rooms forever and make the shuffle cosmetic.
|
||
*/
|
||
export async function getFeaturedRooms(db: D1Database): Promise<FeaturedRoomGroup> {
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const candidates = parseAll(results).filter(isListable)
|
||
// Fisher–Yates shuffle so the feed varies between requests.
|
||
for (let i = candidates.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1))
|
||
;[candidates[i], candidates[j]] = [candidates[j], candidates[i]]
|
||
}
|
||
candidates.length = Math.min(candidates.length, FEATURED_ROOM_LIMIT)
|
||
// The stubs carry none of the fields the group shows, so the sample is read in full.
|
||
const rooms = await loadRankedPage(db, candidates)
|
||
|
||
const str = (v: unknown): string => (typeof v === 'string' ? v : '')
|
||
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||
return {
|
||
FeaturedRoomGroupId: 1,
|
||
name: 'Featured Rooms',
|
||
StartAt: '2025-12-01T11:01:00Z',
|
||
EndAt: '9999-12-08T11:00:00Z',
|
||
Rooms: rooms.map((r) => ({
|
||
RoomId: num(r.RoomId),
|
||
RoomName: str(r.Name),
|
||
ImageName: str(r.ImageName),
|
||
IsRecRoomApproved: r.IsRecRoomApproved === true,
|
||
ExcludeFromLists: r.ExcludeFromLists === true,
|
||
ExcludeFromSearch: r.ExcludeFromSearch === true,
|
||
})),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Rooms similar to a target room: public, non-dorm rooms (excluding the target)
|
||
* that share at least one tag with it, ranked by shared-tag count then
|
||
* engagement. Returns a paginated `{ Results, TotalResults }` (the client's
|
||
* RoomSimilarity source expects an object, not a bare array); empty if the target
|
||
* isn't in D1 or is untagged. Eligibility is filtered in SQL ({@link LISTABLE_WHERE}); the
|
||
* tag ranking is in memory.
|
||
*/
|
||
export async function getSimilarRooms(
|
||
db: D1Database,
|
||
roomId: number,
|
||
skip: number,
|
||
take: number
|
||
): Promise<{ Results: Room[]; TotalResults: number }> {
|
||
const empty = { Results: [] as Room[], TotalResults: 0 }
|
||
const target = await getRoomById(db, roomId)
|
||
if (!target) return empty
|
||
const targetTags = new Set(roomTags(target))
|
||
if (targetTags.size === 0) return empty
|
||
|
||
const { results } = await db
|
||
.prepare(`SELECT ${RANK_COLUMNS} FROM room WHERE ${LISTABLE_WHERE}`)
|
||
.all<RoomRow>()
|
||
const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length
|
||
const stats = await getRoomStats(db)
|
||
|
||
// Ranking is by SHARED TAG COUNT, so every candidate needs its tags before the sort —
|
||
// not a filter one tag can narrow, since "shares any tag with the target" is the whole
|
||
// candidate set.
|
||
const scored = (await parseAllWithTags(db, results))
|
||
.filter(
|
||
(r) =>
|
||
roomIdOf(r) !== roomId &&
|
||
r.IsDorm !== true &&
|
||
r.Accessibility === 1 &&
|
||
r.ExcludeFromLists !== true
|
||
)
|
||
.map((room) => ({ room, shared: sharedCount(room) }))
|
||
.filter((x) => x.shared > 0)
|
||
|
||
scored.sort(
|
||
(a, b) =>
|
||
b.shared - a.shared ||
|
||
hotScore(b.room, stats) - hotScore(a.room, stats) ||
|
||
roomIdOf(a.room) - roomIdOf(b.room)
|
||
)
|
||
const rooms = scored.map((x) => x.room)
|
||
return {
|
||
Results: await hydrateRooms(
|
||
db,
|
||
await loadRankedPage(db, rooms.slice(skip, skip + take)),
|
||
stats
|
||
),
|
||
TotalResults: rooms.length,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* "Base" rooms — the template rooms tagged `base` that the client offers as
|
||
* starting points when creating a room. Unlike the public feeds these are
|
||
* returned regardless of accessibility (most base rooms aren't publicly listed).
|
||
* Ordered by RoomId for stable paging. Paginated via skip/take; returns a bare
|
||
* array. Small dataset, so done in memory.
|
||
*/
|
||
export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise<Room[]> {
|
||
// Selected in SQL off the tag index — a handful of rooms out of the whole table, so this
|
||
// is the clearest case for narrowing before the blobs are read.
|
||
const { sql, binds } = roomsByTagsQuery([['base']])
|
||
const { results } = await db
|
||
.prepare(sql)
|
||
.bind(...binds)
|
||
.all<RoomRow>()
|
||
return hydrateRooms(
|
||
db,
|
||
parseAll(results)
|
||
.sort((a, b) => roomIdOf(a) - roomIdOf(b))
|
||
.slice(skip, skip + take)
|
||
)
|
||
}
|
||
|
||
/** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */
|
||
const DORM_TEMPLATE_ROOM_ID = 1
|
||
|
||
/** A player's username from the shared accounts table (for naming their dorm), or null. */
|
||
export async function getUsername(db: D1Database, accountId: number): Promise<string | null> {
|
||
const row = await db
|
||
.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||
.bind(accountId)
|
||
.first<{ data: string }>()
|
||
if (!row) return null
|
||
const account = JSON.parse(row.data) as { username?: string }
|
||
return typeof account.username === 'string' ? account.username : null
|
||
}
|
||
|
||
/** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */
|
||
export async function getDormRoom(db: D1Database, accountId: number): Promise<Room | null> {
|
||
return hydrateRoom(
|
||
db,
|
||
parseOne(
|
||
await db
|
||
.prepare(
|
||
`SELECT ${ROOM_COLUMNS} FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1`
|
||
)
|
||
.bind(accountId)
|
||
.first<RoomRow>()
|
||
)
|
||
)
|
||
}
|
||
|
||
/**
|
||
* The player's personal dorm room, created on first access. Cloned from the
|
||
* seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so
|
||
* matchmaking routes them into their own dorm and they can save it via the
|
||
* owner-gated room-save. Idempotent: returns the existing dorm once created.
|
||
*
|
||
* NOTE: this is the one place the match worker writes to the rooms table (the
|
||
* `rooms` worker otherwise owns the schema).
|
||
*/
|
||
export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise<Room> {
|
||
const existing = await getDormRoom(db, accountId)
|
||
if (existing) return existing
|
||
|
||
const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID)
|
||
const idRow = await db
|
||
.prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM room')
|
||
.first<{ next: number }>()
|
||
const roomId = idRow?.next ?? 2
|
||
|
||
// Reuse the template's subroom (scene/capacity), owned by the player, starting
|
||
// from a clean save. Fall back to the base dorm scene if the template is absent.
|
||
const templateSub =
|
||
template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0
|
||
? (template.SubRooms[0] as Record<string, unknown>)
|
||
: { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 }
|
||
|
||
// Named after the owner: `@<username>'s Dorm` (falls back to the account id).
|
||
const username = (await getUsername(db, accountId)) ?? `Player${accountId}`
|
||
|
||
const room: Room = {
|
||
...(template ?? { Accessibility: Accessibility.Unlisted }),
|
||
RoomId: roomId,
|
||
Name: `@${username}'s Dorm`,
|
||
CreatorAccountId: accountId,
|
||
IsDorm: true,
|
||
Roles: [
|
||
{
|
||
AccountId: accountId,
|
||
Role: Role.Creator,
|
||
LastChangedByAccountId: null,
|
||
InvitedRole: Role.None,
|
||
},
|
||
],
|
||
// Counters start at zero rather than inheriting the template dorm's (see cloneRoom).
|
||
Stats: storedStats(template?.Stats),
|
||
CreatedAt: new Date().toISOString(),
|
||
}
|
||
// serializeRoom drops any SubRooms carried over from the template; the dorm's own
|
||
// subroom is inserted into the subroom table below with a fresh globally-unique id.
|
||
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(room)).run()
|
||
const subRoom = await insertSubRoom(db, roomId, { ...templateSub, CreatorAccountId: accountId })
|
||
room.SubRooms = [subRoom]
|
||
// The template carries these (it was parsed), but a dorm minted without one wouldn't.
|
||
attachRoomDtoDefaults(room)
|
||
return room
|
||
}
|