diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index ccafb37..4a92268 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -3213,6 +3213,7 @@ function PublicRoomView({
<>
{isDeveloper() && }
+
{isDeveloper() && }
>
)}
@@ -3351,6 +3352,64 @@ function StaffListing({
)
}
+/**
+ * Delete every instance of the room, for one whose sessions are stuck. Two steps, like the
+ * dorm clear: nothing is lost for good, but anyone inside is cut off from their session.
+ */
+function StaffClearInstances({ room }: { room: OwnedRoom }) {
+ const [confirming, setConfirming] = useState(false)
+ const { pending, error, done, run } = useAction()
+
+ return (
+
+
Instances
+
+ Deletes every instance of this room, including ones with players in them. The next player to
+ join gets a fresh instance.
+
+
+ {confirming ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ {error &&
{error}
}
+ {done &&
{done}
}
+
+ )
+}
+
function StaffTakedown({ room, onTakenDown }: { room: OwnedRoom; onTakenDown: () => void }) {
const [confirming, setConfirming] = useState(false)
const { pending, error, run } = useAction()
diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts
index ede4d9b..e92023d 100644
--- a/apps/www/src/staff.ts
+++ b/apps/www/src/staff.ts
@@ -4,6 +4,7 @@ import {
clearDormSaveBlob,
clearPasswordHash,
createGift,
+ deleteRoomInstancesByRoom,
getAccount,
getImageById,
getOnlinePlayerIds,
@@ -1016,6 +1017,28 @@ export async function giftRoomTokensHandler(c: Context) {
return c.json({ roomId, amount, paid, skipped })
}
+/**
+ * Delete every instance of a room from `room_instance`, from the room's own page. For a
+ * room whose sessions have gone bad: the next player to matchmake in gets a fresh one.
+ * Clearing a room with no instances succeeds and deletes nothing.
+ */
+export async function clearRoomInstancesHandler(c: Context) {
+ const roomId = Number(c.req.param('roomId'))
+ if (!Number.isInteger(roomId) || roomId <= 0) {
+ return c.json({ error: 'A numeric room id is required' }, 400)
+ }
+
+ const deleted = await deleteRoomInstancesByRoom(c.env.DB, roomId)
+
+ await recordPlayerAudit(c, 'clear_room_instances', { roomId, deleted })
+ logger.info('staff cleared room instances', {
+ moderatorId: staffId(c),
+ roomId,
+ deletedCount: deleted.length,
+ })
+ return c.json({ roomId, deleted })
+}
+
/**
* Send tokens to EVERYONE ONLINE right now — the server-wide drop behind the account page's
* "Token drop" tab, beside the coach broadcast and the maintenance notice.
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index cbdb762..205d2ec 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -22,7 +22,11 @@ import {
setPresence,
} from '@repo/domain/src/presence-db'
import { getProgression, PROGRESSION_SCHEMA_DDL } from '@repo/domain/src/progression-db'
-import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
+import {
+ createRoomInstance,
+ getRoomInstancesByRoom,
+ 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 { getOrCreateDormRoom, ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
@@ -1087,6 +1091,7 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
'/api/staff/players/1/clear-dorm',
+ '/api/staff/rooms/1/clear-instances',
'/api/staff/studio-access',
'/api/staff/players/1/grant-plus',
]
@@ -2472,6 +2477,31 @@ it('clears a player’s dorm by publishing an empty save over the newest one', a
])
})
+it('clears every instance of a room, for any staff member', async () => {
+ const make = (roomId: number, photonRoomId: string) =>
+ createRoomInstance(env.DB, { ownerAccountId: 8110, roomId, photonRoomId })
+ const a = await make(7740, 'clear-a')
+ const b = await make(7740, 'clear-b')
+ const other = await make(7741, 'clear-other')
+
+ // A moderator, not only a developer, may clear a room.
+ const res = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
+ expect(res.status).toBe(200)
+ const body = (await res.json()) as { roomId: number; deleted: number[] }
+ expect(body.roomId).toBe(7740)
+ expect(body.deleted.toSorted()).toEqual([a.roomInstanceId, b.roomInstanceId].toSorted())
+ expect(await getRoomInstancesByRoom(env.DB, 7740)).toEqual([])
+ // Another room's instances are untouched.
+ expect((await getRoomInstancesByRoom(env.DB, 7741)).map((i) => i.roomInstanceId)).toEqual([
+ other.roomInstanceId,
+ ])
+
+ // Clearing an empty room succeeds and deletes nothing.
+ const again = await staffPost('/api/staff/rooms/7740/clear-instances', 8101, {})
+ expect(await again.json()).toEqual({ roomId: 7740, deleted: [] })
+ expect((await staffPost('/api/staff/rooms/abc/clear-instances', 8101, {})).status).toBe(400)
+})
+
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 2024a71..c21aaab 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -51,6 +51,7 @@ import {
bansInForceHandler,
clearDormHandler,
clearPasswordHandler,
+ clearRoomInstancesHandler,
createReportHandler,
getReportHandler,
giftItemHandler,
@@ -813,6 +814,8 @@ const app = new Hono()
.post('/api/staff/players/:id/gift-xp', requireDeveloper, giftXpHandler)
// Everyone standing in a room, across all of its instances.
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
+ // Delete every live instance of a room — for one whose sessions are stuck. All staff.
+ .post('/api/staff/rooms/:roomId/clear-instances', clearRoomInstancesHandler)
// The account page's token drop: the same gift to everyone online at once.
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
// Its sibling: the same gift to every account whose Discord link holds a role, offline
diff --git a/packages/domain/src/room-instance-db.ts b/packages/domain/src/room-instance-db.ts
index 325846d..4332d2d 100644
--- a/packages/domain/src/room-instance-db.ts
+++ b/packages/domain/src/room-instance-db.ts
@@ -476,6 +476,25 @@ export async function deleteEmptyRoomInstances(
return results.map((r) => r.id)
}
+/**
+ * Delete EVERY instance of a room, occupied or not — the staff "Clear instances" button
+ * for a room whose sessions are stuck. Presence is left alone: a player still in a
+ * deleted instance keeps their row until it expires or they move, and their next
+ * matchmake lands them in a fresh instance.
+ *
+ * Returns the ids deleted.
+ */
+export async function deleteRoomInstancesByRoom(db: D1Database, roomId: number): Promise {
+ const { results } = await db
+ .prepare(
+ `DELETE FROM room_instance WHERE room_id = ?1
+ RETURNING json_extract(data, '$.roomInstanceId') AS id`
+ )
+ .bind(roomId)
+ .all<{ id: number }>()
+ return results.map((r) => r.id)
+}
+
/**
* The oldest joinable public instance of a room (not private, not full, joins
* enabled, not already in progress, not set to `Ignore`) that is running `gameVersion`,