[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 -3
View File
@@ -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
+20
View File
@@ -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);
+9 -3
View File
@@ -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'),
})
+153 -47
View File
@@ -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<App>, 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<App>,
playerId: number,
gameSessionId: number,
reason: string,
carriedByAccountId: number
): Promise<void> {
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<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
@@ -443,17 +507,28 @@ export const moderationRoutes = new Hono<App>({ 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<App>({ strict: false })
if (voterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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,
+214 -26
View File
@@ -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<string, string>, 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<string, string>, 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)
+112
View File
@@ -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<RoomVoteRow> {
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<RoomVoteRow>()
// 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<number> {
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
+18 -16
View File
@@ -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<App>, ban: RoomBan): Promise<number
try {
const instance = (await getPresence<PresenceView>(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<App>()
'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',
+15 -6
View File
@@ -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()
+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
+137
View File
@@ -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<PresenceRoomInstance | null> {
const prev = await getPresence<LeftInstance>(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 (`@<user>'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
}
+1
View File
@@ -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'