[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
+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