mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[api] votekick cooldowns
This commit is contained in:
+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