mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] votekick and uniform dorm kicks
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- Vote-to-kick ballots: one row per vote cast, appended, never updated.
|
||||
--
|
||||
-- Generated from apps/api/src/votes-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `response` is 1 for yes (kick them) and 0 for no. A player who votes twice leaves two
|
||||
-- rows: the tally counts DISTINCT voters and reads only each one's latest ballot, so
|
||||
-- re-posting a vote cannot carry one, and a voter may change their mind.
|
||||
--
|
||||
-- Rows are keyed to `game_session_id` (a `room_instance` id) and outlive the session.
|
||||
-- Nothing prunes them; instance ids are not reused, so an old session's ballots can never
|
||||
-- be counted into a new one's.
|
||||
CREATE TABLE IF NOT EXISTS room_vote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
response INTEGER NOT NULL,
|
||||
voter_id INTEGER NOT NULL,
|
||||
voted_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id);
|
||||
@@ -1457,12 +1457,18 @@ export const CreateWarningRequest = z.object({
|
||||
* body — it's the bearer token's subject.
|
||||
*/
|
||||
export const VoteToKickRequest = z.object({
|
||||
PlayerId: z.string().describe('Account id of the player being voted on'),
|
||||
Response: z.string().describe('The caller’s own vote, e.g. `True`'),
|
||||
PlayerId: z
|
||||
.string()
|
||||
.describe(
|
||||
'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names'
|
||||
),
|
||||
Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'),
|
||||
Reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
|
||||
.describe(
|
||||
'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent'
|
||||
),
|
||||
GameSessionId: z.string().describe('The room instance both players are standing in'),
|
||||
})
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ import { describeRoute } from 'hono-openapi'
|
||||
import {
|
||||
canManageRoom,
|
||||
canModerateRoom,
|
||||
deletePresence,
|
||||
countPlayersInInstance,
|
||||
getPlayerIdsInInstance,
|
||||
getPresence,
|
||||
getPresences,
|
||||
getRoomById,
|
||||
getStoredRoomInstance,
|
||||
MessageType,
|
||||
refreshInstanceFullness,
|
||||
movePlayerToDorm,
|
||||
} from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
VoteToKickRequest,
|
||||
} from '../openapi'
|
||||
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
|
||||
import { countKickVotes, isKickMajority, recordRoomVote } from '../votes-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -67,6 +68,15 @@ function formField(
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted
|
||||
* beside them because the same field is an int elsewhere in this controller. Anything else —
|
||||
* absent included — is a no, which is the safe way round: an unreadable answer must not count
|
||||
* toward removing somebody.
|
||||
*/
|
||||
const isYesVote = (value: string | undefined): boolean =>
|
||||
value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase())
|
||||
|
||||
/** Parse a field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
@@ -167,27 +177,23 @@ async function pushInstantKick(
|
||||
}
|
||||
|
||||
/**
|
||||
* What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the
|
||||
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
|
||||
* string on the wire like every other Message's, and the client's decoder rejects an
|
||||
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
|
||||
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
|
||||
* What a vote-to-kick Message carries, and it is not what this once sent.
|
||||
*
|
||||
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
|
||||
* straight through, and this mirrors it verbatim.
|
||||
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
|
||||
* raises its prompt about whoever that names, so sending the caller put the voter's own name
|
||||
* on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame
|
||||
* identifies the caller, and nothing needs to: the prompt is about the accused.
|
||||
*
|
||||
* `Response` is the empty string even though the caller posted their own vote: the frame
|
||||
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
|
||||
* is theirs alone and is not relayed.
|
||||
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
|
||||
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
|
||||
* `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the
|
||||
* prompt where the reason belongs.
|
||||
*
|
||||
* It is still a STRING on the wire, which is the part that has not changed: a Message's `Data`
|
||||
* is a string like every other Message's, and an object there fails the client's decoder
|
||||
* outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification
|
||||
* rather than dropping the field. A reason is text, so it needs no escaping to satisfy that.
|
||||
*/
|
||||
interface VoteToKickData {
|
||||
PlayerId: string
|
||||
Response: string
|
||||
GameSessionId: number
|
||||
}
|
||||
|
||||
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
|
||||
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
|
||||
|
||||
/**
|
||||
* The Message a vote-to-kick frame carries — the same four fields as every other Message
|
||||
@@ -229,6 +235,64 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a
|
||||
* `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do.
|
||||
*
|
||||
* It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and
|
||||
* `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the
|
||||
* player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the
|
||||
* session they are in and nothing more, so they may walk straight back in, and a room that
|
||||
* wants them gone for good bans them.
|
||||
*
|
||||
* `VoteKickReason` carries the reason the vote was called on, which is the field the client
|
||||
* has for it — unlike the staff kicks, which leave it empty and put their text in `Message`.
|
||||
*
|
||||
* Best-effort, and deliberately after the ballot is recorded: the vote happened and was
|
||||
* counted whatever the hub does next.
|
||||
*/
|
||||
async function applyVoteKick(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
gameSessionId: number,
|
||||
reason: string,
|
||||
carriedByAccountId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
const roomName = typeof room?.Name === 'string' ? room.Name : 'this room'
|
||||
|
||||
const frame: ModerationKickPayload = {
|
||||
ReportCategory: KickReportCategory.VoteKick,
|
||||
Duration: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: false,
|
||||
Message: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: carriedByAccountId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: reason,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
[playerId],
|
||||
NotificationType.ModerationKick,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to carry out a vote kick', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
||||
@@ -443,17 +507,28 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
|
||||
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
|
||||
'already, and their own `Response` is what they posted.\n\n' +
|
||||
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
|
||||
'object. A Message’s `Data` is a string on the wire, and an object there fails the ' +
|
||||
"client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " +
|
||||
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
|
||||
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
|
||||
'empty — the frame is the question, not an answer.\n\n' +
|
||||
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
|
||||
'client raises its prompt about whoever it names, so the caller there asks the room ' +
|
||||
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
|
||||
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
|
||||
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
|
||||
"actual:'{'`) and aborts the whole notification.\n\n" +
|
||||
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
|
||||
'offline player gets nothing rather than a prompt about a dead session on their ' +
|
||||
'next connect.\n\n' +
|
||||
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
|
||||
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
|
||||
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
|
||||
'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' +
|
||||
'strictly more than half the players standing in the session right now (a tie is not ' +
|
||||
'a majority, and the count is of the room as it is, since players leave mid-vote). ' +
|
||||
'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' +
|
||||
're-posting a vote cannot carry one and a voter may change their mind.\n\n' +
|
||||
'When it carries, the player is kicked exactly as a staff kick does it — moved into ' +
|
||||
'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' +
|
||||
'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' +
|
||||
'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' +
|
||||
'`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.',
|
||||
security: AUTHED,
|
||||
@@ -471,8 +546,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
if (voterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
|
||||
// reference relays — but parsed here to check it against presence.
|
||||
const playerIdField = formField(body, c, 'PlayerId')
|
||||
const playerId = asInt(playerIdField)
|
||||
if (playerIdField === undefined || playerId === null) {
|
||||
@@ -483,6 +556,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
|
||||
// The prompt's own text. Absent is an empty string rather than a refusal: a vote with
|
||||
// no reason given is still a vote, and the client picks from a fixed list anyway.
|
||||
const reason = formField(body, c, 'Reason') ?? ''
|
||||
|
||||
// One read for both players. A vote may only be called by someone standing in the
|
||||
// session, about someone standing in the same one — the session is read from live
|
||||
// presence, never from the body, so neither side can be asserted by the client.
|
||||
@@ -506,20 +583,45 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
(id) => id !== voterId
|
||||
)
|
||||
|
||||
// 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, {
|
||||
gameSessionId,
|
||||
playerId,
|
||||
voterId: voterId,
|
||||
response: isYesVote(formField(body, c, 'Response')),
|
||||
})
|
||||
|
||||
// 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)
|
||||
const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
|
||||
if (isKickMajority(yesVotes, playerCount)) {
|
||||
logger.info('vote to kick carried', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
yesVotes,
|
||||
playerCount,
|
||||
carriedBy: voterId,
|
||||
})
|
||||
await applyVoteKick(c, playerId, gameSessionId, reason, voterId)
|
||||
// The room is not asked a question that has already been answered.
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
|
||||
// Every recipient is attempted even if an earlier one fails, so the reachable
|
||||
// players still get the vote.
|
||||
const results = await Promise.all(
|
||||
audience.map((toPlayerId) =>
|
||||
pushVoteToKick(c, {
|
||||
FromPlayerId: voterId,
|
||||
// The player being VOTED ON — the client raises its prompt about this id, so
|
||||
// the caller's own id here asks the room to kick the wrong player.
|
||||
FromPlayerId: playerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: MessageType.VoteToKick,
|
||||
// An escaped JSON STRING, not a nested object — see VoteToKickData.
|
||||
Data: voteToKickData({
|
||||
PlayerId: playerIdField,
|
||||
Response: '',
|
||||
GameSessionId: gameSessionId,
|
||||
}),
|
||||
// The reason as plain text; see the note above the Message type.
|
||||
Data: reason,
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -553,8 +655,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
|
||||
'in silence, so naming an account id cannot reach into a session the caller has ' +
|
||||
'no authority over.\n\n' +
|
||||
'Each kicked player loses their presence row (they read offline at once and the ' +
|
||||
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
|
||||
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
|
||||
'so their client lands where it already is instead of loading the dorm a second ' +
|
||||
'time — and the instance they left frees a slot. They also get a `ModerationKick` ' +
|
||||
'frame (id 22) — the frame the ' +
|
||||
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
|
||||
'false: this only removes them from the session they are in, and nothing stops ' +
|
||||
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
|
||||
@@ -614,14 +718,15 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// moderator could throw the creator out of it. Nor is the caller themselves.
|
||||
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
|
||||
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
|
||||
await deletePresence(c.env.DB, playerId)
|
||||
// Moved to their own dorm rather than having their presence deleted: the client
|
||||
// goes to the dorm either way, and with no presence to read it arrives not knowing
|
||||
// where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the
|
||||
// fullness of the instance they left, so this one no longer has to.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
kicked.push(playerId)
|
||||
}
|
||||
|
||||
if (kicked.length > 0) {
|
||||
// The instance just lost players — recompute its fullness so a full room opens
|
||||
// back up, exactly as the `match` worker does when someone logs out.
|
||||
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
|
||||
}
|
||||
@@ -657,8 +762,9 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'account id cannot reach into a session in another room, or another instance of this ' +
|
||||
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
|
||||
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
|
||||
'A kick deletes the player’s presence row (they read offline at once and the instance ' +
|
||||
'frees a slot, its fullness recomputed) and sends them an EPHEMERAL `ModerationKick` ' +
|
||||
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
|
||||
'so their client does not load the dorm twice — frees the slot they held, and sends ' +
|
||||
'them an EPHEMERAL `ModerationKick` ' +
|
||||
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
|
||||
'true when the caller runs the room, false for a staff account acting in a room it ' +
|
||||
'does not.\n\n' +
|
||||
@@ -719,8 +825,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: false, error: 'That player is not in this game session!' })
|
||||
}
|
||||
|
||||
await deletePresence(c.env.DB, playerId)
|
||||
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
|
||||
reason,
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
spendCheerCredit,
|
||||
} from '../../reputation-db'
|
||||
import { charadesWordsFor } from '../../routes/gameplay'
|
||||
import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db'
|
||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
@@ -170,6 +171,8 @@ beforeAll(async () => {
|
||||
|
||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Vote-to-kick ballots (owned by the api worker) — one row per vote cast.
|
||||
for (const stmt of VOTES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Platform identity links (owned by the auth worker) — the sharp arm of the
|
||||
// ban-evasion resolution matches on them.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
@@ -4541,6 +4544,24 @@ describe('instant kick', () => {
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
const isFull = async (roomInstanceId: number) =>
|
||||
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
|
||||
.bind(roomInstanceId)
|
||||
@@ -4572,9 +4593,19 @@ describe('instant kick', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Presence is deleted, so they read offline at once — and only theirs is.
|
||||
expect(await isPresent(205)).toBe(false)
|
||||
expect(await isPresent(206)).toBe(true)
|
||||
// Out of the session — and into their own DORM, rather than having their presence
|
||||
// deleted: with nothing to read the client lands not knowing where it is and loads the
|
||||
// dorm a second time. 206 is untouched.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
expect(await isPresent(205)).toBe(true)
|
||||
const dorm = await presenceInstance(205)
|
||||
// The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player
|
||||
// is not joining a session, and minting one per kick would put live dorm sessions on the
|
||||
// books that nothing ever joins.
|
||||
expect(dorm.roomInstanceId).toBe(-2)
|
||||
expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom
|
||||
expect(dorm.isPrivate).toBe(true)
|
||||
expect(await inSession(206, SESSION)).toBe(true)
|
||||
// The instance lost a player, so it is no longer full.
|
||||
expect(await isFull(SESSION)).toBe(false)
|
||||
|
||||
@@ -4624,7 +4655,7 @@ describe('instant kick', () => {
|
||||
|
||||
// 43 holds Moderator (20) on the room.
|
||||
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
|
||||
expect(await isPresent(209)).toBe(false)
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
await standIn(209, SESSION)
|
||||
// 44 is only a Host (10), and 99 holds nothing at all.
|
||||
@@ -4633,7 +4664,7 @@ describe('instant kick', () => {
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await isPresent(209)).toBe(true)
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||
method: 'POST',
|
||||
@@ -4653,8 +4684,8 @@ describe('instant kick', () => {
|
||||
// 43 (a moderator) names the creator, a fellow moderator and themselves.
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await isPresent(42)).toBe(true)
|
||||
expect(await isPresent(43)).toBe(true)
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(43, SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -4731,11 +4762,24 @@ describe('room mod kick', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const isPresent = async (accountId: number) =>
|
||||
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
// The client's form body.
|
||||
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
@@ -4765,7 +4809,11 @@ describe('room mod kick', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
expect(await isPresent(205)).toBe(false)
|
||||
// Out of the session and into their own dorm — not deleted.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
const kickedTo = await presenceInstance(205)
|
||||
expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel
|
||||
expect(kickedTo.roomInstanceType).toBe(2) // Dormroom
|
||||
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
|
||||
.bind(SESSION)
|
||||
.first<{ f: number }>()
|
||||
@@ -4805,8 +4853,8 @@ describe('room mod kick', () => {
|
||||
error: 'That player is not in this game session!',
|
||||
})
|
||||
}
|
||||
expect(await isPresent(206)).toBe(true)
|
||||
expect(await isPresent(207)).toBe(true)
|
||||
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
|
||||
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
|
||||
const cross = await modKick(
|
||||
@@ -4814,7 +4862,7 @@ describe('room mod kick', () => {
|
||||
'42'
|
||||
)
|
||||
expect(cross.status).toBe(403)
|
||||
expect(await isPresent(207)).toBe(true)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -4826,7 +4874,7 @@ describe('room mod kick', () => {
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
|
||||
).toEqual({ success: true, error: '' })
|
||||
expect(await isPresent(209)).toBe(false)
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
// 999 has no role on the room but carries the staff `moderator` role — and is not the
|
||||
// host, so the frame says so.
|
||||
@@ -4837,7 +4885,7 @@ describe('room mod kick', () => {
|
||||
'moderator',
|
||||
])
|
||||
expect(await staff.json()).toEqual({ success: true, error: '' })
|
||||
expect(await isPresent(209)).toBe(false)
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
|
||||
|
||||
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
|
||||
@@ -4848,7 +4896,7 @@ describe('room mod kick', () => {
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await isPresent(209)).toBe(true)
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
method: 'POST',
|
||||
@@ -4875,8 +4923,8 @@ describe('room mod kick', () => {
|
||||
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
|
||||
).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
expect(await isPresent(42)).toBe(true)
|
||||
expect(await isPresent(45)).toBe(true)
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(45, SESSION)).toBe(true)
|
||||
})
|
||||
|
||||
test('an unknown session 404s, and the body must name a session and a player', async () => {
|
||||
@@ -4901,9 +4949,15 @@ describe('room mod kick', () => {
|
||||
|
||||
describe('vote to kick', () => {
|
||||
// Two live sessions, so a vote called in one can be checked against a player in the
|
||||
// other. Nothing reads `room_instance` here — the gate is presence alone.
|
||||
// other. The gate is presence alone; `room_instance` is read only to name the room in a
|
||||
// carried vote's kick frame.
|
||||
const SESSION = 1014079
|
||||
const OTHER_SESSION = 1014080
|
||||
// The tally tests get sessions of their own: ballots are appended and never pruned, so
|
||||
// votes cast by the tests above would otherwise be counted into theirs.
|
||||
const TALLY_SESSION = 1014081
|
||||
const REVOTE_SESSION = 1014082
|
||||
const TIE_SESSION = 1014083
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
@@ -4923,6 +4977,34 @@ describe('vote to kick', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const isPresent = async (accountId: number) =>
|
||||
(await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1"
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A carried vote moves the player into their own dorm rather than deleting their presence,
|
||||
// so "kicked" is "no longer standing in that session".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE json_extract(data, '$.accountId') = ?1
|
||||
AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** Every ballot cast against one player in one session, oldest first. */
|
||||
const ballots = async (gameSessionId: number, playerId: number) =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'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 }>()
|
||||
).results
|
||||
|
||||
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
|
||||
const vote = async (fields: Record<string, string>, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
||||
@@ -4960,17 +5042,20 @@ describe('vote to kick', () => {
|
||||
|
||||
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
|
||||
// called in front of them), the caller does not, and 207 is in another session.
|
||||
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
|
||||
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
|
||||
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
|
||||
// and `Response` is empty because the frame is the question, not an answer.
|
||||
//
|
||||
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
|
||||
// raises its prompt about whoever that names, so the caller there asks the room to kick
|
||||
// the wrong player. `Data` is the REASON as plain text — it is what the prompt shows,
|
||||
// and it used to be a JSON blob of the ids, which players saw printed on the prompt. It
|
||||
// is still a string on the wire: an object fails the client's decoder
|
||||
// (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it.
|
||||
const message = {
|
||||
ephemeral: true,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: {
|
||||
FromPlayerId: 42,
|
||||
FromPlayerId: 205,
|
||||
Type: MessageType.VoteToKick,
|
||||
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
|
||||
Data: 'Inactive in games (AFK)',
|
||||
},
|
||||
}
|
||||
const sent = await frames()
|
||||
@@ -4987,6 +5072,109 @@ describe('vote to kick', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('a vote with no reason still goes out, with an empty Data', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const { Reason: _reason, ...noReason } = FIELDS
|
||||
expect((await vote(noReason)).status).toBe(200)
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent.every((f) => f.data.Data === '')).toBe(true)
|
||||
// Still about the player voted on, reason or no reason.
|
||||
expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true)
|
||||
})
|
||||
|
||||
// A vote is recorded whether or not it carries — the row is the record of who asked.
|
||||
test('the caller’s ballot is recorded', async () => {
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
const before = await ballots(SESSION, 205)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
const after = await ballots(SESSION, 205)
|
||||
expect(after).toHaveLength(before.length + 1)
|
||||
expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 })
|
||||
expect(typeof after.at(-1)!.voted_at).toBe('string')
|
||||
|
||||
// `Response=False` is recorded as a no rather than dropped.
|
||||
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
|
||||
expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 })
|
||||
})
|
||||
|
||||
// The point of the tally: enough of the room says yes and the player goes, without any
|
||||
// moderator being involved.
|
||||
test('a majority of the room carries the vote and kicks the player', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// Three in the session: 42 calls it on 210, 211 is the other voter.
|
||||
for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) }
|
||||
|
||||
// One yes of three is not a majority — the room is asked instead.
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(true)
|
||||
expect((await frames()).every((f) => f.notificationType === 2)).toBe(true)
|
||||
|
||||
// The second yes carries it: 2 of 3 is strictly more than half.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await vote(fields, '211')).status).toBe(200)
|
||||
|
||||
// Kicked exactly as a staff kick does it — out of the session and into their own dorm,
|
||||
// their presence rewritten rather than deleted...
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(false)
|
||||
expect(await isPresent(210)).toBe(true)
|
||||
expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel
|
||||
// ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered.
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toMatchObject({
|
||||
playerIds: [210],
|
||||
ephemeral: true,
|
||||
notificationType: 22, // NotificationType.ModerationKick
|
||||
data: {
|
||||
ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them
|
||||
IsHostKick: false,
|
||||
IsBan: false, // nothing stops them coming back
|
||||
Duration: 0,
|
||||
GameSessionId: TALLY_SESSION,
|
||||
PlayerIdReporter: 211, // whose ballot carried it
|
||||
VoteKickReason: 'Inactive in games (AFK)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Otherwise one player could carry a vote alone by posting it repeatedly.
|
||||
test('re-posting a vote cannot carry it, and a voter may change their mind', async () => {
|
||||
for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) }
|
||||
|
||||
// Three yes votes from one voter is still one voter.
|
||||
for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200)
|
||||
expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(true)
|
||||
|
||||
// 213 says yes — 2 of 3 — and it carries.
|
||||
expect((await vote(fields, '213')).status).toBe(200)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('a tie is not a majority', async () => {
|
||||
// Four in the session, so two yes votes are exactly half.
|
||||
for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) }
|
||||
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect((await vote(fields, '215')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(true)
|
||||
|
||||
// The third yes is more than half, and carries.
|
||||
expect((await vote(fields, '216')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('both players have to be standing in the session', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, OTHER_SESSION)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Vote-to-kick ballots on the shared `recflare` D1 database.
|
||||
*
|
||||
* One row per vote cast: who was voted on, in which game session, by whom, and which way.
|
||||
* Append-only like the `report` and `warning` tables (see reports-db.ts), so a vote is a
|
||||
* record of what happened rather than a tally that gets rewritten — the tally is computed
|
||||
* from the rows.
|
||||
*
|
||||
* The rows outlive the vote. Nothing deletes them when a session ends, and nothing needs to:
|
||||
* 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
|
||||
* 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). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_vote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
response INTEGER NOT NULL,
|
||||
voter_id INTEGER NOT NULL,
|
||||
voted_at TEXT NOT NULL
|
||||
)`,
|
||||
// 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)`,
|
||||
]
|
||||
|
||||
/** One cast ballot. `response` is 1 for yes (kick them) and 0 for no. */
|
||||
export interface RoomVoteRow {
|
||||
id: number
|
||||
game_session_id: number
|
||||
player_id: number
|
||||
response: number
|
||||
voter_id: number
|
||||
voted_at: string
|
||||
}
|
||||
|
||||
/** A ballot as cast — the timestamp is the table's. */
|
||||
export interface NewRoomVote {
|
||||
gameSessionId: number
|
||||
/** The player being voted on. */
|
||||
playerId: number
|
||||
voterId: number
|
||||
/** True to kick them. */
|
||||
response: 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)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.gameSessionId,
|
||||
input.playerId,
|
||||
input.response ? 1 : 0,
|
||||
input.voterId,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<RoomVoteRow>()
|
||||
// RETURNING always yields the inserted row.
|
||||
return row!
|
||||
}
|
||||
|
||||
/**
|
||||
* How many DISTINCT players have voted to kick this player out of this session.
|
||||
*
|
||||
* Distinct voters, not rows, and only each voter's LATEST ballot: the table is append-only,
|
||||
* so a player who votes twice leaves two rows, and counting rows would let one person carry
|
||||
* a vote on their own by posting it repeatedly. Counting their latest also lets someone
|
||||
* change their mind — a yes followed by a no is a no.
|
||||
*
|
||||
* `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.
|
||||
*/
|
||||
export async function countKickVotes(
|
||||
db: D1Database,
|
||||
gameSessionId: number,
|
||||
playerId: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS yes FROM (
|
||||
SELECT response FROM room_vote
|
||||
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
|
||||
GROUP BY voter_id
|
||||
)
|
||||
) WHERE response = 1`
|
||||
)
|
||||
.bind(gameSessionId, playerId)
|
||||
.first<{ yes: number }>()
|
||||
return row?.yes ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `yesVotes` carries a vote in a session holding `playerCount` players: STRICTLY more
|
||||
* than half. A tie is not a majority, and a session whose presence count has somehow reached
|
||||
* zero can never carry one — `0 > 0` is false — rather than every vote passing unopposed.
|
||||
*/
|
||||
export const isKickMajority = (yesVotes: number, playerCount: number): boolean =>
|
||||
playerCount > 0 && yesVotes > playerCount / 2
|
||||
Reference in New Issue
Block a user