[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
+2 -1
View File
@@ -200,8 +200,9 @@ const CuratedListFields = {
Description: z.string().nullable(),
ImageName: z
.string()
.nullable()
.describe(
'Must be a STRING — the client reads it straight into a string field. `DefaultRoomImage.jpg` where nothing set one; empty or null renders a blank tile.'
'A STRING on any list the client draws a tile for — it reads this straight into a string field, and empty or null renders that tile blank. `DefaultRoomImage.jpg` where nothing set one. Null only on a list with no tile to draw, like the `RoomGenreTags` capture, whose items are genre names rather than rooms.'
),
Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'),
ItemIds: z
+39 -23
View File
@@ -9,6 +9,8 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import curatedLists from '../../../static/curated-lists.json'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -240,40 +242,54 @@ it('serves one curated list object, not a collection', async () => {
})
it('serves every capture in static/curated-lists.json by name', async () => {
// One array holds every list, and each entry must be reachable by the keys the client
// asks with. `ImageName` must be a string even when empty — the client parses it into
// one — while `Description` may be null. The ids the client caches against have to be
// unique and reach it with their digits intact.
const names = [
'Discovery.PageSource.PlayExplore',
'Discovery.PageSource.PlayLibrary',
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
]
// Driven by the file itself, so a capture added to it is a capture this covers: each
// entry must be reachable by the three keys the client asks with, and come back as
// itself. The ids the client caches against have to be unique and reach it with their
// digits intact.
expect(curatedLists.length).toBeGreaterThan(0)
const seen = new Set<string>()
for (const name of names) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
for (const capture of curatedLists) {
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=${capture.CreatorAccountId}` +
`&type=${capture.Type}&name=${capture.Name}`
)
expect(res.status).toBe(200)
const body = await res.text()
// Never a quoted id: the client's field is a number.
expect(body).toMatch(/"ListId":\d+,/)
const list = JSON.parse(body) as {
ListId: number
Type: number
Name: string
ItemIds: string[]
Description: string | null
ImageName: string
}
expect(list.Name).toBe(name)
expect(list.Type).toBe(7)
expect(list.ItemIds.length).toBeGreaterThan(0)
expect(typeof list.ImageName).toBe('string')
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
const { ListId: _captured, ...expected } = capture as Record<string, unknown>
expect(rest).toEqual(expected)
expect((capture.ItemIds as string[]).length).toBeGreaterThan(0)
const id = /"ListId":(\d+),/.exec(body)?.[1]
expect(id).toBe(capture.ListId)
expect(seen.has(id!)).toBe(false)
seen.add(id!)
}
})
it('serves the RoomGenreTags capture with its tag names', async () => {
// Genre NAMES, not room or section ids — and the one capture with a null `ImageName`,
// since the client draws no tile for it. Served under type 5, where the other captures
// are type 7.
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags`
)
expect(res.status).toBe(200)
const body = await res.text()
expect(body).toContain('"ListId":1')
expect(JSON.parse(body)).toEqual({
ListId: 1,
CreatorAccountId: 1,
Name: 'RoomGenreTags',
Description: '',
ImageName: null,
Type: 5,
ItemIds: ['quest', 'battle', 'roleplay', 'horror', 'hangout', 'casual', 'explore'],
CreatedAt: '2026-01-01T00:00:00Z',
})
})
it('matches the name case-insensitively and prefers it over the type', async () => {
const canonical = await (
await SELF.fetch(
+10
View File
@@ -54,5 +54,15 @@
],
"Accessibility": 1,
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
},
{
"ListId": "1",
"CreatorAccountId": 1,
"Name": "RoomGenreTags",
"Description": "",
"ImageName": null,
"Type": 5,
"ItemIds": ["quest", "battle", "roleplay", "horror", "hangout", "casual", "explore"],
"CreatedAt": "2026-01-01T00:00:00Z"
}
]
@@ -0,0 +1,19 @@
-- Which of a room's tags is its PRIMARY GENRE.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- The 2023 client picked a genre by toggling one of five "main" tags
-- (`pvp`/`quest`/`game`/`hangout`/`art`) as radio buttons, which the tag row alone could
-- express: whichever of the five was present was the genre. The 2025 client instead posts
-- `primaryGenreTag=<tag>` to `PUT /rooms/{id}/tags` over a genre vocabulary it reads from
-- the `RoomGenreTags` curated list, and it renders the chosen one differently from the
-- room's other tags — so "is this tag the genre" is now a fact about the ROW, separate
-- from whether the tag is there at all. A room can carry `puzzle` and `social` as ordinary
-- tags with only `social` flagged.
--
-- A real column rather than a `type` value: `type` is the client's tag-CATEGORY int
-- (0 user, 1 beta, 2 auto-derived like `rro`) which is echoed back as stored, and the
-- primary genre is orthogonal to it — the flagged tag is still a plain Type 0 user tag.
--
-- Defaults to 0, so every existing row reads as "not the genre", which is what they were.
ALTER TABLE room_tag ADD COLUMN is_primary_genre INTEGER NOT NULL DEFAULT 0;
+38 -4
View File
@@ -132,10 +132,20 @@ export const RoomRoleDto = z.object({
InvitedRole: z.int(),
})
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
/**
* A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`).
*
* `IsPrimaryGenre` marks the one tag that is the room's genre, and is PRESENT ONLY on
* that tag — the key is absent on the others rather than sent as false. It is orthogonal
* to `Type`: the flagged tag is an ordinary owner-set tag that happens to be the genre.
*/
export const RoomTagDto = z.object({
Tag: z.string(),
Type: z.int().describe('0 = owner-set, 2 = auto'),
Type: z.int().describe('0 = owner-set, 1 = client-derived (`autoTag`), 2 = server-derived'),
IsPrimaryGenre: z
.literal(true)
.optional()
.describe('Present only on the rooms primary genre tag; absent, never false, on the rest'),
})
/**
@@ -524,9 +534,33 @@ export const NameRequest = z.object({
name: z.string().describe('Non-empty, and not already taken by another room'),
})
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
/**
* `PUT /rooms/{roomId}/tags` — one route, two bodies, told apart by their FIELDS.
*
* A lone `tag` is the 2023 toggle: added when absent, removed when present. A `tag`
* alongside anything else is part of a whole-state save, where nothing toggles — `tag`
* repeats and is the complete user-tag set, `autoTag` adds a derived (Type 1) tag, and
* `primaryGenreTag` flags the genre. They compose into one write.
*/
export const TagRequest = z.object({
tag: z.string().describe('Added when absent, removed when present'),
tag: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
'Alone: toggled (added when absent, removed when present). Alongside any other field, or repeated: the COMPLETE set of user (Type 0) tags — an omitted one is removed'
),
autoTag: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
'A derived tag to add at Type 1 (`limitsv2`, `beta`). Repeatable and additive — never removes one'
),
primaryGenreTag: z
.string()
.optional()
.describe(
'Set as the rooms primary genre. Added as a Type 0 tag if the room lacks it; every other tag keeps its place and loses the flag'
),
})
/** `PUT /rooms/{roomId}/image`. */
+70 -18
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
applyRoomTagEdit,
areFriends,
autocompleteRoomSearch,
banPlayerFromRoom,
@@ -51,7 +52,6 @@ import {
setSubRoomPermissions,
toggleCheer,
toggleFavorite,
toggleRoomTag,
unbanPlayerFromRoom,
updateRoomFields,
} from '@repo/domain'
@@ -1687,25 +1687,54 @@ const app = new Hono<App>()
}
)
// Toggle a tag on a room. Auth-gated (401) and owner/co-owner-only (403). Body is
// the `tag` form field. There's no delete/patch endpoint, so this call toggles: it
// adds the tag (Type 0) if absent and removes it if present. The "main" tags
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
// others. Returns the `{ success, error, value }` envelope with the updated
// room as `value`; business failures are 200 with success:false.
// 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). There is no delete/patch counterpart, so',
'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when',
'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
'buttons — setting one clears the others. 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(' '),
'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 clients 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 rooms 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'),
@@ -1726,11 +1755,34 @@ const app = new Hono<App>()
// 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 tag = typeof body.tag === 'string' ? body.tag.trim() : ''
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
// `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 updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
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)
+175
View File
@@ -2584,6 +2584,181 @@ describe('rooms endpoints', () => {
expect(tagsIn(byCoOwner)).toContain('spooky')
})
it('PUT /rooms/:id/tags sets the primary genre from primaryGenreTag', async () => {
type TagResult = {
success: boolean
error: string
value: { Tags?: Array<{ Tag: string; Type: number; IsPrimaryGenre?: boolean }> } | null
}
const tags = async (res: Response) => ((await res.json()) as TagResult).value?.Tags ?? []
const genre = (list: Awaited<ReturnType<typeof tags>>) =>
list.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)
// Same gates as the toggle body — the second shape doesn't open a second door.
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' })).status).toBe(401)
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '999')).status).toBe(403)
// A blank value is a malformed post, not "unset the genre".
expect(
await (await putForm('/rooms/4/tags', { primaryGenreTag: ' ' }, '1')).json()
).toMatchObject({ success: false, error: 'You must provide a tag!' })
// Room 4 is seeded with the auto-derived `rro` (Type 2); add an ordinary tag too, so
// the genre can be seen not to disturb either of them.
await putForm('/rooms/4/tags', { tag: 'puzzle' }, '1')
// The genre tag is ADDED when the room lacks it — a Type 0 tag, flagged.
const set = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1'))
expect(set).toContainEqual({ Tag: 'social', Type: 0, IsPrimaryGenre: true })
// …and the key is absent on the others rather than false.
expect(set).toContainEqual({ Tag: 'puzzle', Type: 0 })
// Choosing another genre MOVES the flag. Unlike the old five-way radio it does not
// remove the tag it displaced: `social` stays on the room, just no longer the genre.
const moved = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'horror' }, '1'))
expect(genre(moved)).toEqual(['horror'])
expect(moved.map((t) => t.Tag).sort()).toEqual(['horror', 'puzzle', 'rro', 'social'])
// A tag the room already carries keeps its Type and simply becomes the genre — the
// seeded `rro` is Type 2 and stays Type 2.
const promoted = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'rro' }, '1'))
expect(promoted).toContainEqual({ Tag: 'rro', Type: 2, IsPrimaryGenre: true })
expect(genre(promoted)).toEqual(['rro'])
// Stored on the row, so a cold read says the same thing.
expect(
await env.DB.prepare(
'SELECT tag, is_primary_genre FROM room_tag WHERE room_id = 4 AND is_primary_genre = 1'
).all<{ tag: string; is_primary_genre: number }>()
).toMatchObject({ results: [{ tag: 'rro', is_primary_genre: 1 }] })
const read = (await (await SELF.fetch(`${ORIGIN}/rooms/4`)).json()) as {
Tags: Array<{ Tag: string; IsPrimaryGenre?: boolean }>
}
expect(read.Tags.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)).toEqual(['rro'])
// The toggle body still works on the same room, and toggling the flagged tag off
// takes the genre with it — the room's genre WAS that tag.
const toggledOff = await tags(await putForm('/rooms/4/tags', { tag: 'rro' }, '1'))
expect(toggledOff.map((t) => t.Tag)).not.toContain('rro')
expect(genre(toggledOff)).toEqual([])
// …while toggling an unrelated tag leaves a genre alone.
await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1')
const other = await tags(await putForm('/rooms/4/tags', { tag: 'campfire' }, '1'))
expect(genre(other)).toEqual(['social'])
// A `tag` alongside the genre is a whole-state save, NOT a toggle: `campfire` stays
// rather than being toggled back off. (The set semantics themselves are next.)
const both = await tags(
await putForm('/rooms/4/tags', { tag: 'campfire', primaryGenreTag: 'puzzle' }, '1')
)
expect(genre(both)).toEqual(['puzzle'])
expect(both.map((t) => t.Tag)).toContain('campfire')
// Put room 4 back the way it was seeded — its `rro` tag is what the rro feeds count.
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = 4'),
env.DB.prepare(
"INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (4, 'rro', 2, 0)"
),
])
})
it('PUT /rooms/:id/tags takes the whole-state save: repeated tag, autoTag, genre', async () => {
type Tag = { Tag: string; Type: number; IsPrimaryGenre?: boolean }
// A room of this test's own: the whole-state save REPLACES the user tags, and doing
// that to a seeded room would strip tags the feeds above are asserted on.
const ROOM = 9700
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: ROOM,
Name: 'TagSaveRoom',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
SubRooms: [],
})
)
.run()
const save = async (query: string, sub = '1') => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: query,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; value: { Tags?: Tag[] } | null }
expect(body.success).toBe(true)
return [...(body.value?.Tags ?? [])].sort((a, b) => a.Tag.localeCompare(b.Tag))
}
// The form room settings posts. `tag` repeats — without `all: true` on the parse only
// the last would arrive, and a three-tag save would land as one.
const saved = await save(
'autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay'
)
expect(saved).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
{ Tag: 'social', Type: 0 },
{ Tag: 'sports', Type: 0 },
])
// Idempotent — the same save twice is the same room. This is why a lone `tag` toggles
// but a `tag` in company does not: toggling here would clear the room on every save.
expect(
await save('autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay')
).toEqual(saved)
// `tag` is the COMPLETE user set: dropping one removes it. The auto tag is NOT the
// client's to send here and survives a save that never mentions it.
expect(await save('tag=roleplay&primaryGenreTag=roleplay')).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
])
// autoTag is additive and repeatable, and re-categorises a tag the room already has
// rather than duplicating it — `tag` is the table's key, so there is one row per name.
expect(await save('autoTag=beta&autoTag=roleplay')).toEqual([
{ Tag: 'beta', Type: 1 },
{ Tag: 'limitsv2', Type: 1 },
// Was a Type 0 user tag; posting it as an auto tag moves its category, and the
// genre flag rides along with the row.
{ Tag: 'roleplay', Type: 1, IsPrimaryGenre: true },
])
// An autoTag alone is a valid request — it names no `tag`, and must not be refused
// for it.
const bare = await save('autoTag=limitsv2')
expect(bare.map((t) => t.Tag)).toContain('limitsv2')
// A save that names no user tags at all clears them, leaving the derived ones.
expect((await save('tag=&autoTag=limitsv2')).map((t) => t.Tag)).toEqual([
'beta',
'limitsv2',
'roleplay',
])
// Same gates as every other body.
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'autoTag=limitsv2',
})
).status
).toBe(401)
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(ROOM),
env.DB.prepare('DELETE FROM room WHERE room_id = ?1').bind(ROOM),
])
})
// Tags live in `room_tag`, not in the room blob (migration 0013). These pin the
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
// from it on read.
+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)