[api] votekick cooldowns

This commit is contained in:
Devin Zuczek
2026-09-22 16:28:19 -04:00
parent db8ea1d056
commit 01f64f78af
4 changed files with 242 additions and 25 deletions
@@ -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;
+59 -13
View File
@@ -44,7 +44,13 @@ import {
VoteToKickRequest, VoteToKickRequest,
} from '../openapi' } from '../openapi'
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db' 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 { createWarning } from '../warnings-db'
import type { Context } from 'hono' import type { Context } from 'hono'
@@ -609,7 +615,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
// A player calling a vote to kick another. Ungated by role — anyone may start one — // 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 // 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 // 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( .post(
'/api/PlayerReporting/v3/voteToKick', '/api/PlayerReporting/v3/voteToKick',
describeRoute({ describeRoute({
@@ -646,6 +653,15 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' + 'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' +
'`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' + '`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' + '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 ' + '`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 ' + 'write; a hub failure for any recipient is reported honestly as a 500, since ' +
'with nothing behind it the frame is the whole delivery.', 'with nothing behind it the frame is the whole delivery.',
@@ -656,6 +672,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'), 400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'), 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'), 500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
}, },
}), }),
@@ -694,26 +712,43 @@ export const moderationRoutes = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'That player is not in that game session!' }, 403) 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 // Call or answer? Nothing in the body says, so it is whether a vote on this player is
// voted on included; a vote is called in front of them. The caller is dropped: // already open here: with none, this ballot calls one.
// their vote is the one they just posted. const response = isYesVote(formField(body, c, 'Response'))
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter( const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId)
(id) => id !== voterId 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 // 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 // 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. // 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, gameSessionId,
playerId, playerId,
voterId: voterId, voterId,
response: isYesVote(formField(body, c, 'Response')), response,
init,
}) })
// Counted against who is standing there RIGHT NOW: a majority is of the room as it is, // 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. // not of the room as it was when the vote was called, and players leave mid-vote. Only
const yesVotes = await countKickVotes(c.env.DB, gameSessionId, playerId) // 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) const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
if (isKickMajority(yesVotes, playerCount)) { if (isKickMajority(yesVotes, playerCount)) {
logger.info('vote to kick carried', { logger.info('vote to kick carried', {
@@ -728,6 +763,17 @@ export const moderationRoutes = new Hono<App>({ strict: false })
return c.json({ success: true, error: '' }) 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 // Every recipient is attempted even if an earlier one fails, so the reachable
// players still get the vote. // players still get the vote.
const results = await Promise.all( const results = await Promise.all(
+89 -2
View File
@@ -1,6 +1,6 @@
import { adminSecretsStore, env } from 'cloudflare:test' import { adminSecretsStore, env } from 'cloudflare:test'
import { exports } from 'cloudflare:workers' import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
import { import {
addXp, addXp,
@@ -5162,6 +5162,13 @@ describe('vote to kick', () => {
const REVOTE_SESSION = 1014082 const REVOTE_SESSION = 1014082
const TIE_SESSION = 1014083 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 hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const standIn = async (accountId: number, roomInstanceId: number) => 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' 'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id'
) )
.bind(gameSessionId, playerId) .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 ).results
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`. // 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. // 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]) 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', () => { describe('player reports', () => {
+73 -10
View File
@@ -10,12 +10,13 @@
* a ballot is keyed to its `game_session_id`, and instance ids are not reused, so an old * 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. * 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 * its own `migrations_table` so it doesn't clash with the other workers' migrations that
* share the database). * 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[] = [ export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_vote ( `CREATE TABLE IF NOT EXISTS room_vote (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -23,13 +24,30 @@ export const SCHEMA_DDL: string[] = [
player_id INTEGER NOT NULL, player_id INTEGER NOT NULL,
response INTEGER NOT NULL, response INTEGER NOT NULL,
voter_id 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. // 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)`, `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 { export interface RoomVoteRow {
id: number id: number
game_session_id: number game_session_id: number
@@ -37,6 +55,7 @@ export interface RoomVoteRow {
response: number response: number
voter_id: number voter_id: number
voted_at: string voted_at: string
init: number
} }
/** A ballot as cast — the timestamp is the table's. */ /** A ballot as cast — the timestamp is the table's. */
@@ -47,14 +66,16 @@ export interface NewRoomVote {
voterId: number voterId: number
/** True to kick them. */ /** True to kick them. */
response: boolean response: boolean
/** True when this ballot calls the vote rather than answering one. */
init: boolean
} }
/** Record one cast ballot, returning the stored row. */ /** Record one cast ballot, returning the stored row. */
export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise<RoomVoteRow> { export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise<RoomVoteRow> {
const row = await db const row = await db
.prepare( .prepare(
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at) `INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
RETURNING *` RETURNING *`
) )
.bind( .bind(
@@ -62,13 +83,50 @@ export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promis
input.playerId, input.playerId,
input.response ? 1 : 0, input.response ? 1 : 0,
input.voterId, input.voterId,
new Date().toISOString() new Date().toISOString(),
input.init ? 1 : 0
) )
.first<RoomVoteRow>() .first<RoomVoteRow>()
// RETURNING always yields the inserted row. // RETURNING always yields the inserted row.
return 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<RoomVoteRow | null> {
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<RoomVoteRow>()
}
/**
* 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<boolean> {
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. * 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 * `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 * millisecond carry the same timestamp, and the id is the order they were actually recorded
* in. * 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( export async function countKickVotes(
db: D1Database, db: D1Database,
gameSessionId: number, gameSessionId: number,
playerId: number playerId: number,
sinceId: number
): Promise<number> { ): Promise<number> {
const row = await db const row = await db
.prepare( .prepare(
@@ -93,12 +156,12 @@ export async function countKickVotes(
WHERE game_session_id = ?1 AND player_id = ?2 WHERE game_session_id = ?1 AND player_id = ?2
AND id IN ( AND id IN (
SELECT MAX(id) FROM room_vote 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 GROUP BY voter_id
) )
) WHERE response = 1` ) WHERE response = 1`
) )
.bind(gameSessionId, playerId) .bind(gameSessionId, playerId, sinceId)
.first<{ yes: number }>() .first<{ yes: number }>()
return row?.yes ?? 0 return row?.yes ?? 0
} }