[www] reset dorm button for 2023

This commit is contained in:
Devin Zuczek
2026-10-14 10:42:36 -04:00
parent 94b106c12f
commit 9902de39a4
5 changed files with 208 additions and 1 deletions
+44
View File
@@ -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. // One message for every box sent from this card; it stays put between sends.
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [confirmingClear, setConfirmingClear] = useState(false) const [confirmingClear, setConfirmingClear] = useState(false)
const [confirmingDorm, setConfirmingDorm] = useState(false)
const [itemId, setItemId] = useState('') const [itemId, setItemId] = useState('')
const [xp, setXp] = useState('') const [xp, setXp] = useState('')
const gift = useAction() const gift = useAction()
@@ -1404,6 +1405,7 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
const itemGift = useAction() const itemGift = useAction()
const usernameChange = useAction() const usernameChange = useAction()
const clearPassword = useAction() const clearPassword = useAction()
const clearDorm = useAction()
const grantPlus = useAction() const grantPlus = useAction()
const base = `/api/staff/players/${account.accountId}` const base = `/api/staff/players/${account.accountId}`
@@ -1626,6 +1628,41 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
Clear password Clear password
</button> </button>
)} )}
{confirmingDorm ? (
<>
<button
type="submit"
disabled={clearDorm.pending}
onClick={() =>
void clearDorm.run(async () => {
const res = await call<{ previousSaveId: number | null; saveId: number | null }>(
`${base}/clear-dorm`,
{ authed: true, method: 'POST' }
)
setConfirmingDorm(false)
if (res.previousSaveId === null)
return `@${account.username}'s dorm has never been saved.`
if (res.saveId === null) return `@${account.username}'s dorm is already empty.`
return `Dorm cleared: @${account.username}'s dorm loads empty now (save ${res.saveId}). Their old dorm is kept as save ${res.previousSaveId}.`
})
}
>
{clearDorm.pending ? 'Clearing…' : 'Confirm clear dorm'}
</button>
<button
type="button"
className="linkish"
disabled={clearDorm.pending}
onClick={() => setConfirmingDorm(false)}
>
Cancel
</button>
</>
) : (
<button type="submit" onClick={() => setConfirmingDorm(true)}>
Clear dorm
</button>
)}
<button <button
type="submit" type="submit"
onClick={() => onClick={() =>
@@ -1639,10 +1676,17 @@ function StaffPlayerActions({ account, navigate }: { account: PublicAccount; nav
<strong>Clear password:</strong> Only use if the player has requested a password clear and <strong>Clear password:</strong> Only use if the player has requested a password clear and
they can still login via Steam or Meta. they can still login via Steam or Meta.
</p> </p>
<p className="hint">
<strong>Clear dorm:</strong> Empties the player&apos;s dorm so it loads in the 2023 client
again — a dorm saved in a newer client won&apos;t. It publishes a new, empty save; the old
one stays in the dorm&apos;s save history and can be restored.
</p>
{usernameChange.error && <p className="error">{usernameChange.error}</p>} {usernameChange.error && <p className="error">{usernameChange.error}</p>}
{usernameChange.done && <p className="ok">{usernameChange.done}</p>} {usernameChange.done && <p className="ok">{usernameChange.done}</p>}
{clearPassword.error && <p className="error">{clearPassword.error}</p>} {clearPassword.error && <p className="error">{clearPassword.error}</p>}
{clearPassword.done && <p className="ok">{clearPassword.done}</p>} {clearPassword.done && <p className="ok">{clearPassword.done}</p>}
{clearDorm.error && <p className="error">{clearDorm.error}</p>}
{clearDorm.done && <p className="ok">{clearDorm.done}</p>}
{grantPlus.error && <p className="error">{grantPlus.error}</p>} {grantPlus.error && <p className="error">{grantPlus.error}</p>}
{grantPlus.done && <p className="ok">{grantPlus.done}</p>} {grantPlus.done && <p className="ok">{grantPlus.done}</p>}
</section> </section>
+20
View File
@@ -1,6 +1,7 @@
import { import {
addUsernameChange, addUsernameChange,
addXp, addXp,
clearDormSaveBlob,
clearPasswordHash, clearPasswordHash,
createGift, createGift,
getAccount, getAccount,
@@ -1532,6 +1533,25 @@ export async function clearPasswordHandler(c: Context<App>) {
return c.json({ playerId, hadPassword }) 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<App>) {
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 * 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. * Discord benefits claim and `runx admin grant-plus` set, with no Discord link involved.
+83 -1
View File
@@ -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' 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 // 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. // 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 { recordStat, STAT_SCHEMA_DDL } from '@repo/domain/src/stats-db'
import { generateToken } from '@repo/jwt' 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/gift-xp',
'/api/staff/players/1/username-changes', '/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password', '/api/staff/players/1/clear-password',
'/api/staff/players/1/clear-dorm',
'/api/staff/studio-access', '/api/staff/studio-access',
'/api/staff/players/1/grant-plus', '/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) 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 () => { it('grants a player Plus, developers only', async () => {
await updateAccount(env.DB, 8330, { username: 'Plusless' }) await updateAccount(env.DB, 8330, { username: 'Plusless' })
const hasPlus = async () => const hasPlus = async () =>
+3
View File
@@ -43,6 +43,7 @@ import {
addUsernameChangeHandler, addUsernameChangeHandler,
banReportHandler, banReportHandler,
bansInForceHandler, bansInForceHandler,
clearDormHandler,
clearPasswordHandler, clearPasswordHandler,
createReportHandler, createReportHandler,
getReportHandler, getReportHandler,
@@ -610,6 +611,8 @@ const app = new Hono<App>()
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler) .post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler) .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler) .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, // 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` // so plain staff (not developer-only) — it moves nobody but the caller, and `match`
// honours it on their next room join. // honours it on their next room join.
+58
View File
@@ -3982,6 +3982,64 @@ export async function getDormRoom(db: D1Database, accountId: number): Promise<Ro
) )
} }
/**
* Blank the scene on a player's dorm by publishing a NEW save with an empty `DataBlob` (and a
* null `DataBlobHash`) on top of the dorm's most recent one. A dorm saved by a newer client
* won't load in the 2023 one, and this puts it back to the empty dorm. Nothing is erased: the
* new save copies every other field of the newest one, and that save stays in the history, so
* `publish_save` with `previousSaveId` restores the dorm as it was.
*
* Dorms always publish, so the newest save is the one `CurrentSave` serves, and the new save
* goes live the same way — `current_save_id`, with any staged save dropped.
*
* Null when the player has no dorm. `previousSaveId` is the newest save beforehand (null when
* the dorm has never been saved); `saveId` is the blank save made here, null when there was
* no blob to clear and so nothing was written.
*/
export async function clearDormSaveBlob(
db: D1Database,
accountId: number
): Promise<{ roomId: number; previousSaveId: number | null; saveId: number | null } | null> {
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 * 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 * seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so