From 9902de39a4bb85c212abf72848be695304d69df0 Mon Sep 17 00:00:00 2001
From: Devin Zuczek
Date: Wed, 14 Oct 2026 10:42:36 -0400
Subject: [PATCH] [www] reset dorm button for 2023
---
apps/www/src/client/App.tsx | 44 ++++++++++++
apps/www/src/staff.ts | 20 ++++++
apps/www/src/test/integration/api.test.ts | 84 ++++++++++++++++++++++-
apps/www/src/www.app.ts | 3 +
packages/domain/src/rooms-db.ts | 58 ++++++++++++++++
5 files changed, 208 insertions(+), 1 deletion(-)
diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 146bdba..96a0526 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -1397,6 +1397,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
// One message for every box sent from this card; it stays put between sends.
const [message, setMessage] = useState('')
const [confirmingClear, setConfirmingClear] = useState(false)
+ const [confirmingDorm, setConfirmingDorm] = useState(false)
const [itemId, setItemId] = useState('')
const [xp, setXp] = useState('')
const gift = useAction()
@@ -1404,6 +1405,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
const itemGift = useAction()
const usernameChange = useAction()
const clearPassword = useAction()
+ const clearDorm = useAction()
const grantPlus = useAction()
const base = `/api/staff/players/${account.accountId}`
@@ -1626,6 +1628,41 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
Clear password
)}
+ {confirmingDorm ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ Clear dorm: Empties the player's dorm so it loads in the 2023 client
+ again — a dorm saved in a newer client won't. It publishes a new, empty save; the old
+ one stays in the dorm's save history and can be restored.
+
{usernameChange.error &&
{usernameChange.error}
}
{usernameChange.done &&
{usernameChange.done}
}
{clearPassword.error &&
{clearPassword.error}
}
{clearPassword.done &&
{clearPassword.done}
}
+ {clearDorm.error &&
{clearDorm.error}
}
+ {clearDorm.done &&
{clearDorm.done}
}
{grantPlus.error &&
{grantPlus.error}
}
{grantPlus.done &&
{grantPlus.done}
}
diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts
index 6382712..84d07bb 100644
--- a/apps/www/src/staff.ts
+++ b/apps/www/src/staff.ts
@@ -1,6 +1,7 @@
import {
addUsernameChange,
addXp,
+ clearDormSaveBlob,
clearPasswordHash,
createGift,
getAccount,
@@ -1532,6 +1533,25 @@ export async function clearPasswordHandler(c: Context) {
return c.json({ playerId, hadPassword })
}
+/**
+ * Blank the scene on a player's dorm, for a player going back to the 2023 client: a dorm
+ * saved by a newer one won't load there. It publishes a new, empty save rather than erasing
+ * anything, so `previousSaveId` is the save to restore if they want the old dorm back.
+ * `saveId` is null when there was nothing to clear, which writes nothing and is still
+ * logged; a player with no dorm at all is a 404.
+ */
+export async function clearDormHandler(c: Context) {
+ const playerId = playerIdParam(c)
+ if (playerId === null) return c.json({ error: 'A numeric player id is required' }, 400)
+
+ const cleared = await clearDormSaveBlob(c.env.DB, playerId)
+ if (cleared === null) return c.json({ error: 'That player has no dorm' }, 404)
+
+ await recordPlayerAudit(c, 'clear_dorm', { playerId, ...cleared })
+ logger.info('staff cleared a dorm', { moderatorId: staffId(c), playerId, ...cleared })
+ return c.json({ playerId, ...cleared })
+}
+
/**
* Give a player Rec Room Plus by setting `hasPlus` on their account — the same flag the
* Discord benefits claim and `runx admin grant-plus` set, with no Discord link involved.
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index cf7a1e1..4fdff73 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -25,7 +25,7 @@ import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progres
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
// Banning a player moves them into their own dorm, which is a room (created on demand) with
// a subroom — so those tables have to be here, as they are on the shared database.
-import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
+import { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
import { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
import { generateToken } from '@repo/jwt'
@@ -871,6 +871,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/gift-xp',
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
+ '/api/staff/players/1/clear-dorm',
'/api/staff/studio-access',
'/api/staff/players/1/grant-plus',
]
@@ -2140,6 +2141,87 @@ it('clears a player’s password so they can set a new one in game', async () =>
expect((await staffPost('/api/staff/players/8397/clear-password', 8110, {})).status).toBe(404)
})
+it('clears a player’s dorm by publishing an empty save over the newest one', async () => {
+ await updateAccount(env.DB, 8340, { username: 'Timetraveler' })
+ expect((await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})).status).toBe(404)
+
+ const dorm = await getOrCreateDormRoom(env.DB, 8340)
+ const roomId = Number(dorm.RoomId)
+ const { sub_room_id: subRoomId } = (await env.DB.prepare(
+ 'SELECT sub_room_id FROM subroom WHERE room_id = ?1 LIMIT 1'
+ )
+ .bind(roomId)
+ .first<{ sub_room_id: number }>())!
+
+ // A dorm that has never been saved: nothing to clear, still a success.
+ let res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
+ expect(await res.json()).toEqual({ playerId: 8340, roomId, previousSaveId: null, saveId: null })
+
+ const insertSave = (blob: string) =>
+ env.DB.prepare(
+ 'INSERT INTO subroom_save (sub_room_id, data) VALUES (?1, ?2) RETURNING sub_room_data_save_id AS id'
+ )
+ .bind(
+ subRoomId,
+ JSON.stringify({
+ DataBlob: blob,
+ DataBlobHash: 'hash',
+ Description: blob,
+ PersistenceVersion: 7,
+ CreatedAt: '2025-08-01T00:00:00.000Z',
+ })
+ )
+ .first<{ id: number }>()
+ const older = (await insertSave('old.blob'))!.id
+ const newer = (await insertSave('new.blob'))!.id
+ await env.DB.prepare(
+ 'UPDATE subroom SET current_save_id = ?2, staged_save_id = ?2 WHERE sub_room_id = ?1'
+ )
+ .bind(subRoomId, newer)
+ .run()
+ const saveData = async (id: number) =>
+ JSON.parse(
+ (await env.DB.prepare('SELECT data FROM subroom_save WHERE sub_room_data_save_id = ?1')
+ .bind(id)
+ .first<{ data: string }>())!.data
+ )
+ const pointers = () =>
+ env.DB.prepare('SELECT current_save_id, staged_save_id FROM subroom WHERE sub_room_id = ?1')
+ .bind(subRoomId)
+ .first<{ current_save_id: number; staged_save_id: number | null }>()
+
+ res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
+ expect(res.status).toBe(200)
+ const { saveId } = (await res.json()) as { saveId: number }
+ expect(saveId).toBeGreaterThan(newer)
+
+ // The blank save copies the newest one but for the blob, and goes live.
+ const blank = await saveData(saveId)
+ expect(blank).toEqual({
+ DataBlob: '',
+ DataBlobHash: null,
+ Description: 'Cleared by staff',
+ PersistenceVersion: 7,
+ CreatedAt: expect.any(String),
+ })
+ expect(blank.CreatedAt).not.toBe('2025-08-01T00:00:00.000Z')
+ expect(await pointers()).toEqual({ current_save_id: saveId, staged_save_id: null })
+ // Nothing is erased: both earlier saves are as they were.
+ expect((await saveData(newer)).DataBlob).toBe('new.blob')
+ expect((await saveData(older)).DataBlob).toBe('old.blob')
+
+ // Clearing an already-empty dorm writes nothing more.
+ res = await staffPost('/api/staff/players/8340/clear-dorm', 8110, {})
+ expect(await res.json()).toEqual({ playerId: 8340, roomId, previousSaveId: saveId, saveId: null })
+ expect(await pointers()).toEqual({ current_save_id: saveId, staged_save_id: null })
+
+ expect((await auditRows('clear_dorm', 8340)).map((r) => r.data)).toEqual([
+ { playerId: 8340, roomId, previousSaveId: null, saveId: null },
+ { playerId: 8340, roomId, previousSaveId: newer, saveId },
+ { playerId: 8340, roomId, previousSaveId: saveId, saveId: null },
+ ])
+})
+
it('grants a player Plus, developers only', async () => {
await updateAccount(env.DB, 8330, { username: 'Plusless' })
const hasPlus = async () =>
diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts
index 5e9437f..f7b2c6b 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -43,6 +43,7 @@ import {
addUsernameChangeHandler,
banReportHandler,
bansInForceHandler,
+ clearDormHandler,
clearPasswordHandler,
createReportHandler,
getReportHandler,
@@ -610,6 +611,8 @@ const app = new Hono()
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
+ // Blanks the dorm's newest save so a player can go back to the 2023 client.
+ .post('/api/staff/players/:id/clear-dorm', clearDormHandler)
// The signed-in staff member's OWN sandbox flag: read it, and opt in or out. Self-only,
// so plain staff (not developer-only) — it moves nobody but the caller, and `match`
// honours it on their next room join.
diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts
index d7eb473..4d931c1 100644
--- a/packages/domain/src/rooms-db.ts
+++ b/packages/domain/src/rooms-db.ts
@@ -3982,6 +3982,64 @@ export async function getDormRoom(db: D1Database, accountId: number): Promise {
+ const dorm = await db
+ .prepare('SELECT room_id FROM room WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1')
+ .bind(accountId)
+ .first<{ room_id: number }>()
+ if (!dorm) return null
+
+ const latest = await db
+ .prepare(
+ `SELECT s.sub_room_data_save_id AS id, s.sub_room_id AS subRoomId,
+ json_extract(s.data, '$.DataBlob') AS blob
+ FROM subroom_save s JOIN subroom r ON r.sub_room_id = s.sub_room_id
+ WHERE r.room_id = ?1
+ ORDER BY s.sub_room_data_save_id DESC LIMIT 1`
+ )
+ .bind(dorm.room_id)
+ .first<{ id: number; subRoomId: number; blob: string | null }>()
+ if (!latest || !latest.blob) {
+ return { roomId: dorm.room_id, previousSaveId: latest?.id ?? null, saveId: null }
+ }
+
+ const save = await db
+ .prepare(
+ `INSERT INTO subroom_save (sub_room_id, data)
+ SELECT sub_room_id, json_set(data, '$.DataBlob', '', '$.DataBlobHash', NULL,
+ '$.Description', 'Cleared by staff', '$.CreatedAt', ?2)
+ FROM subroom_save WHERE sub_room_data_save_id = ?1
+ RETURNING sub_room_data_save_id AS id`
+ )
+ .bind(latest.id, new Date().toISOString())
+ .first<{ id: number }>()
+ if (!save) return { roomId: dorm.room_id, previousSaveId: latest.id, saveId: null }
+ await db
+ .prepare(
+ 'UPDATE subroom SET current_save_id = ?2, staged_save_id = NULL WHERE sub_room_id = ?1'
+ )
+ .bind(latest.subRoomId, save.id)
+ .run()
+ return { roomId: dorm.room_id, previousSaveId: latest.id, saveId: save.id }
+}
+
/**
* The player's personal dorm room, created on first access. Cloned from the
* seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so