[rooms] retain beta/limitsv2 tags after clone

This commit is contained in:
Devin Zuczek
2026-09-25 10:29:15 -04:00
parent 95e752513a
commit f72d29e088
3 changed files with 43 additions and 14 deletions
+3 -2
View File
@@ -1750,8 +1750,9 @@ const app = new Hono<App>()
'Copies a room’s content (scene, subrooms, settings) into a new room owned by the', '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', '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', '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,', 'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone keeps only the source’s',
'zeroed `Stats`, `IsRRO`/`IsDeveloperOwned`/`CloningAllowed` cleared, and PRIVATE accessibility — a new room is unpublished until its', '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.', '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', 'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
+19 -4
View File
@@ -1334,6 +1334,14 @@ describe('rooms endpoints', () => {
`UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'), `UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'),
'$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24` '$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24`
).run() ).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). // Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801).
const ok = await post(24, 'MyMakerClone') const ok = await post(24, 'MyMakerClone')
@@ -1343,9 +1351,12 @@ describe('rooms endpoints', () => {
expect(ok.value!.Name).toBe('MyMakerClone') expect(ok.value!.Name).toBe('MyMakerClone')
expect(ok.value!.CreatorAccountId).toBe(801) expect(ok.value!.CreatorAccountId).toBe(801)
expect(ok.value!.RoomId).toBeGreaterThan(51) expect(ok.value!.RoomId).toBeGreaterThan(51)
// The clone starts fresh with no tags — none of the source's tags (including // Only the system tags (Type 1) carry over — the scene needs them to run. The
// the `base` template tag) carry over. // owner's tags, the `base` template tag, the derived `rro` and the genre flag don't.
expect(ok.value!.Tags).toEqual([]) 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. // IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
expect(ok.value!.IsRRO).toBe(false) expect(ok.value!.IsRRO).toBe(false)
// Nor is it developer-owned, and the new owner opts it into cloning themselves. // Nor is it developer-owned, and the new owner opts it into cloning themselves.
@@ -1375,7 +1386,11 @@ describe('rooms endpoints', () => {
Stats: Record<string, number> Stats: Record<string, number>
} }
expect(fetched.Name).toBe('MyMakerClone') 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.IsDeveloperOwned).toBe(false)
expect(fetched.CloningAllowed).toBe(false) expect(fetched.CloningAllowed).toBe(false)
expect(fetched.Stats.VisitorCount).toBe(0) expect(fetched.Stats.VisitorCount).toBe(0)
+21 -8
View File
@@ -658,12 +658,14 @@ export async function deleteRoomLeaderboard(
/** /**
* Clone an existing room into a new one owned by `accountId`. Copies the source * 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 * 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 * name, and the new owner. The clone keeps only the source's SYSTEM tags (`Type` 1,
* tags (including the `base` template tag) do not carry over, so the owner tags the * `RoomTagType.auto` — `beta`, `limitsv2`): the client posts those to describe what the
* clone from scratch — `IsRRO` is cleared so the client doesn't render a virtual * scene needs to run, so a copy of the scene needs them too. Every other tag (the owner's
* "RRO" tag on it, `IsDeveloperOwned`/`CloningAllowed` are cleared, its `Stats` start * `Type` 0 tags, the `base` template tag, the server-derived `rro`) is dropped, so the
* 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 * owner tags the clone from scratch. `IsRRO` is cleared so the client doesn't render a
* cloning. * 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( export async function cloneRoom(
db: D1Database, 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 = { const cloned: Room = {
...source, ...source,
RoomId: newRoomId, RoomId: newRoomId,
Name: name, Name: name,
CreatorAccountId: accountId, CreatorAccountId: accountId,
IsDorm: false, IsDorm: false,
// Start fresh: drop every tag the source carried (including `base`). // Start fresh, except for the system tags the scene needs to run (see below).
Tags: [], Tags: systemTags,
// A user clone is not a Rec Room Original — clear the inherited flag, or the // A user clone is not a Rec Room Original — clear the inherited flag, or the
// client renders a virtual "RRO" tag on the clone. // client renders a virtual "RRO" tag on the clone.
IsRRO: false, IsRRO: false,
@@ -722,6 +733,8 @@ export async function cloneRoom(
// serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are // serializeRoom drops the hydrated SubRooms from the blob; the clone's subrooms are
// inserted into the subroom table below with fresh globally-unique ids. // inserted into the subroom table below with fresh globally-unique ids.
await db.prepare('INSERT INTO room (data) VALUES (?1)').bind(serializeRoom(cloned)).run() 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 sourceSubRooms = Array.isArray(source.SubRooms) ? (source.SubRooms as SubRoom[]) : []
const clonedSubRooms: SubRoom[] = [] const clonedSubRooms: SubRoom[] = []
for (const sub of sourceSubRooms) { for (const sub of sourceSubRooms) {