[api] votekick and uniform dorm kicks

This commit is contained in:
Devin Zuczek
2026-09-18 16:03:30 -04:00
parent 04554b0826
commit ba428199c5
12 changed files with 710 additions and 111 deletions
+9 -5
View File
@@ -1,4 +1,4 @@
import { deletePresence, getPresences, refreshInstanceFullness } from '@repo/domain'
import { getPresences, movePlayerToDorm } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -173,8 +173,11 @@ function banExpiry(
* none. EPHEMERAL, because an offline player needs no frame — they meet the same screen at
* `moderationBlockDetails` when they next sign in — and a queued one would fire again then.
*
* Their presence row is deleted (they read offline at once), and the instance they were in,
* if any, has its fullness recomputed so a full room frees a slot.
* They are moved into their own DORM rather than having their presence deleted — the same move
* every kick makes, and the one place a banned player is still allowed to be: `match` lets them
* matchmake there and nowhere else, which is how they reach this screen at all. Deleting the row
* instead left the client arriving at the dorm not knowing where it was, loading it a second
* time. The instance they were in, if any, frees a slot.
*
* Entirely best-effort. The ban row is already committed by the time this runs; a hub hiccup
* or a missing presence must not fail a ban that has been handed down.
@@ -188,9 +191,10 @@ async function kickBannedPlayer(c: Context<App>, report: ReportRow, moderatorId:
// Offline: nothing to eject and nobody to tell. The sign-in check has them.
if (!presence) return
await deletePresence(c.env.DB, playerId)
// Read before the move, so the frame names the session they were thrown out of rather
// than the dorm they landed in. `movePlayerToDorm` recomputes that instance's fullness.
const gameSessionId = presence.roomInstance?.roomInstanceId
if (gameSessionId !== undefined) await refreshInstanceFullness(c.env.DB, gameSessionId)
await movePlayerToDorm(c.env.DB, playerId)
const block = banBlockDetails(report)
const frame: ModerationKickPayload = {
+13 -5
View File
@@ -9,6 +9,9 @@ import {
setPresence,
} from '@repo/domain/src/presence-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
// 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 { generateToken } from '@repo/jwt'
import {
@@ -87,6 +90,8 @@ beforeAll(async () => {
for (const stmt of REPORT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of WARNING_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
@@ -947,12 +952,15 @@ it('throws a banned player out of the instance they are standing in', async () =
Duration: 3 * 86_400,
})
// And their presence row is gone, so they read offline at once and the instance frees
// a slot.
// And they are out of that instance — moved into their own DORM, not deleted: `match`
// lets a banned player matchmake there and nowhere else, so it is where they read the
// block screen, and with no presence at all the client loads the dorm a second time.
const presence = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = 8190"
).first<{ n: number }>()
expect(presence?.n).toBe(0)
"SELECT json_extract(data, '$.roomInstance.roomInstanceId') AS instance FROM presence WHERE json_extract(data, '$.accountId') = 8190"
).first<{ instance: number | null }>()
expect(presence).not.toBeNull()
// The OFFLINE dorm sentinel — a room the client loads locally, with no session behind it.
expect(presence?.instance).toBe(-2)
})
// Online but not standing in any room — in a menu — is still banned from the game, and is