From da62d7138d3a18042f71d41e196cb0969a66a3ca Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 17:20:17 -0400 Subject: [PATCH] [rooms] add support for beta/limitsv2 --- apps/lists/src/openapi.ts | 3 +- apps/lists/src/test/integration/api.test.ts | 62 ++++-- apps/lists/static/curated-lists.json | 10 + .../0015_room_tag_primary_genre.sql | 19 ++ apps/rooms/src/openapi.ts | 42 +++- apps/rooms/src/rooms.app.ts | 88 ++++++-- apps/rooms/src/test/integration/api.test.ts | 175 ++++++++++++++++ packages/domain/src/lists-db.ts | 11 +- packages/domain/src/rooms-db.ts | 196 ++++++++++++++---- 9 files changed, 521 insertions(+), 85 deletions(-) create mode 100644 apps/rooms/migrations/0015_room_tag_primary_genre.sql diff --git a/apps/lists/src/openapi.ts b/apps/lists/src/openapi.ts index f393d6a..0239ef4 100644 --- a/apps/lists/src/openapi.ts +++ b/apps/lists/src/openapi.ts @@ -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 diff --git a/apps/lists/src/test/integration/api.test.ts b/apps/lists/src/test/integration/api.test.ts index c6a6dbe..ef1a63c 100644 --- a/apps/lists/src/test/integration/api.test.ts +++ b/apps/lists/src/test/integration/api.test.ts @@ -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() - 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 + const { ListId: _captured, ...expected } = capture as Record + 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( diff --git a/apps/lists/static/curated-lists.json b/apps/lists/static/curated-lists.json index c5e2fc0..72ce25d 100644 --- a/apps/lists/static/curated-lists.json +++ b/apps/lists/static/curated-lists.json @@ -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" } ] diff --git a/apps/rooms/migrations/0015_room_tag_primary_genre.sql b/apps/rooms/migrations/0015_room_tag_primary_genre.sql new file mode 100644 index 0000000..81bc8ab --- /dev/null +++ b/apps/rooms/migrations/0015_room_tag_primary_genre.sql @@ -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=` 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; diff --git a/apps/rooms/src/openapi.ts b/apps/rooms/src/openapi.ts index 2a8bd5a..df4410c 100644 --- a/apps/rooms/src/openapi.ts +++ b/apps/rooms/src/openapi.ts @@ -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 room’s 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 room’s 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`. */ diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 1f509f3..52524d1 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -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() } ) - // 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=` 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=` 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'), @@ -1726,11 +1755,34 @@ const app = new Hono() // 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 - 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 = 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) diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 380ea53..514d740 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -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>) => + 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. diff --git a/packages/domain/src/lists-db.ts b/packages/domain/src/lists-db.ts index ad5aa40..c9fe34b 100644 --- a/packages/domain/src/lists-db.ts +++ b/packages/domain/src/lists-db.ts @@ -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 diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 63f2027..ef0d835 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -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 { - 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 { + 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 { const byRoom = new Map() 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 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() 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 `?${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 { 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)