From ba428199c590ece6218d15c12a7e0f6a12776c54 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Fri, 18 Sep 2026 16:03:30 -0400 Subject: [PATCH] [api] votekick and uniform dorm kicks --- CLAUDE.md | 12 +- apps/api/migrations/0023_room_vote.sql | 20 ++ apps/api/src/openapi.ts | 12 +- apps/api/src/routes/moderation.ts | 200 ++++++++++++---- apps/api/src/test/integration/api.test.ts | 240 +++++++++++++++++--- apps/api/src/votes-db.ts | 112 +++++++++ apps/rooms/src/rooms.app.ts | 34 +-- apps/rooms/src/test/integration/api.test.ts | 21 +- apps/www/src/staff.ts | 14 +- apps/www/src/test/integration/api.test.ts | 18 +- packages/domain/src/dorm-presence.ts | 137 +++++++++++ packages/domain/src/index.ts | 1 + 12 files changed, 710 insertions(+), 111 deletions(-) create mode 100644 apps/api/migrations/0023_room_vote.sql create mode 100644 apps/api/src/votes-db.ts create mode 100644 packages/domain/src/dorm-presence.ts diff --git a/CLAUDE.md b/CLAUDE.md index a3c65e5..bc367b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,9 +187,15 @@ inconsistency here without checking the client first. with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested object. An object there does not degrade: the client's decoder rejects it outright (`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just - the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`, - whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside - it, as the reference relays it) and, the same way, a chat message's `Contents`. + the field. Bites a chat message's `Contents` the same way. +- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player + being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame + names — because the client raises its prompt about whoever that field names. Sending the + voter asks the room to kick the player who called the vote. Its `Data` is the posted + `Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it + once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON + printed where the reason belongs. `Data` is still a string on the wire — that part of the + rule above holds; a reason simply needs no escaping to satisfy it. - Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is 0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second place and the top of a board must be 0. Its own slice says the same: it asks for the first diff --git a/apps/api/migrations/0023_room_vote.sql b/apps/api/migrations/0023_room_vote.sql new file mode 100644 index 0000000..6e46229 --- /dev/null +++ b/apps/api/migrations/0023_room_vote.sql @@ -0,0 +1,20 @@ +-- Vote-to-kick ballots: one row per vote cast, appended, never updated. +-- +-- Generated from apps/api/src/votes-db.ts (SCHEMA_DDL) — keep in sync. +-- +-- `response` is 1 for yes (kick them) and 0 for no. A player who votes twice leaves two +-- rows: the tally counts DISTINCT voters and reads only each one's latest ballot, so +-- re-posting a vote cannot carry one, and a voter may change their mind. +-- +-- Rows are keyed to `game_session_id` (a `room_instance` id) and outlive the session. +-- Nothing prunes them; instance ids are not reused, so an old session's ballots can never +-- be counted into a new one's. +CREATE TABLE IF NOT EXISTS room_vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_session_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + response INTEGER NOT NULL, + voter_id INTEGER NOT NULL, + voted_at TEXT NOT NULL + ); +CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index ff32091..a85cacf 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -1457,12 +1457,18 @@ export const CreateWarningRequest = z.object({ * body — it's the bearer token's subject. */ export const VoteToKickRequest = z.object({ - PlayerId: z.string().describe('Account id of the player being voted on'), - Response: z.string().describe('The caller’s own vote, e.g. `True`'), + PlayerId: z + .string() + .describe( + 'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names' + ), + Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'), Reason: z .string() .optional() - .describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'), + .describe( + 'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent' + ), GameSessionId: z.string().describe('The room instance both players are standing in'), }) diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index aff7847..a3674d7 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -4,14 +4,14 @@ import { describeRoute } from 'hono-openapi' import { canManageRoom, canModerateRoom, - deletePresence, + countPlayersInInstance, getPlayerIdsInInstance, getPresence, getPresences, getRoomById, getStoredRoomInstance, MessageType, - refreshInstanceFullness, + movePlayerToDorm, } from '@repo/domain' import { logger } from '@repo/hono-helpers' @@ -39,6 +39,7 @@ import { VoteToKickRequest, } from '../openapi' import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db' +import { countKickVotes, isKickMajority, recordRoomVote } from '../votes-db' import { createWarning } from '../warnings-db' import type { Context } from 'hono' @@ -67,6 +68,15 @@ function formField( return c.req.query(name) || undefined } +/** + * Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted + * beside them because the same field is an int elsewhere in this controller. Anything else — + * absent included — is a no, which is the safe way round: an unreadable answer must not count + * toward removing somebody. + */ +const isYesVote = (value: string | undefined): boolean => + value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase()) + /** Parse a field as an integer, or null when absent / not a number. */ const asInt = (v: string | undefined): number | null => { if (v === undefined) return null @@ -167,27 +177,23 @@ async function pushInstantKick( } /** - * What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the - * client as an escaped JSON string, never as a nested object — a Message's `Data` is a - * string on the wire like every other Message's, and the client's decoder rejects an - * object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole - * notification rather than dropping the field. Serialize it with {@link voteToKickData}. + * What a vote-to-kick Message carries, and it is not what this once sent. * - * `PlayerId` is the account id as a STRING — the reference passes the posted form field - * straight through, and this mirrors it verbatim. + * `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client + * raises its prompt about whoever that names, so sending the caller put the voter's own name + * on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame + * identifies the caller, and nothing needs to: the prompt is about the accused. * - * `Response` is the empty string even though the caller posted their own vote: the frame - * is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response` - * is theirs alone and is not relayed. + * `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client + * offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of + * `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the + * prompt where the reason belongs. + * + * It is still a STRING on the wire, which is the part that has not changed: a Message's `Data` + * is a string like every other Message's, and an object there fails the client's decoder + * outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification + * rather than dropping the field. A reason is text, so it needs no escaping to satisfy that. */ -interface VoteToKickData { - PlayerId: string - Response: string - GameSessionId: number -} - -/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */ -const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data) /** * The Message a vote-to-kick frame carries — the same four fields as every other Message @@ -229,6 +235,64 @@ async function pushVoteToKick(c: Context, message: VoteToKickMessage): Prom } } +/** + * Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a + * `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do. + * + * It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and + * `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the + * player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the + * session they are in and nothing more, so they may walk straight back in, and a room that + * wants them gone for good bans them. + * + * `VoteKickReason` carries the reason the vote was called on, which is the field the client + * has for it — unlike the staff kicks, which leave it empty and put their text in `Message`. + * + * Best-effort, and deliberately after the ballot is recorded: the vote happened and was + * counted whatever the hub does next. + */ +async function applyVoteKick( + c: Context, + playerId: number, + gameSessionId: number, + reason: string, + carriedByAccountId: number +): Promise { + try { + // Into their own dorm, not out of existence — see the note in `instantKick`. + await movePlayerToDorm(c.env.DB, playerId) + + const instance = await getStoredRoomInstance(c.env.DB, gameSessionId) + const room = instance && (await getRoomById(c.env.DB, instance.roomId)) + const roomName = typeof room?.Name === 'string' ? room.Name : 'this room' + + const frame: ModerationKickPayload = { + ReportCategory: KickReportCategory.VoteKick, + Duration: 0, + GameSessionId: gameSessionId, + IsHostKick: false, + Message: `You have been kicked from ${roomName}.`, + PlayerIdReporter: carriedByAccountId, + IsBan: false, + IsVoiceModAutoban: false, + IsWarning: false, + VoteKickReason: reason, + TimeoutStartedAt: null, + } + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + [playerId], + NotificationType.ModerationKick, + { ...frame } + ) + } catch (err) { + logger.error('failed to carry out a vote kick', { + playerId, + gameSessionId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + // ---- Player reporting ------------------------------------------------------ export const moderationRoutes = new Hono({ strict: false }) // Whether the caller is currently blocked (banned / timed out / host-kicked). The one @@ -443,17 +507,28 @@ export const moderationRoutes = new Hono({ strict: false }) 'vote is called in front of them — gets a `MessageReceived` frame carrying a ' + 'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' + 'already, and their own `Response` is what they posted.\n\n' + - '`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' + - 'object. A Message’s `Data` is a string on the wire, and an object there fails the ' + - "client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " + - 'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' + - 'is the account id as a STRING, as the reference relays it, and `Response` is ' + - 'empty — the frame is the question, not an answer.\n\n' + + '`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' + + 'client raises its prompt about whoever it names, so the caller there asks the room ' + + 'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' + + '(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' + + "an object there fails the client’s decoder (`expected:'String Begin Token', " + + "actual:'{'`) and aborts the whole notification.\n\n" + 'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' + 'offline player gets nothing rather than a prompt about a dead session on their ' + 'next connect.\n\n' + - 'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' + - 'unused. Answers the same lowercase `{ success, error }` envelope as the report ' + + 'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' + + 'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' + + 'strictly more than half the players standing in the session right now (a tie is not ' + + 'a majority, and the count is of the room as it is, since players leave mid-vote). ' + + 'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' + + 're-posting a vote cannot carry one and a voter may change their mind.\n\n' + + 'When it carries, the player is kicked exactly as a staff kick does it — moved into ' + + 'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' + + 'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' + + 'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' + + '`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' + + 'out in that case — the room is not asked a question that is already answered.\n\n' + + '`Response` is the caller’s own vote, recorded but never relayed. Answers the same lowercase `{ success, error }` envelope as the ' + 'write; a hub failure for any recipient is reported honestly as a 500, since ' + 'with nothing behind it the frame is the whole delivery.', security: AUTHED, @@ -471,8 +546,6 @@ export const moderationRoutes = new Hono({ strict: false }) if (voterId === null) return unauthorized(c) const body = await c.req.parseBody().catch(() => ({}) as Record) - // Kept as posted for the frame — `Data.PlayerId` goes out as the string the - // reference relays — but parsed here to check it against presence. const playerIdField = formField(body, c, 'PlayerId') const playerId = asInt(playerIdField) if (playerIdField === undefined || playerId === null) { @@ -483,6 +556,10 @@ export const moderationRoutes = new Hono({ strict: false }) return c.json({ success: false, error: 'GameSessionId is required' }, 400) } + // The prompt's own text. Absent is an empty string rather than a refusal: a vote with + // no reason given is still a vote, and the client picks from a fixed list anyway. + const reason = formField(body, c, 'Reason') ?? '' + // One read for both players. A vote may only be called by someone standing in the // session, about someone standing in the same one — the session is read from live // presence, never from the body, so neither side can be asserted by the client. @@ -506,20 +583,45 @@ export const moderationRoutes = new Hono({ strict: false }) (id) => id !== voterId ) + // The caller's own ballot, recorded before the room is asked: they have voted, and a + // vote in a session holding only them and the player they called it on should carry on + // its own rather than waiting for an answer that can never come. + await recordRoomVote(c.env.DB, { + gameSessionId, + playerId, + voterId: voterId, + response: isYesVote(formField(body, c, 'Response')), + }) + + // Counted against who is standing there RIGHT NOW: a majority is of the room as it is, + // not of the room as it was when the vote was called, and players leave mid-vote. + const yesVotes = await countKickVotes(c.env.DB, gameSessionId, playerId) + const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId) + if (isKickMajority(yesVotes, playerCount)) { + logger.info('vote to kick carried', { + playerId, + gameSessionId, + yesVotes, + playerCount, + carriedBy: voterId, + }) + await applyVoteKick(c, playerId, gameSessionId, reason, voterId) + // The room is not asked a question that has already been answered. + return c.json({ success: true, error: '' }) + } + // Every recipient is attempted even if an earlier one fails, so the reachable // players still get the vote. const results = await Promise.all( audience.map((toPlayerId) => pushVoteToKick(c, { - FromPlayerId: voterId, + // The player being VOTED ON — the client raises its prompt about this id, so + // the caller's own id here asks the room to kick the wrong player. + FromPlayerId: playerId, ToPlayerId: toPlayerId, Type: MessageType.VoteToKick, - // An escaped JSON STRING, not a nested object — see VoteToKickData. - Data: voteToKickData({ - PlayerId: playerIdField, - Response: '', - GameSessionId: gameSessionId, - }), + // The reason as plain text; see the note above the Message type. + Data: reason, }) ) ) @@ -553,8 +655,10 @@ export const moderationRoutes = new Hono({ strict: false }) 'instance. Anyone else named — offline, or standing in another room — is skipped ' + 'in silence, so naming an account id cannot reach into a session the caller has ' + 'no authority over.\n\n' + - 'Each kicked player loses their presence row (they read offline at once and the ' + - 'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' + + 'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' + + 'so their client lands where it already is instead of loading the dorm a second ' + + 'time — and the instance they left frees a slot. They also get a `ModerationKick` ' + + 'frame (id 22) — the frame the ' + 'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' + 'false: this only removes them from the session they are in, and nothing stops ' + 'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' + @@ -614,14 +718,15 @@ export const moderationRoutes = new Hono({ strict: false }) // moderator could throw the creator out of it. Nor is the caller themselves. if (playerId === moderatorId || canModerateRoom(room, playerId)) continue if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue - await deletePresence(c.env.DB, playerId) + // Moved to their own dorm rather than having their presence deleted: the client + // goes to the dorm either way, and with no presence to read it arrives not knowing + // where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the + // fullness of the instance they left, so this one no longer has to. + await movePlayerToDorm(c.env.DB, playerId) kicked.push(playerId) } if (kicked.length > 0) { - // The instance just lost players — recompute its fullness so a full room opens - // back up, exactly as the `match` worker does when someone logs out. - await refreshInstanceFullness(c.env.DB, gameSessionId) const roomName = typeof room.Name === 'string' ? room.Name : 'this room' await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId) } @@ -657,8 +762,9 @@ export const moderationRoutes = new Hono({ strict: false }) 'account id cannot reach into a session in another room, or another instance of this ' + 'one. The caller need not be in the instance themselves. The caller cannot kick ' + 'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' + - 'A kick deletes the player’s presence row (they read offline at once and the instance ' + - 'frees a slot, its fullness recomputed) and sends them an EPHEMERAL `ModerationKick` ' + + 'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' + + 'so their client does not load the dorm twice — frees the slot they held, and sends ' + + 'them an EPHEMERAL `ModerationKick` ' + 'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' + 'true when the caller runs the room, false for a staff account acting in a room it ' + 'does not.\n\n' + @@ -719,8 +825,8 @@ export const moderationRoutes = new Hono({ strict: false }) return c.json({ success: false, error: 'That player is not in this game session!' }) } - await deletePresence(c.env.DB, playerId) - await refreshInstanceFullness(c.env.DB, gameSessionId) + // Into their own dorm, not out of existence — see the note in `instantKick`. + await movePlayerToDorm(c.env.DB, playerId) const roomName = typeof room.Name === 'string' ? room.Name : 'this room' await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, { reason, diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index a0fd017..b8231a5 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -65,6 +65,7 @@ import { spendCheerCredit, } from '../../reputation-db' import { charadesWordsFor } from '../../routes/gameplay' +import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db' import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db' import type { SavedImage } from '@repo/domain' @@ -170,6 +171,8 @@ beforeAll(async () => { // Reports table (owned by the api worker) — player reports are recorded here. for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + // Vote-to-kick ballots (owned by the api worker) — one row per vote cast. + for (const stmt of VOTES_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Platform identity links (owned by the auth worker) — the sharp arm of the // ban-evasion resolution matches on them. for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -4541,6 +4544,24 @@ describe('instant kick', () => { .bind(accountId) .first<{ n: number }>())!.n === 1 + // A kick no longer deletes presence — it moves the player into their own dorm — so + // "kicked" is "no longer standing in that session", not "gone". + const inSession = async (accountId: number, roomInstanceId: number) => + (await env.DB.prepare( + `SELECT COUNT(*) AS n FROM presence + WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2` + ) + .bind(accountId, roomInstanceId) + .first<{ n: number }>())!.n === 1 + + /** The instance a player's presence says they are standing in. */ + const presenceInstance = async (accountId: number) => + JSON.parse( + (await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') + .bind(accountId) + .first<{ data: string }>())!.data + ).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean } + const isFull = async (roomInstanceId: number) => (await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1') .bind(roomInstanceId) @@ -4572,9 +4593,19 @@ describe('instant kick', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true, error: '' }) - // Presence is deleted, so they read offline at once — and only theirs is. - expect(await isPresent(205)).toBe(false) - expect(await isPresent(206)).toBe(true) + // Out of the session — and into their own DORM, rather than having their presence + // deleted: with nothing to read the client lands not knowing where it is and loads the + // dorm a second time. 206 is untouched. + expect(await inSession(205, SESSION)).toBe(false) + expect(await isPresent(205)).toBe(true) + const dorm = await presenceInstance(205) + // The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player + // is not joining a session, and minting one per kick would put live dorm sessions on the + // books that nothing ever joins. + expect(dorm.roomInstanceId).toBe(-2) + expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom + expect(dorm.isPrivate).toBe(true) + expect(await inSession(206, SESSION)).toBe(true) // The instance lost a player, so it is no longer full. expect(await isFull(SESSION)).toBe(false) @@ -4624,7 +4655,7 @@ describe('instant kick', () => { // 43 holds Moderator (20) on the room. expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200) - expect(await isPresent(209)).toBe(false) + expect(await inSession(209, SESSION)).toBe(false) await standIn(209, SESSION) // 44 is only a Host (10), and 99 holds nothing at all. @@ -4633,7 +4664,7 @@ describe('instant kick', () => { expect(res.status, sub).toBe(403) expect(await res.json()).toEqual({ success: false, error: 'Forbidden' }) } - expect(await isPresent(209)).toBe(true) + expect(await inSession(209, SESSION)).toBe(true) const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, { method: 'POST', @@ -4653,8 +4684,8 @@ describe('instant kick', () => { // 43 (a moderator) names the creator, a fellow moderator and themselves. const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43') expect(res.status).toBe(200) - expect(await isPresent(42)).toBe(true) - expect(await isPresent(43)).toBe(true) + expect(await inSession(42, SESSION)).toBe(true) + expect(await inSession(43, SESSION)).toBe(true) expect(await frames()).toEqual([]) }) @@ -4731,11 +4762,24 @@ describe('room mod kick', () => { ) .run() - const isPresent = async (accountId: number) => - (await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1') - .bind(accountId) + // A kick no longer deletes presence — it moves the player into their own dorm — so + // "kicked" is "no longer standing in that session", not "gone". + const inSession = async (accountId: number, roomInstanceId: number) => + (await env.DB.prepare( + `SELECT COUNT(*) AS n FROM presence + WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2` + ) + .bind(accountId, roomInstanceId) .first<{ n: number }>())!.n === 1 + /** The instance a player's presence says they are standing in. */ + const presenceInstance = async (accountId: number) => + JSON.parse( + (await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1') + .bind(accountId) + .first<{ data: string }>())!.data + ).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean } + // The client's form body. const modKick = async (fields: Record, sub = '42', roles?: string[]) => exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, { @@ -4765,7 +4809,11 @@ describe('room mod kick', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual({ success: true, error: '' }) - expect(await isPresent(205)).toBe(false) + // Out of the session and into their own dorm — not deleted. + expect(await inSession(205, SESSION)).toBe(false) + const kickedTo = await presenceInstance(205) + expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel + expect(kickedTo.roomInstanceType).toBe(2) // Dormroom const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1') .bind(SESSION) .first<{ f: number }>() @@ -4805,8 +4853,8 @@ describe('room mod kick', () => { error: 'That player is not in this game session!', }) } - expect(await isPresent(206)).toBe(true) - expect(await isPresent(207)).toBe(true) + expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true) + expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true) // Naming the OTHER room's session instead doesn't help: 42 has no authority there. const cross = await modKick( @@ -4814,7 +4862,7 @@ describe('room mod kick', () => { '42' ) expect(cross.status).toBe(403) - expect(await isPresent(207)).toBe(true) + expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true) expect(await frames()).toEqual([]) }) @@ -4826,7 +4874,7 @@ describe('room mod kick', () => { expect( await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json() ).toEqual({ success: true, error: '' }) - expect(await isPresent(209)).toBe(false) + expect(await inSession(209, SESSION)).toBe(false) // 999 has no role on the room but carries the staff `moderator` role — and is not the // host, so the frame says so. @@ -4837,7 +4885,7 @@ describe('room mod kick', () => { 'moderator', ]) expect(await staff.json()).toEqual({ success: true, error: '' }) - expect(await isPresent(209)).toBe(false) + expect(await inSession(209, SESSION)).toBe(false) expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 }) // 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host, @@ -4848,7 +4896,7 @@ describe('room mod kick', () => { expect(res.status, sub).toBe(403) expect(await res.json()).toEqual({ success: false, error: 'Forbidden' }) } - expect(await isPresent(209)).toBe(true) + expect(await inSession(209, SESSION)).toBe(true) const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, { method: 'POST', @@ -4875,8 +4923,8 @@ describe('room mod kick', () => { await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator']) ).json() ).toEqual({ success: false, error: 'You cannot kick an owner of this room!' }) - expect(await isPresent(42)).toBe(true) - expect(await isPresent(45)).toBe(true) + expect(await inSession(42, SESSION)).toBe(true) + expect(await inSession(45, SESSION)).toBe(true) }) test('an unknown session 404s, and the body must name a session and a player', async () => { @@ -4901,9 +4949,15 @@ describe('room mod kick', () => { describe('vote to kick', () => { // Two live sessions, so a vote called in one can be checked against a player in the - // other. Nothing reads `room_instance` here — the gate is presence alone. + // other. The gate is presence alone; `room_instance` is read only to name the room in a + // carried vote's kick frame. const SESSION = 1014079 const OTHER_SESSION = 1014080 + // The tally tests get sessions of their own: ballots are appended and never pruned, so + // votes cast by the tests above would otherwise be counted into theirs. + const TALLY_SESSION = 1014081 + const REVOTE_SESSION = 1014082 + const TIE_SESSION = 1014083 const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') @@ -4923,6 +4977,34 @@ describe('vote to kick', () => { ) .run() + const isPresent = async (accountId: number) => + (await env.DB.prepare( + "SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1" + ) + .bind(accountId) + .first<{ n: number }>())!.n === 1 + + // A carried vote moves the player into their own dorm rather than deleting their presence, + // so "kicked" is "no longer standing in that session". + const inSession = async (accountId: number, roomInstanceId: number) => + (await env.DB.prepare( + `SELECT COUNT(*) AS n FROM presence + WHERE json_extract(data, '$.accountId') = ?1 + AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2` + ) + .bind(accountId, roomInstanceId) + .first<{ n: number }>())!.n === 1 + + /** Every ballot cast against one player in one session, oldest first. */ + const ballots = async (gameSessionId: number, playerId: number) => + ( + await env.DB.prepare( + 'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id' + ) + .bind(gameSessionId, playerId) + .all<{ voter_id: number; response: number; player_id: number; voted_at: string }>() + ).results + // The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`. const vote = async (fields: Record, sub = '42') => exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, { @@ -4960,17 +5042,20 @@ describe('vote to kick', () => { // One frame each for 205 and 206 — the player voted on gets it too (the vote is // called in front of them), the caller does not, and 207 is in another session. - // `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the - // client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole - // notification with it. `PlayerId` inside it is a STRING, as the reference relays it, - // and `Response` is empty because the frame is the question, not an answer. + // + // `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client + // raises its prompt about whoever that names, so the caller there asks the room to kick + // the wrong player. `Data` is the REASON as plain text — it is what the prompt shows, + // and it used to be a JSON blob of the ids, which players saw printed on the prompt. It + // is still a string on the wire: an object fails the client's decoder + // (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it. const message = { ephemeral: true, notificationType: 2, // NotificationType.MessageReceived data: { - FromPlayerId: 42, + FromPlayerId: 205, Type: MessageType.VoteToKick, - Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`, + Data: 'Inactive in games (AFK)', }, } const sent = await frames() @@ -4987,6 +5072,109 @@ describe('vote to kick', () => { }) }) + test('a vote with no reason still goes out, with an empty Data', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await standIn(42, SESSION) + await standIn(205, SESSION) + await standIn(206, SESSION) + + const { Reason: _reason, ...noReason } = FIELDS + expect((await vote(noReason)).status).toBe(200) + const sent = await frames() + expect(sent).toHaveLength(2) + expect(sent.every((f) => f.data.Data === '')).toBe(true) + // Still about the player voted on, reason or no reason. + expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true) + }) + + // A vote is recorded whether or not it carries — the row is the record of who asked. + test('the caller’s ballot is recorded', async () => { + await standIn(42, SESSION) + await standIn(205, SESSION) + await standIn(206, SESSION) + const before = await ballots(SESSION, 205) + + expect((await vote(FIELDS)).status).toBe(200) + const after = await ballots(SESSION, 205) + expect(after).toHaveLength(before.length + 1) + expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 }) + expect(typeof after.at(-1)!.voted_at).toBe('string') + + // `Response=False` is recorded as a no rather than dropped. + expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200) + expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 }) + }) + + // The point of the tally: enough of the room says yes and the player goes, without any + // moderator being involved. + test('a majority of the room carries the vote and kicks the player', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + // Three in the session: 42 calls it on 210, 211 is the other voter. + for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION) + const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) } + + // One yes of three is not a majority — the room is asked instead. + expect((await vote(fields)).status).toBe(200) + expect(await inSession(210, TALLY_SESSION)).toBe(true) + expect((await frames()).every((f) => f.notificationType === 2)).toBe(true) + + // The second yes carries it: 2 of 3 is strictly more than half. + await hub().fetch('http://do/all', { method: 'DELETE' }) + expect((await vote(fields, '211')).status).toBe(200) + + // Kicked exactly as a staff kick does it — out of the session and into their own dorm, + // their presence rewritten rather than deleted... + expect(await inSession(210, TALLY_SESSION)).toBe(false) + expect(await isPresent(210)).toBe(true) + expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel + // ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered. + const sent = await frames() + expect(sent).toHaveLength(1) + expect(sent[0]).toMatchObject({ + playerIds: [210], + ephemeral: true, + notificationType: 22, // NotificationType.ModerationKick + data: { + ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them + IsHostKick: false, + IsBan: false, // nothing stops them coming back + Duration: 0, + GameSessionId: TALLY_SESSION, + PlayerIdReporter: 211, // whose ballot carried it + VoteKickReason: 'Inactive in games (AFK)', + }, + }) + }) + + // Otherwise one player could carry a vote alone by posting it repeatedly. + test('re-posting a vote cannot carry it, and a voter may change their mind', async () => { + for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION) + const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) } + + // Three yes votes from one voter is still one voter. + for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200) + expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3) + expect(await inSession(212, REVOTE_SESSION)).toBe(true) + + // 213 says yes — 2 of 3 — and it carries. + expect((await vote(fields, '213')).status).toBe(200) + expect(await inSession(212, REVOTE_SESSION)).toBe(false) + }) + + test('a tie is not a majority', async () => { + // Four in the session, so two yes votes are exactly half. + for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION) + const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) } + + expect((await vote(fields)).status).toBe(200) + expect((await vote(fields, '215')).status).toBe(200) + expect(await inSession(214, TIE_SESSION)).toBe(true) + + // The third yes is more than half, and carries. + expect((await vote(fields, '216')).status).toBe(200) + expect(await inSession(214, TIE_SESSION)).toBe(false) + }) + test('both players have to be standing in the session', async () => { await hub().fetch('http://do/all', { method: 'DELETE' }) await standIn(42, OTHER_SESSION) diff --git a/apps/api/src/votes-db.ts b/apps/api/src/votes-db.ts new file mode 100644 index 0000000..9b6df02 --- /dev/null +++ b/apps/api/src/votes-db.ts @@ -0,0 +1,112 @@ +/** + * Vote-to-kick ballots on the shared `recflare` D1 database. + * + * One row per vote cast: who was voted on, in which game session, by whom, and which way. + * Append-only like the `report` and `warning` tables (see reports-db.ts), so a vote is a + * record of what happened rather than a tally that gets rewritten — the tally is computed + * from the rows. + * + * The rows outlive the vote. Nothing deletes them when a session ends, and nothing needs to: + * a ballot is keyed to its `game_session_id`, and instance ids are not reused, so an old + * session's votes can never be counted into a new one's. + * + * The `api` worker owns this schema/migration (migrations/0023_room_vote.sql, applied under + * its own `migrations_table` so it doesn't clash with the other workers' migrations that + * share the database). + */ + +/** Schema DDL (mirror of migrations/0023_room_vote.sql). */ +export const SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS room_vote ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_session_id INTEGER NOT NULL, + player_id INTEGER NOT NULL, + response INTEGER NOT NULL, + voter_id INTEGER NOT NULL, + voted_at TEXT NOT NULL + )`, + // The tally's own query: every ballot cast against one player in one session. + `CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id)`, +] + +/** One cast ballot. `response` is 1 for yes (kick them) and 0 for no. */ +export interface RoomVoteRow { + id: number + game_session_id: number + player_id: number + response: number + voter_id: number + voted_at: string +} + +/** A ballot as cast — the timestamp is the table's. */ +export interface NewRoomVote { + gameSessionId: number + /** The player being voted on. */ + playerId: number + voterId: number + /** True to kick them. */ + response: boolean +} + +/** Record one cast ballot, returning the stored row. */ +export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise { + const row = await db + .prepare( + `INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at) + VALUES (?1, ?2, ?3, ?4, ?5) + RETURNING *` + ) + .bind( + input.gameSessionId, + input.playerId, + input.response ? 1 : 0, + input.voterId, + new Date().toISOString() + ) + .first() + // RETURNING always yields the inserted row. + return row! +} + +/** + * How many DISTINCT players have voted to kick this player out of this session. + * + * Distinct voters, not rows, and only each voter's LATEST ballot: the table is append-only, + * so a player who votes twice leaves two rows, and counting rows would let one person carry + * a vote on their own by posting it repeatedly. Counting their latest also lets someone + * change their mind — a yes followed by a no is a no. + * + * `MAX(id)` picks the latest rather than `MAX(voted_at)`: two ballots cast in the same + * millisecond carry the same timestamp, and the id is the order they were actually recorded + * in. + */ +export async function countKickVotes( + db: D1Database, + gameSessionId: number, + playerId: number +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS yes FROM ( + SELECT response FROM room_vote + WHERE game_session_id = ?1 AND player_id = ?2 + AND id IN ( + SELECT MAX(id) FROM room_vote + WHERE game_session_id = ?1 AND player_id = ?2 + GROUP BY voter_id + ) + ) WHERE response = 1` + ) + .bind(gameSessionId, playerId) + .first<{ yes: number }>() + return row?.yes ?? 0 +} + +/** + * Whether `yesVotes` carries a vote in a session holding `playerCount` players: STRICTLY more + * than half. A tie is not a majority, and a session whose presence count has somehow reached + * zero can never carry one — `0 > 0` is false — rather than every vote passing unopposed. + */ +export const isKickMajority = (yesVotes: number, playerCount: number): boolean => + playerCount > 0 && yesVotes > playerCount / 2 diff --git a/apps/rooms/src/rooms.app.ts b/apps/rooms/src/rooms.app.ts index 1919abe..cd41a3e 100644 --- a/apps/rooms/src/rooms.app.ts +++ b/apps/rooms/src/rooms.app.ts @@ -15,7 +15,6 @@ import { countRoomsByCreator, createNotification, createSubRoom, - deletePresence, deleteRoom, deleteRoomLeaderboard, deleteSubRoom, @@ -49,8 +48,8 @@ import { isRoomOwner, MessageType, modifySubRoom, + movePlayerToDorm, publishSubRoomSave, - refreshInstanceFullness, removeCheer, removeFavorite, removeRoomRole, @@ -621,18 +620,19 @@ async function pushRoleInvite( const REPORT_CATEGORY_MODERATOR = -1 /** - * Take a freshly banned player out of the room's LIVE PRESENCE, so the ban takes effect on - * everything that reads presence right now rather than when their row next expires. The kick - * push ejects their client, but until the row goes they still count toward the instance's - * player total (keeping a full room full), still show as standing in the room to friends, and - * still pass the presence-based grants — reading the room's staged saves among them. + * Move a freshly banned player out of the room and into their own DORM, so the ban takes + * effect on everything that reads presence right now rather than when their row next expires. + * The kick push ejects their client, but until presence moves they still count toward the + * instance's player total (keeping a full room full), still show as standing in the room to + * friends, and still pass the presence-based grants — reading the room's staged saves among + * them. * * Only when their presence puts them in THIS room. A player banned from a room they are not * standing in keeps their presence: a ban here is no reason to knock them offline from wherever * they actually are. The same rule the instant kick in `api` applies to its instance. * - * The instance they left gets its fullness recomputed, as every other presence removal does, so - * a room they were filling opens back up. Best-effort like the push: the ban row is already + * The instance they left gets its fullness recomputed, as every other departure does, so a room + * they were filling opens back up. Best-effort like the push: the ban row is already * committed, and a failure here must not turn a ban that happened into an error response. * * Returns the instance they were removed from, or null when they were not standing in the @@ -642,10 +642,11 @@ async function evictBannedPlayer(c: Context, ban: RoomBan): Promise(c.env.DB, ban.BannedPlayerId))?.roomInstance if (instance?.roomId !== ban.RoomId) return null - await deletePresence(c.env.DB, ban.BannedPlayerId) - if (instance.roomInstanceId == null) return null - await refreshInstanceFullness(c.env.DB, instance.roomInstanceId) - return instance.roomInstanceId + // Moved to their own dorm rather than deleted: the client goes there either way, and with + // no presence to read it arrives not knowing where it is and loads the dorm a second + // time. This also recomputes the fullness of the instance they left. + await movePlayerToDorm(c.env.DB, ban.BannedPlayerId) + return instance.roomInstanceId ?? null } catch (err) { logger.error('failed to clear a banned player’s presence', { playerId: ban.BannedPlayerId, @@ -2593,9 +2594,10 @@ const app = new Hono() 'the ban list — without anyone lifting it. Re-banning REPLACES the reason and', 'duration and restarts the clock; it does not extend the remaining time.', '', - 'If the banned player’s live presence puts them in THIS room, that presence row is', - 'deleted and the instance’s fullness recomputed, so they stop counting as standing in', - 'the room immediately. Presence in some other room is left alone.', + 'If the banned player’s live presence puts them in THIS room, they are moved into their', + 'own DORM — presence rewritten rather than deleted, so their client does not load the', + 'dorm twice — and the instance they left frees a slot. Presence in some other room is', + 'left alone.', '', 'A banned player standing in the room also gets a `ModerationKick` frame (id 22),', 'the frame the client acts on to leave, with `gameSessionId` the instance they were', diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 4ef3275..c7350d6 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -2552,11 +2552,14 @@ describe('rooms endpoints', () => { expect(rebanned.value).toMatchObject({ Reason: null, ExpiresAt: null }) }) - it('POST /rooms/:id/bans deletes the banned player’s presence in that room', async () => { + it('POST /rooms/:id/bans moves the banned player out of that room, into their dorm', async () => { const presenceOf = (accountId: number) => - env.DB.prepare('SELECT room_id FROM presence WHERE account_id = ?1') + env.DB.prepare( + `SELECT room_id, json_extract(data, '$.roomInstance.roomInstanceId') AS instance + FROM presence WHERE account_id = ?1` + ) .bind(accountId) - .first<{ room_id: number }>() + .first<{ room_id: number; instance: number }>() const instanceId = 1000002 // what `putInRoom` puts room 2's players in const isFull = async () => ( @@ -2582,13 +2585,19 @@ describe('rooms endpoints', () => { await putInRoom(321, 4) expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200) - // Gone from the room they were banned from, and the seat they held is free again. - expect(await presenceOf(320)).toBeNull() + // Out of the room they were banned from, and the seat they held is free again. Their + // presence is REWRITTEN to their own dorm rather than deleted: with none to read, the + // client arrives at the dorm not knowing where it is and loads it a second time. + const moved = await presenceOf(320) + expect(moved).not.toBeNull() + expect(moved!.room_id).not.toBe(2) + // The OFFLINE dorm sentinel, not a live dorm session minted per ban. + expect(moved!.instance).toBe(-2) expect(await isFull()).toBe(0) // A ban from room 2 does not knock someone offline from room 4. expect((await postForm('/rooms/2/bans', { banMask: '0', id: '321' }, '1')).status).toBe(200) - expect(await presenceOf(321)).toEqual({ room_id: 4 }) + expect((await presenceOf(321))?.room_id).toBe(4) await clearPresence(321) await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run() diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts index f09732b..5ef1786 100644 --- a/apps/www/src/staff.ts +++ b/apps/www/src/staff.ts @@ -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, 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 = { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index e72cf4d..6622cde 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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 diff --git a/packages/domain/src/dorm-presence.ts b/packages/domain/src/dorm-presence.ts new file mode 100644 index 0000000..b72dea5 --- /dev/null +++ b/packages/domain/src/dorm-presence.ts @@ -0,0 +1,137 @@ +/** + * Sending a player back to their own dorm — the move every kick makes. + * + * A kick used to DELETE the kicked player's presence row. The client goes to the dorm on its + * own either way, but with no presence to read it arrives without knowing where it is and + * loads the dorm a second time, so the player watches it appear twice. Writing the dorm into + * presence instead leaves them where they are actually standing, and the extra load goes away. + * + * The presence written names the OFFLINE DORM: the player's own dorm room, with the sentinel + * instance id {@link OFFLINE_DORM_INSTANCE_ID} (-2) in place of a `room_instance` row. That is + * the same shape `auth` seeds a brand-new player's Orientation presence with — a room the + * client loads locally, with no session behind it and nobody else in it. A kicked player is + * not joining a session, so minting a real instance for them said otherwise: it put a live + * dorm session on the books, one per kick, that nothing would ever join. + * + * Lives in `@repo/domain` rather than in `match` because the callers are the workers that + * kick: `api`'s moderation kicks and vote-kicks, `rooms`' ban, and `www`'s server ban. + */ + +import { RoomInstanceType } from './enums' +import { GAME_VERSION, getPresence, setPresence } from './presence-db' +import { refreshInstanceFullness } from './room-instance-db' +import { getOrCreateDormRoom, subRoomDataBlob } from './rooms-db' + +import type { SubRoom } from './rooms-db' + +/** + * The sentinel `roomInstanceId` for a room the client is in WITHOUT a live session — what + * `auth` seeds Orientation with, and what a kicked player's dorm carries. + * + * It must be this exact value on the wire: the heartbeat echoes the id back, and a client + * whose presence names an instance it isn't in treats presence as out of sync and bounces the + * player to the dorm — the second load this whole helper exists to avoid. `match` also reads + * it directly, leaving a logout that still points at -2 alone. + */ +export const OFFLINE_DORM_INSTANCE_ID = -2 + +/** The instance shape a presence row carries — what `match` writes and `/matchmake/none` serves. */ +export interface PresenceRoomInstance { + roomInstanceId: number + roomId: number + subRoomId: number + roomInstanceType: number + location: string + dataBlob: string + eventId: number + clubId: number + roomCode: string + photonRegion: string + photonRegionId: string + photonRoomId: string + name: string + maxCapacity: number + isFull: boolean + isPrivate: boolean + isInProgress: boolean + EncryptVoiceChat: boolean +} + +/** What a kicked player's presence says about the room they came from. */ +interface LeftInstance { + roomInstanceId?: number + photonRegionId?: string +} + +/** + * Move a player's presence into their own offline dorm, creating the dorm room if they have + * none, and return the instance written. + * + * Everything else about their presence is carried forward — device class, platform, build, + * status visibility and the session's login lock — because none of it changed by being kicked; + * only where they are standing did. A player with NO live presence is left alone and `null` is + * returned: they are offline, and there is nothing to move. + * + * The instance they left has its fullness recomputed, as every other departure does, so the + * room they were kicked out of frees a slot. + */ +export async function movePlayerToDorm( + db: D1Database, + accountId: number +): Promise { + const prev = await getPresence(db, accountId) + if (!prev) return null + + const room = await getOrCreateDormRoom(db, accountId) + const roomId = typeof room.RoomId === 'number' ? room.RoomId : 0 + const sub = (Array.isArray(room.SubRooms) ? room.SubRooms : [])[0] as SubRoom | undefined + // Inherited rather than configured: these workers have no Photon bindings, and there is no + // session to connect to anyway — the dorm is loaded locally. + const photonRegionId = prev.roomInstance?.photonRegionId || 'us' + + const roomInstance: PresenceRoomInstance = { + roomInstanceId: OFFLINE_DORM_INSTANCE_ID, + roomId, + subRoomId: typeof sub?.SubRoomId === 'number' ? sub.SubRoomId : 1, + roomInstanceType: RoomInstanceType.Dormroom, + location: typeof sub?.UnitySceneId === 'string' ? sub.UnitySceneId : '', + dataBlob: subRoomDataBlob(sub), + eventId: 0, + clubId: 0, + roomCode: '', + photonRegion: photonRegionId, + photonRegionId, + // Derived from the room like Orientation's, rather than a fresh GUID: there is no Photon + // session here, and a new id per kick would suggest one. + photonRoomId: `rec.${roomId}`, + // A dorm's name carries the owner prefix (`@'s Dorm`) and must NOT also get the + // `^` an ordinary instance name takes — see `instanceFieldsFromRoom` in `match`. + name: typeof room.Name === 'string' ? room.Name : '', + maxCapacity: typeof sub?.MaxPlayers === 'number' ? sub.MaxPlayers : 4, + isFull: false, + isPrivate: true, + isInProgress: false, + EncryptVoiceChat: false, + } + + await setPresence(db, { + accountId, + roomInstance, + statusVisibility: prev.statusVisibility ?? 0, + deviceClass: prev.deviceClass ?? 0, + vrMovementMode: prev.vrMovementMode ?? 1, + platform: prev.platform ?? 0, + appVersion: prev.appVersion ?? GAME_VERSION, + // Carried forward so the heartbeat can keep verifying the session it belongs to. + loginLock: prev.loginLock, + }) + + // The room they left lost a player. Nothing to recompute on the other side: the offline + // dorm has no `room_instance` row, which is the point of the sentinel. + const leftId = prev.roomInstance?.roomInstanceId + if (leftId !== undefined && leftId !== OFFLINE_DORM_INSTANCE_ID) { + await refreshInstanceFullness(db, leftId) + } + + return roomInstance +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index e1e9ea5..ce0d284 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -15,6 +15,7 @@ export * from './rooms-db' export * from './room-instance-db' export * from './room-comments-db' export * from './room-invites-db' +export * from './dorm-presence' export * from './presence-db' export * from './stats-db' export * from './gifts-db'