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:
@@ -187,9 +187,15 @@ inconsistency here without checking the client first.
|
||||
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
|
||||
object. An object there does not degrade: the client's decoder rejects it outright
|
||||
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
|
||||
the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
|
||||
whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside
|
||||
it, as the reference relays it) and, the same way, a chat message's `Contents`.
|
||||
the field. Bites a chat message's `Contents` the same way.
|
||||
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
|
||||
being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame
|
||||
names — because the client raises its prompt about whoever that field names. Sending the
|
||||
voter asks the room to kick the player who called the vote. Its `Data` is the posted
|
||||
`Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it
|
||||
once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON
|
||||
printed where the reason belongs. `Data` is still a string on the wire — that part of the
|
||||
rule above holds; a reason simply needs no escaping to satisfy it.
|
||||
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
|
||||
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
|
||||
place and the top of a board must be 0. Its own slice says the same: it asks for the first
|
||||
|
||||
@@ -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
|
||||
+18
-16
@@ -15,7 +15,6 @@ import {
|
||||
countRoomsByCreator,
|
||||
createNotification,
|
||||
createSubRoom,
|
||||
deletePresence,
|
||||
deleteRoom,
|
||||
deleteRoomLeaderboard,
|
||||
deleteSubRoom,
|
||||
@@ -49,8 +48,8 @@ import {
|
||||
isRoomOwner,
|
||||
MessageType,
|
||||
modifySubRoom,
|
||||
movePlayerToDorm,
|
||||
publishSubRoomSave,
|
||||
refreshInstanceFullness,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
removeRoomRole,
|
||||
@@ -621,18 +620,19 @@ async function pushRoleInvite(
|
||||
const REPORT_CATEGORY_MODERATOR = -1
|
||||
|
||||
/**
|
||||
* Take a freshly banned player out of the room's LIVE PRESENCE, so the ban takes effect on
|
||||
* everything that reads presence right now rather than when their row next expires. The kick
|
||||
* push ejects their client, but until the row goes they still count toward the instance's
|
||||
* player total (keeping a full room full), still show as standing in the room to friends, and
|
||||
* still pass the presence-based grants — reading the room's staged saves among them.
|
||||
* Move a freshly banned player out of the room and into their own DORM, so the ban takes
|
||||
* effect on everything that reads presence right now rather than when their row next expires.
|
||||
* The kick push ejects their client, but until presence moves they still count toward the
|
||||
* instance's player total (keeping a full room full), still show as standing in the room to
|
||||
* friends, and still pass the presence-based grants — reading the room's staged saves among
|
||||
* them.
|
||||
*
|
||||
* Only when their presence puts them in THIS room. A player banned from a room they are not
|
||||
* standing in keeps their presence: a ban here is no reason to knock them offline from wherever
|
||||
* they actually are. The same rule the instant kick in `api` applies to its instance.
|
||||
*
|
||||
* The instance they left gets its fullness recomputed, as every other presence removal does, so
|
||||
* a room they were filling opens back up. Best-effort like the push: the ban row is already
|
||||
* The instance they left gets its fullness recomputed, as every other departure does, so a room
|
||||
* they were filling opens back up. Best-effort like the push: the ban row is already
|
||||
* committed, and a failure here must not turn a ban that happened into an error response.
|
||||
*
|
||||
* Returns the instance they were removed from, or null when they were not standing in the
|
||||
@@ -642,10 +642,11 @@ async function evictBannedPlayer(c: Context<App>, ban: RoomBan): Promise<number
|
||||
try {
|
||||
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
|
||||
if (instance?.roomId !== ban.RoomId) return null
|
||||
await deletePresence(c.env.DB, ban.BannedPlayerId)
|
||||
if (instance.roomInstanceId == null) return null
|
||||
await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)
|
||||
return instance.roomInstanceId
|
||||
// Moved to their own dorm rather than deleted: the client goes there either way, and with
|
||||
// no presence to read it arrives not knowing where it is and loads the dorm a second
|
||||
// time. This also recomputes the fullness of the instance they left.
|
||||
await movePlayerToDorm(c.env.DB, ban.BannedPlayerId)
|
||||
return instance.roomInstanceId ?? null
|
||||
} catch (err) {
|
||||
logger.error('failed to clear a banned player’s presence', {
|
||||
playerId: ban.BannedPlayerId,
|
||||
@@ -2593,9 +2594,10 @@ const app = new Hono<App>()
|
||||
'the ban list — without anyone lifting it. Re-banning REPLACES the reason and',
|
||||
'duration and restarts the clock; it does not extend the remaining time.',
|
||||
'',
|
||||
'If the banned player’s live presence puts them in THIS room, that presence row is',
|
||||
'deleted and the instance’s fullness recomputed, so they stop counting as standing in',
|
||||
'the room immediately. Presence in some other room is left alone.',
|
||||
'If the banned player’s live presence puts them in THIS room, they are moved into their',
|
||||
'own DORM — presence rewritten rather than deleted, so their client does not load the',
|
||||
'dorm twice — and the instance they left frees a slot. Presence in some other room is',
|
||||
'left alone.',
|
||||
'',
|
||||
'A banned player standing in the room also gets a `ModerationKick` frame (id 22),',
|
||||
'the frame the client acts on to leave, with `gameSessionId` the instance they were',
|
||||
|
||||
@@ -2552,11 +2552,14 @@ describe('rooms endpoints', () => {
|
||||
expect(rebanned.value).toMatchObject({ Reason: null, ExpiresAt: null })
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/bans deletes the banned player’s presence in that room', async () => {
|
||||
it('POST /rooms/:id/bans moves the banned player out of that room, into their dorm', async () => {
|
||||
const presenceOf = (accountId: number) =>
|
||||
env.DB.prepare('SELECT room_id FROM presence WHERE account_id = ?1')
|
||||
env.DB.prepare(
|
||||
`SELECT room_id, json_extract(data, '$.roomInstance.roomInstanceId') AS instance
|
||||
FROM presence WHERE account_id = ?1`
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ room_id: number }>()
|
||||
.first<{ room_id: number; instance: number }>()
|
||||
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
|
||||
const isFull = async () =>
|
||||
(
|
||||
@@ -2582,13 +2585,19 @@ describe('rooms endpoints', () => {
|
||||
await putInRoom(321, 4)
|
||||
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200)
|
||||
// Gone from the room they were banned from, and the seat they held is free again.
|
||||
expect(await presenceOf(320)).toBeNull()
|
||||
// Out of the room they were banned from, and the seat they held is free again. Their
|
||||
// presence is REWRITTEN to their own dorm rather than deleted: with none to read, the
|
||||
// client arrives at the dorm not knowing where it is and loads it a second time.
|
||||
const moved = await presenceOf(320)
|
||||
expect(moved).not.toBeNull()
|
||||
expect(moved!.room_id).not.toBe(2)
|
||||
// The OFFLINE dorm sentinel, not a live dorm session minted per ban.
|
||||
expect(moved!.instance).toBe(-2)
|
||||
expect(await isFull()).toBe(0)
|
||||
|
||||
// A ban from room 2 does not knock someone offline from room 4.
|
||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '321' }, '1')).status).toBe(200)
|
||||
expect(await presenceOf(321)).toEqual({ room_id: 4 })
|
||||
expect((await presenceOf(321))?.room_id).toBe(4)
|
||||
|
||||
await clearPresence(321)
|
||||
await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deletePresence, getPresences, refreshInstanceFullness } from '@repo/domain'
|
||||
import { getPresences, movePlayerToDorm } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
@@ -173,8 +173,11 @@ function banExpiry(
|
||||
* none. EPHEMERAL, because an offline player needs no frame — they meet the same screen at
|
||||
* `moderationBlockDetails` when they next sign in — and a queued one would fire again then.
|
||||
*
|
||||
* Their presence row is deleted (they read offline at once), and the instance they were in,
|
||||
* if any, has its fullness recomputed so a full room frees a slot.
|
||||
* They are moved into their own DORM rather than having their presence deleted — the same move
|
||||
* every kick makes, and the one place a banned player is still allowed to be: `match` lets them
|
||||
* matchmake there and nowhere else, which is how they reach this screen at all. Deleting the row
|
||||
* instead left the client arriving at the dorm not knowing where it was, loading it a second
|
||||
* time. The instance they were in, if any, frees a slot.
|
||||
*
|
||||
* Entirely best-effort. The ban row is already committed by the time this runs; a hub hiccup
|
||||
* or a missing presence must not fail a ban that has been handed down.
|
||||
@@ -188,9 +191,10 @@ async function kickBannedPlayer(c: Context<App>, report: ReportRow, moderatorId:
|
||||
// Offline: nothing to eject and nobody to tell. The sign-in check has them.
|
||||
if (!presence) return
|
||||
|
||||
await deletePresence(c.env.DB, playerId)
|
||||
// Read before the move, so the frame names the session they were thrown out of rather
|
||||
// than the dorm they landed in. `movePlayerToDorm` recomputes that instance's fullness.
|
||||
const gameSessionId = presence.roomInstance?.roomInstanceId
|
||||
if (gameSessionId !== undefined) await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
|
||||
const block = banBlockDetails(report)
|
||||
const frame: ModerationKickPayload = {
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
setPresence,
|
||||
} from '@repo/domain/src/presence-db'
|
||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-db'
|
||||
// Banning a player moves them into their own dorm, which is a room (created on demand) with
|
||||
// a subroom — so those tables have to be here, as they are on the shared database.
|
||||
import { ROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL } from '@repo/domain/src/rooms-db'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
@@ -87,6 +90,8 @@ beforeAll(async () => {
|
||||
for (const stmt of REPORT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of WARNING_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of SUBROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
@@ -947,12 +952,15 @@ it('throws a banned player out of the instance they are standing in', async () =
|
||||
Duration: 3 * 86_400,
|
||||
})
|
||||
|
||||
// And their presence row is gone, so they read offline at once and the instance frees
|
||||
// a slot.
|
||||
// And they are out of that instance — moved into their own DORM, not deleted: `match`
|
||||
// lets a banned player matchmake there and nowhere else, so it is where they read the
|
||||
// block screen, and with no presence at all the client loads the dorm a second time.
|
||||
const presence = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
||||
).first<{ n: number }>()
|
||||
expect(presence?.n).toBe(0)
|
||||
"SELECT json_extract(data, '$.roomInstance.roomInstanceId') AS instance FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
||||
).first<{ instance: number | null }>()
|
||||
expect(presence).not.toBeNull()
|
||||
// The OFFLINE dorm sentinel — a room the client loads locally, with no session behind it.
|
||||
expect(presence?.instance).toBe(-2)
|
||||
})
|
||||
|
||||
// Online but not standing in any room — in a menu — is still banned from the game, and is
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Sending a player back to their own dorm — the move every kick makes.
|
||||
*
|
||||
* A kick used to DELETE the kicked player's presence row. The client goes to the dorm on its
|
||||
* own either way, but with no presence to read it arrives without knowing where it is and
|
||||
* loads the dorm a second time, so the player watches it appear twice. Writing the dorm into
|
||||
* presence instead leaves them where they are actually standing, and the extra load goes away.
|
||||
*
|
||||
* The presence written names the OFFLINE DORM: the player's own dorm room, with the sentinel
|
||||
* instance id {@link OFFLINE_DORM_INSTANCE_ID} (-2) in place of a `room_instance` row. That is
|
||||
* the same shape `auth` seeds a brand-new player's Orientation presence with — a room the
|
||||
* client loads locally, with no session behind it and nobody else in it. A kicked player is
|
||||
* not joining a session, so minting a real instance for them said otherwise: it put a live
|
||||
* dorm session on the books, one per kick, that nothing would ever join.
|
||||
*
|
||||
* Lives in `@repo/domain` rather than in `match` because the callers are the workers that
|
||||
* kick: `api`'s moderation kicks and vote-kicks, `rooms`' ban, and `www`'s server ban.
|
||||
*/
|
||||
|
||||
import { RoomInstanceType } from './enums'
|
||||
import { GAME_VERSION, getPresence, setPresence } from './presence-db'
|
||||
import { refreshInstanceFullness } from './room-instance-db'
|
||||
import { getOrCreateDormRoom, subRoomDataBlob } from './rooms-db'
|
||||
|
||||
import type { SubRoom } from './rooms-db'
|
||||
|
||||
/**
|
||||
* The sentinel `roomInstanceId` for a room the client is in WITHOUT a live session — what
|
||||
* `auth` seeds Orientation with, and what a kicked player's dorm carries.
|
||||
*
|
||||
* It must be this exact value on the wire: the heartbeat echoes the id back, and a client
|
||||
* whose presence names an instance it isn't in treats presence as out of sync and bounces the
|
||||
* player to the dorm — the second load this whole helper exists to avoid. `match` also reads
|
||||
* it directly, leaving a logout that still points at -2 alone.
|
||||
*/
|
||||
export const OFFLINE_DORM_INSTANCE_ID = -2
|
||||
|
||||
/** The instance shape a presence row carries — what `match` writes and `/matchmake/none` serves. */
|
||||
export interface PresenceRoomInstance {
|
||||
roomInstanceId: number
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
roomInstanceType: number
|
||||
location: string
|
||||
dataBlob: string
|
||||
eventId: number
|
||||
clubId: number
|
||||
roomCode: string
|
||||
photonRegion: string
|
||||
photonRegionId: string
|
||||
photonRoomId: string
|
||||
name: string
|
||||
maxCapacity: number
|
||||
isFull: boolean
|
||||
isPrivate: boolean
|
||||
isInProgress: boolean
|
||||
EncryptVoiceChat: boolean
|
||||
}
|
||||
|
||||
/** What a kicked player's presence says about the room they came from. */
|
||||
interface LeftInstance {
|
||||
roomInstanceId?: number
|
||||
photonRegionId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a player's presence into their own offline dorm, creating the dorm room if they have
|
||||
* none, and return the instance written.
|
||||
*
|
||||
* Everything else about their presence is carried forward — device class, platform, build,
|
||||
* status visibility and the session's login lock — because none of it changed by being kicked;
|
||||
* only where they are standing did. A player with NO live presence is left alone and `null` is
|
||||
* returned: they are offline, and there is nothing to move.
|
||||
*
|
||||
* The instance they left has its fullness recomputed, as every other departure does, so the
|
||||
* room they were kicked out of frees a slot.
|
||||
*/
|
||||
export async function movePlayerToDorm(
|
||||
db: D1Database,
|
||||
accountId: number
|
||||
): Promise<PresenceRoomInstance | null> {
|
||||
const prev = await getPresence<LeftInstance>(db, accountId)
|
||||
if (!prev) return null
|
||||
|
||||
const room = await getOrCreateDormRoom(db, accountId)
|
||||
const roomId = typeof room.RoomId === 'number' ? room.RoomId : 0
|
||||
const sub = (Array.isArray(room.SubRooms) ? room.SubRooms : [])[0] as SubRoom | undefined
|
||||
// Inherited rather than configured: these workers have no Photon bindings, and there is no
|
||||
// session to connect to anyway — the dorm is loaded locally.
|
||||
const photonRegionId = prev.roomInstance?.photonRegionId || 'us'
|
||||
|
||||
const roomInstance: PresenceRoomInstance = {
|
||||
roomInstanceId: OFFLINE_DORM_INSTANCE_ID,
|
||||
roomId,
|
||||
subRoomId: typeof sub?.SubRoomId === 'number' ? sub.SubRoomId : 1,
|
||||
roomInstanceType: RoomInstanceType.Dormroom,
|
||||
location: typeof sub?.UnitySceneId === 'string' ? sub.UnitySceneId : '',
|
||||
dataBlob: subRoomDataBlob(sub),
|
||||
eventId: 0,
|
||||
clubId: 0,
|
||||
roomCode: '',
|
||||
photonRegion: photonRegionId,
|
||||
photonRegionId,
|
||||
// Derived from the room like Orientation's, rather than a fresh GUID: there is no Photon
|
||||
// session here, and a new id per kick would suggest one.
|
||||
photonRoomId: `rec.${roomId}`,
|
||||
// A dorm's name carries the owner prefix (`@<user>'s Dorm`) and must NOT also get the
|
||||
// `^` an ordinary instance name takes — see `instanceFieldsFromRoom` in `match`.
|
||||
name: typeof room.Name === 'string' ? room.Name : '',
|
||||
maxCapacity: typeof sub?.MaxPlayers === 'number' ? sub.MaxPlayers : 4,
|
||||
isFull: false,
|
||||
isPrivate: true,
|
||||
isInProgress: false,
|
||||
EncryptVoiceChat: false,
|
||||
}
|
||||
|
||||
await setPresence(db, {
|
||||
accountId,
|
||||
roomInstance,
|
||||
statusVisibility: prev.statusVisibility ?? 0,
|
||||
deviceClass: prev.deviceClass ?? 0,
|
||||
vrMovementMode: prev.vrMovementMode ?? 1,
|
||||
platform: prev.platform ?? 0,
|
||||
appVersion: prev.appVersion ?? GAME_VERSION,
|
||||
// Carried forward so the heartbeat can keep verifying the session it belongs to.
|
||||
loginLock: prev.loginLock,
|
||||
})
|
||||
|
||||
// The room they left lost a player. Nothing to recompute on the other side: the offline
|
||||
// dorm has no `room_instance` row, which is the point of the sentinel.
|
||||
const leftId = prev.roomInstance?.roomInstanceId
|
||||
if (leftId !== undefined && leftId !== OFFLINE_DORM_INSTANCE_ID) {
|
||||
await refreshInstanceFullness(db, leftId)
|
||||
}
|
||||
|
||||
return roomInstance
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from './rooms-db'
|
||||
export * from './room-instance-db'
|
||||
export * from './room-comments-db'
|
||||
export * from './room-invites-db'
|
||||
export * from './dorm-presence'
|
||||
export * from './presence-db'
|
||||
export * from './stats-db'
|
||||
export * from './gifts-db'
|
||||
|
||||
Reference in New Issue
Block a user