mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[api] votekick cooldowns
This commit is contained in:
@@ -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;
|
||||
@@ -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<App>({ 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<App>({ 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<App>({ 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<App>({ 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<App>({ 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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+73
-10
@@ -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<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)
|
||||
`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<RoomVoteRow>()
|
||||
// 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<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.
|
||||
*
|
||||
@@ -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<number> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user