diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 6e45472..fb45c6d 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1750,8 +1750,9 @@ const app = new Hono() '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,', - 'zeroed `Stats`, `IsRRO`/`IsDeveloperOwned`/`CloningAllowed` cleared, and PRIVATE accessibility — a new room is unpublished until its', + 'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone keeps only the source’s', + 'system tags (`Type` 1: `beta`, `limitsv2` — what the scene needs to run) and drops', + 'every other tag, and starts with zeroed `Stats`, `IsRRO`/`IsDeveloperOwned`/`CloningAllowed` 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', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 8cb26ad..f43b3d1 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1334,6 +1334,14 @@ describe('rooms endpoints', () => { `UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'), '$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24` ).run() + // And every kind of tag: an owner tag flagged as the genre (Type 0), the server's + // `rro` (Type 2), and the two system tags the client derives (Type 1). + await env.DB.batch([ + env.DB.prepare( + `INSERT OR REPLACE INTO room_tag (room_id, tag, type, is_primary_genre) + VALUES (24, 'hangout', 0, 1), (24, 'rro', 2, 0), (24, 'beta', 1, 0), (24, 'limitsv2', 1, 0)` + ), + ]) // Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801). const ok = await post(24, 'MyMakerClone') @@ -1343,9 +1351,12 @@ describe('rooms endpoints', () => { expect(ok.value!.Name).toBe('MyMakerClone') expect(ok.value!.CreatorAccountId).toBe(801) expect(ok.value!.RoomId).toBeGreaterThan(51) - // The clone starts fresh with no tags — none of the source's tags (including - // the `base` template tag) carry over. - expect(ok.value!.Tags).toEqual([]) + // Only the system tags (Type 1) carry over — the scene needs them to run. The + // owner's tags, the `base` template tag, the derived `rro` and the genre flag don't. + expect(ok.value!.Tags).toEqual([ + { Tag: 'beta', Type: 1 }, + { Tag: 'limitsv2', Type: 1 }, + ]) // IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone. expect(ok.value!.IsRRO).toBe(false) // Nor is it developer-owned, and the new owner opts it into cloning themselves. @@ -1375,7 +1386,11 @@ describe('rooms endpoints', () => { Stats: Record } expect(fetched.Name).toBe('MyMakerClone') - expect(fetched.Tags).toEqual([]) + // The system tags were written to `room_tag`, so they survive a re-read. + expect(fetched.Tags).toEqual([ + { Tag: 'beta', Type: 1 }, + { Tag: 'limitsv2', Type: 1 }, + ]) expect(fetched.IsDeveloperOwned).toBe(false) expect(fetched.CloningAllowed).toBe(false) expect(fetched.Stats.VisitorCount).toBe(0) diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 2b52668..490d59c 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -658,12 +658,14 @@ export async function deleteRoomLeaderboard( /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given - * name, and the new owner. The clone starts with an empty tag set — the source's - * tags (including the `base` template tag) do not carry over, so the owner tags the - * clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual - * "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats` start - * at zero, and it starts PRIVATE rather than inheriting the source's visibility. Returns the new room, or null when the source isn't in D1 or disallows - * cloning. + * name, and the new owner. The clone keeps only the source's SYSTEM tags (`Type` 1, + * `RoomTagType.auto` — `beta`, `limitsv2`): the client posts those to describe what the + * scene needs to run, so a copy of the scene needs them too. Every other tag (the owner's + * `Type` 0 tags, the `base` template tag, the server-derived `rro`) is dropped, so the + * owner tags the clone from scratch. `IsRRO` is cleared so the client doesn't render a + * virtual "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats` + * start at zero, and it starts PRIVATE rather than inheriting the source's visibility. + * Returns the new room, or null when the source isn't in D1 or disallows cloning. */ export async function cloneRoom( db: D1Database, @@ -691,14 +693,23 @@ export async function cloneRoom( }, ] + // The system tags (`Type` 1: `beta`, `limitsv2`) carry over — the client derives them + // from what the scene is built with and the clone is the same scene, so without them + // the copy doesn't work. Nothing else does: no owner tags, no `base`, no `rro`, and no + // primary-genre flag (an auto tag is never the genre). + const sourceTags = Array.isArray(source.Tags) ? (source.Tags as RoomTag[]) : [] + const systemTags: RoomTag[] = sourceTags + .filter((t) => t.Type === RoomTagType.auto) + .map((t) => ({ Tag: t.Tag, Type: RoomTagType.auto })) + const cloned: Room = { ...source, RoomId: newRoomId, Name: name, CreatorAccountId: accountId, IsDorm: false, - // Start fresh: drop every tag the source carried (including `base`). - Tags: [], + // Start fresh, except for the system tags the scene needs to run (see below). + Tags: systemTags, // A user clone is not a Rec Room Original — clear the inherited flag, or the // client renders a virtual "RRO" tag on the clone. IsRRO: false, @@ -722,6 +733,8 @@ export async function cloneRoom( // serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are // inserted into the subroom table below with fresh globally-unique ids. await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run() + // Tags live in `room_tag`, not the blob: the system tags need their own write. + if (systemTags.length > 0) await setRoomTags(db, newRoomId, systemTags) const sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : [] const clonedSubRooms: SubRoom[] = [] for (const sub of sourceSubRooms) {