import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'

import {
	Accessibility,
	applyRoomTagEdit,
	areFriends,
	autocompleteRoomSearch,
	banPlayerFromRoom,
	canManageRoom,
	cloneRoom,
	cloneSubRoom,
	countRoomsByCreator,
	createSubRoom,
	deleteRoom,
	deleteSubRoom,
	findSubRoom,
	getBaseRooms,
	getContributedRooms,
	getFavoritedRooms,
	getFeaturedRooms,
	getHotRooms,
	getInteraction,
	getOrCreateDormRoom,
	getPresence,
	getPublicRoomsByCreator,
	getRecommendedRooms,
	getRoomBans,
	getRoomById,
	getRoomByName,
	getRoomsByCreator,
	getRoomsByIds,
	getSimilarRooms,
	getSubRoomPermissions,
	getSubRoomSaveById,
	getSubRoomSaves,
	getTrendingRooms,
	getVisitedRooms,
	isPlayerBannedFromRoom,
	modifySubRoom,
	publishSubRoomSave,
	removeCheer,
	removeFavorite,
	roomNameRejection,
	saveSubRoomData,
	searchRooms,
	setRoomDescription,
	setRoomImage,
	setRoomName,
	setRoomRole,
	setSubRoomPermissions,
	toggleCheer,
	toggleFavorite,
	unbanPlayerFromRoom,
	updateRoomFields,
} from '@repo/domain'
import {
	intVar,
	logger,
	withCleanSpec,
	withDefaultCors,
	withNotFound,
	withOnError,
} from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles, validateAndGetVersion } from '@repo/jwt'

// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
import {
	AccessibilityRequest,
	AUTHED,
	bannedPlayerIdParam,
	BanRequest,
	BulkRoomsRequest,
	CloneRoomRequest,
	CloningRequest,
	CreateSubRoomRequest,
	CuratedPlaylists,
	DescriptionRequest,
	DormRoomId,
	FeaturedRoomGroupDto,
	FORBIDDEN_RESPONSE,
	form,
	ImageRequest,
	InteractionDto,
	intQuery,
	IsBannedEnvelope,
	IsBannedPascalEnvelope,
	json,
	jsonBody,
	LoadScreenRequest,
	MissingLookupParam,
	ModifySubRoomRequest,
	NameRequest,
	NOT_FRIENDS_RESPONSE,
	PagedRooms,
	pageParams,
	PhotonAccessTokenDto,
	PlayerDataDto,
	playerIdParam,
	PublishSaveRequest,
	PublishStateConfigsEnvelope,
	RestrictionsRequest,
	RoleRequest,
	RoomBanEntryDto,
	RoomBanEnvelope,
	RoomDto,
	RoomEnvelope,
	RoomExperience,
	RoomExperiencePlayer,
	roomIdParam,
	RoomLookup,
	RoomResultEnvelope,
	RoomSaveEnvelope,
	saveIdParam,
	SaveSubRoomDataRequest,
	SearchSuggestions,
	ServiceStatus,
	stringQuery,
	SubRoomAccessibilityRequest,
	SubRoomDataSaveResponseDto,
	subRoomIdParam,
	SubRoomPermissionsRequest,
	SubRoomSavesNoUnityAssetsPage,
	SubRoomSavesPage,
	TagRequest,
	TooManyLookupIds,
	UNAUTHORIZED_EMPTY,
	UNAUTHORIZED_ENVELOPE,
	UNAUTHORIZED_RESPONSE,
	WarningRequest,
} from './openapi'

import type { Context } from 'hono'
import type { RoomBan, RoomPermission } from '@repo/domain'
import type { App } from './context'

/**
 * Room server. Rooms are stored in D1 as JSON blobs with generated columns for
 * querying (see rooms-db.ts); the dorm (RoomId 1) is seeded by the migration.
 * Responses are the stored JSON verbatim (PascalCase, client-facing shape).
 *
 * The `rooms` prefix maps to this worker's subdomain, so method
 * routes are served bare. The 2023 client also hits several of these without the
 * `/roomserver` prefix, so both forms are registered.
 */

/** Parse the first valid integer id from a comma-separated `id` query param. */
function firstId(idParam: string): number | undefined {
	return idParam
		.split(',')
		.map((s) => Number.parseInt(s.trim(), 10))
		.find((n) => !Number.isNaN(n))
}

/** Parse all valid integer ids from a comma-separated `id` query param. */
/**
 * How many rooms one bulk lookup may ask about. It is D1's cap on bound parameters, which
 * `getRoomsByIds` binds one of per id: over it the query fails outright, so the request is
 * refused with a 400 instead. Splitting the read would work, but a client asking about more
 * than a hundred rooms in one call has lost track of what it is rendering — better it hears
 * so than gets served.
 */
const MAX_BULK_ROOM_IDS = 100

/** The 400 an over-cap bulk lookup answers, in the bare-string style the other 400 uses. */
function tooManyIds(c: Context<App>) {
	return c.json(`At most ${MAX_BULK_ROOM_IDS} room ids may be looked up at once`, 400)
}

/**
 * Whether a bulk lookup asked for the public-only filter. The client sends the C# spelling
 * (`True`/`False`) and absent means False, so anything but a case-insensitive `true` is off.
 */
function excludePrivateRooms(value: string | undefined): boolean {
	return (value ?? '').toLowerCase() === 'true'
}

function allIds(idParam: string): number[] {
	return idParam
		.split(',')
		.map((s) => Number.parseInt(s.trim(), 10))
		.filter((n) => !Number.isNaN(n))
}

/**
 * How many rooms one account may create, when the `MAX_ROOMS_PER_ACCOUNT` var is
 * unset. Cloning is the only way to make a room, so the cap is enforced there; it
 * counts rooms the account created, minus their auto-provisioned dorm. Setting the
 * var to 0 lifts the cap entirely, which a small private server will want. Existing
 * rooms are never touched — lowering the cap just stops new ones.
 */
const DEFAULT_MAX_ROOMS_PER_ACCOUNT = 10

/** Account ids granted the global (Role 0) maker pen — the reference server's
 * hardcoded moderator/dev accounts. */
const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])

/**
 * The slice of the shared presence row we read — the caller's current room instance.
 * `subRoomId` is what scopes the stored permission overrides: they belong to the subroom
 * the player is standing in, not to the room.
 */
interface PresenceView {
	roomInstanceId?: number
	roomId?: number
	subRoomId?: number
}

/**
 * Room permissions + Photon token the client needs to spawn into a room. The
 * global (Role 0) maker pen is added only for the hardcoded dev accounts, and
 * `RoomInstanceId` is the caller's current instance from presence (null when
 * they aren't in one). `PhotonAccessToken` stays empty — the reference server
 * signs it via `ClientSecurity`, whose secret/algorithm we don't have; our
 * Photon setup accepts an empty token.
 *
 * `overrides` are the permissions the room's creator saved on the subroom the caller is
 * in (see `PUT …/subrooms/{subRoomId}/permissions`). They are matched against the
 * defaults by (`Permission`, `Role`) — the same pair the client addresses an entry by —
 * and win, so a subroom that revokes the Role 0 maker pen revokes it for a dev account
 * standing in it as well.
 */
function photonAccessToken(
	accountId: number,
	roomInstanceId: number | null,
	overrides: RoomPermission[] = []
) {
	const perm = (Permission: string, Role: number, Override: boolean): RoomPermission => ({
		Override,
		Permission,
		Role,
		Type: 0,
		Value: 'True',
	})
	const permissions: RoomPermission[] = [
		perm('CAN_USE_ROOM_RESET_BUTTON', 0, true),
		perm('CAN_USE_DELETE_ALL_BUTTON', 0, true),
		perm('CAN_SAVE_INVENTIONS', 0, true),
		perm('CAN_SPAWN_INVENTIONS', 0, true),
		perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 0, true),
		perm('CAN_USE_MAKER_PEN', 30, false),
		perm('CAN_USE_ROOM_RESET_BUTTON', 30, true),
		perm('CAN_USE_DELETE_ALL_BUTTON', 30, true),
		perm('CAN_SAVE_INVENTIONS', 30, true),
		perm('CAN_SPAWN_INVENTIONS', 30, true),
		perm('CAN_USE_PLAY_GIZMOS_TOGGLE', 30, true),
	]

	if (MAKER_PEN_ACCOUNT_IDS.has(accountId)) {
		permissions.unshift(perm('CAN_USE_MAKER_PEN', 0, true))
	}

	// The subroom's stored table wins, applied LAST and over the dev grant too: a
	// (Permission, Role) the table already carries is replaced in place — so the order
	// doesn't shift under the client, and no pair is ever listed twice with two values —
	// and one it doesn't (e.g. CAN_INVITE) is appended.
	for (const override of overrides) {
		const i = permissions.findIndex(
			(p) => p.Permission === override.Permission && p.Role === override.Role
		)
		if (i === -1) permissions.push(override)
		else permissions[i] = override
	}
	return {
		Permissions: permissions,
		PhotonAccessToken: '',
		RoomInstanceId: roomInstanceId,
	}
}

/**
 * Photon access-token handler. Auth-gated: resolves the caller, reads their current
 * room instance from the shared `presence` table (see @repo/domain), and returns the
 * permissions + token.
 */
async function handlePhotonAccessToken(c: Context<App>) {
	const accountId = await authedAccountId(c)
	if (accountId === null) return unauthorized(c)
	const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
	// The permission overrides are the ones saved on the subroom the caller is standing in.
	// A player in no instance — sitting in the lobby, or an instance predating subroom
	// tracking — gets the default table untouched.
	const overrides =
		typeof instance?.subRoomId === 'number'
			? await getSubRoomPermissions(c.env.DB, instance.subRoomId)
			: []
	return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
}

/**
 * May this caller read the room's saves? The room's creator always may. So may anyone
 * whose live presence puts them IN the room: they are already loading its scene, and the
 * client resolves which version to load — the published one or the creator's latest — from
 * the save list, so refusing everyone but the creator leaves a visitor unable to load what
 * the instance is actually running.
 *
 * Presence is the shared `presence` table the `match` heartbeat maintains, so this grant
 * lasts only as long as the player is actually there (rows carry an absolute expiry and
 * expired ones don't read back). Co-owners get nothing extra from being co-owners — a
 * co-owner standing in the room passes because of where they are, not what they hold.
 *
 * The presence read only happens for a non-creator, so the owner's own path stays one query.
 */
async function canReadSaves(
	c: Context<App>,
	room: Record<string, unknown>,
	roomId: number,
	accountId: number
): Promise<boolean> {
	if (room.CreatorAccountId === accountId) return true
	const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
	return instance?.roomId === roomId
}

/** The Bearer token's account id (`sub`), or null when there's no valid token. */
async function authedAccountId(c: Context<App>): Promise<number | null> {
	return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
}

/**
 * The client build this request's token was minted for (`rn.ver`), or null when there's no
 * valid token. The claim is the build the CLIENT posted at login, not this server's
 * GAME_VERSION, so it identifies what the player is actually running.
 *
 * It is unverified — a client can claim any build — which is fine for the one thing it is
 * used for here: keeping a payload away from a build it breaks. Lying about your version to
 * opt IN to a broken room list only breaks your own client.
 */
async function authedGameVersion(c: Context<App>): Promise<string | null> {
	return validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
}

/**
 * The client builds `GET /featuredrooms/current` will serve a group to.
 *
 * Serving it to the 2023 client breaks the OTHER room listings — they start failing with
 * NREs, apparently because the featured-room load corrupts its room cache — so the route
 * was parked entirely for a while. It works on the 2025 build, so rather than stay parked
 * it is gated: a build that isn't on this list gets the 404 it got when the path didn't
 * exist, which is the state everything was known good in.
 *
 * An allow-list rather than a "this build or newer" comparison, deliberately: the thing
 * being asserted is that a build was CHECKED, and a version string that sorts high (a
 * debug build, say) must not opt itself in. Add a build here once it's been tried.
 */
const FEATURED_ROOMS_VERSIONS: ReadonlySet<string> = new Set(['20250718.01'])

/**
 * Operator-granted elevated roles — the ones the auth worker stamps from an account's
 * isDeveloper/isModerator flags (see the admin CLI). Same set the `notify` / `www`
 * workers gate their admin surfaces on.
 */
const STAFF_ROLES: ReadonlySet<string> = new Set(['developer', 'moderator'])

/**
 * Whether the caller's token carries a staff role. Used alongside the per-room owner
 * check for actions staff may take in a room they don't own.
 */
async function isStaff(c: Context<App>): Promise<boolean> {
	const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
	return roles?.some((role) => STAFF_ROLES.has(role)) ?? false
}

/**
 * Whether the `:playerId` in the path is banned from the `:roomId` in it — the read behind
 * both `isBanned` routes, which differ only in the envelope they wrap the answer in.
 *
 * Reads the same `room_ban` rows the ban writes make and `match` refuses matchmakes on, so
 * the answer is what would actually happen. A room that does not exist simply has no ban
 * rows and comes back false: the question is about the ban, not about the room.
 */
async function pathBan(c: Context<App>): Promise<boolean> {
	// Both routes constrain these to `[0-9]+`, so neither is ever missing — the fallback is
	// only here because a helper is typed against the whole app rather than one route.
	const id = (name: string) => Number.parseInt(c.req.param(name) ?? '', 10)
	return isPlayerBannedFromRoom(c.env.DB, id('roomId'), id('playerId'))
}

/** 401 for the auth-gated `*by/me` endpoints — no stub-account fallback. */
function unauthorized(c: Context<App>) {
	return c.json({ error: 'Unauthorized' }, 401)
}

/**
 * Parse an `accessibility` form field into a `RoomAccessibility` value. The client
 * sends the enum NAME on the subroom route (`accessibility=Private`), not the number
 * the room-level route takes, so both forms are accepted. Returns undefined when the
 * field is missing or names nothing in the enum.
 */
