From 01f64f78af53e5e14e87fb54ce1c7811c7b926cc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 22 Sep 2026 16:28:19 -0400 Subject: [PATCH] [api] votekick cooldowns --- apps/api/migrations/0026_room_vote_init.sql | 21 +++++ apps/api/src/routes/moderation.ts | 72 +++++++++++++--- apps/api/src/test/integration/api.test.ts | 91 ++++++++++++++++++++- apps/api/src/votes-db.ts | 83 ++++++++++++++++--- 4 files changed, 242 insertions(+), 25 deletions(-) create mode 100644 apps/api/migrations/0026_room_vote_init.sql diff --git a/apps/api/migrations/0026_room_vote_init.sql b/apps/api/migrations/0026_room_vote_init.sql new file mode 100644 index 0000000..b60a882 --- /dev/null +++ b/apps/api/migrations/0026_room_vote_init.sql @@ -0,0 +1,21 @@ +-- Marks the ballot that CALLED a vote-to-kick. Generated from src/votes-db.ts (SCHEMA_DDL) — +-- keep in sync. +-- +-- The client posts the call and every answer to the same endpoint with the same body, so +-- nothing on the wire says which ballot started a vote. `init` is 1 on the first ballot cast +-- against a player in a session while no vote on them is open (VOTE_WINDOW_MS), 0 on every +-- answer. Two things read it: +-- - the tally counts only ballots from the open vote's `init` row on, so an expired vote's +-- yes ballots cannot carry the next one; +-- - the caller throttle: a player who called a vote within VOTE_CALL_COOLDOWN_MS may not +-- call another (`idx_room_vote_caller`). Answering is never throttled. +-- +-- Backfill: each (session, player)'s first ballot is taken as its call. Those sessions are +-- over, so this only keeps the caller history honest. + +ALTER TABLE room_vote ADD COLUMN init INTEGER NOT NULL DEFAULT 0; + +UPDATE room_vote SET init = 1 +WHERE id IN (SELECT MIN(id) FROM room_vote GROUP BY game_session_id, player_id); + +CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1; diff --git a/apps/api/src/routes/moderation.ts b/apps/api/src/routes/moderation.ts index 2b2695c..a21d2c3 100644 --- a/apps/api/src/routes/moderation.ts +++ b/apps/api/src/routes/moderation.ts @@ -44,7 +44,13 @@ import { VoteToKickRequest, } from '../openapi' import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db' -import { countKickVotes, isKickMajority, recordRoomVote } from '../votes-db' +import { + countKickVotes, + getOpenVote, + isKickMajority, + isOnVoteCooldown, + recordRoomVote, +} from '../votes-db' import { createWarning } from '../warnings-db' import type { Context } from 'hono' @@ -609,7 +615,8 @@ export const moderationRoutes = new Hono({ strict: false }) // A player calling a vote to kick another. Ungated by role — anyone may start one — // but both players have to be standing in the session the vote is called in, which is // what stops a client putting a vote to a room it isn't in, about someone who isn't - // there. Nothing tallies the votes yet: this relays the prompt and no more. + // there. The call and every answer arrive here alike; the first ballot while no vote on + // that player is open CALLS one (`init`), and only a call is put to the room. .post( '/api/PlayerReporting/v3/voteToKick', describeRoute({ @@ -646,6 +653,15 @@ export const moderationRoutes = new Hono({ strict: false }) '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' + + 'The call and the answers arrive on this same endpoint with the same body. A ballot ' + + 'posted while no vote on `PlayerId` is open in that session CALLS one: it is stored ' + + 'with `init` 1 and is the only kind that puts the prompt to the room. A vote stays ' + + 'open for 60 seconds; every ballot inside that window answers it — recorded and ' + + 'tallied, but relayed to nobody — and only ballots from its call on are counted, so ' + + 'an expired vote’s yes ballots cannot carry the next.\n\n' + + 'Calling is THROTTLED: a player who called a vote in the last 5 minutes, in any ' + + 'session, is refused with a 429. Answering is never throttled. A `No` cannot call a ' + + 'vote — with none open it is refused with a 409.\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.', @@ -656,6 +672,8 @@ export const moderationRoutes = new Hono({ strict: false }) 400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'), 401: UNAUTHORIZED_RESPONSE, 403: json(SuccessErrorEnvelope, 'Either player is not in that game session'), + 409: json(SuccessErrorEnvelope, 'A `No` with no vote open to answer'), + 429: json(SuccessErrorEnvelope, 'The caller called a vote too recently'), 500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'), }, }), @@ -694,26 +712,43 @@ export const moderationRoutes = new Hono({ strict: false }) return c.json({ success: false, error: 'That player is not in that game session!' }, 403) } - // The room votes, so the audience is everyone standing there — the player being - // voted on included; a vote is called in front of them. The caller is dropped: - // their vote is the one they just posted. - const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter( - (id) => id !== voterId - ) + // Call or answer? Nothing in the body says, so it is whether a vote on this player is + // already open here: with none, this ballot calls one. + const response = isYesVote(formField(body, c, 'Response')) + const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId) + const init = openVote === null + if (init) { + // A late "no" to a vote that has expired would otherwise call a new one. + if (!response) { + return c.json({ success: false, error: 'There is no vote in progress!' }, 409) + } + // Only calling is throttled — this is what stops one player putting vote after + // vote to the room. Answering someone else's vote never is. + if (await isOnVoteCooldown(c.env.DB, voterId)) { + return c.json({ success: false, error: 'You are calling votes too quickly!' }, 429) + } + } // 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, { + const ballot = await recordRoomVote(c.env.DB, { gameSessionId, playerId, - voterId: voterId, - response: isYesVote(formField(body, c, 'Response')), + voterId, + response, + init, }) // 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) + // not of the room as it was when the vote was called, and players leave mid-vote. Only + // THIS vote's ballots count — from its call on. + const yesVotes = await countKickVotes( + c.env.DB, + gameSessionId, + playerId, + openVote?.id ?? ballot.id + ) const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId) if (isKickMajority(yesVotes, playerCount)) { logger.info('vote to kick carried', { @@ -728,6 +763,17 @@ export const moderationRoutes = new Hono({ strict: false }) return c.json({ success: true, error: '' }) } + // An answer is recorded and no more: the room was asked when the vote was called, and + // re-sending the prompt on every answer would raise it again for everyone. + if (!init) return c.json({ success: true, error: '' }) + + // The room votes, so the audience is everyone standing there — the player being + // voted on included; a vote is called in front of them. The caller is dropped: + // their vote is the one they just posted. + const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter( + (id) => id !== voterId + ) + // Every recipient is attempted even if an earlier one fails, so the reachable // players still get the vote. const results = await Promise.all( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index d541c70..14fdf8a 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -1,6 +1,6 @@ import { adminSecretsStore, env } from 'cloudflare:test' import { exports } from 'cloudflare:workers' -import { beforeAll, describe, expect, test } from 'vitest' +import { beforeAll, beforeEach, describe, expect, test } from 'vitest' import { addXp, @@ -5162,6 +5162,13 @@ describe('vote to kick', () => { const REVOTE_SESSION = 1014082 const TIE_SESSION = 1014083 + // Every test calls votes as 42, and calling is throttled per caller — with the ballots of + // the test before still there, each call after the first would be refused with a 429, or + // land as an answer to a vote still open from earlier. + beforeEach(async () => { + await env.DB.prepare('DELETE FROM room_vote').run() + }) + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') const standIn = async (accountId: number, roomInstanceId: number) => @@ -5205,7 +5212,13 @@ describe('vote to kick', () => { '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 }>() + .all<{ + voter_id: number + response: number + player_id: number + voted_at: string + init: number + }>() ).results // The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`. @@ -5445,6 +5458,80 @@ describe('vote to kick', () => { // 205 is still in the session, so they still hear it — only the caller is dropped. expect((await frames()).map((f) => f.playerId)).toEqual([205]) }) + + // The client posts the call and every answer alike; only the call raises the prompt. + test('the first ballot calls the vote, and answers to it put nothing to the room', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION) + + expect((await vote(FIELDS)).status).toBe(200) + expect(await frames()).toHaveLength(4) + + await hub().fetch('http://do/all', { method: 'DELETE' }) + expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200) + expect((await vote(FIELDS, '207')).status).toBe(200) + expect(await frames()).toEqual([]) + + expect((await ballots(SESSION, 205)).map((b) => [b.voter_id, b.init])).toEqual([ + [42, 1], + [206, 0], + [207, 0], + ]) + }) + + test('a player may not call vote after vote', async () => { + for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION) + + expect((await vote(FIELDS)).status).toBe(200) + // Another vote, on another player — still a CALL, so still refused. + const again = await vote({ ...FIELDS, PlayerId: '206' }) + expect(again.status).toBe(429) + expect(await again.json()).toEqual({ + success: false, + error: 'You are calling votes too quickly!', + }) + // Answering someone else's vote is never throttled... + expect((await vote({ ...FIELDS, PlayerId: '206' }, '207')).status).toBe(200) + expect((await vote({ ...FIELDS, PlayerId: '206' })).status).toBe(200) + // ...and neither is re-voting in their own. + expect((await vote(FIELDS)).status).toBe(200) + + // Once the cooldown has passed, they may call again. + await env.DB.prepare( + "UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z' WHERE voter_id = 42 AND init = 1" + ).run() + expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(200) + }) + + test('a "no" cannot call a vote', async () => { + await hub().fetch('http://do/all', { method: 'DELETE' }) + await standIn(42, SESSION) + await standIn(205, SESSION) + await standIn(206, SESSION) + + const res = await vote({ ...FIELDS, Response: 'False' }) + expect(res.status).toBe(409) + expect(await res.json()).toEqual({ success: false, error: 'There is no vote in progress!' }) + expect(await ballots(SESSION, 205)).toEqual([]) + expect(await frames()).toEqual([]) + }) + + // Otherwise yes ballots from a vote that failed would carry the next one on the same player. + test('an expired vote’s ballots do not count toward the next', async () => { + for (const id of [42, 217, 218, 219]) await standIn(id, TALLY_SESSION) + const fields = { ...FIELDS, PlayerId: '217', GameSessionId: String(TALLY_SESSION) } + + // 42 calls it: one yes of four. Then the vote expires. + expect((await vote(fields)).status).toBe(200) + await env.DB.prepare("UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z'").run() + + // 218 calls a fresh one — counting 42's old yes, that would be two of four... and 219's + // yes would make three and carry it. Without it, 219 makes two of four: a tie. + expect((await vote(fields, '218')).status).toBe(200) + expect((await vote(fields, '219')).status).toBe(200) + expect(await inSession(217, TALLY_SESSION)).toBe(true) + expect((await ballots(TALLY_SESSION, 217)).map((b) => b.init)).toEqual([1, 1, 0]) + }) }) describe('player reports', () => { diff --git a/apps/api/src/votes-db.ts b/apps/api/src/votes-db.ts index 9b6df02..e2d6574 100644 --- a/apps/api/src/votes-db.ts +++ b/apps/api/src/votes-db.ts @@ -10,12 +10,13 @@ * 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 + * The `api` worker owns this schema/migration (migrations/0023_room_vote.sql and + * 0026_room_vote_init.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). */ +/** Schema DDL (mirror of migrations/0023_room_vote.sql + 0026_room_vote_init.sql). */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS room_vote ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -23,13 +24,30 @@ export const SCHEMA_DDL: string[] = [ player_id INTEGER NOT NULL, response INTEGER NOT NULL, voter_id INTEGER NOT NULL, - voted_at TEXT NOT NULL + voted_at TEXT NOT NULL, + init INTEGER NOT NULL DEFAULT 0 )`, // 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)`, + // The caller throttle's query: the last vote one player CALLED. + `CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1`, ] -/** One cast ballot. `response` is 1 for yes (kick them) and 0 for no. */ +/** + * How long a vote stays open once it is called. Every ballot posted inside it is an answer to + * THAT vote; the first one posted after it is a new vote, with a prompt of its own. + */ +export const VOTE_WINDOW_MS = 60_000 + +/** How long a player must wait after calling a vote before they may call another, anywhere. */ +export const VOTE_CALL_COOLDOWN_MS = 5 * 60_000 + +/** + * One cast ballot. `response` is 1 for yes (kick them) and 0 for no. `init` is 1 on the ballot + * that CALLED the vote — the first cast against this player in this session once no vote was + * open — and 0 on every answer to it. The client posts both to the same endpoint and nothing + * in the body tells them apart, so the row is where a vote's start is recorded. + */ export interface RoomVoteRow { id: number game_session_id: number @@ -37,6 +55,7 @@ export interface RoomVoteRow { response: number voter_id: number voted_at: string + init: number } /** A ballot as cast — the timestamp is the table's. */ @@ -47,14 +66,16 @@ export interface NewRoomVote { voterId: number /** True to kick them. */ response: boolean + /** True when this ballot calls the vote rather than answering one. */ + init: boolean } /** Record one cast ballot, returning the stored row. */ export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise { const row = await db .prepare( - `INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at) - VALUES (?1, ?2, ?3, ?4, ?5) + `INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) RETURNING *` ) .bind( @@ -62,13 +83,50 @@ export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promis input.playerId, input.response ? 1 : 0, input.voterId, - new Date().toISOString() + new Date().toISOString(), + input.init ? 1 : 0 ) .first() // RETURNING always yields the inserted row. return row! } +/** + * The ballot that called the vote currently open on this player in this session — the latest + * `init` row, if it was cast inside `VOTE_WINDOW_MS` — or null when no vote is open. + */ +export async function getOpenVote( + db: D1Database, + gameSessionId: number, + playerId: number, + now = Date.now() +): Promise { + return db + .prepare( + `SELECT * FROM room_vote + WHERE game_session_id = ?1 AND player_id = ?2 AND init = 1 AND voted_at > ?3 + ORDER BY id DESC LIMIT 1` + ) + .bind(gameSessionId, playerId, new Date(now - VOTE_WINDOW_MS).toISOString()) + .first() +} + +/** + * Whether `voterId` called a vote — any vote, in any session — within `VOTE_CALL_COOLDOWN_MS`. + * Only CALLING is throttled: answering a vote someone else called is never refused. + */ +export async function isOnVoteCooldown( + db: D1Database, + voterId: number, + now = Date.now() +): Promise { + const row = await db + .prepare(`SELECT 1 FROM room_vote WHERE voter_id = ?1 AND init = 1 AND voted_at > ?2 LIMIT 1`) + .bind(voterId, new Date(now - VOTE_CALL_COOLDOWN_MS).toISOString()) + .first() + return row !== null +} + /** * How many DISTINCT players have voted to kick this player out of this session. * @@ -80,11 +138,16 @@ export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promis * `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. + * + * Only ballots from `sinceId` on count — the id of the `init` row that called THIS vote. A + * vote that expired without carrying leaves its yes ballots behind, and without the bound + * they would carry the next vote called on the same player. */ export async function countKickVotes( db: D1Database, gameSessionId: number, - playerId: number + playerId: number, + sinceId: number ): Promise { const row = await db .prepare( @@ -93,12 +156,12 @@ export async function countKickVotes( 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 + WHERE game_session_id = ?1 AND player_id = ?2 AND id >= ?3 GROUP BY voter_id ) ) WHERE response = 1` ) - .bind(gameSessionId, playerId) + .bind(gameSessionId, playerId, sinceId) .first<{ yes: number }>() return row?.yes ?? 0 }