mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[www] reset dorm button for 2023
This commit is contained in:
@@ -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
|
||||
</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
|
||||
type="submit"
|
||||
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
|
||||
they can still login via Steam or Meta.
|
||||
</p>
|
||||
<p className="hint">
|
||||
<strong>Clear dorm:</strong> 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.
|
||||
</p>
|
||||
{usernameChange.error && <p className="error">{usernameChange.error}</p>}
|
||||
{usernameChange.done && <p className="ok">{usernameChange.done}</p>}
|
||||
{clearPassword.error && <p className="error">{clearPassword.error}</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.done && <p className="ok">{grantPlus.done}</p>}
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
addUsernameChange,
|
||||
addXp,
|
||||
clearDormSaveBlob,
|
||||
clearPasswordHash,
|
||||
createGift,
|
||||
getAccount,
|
||||
@@ -1532,6 +1533,25 @@ export async function clearPasswordHandler(c: Context<App>) {
|
||||
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
|
||||
* Discord benefits claim and `runx admin grant-plus` set, with no Discord link involved.
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
addUsernameChangeHandler,
|
||||
banReportHandler,
|
||||
bansInForceHandler,
|
||||
clearDormHandler,
|
||||
clearPasswordHandler,
|
||||
createReportHandler,
|
||||
getReportHandler,
|
||||
@@ -610,6 +611,8 @@ const app = new Hono<App>()
|
||||
.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.
|
||||
|
||||
@@ -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
|
||||
* seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so
|
||||
|
||||
Reference in New Issue
Block a user