[rooms] add support for beta/limitsv2

This commit is contained in:
Devin Zuczek
2026-08-25 17:20:17 -04:00
parent 751e1f28c2
commit da62d7138d
9 changed files with 521 additions and 85 deletions
+8 -3
View File
@@ -63,10 +63,15 @@ export const CURATED_LIST_SCHEMA_DDL: string[] = [
/**
* One curated list as the client parses it, whether it came out of D1 or out of a static
* capture. `Description` may be null but `ImageName` must be a STRING — the client reads it
* straight into a string field — and `ItemIds` are strings even where they stand for
* capture. `Description` may be null, and `ItemIds` are strings even where they stand for
* numeric ids, which is what the working captures carry.
*
* `ImageName` is a STRING on every list the client draws a TILE for — it reads it straight
* into a string field, and empty or null renders that tile blank. It is nullable only
* because one capture (`RoomGenreTags`, whose `ItemIds` are genre names rather than rooms)
* is served with a null, having no tile to draw. A stored list always has a string; don't
* reach for the null on anything the client renders as a row.
*
* `ListId` is a string HERE ONLY and never reaches the client as one: the `lists` worker's
* `serializeCuratedList` puts the digits back on the wire unquoted, because the client's
* field is a number and a quoted id fails its parser. It stays a string even though a
@@ -78,7 +83,7 @@ export interface CuratedList {
CreatorAccountId: number
Name: string
Description: string | null
ImageName: string
ImageName: string | null
Type: number
ItemIds: string[]
Accessibility?: number
+160 -36
View File
@@ -63,10 +63,16 @@ export const ROOM_SCHEMA_DDL: string[] = [
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
// auto-derived ones like `rro` — echoed back as stored.
//
// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
`CREATE TABLE IF NOT EXISTS room_tag (
room_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
is_primary_genre INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
@@ -451,47 +457,150 @@ export async function setRoomRole(
}
/**
* Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so
* setting one clears any other main tag. Compared case-insensitively.
* Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
* toggling one on clears any other main tag. Compared case-insensitively.
*
* Only the TOGGLE body obeys this — the newer whole-set body says outright which tags the
* room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
*/
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
/**
* Add a user tag (`Type: 0`) to a room's tags, or remove it when it's already there
* (case-insensitive). The caller supplies the already-loaded room (owner-checked) to avoid
* a re-read. Returns the updated room.
* A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
*
* Only `room_tag` is written — the room blob no longer carries tags at all, so the room row
* is left alone. The whole resulting set is written rather than a single insert/delete, so
* the radio-button behaviour below stays one atomic batch.
* `user` is what a player types or picks. `auto` is what the client derives about the room
* and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
* A tag's category is orthogonal to whether it is the room's primary genre.
*/
export async function toggleRoomTag(
export const RoomTagType = {
user: 0,
auto: 1,
derived: 2,
} as const
/**
* The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
* they compose: a single request may replace the user tags, add a derived one and move the
* genre, and it is applied as one write.
*/
export interface RoomTagEdit {
/**
* The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
* it has it, and adding one of {@link MAIN_TAGS} clears the others.
*/
toggle?: string
/**
* The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
* derived tags (`auto`, `derived`) are not the client's to send and are left alone.
*/
tags?: string[]
/**
* Tags to ensure present at `Type: 1`. Additive — nothing here removes an auto tag,
* since the client posts the ones it wants rather than the full set. A tag already on
* the room is re-categorised rather than duplicated.
*/
autoTags?: string[]
/**
* The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
* every other tag keeps its place and loses the flag.
*/
primaryGenre?: string
}
/** A tag's name, lowercased — every comparison in here is case-insensitive. */
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()
/**
* Apply one request's worth of tag changes to a room and store the result. The caller
* supplies the already-loaded (owner-checked) room, so nothing is re-read.
*
* The changes are composed into ONE set and written once: a request naming tags, an auto
* tag and a genre is a single state for the room, and applying it in three writes would
* let a reader (or a failure) land between them.
*
* Only `room_tag` is written — the room blob carries no tags at all, so the room row is
* left alone.
*/
export async function applyRoomTagEdit(
db: D1Database,
roomId: number,
room: Room,
tag: string
edit: RoomTagEdit
): Promise<Room> {
const tags = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
const lower = tag.toLowerCase()
const tagLower = (t: RoomTag): string => String(t?.Tag).toLowerCase()
const existing = tags.findIndex((t) => tagLower(t) === lower)
const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
// Copies throughout: the room handed in is answered to the client, and the steps below
// mutate what they build.
let next: RoomTag[] = current.map((t) => ({ ...t }))
// The client has no delete/patch endpoint — the same call toggles a tag: remove
// it if already present, add it otherwise. Adding a main tag is a radio pick, so
// it also clears any other main tag already set.
let nextTags: RoomTag[]
if (existing !== -1) {
nextTags = tags.filter((_, i) => i !== existing)
} else if (MAIN_TAGS.has(lower)) {
nextTags = [...tags.filter((t) => !MAIN_TAGS.has(tagLower(t))), { Tag: tag, Type: 0 }]
} else {
nextTags = [...tags, { Tag: tag, Type: 0 }]
if (edit.tags !== undefined) {
// A SET, not a merge. The posted list is exactly the room's user tags afterwards; a
// tag already there keeps its row (and its genre flag, until the genre step below
// says otherwise) rather than being deleted and re-added.
const posted = new Set(edit.tags.map((t) => t.toLowerCase()))
next = [
...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))),
...edit.tags.map(
(tag) =>
next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user }
),
]
} else if (edit.toggle !== undefined) {
// The 2023 client has no delete/patch endpoint, so the same call toggles: remove the
// tag if present, add it otherwise. Adding a main tag is a radio pick, so it also
// clears any other main tag. Removing the flagged tag takes the genre with it, which
// is right — the room's genre WAS that tag.
const lower = edit.toggle.toLowerCase()
const existing = next.findIndex((t) => tagKey(t) === lower)
if (existing !== -1) {
next = next.filter((_, i) => i !== existing)
} else {
const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next
next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }]
}
}
await setRoomTags(db, roomId, nextTags)
// Reflect what was just stored, lowercased the way the table holds it, so the caller
// answers the client with the tags a re-read would give it.
return { ...room, Tags: nextTags.map((t) => ({ Tag: t.Tag.toLowerCase(), Type: t.Type })) }
for (const auto of edit.autoTags ?? []) {
const existing = next.find((t) => tagKey(t) === auto.toLowerCase())
// A tag the room already carries is re-categorised in place rather than duplicated —
// `tag` is the table's key, so there is only ever one row per name anyway.
if (existing) existing.Type = RoomTagType.auto
else next.push({ Tag: auto, Type: RoomTagType.auto })
}
if (edit.primaryGenre !== undefined) {
const lower = edit.primaryGenre.toLowerCase()
for (const tag of next) delete tag.IsPrimaryGenre
const chosen = next.find((t) => tagKey(t) === lower)
// A tag the room already carries keeps its category and simply becomes the genre;
// one it doesn't is added as an ordinary user tag.
if (chosen) chosen.IsPrimaryGenre = true
else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true })
}
return storeRoomTags(db, roomId, room, next)
}
/**
* Write a room's whole tag set and answer the room carrying it, lowercased the way the
* table holds it — so the caller replies with exactly what a re-read would give, without
* paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays
* absent (not false) everywhere else.
*/
async function storeRoomTags(
db: D1Database,
roomId: number,
room: Room,
tags: RoomTag[]
): Promise<Room> {
await setRoomTags(db, roomId, tags)
return {
...room,
Tags: tags.map((t) => {
const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type }
if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true
return stored
}),
}
}
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
@@ -1136,16 +1245,31 @@ async function attachCurrentSaves(
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
// parses every room to ask.
/** One of a room's tags, as the client's room DTO carries it. */
/**
* One of a room's tags, as the client's room DTO carries it.
*
* `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre — the key is
* left off the others rather than sent as false, which is the shape the client sends and
* reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}.
*/
export interface RoomTag {
Tag: string
Type: number
IsPrimaryGenre?: boolean
}
interface RoomTagRow {
room_id: number
tag: string
type: number
is_primary_genre: number
}
/** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */
function toRoomTag(row: RoomTagRow): RoomTag {
const tag: RoomTag = { Tag: row.tag, Type: row.type }
if (row.is_primary_genre) tag.IsPrimaryGenre = true
return tag
}
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
@@ -1153,7 +1277,7 @@ function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
const byRoom = new Map<number, RoomTag[]>()
for (const row of rows) {
const list = byRoom.get(row.room_id) ?? []
list.push({ Tag: row.tag, Type: row.type })
list.push(toRoomTag(row))
byRoom.set(row.room_id, list)
}
return byRoom
@@ -1183,7 +1307,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
if (ids.length > MAX_BOUND_PARAMS) {
const { results } = await db
.prepare('SELECT room_id, tag, type FROM room_tag ORDER BY tag')
.prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag')
.all<RoomTagRow>()
const wanted = new Set(ids)
return groupTags(results.filter((row) => wanted.has(row.room_id)))
@@ -1192,7 +1316,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(
`SELECT room_id, tag, type FROM room_tag
`SELECT room_id, tag, type, is_primary_genre FROM room_tag
WHERE room_id IN (${placeholders}) ORDER BY tag`
)
.bind(...ids)
@@ -1234,14 +1358,14 @@ async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]
*/
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
for (const { Tag, Type } of tags) {
for (const { Tag, Type, IsPrimaryGenre } of tags) {
statements.push(
db
.prepare(
`INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, ?3)
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3`
`INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4`
)
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0)
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0)
)
}
await db.batch(statements)