diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index b93b235..2524b9c 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -1719,7 +1719,7 @@ const app = new Hono() '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,', - '`IsRRO` cleared, and PRIVATE accessibility — a new room is unpublished until its', + '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 6301eac..5802d23 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -1309,11 +1309,20 @@ describe('rooms endpoints', () => { CreatorAccountId: number Tags?: Array<{ Tag: string }> IsRRO: boolean + IsDeveloperOwned: boolean + CloningAllowed: boolean Accessibility: number Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }> + Stats: Record } | null } + // Give the source every flag and counter the clone must NOT inherit. + await env.DB.prepare( + `UPDATE room SET data = json_set(data, '$.IsDeveloperOwned', json('true'), + '$.CloningAllowed', json('true'), '$.Stats.VisitorCount', 42) WHERE room_id = 24` + ).run() + // Clone MakerRoom (base, RoomId 24) → a fresh room owned by the caller (801). const ok = await post(24, 'MyMakerClone') expect(ok.success).toBe(true) @@ -1327,6 +1336,16 @@ describe('rooms endpoints', () => { expect(ok.value!.Tags).toEqual([]) // 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. + expect(ok.value!.IsDeveloperOwned).toBe(false) + expect(ok.value!.CloningAllowed).toBe(false) + // No engagement carries over — not even the blob-stored VisitorCount. + expect(ok.value!.Stats).toEqual({ + CheerCount: 0, + FavoriteCount: 0, + VisitorCount: 0, + VisitCount: 0, + }) // A new room is unpublished: Private (0), never the source's visibility. expect(ok.value!.Accessibility).toBe(0) // Ownership is reset to the cloner: sole owner (Role 255), and none of the @@ -1338,8 +1357,16 @@ describe('rooms endpoints', () => { // It persists and is fetchable by its new id. const fetched = (await (await SELF.fetch(`${ORIGIN}/rooms/${ok.value!.RoomId}`)).json()) as { Name: string + Tags: unknown[] + IsDeveloperOwned: boolean + CloningAllowed: boolean + Stats: Record } expect(fetched.Name).toBe('MyMakerClone') + expect(fetched.Tags).toEqual([]) + expect(fetched.IsDeveloperOwned).toBe(false) + expect(fetched.CloningAllowed).toBe(false) + expect(fetched.Stats.VisitorCount).toBe(0) // Duplicate name is rejected. const dup = await post(24, 'MyMakerClone') diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index 3707321..53f6923 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -661,8 +661,8 @@ export async function deleteRoomLeaderboard( * 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, 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 + * "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( @@ -702,15 +702,20 @@ export async function cloneRoom( // 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, + // Neither is the cloner a developer, and a copy doesn't inherit the source's consent + // to be copied: the new owner opts their room into cloning themselves. + IsDeveloperOwned: false, + CloningAllowed: false, // A brand-new room is unpublished: the owner publishes it by setting the room's // accessibility. Inheriting the source's would put the clone straight into the // public feeds (hot/search/recommendations/similar all key on Accessibility === 1) // the moment it was made — every clone of a PUBLIC source, template or player room. Accessibility: Accessibility.Private, Roles: roles, - // A fresh room has no engagement of its own — don't inherit the source's counters - // (the derived ones are recomputed per read, but the clone is returned as-is here). - Stats: storedStats(source.Stats), + // A fresh room has no engagement of its own — don't inherit any of the source's + // counters. `storedStats` alone would keep the blob-owned `VisitorCount`, which + // `hotScore` ranks on. + Stats: { ...ZERO_STATS }, CreatedAt: new Date().toISOString(), }