mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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,
|
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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user