mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[api] add non-working v3/votekick
This commit is contained in:
@@ -183,6 +183,13 @@ inconsistency here without checking the client first.
|
|||||||
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
|
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
|
||||||
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
|
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
|
||||||
the two live side by side in the same worker and must not be unified.
|
the two live side by side in the same worker and must not be unified.
|
||||||
|
- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload
|
||||||
|
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`.
|
||||||
- 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
|
||||||
|
|||||||
@@ -1304,6 +1304,22 @@ export const CreateWarningRequest = z.object({
|
|||||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on
|
||||||
|
* another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the
|
||||||
|
* labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the
|
||||||
|
* 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`'),
|
||||||
|
Reason: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
|
||||||
|
GameSessionId: z.string().describe('The room instance both players are standing in'),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
|
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
|
||||||
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
|
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { describeRoute } from 'hono-openapi'
|
|||||||
import {
|
import {
|
||||||
canModerateRoom,
|
canModerateRoom,
|
||||||
deletePresence,
|
deletePresence,
|
||||||
|
getPlayerIdsInInstance,
|
||||||
getPresences,
|
getPresences,
|
||||||
getRoomById,
|
getRoomById,
|
||||||
getStoredRoomInstance,
|
getStoredRoomInstance,
|
||||||
|
MessageType,
|
||||||
refreshInstanceFullness,
|
refreshInstanceFullness,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
import { logger } from '@repo/hono-helpers'
|
import { logger } from '@repo/hono-helpers'
|
||||||
@@ -31,6 +33,7 @@ import {
|
|||||||
SuccessErrorEnvelope,
|
SuccessErrorEnvelope,
|
||||||
UNAUTHORIZED_RESPONSE,
|
UNAUTHORIZED_RESPONSE,
|
||||||
VoteToKickReason,
|
VoteToKickReason,
|
||||||
|
VoteToKickRequest,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
import { createReport } from '../reports-db'
|
import { createReport } from '../reports-db'
|
||||||
import { createWarning } from '../warnings-db'
|
import { createWarning } from '../warnings-db'
|
||||||
@@ -157,6 +160,69 @@ 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}.
|
||||||
|
*
|
||||||
|
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
|
||||||
|
* straight through, and this mirrors it verbatim.
|
||||||
|
*
|
||||||
|
* `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.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* this server sends (see the `social` routes' `Message`), `Data` string included. A type
|
||||||
|
* alias rather than an interface: the hub's send takes an index-signature record, which
|
||||||
|
* only aliases satisfy implicitly.
|
||||||
|
*/
|
||||||
|
type VoteToKickMessage = {
|
||||||
|
FromPlayerId: number
|
||||||
|
ToPlayerId: number
|
||||||
|
Type: number
|
||||||
|
Data: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type
|
||||||
|
* 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when
|
||||||
|
* the hub could not be reached, which the caller reports honestly: nothing stores a vote,
|
||||||
|
* so the notification is the whole delivery.
|
||||||
|
*
|
||||||
|
* EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it
|
||||||
|
* was called: queued for an offline player, it would raise a prompt on their next connect
|
||||||
|
* about a session that ended hours ago, and there would be nothing left to vote on.
|
||||||
|
*/
|
||||||
|
async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
|
||||||
|
message.ToPlayerId,
|
||||||
|
NotificationType.MessageReceived,
|
||||||
|
message
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('failed to push VoteToKick MessageReceived notification', {
|
||||||
|
toPlayerId: message.ToPlayerId,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 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). Bans
|
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
|
||||||
@@ -332,6 +398,113 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A player calling a vote to kick another. Ungated by role — anyone may start one —
|
||||||
|
// but both players have to be standing in the session the vote is called in, which is
|
||||||
|
// what stops a client putting a vote to a room it isn't in, about someone who isn't
|
||||||
|
// there. Nothing tallies the votes yet: this relays the prompt and no more.
|
||||||
|
.post(
|
||||||
|
'/api/PlayerReporting/v3/voteToKick',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Moderation'],
|
||||||
|
summary: 'Call a vote to kick a player',
|
||||||
|
description:
|
||||||
|
'Puts a vote-to-kick to the room instance. Open to any player — no role is ' +
|
||||||
|
'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' +
|
||||||
|
'the instance `GameSessionId` names, or the call is refused with a 403. That is ' +
|
||||||
|
'the whole gate: without it a client could raise a vote in a session it is not ' +
|
||||||
|
'in, or against a player who is not there.\n\n' +
|
||||||
|
'Everyone else in that instance — the player being voted on included, since 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 ' +
|
||||||
|
'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' +
|
||||||
|
'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 ' +
|
||||||
|
'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,
|
||||||
|
requestBody: form(VoteToKickRequest, 'The vote'),
|
||||||
|
responses: {
|
||||||
|
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||||
|
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
|
||||||
|
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const voterId = await authedId(c)
|
||||||
|
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) {
|
||||||
|
return c.json({ success: false, error: 'PlayerId is required' }, 400)
|
||||||
|
}
|
||||||
|
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
|
||||||
|
if (gameSessionId === null) {
|
||||||
|
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [
|
||||||
|
voterId,
|
||||||
|
playerId,
|
||||||
|
])
|
||||||
|
const isHere = (id: number) =>
|
||||||
|
presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId
|
||||||
|
if (!isHere(voterId)) {
|
||||||
|
return c.json({ success: false, error: 'You are not in that game session!' }, 403)
|
||||||
|
}
|
||||||
|
if (!isHere(playerId)) {
|
||||||
|
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The room votes, so the audience is everyone standing there — the player being
|
||||||
|
// voted on included; a vote is called in front of them. The caller is dropped:
|
||||||
|
// their vote is the one they just posted.
|
||||||
|
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter(
|
||||||
|
(id) => id !== voterId
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every recipient is attempted even if an earlier one fails, so the reachable
|
||||||
|
// players still get the vote.
|
||||||
|
const results = await Promise.all(
|
||||||
|
audience.map((toPlayerId) =>
|
||||||
|
pushVoteToKick(c, {
|
||||||
|
FromPlayerId: voterId,
|
||||||
|
ToPlayerId: toPlayerId,
|
||||||
|
Type: MessageType.VoteToKick,
|
||||||
|
// An escaped JSON STRING, not a nested object — see VoteToKickData.
|
||||||
|
Data: voteToKickData({
|
||||||
|
PlayerId: playerIdField,
|
||||||
|
Response: '',
|
||||||
|
GameSessionId: gameSessionId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (results.includes(false)) {
|
||||||
|
return c.json({ success: false, error: 'Failed to deliver vote' }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, error: '' })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// The kick a room's own staff hand out from the moderation menu: eject named players
|
// The kick a room's own staff hand out from the moderation menu: eject named players
|
||||||
// from ONE live instance. Two gates, and both matter — the caller must be able to
|
// from ONE live instance. Two gates, and both matter — the caller must be able to
|
||||||
// moderate the room the instance belongs to, and each named player must actually be
|
// moderate the room the instance belongs to, and each named player must actually be
|
||||||
|
|||||||
@@ -4216,6 +4216,163 @@ describe('instant 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.
|
||||||
|
const SESSION = 1014079
|
||||||
|
const OTHER_SESSION = 1014080
|
||||||
|
|
||||||
|
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||||
|
|
||||||
|
const standIn = async (accountId: number, roomInstanceId: number) =>
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||||
|
.bind(
|
||||||
|
JSON.stringify({
|
||||||
|
accountId,
|
||||||
|
roomInstance: { roomInstanceId, roomId: 4 },
|
||||||
|
statusVisibility: 0,
|
||||||
|
deviceClass: 0,
|
||||||
|
vrMovementMode: 0,
|
||||||
|
platform: 0,
|
||||||
|
appVersion: GAME_VERSION,
|
||||||
|
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
// 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`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(fields),
|
||||||
|
})
|
||||||
|
|
||||||
|
const frames = async () =>
|
||||||
|
(await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||||
|
playerId?: number
|
||||||
|
ephemeral?: boolean
|
||||||
|
notificationType: number
|
||||||
|
data: Record<string, unknown>
|
||||||
|
}>
|
||||||
|
|
||||||
|
const FIELDS = {
|
||||||
|
PlayerId: '205',
|
||||||
|
Response: 'True',
|
||||||
|
Reason: 'Inactive in games (AFK)',
|
||||||
|
GameSessionId: String(SESSION),
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the vote goes to everyone in the session except the caller', async () => {
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
// 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere.
|
||||||
|
await standIn(42, SESSION)
|
||||||
|
await standIn(205, SESSION)
|
||||||
|
await standIn(206, SESSION)
|
||||||
|
await standIn(207, OTHER_SESSION)
|
||||||
|
|
||||||
|
const res = await vote(FIELDS)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
const message = {
|
||||||
|
ephemeral: true,
|
||||||
|
notificationType: 2, // NotificationType.MessageReceived
|
||||||
|
data: {
|
||||||
|
FromPlayerId: 42,
|
||||||
|
Type: MessageType.VoteToKick,
|
||||||
|
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const sent = await frames()
|
||||||
|
expect(sent).toHaveLength(2)
|
||||||
|
expect(sent).toContainEqual({
|
||||||
|
...message,
|
||||||
|
playerId: 205,
|
||||||
|
data: { ...message.data, ToPlayerId: 205 },
|
||||||
|
})
|
||||||
|
expect(sent).toContainEqual({
|
||||||
|
...message,
|
||||||
|
playerId: 206,
|
||||||
|
data: { ...message.data, ToPlayerId: 206 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('both players have to be standing in the session', async () => {
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
await standIn(42, OTHER_SESSION)
|
||||||
|
await standIn(205, SESSION)
|
||||||
|
|
||||||
|
// The caller is somewhere else — a vote can't be called into a session you're not in.
|
||||||
|
const away = await vote(FIELDS)
|
||||||
|
expect(away.status).toBe(403)
|
||||||
|
expect(await away.json()).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'You are not in that game session!',
|
||||||
|
})
|
||||||
|
|
||||||
|
// And with the caller present, the player voted on has to be there too — offline,
|
||||||
|
// or standing elsewhere, both refuse.
|
||||||
|
await standIn(42, SESSION)
|
||||||
|
await standIn(205, OTHER_SESSION)
|
||||||
|
const elsewhere = await vote(FIELDS)
|
||||||
|
expect(elsewhere.status).toBe(403)
|
||||||
|
expect(await elsewhere.json()).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'That player is not in that game session!',
|
||||||
|
})
|
||||||
|
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403)
|
||||||
|
|
||||||
|
// Nothing was put to the room on any of those.
|
||||||
|
expect(await frames()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the body must name a player and a session, and the call needs a token', async () => {
|
||||||
|
await standIn(42, SESSION)
|
||||||
|
await standIn(205, SESSION)
|
||||||
|
|
||||||
|
for (const [fields, error] of [
|
||||||
|
[{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||||
|
[{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||||
|
[{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'],
|
||||||
|
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
|
||||||
|
] as Array<[Record<string, string>, string]>) {
|
||||||
|
const res = await vote(fields)
|
||||||
|
expect(res.status, error).toBe(400)
|
||||||
|
expect(await res.json()).toEqual({ success: false, error })
|
||||||
|
}
|
||||||
|
|
||||||
|
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(FIELDS),
|
||||||
|
})
|
||||||
|
expect(anon.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
// A vote called with nobody else there is a no-op rather than an error: the caller and
|
||||||
|
// the player voted on are both here, so the gate passes, and there is simply no room
|
||||||
|
// to put it to.
|
||||||
|
test('a session holding only the two of them sends nothing', async () => {
|
||||||
|
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||||
|
await standIn(42, SESSION)
|
||||||
|
await standIn(205, SESSION)
|
||||||
|
await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run()
|
||||||
|
|
||||||
|
const res = await vote(FIELDS)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||||
|
// 205 is still in the session, so they still hear it — only the caller is dropped.
|
||||||
|
expect((await frames()).map((f) => f.playerId)).toEqual([205])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('player reports', () => {
|
describe('player reports', () => {
|
||||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||||
@@ -7234,6 +7391,7 @@ describe('openapi', () => {
|
|||||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
'POST /api/PlayerReporting/v1/referee',
|
'POST /api/PlayerReporting/v1/referee',
|
||||||
'POST /api/PlayerReporting/v3/create',
|
'POST /api/PlayerReporting/v3/create',
|
||||||
|
'POST /api/PlayerReporting/v3/voteToKick',
|
||||||
'POST /api/avatar/v1/lockeditems/bulk',
|
'POST /api/avatar/v1/lockeditems/bulk',
|
||||||
'POST /api/avatar/v2/gifts/generate',
|
'POST /api/avatar/v2/gifts/generate',
|
||||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ export default defineConfig({
|
|||||||
// GET /all for the whole list (friend-graph changes notify both players),
|
// GET /all for the whole list (friend-graph changes notify both players),
|
||||||
// DELETE to reset it between assertions.
|
// DELETE to reset it between assertions.
|
||||||
//
|
//
|
||||||
// notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and
|
// The ephemeral sends land in the same list, tagged `ephemeral`:
|
||||||
// carrying `playerIds` rather than `playerId` — the two sends differ in
|
// notifyPlayerEphemeral carries `playerId` like the durable send,
|
||||||
// whether an offline recipient gets the frame later, which is a thing worth
|
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
|
||||||
// asserting (a cheer's effect is broadcast to a room this way).
|
// and ephemeral differ in whether an offline recipient gets the frame
|
||||||
|
// later, which is a thing worth asserting (a cheer's effect is broadcast to
|
||||||
|
// a room this way, a vote-to-kick is put to each player in it).
|
||||||
script: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
@@ -38,6 +40,10 @@ export default defineConfig({
|
|||||||
this.sent.push({ playerId, notificationType, data })
|
this.sent.push({ playerId, notificationType, data })
|
||||||
return { delivered: 0, queued: true }
|
return { delivered: 0, queued: true }
|
||||||
}
|
}
|
||||||
|
async notifyPlayerEphemeral(playerId, notificationType, data) {
|
||||||
|
this.sent.push({ playerId, ephemeral: true, notificationType, data })
|
||||||
|
return { delivered: 0 }
|
||||||
|
}
|
||||||
async notifyPlayersEphemeral(playerIds, notificationType, data) {
|
async notifyPlayersEphemeral(playerIds, notificationType, data) {
|
||||||
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
|
||||||
return { delivered: 0 }
|
return { delivered: 0 }
|
||||||
|
|||||||
Reference in New Issue
Block a user