function parseAccessibility(value: unknown): number | undefined {
	if (typeof value !== 'string') return undefined
	const raw = value.trim()
	if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10)
	const named = Object.entries(Accessibility).find(
		([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
	)
	return named ? (named[1] as number) : undefined
}

/** Parse an integer from the number or numeric string a JSON body may carry. */
function parseInt10(value: unknown): number | undefined {
	if (typeof value === 'number') return Number.isFinite(value) ? Math.trunc(value) : undefined
	if (typeof value !== 'string') return undefined
	const n = Number.parseInt(value.trim(), 10)
	return Number.isNaN(n) ? undefined : n
}

/**
 * The client's `Value`, kept as the STRING it sends. Usually `"True"`/`"False"` — the
 * True/False picker beside the override checkbox — but a permission whose UI is something
 * else carries a different value, so nothing here interprets it. A JSON boolean or number
 * is rendered the way the client would have written it.
 */
function permissionValue(value: unknown): string {
	if (typeof value === 'string') return value
	if (typeof value === 'boolean') return value ? 'True' : 'False'
	if (typeof value === 'number') return String(value)
	return ''
}

/**
 * Parse the subroom-permissions PUT body: a JSON ARRAY of
 * `{ Permission, Role, Override, Type, Value }` entries.
 *
 * `Override` is the client's checkbox, not data — see {@link setSubRoomPermissions}: true
 * stores `Value` for that (`Permission`, `Role`), false clears any stored entry so the
 * pair falls back to the default. It is carried through as sent.
 *
 * Entries without a permission name or a usable role are dropped rather than rejected —
 * the client ignores the response either way, so half a table applied beats none.
 */
function parseRoomPermissions(body: unknown): RoomPermission[] {
	if (!Array.isArray(body)) return []
	const permissions: RoomPermission[] = []
	for (const entry of body) {
		if (typeof entry !== 'object' || entry === null) continue
		const e = entry as Record<string, unknown>
		const permission = typeof e.Permission === 'string' ? e.Permission.trim() : ''
		const role = parseInt10(e.Role)
		if (permission === '' || role === undefined) continue
		permissions.push({
			Permission: permission,
			Role: role,
			// Sent as a JSON boolean, unlike `Value` — accept the string form regardless.
			Override: e.Override === true || String(e.Override).toLowerCase() === 'true',
			Type: parseInt10(e.Type) ?? 0,
			Value: permissionValue(e.Value),
		})
	}
	return permissions
}

/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'

/**
 * The room `Supports*` flags the `/restrictions` endpoint can toggle, keyed by the
 * lowercased form field the client posts. Only fields present in the body are changed.
 */
const RESTRICTION_FIELDS: Record<string, string> = {
	supportsscreens: 'SupportsScreens',
	supportswalkvr: 'SupportsWalkVR',
	supportsteleportvr: 'SupportsTeleportVR',
	supportsvrlow: 'SupportsVRLow',
	supportsquest2: 'SupportsQuest2',
	supportsmobile: 'SupportsMobile',
	supportsjuniors: 'SupportsJuniors',
}

/**
 * Push a RoomUpdate notification to a player after their room changes, mirroring
 * the reference server's `HubSendToPlayer(playerId, NotifFrame("RoomUpdate", room))`.
 * Hub failures are logged and swallowed — the room write has already committed,
 * so a hub hiccup must not fail the request.
 */
async function pushRoomUpdate(
	c: Context<App>,
	playerId: number,
	room: Record<string, unknown>
): Promise<void> {
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			playerId,
			NotificationType.SubscriptionUpdateRoom,
			room
		)
	} catch (err) {
		logger.error('failed to push RoomUpdate notification', {
			playerId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * `reportCategory` on a moderation frame. -1 is "Moderator" — the category for an
 * action a person took rather than one the system inferred, which is what a room ban
 * is. The rest of the enum, for reference: 2 Harassment, 3 Cheating, 5 AFK, 6 Misc,
 * 7 Underage, 10 VoteKick, 100–104 CoC_*, 200 InappropriateClothing.
 */
const REPORT_CATEGORY_MODERATOR = -1

/**
 * Eject a player from the room they're in — a `ModerationKick` push (id 22), the frame
 * the client acts on to remove someone. Sent on a ban: the row keeps them out of future
 * matchmakes, this gets them out of the instance they're in right now.
 *
 * The payload is the client's moderation shape, camelCase, in wire order:
 * `reportCategory`, `duration`, `gameSessionId`, `isHostKick`, `message`,
 * `playerIdReporter`, `isBan`, `isVoiceModAutoban`. `duration` is 0 (a room ban has no
 * expiry — it's lifted by DELETE, not by time) and `gameSessionId` is 0 (nothing here
 * tracks one).
 *
 * `isHostKick` says the room's HOST ejected the player, as opposed to the room
 * majority vote-kicking them. There is no vote-kick path yet, so the only false case
 * here is a staff moderator acting in a room they don't host. `playerIdReporter` is
 * whoever caused it — the host today, and the player who started the vote once
 * vote-kicks exist (those will carry `reportCategory` 10 and `isHostKick` false).
 *
 * Like {@link pushRoomUpdate}, hub failures are logged and swallowed: the ban row has
 * already committed, so a hub hiccup must not fail the request.
 */
async function pushRoomBan(
	c: Context<App>,
	ban: RoomBan,
	roomName: string,
	isHostKick: boolean
): Promise<void> {
	try {
		await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
			ban.BannedPlayerId,
			NotificationType.ModerationKick,
			{
				reportCategory: REPORT_CATEGORY_MODERATOR,
				duration: 0,
				gameSessionId: 0,
				isHostKick,
				message: `You have been banned from ${roomName}.`,
				playerIdReporter: ban.BannedByAccountId,
				isBan: true,
				isVoiceModAutoban: false,
			}
		)
	} catch (err) {
		logger.error('failed to push ModerationKick notification', {
			playerId: ban.BannedPlayerId,
			error: err instanceof Error ? err.message : String(err),
		})
	}
}

/**
 * Room-mutation result envelope: `{ Success, Value, ErrorId, Error }`, always
 * HTTP 200 (the client reads `Success`). `ErrorId`/`Error` are null on success.
 */
function roomResult(
	c: Context<App>,
	fields: { Success: boolean; Value?: unknown; ErrorId?: string; Error?: string }
) {
	return c.json({
		Success: fields.Success,
		Value: fields.Value ?? null,
		ErrorId: fields.ErrorId ?? null,
		Error: fields.Error ?? null,
	})
}

/**
 * The room save's `value.subRoomDataSave` — a camelCase projection with a DIFFERENT
 * field set from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC
 * versions, no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`
 * that `CurrentSave` never shows). Don't unify the two without checking the client.
 *
 * `unityAsset`/`unityAssetHash` are always null: we resolve no baked Unity assets.
 */
function toSaveResponse(save: Record<string, unknown>) {
	const str = (v: unknown) => (typeof v === 'string' ? v : null)
	const num = (v: unknown) => (typeof v === 'number' ? v : null)
	return {
		subRoomDataSaveId: num(save.SubRoomDataSaveId),
		subRoomId: num(save.SubRoomId),
		unityAssetId: str(save.UnityAssetId),
		unityAsset: null,
		unityAssetHash: null,
		dataBlob: str(save.DataBlob) ?? '',
		dataBlobHash: str(save.DataBlobHash),
		savedByAccountId: num(save.SavedByAccountId),
		savedOnPlatform: num(save.SavedOnPlatform) ?? 0,
		savedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
		description: str(save.Description),
		createdAt: str(save.CreatedAt) ?? '',
	}
}

/**
 * A save row with its Unity-asset payloads left out — what `…/saves/no_unity_assets`
 * lists. PascalCase like the rows `…/saves` serves, minus the two hydrated asset arrays
 * (`UnitySubAssets`, `ReferencedUnityAssets`) and `Tags`, keeping only the asset
 * IDENTIFIERS. The point of the variant is weight: those arrays are the heavy part of a
 * save row, and a history list doesn't render them.
 *
 * `UnityAssetId` is the one field that differs rather than disappearing — always present
 * and null when the save carried none, where the full row omits the key entirely.
 *
 * Built key by key rather than by deleting from the stored save: a save is stored as an
 * opaque blob, so a future field would otherwise leak into this projection unannounced.
 */
function toSaveWithoutUnityAssets(save: Record<string, unknown>) {
	const str = (v: unknown) => (typeof v === 'string' ? v : null)
	const num = (v: unknown) => (typeof v === 'number' ? v : null)
	return {
		SubRoomDataSaveId: num(save.SubRoomDataSaveId),
		SubRoomId: num(save.SubRoomId),
		UnityAssetId: str(save.UnityAssetId),
		ReferencedUnityAssetIds: Array.isArray(save.ReferencedUnityAssetIds)
			? save.ReferencedUnityAssetIds
			: [],
		DataBlob: str(save.DataBlob) ?? '',
		DataBlobHash: str(save.DataBlobHash),
		PersistenceVersion: num(save.PersistenceVersion) ?? 0,
		OMVersion: num(save.OMVersion) ?? 0,
		SavedByAccountId: num(save.SavedByAccountId),
		SavedOnPlatform: num(save.SavedOnPlatform) ?? 0,
		SavedOnDeviceClass: num(save.SavedOnDeviceClass) ?? 0,
		Description: str(save.Description) ?? '',
		ModerationState: num(save.ModerationState) ?? 0,
		CreatedAt: str(save.CreatedAt) ?? '',
		UgcSubVersion: num(save.UgcSubVersion) ?? 0,
	}
}

/** Client envelope for room mutations: `{ success, error, value }` (lowercase). */
function roomEnvelope(c: Context<App>, value: unknown, error = '') {
	return c.json({ success: error === '', error, value })
}

/**
 * The same envelope for the ban write, whose `value` is the BAN rather than the room —
 * a ban isn't part of the room the client renders, so there is no updated room to send.
 */
const banEnvelope = roomEnvelope

/** Rooms created/owned by the authed caller (shared by the createdby routes). */
async function ownedRooms(c: Context<App>) {
	const accountId = await authedAccountId(c)
	if (accountId === null) return unauthorized(c)
	return c.json(await getRoomsByCreator(c.env.DB, accountId))
}

/**
 * The caller's owned rooms, excluding their dorm. The dorm is auto-provisioned,
 * not a room the player made, so it doesn't belong in the "rooms you own" list.
 */
async function ownedRoomsExcludingDorm(c: Context<App>) {
	const accountId = await authedAccountId(c)
	if (accountId === null) return unauthorized(c)
	const rooms = await getRoomsByCreator(c.env.DB, accountId)
	return c.json(rooms.filter((r) => r.IsDorm !== true))
}

/** Suggestions `/rooms/autocomplete_search` returns when the client names no `take`. */
const DEFAULT_SUGGESTION_COUNT = 10

/**
 * The XP settings every room reports (`GET /rooms/{roomId}/experience`). Constants because
 * nothing stores them per room and nothing enforces them: the `api` worker's progression
 * grants XP without a room-scoped daily cap, so these are what the client is told, not a
 * limit this server applies.
 *
 * Disabled, which is the honest answer here — no room awards XP. `DailyLimit` is kept at
 * the reference's number rather than zeroed: it is the cap that WOULD apply, and the client
 * reads both keys whatever `Enabled` says.
 */
const ROOM_XP_ENABLED = false
const ROOM_XP_DAILY_LIMIT = 1000

const app = new Hono<App>()
	.use(
		'*',
		// middleware
		(c, next) =>
			useWorkersLogger(c.env.NAME, {
				environment: c.env.ENVIRONMENT,
				release: c.env.SENTRY_RELEASE,
			})(c, next)
	)

	// The website (`www`) is a browser origin calling these endpoints directly, the way
	// rec.net's own site called the game's API — its "My rooms" list is this worker's
	// `GET /rooms/ownedby/me` — so the responses need CORS headers or the browser
	// discards them. `origin: '*'` is deliberate and safe HERE because these endpoints
	// authenticate with a bearer token in the `Authorization` header, never a cookie: a
	// hostile page can't read another origin's stored token, so there is no ambient
	// credential for `*` to expose. Do not add cookie auth without narrowing it.
	.use('*', withDefaultCors())

	.onError(withOnError())
	.notFound(withNotFound())

	.get(
		'/',
		describeRoute({
			tags: ['Service'],
			summary: 'Service liveness',
			description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
			responses: { 200: json(ServiceStatus, 'Always `{ service: "rooms", status: "ok" }`') },
		}),
		(c) => c.json({ service: 'rooms', status: 'ok' })
	)

	// Room lookup by `id` (first match wins) or `name`. 400s when neither is
	// supplied and returns `{}` when nothing matches.
	.get(
		'/rooms',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Look up a room by id or name',
			description: [
				'A single room by `id` or `name`. `id` may be a comma-separated list — the first',
				'valid integer wins. An unknown room is `{}`, not a 404: the client reads an empty',
				'object as “no such room”.',
			].join(' '),
			parameters: [
				stringQuery('id', 'Room id (comma-separated; the first valid one is used)'),
				stringQuery('name', 'Room name (matched case-insensitively). Ignored when `id` is given'),
			],
			responses: {
				200: json(RoomLookup, 'The room, or `{}` when there’s no match'),
				400: json(MissingLookupParam, 'Neither `id` nor `name` was supplied'),
			},
		}),
		async (c) => {
			const idParam = c.req.query('id')
			const nameParam = c.req.query('name')
			if (!idParam && !nameParam) {
				return c.json("Either 'id' or 'name' query parameter is required", 400)
			}
			if (idParam) {
				const id = firstId(idParam)
				const room = id === undefined ? null : await getRoomById(c.env.DB, id)
				return c.json(room ?? {})
			}
			const room = await getRoomByName(c.env.DB, nameParam ?? '')
			return c.json(room ?? {})
		}
	)

	// Room search: `query` is space/`+`-separated terms — `#tag` matches room tags,
	// plain terms match the name. Public, non-dorm rooms only. Paginated via
	// skip/take. Returns `{ Results, TotalResults }`.
	//
	// `#community` is the one tag term that isn't a tag lookup: it is the browse chip's
	// pseudo-tag reaching search, and means rooms a PLAYER made rather than the seeded
	// first-party ones — the same filter `/rooms/hot?tag=community` applies.
	.get(
		'/rooms/search',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Search rooms',
			description: [
				'Full room search. `query` is space- or `+`-separated terms: a `#tag` term matches the',
				'room’s tags, a plain term matches its name. Public, non-dorm rooms only.',
				'`#community` is a pseudo-tag no room carries — it narrows to rooms a player made',
				'(anything the system Coach account didn’t create), like `/rooms/hot?tag=community`.',
			].join(' '),
			parameters: [
				stringQuery(
					'query',
					'Search terms — `#tag` matches tags, plain terms match the name, `#community` matches player-made rooms'
				),
				...pageParams(30),
			],
			responses: { 200: json(PagedRooms, 'The matching rooms') },
		}),
		async (c) => {
			const query = c.req.query('query') ?? ''
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '30', 10) || 30
			return c.json(await searchRooms(c.env.DB, query, skip, take))
		}
	)

	// Type-ahead for the search box (`?query=r&take=4&searchSessionId=…`). A bare array of
	// plain STRINGS — suggestions, not rooms and not an envelope.
	//
	// Every suggestion is something `/rooms/search` will actually find, so submitting one
	// can't come back empty: they're drawn from room names (what a plain search term
	// matches) and room tags (what a `#tag` term matches), over the same public, non-dorm
	// rooms search considers. Tags come back with their `#` for that reason.
	//
	// `searchSessionId` is the client's own correlation id for a typing session — it ties
	// the keystrokes and the eventual search together in the reference's analytics. Nothing
	// here records searches, so it is accepted and ignored.
	.get(
		'/rooms/autocomplete_search',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Search suggestions for the search box',
			description: [
				'Type-ahead suggestions as a bare array of strings — not rooms, not an envelope.',
				'Drawn from room names and room tags over the public, non-dorm rooms `/rooms/search`',
				'searches, so every suggestion is one that finds something when submitted; a tag',
				'suggestion carries its `#` so it searches by tag. A `query` starting with `#`',
				'suggests tags only. Matches that START with the query come first, then ones that',
				'merely contain it, alphabetically within each — the same query always suggests the',
				'same things. `take` caps the list (4 is what the client asks for);',
				'`searchSessionId` is the client’s analytics correlation id and is ignored.',
			].join(' '),
			parameters: [
				stringQuery('query', 'What the player has typed so far. `#` prefix suggests tags only'),
				intQuery('take', 'How many suggestions to return (default 10)'),
				stringQuery('searchSessionId', 'The client’s typing-session id. Accepted and ignored'),
			],
			responses: { 200: json(SearchSuggestions, 'The suggestions, best match first') },
		}),
		async (c) => {
			const take = Number.parseInt(c.req.query('take') ?? '', 10)
			return c.json(
				await autocompleteRoomSearch(
					c.env.DB,
					c.req.query('query') ?? '',
					Number.isNaN(take) ? DEFAULT_SUGGESTION_COUNT : take
				)
			)
		}
	)

	// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
	// instances' presence), then stored engagement, optionally filtered to a single
	// `tag` (e.g. `rro`). `tag=new` and `tag=community` are pseudo-tags no room
	// carries: `new` serves the player-made (non-RRO) rooms newest-first, `community`
	// keeps the normal ordering but drops the rooms the Coach account created.
	// Paginated via skip/take (take defaults to 100). Returns
	// `{ Results, TotalResults }` like search.
	.get(
		'/rooms/hot',
		describeRoute({
			tags: ['Discovery'],
			summary: 'The “hot” rooms feed',
			description: [
				'Public, non-dorm rooms ordered by how many players are in them right now — live',
				'presence summed across each room’s instances — falling back to stored engagement',
				'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
				'filter chips post one, e.g. `rro`). The `new` and `community` chips are pseudo-tags —',
				'no room carries either. `new` instead serves the player-made (non-RRO) rooms, newest',
				'first; `community` keeps the ordering above but serves only rooms the Coach account',
				'(the system account owning the seeded first-party rooms) did not create.',
			].join(' '),
			parameters: [
				stringQuery(
					'tag',
					'Restrict to rooms carrying this tag (or `new`/`community`, pseudo-tags)'
				),
				...pageParams(100),
			],
			responses: { 200: json(PagedRooms, 'The feed page') },
		}),
		async (c) => {
			const tag = c.req.query('tag') ?? ''
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getHotRooms(c.env.DB, tag, skip, take))
		}
	)

	// Curated room playlists — the editorially grouped room lists the discovery pages'
	// `PlaylistById` sections draw from. Nothing curates one yet, so this is an empty array:
	// the client reads that as "no playlists" and simply draws no playlist rows, where a 404
	// leaves it retrying a feed that isn't coming.
	.get(
		'/rooms/curated_playlists',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Curated room playlists',
			description: [
				'The curated room playlists the discovery pages’ playlist sections draw from. There',
				'is no editorial curation on this server yet, so this is always an empty array —',
				'which the client reads as “no playlists” and draws nothing, rather than the 404 it',
				'would keep retrying.',
			].join(' '),
			responses: { 200: json(CuratedPlaylists, 'Always an empty list') },
		}),
		(c) => c.json([])
	)

	// The `rising` carousel — the discovery pages fill a `CarouselEndpoint` section by
	// slug, and this is the one the client asks for by name. Trending means someone is IN
	// the room right now: unlike the hot feed, which ranks by head-count but still lists
	// the empty rooms underneath, this one FILTERS on live presence, so a quiet server
	// serves an empty carousel rather than a stale one.
	//
	// Paged like the hot feed (`skip`/`take`, take defaults to 100) and answers the same
	// `{ Results, TotalResults }` envelope its sibling feeds do. Only `rising` is served —
	// the other slugs in the discovery catalogue (`foryou`, `staffpicks`, the
	// `*_algoendpoint` rows) keep 404ing until each is given a feed of its own.
	.get(
		'/rooms/carousel/rising',
		describeRoute({
			tags: ['Discovery'],
			summary: 'The “rising” rooms carousel',
			description: [
				'The rooms players are in RIGHT NOW, busiest first — the trending carousel. Live',
				'presence is a filter here, not just a sort: a room nobody is standing in is absent',
				'entirely, so this is empty when the server is quiet rather than falling back to',
				'stored engagement the way `/rooms/hot` does. Ties break on engagement and then',
				'RoomId, so equally busy rooms page stably. Public, non-dorm, listable rooms only.',
			].join(' '),
			parameters: pageParams(100),
			responses: { 200: json(PagedRooms, 'The carousel page') },
		}),
		async (c) => {
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getTrendingRooms(c.env.DB, skip, take))
		}
	)

	// "Base" rooms — template rooms (tagged `base`) the client offers when creating
	// a room. Returned regardless of accessibility. Paginated via skip/take (take
	// defaults to 100). Returns a bare array.
	.get(
		'/rooms/base',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Base (template) rooms',
			description: [
				'The template rooms — those tagged `base` — the client offers when a player creates a',
				'room. Served regardless of accessibility, and as a bare array rather than a page.',
			].join(' '),
			parameters: pageParams(100),
			responses: { 200: json(RoomDto.array(), 'The template rooms') },
		}),
		async (c) => {
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getBaseRooms(c.env.DB, skip, take))
		}
	)

	// Recommended rooms feed — public, non-dorm rooms ranked by engagement, returned
	// as a bare array (the client's recommendation room-source expects a plain list).
	// The `splitTestId`/`splitTestValue` A/B params are accepted and ignored.
	// Paginated via skip/take (take defaults to 100).
	.get(
		'/rooms/recommendations',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Recommended rooms',
			description: [
				'Public, non-dorm rooms ranked by engagement. Unlike search and hot this is a BARE',
				'array — the client’s recommendation room-source expects a plain list. The',
				'`splitTestId`/`splitTestValue` A/B params are accepted and ignored.',
			].join(' '),
			parameters: [
				stringQuery('splitTestId', 'A/B test id — accepted and ignored'),
				stringQuery('splitTestValue', 'A/B test bucket — accepted and ignored'),
				...pageParams(100),
			],
			responses: { 200: json(RoomDto.array(), 'The recommended rooms') },
		}),
		async (c) => {
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getRecommendedRooms(c.env.DB, skip, take))
		}
	)

	// Featured rooms — a single always-active group whose `Rooms` are a randomly
	// ordered set of public, non-dorm rooms, at most ten of them. No real curation yet, so
	// `current` just returns a shuffled sample of eligible rooms in the featured-group shape.
	//
	// Gated on the caller's BUILD, not just their token: this payload breaks the 2023
	// client — its other room listings start failing with NREs, apparently because the
	// featured-room load corrupts the client's room cache — while the 2025 build renders it
	// fine. So a build on FEATURED_ROOMS_VERSIONS gets the group and every other build gets
	// a 404, which is exactly what it got while this path was parked and everything worked.
	//
	// Auth-gated for the version, really: the build comes off the token's `rn.ver` claim, so
	// there is nowhere to read it from without one. Nothing in the answer is per-player.
	.get(
		'/featuredrooms/current',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Featured rooms',
			description: [
				'A single always-active group of featured rooms: a random shuffle of eligible public',
				'rooms, since there is no editorial curation yet. Capped at 10 rooms — a featured',
				'group is a short selection, not the whole room list — and the cap is applied after',
				'the shuffle, so each request serves a different sample.',
				'',
				'Restricted by CLIENT BUILD. Serving this to the 2023 client breaks its other room',
				'listings (NREs, apparently from the featured-room load corrupting its room cache), so',
				'only the builds known to render it — `20250718.01` today — get the group; anything',
				'else gets a 404, the same answer it got while the route was parked. The build is read',
				'from the token’s `rn.ver` claim, which is why this needs a token at all: nothing in',
				'the group itself is per-player.',
			].join('\n'),
			security: AUTHED,
			responses: {
				200: json(FeaturedRoomGroupDto, 'The featured-room group'),
				401: UNAUTHORIZED_RESPONSE,
				404: { description: 'The caller’s client build is not one this is served to' },
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const version = await authedGameVersion(c)
			if (version === null || !FEATURED_ROOMS_VERSIONS.has(version)) {
				logger.info('featured rooms withheld: unsupported client build', { accountId, version })
				return c.notFound()
			}

			return c.json(await getFeaturedRooms(c.env.DB))
		}
	)

	// Bulk room lookup by `id` or `name` — returns an array of matched rooms (the
	// client calls this bare on the rooms host). Rooms not in D1 are simply absent
	// from the result; the client treats an empty result as NoSuchRoom.
	.get(
		'/rooms/bulk',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Look up several rooms at once',
			description: [
				'Rooms by `id` — repeated `id` params, a comma-separated list, or both — or a single',
				'`name`. Ids that aren’t in D1 are simply absent from the result rather than an error —',
				'the client reads an empty result as NoSuchRoom. `excludePrivateRooms=True` drops rooms',
				'that are not publicly visible, as on the POST.',
			].join(' '),
			parameters: [
				stringQuery('id', 'Room ids — repeated `id` fields, comma-separated, or both'),
				stringQuery('name', 'A single room name. Ignored when `id` is given'),
				stringQuery('excludePrivateRooms', 'True drops rooms that are not publicly visible'),
			],
			responses: {
				200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
				400: json(
					MissingLookupParam,
					'Neither `id` nor `name` was supplied, or more than 100 ids were'
				),
			},
		}),
		async (c) => {
			// `id` repeats (`?id=641&id=657`) as often as it is comma-separated, and the client
			// spells it both ways — read every occurrence, then split each on commas.
			const idParams = c.req.queries('id') ?? []
			const nameParam = c.req.query('name')
			if (idParams.length === 0 && !nameParam) {
				return c.json("Either 'id' or 'name' query parameter is required", 400)
			}
			if (idParams.length > 0) {
				const ids = idParams.flatMap(allIds)
				if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
				const rooms = await getRoomsByIds(c.env.DB, ids)
				return c.json(
					excludePrivateRooms(c.req.query('excludePrivateRooms'))
						? rooms.filter((r) => r.Accessibility === 1)
						: rooms
				)
			}
			const room = await getRoomByName(c.env.DB, nameParam ?? '')
			return c.json(room ? [room] : [])
		}
	)

	// The same bulk lookup as a POST, which is the form the client sends: the ids ride in a
	// form-urlencoded body of repeated `id` fields (`id=888&id=532&…`) rather than a query
	// string, because it asks for a whole room list at once — 70-odd ids in the wild.
	//
	// `excludePrivateRooms=True` drops rooms that are not publicly visible; the client sends
	// `False`, and absent means False, which is also what the GET does. Note this is a filter
	// the CALLER asks for, not an access check: a room id is not a secret (the client only
	// has ids it was already given), and the GET has always answered by id regardless of
	// accessibility — a player's own unpublished room has to resolve here or it vanishes from
	// their lists.
	.post(
		'/rooms/bulk',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Look up several rooms at once (bulk POST)',
			description: [
				'Rooms by id, as a form body of repeated `id` fields — the form the client sends, since',
				'it asks about a whole room list at once. Ids that aren’t in D1 are simply absent from',
				'the result rather than an error, so the array can be shorter than the request. At most',
				'100 ids per call (D1 binds one parameter each); more is a 400.',
				'',
				'`excludePrivateRooms=True` drops rooms that are not publicly visible. It is a filter',
				'the caller asks for, not an access check — like the GET, this answers by id whatever',
				'the room’s accessibility, which is what makes a player’s own unpublished room resolve.',
			].join('\n'),
			requestBody: form(BulkRoomsRequest, 'The room ids, plus the optional filter'),
			responses: {
				200: json(RoomDto.array(), 'The rooms that matched (missing ids are omitted)'),
				400: json(TooManyLookupIds, 'More than 100 ids were asked for'),
			},
		}),
		async (c) => {
			const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
			const field = (name: string): string[] => {
				const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
				const value = key === undefined ? [] : body[key]
				return (Array.isArray(value) ? value : [value]).filter(
					(v): v is string => typeof v === 'string'
				)
			}

			// Repeated fields, and each value may itself be comma-separated — the GET's spelling,
			// accepted here too so one body shape doesn't have to be guessed at.
			const ids = field('id').flatMap(allIds)
			if (ids.length > MAX_BULK_ROOM_IDS) return tooManyIds(c)
			const rooms = await getRoomsByIds(c.env.DB, ids)

			const excludePrivate = excludePrivateRooms(field('excludePrivateRooms')[0])
			return c.json(excludePrivate ? rooms.filter((r) => r.Accessibility === 1) : rooms)
		}
	)

	// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
	// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
	// the `createdby` variants return everything the account created. None of them
	// filter on Accessibility: these are the owner's own "My Rooms" lists, so a room
	// they haven't published yet (a fresh clone is Private) has to show up here.
	// Only the public `ownedby/:accountId` profile list is accessibility-filtered.
	.get(
		'/roomserver/rooms/createdby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller created (legacy path)',
			description: [
				'Every room the caller created, dorm included. Identical to',
				'`GET /rooms/createdby/me` — the 2023 client calls this one under the `/roomserver`',
				'prefix, so both forms are registered.',
			].join(' '),
			security: AUTHED,
			responses: { 200: json(RoomDto.array(), 'The caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
		}),
		ownedRooms
	)
	.get(
		'/rooms/ownedby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller owns (excluding their dorm)',
			description: [
				'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
				'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
				'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
				'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
				'the public `ownedby/{accountId}` profile list.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(RoomDto.array(), 'The caller’s rooms, dorm excluded'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		ownedRoomsExcludingDorm
	)
	.get(
		'/rooms/createdby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller created',
			description: 'Every room the caller created, dorm included.',
			security: AUTHED,
			responses: { 200: json(RoomDto.array(), 'The caller’s rooms'), 401: UNAUTHORIZED_RESPONSE },
		}),
		ownedRooms
	)

	// Rooms the caller works on — the ones they CREATED plus anyone else's that name them in
	// `Roles` (Host, Moderator or CoOwner). Auth-scoped: `me` resolves from the bearer token,
	// and there is no query string or body to read.
	//
	// Created rooms used to be excluded, on the grounds that a room's `Roles` names its
	// creator too and the client shows "owned" and "contributed" separately. That left the
	// list empty for every account that had only built its own rooms — most of them — so it
	// now overlaps `createdby/me` rather than coming back empty. The dorm stays out, as it
	// does on `ownedby/me`. Like the other `*by/me` lists it answers a bare array of the
	// canonical room DTO — no envelope, no paging wrapper — and doesn't filter on
	// accessibility, since a contributor is working on the room whether or not it's
	// published.
	.get(
		'/rooms/contributedby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller owns or contributes to',
			description: [
				'Every room the caller works on, as a bare array: the ones they CREATED plus the ones',
				'that name them in their `Roles` — Host, Moderator or CoOwner. Overlaps',
				'`createdby/me` deliberately, so a client rendering one list sees everything; the',
				'dorm is excluded as it is on `ownedby/me`. Every role tier counts, not only the',
				'owner-level ones, and accessibility is not filtered: a contributor works on the room',
				'whether or not it is published.',
			].join(' '),
			security: AUTHED,
			responses: {
				200: json(RoomDto.array(), 'The rooms the caller owns or contributes to (empty when none)'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			return c.json(await getContributedRooms(c.env.DB, accountId))
		}
	)

	// The caller's own dorm, as its ID ALONE — a bare JSON number, not the room. The
	// caller follows up with `GET /rooms/{roomId}` when it wants the room itself.
	//
	// Gets-or-creates, exactly as entering a dorm does (`match`): the provisioning is the
	// point of the call as much as the answer is, so a player who has never been to their
	// dorm gets one minted here rather than a 404, and the id is stable from then on.
	.get(
		'/dormroom/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'The caller’s dorm id',
			description: [
				'The `RoomId` of the caller’s personal dorm, as a bare JSON number — NOT the room:',
				'fetch that from `GET /rooms/{roomId}` with the id this returns.',
				'',
				'The dorm is provisioned on first access (cloned from the seeded template dorm), so',
				'this answers for any authed caller and never 404s, and calling it again returns the',
				'same id.',
			].join(' '),
			security: AUTHED,
			responses: { 200: json(DormRoomId, 'The caller’s dorm id'), 401: UNAUTHORIZED_RESPONSE },
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const dorm = await getOrCreateDormRoom(c.env.DB, accountId)
			return c.json(Number(dorm.RoomId))
		}
	)

	// Public: the rooms a given account owns that are publicly viewable. No auth —
	// returns a bare array (empty when the account owns no public rooms).
	.get(
		'/rooms/ownedby/:accountId{[0-9]+}',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Another player’s public rooms',
			description: [
				'The rooms an account owns that are publicly viewable — what the client shows on a',
				'player’s profile. No auth; empty when the account owns no public rooms.',
			].join(' '),
			parameters: [
				{
					name: 'accountId',
					in: 'path',
					required: true,
					description: 'The account whose rooms to list',
					schema: { type: 'string', pattern: '^[0-9]+$' },
				},
			],
			responses: { 200: json(RoomDto.array(), 'That account’s public rooms') },
		}),
		async (c) =>
			c.json(await getPublicRoomsByCreator(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
	)

	// Rooms the caller has favorited (from the interaction table). Auth-gated.
	// Paginated via skip/take (take defaults to 100). Returns a bare array, like the
	// other room-source `*by/me` lists the client loads.
	.get(
		'/rooms/favoritedby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller favorited',
			description:
				'The rooms the caller has favorited (from the interaction table), as a bare array.',
			security: AUTHED,
			parameters: pageParams(100),
			responses: {
				200: json(RoomDto.array(), 'The favorited rooms'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getFavoritedRooms(c.env.DB, accountId, skip, take))
		}
	)

	// Rooms the caller has visited (interaction rows with a last-visited time).
	// Auth-gated. Paginated via skip/take (take defaults to 100). Returns a bare array.
	.get(
		'/rooms/visitedby/me',
		describeRoute({
			tags: ['My rooms'],
			summary: 'Rooms the caller visited',
			description: [
				'The rooms the caller has visited — interaction rows carrying a last-visited time —',
				'as a bare array.',
			].join(' '),
			security: AUTHED,
			parameters: pageParams(100),
			responses: { 200: json(RoomDto.array(), 'The visited rooms'), 401: UNAUTHORIZED_RESPONSE },
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getVisitedRooms(c.env.DB, accountId, skip, take))
		}
	)

	// Another player's visited rooms — what the client shows on a friend's profile.
	// Auth-gated (401), and FRIENDS-ONLY: a valid token for someone who isn't that
	// player and isn't a mutual friend of theirs is a 403, since where a player has
	// been is not public. Registered after `visitedby/me` so the literal path wins.
	// Paginated via skip/take (take defaults to 100) and, like `visitedby/me`, a bare
	// array — the client's room-source loaders expect a plain list, not a page.
	.get(
		'/rooms/visitedby/:playerId{[0-9]+}',
		describeRoute({
			tags: ['Rooms'],
			summary: 'A friend’s visited rooms',
			description: [
				'The rooms another player has visited, as a bare array. Friends only: the caller must',
				'be that player or a mutual friend of theirs (403 otherwise) — visit history is not',
				'public.',
			].join(' '),
			security: AUTHED,
			parameters: [playerIdParam, ...pageParams(100)],
			responses: {
				200: json(RoomDto.array(), 'That player’s visited rooms'),
				401: UNAUTHORIZED_RESPONSE,
				403: NOT_FRIENDS_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const playerId = Number.parseInt(c.req.param('playerId'), 10)
			// Your own history is always readable (the client sometimes sends the id
			// rather than `me`); anyone else's needs a mutual friendship.
			if (playerId !== accountId && !(await areFriends(c.env.DB, accountId, playerId))) {
				return c.body(null, 403)
			}
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(await getVisitedRooms(c.env.DB, playerId, skip, take))
		}
	)

	// The current player's interaction state with a room (cheered/favorited/last
	// visited), read from the `interaction` table. Auth-gated.
	.get(
		'/rooms/:roomId{[0-9]+}/interactionby/me',
		describeRoute({
			tags: ['Interaction'],
			summary: 'The caller’s state on a room',
			description: [
				'Whether the caller has cheered/favorited the room. An unknown room (or one the caller',
				'has never touched) reads as all-false rather than 404. `LastVisitedAt` is stamped',
				'with “now” on every read, not served from storage.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const interaction = await getInteraction(
				c.env.DB,
				accountId,
				Number.parseInt(c.req.param('roomId'), 10)
			)
			return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
		}
	)

	// Toggle the player's cheer/favorite on a room. Both are auth-gated PUTs that
	// flip the stored flag and return the updated interaction.
	.put(
		'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
		describeRoute({
			tags: ['Interaction'],
			summary: 'Toggle the caller’s cheer on a room',
			description: 'Flips the stored cheer flag and answers the updated interaction.',
			security: AUTHED,
			parameters: [roomIdParam],
			responses: {
				200: json(InteractionDto, 'The interaction after the toggle'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const interaction = await toggleCheer(
				c.env.DB,
				accountId,
				Number.parseInt(c.req.param('roomId'), 10)
			)
			return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
		}
	)
	// Explicitly un-cheer a room (DELETE clears the cheer, vs the PUT toggle).
	// Auth-gated; idempotent — un-cheering when there's no cheer is a no-op.
	.delete(
		'/rooms/:roomId{[0-9]+}/interactionby/me/cheer',
		describeRoute({
			tags: ['Interaction'],
			summary: 'Un-cheer a room',
			description: [
				'Clears the cheer outright, where the PUT toggles it. Idempotent — un-cheering a room',
				'that isn’t cheered is a no-op.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const interaction = await removeCheer(
				c.env.DB,
				accountId,
				Number.parseInt(c.req.param('roomId'), 10)
			)
			return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
		}
	)
	.put(
		'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
		describeRoute({
			tags: ['Interaction'],
			summary: 'Toggle the caller’s favorite on a room',
			description: 'Flips the stored favorite flag and answers the updated interaction.',
			security: AUTHED,
			parameters: [roomIdParam],
			responses: {
				200: json(InteractionDto, 'The interaction after the toggle'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const interaction = await toggleFavorite(
				c.env.DB,
				accountId,
				Number.parseInt(c.req.param('roomId'), 10)
			)
			return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
		}
	)
	// Explicitly un-favorite a room (DELETE clears the favorite, vs the PUT toggle).
	// Auth-gated; idempotent — un-favoriting when there's no favorite is a no-op.
	.delete(
		'/rooms/:roomId{[0-9]+}/interactionby/me/favorite',
		describeRoute({
			tags: ['Interaction'],
			summary: 'Un-favorite a room',
			description: [
				'Clears the favorite outright, where the PUT toggles it. Idempotent — un-favoriting a',
				'room that isn’t favorited is a no-op.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			responses: { 200: json(InteractionDto, 'The interaction'), 401: UNAUTHORIZED_RESPONSE },
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)
			const interaction = await removeFavorite(
				c.env.DB,
				accountId,
				Number.parseInt(c.req.param('roomId'), 10)
			)
			return c.json({ ...interaction, LastVisitedAt: new Date().toISOString() })
		}
	)

	// Clone a room into a new one owned by the caller, using the `name` form field
	// (also accepted as a query param). Auth is required — no valid token is a 401,
	// with no stub-account fallback. Returns the `{ success, error, value }` envelope
	// the client expects; business failures are 200 with success:false.
	.post(
		'/rooms/:roomId{[0-9]+}/clone',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Clone a room',
			description: [
				'Copies a room’s content (scene, subrooms, settings) into a new room owned by the',
				'caller. Cloning is the only way to make a room, so the per-account room cap is',
				'enforced here — it counts the rooms the account created, minus their auto-provisioned',
				'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags,',
				'`IsRRO` cleared, and PRIVATE accessibility — a new room is unpublished until its',
				'owner sets its accessibility, so it never lands in the public feeds on creation.',
				'',
				'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
				'HTTP 200 with `success: false` and the message the client shows.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(CloneRoomRequest, 'The new room’s name (also read from `?name=`)'),
			responses: {
				200: json(RoomEnvelope, 'The new room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const raw = body.name ?? c.req.query('name') ?? ''
			const name = typeof raw === 'string' ? raw.trim() : ''

			if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
			// Shape before availability, so a rejected name costs no D1 read.
			const badName = roomNameRejection(name, 'room name')
			if (badName !== null) return roomEnvelope(c, null, badName)
			if (await getRoomByName(c.env.DB, name)) {
				return roomEnvelope(c, null, 'A room with that name already exists!')
			}
			// Cloning is how a player makes a room, so the per-account cap belongs here.
			// Checked after the cheap validations so a rejected name costs no extra D1 read.
			const maxRooms = intVar(c.env.MAX_ROOMS_PER_ACCOUNT, DEFAULT_MAX_ROOMS_PER_ACCOUNT)
			if (maxRooms > 0 && (await countRoomsByCreator(c.env.DB, accountId)) >= maxRooms) {
				logger.info('room create rejected: per-account room limit', { accountId })
				return roomEnvelope(c, null, `You can only have ${maxRooms} rooms.`)
			}
			const room = await cloneRoom(
				c.env.DB,
				Number.parseInt(c.req.param('roomId'), 10),
				name,
				accountId
			)
			if (!room) return roomEnvelope(c, null, "You can't clone this room!")
			return roomEnvelope(c, room)
		}
	)

	// Update a room's description. Auth-gated (401) and owner-only. Business results
	// use the `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
	.put(
		'/rooms/:roomId{[0-9]+}/description',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s description',
			description: [
				'Owner-only (the room’s `CreatorAccountId` — co-owners cannot). An unknown room or a',
				'non-owner is HTTP 200 with `Success: false` and an `ErrorId`; only a missing token is',
				'a real 401. An absent `description` field clears the description. Pushes a',
				'`RoomUpdate` to the owner — this envelope carries no room, so the push is the only',
				'thing that tells their client to redraw.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(DescriptionRequest, 'The new description'),
			responses: {
				200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This room does not exist!',
				})
			}
			if (room.CreatorAccountId !== accountId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.NotOwner',
					Error: 'You are not the owner of this room!',
				})
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const description = typeof body.description === 'string' ? body.description : ''
			await setRoomDescription(c.env.DB, roomId, description)
			// Same reason as the rename below: the envelope carries no room, so without this
			// the client redraws the room from what it already had — the old description.
			await pushRoomUpdate(c, accountId, { ...room, Description: description })
			return roomResult(c, { Success: true })
		}
	)

	// Rename a room. Auth-gated (401) and owner-only; the new name must be non-empty
	// and not already taken by another room. Business results use the
	// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
	// NOTE: the ErrorId strings (besides Rooms.DoesntExist) are best guesses.
	.put(
		'/rooms/:roomId{[0-9]+}/name',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Rename a room',
			description: [
				'Owner-only. The new name must be non-empty and not already taken by another room',
				'(names are compared case-insensitively). Rejections are HTTP 200 with',
				'`Success: false`.',
				'',
				'NOTE: the `ErrorId` strings other than `Rooms.DoesntExist` are best guesses — the',
				'client only renders `Error`, so they have never been confirmed against the real one.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(NameRequest, 'The new name'),
			responses: {
				200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This room does not exist!',
				})
			}
			if (room.CreatorAccountId !== accountId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.NotOwner',
					Error: 'You are not the owner of this room!',
				})
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const name = typeof body.name === 'string' ? body.name.trim() : ''
			if (name === '') {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.InvalidName',
					Error: 'You must enter a name for your room!',
				})
			}
			// Same ErrorId as the empty case — the client keys off it to mark the field, and
			// both are the name being unusable. The sentence is what tells them which.
			const badName = roomNameRejection(name, 'room name')
			if (badName !== null) {
				return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
			}

			// Reject if a different room already uses this name (case-insensitive).
			const existing = await getRoomByName(c.env.DB, name)
			if (existing && existing.RoomId !== roomId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.AlreadyExists',
					Error: 'A room with that name already exists!',
				})
			}

			// Writes `FriendlyName` too — see `setRoomName`. The two are the same string here,
			// and the client labels the room from the display one.
			await setRoomName(c.env.DB, roomId, name)
			// The rename answers a bare `{ Success }` with no room in it, so the client has
			// nothing to re-render from and kept showing the old name until the push arrived.
			// Built from the room already in hand rather than re-read, like the image route's.
			await pushRoomUpdate(c, accountId, { ...room, Name: name, FriendlyName: name })
			return roomResult(c, { Success: true })
		}
	)

	// Change a room's tags. Auth-gated (401) and owner/co-owner-only (403). Returns the
	// `{ success, error, value }` envelope with the updated room as `value`; business
	// failures are 200 with success:false.
	//
	// TWO BODIES reach this one path — Rec Room reshaped the request rather than minting a
	// second route, so the fields, not the URL, say which one this is:
	//
	//  - `tag=<name>` ALONE is the 2023 toggle. There is no delete/patch counterpart, so
	//    the same call adds the tag (Type 0) when absent and removes it when present, and
	//    the five "main" tags (pvp/quest/game/hangout/art) act as radio buttons.
	//  - Anything else is the whole-state save both clients send from room settings:
	//    `autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.
	//    `tag` repeats and is the complete set of USER tags, `autoTag` adds a derived one
	//    (Type 1), and `primaryGenreTag` flags the genre. All three compose into one write.
	//
	// The discriminator is deliberately "is there more than a lone `tag`": a save that
	// happens to carry one selected tag must not TOGGLE it back off, which is what made
	// this worth spelling out rather than counting `tag` alone.
	.put(
		'/rooms/:roomId{[0-9]+}/tags',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Toggle a tag on a room',
			description: [
				'Owner or co-owner only (403 otherwise). Two bodies reach this one path, and the',
				'FIELDS say which — not the URL.',
				'',
				'**A lone `tag=<name>` TOGGLES** (the 2023 form): there is no delete/patch',
				'counterpart, so the same call adds the tag (Type 0) when absent and removes it when',
				'present, and the “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
				'buttons among themselves.',
				'',
				'**Anything else is a whole-state save** — the form room settings posts, e.g.',
				'`autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.',
				'Nothing toggles here; the three fields compose into one write:',
				'',
				'- `tag` repeats and is the COMPLETE set of user (Type 0) tags — one the body omits',
				'is removed. Derived tags are not the client’s to send and are left alone.',
				'- `autoTag` repeats and adds a derived tag at **Type 1** (`limitsv2`, `beta`). It is',
				'additive: it never removes one, since the client posts what it wants rather than the',
				'full set. A tag already on the room is re-categorised rather than duplicated.',
				'- `primaryGenreTag` flags the room’s genre. The tag is added as a Type 0 tag when',
				'the room lacks it and left as it stands when it has it; `IsPrimaryGenre: true` moves',
				'onto it, and every OTHER tag loses the flag but KEEPS its place.',
				'',
				'Answers the lowercase envelope with the updated room, which the client re-renders',
				'from, and pushes a `RoomUpdate` to the owner for their other sessions.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(TagRequest, 'The tag to toggle'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			// `all: true` because `tag` REPEATS on the whole-state save; without it Hono keeps
			// only the last value and a three-tag save would land as one tag.
			const body: Record<string, unknown> = await c.req.parseBody({ all: true }).catch(() => ({}))
			// An empty value is the same nothing as an absent field. There is no "clear the
			// genre" request, so a blank `primaryGenreTag` is a malformed post rather than an
			// instruction to unset — and a blank `tag` can't name what to toggle.
			const values = (name: string): string[] =>
				(Array.isArray(body[name]) ? body[name] : [body[name]])
					.filter((v): v is string => typeof v === 'string')
					.map((v) => v.trim())
					.filter((v) => v !== '')

			const tags = values('tag')
			const autoTags = values('autoTag')
			const primaryGenre = values('primaryGenreTag')[0]
			if (tags.length === 0 && autoTags.length === 0 && primaryGenre === undefined) {
				return roomEnvelope(c, null, 'You must provide a tag!')
			}

			// A LONE tag is the 2023 toggle; a tag alongside anything else — another tag, an
			// auto tag, a genre — is part of a whole-state save, where nothing toggles.
			const isToggle = tags.length === 1 && autoTags.length === 0 && primaryGenre === undefined
			const updated = await applyRoomTagEdit(c.env.DB, roomId, room, {
				toggle: isToggle ? tags[0] : undefined,
				tags: isToggle ? undefined : tags.length > 0 ? tags : undefined,
				autoTags,
				primaryGenre,
			})
			// This one DOES answer the updated room, so the caller's own client redraws from
			// the response; the push is for their other sessions, as on every mutation below.
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Set a room's image. Auth-gated (401) and owner-only. Body is the `imageName`
	// form field (a key from the storage/image upload). Business results use the
	// `{ Success, Value, ErrorId, Error }` envelope at HTTP 200.
	.put(
		'/rooms/:roomId{[0-9]+}/image',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s image',
			description: [
				'Owner-only. `imageName` is a key from the storage upload, stored un-prefixed (the',
				'`cdn` worker serves it back under `room/`). Pushes a `RoomUpdate` to the owner so',
				'their client re-renders with the new image.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(ImageRequest, 'The uploaded image key'),
			responses: {
				200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This room does not exist!',
				})
			}
			if (room.CreatorAccountId !== accountId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.NotOwner',
					Error: 'You are not the owner of this room!',
				})
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
			if (imageName === '') {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.InvalidImage',
					Error: 'You must provide an image!',
				})
			}
			await setRoomImage(c.env.DB, roomId, imageName)
			// Notify the owner so their client refreshes the room (RoomUpdate carries the
			// updated room). The reference sends the post-update room, so merge the change.
			await pushRoomUpdate(c, accountId, { ...room, ImageName: imageName })
			return roomResult(c, { Success: true })
		}
	)

	// Delete a room. Auth-gated (401) and owner-only (the room's CreatorAccountId).
	// Removes the room record (and per-player interactions with it) and the room's
	// image object from the shared CDN bucket. Images players *took* in the room are
	// left alone — they live in the api/img world and outlast the room.
	.delete(
		'/rooms/:roomId{[0-9]+}',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Delete a room',
			description: [
				'Owner-only. Removes the room record, the per-player interactions with it, and the',
				'room’s image object from the shared CDN bucket. Photos players TOOK in the room are',
				'left alone — those live in the api/img world and outlive the room.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			responses: {
				200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This room does not exist!',
				})
			}
			if (room.CreatorAccountId !== accountId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.NotOwner',
					Error: 'You are not the owner of this room!',
				})
			}

			await deleteRoom(c.env.DB, roomId)

			// Remove the room image from the CDN bucket. The stored ImageName is the
			// un-prefixed key the `cdn` worker serves back under `room/` (see storage
			// upload + the `GET /room/:dataBlob` route), so the object key is `room/<name>`.
			// R2 deletes are idempotent, so a canonical/static or already-gone image is fine.
			const imageName = typeof room.ImageName === 'string' ? room.ImageName : ''
			if (imageName !== '') {
				await c.env.CDN_ASSETS.delete(`room/${imageName}`)
			}

			return roomResult(c, { Success: true })
		}
	)

	// Set a member's role in a room (`Roles[].Role`). Auth-gated (401) and gated to
	// the room creator or a co-owner (403 otherwise) — the same owner/co-owner check
	// the other room-admin actions use. Body is the `role` form field (an integer role
	// tier). Updates the target account's existing role entry or adds one, notifies the
	// affected member so their client refreshes permissions, and returns the updated
	// room in the lowercase `{ success, error, value }` envelope.
	.put(
		'/rooms/:roomId{[0-9]+}/roles/:accountId{[0-9]+}',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a member’s role in a room',
			description: [
				'Updates the target account’s entry in the room’s `Roles` (or adds one). Gated to the',
				'room’s creator or a co-owner — a valid token from anyone else is a 403. The affected',
				'MEMBER gets the `RoomUpdate` push, not the caller, so their client refreshes the',
				'permissions it just gained or lost.',
			].join(' '),
			security: AUTHED,
			parameters: [
				roomIdParam,
				{
					name: 'accountId',
					in: 'path',
					required: true,
					description: 'The member whose role changes',
					schema: { type: 'string', pattern: '^[0-9]+$' },
				},
			],
			requestBody: form(RoleRequest, 'The role tier to grant'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const targetAccountId = Number.parseInt(c.req.param('accountId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const role = typeof body.role === 'string' ? Number.parseInt(body.role, 10) : Number.NaN
			if (Number.isNaN(role)) return roomEnvelope(c, null, 'You must provide a valid role!')

			const updated = await setRoomRole(c.env.DB, roomId, targetAccountId, role, accountId, room)
			// Notify the member whose role changed so their client refreshes the room
			// (and the permissions it grants them).
			await pushRoomUpdate(c, targetAccountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// A room's ban list — the owner's view of who they've banned. Same gate as issuing a
	// ban: a ban list says who a room's owner has had trouble with, so it isn't public.
	// Answers a BARE array (not the room-write envelope), newest ban first.
	.get(
		'/rooms/:roomId{[0-9]+}/bans',
		describeRoute({
			tags: ['Room settings'],
			summary: 'A room’s ban list',
			description: [
				'Everyone banned from the room, most recently banned first. Auth-gated, then gated',
				'exactly like issuing a ban: the room’s creator or a co-owner, or an account whose',
				'token carries the `developer` / `moderator` role. A ban list says who a room’s',
				'owner has had trouble with, so it is not public.',
				'',
				'A bare array, NOT the `{ success, error, value }` envelope the ban write answers,',
				'and the entries are camelCase with a different field set: no room id (the path',
				'already says which room) and no ban mask. An unknown room is an empty list rather',
				'than an error — it reads the same as a room nobody is banned from.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			responses: {
				200: json(RoomBanEntryDto.array(), 'The room’s bans, newest first'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			// No room → nothing banned. Same answer as a room with an empty ban list, so
			// this doesn't become a way to probe which room ids exist.
			if (!room) return c.json([])
			if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)

			const bans = await getRoomBans(c.env.DB, roomId)
			return c.json(
				bans.map((ban) => ({
					accountId: ban.BannedPlayerId,
					bannedByAccountId: ban.BannedByAccountId,
					banStartTime: ban.CreatedAt,
				}))
			)
		}
	)

	// Ban a player from a room (form body `id` + `banMask`). Auth-gated (401), then
	// gated to the room's owner/co-owner OR a staff token (403). One row per
	// (room, player) — re-banning rewrites it, so the call is idempotent.
	.post(
		'/rooms/:roomId{[0-9]+}/bans',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Ban a player from a room',
			description: [
				'Records a ban in the `room_ban` table — one row per (room, player), so re-banning',
				'someone already banned rewrites their row rather than adding a second. The row is',
				'what the `match` worker checks: a banned player’s matchmake into this room is',
				'refused with errorCode 55 and never gets a Photon room id.',
				'',
				'Gated to the room’s creator or a co-owner, OR to any account whose token carries the',
				'`developer` / `moderator` role — a valid token from anyone else is a 403. Banning',
				'yourself, or banning someone who can manage the room, is refused: otherwise a',
				'co-owner could ban the owner out of their own room.',
				'',
				'`banMask` is stored verbatim and nothing interprets it — the client sends `0` and',
				'what it selects is not known yet. It defaults to 0 when absent.',
				'',
				'The BANNED player (not the caller) gets a `ModerationKick` push (id 22) — the frame',
				'the client acts on to eject someone — so a ban takes effect immediately rather than',
				'only at their next matchmake. `isBan` is true, `duration` 0 (a room ban has no',
				'expiry; it is lifted by DELETE, not by time) and `reportCategory` -1 (Moderator).',
				'',
				'`isHostKick` means the room’s HOST ejected them rather than the room majority',
				'vote-kicking them; with no vote-kick path yet the only false case is a staff',
				'moderator acting in a room they do not host. `playerIdReporter` is whoever caused',
				'it — the host today, the player who started the vote once vote-kicks exist. The hub',
				'queues the frame if they are offline.',
				'',
				'Answers the same lowercase `{ success, error, value }` envelope the room writes use,',
				'but `value` is the BAN, not the room — a ban is not part of the room the client',
				'renders. This shape is unverified against the real service.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(BanRequest, 'The player to ban'),
			responses: {
				200: json(RoomBanEnvelope, 'The stored ban, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return banEnvelope(c, null, 'This room does not exist!')

			// The room's own owners, or a staffer acting across rooms. Roles are only
			// looked up when the cheaper room check fails. The room's own owner IS the
			// host, which is what the kick frame's `isHostKick` reports.
			const isHostKick = canManageRoom(room, accountId)
			if (!isHostKick && !(await isStaff(c))) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const str = (v: unknown): string => (typeof v === 'string' ? v : '')
			const bannedPlayerId = Number.parseInt(str(body.id), 10)
			if (Number.isNaN(bannedPlayerId)) {
				return banEnvelope(c, null, 'You must provide a valid player to ban!')
			}
			if (bannedPlayerId === accountId) return banEnvelope(c, null, 'You cannot ban yourself!')
			// Without this a co-owner could ban the room's creator out of their own room.
			if (canManageRoom(room, bannedPlayerId)) {
				return banEnvelope(c, null, 'You cannot ban an owner of this room!')
			}
			// Absent or unparseable → 0, the value the client sends.
			const banMask = Number.parseInt(str(body.banMask), 10) || 0

			const ban = await banPlayerFromRoom(c.env.DB, roomId, bannedPlayerId, banMask, accountId)
			// The banned player is told, not the caller — their client acts on the kick.
			const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
			await pushRoomBan(c, ban, roomName, isHostKick)
			return banEnvelope(c, ban)
		}
	)

	// Is this player banned from this room? The client asks before offering someone a room
	// action, so it can grey it out rather than let the attempt fail.
	//
	// The path is the client's, verbatim: `/Room_server/…`, capitalised and underscored,
	// unlike the `/roomserver/rooms/createdby/me` alias elsewhere. Hono matches
	// case-sensitively, so it is registered exactly as the client spells it.
	//
	// Answers the `{ success, error, error_id, value }` envelope — NOT the room mutations'
	// `{ success, error, value }`: this one carries `error_id`, and its `error` is null where
	// theirs is an empty string. `success` is whether the CHECK ran, not the answer; the
	// answer is `value`.
	//
	// Auth-gated but not owner-gated: a player about to interact with someone needs this, and
	// a ban is not a secret from the person it would stop.
	.get(
		'/Room_server/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}/isBanned',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Whether a player is banned from a room',
			description: [
				'Whether `playerId` is banned from `roomId`, read from the same `room_ban` rows the',
				'ban routes write and `match` refuses matchmakes on — so it answers what would',
				'actually happen, not a stub.',
				'',
				'The envelope is `{ success, error, error_id, value }`: `success` says the check ran,',
				'`value` is the answer. It is NOT the room mutations’ envelope — that one has no',
				'`error_id` and uses `""` where this uses null.',
				'',
				'Auth-gated, but any authenticated caller may ask: a ban is not a secret from the',
				'player it stops. The path is the client’s own capitalised `/Room_server/` spelling.',
			].join('\n'),
			security: AUTHED,
			parameters: [
				roomIdParam,
				{
					name: 'playerId',
					in: 'path',
					required: true,
					description: 'The account being asked about',
					schema: { type: 'string', pattern: '^[0-9]+$' },
				},
			],
			responses: {
				200: json(IsBannedEnvelope, 'Whether that player is banned from that room'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			return c.json({ success: true, error: null, error_id: null, value: await pathBan(c) })
		}
	)

	// The SAME check on the unprefixed path (`GET /rooms/112/bans/1/isBanned`), which is the
	// spelling the client uses on the rooms host itself. Registered as its own route rather
	// than as an alias because the answer is not the same bytes: this one is PascalCase
	// (`Value`/`Success`/`Error`, `error_id` still lowercase), and the client's decoder drops
	// members it doesn't know silently — a lowercase `value` here would read as `false` and
	// show a banned player as unbanned.
	//
	// Same gate as its sibling: auth-gated, not owner-gated. A ban is not a secret from the
	// player it stops, and the client asks this before offering a room action so it can grey
	// it out rather than let the attempt fail.
	.get(
		'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}/isBanned',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Whether a player is banned from a room (unprefixed path)',
			description: [
				'The same `room_ban` check as the `/Room_server/` route, on the path the client uses',
				'against the rooms host directly — and in the PascalCase envelope it reads there:',
				'`{ Value, Success, Error, error_id }`, with `error_id` lowercase.',
				'',
				'The two envelopes are NOT unified. The client’s decoder drops members it does not',
				'recognise, so serving the other route’s lowercase `value` here would decode as',
				'`false` — a banned player shown as unbanned — rather than fail.',
				'',
				'Auth-gated, but any authenticated caller may ask: a ban is not a secret from the',
				'player it stops. A room that does not exist has no bans, so it answers',
				'`Value: false` rather than 404ing — the check is about the ban row, not the room.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam, bannedPlayerIdParam],
			responses: {
				200: json(IsBannedPascalEnvelope, 'Whether that player is banned from that room'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			return c.json({ Value: await pathBan(c), Success: true, Error: null, error_id: null })
		}
	)

	// Lift a player's ban on a room. Same gate as issuing one: auth-gated (401), then the
	// room's owner/co-owner OR a staff token (403).
	.delete(
		'/rooms/:roomId{[0-9]+}/bans/:playerId{[0-9]+}',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Unban a player from a room',
			description: [
				'Removes the player’s `room_ban` row, so they can matchmake into the room again.',
				'Gated exactly like issuing a ban: the room’s creator or a co-owner, or an account',
				'whose token carries the `developer` / `moderator` role.',
				'',
				'Unbanning someone who is not banned is a rejection (`success: false`), not a silent',
				'success — the caller asked to undo something that was not there.',
				'',
				'Answers the same envelope as the ban write, with the REMOVED ban as `value`. No',
				'notification is pushed: nothing tells a player their ban was lifted.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam, bannedPlayerIdParam],
			responses: {
				200: json(RoomBanEnvelope, 'The removed ban, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return banEnvelope(c, null, 'This room does not exist!')
			if (!canManageRoom(room, accountId) && !(await isStaff(c))) return c.body(null, 403)

			const playerId = Number.parseInt(c.req.param('playerId'), 10)
			const removed = await unbanPlayerFromRoom(c.env.DB, roomId, playerId)
			if (!removed) return banEnvelope(c, null, 'This player is not banned from this room!')
			return banEnvelope(c, removed)
		}
	)

	// Set a room's content warning: the `WarningMask` bit flags plus an optional
	// free-text `CustomWarning`. Auth-gated (401) and owner/co-owner-only (403). Body is
	// the `warningMask` form field (an integer) and an optional `customWarning` string
	// (set when present — an empty value clears it). Returns the updated room in the
	// `{ success, error, value }` envelope.
	.put(
		'/rooms/:roomId{[0-9]+}/warning',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s content warning',
			description: [
				'The `WarningMask` bit flags plus an optional free-text `CustomWarning`. Owner or',
				'co-owner only (403 otherwise). `CustomWarning` is only touched when the field is',
				'present — sending it empty clears it, omitting it leaves it alone.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(WarningRequest, 'The warning flags and optional custom text'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const warningMask =
				typeof body.warningMask === 'string' ? Number.parseInt(body.warningMask, 10) : Number.NaN
			if (Number.isNaN(warningMask))
				return roomEnvelope(c, null, 'You must provide a valid warning mask!')

			const patch: Record<string, unknown> = { WarningMask: warningMask }
			// Only touch CustomWarning when the field is present (an empty string clears it).
			if (typeof body.customWarning === 'string') patch.CustomWarning = body.customWarning

			const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
			// Notify the owner so their client refreshes the room with the updated warning.
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Toggle whether a room may be cloned (`CloningAllowed`). Auth-gated (401) and
	// owner/co-owner-only (403). Body is the `cloningAllowed` form field (`True`/`False`).
	// Returns the updated room in the `{ success, error, value }` envelope.
	.put(
		'/rooms/:roomId{[0-9]+}/cloning',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Allow or block cloning of a room',
			description: [
				'Sets `CloningAllowed` — false makes `POST /rooms/{roomId}/clone` refuse. Owner or',
				'co-owner only (403 otherwise).',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(CloningRequest, 'Whether cloning is allowed'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			if (typeof body.cloningAllowed !== 'string') {
				return roomEnvelope(c, null, 'You must provide cloningAllowed.')
			}
			const cloningAllowed = body.cloningAllowed.toLowerCase() === 'true'

			const updated = await updateRoomFields(c.env.DB, roomId, room, {
				CloningAllowed: cloningAllowed,
			})
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Set a room's platform/movement support flags (its `Supports*` restrictions).
	// Auth-gated (401) and owner/co-owner-only (403). Body is a form of
	// `supports*=True|False` fields (see RESTRICTION_FIELDS); only the fields present
	// are changed. Returns the updated room in the `{ success, error, value }` envelope.
	.put(
		'/rooms/:roomId{[0-9]+}/restrictions',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s platform/movement support flags',
			description: [
				'The room’s `Supports*` restrictions — which platforms and movement modes may enter.',
				'Owner or co-owner only (403 otherwise). Only the fields actually posted are changed,',
				'and field names are matched case-insensitively.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(RestrictionsRequest, 'The flags to change'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const patch: Record<string, boolean> = {}
			for (const [key, value] of Object.entries(body)) {
				const field = RESTRICTION_FIELDS[key.toLowerCase()]
				if (field !== undefined && typeof value === 'string') {
					patch[field] = value.toLowerCase() === 'true'
				}
			}

			const updated = await updateRoomFields(c.env.DB, roomId, room, patch)
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Set a room's load screen (`LoadScreens[]` — the image shown while the room loads).
	// Auth-gated (401) and owner/co-owner-only (403). Body is the `imageName` form field
	// plus optional `title`/`subtitle`. REPLACES the list with the single posted
	// `{ ImageName, Title, Subtitle }` and returns the updated room in the
	// `{ success, error, value }` envelope.
	//
	// The field is an array because the client's parser wants one, but the client only
	// ever renders (and only ever posts) a single screen — appending left the old screen
	// in slot 0 and the new one unreachable behind it, so setting a load screen appeared
	// to do nothing. Kept as an array so multi-screen support can land without a
	// migration.
	.put(
		'/rooms/:roomId{[0-9]+}/loadscreen',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s load screen',
			description: [
				'REPLACES the room’s `LoadScreens` with the single posted `{ ImageName, Title,',
				'Subtitle }` — the image shown while the room loads. The field is an array (the',
				'client’s parser expects one) but the client only supports a single screen, so this',
				'never appends. Owner or co-owner only (403 otherwise).',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(LoadScreenRequest, 'The load screen to set'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const imageName = typeof body.imageName === 'string' ? body.imageName.trim() : ''
			if (imageName === '') return roomEnvelope(c, null, 'You must provide an image!')
			const title = typeof body.title === 'string' ? body.title : ''
			const subtitle = typeof body.subtitle === 'string' ? body.subtitle : ''

			// The posted screen becomes the whole list — the client shows one load screen.
			const loadScreens = [{ ImageName: imageName, Title: title, Subtitle: subtitle }]
			const updated = await updateRoomFields(c.env.DB, roomId, room, { LoadScreens: loadScreens })
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Set a room's top-level `Accessibility` (the visibility the public-room/search
	// filters key on — see the RoomAccessibility enum). Auth-gated (401) and
	// owner/co-owner-only (403). Body is the `accessibility` form field (an integer).
	// Returns the updated room in the `{ success, error, value }` envelope.
	.put(
		'/rooms/:roomId{[0-9]+}/accessibility',
		describeRoute({
			tags: ['Room settings'],
			summary: 'Set a room’s accessibility',
			description: [
				'The room’s top-level visibility — the field the public-room and search filters key',
				'on (0 Private, 1 Public, 2 Unlisted). Owner or co-owner only (403 otherwise).',
				'Subrooms carry their own `Accessibility`, set through the subroom `modify` call.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(AccessibilityRequest, 'The new accessibility'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const accessibility =
				typeof body.accessibility === 'string'
					? Number.parseInt(body.accessibility, 10)
					: Number.NaN
			if (Number.isNaN(accessibility)) {
				return roomEnvelope(c, null, 'You must provide a valid accessibility!')
			}

			const updated = await updateRoomFields(c.env.DB, roomId, room, {
				Accessibility: accessibility,
			})
			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// A subroom's saved-data versions — the room-history / "restore a save" list. Every
	// save is its own `subroom_save` row (nothing is overwritten), so this is real
	// history, newest first, paged by skip/take. Auth-gated (401), and readable by the
	// room's creator or anyone whose presence puts them in the room (see `canReadSaves`).
	.get(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'A subroom’s saved-data versions',
			description: [
				'The room-history / “restore a save” list, newest first. Every room save appends a',
				'row rather than overwriting, so this is the subroom’s full history; it is empty',
				'only when the subroom has never been saved.',
				'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
				'',
				'The list includes STAGED saves that were never published, so it is not public:',
				'the room’s creator may read it, and so may anyone standing IN the room (their live',
				'presence says so). Anyone else is a 403. It is what the client reads to resolve',
				'“load the latest or the published version?” on entering a private instance — a',
				'visitor who cannot read it cannot load what the instance is running.',
				'',
				'`TotalResults` and `TotalCount` carry the same number: the client’s paged DTO and',
				'the reference disagree on the name, so both are emitted.',
			].join(' '),
			security: AUTHED,
			parameters: [
				roomIdParam,
				subRoomIdParam,
				stringQuery('unityAssetTarget', 'Accepted and ignored'),
				stringQuery('unityAssetVersion', 'Accepted and ignored'),
				stringQuery('skip', 'How many saves to skip (default 0)'),
				stringQuery('take', 'How many saves to return (default all)'),
			],
			responses: {
				200: json(SubRoomSavesPage, 'The subroom’s saves, newest first'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
			// Scoped through the room so a subroom id from another room can't read its saves.
			const room = await getRoomById(c.env.DB, roomId)
			if (!room || !findSubRoom(room, subRoomId)) {
				return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
			}
			if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
			const saves = await getSubRoomSaves(c.env.DB, subRoomId)

			const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
			const take = Number.parseInt(c.req.query('take') ?? '', 10)
			const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
			const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)

			return c.json({ Results: page, TotalResults: saves.length, TotalCount: saves.length })
		}
	)

	// The same history list, with the Unity-asset payloads left out. The rows are the
	// `…/saves` rows minus `UnitySubAssets`/`ReferencedUnityAssets`/`Tags` — the heavy part
	// of a save row, which a history list never renders — keeping the asset IDENTIFIERS.
	//
	// It answers a BARE paged wrapper: no `{ success, error, value }` envelope, unlike the
	// room mutations next door. Same gate, same paging and the same empty page for an
	// unknown room/subroom as `…/saves`, so the two can be swapped for one another.
	//
	// The `:saveId` detail route below is digit-constrained, so `no_unity_assets` can never
	// be read as a save id whichever order these are declared in.
	.get(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/no_unity_assets',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'A subroom’s saves, without their Unity-asset payloads',
			description: [
				'The same page as `…/saves`, newest first, carrying the lighter rows: no',
				'`UnitySubAssets`, no `ReferencedUnityAssets`, no `Tags` — only the asset ids. A BARE',
				'paged wrapper, with no `{ success, error, value }` envelope around it.',
				'',
				'Gated exactly like `…/saves`, and for the same reason: the list includes STAGED',
				'saves that were never published, so it is the room’s creator or anyone whose live',
				'presence puts them in the room, and anyone else is a 403.',
				'',
				'`TotalResults` and `TotalCount` carry the same number — the client’s paged DTO and',
				'the reference disagree on the name, so both are emitted.',
			].join(' '),
			security: AUTHED,
			parameters: [
				roomIdParam,
				subRoomIdParam,
				stringQuery('skip', 'How many saves to skip (default 0)'),
				stringQuery('take', 'How many saves to return (default all)'),
			],
			responses: {
				200: json(SubRoomSavesNoUnityAssetsPage, 'The subroom’s saves, newest first'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
			// Scoped through the room so a subroom id from another room can't read its saves.
			const room = await getRoomById(c.env.DB, roomId)
			if (!room || !findSubRoom(room, subRoomId)) {
				return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
			}
			if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
			const saves = await getSubRoomSaves(c.env.DB, subRoomId)

			const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
			const take = Number.parseInt(c.req.query('take') ?? '', 10)
			const from = Number.isNaN(skip) || skip < 0 ? 0 : skip
			const page = saves.slice(from, Number.isNaN(take) || take < 0 ? undefined : from + take)

			// `TotalResults` counts the whole history, not the page — that is what the client
			// pages against.
			return c.json({
				Results: page.map(toSaveWithoutUnityAssets),
				TotalResults: saves.length,
				TotalCount: saves.length,
			})
		}
	)

	// One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
	// Same gate as that list: a save id resolves whether or not it was ever published, so
	// this exposes the same unpublished work, to the same readers.
	.get(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'One of a subroom’s saves by id',
			description: [
				'A single save, in the SAME camelCase projection the room save that created it',
				'returned — not the PascalCase rows `…/saves` lists. Save ids are globally',
				'unique but resolved scoped to the subroom, so one subroom cannot read another’s',
				'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown',
				'one.',
				'',
				'Gated like the list it details — the room’s creator, or anyone whose presence puts',
				'them in the room. A save id resolves whether or not it was ever published, so this',
				'reads unpublished work.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam, saveIdParam],
			responses: {
				200: json(SubRoomDataSaveResponseDto, 'The save'),
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
				404: { description: 'No such room, subroom, or save on that subroom' },
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
			const saveId = Number.parseInt(c.req.param('saveId'), 10)

			// Scoped through the room, like the list, so a subroom id from another room can't
			// be used to read its saves.
			const room = await getRoomById(c.env.DB, roomId)
			if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
			if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)

			const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
			return save ? c.json(toSaveResponse(save)) : c.notFound()
		}
	)

	// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
	// by the room creator or a Creator/CoOwner role holder. Points the subroom at
	// the uploaded data blobs and records the revision's fields against that SUBROOM,
	// notifies the owner, and returns the updated ROOM in the lowercase
	// `{ success, error, value }` envelope the reference's SetRoomData uses.
	.post(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Save a subroom’s data (room save)',
			description: [
				'Records a save against the subroom from the blobs the client has already uploaded',
				'through the `storage` worker. Everything the body carries describes THAT revision',
				'and lands on the save and its subroom — `Description` is the save comment, NOT the',
				'room’s description (only `PUT /rooms/{roomId}/description` sets that). Nothing here',
				'writes to the room. Editable by the room’s creator or a co-owner (403 otherwise); a',
				'missing token is an EMPTY-body 401, unlike the other room writes.',
				'',
				'`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it',
				'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so',
				'players keep loading the last published version until the owner calls',
				'`POST …/subrooms/{subRoomId}/publish_save`. DORMS always publish — they have no',
				'publish step in the client, so staging one would hide the player’s own edits.',
				'',
				'`value` carries BOTH the updated `room` and the `subRoomDataSave` just created,',
				'and `error` is NULL here rather than the empty string the other room envelopes',
				'use. The save is projected in camelCase with a different field set from the',
				'PascalCase `CurrentSave` embedded in the room — the two are not the same shape.',
				'A subroom with no `CreatorAccountId` yet (the seeded rooms start null) gets the',
				'saver’s id here, because the client NREs on a null one.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			requestBody: jsonBody(SaveSubRoomDataRequest, 'The uploaded blob keys and save fields'),
			responses: {
				200: json(RoomSaveEnvelope, 'The updated room + the new save, or a rejection'),
				401: UNAUTHORIZED_EMPTY,
				403: FORBIDDEN_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return c.body(null, 401)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return c.json({ success: false, error: 'This room does not exist!', value: null })
			}
			// A valid token but not the room's owner/co-owner → 403 (the auth gate above
			// already returned 401 for a missing/invalid token).
			if (!canManageRoom(room, accountId)) return c.body(null, 403)

			// The client uploads BOTH blobs to `storage` first and sends their keys here:
			// `SubRoomData` is the scene blob (what the loader downloads), `RoomData` the
			// metadata blob. `OwnershipProof` is accepted and ignored.
			const body = (await c.req.json().catch(() => ({}))) as {
				RoomData?: { Filename?: string }
				SubRoomData?: { Filename?: string; Hash?: string | null }
				UnityAssetId?: string | null
				Description?: string
				PersistenceVersion?: number
				InventionUsage?: string
				AutoPublish?: boolean
			}

			const result = await saveSubRoomData(c.env.DB, roomId, subRoomId, accountId, {
				subRoomDataFilename: body.SubRoomData?.Filename,
				subRoomDataHash:
					typeof body.SubRoomData?.Hash === 'string' ? body.SubRoomData.Hash : undefined,
				roomDataFilename: body.RoomData?.Filename,
				unityAssetId: typeof body.UnityAssetId === 'string' ? body.UnityAssetId : undefined,
				autoPublish: body.AutoPublish === true,
				description: typeof body.Description === 'string' ? body.Description : undefined,
				persistenceVersion:
					typeof body.PersistenceVersion === 'number' ? body.PersistenceVersion : undefined,
				inventionUsage: typeof body.InventionUsage === 'string' ? body.InventionUsage : undefined,
			})
			if (!result) {
				return c.json({ success: false, error: 'This subroom does not exist!', value: null })
			}

			// `value` carries BOTH the updated room and the save just created — and `error`
			// is null here, not the empty string the other room envelopes use.
			await pushRoomUpdate(c, accountId, result.room)
			return c.json({
				success: true,
				error: null,
				value: { room: result.room, subRoomDataSave: toSaveResponse(result.save) },
			})
		}
	)

	// Modify a subroom's settings (Name/Accessibility/MaxPlayers) from the form body.
	// Auth-gated (401) and owner-only — only the room creator may change its subrooms.
	// Notifies the owner (RoomUpdate) and returns the `{ Success, Value, ErrorId, Error }`
	// envelope at HTTP 200, matching the other owner-gated room mutations.
	.put(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/modify',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Modify a subroom’s settings',
			description: [
				'Sets a subroom’s `Name`, `Accessibility` and `MaxPlayers`. Owner-only — only the',
				'room’s creator may change its subrooms, not co-owners. `name` is required;',
				'`accessibility` and `maxPlayers` are applied only when supplied, and a non-positive',
				'`maxPlayers` is ignored rather than rejected.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			requestBody: form(ModifySubRoomRequest, 'The settings to change'),
			responses: {
				200: json(RoomResultEnvelope, 'Success, or a rejection carrying an `ErrorId`'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This room does not exist!',
				})
			}
			if (room.CreatorAccountId !== accountId) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.NotOwner',
					Error: 'You are not the owner of this room!',
				})
			}
			if (!findSubRoom(room, subRoomId)) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This subroom does not exist!',
				})
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const name = typeof body.name === 'string' ? body.name.trim() : ''
			if (name === '') {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.InvalidName',
					Error: 'You must enter a name for your room!',
				})
			}
			const badName = roomNameRejection(name, 'subroom name')
			if (badName !== null) {
				return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
			}
			const maxPlayers =
				typeof body.maxPlayers === 'string' ? Number.parseInt(body.maxPlayers, 10) : Number.NaN

			const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, {
				name,
				// Accepts the enum name as well as the ordinal — the dedicated
				// `/accessibility` route below is sent names, so this may be too.
				accessibility: parseAccessibility(body.accessibility),
				maxPlayers: Number.isNaN(maxPlayers) || maxPlayers <= 0 ? undefined : maxPlayers,
			})
			if (!updated) {
				return roomResult(c, {
					Success: false,
					ErrorId: 'Rooms.DoesntExist',
					Error: 'This subroom does not exist!',
				})
			}

			await pushRoomUpdate(c, accountId, updated)
			return roomResult(c, { Success: true })
		}
	)

	// Publish a subroom's staged save — promote it to the live one players load. Every
	// non-dorm room save only STAGES (see the save route), so this is the manual step that
	// makes edits visible. Auth-gated (401) and creator-only: co-owners may save, but
	// only the room's owner decides what goes live. Answers the updated ROOM in the
	// `{ success, error, value }` envelope, like the other subroom mutations.
	.post(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/publish_save',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Publish one of a subroom’s saves',
			description: [
				'Makes the save named by the `subRoomDataSaveId` form field the one players load —',
				'it becomes the subroom’s `CurrentSave`. A room save only STAGES (dorms excepted),',
				'so nothing a creator saves reaches players until this is called.',
				'',
				'The id may be any save in the subroom’s history, so this doubles as restore-a-save.',
				'`StagedSubRoomDataSaveId` is cleared only when the published save IS the staged',
				'one — restoring an older version keeps newer unpublished work staged.',
				'',
				'Owner-only: co-owners may save but not decide what goes live. A save id belonging',
				'to another subroom is rejected.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			requestBody: form(PublishSaveRequest, 'The save to publish'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			if (room.CreatorAccountId !== accountId) {
				return roomEnvelope(c, null, 'You are not the owner of this room!')
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const saveId =
				typeof body.subRoomDataSaveId === 'string'
					? Number.parseInt(body.subRoomDataSaveId, 10)
					: Number.NaN
			if (Number.isNaN(saveId)) {
				return roomEnvelope(c, null, 'You must provide a valid save!')
			}

			const result = await publishSubRoomSave(c.env.DB, roomId, subRoomId, saveId)
			if (!result.ok) {
				return roomEnvelope(
					c,
					null,
					result.reason === 'unknown_save'
						? 'That save does not exist!'
						: 'This subroom does not exist!'
				)
			}

			await pushRoomUpdate(c, accountId, result.room)
			return roomEnvelope(c, result.room)
		}
	)

	// Set a single subroom's `Accessibility`. Same effect as the `accessibility` field of
	// the subroom `modify` call, but this is what the client actually calls when the
	// player flips one subroom's visibility, and the body carries the enum NAME
	// (`accessibility=Private`), not the number the room-level `/accessibility` takes.
	// Auth-gated (401) and owner-only, like the other subroom mutations. Answers the
	// updated ROOM in the `{ success, error, value }` envelope — the client re-renders
	// the room's subroom list from `value`, the same as subroom create/delete.
	.put(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/accessibility',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Set a subroom’s accessibility',
			description: [
				'A subroom’s own visibility, independent of the room’s top-level `Accessibility`.',
				'The client sends the `RoomAccessibility` NAME here (`accessibility=Private`) rather',
				'than the ordinal the room-level route takes, so both forms are accepted; an',
				'unrecognised value is rejected. Owner-only — only the room’s creator may change',
				'its subrooms, not co-owners.',
				'',
				'Answers the updated ROOM, not the bare subroom, so the client can re-render the',
				'room’s subroom list from `value`.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			requestBody: form(SubRoomAccessibilityRequest, 'The new accessibility'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			if (room.CreatorAccountId !== accountId) {
				return roomEnvelope(c, null, 'You are not the owner of this room!')
			}
			if (!findSubRoom(room, subRoomId)) {
				return roomEnvelope(c, null, 'This subroom does not exist!')
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const accessibility = parseAccessibility(body.accessibility)
			if (accessibility === undefined) {
				return roomEnvelope(c, null, 'You must provide a valid accessibility!')
			}

			const updated = await modifySubRoom(c.env.DB, roomId, subRoomId, { accessibility })
			if (!updated) return roomEnvelope(c, null, 'This subroom does not exist!')

			await pushRoomUpdate(c, accountId, updated)
			return roomEnvelope(c, updated)
		}
	)

	// Set a subroom's permission overrides — what each role may do in that subroom. The
	// body is a JSON ARRAY of the entries to change, keyed by (Permission, Role): `Override`
	// is the client's checkbox, so true stores the entry for that pair and false clears it
	// back to the default. The stored table then overwrites the matching defaults in
	// `GET /photon_access_token`. Auth-gated (401) and creator-only (403), like the other
	// subroom mutations. Answers an EMPTY 200 — the client fires this and re-reads nothing,
	// so there is no envelope to match.
	.put(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/permissions',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Set a subroom’s permissions',
			description: [
				'Stores the permission entries a room’s creator changed for one subroom — who may',
				'save inventions, invite players, use the delete-all button, and so on. The body is a',
				'JSON ARRAY; each entry is addressed by its (`Permission`, `Role`) pair, so re-sending',
				'a pair overwrites the stored entry rather than adding a second, and pairs that were',
				'never sent are left alone.',
				'',
				'`Override` is the checkbox the client draws beside each permission, not data:',
				'`true` stores `Value` for that pair, and `false` means “fall back to the default”, so',
				'it DELETES any stored entry. Nothing is stored with `Override: false`, and reads',
				'always serve `true`. `Value` is a string — usually `True`/`False`, but it is kept',
				'verbatim, since not every permission’s UI is a True/False picker.',
				'',
				'What this feeds is `GET /photon_access_token`: a stored entry replaces the default',
				'with the same (`Permission`, `Role`) in the table the client applies when it spawns,',
				'and one naming a pair the defaults don’t carry (e.g. `CAN_INVITE`) is added to it.',
				'The overrides apply to the subroom the caller is standing in, resolved from presence.',
				'',
				'Creator-only — co-owners may build in a room but not decide what a role may do.',
				'The response body is EMPTY: the client doesn’t read one.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			requestBody: jsonBody(SubRoomPermissionsRequest, 'The permission entries to set'),
			responses: {
				200: { description: 'Stored (empty body)' },
				401: UNAUTHORIZED_RESPONSE,
				403: FORBIDDEN_RESPONSE,
				404: { description: 'No such room or subroom' },
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) return unauthorized(c)

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			// Scoped through the room so a subroom id from another room can't be written.
			const room = await getRoomById(c.env.DB, roomId)
			if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
			if (room.CreatorAccountId !== accountId) return c.body(null, 403)

			const permissions = parseRoomPermissions(await c.req.json().catch(() => null))
			await setSubRoomPermissions(c.env.DB, subRoomId, permissions)
			return c.body(null, 200)
		}
	)

	// Clone a subroom into a new subroom of the same room (fresh SubRoomId, same
	// scene/settings/data). Auth-gated (401) and owner-only. Notifies the owner and
	// returns the updated ROOM in the `{ success, error, value }` envelope — NOT the new
	// subroom, even though the new subroom is what the call produces. The client
	// re-renders the room's subroom list from `value`, the same as subroom
	// create/delete/accessibility.
	.post(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/clone',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Clone a subroom',
			description: [
				'Copies a subroom into a new subroom of the SAME room — same scene, settings and saved',
				'data blobs, so it loads identical content — with a fresh globally-unique `SubRoomId`.',
				'Owner-only.',
				'',
				'Answers the updated ROOM, not the new subroom — the client re-renders the room’s',
				'subroom list from `value`. Unlike the room-level `/clone`, whose `value` IS the new',
				'room, the thing this call creates is not what comes back.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			if (room.CreatorAccountId !== accountId) {
				return roomEnvelope(c, null, 'You are not the owner of this room!')
			}

			const result = await cloneSubRoom(c.env.DB, roomId, subRoomId, accountId)
			if (!result) return roomEnvelope(c, null, 'This subroom does not exist!')

			await pushRoomUpdate(c, accountId, result.room)
			return roomEnvelope(c, result.room)
		}
	)

	// Create a new (empty) subroom in a room (form body `name`). Auth-gated (401) and
	// owner-only. Mints a fresh globally-unique SubRoomId, bases the scene/capacity on the
	// room's first subroom, notifies the owner (RoomUpdate), and returns the updated ROOM
	// in the `{ success, error, value }` envelope (the client re-renders the room's subroom
	// list from `value`, so it's the whole room, not the bare subroom).
	.post(
		'/rooms/:roomId{[0-9]+}/subrooms',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Create a subroom',
			description: [
				'Adds an empty subroom to a room. Owner-only. It mints a fresh globally-unique',
				'`SubRoomId` (the game numbers subrooms from one sequence, not per room) and inherits',
				'the scene and capacity of the room’s first existing subroom.',
				'',
				'Answers the updated ROOM, not the bare subroom — the client re-renders the room’s',
				'subroom list from `value`. Delete answers the same shape.',
			].join('\n'),
			security: AUTHED,
			parameters: [roomIdParam],
			requestBody: form(CreateSubRoomRequest, 'The new subroom’s name'),
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			if (room.CreatorAccountId !== accountId) {
				return roomEnvelope(c, null, 'You are not the owner of this room!')
			}

			const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
			const name = typeof body.name === 'string' ? body.name.trim() : ''
			if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
			const badName = roomNameRejection(name, 'subroom name')
			if (badName !== null) return roomEnvelope(c, null, badName)

			const result = await createSubRoom(c.env.DB, roomId, accountId, name)
			if (!result) return roomEnvelope(c, null, 'This room does not exist!')

			await pushRoomUpdate(c, accountId, result.room)
			return roomEnvelope(c, result.room)
		}
	)

	// Delete a subroom from a room. Auth-gated (401) and owner-only. Refuses to remove a
	// room's only subroom. Notifies the owner (RoomUpdate) and returns the updated ROOM in
	// the `{ success, error, value }` envelope (same shape as create, so the client
	// re-renders the subroom list from `value`).
	.delete(
		'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}',
		describeRoute({
			tags: ['Subrooms'],
			summary: 'Delete a subroom',
			description: [
				'Removes a subroom from a room. Owner-only, and it refuses to remove a room’s only',
				'subroom — that would leave the room with no scene to load. Any saved-data blob the',
				'subroom pointed at is left in R2, the same way deleting a room leaves the photos',
				'taken in it. Answers the updated ROOM, like create.',
			].join(' '),
			security: AUTHED,
			parameters: [roomIdParam, subRoomIdParam],
			responses: {
				200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
				401: UNAUTHORIZED_ENVELOPE,
			},
		}),
		async (c) => {
			const accountId = await authedAccountId(c)
			if (accountId === null) {
				return c.json({ success: false, error: 'Unauthorized', value: null }, 401)
			}

			const roomId = Number.parseInt(c.req.param('roomId'), 10)
			const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)

			const room = await getRoomById(c.env.DB, roomId)
			if (!room) return roomEnvelope(c, null, 'This room does not exist!')
			if (room.CreatorAccountId !== accountId) {
				return roomEnvelope(c, null, 'You are not the owner of this room!')
			}

			const result = await deleteSubRoom(c.env.DB, roomId, subRoomId)
			if (!result.ok) {
				return roomEnvelope(
					c,
					null,
					result.reason === 'last_subroom'
						? "You can't delete a room's only subroom!"
						: 'This subroom does not exist!'
				)
			}

			await pushRoomUpdate(c, accountId, result.room)
			return roomEnvelope(c, result.room)
		}
	)

	// Rooms similar to the given room (sharing tags). Paginated via skip/take (take
	// defaults to 100). Returns `{ Results, TotalResults }`; empty when the room is
	// unknown/untagged.
	.get(
		'/rooms/:roomId{[0-9]+}/similar',
		describeRoute({
			tags: ['Discovery'],
			summary: 'Rooms similar to a room',
			description: [
				'Rooms sharing tags with the given one — the “more like this” rail. Empty when the',
				'room is unknown or carries no tags.',
			].join(' '),
			parameters: [roomIdParam, ...pageParams(100)],
			responses: { 200: json(PagedRooms, 'The similar rooms') },
		}),
		async (c) => {
			const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
			const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
			return c.json(
				await getSimilarRooms(c.env.DB, Number.parseInt(c.req.param('roomId'), 10), skip, take)
			)
		}
	)

	// The caller's per-room player data. Stub → empty blob (client reads `Data`).
	.get(
		'/rooms/:roomId{[0-9]+}/playerdata/me',
		describeRoute({
			tags: ['Rooms'],
			summary: 'The caller’s per-room player data',
			description: [
				'Per-room save data for the calling player. Nothing stores any yet, so this is a stub',
				'serving an empty blob — which the client reads as “no saved data”. No auth: there’s',
				'no caller-specific state to protect until something writes here.',
			].join(' '),
			parameters: [roomIdParam],
			responses: { 200: json(PlayerDataDto, 'An empty data blob') },
		}),
		(c) => c.json({ Data: '' })
	)

	// A room's XP settings — whether players earn experience there and how much of it counts
	// toward their day. Fixed values, the same for every room: progression lives in the `api`
	// worker and applies no per-room daily cap, so there is nothing room-scoped to read and
	// nothing here enforces the number. It is what the client displays and meters against.
	//
	// A bare two-key object, no `{ success, error, value }` envelope, and no auth — nothing
	// in the answer is per-player (`experience/player` below is the per-player half). The
	// room isn't looked up either: the answer would be the same for a room that doesn't
	// exist, so a lookup would only add a way to fail.
	.get(
		'/rooms/:roomId{[0-9]+}/experience',
		describeRoute({
			tags: ['Rooms'],
			summary: 'A room’s XP settings',
			description: [
				'Whether players earn XP in the room (`Enabled`) and how much of it counts toward a',
				'day (`DailyLimit`), as a bare two-key object. Fixed values, and `Enabled` is FALSE —',
				'no room awards XP here. Progression is the `api` worker’s and applies no per-room',
				'cap, so nothing is stored per room and nothing enforces the limit; the client is what',
				'reads it. No auth: the answer is the same for every caller and every room.',
			].join(' '),
			parameters: [roomIdParam],
			responses: { 200: json(RoomExperience, 'The room’s XP settings — always the same') },
		}),
		(c) => c.json({ Enabled: ROOM_XP_ENABLED, DailyLimit: ROOM_XP_DAILY_LIMIT })
	)

	// The caller's per-room experience/progression. Stub → empty list.
	.get(
		'/rooms/:roomId{[0-9]+}/experience/player',
		describeRoute({
			tags: ['Rooms'],
			summary: 'The caller’s per-room experience',
			description: [
				'Per-room experience/progression for the calling player. Nothing tracks any yet, so',
				'this is an empty list — which the client reads as “no progress in this room”, where',
				'a 404 would stall the room load. No auth, matching `playerdata/me`: the answer is',
				'the same for every caller until something writes here.',
			].join(' '),
			parameters: [roomIdParam],
			responses: { 200: json(RoomExperiencePlayer, 'An empty list') },
		}),
		(c) => c.json([])
	)

	// Single room by id. 404 when the room isn't in D1. Ignores the
	// include/unityAsset* query params.
	.get(
		'/rooms/:roomId{[0-9]+}',
		describeRoute({
			tags: ['Rooms'],
			summary: 'A room by id',
			description: [
				'The room as stored, with its `SubRooms` re-attached. Unlike `GET /rooms?id=`, an',
				'unknown room here is a 404, not `{}`. The `include`/`unityAsset*` query params the',
				'client sends are accepted and ignored.',
			].join(' '),
			parameters: [
				roomIdParam,
				stringQuery('include', 'Accepted and ignored'),
				stringQuery('unityAssetTarget', 'Accepted and ignored'),
				stringQuery('unityAssetVersion', 'Accepted and ignored'),
			],
			responses: { 200: json(RoomDto, 'The room'), 404: { description: 'No such room' } },
		}),
		async (c) => {
			const room = await getRoomById(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
			return room ? c.json(room) : c.notFound()
		}
	)

	// The republish limits, verbatim from the reference server. Fixed values, no auth:
	// the client reads them to render its publish UI, before any room is in play.
	.get(
		'/publishState/configs',
		describeRoute({
			tags: ['Rooms'],
			summary: 'Room republish limits',
			description: [
				'The limits the client enforces around republishing a room — how many updates are',
				'allowed per rolling window, and the cooldown and expiry around them. Fixed values',
				'from the reference server; nothing here enforces them server-side yet, so this is',
				'what the client shows and gates its own UI on.',
				'',
				'Note the envelope differs from the room mutations’: `error` is null (not `""`) and',
				'there is an extra `error_id`.',
			].join(' '),
			responses: { 200: json(PublishStateConfigsEnvelope, 'The republish limits') },
		}),
		(c) =>
			c.json({
				value: {
					UpdateMaxCount: 3,
					UpdateRollingWindowInDays: 365,
					UpdateExpirationInDays: 30,
					UpdateCooldownInDays: 45,
				},
				success: true,
				error_id: null,
				error: null,
			})
	)

	// Photon access token + room permissions the client needs to spawn into a room.
	.get(
		'/photon_access_token',
		describeRoute({
			tags: ['Session'],
			summary: 'Photon token + room permissions',
			description: [
				'The permission table and Photon credentials the client needs to spawn into a room.',
				'`RoomInstanceId` is the caller’s current instance, read from the shared `presence`',
				'table (null when they’re in none).',
				'',
				'`PhotonAccessToken` is always empty: the reference server signs it with a',
				'secret/algorithm we don’t have, and our Photon setup accepts an empty token. The',
				'global (Role 0) maker pen is granted only to the hardcoded dev accounts.',
			].join('\n'),
			security: AUTHED,
			responses: {
				200: json(PhotonAccessTokenDto, 'The permissions and (empty) token'),
				401: UNAUTHORIZED_RESPONSE,
			},
		}),
		handlePhotonAccessToken
	)

// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
	'/openapi.json',
	describeRoute({ hide: true }),
	withCleanSpec(
		openAPIRouteHandler(app, {
			documentation: {
				info: {
					title: 'recflare rooms',
					version: '1.0.0',
					description: [
						'The room server for recflare, a private-server reimplementation of the Rec Room',
						'backend: room storage, the browse/search feeds, per-player cheers and favorites,',
						'the owner’s room settings, and subrooms.',
						'',
						'A room is a single JSON blob in the shared `recflare` D1, with generated columns',
						'for the queryable fields; reads serve that blob verbatim, which is why the shapes',
						'here are the client’s PascalCase ones. Subrooms live in their own table (their ids',
						'come from one global sequence, not per room) and are re-attached to each room on',
						'read. The seed rooms — including the dorm — come from `static/ImportRooms.json`.',
						'',
						'Two response envelopes appear side by side: a PascalCase',
						'`{ Success, Value, ErrorId, Error }` and a lowercase `{ success, error, value }`.',
						'Which one a route uses is dictated by the client’s deserializer for that call, so',
						'the inconsistency is deliberate. Both answer HTTP 200 even for a rejection — the',
						'client reads the flag, not the status.',
					].join('\n'),
				},
				servers: [{ url: 'https://rooms.recflare.net', description: 'Production' }],
				components: {
					securitySchemes: {
						bearerAuth: {
							type: 'http',
							scheme: 'bearer',
							bearerFormat: 'JWT',
							description: 'An `access_token` from the auth worker’s `POST /connect/token`.',
						},
					},
				},
			},
		})
	)
)

export default app
