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
|
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
|
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
|
(`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`,
|
the field. Bites a chat message's `Contents` the same way.
|
||||||
whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside
|
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
|
||||||
it, as the reference relays it) and, the same way, a chat message's `Contents`.
|
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
|
- 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
|
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
|
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.
|
* body — it's the bearer token's subject.
|
||||||
*/
|
*/
|
||||||
export const VoteToKickRequest = z.object({
|
export const VoteToKickRequest = z.object({
|
||||||
PlayerId: z.string().describe('Account id of the player being voted on'),
|
PlayerId: z
|
||||||
Response: z.string().describe('The caller’s own vote, e.g. `True`'),
|
.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
|
Reason: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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'),
|
GameSessionId: z.string().describe('The room instance both players are standing in'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import { describeRoute } from 'hono-openapi'
|
|||||||
import {
|
import {
|
||||||
canManageRoom,
|
canManageRoom,
|
||||||
canModerateRoom,
|
canModerateRoom,
|
||||||
deletePresence,
|
countPlayersInInstance,
|
||||||
getPlayerIdsInInstance,
|
getPlayerIdsInInstance,
|
||||||
getPresence,
|
getPresence,
|
||||||
getPresences,
|
getPresences,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getStoredRoomInstance,
|
getStoredRoomInstance,
|
||||||
MessageType,
|
MessageType,
|
||||||
refreshInstanceFullness,
|
movePlayerToDorm,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
import { logger } from '@repo/hono-helpers'
|
||||||
|
|
||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
VoteToKickRequest,
|
VoteToKickRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
|
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
|
||||||
|
import { countKickVotes, isKickMajority, recordRoomVote } from '../votes-db'
|
||||||
import { createWarning } from '../warnings-db'
|
import { createWarning } from '../warnings-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
@@ -67,6 +68,15 @@ function formField(
|
|||||||
return c.req.query(name) || undefined
|
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. */
|
/** Parse a field as an integer, or null when absent / not a number. */
|
||||||
const asInt = (v: string | undefined): number | null => {
|
const asInt = (v: string | undefined): number | null => {
|
||||||
if (v === undefined) return 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
|
* What a vote-to-kick Message carries, and it is not what this once sent.
|
||||||
* 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}.
|
|
||||||
*
|
*
|
||||||
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
|
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
|
||||||
* straight through, and this mirrors it verbatim.
|
* 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
|
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
|
||||||
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
|
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
|
||||||
* is theirs alone and is not relayed.
|
* `{ 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
|
* 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 ------------------------------------------------------
|
// ---- Player reporting ------------------------------------------------------
|
||||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
|
// 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 ' +
|
'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 ' +
|
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
|
||||||
'already, and their own `Response` is what they posted.\n\n' +
|
'already, and their own `Response` is what they posted.\n\n' +
|
||||||
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
|
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
|
||||||
'object. A Message’s `Data` is a string on the wire, and an object there fails the ' +
|
'client raises its prompt about whoever it names, so the caller there asks the room ' +
|
||||||
"client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " +
|
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
|
||||||
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
|
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
|
||||||
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
|
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
|
||||||
'empty — the frame is the question, not an answer.\n\n' +
|
"actual:'{'`) and aborts the whole notification.\n\n" +
|
||||||
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
|
'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 ' +
|
'offline player gets nothing rather than a prompt about a dead session on their ' +
|
||||||
'next connect.\n\n' +
|
'next connect.\n\n' +
|
||||||
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
|
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
|
||||||
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
|
'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 ' +
|
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
|
||||||
'with nothing behind it the frame is the whole delivery.',
|
'with nothing behind it the frame is the whole delivery.',
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
@@ -471,8 +546,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
if (voterId === null) return unauthorized(c)
|
if (voterId === null) return unauthorized(c)
|
||||||
|
|
||||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
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 playerIdField = formField(body, c, 'PlayerId')
|
||||||
const playerId = asInt(playerIdField)
|
const playerId = asInt(playerIdField)
|
||||||
if (playerIdField === undefined || playerId === null) {
|
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)
|
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
|
// 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
|
// 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.
|
// 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
|
(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
|
// Every recipient is attempted even if an earlier one fails, so the reachable
|
||||||
// players still get the vote.
|
// players still get the vote.
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
audience.map((toPlayerId) =>
|
audience.map((toPlayerId) =>
|
||||||
pushVoteToKick(c, {
|
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,
|
ToPlayerId: toPlayerId,
|
||||||
Type: MessageType.VoteToKick,
|
Type: MessageType.VoteToKick,
|
||||||
// An escaped JSON STRING, not a nested object — see VoteToKickData.
|
// The reason as plain text; see the note above the Message type.
|
||||||
Data: voteToKickData({
|
Data: reason,
|
||||||
PlayerId: playerIdField,
|
|
||||||
Response: '',
|
|
||||||
GameSessionId: gameSessionId,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -553,8 +655,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
|
'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 ' +
|
'in silence, so naming an account id cannot reach into a session the caller has ' +
|
||||||
'no authority over.\n\n' +
|
'no authority over.\n\n' +
|
||||||
'Each kicked player loses their presence row (they read offline at once and the ' +
|
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
|
||||||
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
|
'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 ' +
|
'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 ' +
|
'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 ' +
|
'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.
|
// moderator could throw the creator out of it. Nor is the caller themselves.
|
||||||
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
|
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
|
||||||
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) 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)
|
kicked.push(playerId)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kicked.length > 0) {
|
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'
|
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||||
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
|
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 ' +
|
'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 ' +
|
'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' +
|
'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 ' +
|
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
|
||||||
'frees a slot, its fullness recomputed) and sends them an EPHEMERAL `ModerationKick` ' +
|
'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 ' +
|
'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 ' +
|
'true when the caller runs the room, false for a staff account acting in a room it ' +
|
||||||
'does not.\n\n' +
|
'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!' })
|
return c.json({ success: false, error: 'That player is not in this game session!' })
|
||||||
}
|
}
|
||||||
|
|
||||||
await deletePresence(c.env.DB, playerId)
|
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||||
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
await movePlayerToDorm(c.env.DB, playerId)
|
||||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||||
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
|
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
|
||||||
reason,
|
reason,
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
spendCheerCredit,
|
spendCheerCredit,
|
||||||
} from '../../reputation-db'
|
} from '../../reputation-db'
|
||||||
import { charadesWordsFor } from '../../routes/gameplay'
|
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 { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||||
|
|
||||||
import type { SavedImage } from '@repo/domain'
|
import type { SavedImage } from '@repo/domain'
|
||||||
@@ -170,6 +171,8 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
// 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()
|
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
|
// Platform identity links (owned by the auth worker) — the sharp arm of the
|
||||||
// ban-evasion resolution matches on them.
|
// ban-evasion resolution matches on them.
|
||||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -4541,6 +4544,24 @@ describe('instant kick', () => {
|
|||||||
.bind(accountId)
|
.bind(accountId)
|
||||||
.first<{ n: number }>())!.n === 1
|
.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) =>
|
const isFull = async (roomInstanceId: number) =>
|
||||||
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
|
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
|
||||||
.bind(roomInstanceId)
|
.bind(roomInstanceId)
|
||||||
@@ -4572,9 +4593,19 @@ describe('instant kick', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||||
|
|
||||||
// Presence is deleted, so they read offline at once — and only theirs is.
|
// Out of the session — and into their own DORM, rather than having their presence
|
||||||
expect(await isPresent(205)).toBe(false)
|
// deleted: with nothing to read the client lands not knowing where it is and loads the
|
||||||
expect(await isPresent(206)).toBe(true)
|
// 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.
|
// The instance lost a player, so it is no longer full.
|
||||||
expect(await isFull(SESSION)).toBe(false)
|
expect(await isFull(SESSION)).toBe(false)
|
||||||
|
|
||||||
@@ -4624,7 +4655,7 @@ describe('instant kick', () => {
|
|||||||
|
|
||||||
// 43 holds Moderator (20) on the room.
|
// 43 holds Moderator (20) on the room.
|
||||||
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
|
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)
|
await standIn(209, SESSION)
|
||||||
// 44 is only a Host (10), and 99 holds nothing at all.
|
// 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(res.status, sub).toBe(403)
|
||||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
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`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -4653,8 +4684,8 @@ describe('instant kick', () => {
|
|||||||
// 43 (a moderator) names the creator, a fellow moderator and themselves.
|
// 43 (a moderator) names the creator, a fellow moderator and themselves.
|
||||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
|
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await isPresent(42)).toBe(true)
|
expect(await inSession(42, SESSION)).toBe(true)
|
||||||
expect(await isPresent(43)).toBe(true)
|
expect(await inSession(43, SESSION)).toBe(true)
|
||||||
expect(await frames()).toEqual([])
|
expect(await frames()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4731,11 +4762,24 @@ describe('room mod kick', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
const isPresent = async (accountId: number) =>
|
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||||
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
|
// "kicked" is "no longer standing in that session", not "gone".
|
||||||
.bind(accountId)
|
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
|
.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.
|
// The client's form body.
|
||||||
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
|
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||||
@@ -4765,7 +4809,11 @@ describe('room mod kick', () => {
|
|||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
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')
|
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
|
||||||
.bind(SESSION)
|
.bind(SESSION)
|
||||||
.first<{ f: number }>()
|
.first<{ f: number }>()
|
||||||
@@ -4805,8 +4853,8 @@ describe('room mod kick', () => {
|
|||||||
error: 'That player is not in this game session!',
|
error: 'That player is not in this game session!',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
expect(await isPresent(206)).toBe(true)
|
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
|
||||||
expect(await isPresent(207)).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.
|
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
|
||||||
const cross = await modKick(
|
const cross = await modKick(
|
||||||
@@ -4814,7 +4862,7 @@ describe('room mod kick', () => {
|
|||||||
'42'
|
'42'
|
||||||
)
|
)
|
||||||
expect(cross.status).toBe(403)
|
expect(cross.status).toBe(403)
|
||||||
expect(await isPresent(207)).toBe(true)
|
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||||
expect(await frames()).toEqual([])
|
expect(await frames()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -4826,7 +4874,7 @@ describe('room mod kick', () => {
|
|||||||
expect(
|
expect(
|
||||||
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
|
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
|
||||||
).toEqual({ success: true, error: '' })
|
).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
|
// 999 has no role on the room but carries the staff `moderator` role — and is not the
|
||||||
// host, so the frame says so.
|
// host, so the frame says so.
|
||||||
@@ -4837,7 +4885,7 @@ describe('room mod kick', () => {
|
|||||||
'moderator',
|
'moderator',
|
||||||
])
|
])
|
||||||
expect(await staff.json()).toEqual({ success: true, error: '' })
|
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 })
|
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,
|
// 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(res.status, sub).toBe(403)
|
||||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
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`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -4875,8 +4923,8 @@ describe('room mod kick', () => {
|
|||||||
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
|
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
|
||||||
).json()
|
).json()
|
||||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||||
expect(await isPresent(42)).toBe(true)
|
expect(await inSession(42, SESSION)).toBe(true)
|
||||||
expect(await isPresent(45)).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 () => {
|
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', () => {
|
describe('vote to kick', () => {
|
||||||
// Two live sessions, so a vote called in one can be checked against a player in the
|
// 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 SESSION = 1014079
|
||||||
const OTHER_SESSION = 1014080
|
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')
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
|
||||||
@@ -4923,6 +4977,34 @@ describe('vote to kick', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.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=…`.
|
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
|
||||||
const vote = async (fields: Record<string, string>, sub = '42') =>
|
const vote = async (fields: Record<string, string>, sub = '42') =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
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
|
// 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.
|
// 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
|
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
|
||||||
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
|
// raises its prompt about whoever that names, so the caller there asks the room to kick
|
||||||
// and `Response` is empty because the frame is the question, not an answer.
|
// 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 = {
|
const message = {
|
||||||
ephemeral: true,
|
ephemeral: true,
|
||||||
notificationType: 2, // NotificationType.MessageReceived
|
notificationType: 2, // NotificationType.MessageReceived
|
||||||
data: {
|
data: {
|
||||||
FromPlayerId: 42,
|
FromPlayerId: 205,
|
||||||
Type: MessageType.VoteToKick,
|
Type: MessageType.VoteToKick,
|
||||||
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
|
Data: 'Inactive in games (AFK)',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
const sent = await frames()
|
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 () => {
|
test('both players have to be standing in the session', async () => {
|
||||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
await standIn(42, OTHER_SESSION)
|
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,
|
countRoomsByCreator,
|
||||||
createNotification,
|
createNotification,
|
||||||
createSubRoom,
|
createSubRoom,
|
||||||
deletePresence,
|
|
||||||
deleteRoom,
|
deleteRoom,
|
||||||
deleteRoomLeaderboard,
|
deleteRoomLeaderboard,
|
||||||
deleteSubRoom,
|
deleteSubRoom,
|
||||||
@@ -49,8 +48,8 @@ import {
|
|||||||
isRoomOwner,
|
isRoomOwner,
|
||||||
MessageType,
|
MessageType,
|
||||||
modifySubRoom,
|
modifySubRoom,
|
||||||
|
movePlayerToDorm,
|
||||||
publishSubRoomSave,
|
publishSubRoomSave,
|
||||||
refreshInstanceFullness,
|
|
||||||
removeCheer,
|
removeCheer,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
removeRoomRole,
|
removeRoomRole,
|
||||||
@@ -621,18 +620,19 @@ async function pushRoleInvite(
|
|||||||
const REPORT_CATEGORY_MODERATOR = -1
|
const REPORT_CATEGORY_MODERATOR = -1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Take a freshly banned player out of the room's LIVE PRESENCE, so the ban takes effect on
|
* Move a freshly banned player out of the room and into their own DORM, so the ban takes
|
||||||
* everything that reads presence right now rather than when their row next expires. The kick
|
* effect on everything that reads presence right now rather than when their row next expires.
|
||||||
* push ejects their client, but until the row goes they still count toward the instance's
|
* The kick push ejects their client, but until presence moves they still count toward the
|
||||||
* player total (keeping a full room full), still show as standing in the room to friends, and
|
* instance's player total (keeping a full room full), still show as standing in the room to
|
||||||
* still pass the presence-based grants — reading the room's staged saves among them.
|
* 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
|
* 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
|
* 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.
|
* 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
|
* The instance they left gets its fullness recomputed, as every other departure does, so a room
|
||||||
* a room they were filling opens back up. Best-effort like the push: the ban row is already
|
* 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.
|
* 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
|
* 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 {
|
try {
|
||||||
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
|
const instance = (await getPresence<PresenceView>(c.env.DB, ban.BannedPlayerId))?.roomInstance
|
||||||
if (instance?.roomId !== ban.RoomId) return null
|
if (instance?.roomId !== ban.RoomId) return null
|
||||||
await deletePresence(c.env.DB, ban.BannedPlayerId)
|
// Moved to their own dorm rather than deleted: the client goes there either way, and with
|
||||||
if (instance.roomInstanceId == null) return null
|
// no presence to read it arrives not knowing where it is and loads the dorm a second
|
||||||
await refreshInstanceFullness(c.env.DB, instance.roomInstanceId)
|
// time. This also recomputes the fullness of the instance they left.
|
||||||
return instance.roomInstanceId
|
await movePlayerToDorm(c.env.DB, ban.BannedPlayerId)
|
||||||
|
return instance.roomInstanceId ?? null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to clear a banned player’s presence', {
|
logger.error('failed to clear a banned player’s presence', {
|
||||||
playerId: ban.BannedPlayerId,
|
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',
|
'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.',
|
'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',
|
'If the banned player’s live presence puts them in THIS room, they are moved into their',
|
||||||
'deleted and the instance’s fullness recomputed, so they stop counting as standing in',
|
'own DORM — presence rewritten rather than deleted, so their client does not load the',
|
||||||
'the room immediately. Presence in some other room is left alone.',
|
'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),',
|
'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',
|
'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 })
|
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) =>
|
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)
|
.bind(accountId)
|
||||||
.first<{ room_id: number }>()
|
.first<{ room_id: number; instance: number }>()
|
||||||
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
|
const instanceId = 1000002 // what `putInRoom` puts room 2's players in
|
||||||
const isFull = async () =>
|
const isFull = async () =>
|
||||||
(
|
(
|
||||||
@@ -2582,13 +2585,19 @@ describe('rooms endpoints', () => {
|
|||||||
await putInRoom(321, 4)
|
await putInRoom(321, 4)
|
||||||
|
|
||||||
expect((await postForm('/rooms/2/bans', { banMask: '0', id: '320' }, '1')).status).toBe(200)
|
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.
|
// Out of the room they were banned from, and the seat they held is free again. Their
|
||||||
expect(await presenceOf(320)).toBeNull()
|
// 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)
|
expect(await isFull()).toBe(0)
|
||||||
|
|
||||||
// A ban from room 2 does not knock someone offline from room 4.
|
// 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 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 clearPresence(321)
|
||||||
await env.DB.prepare('DELETE FROM room_instance WHERE id = ?1').bind(instanceId).run()
|
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 { logger } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
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
|
* 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.
|
* `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,
|
* They are moved into their own DORM rather than having their presence deleted — the same move
|
||||||
* if any, has its fullness recomputed so a full room frees a slot.
|
* 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
|
* 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.
|
* 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.
|
// Offline: nothing to eject and nobody to tell. The sign-in check has them.
|
||||||
if (!presence) return
|
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
|
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 block = banBlockDetails(report)
|
||||||
const frame: ModerationKickPayload = {
|
const frame: ModerationKickPayload = {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
setPresence,
|
setPresence,
|
||||||
} from '@repo/domain/src/presence-db'
|
} from '@repo/domain/src/presence-db'
|
||||||
import { ROOM_INSTANCE_SCHEMA_DDL } from '@repo/domain/src/room-instance-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 { generateToken } from '@repo/jwt'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +90,8 @@ beforeAll(async () => {
|
|||||||
for (const stmt of REPORT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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 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_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:
|
// 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,
|
Duration: 3 * 86_400,
|
||||||
})
|
})
|
||||||
|
|
||||||
// And their presence row is gone, so they read offline at once and the instance frees
|
// And they are out of that instance — moved into their own DORM, not deleted: `match`
|
||||||
// a slot.
|
// 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(
|
const presence = await env.DB.prepare(
|
||||||
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
"SELECT json_extract(data, '$.roomInstance.roomInstanceId') AS instance FROM presence WHERE json_extract(data, '$.accountId') = 8190"
|
||||||
).first<{ n: number }>()
|
).first<{ instance: number | null }>()
|
||||||
expect(presence?.n).toBe(0)
|
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
|
// 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-instance-db'
|
||||||
export * from './room-comments-db'
|
export * from './room-comments-db'
|
||||||
export * from './room-invites-db'
|
export * from './room-invites-db'
|
||||||
|
export * from './dorm-presence'
|
||||||
export * from './presence-db'
|
export * from './presence-db'
|
||||||
export * from './stats-db'
|
export * from './stats-db'
|
||||||
export * from './gifts-db'
|
export * from './gifts-db'
|
||||||
|
|||||||
Reference in New Issue
Block a user