[api] #29 fix player cheers

This commit is contained in:
Devin Zuczek
2026-08-25 22:22:13 -04:00
parent 4ff9609a8f
commit 199c34d1cb
7 changed files with 287 additions and 133 deletions
+15 -17
View File
@@ -151,23 +151,21 @@ inconsistency here without checking the client first.
served under TWO names, `result` (what the client reads) and `errorCode` (what this
server has always sent); they are the same number and must never disagree, which is why
everything answers through `matchmakeResult` rather than building the envelope by hand.
- A cheer (`api`: `POST /api/PlayerCheer/v1/create`) is a thing that happens in FRONT of
people, so the `ReputationUpdate` frame naming the cheered player goes to everyone in the
room instance, not just the two players — the effect plays on every client that gets it.
Two of the frame's fields are per-send instructions that wear the name of a profile field
on the reputation DTO (`api`: `/api/playerReputation/…`) and mean something else:
- `IsCheerful` on the DTO is a profile flag; on the frame, true plays the visual effect
and false moves the counters silently. It comes from the request's `Anonymous`,
INVERTED — never off the stored record, which doesn't vary. The second frame, the one
refreshing the giver's own `CheerCredit`, sends false: nothing was cheered at them.
- `SelectedCheer` on the DTO is the player's pinned cheer; on the frame it is WHICH cheer
plays the category just given.
The cheered player gets the frame durably (their counters moved); the rest of the room
gets it ephemerally, since an effect queued for someone offline would play out of
nowhere hours later. The audience comes from the giver's live `presence` row, NOT the
body's `RoomId`, which is accepted and unused — otherwise a client could play its effect
in a room it isn't in. Neither `RoomId` nor `Anonymous` is stored; the frame is the whole
of their effect.
- What PLAYS a cheer on the cheered player's client is a `MessageReceived` frame carrying a
Message of type 50 `PlayerCheer` (51 `PlayerCheerAnonymous`, `FromPlayerId` 0, when the
body says `Anonymous`) with `Data` = the category as a string — the same frame every
reference server (meownet-api, DorkNet, E12354) sends. `ReputationUpdate` alone refreshes
the counters and shows nothing: the cheer "worked" server-side and nobody saw it. The
`ReputationUpdate` frames the cheer (`api`: `POST /api/PlayerCheer/v1/create`) sends are
the RECORD, trimmed — `IsCheerful` (a profile flag, always true) and `SelectedCheer` (the
cheer pinned via `POST /api/PlayerCheer/v1/SetSelectedCheer`, stored on `reputation`) come
off the row exactly as the DTO serves them. This server once overrode both per frame to
"play" the cheer; no reference does, and it played nothing.
- A cheer is a thing that happens in FRONT of people, so the `ReputationUpdate` naming the
cheered player goes to everyone in the room instance, not just the two players. The
cheered player gets it durably (their counters moved); the rest of the room gets it
ephemerally. The audience comes from the giver's live `presence` row, NOT the body's
`RoomId`, which is accepted and unused. Neither `RoomId` nor `Anonymous` is stored.
- 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;
the two live side by side in the same worker and must not be unified.
+2 -4
View File
@@ -23,10 +23,8 @@
-- numbers that will have a source one day, so the column is here and turning them on later
-- is a write rather than a migration.
--
-- `IsCheerful` and `SelectedCheer` are NOT columns. They ride along on the DTO and the
-- `ReputationUpdate` frame, but nothing on this server varies them per player — they are
-- constants (true / 0) the projection fills in. A column defaulted to the same value for
-- everybody, that nothing ever writes, only invites a reader to believe it means something.
-- `IsCheerful` and `SelectedCheer` were left off here on the theory that nothing varied
-- them per player; 0014 adds them — `SelectedCheer` is written by `SetSelectedCheer`.
CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
@@ -0,0 +1,12 @@
-- The two profile fields 0013 left off the `reputation` table on the theory that nothing
-- varied them per player. Owned by the `api` worker; generated from src/reputation-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- `selected_cheer` is the cheer a player has PINNED to their profile, written by
-- `POST /api/PlayerCheer/v1/SetSelectedCheer` (form `CheerCategory`), which every reference
-- server stores per player — 0013's "no endpoint sets one" was wrong. `is_cheerful` is the
-- profile flag the client's DTO and `ReputationUpdate` frame both carry, read straight off
-- the record like every reference does; it is a column so it can vary one day without a
-- migration, defaulted true because that is what every reference serves.
ALTER TABLE reputation ADD COLUMN is_cheerful INTEGER NOT NULL DEFAULT 1;
ALTER TABLE reputation ADD COLUMN selected_cheer INTEGER NOT NULL DEFAULT 0;
+10 -4
View File
@@ -263,7 +263,7 @@ export const ReputationDto = z.object({
AccountId: z.int(),
IsCheerful: z.boolean(),
Noteriety: z.int(),
SelectedCheer: z.int().describe('0 = none selected'),
SelectedCheer: z.int().describe('The cheer pinned to the profile; 0 = none selected'),
CheerCredit: z.int(),
CheerGeneral: z.int(),
CheerHelpful: z.int(),
@@ -303,12 +303,18 @@ export const CheerPlayerRequest = z.object({
.string()
.optional()
.describe(
'`True`/`False` (default `False`). Not stored — it sets `IsCheerful` (inverted) on ' +
'the `ReputationUpdate` frame, which is what plays the cheer effect on the clients ' +
'that receive it'
'`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
),
})
/** The form body of `POST /api/PlayerCheer/v1/SetSelectedCheer`. */
export const SetSelectedCheerRequest = z.object({
CheerCategory: z
.string()
.describe('The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'),
})
/**
* What a cheer answers — the reference's PascalCase `{ Success, Message }`, NOT the
* lowercase `{ success, error }` envelope the reports use, and `Message` is NULL on success
+51 -37
View File
@@ -13,19 +13,20 @@
* {@link DAILY_CHEER_CREDIT} once the window in `created` is a day old. One row per
* account, created the first time they spend one.
*
* Three of the client's fields are deliberately NOT columns. `CheerCredit` sits alongside
* One of the client's fields is deliberately NOT a column. `CheerCredit` sits alongside
* the counters in the client's record but is `player_cheer.cheers_left` with the rollover
* applied — storing it twice would let the number a player reads drift from the one the
* spend checks. `IsCheerful` and `SelectedCheer` are constants nothing varies per player;
* they exist only to fill out the DTO. (On the `ReputationUpdate` frame `IsCheerful` is a
* different thing wearing the same name — see {@link IS_CHEERFUL}.)
* spend checks. `SelectedCheer` (the cheer pinned to the profile, set by
* `POST /api/PlayerCheer/v1/SetSelectedCheer`) and `IsCheerful` (a profile flag every
* reference serves as true) ARE columns, added in 0014 — the `ReputationUpdate` frame is
* the record trimmed, nothing more, so both come off the row.
*
* The `api` worker owns the schema/migration (migrations/0013_reputation.sql, applied
* under its own `migrations_table` so it doesn't clash with the other workers' migrations
* that share the database).
* The `api` worker owns the schema/migrations (migrations/0013_reputation.sql and
* 0014_reputation_selected_cheer.sql, applied under its own `migrations_table` so they
* don't clash with the other workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0013_reputation.sql). */
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
@@ -36,7 +37,9 @@ export const SCHEMA_DDL: string[] = [
cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count INTEGER NOT NULL DEFAULT 0,
subscribed_count INTEGER NOT NULL DEFAULT 0
subscribed_count INTEGER NOT NULL DEFAULT 0,
is_cheerful INTEGER NOT NULL DEFAULT 1,
selected_cheer INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY,
@@ -93,15 +96,18 @@ interface ReputationRow {
cheer_sportsman: number
subscriber_count: number
subscribed_count: number
/** SQLite boolean: 0 / 1. */
is_cheerful: number
selected_cheer: number
}
/**
* A player's reputation as the client's DTO renders it — and, trimmed and with `IsCheerful`
* overridden, as the `ReputationUpdate` frame carries it.
* A player's reputation as the client's DTO renders it — and, trimmed, as the
* `ReputationUpdate` frame carries it.
*
* Not all of it is stored. `Noteriety` (the reference's spelling), `SubscriberCount` and
* `SubscribedCount` are columns nothing writes yet. {@link IS_CHEERFUL} and
* {@link SELECTED_CHEER} aren't columns at all — see their comments.
* `Noteriety` (the reference's spelling), `SubscriberCount` and `SubscribedCount` are
* columns nothing writes yet. `IsCheerful` is a column nothing writes either, defaulted
* true like every reference serves it. `SelectedCheer` is the pinned cheer, 0 = none.
*/
export interface Reputation {
AccountId: number
@@ -118,25 +124,6 @@ export interface Reputation {
SubscribedCount: number
}
/**
* `IsCheerful` as the profile DTO carries it. Nothing on this server varies it per player,
* so it is a constant rather than a column defaulted the same way for everybody.
*
* Do NOT reach for this when building a `ReputationUpdate` frame. The field is named the
* same there but means something else — it is a per-frame flag driving the cheer's visual
* effect on the receiving client, which the cheer route sets from the request's
* `Anonymous`. Only the two names coincide.
*/
const IS_CHEERFUL = true
/**
* The cheer a player has PINNED to their profile (0 = none). No endpoint sets one — the
* client's picker posts elsewhere and this server doesn't serve that path — so, like
* {@link IS_CHEERFUL}, it is a constant rather than a column defaulted to the same value
* for everybody.
*/
const SELECTED_CHEER = 0
/**
* What a player with no row has: nobody has cheered them, and they hold their full credit.
* `credit` is passed in rather than defaulted because a player can have spent cheers
@@ -145,9 +132,9 @@ const SELECTED_CHEER = 0
export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation {
return {
AccountId: accountId,
IsCheerful: IS_CHEERFUL,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: SELECTED_CHEER,
SelectedCheer: 0,
CheerCredit: credit,
CheerGeneral: 0,
CheerHelpful: 0,
@@ -163,9 +150,9 @@ export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT
function toReputation(row: ReputationRow, credit: number): Reputation {
return {
AccountId: row.account_id,
IsCheerful: IS_CHEERFUL,
IsCheerful: row.is_cheerful !== 0,
Noteriety: row.noteriety,
SelectedCheer: SELECTED_CHEER,
SelectedCheer: row.selected_cheer,
CheerCredit: credit,
CheerGeneral: row.cheer_general,
CheerHelpful: row.cheer_helpful,
@@ -329,3 +316,30 @@ export async function addCheer(
// having to handle an impossible null.
return toReputation(row!, credit)
}
/**
* Pin `category` as `accountId`'s selected cheer — the badge the client shows on their
* profile — returning the reputation they now hold. `None` (-1) unpins it, stored as 0 the
* way the DTO reads "nothing selected". Creates the row if nobody has cheered them yet:
* a player can pin a badge before ever receiving a cheer.
*/
export async function setSelectedCheer(
db: D1Database,
accountId: number,
category: CheerCategory,
now: Date = new Date()
): Promise<Reputation> {
const selected = category === CheerCategory.None ? 0 : category
const [row, credit] = await Promise.all([
db
.prepare(
`INSERT INTO reputation (account_id, selected_cheer) VALUES (?1, ?2)
ON CONFLICT (account_id) DO UPDATE SET selected_cheer = ?2
RETURNING *`
)
.bind(accountId, selected)
.first<ReputationRow>(),
getCheerCredit(db, accountId, now),
])
return toReputation(row!, credit)
}
+106 -41
View File
@@ -1,7 +1,13 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getPlayerIdsInInstance, getPresence, getProgression, getProgressions } from '@repo/domain'
import {
getPlayerIdsInInstance,
getPresence,
getProgression,
getProgressions,
MessageType,
} from '@repo/domain'
import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
@@ -13,6 +19,7 @@ import {
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
SetSelectedCheerRequest,
form,
idParam,
intQuery,
@@ -24,10 +31,12 @@ import {
} from '../openapi'
import {
addCheer,
CheerCategory,
DAILY_CHEER_CREDIT,
getReputation,
getReputations,
isCheerCategory,
setSelectedCheer,
spendCheerCredit,
} from '../reputation-db'
@@ -63,34 +72,60 @@ async function pushProgression(c: Context<App>, progression: Progression): Promi
}
/**
* The two fields a `ReputationUpdate` frame carries as INSTRUCTIONS rather than as facts
* about the player named in it. Both wear the name of a profile field and mean something
* else here, which is why they are passed per send instead of read off the record — see
* {@link reputationFrame}.
* Push the `MessageReceived` frame that actually plays a cheer on the cheered player's
* client — a Message of type `PlayerCheer` (or its anonymous twin). That message, NOT
* `ReputationUpdate`, is what the client renders the cheer from; every reference server
* (meownet-api, DorkNet, the E12354 C# server) sends it, and a cheer that only pushes
* `ReputationUpdate` moves the counters and plays nothing.
*
* `Data` is the category given, as a string (a Message's `Data` is always a string). An
* anonymous cheer uses the anonymous type and names sender 0, so the recipient's client
* neither shows nor can look up who gave it — `Anonymous` decides nothing else.
*
* Durable, like the rest of the target's frames: the cheer is theirs whether or not they are
* connected right now. Best-effort — the cheer is already counted.
*/
interface CheerEffect {
/** True plays the cheer's visual effect on the receiving client; false is silent. */
isCheerful: boolean
/** WHICH cheer plays — the category just given, not the player's pinned cheer. */
selectedCheer?: number
async function pushCheerMessage(
c: Context<App>,
fromId: number,
toId: number,
category: number,
anonymous: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toId,
NotificationType.MessageReceived,
{
FromPlayerId: anonymous ? 0 : fromId,
ToPlayerId: toId,
Type: anonymous ? MessageType.PlayerCheerAnonymous : MessageType.PlayerCheer,
Data: String(category),
}
)
} catch (err) {
logger.error('failed to push PlayerCheer MessageReceived notification', {
fromId,
toId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Trim a stored reputation to the fields a `ReputationUpdate` frame carries — the client's
* decoder has no `Noteriety` or subscriber counts on this payload, and its `SelectedCheer`
* is nullable where the DTO's is not. Built against the recovered interface so a renamed
* key fails the build rather than vanishing on the wire.
* decoder has no `Noteriety` or subscriber counts on this payload. Nothing else changes:
* `IsCheerful` and `SelectedCheer` are the record's, exactly as the profile DTO serves them.
* (This once overrode both per send to "play" the cheer — no reference does that, and the
* client plays a cheer off the `PlayerCheer` MESSAGE, not this frame.) Built against the
* recovered interface so a renamed key fails the build rather than vanishing on the wire.
*
* `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's effect
* frame names the player being cheered and goes to everyone watching.
* `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's frame
* names the player being cheered and goes to everyone watching.
*/
function reputationFrame(reputation: Reputation, effect: CheerEffect): ReputationPayload {
function reputationFrame(reputation: Reputation): ReputationPayload {
const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation
return {
...payload,
IsCheerful: effect.isCheerful,
SelectedCheer: effect.selectedCheer ?? payload.SelectedCheer,
}
return payload
}
/**
@@ -302,17 +337,18 @@ export const progressionRoutes = new Hono<App>({ strict: false })
'the first cheer opens a 24-hour window, and the first cheer after that window has ' +
'passed starts a fresh one at full credit — so a player who spends all day refills ' +
'24h after their FIRST cheer, not their last.\n\n' +
'The cheered player gets a durable `MessageReceived` frame carrying a Message of ' +
'type 50 (`PlayerCheer`) — 51 (`PlayerCheerAnonymous`, sender 0) when `Anonymous` — ' +
'with `Data` = the category. That message is what plays the cheer on their ' +
'client; the `ReputationUpdate` frames below only refresh the numbers.\n\n' +
'A cheer is played in front of people, so the `ReputationUpdate` frame naming the ' +
'cheered player goes to EVERYONE in the room instance the caller is standing in, ' +
'not just the two of them. The cheered player gets it durably (their counters ' +
'really moved); the rest of the room gets it only if they are connected, since ' +
'the effect belongs to the moment. The caller gets a second frame of their own ' +
'because their `CheerCredit` moved and the response body does not carry it.\n\n' +
'Two fields on that frame are instructions, not facts about the player named in ' +
'it: `IsCheerful` plays the effect — set from `Anonymous`, inverted, so an ' +
'anonymous cheer moves the counters in silence — and `SelectedCheer` says which ' +
'cheer plays, the category just given. Both wear the name of a profile field on ' +
'the reputation DTO and mean something else here.\n\n' +
'`Anonymous` swaps the message for its anonymous twin (type 51, sender 0) and ' +
'nothing else — the counters move the same either way.\n\n' +
'The audience comes from the callers live presence, not from `RoomId`, which is ' +
'accepted and unused: a client cannot aim its effect at a room it is not in. ' +
'Neither field is stored — this keeps counters, not a log of individual cheers.\n\n' +
@@ -350,15 +386,15 @@ export const progressionRoutes = new Hono<App>({ strict: false })
const cheered = await addCheer(c.env.DB, toId, category)
// The frame the room sees. It is ABOUT the player cheered — `AccountId` is theirs,
// and so are the counters — but it goes to everyone standing there, because the
// cheer is a thing that visibly happens in front of people. `IsCheerful` is what
// plays it (so an anonymous cheer moves the numbers in silence) and `SelectedCheer`
// says WHICH cheer plays: the category just given, not anyone's pinned one.
const frame = reputationFrame(cheered, {
isCheerful: !asBool(formField(body, c, 'Anonymous')),
selectedCheer: category,
})
// The frame that PLAYS the cheer on the cheered player's client — a Message of
// type PlayerCheer (or its anonymous twin). `ReputationUpdate` alone moves the
// numbers and shows nothing.
await pushCheerMessage(c, fromId, toId, category, asBool(formField(body, c, 'Anonymous')))
// The frame the room sees: the cheered player's record, so everyone's copy of
// their counters moves. It is ABOUT them — `AccountId` is theirs — but goes to
// everyone standing there.
const frame = reputationFrame(cheered)
// The audience is read from the GIVER's live presence, not from the body's
// `RoomId` — a client that lied about the room would otherwise play its effect in
@@ -379,20 +415,49 @@ export const progressionRoutes = new Hono<App>({ strict: false })
// The caller's own record, with the credit the spend just resolved rather than a
// re-read — a cheer they fired off in parallel must not make this frame report a
// credit they no longer have. Never cheerful: this one reports THEIR numbers, and
// nothing was cheered at them.
// credit they no longer have.
await pushReputation(
c,
fromId,
reputationFrame(
{ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining },
{ isCheerful: false }
)
reputationFrame({ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining })
)
return cheerResult(c)
}
)
// Pinning a cheer to the caller's own profile: the badge the client shows next to
// their name, read back as `SelectedCheer` on the reputation DTO.
.post(
'/api/PlayerCheer/v1/SetSelectedCheer',
describeRoute({
tags: ['Progression'],
summary: 'Pin a cheer to your profile',
description:
'Stores `CheerCategory` as the callers `SelectedCheer` (-1 `None` unpins, read ' +
'back as 0) and pushes them a `ReputationUpdate` so a second device catches up. ' +
'Same `{ Success, Message }` reply as the cheer.',
security: AUTHED,
requestBody: form(SetSelectedCheerRequest, 'The category to pin'),
responses: {
200: json(CheerPlayerResponse, '`{ Success: true, Message: null }`'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const category = asInt(formField(body, c, 'CheerCategory'))
if (category === null || !(category === CheerCategory.None || isCheerCategory(category))) {
return cheerResult(c, 'CheerCategory is not a cheer category')
}
const reputation = await setSelectedCheer(c.env.DB, id, category)
await pushReputation(c, id, reputationFrame(reputation))
return cheerResult(c)
}
)
.post(
'/api/players/v1/progression/bulk',
describeRoute({
+91 -30
View File
@@ -14,6 +14,7 @@ import {
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
@@ -460,30 +461,35 @@ describe('public endpoints', () => {
playerIds?: number[]
ephemeral?: boolean
notificationType: string
data: {
AccountId: number
IsCheerful: boolean
SelectedCheer: number | null
CheerHelpful: number
CheerCredit: number
}
data: Record<string, unknown>
}>
}
for (const id of [7108, 7109, 7120]) await standIn(id, 8800)
await standIn(7121, 8801)
// A signed cheer. Three sends: the target durably, the rest of their instance
// ephemerally, then the giver's own credit refresh.
const signed = await framesFor('False')
expect(signed).toHaveLength(3)
// A signed cheer. Four sends: the PlayerCheer message that plays the cheer on the
// target's client, then the ReputationUpdate for the target durably, the rest of their
// instance ephemerally, and the giver's own credit refresh.
const all = await framesFor('False')
expect(all).toHaveLength(4)
expect(all[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: { FromPlayerId: 7108, ToPlayerId: 7109, Type: MessageType.PlayerCheer, Data: '10' },
})
expect(all[0]!.ephemeral).toBeFalsy()
const signed = all.slice(1)
expect(signed.every((f) => f.notificationType === 'ReputationUpdate')).toBe(true)
// `AccountId` is who the frame is ABOUT, not who it goes to — the room hears about
// 7109. `SelectedCheer` is the category just given (10, Helpful), not a pinned cheer.
// 7109. The frame is 7109's RECORD: `SelectedCheer` is their pinned cheer (none), not
// the category just given, and `IsCheerful` is the profile flag — the message above
// is what plays the cheer.
const played = {
AccountId: 7109,
IsCheerful: true,
SelectedCheer: CheerCategory.Helpful,
SelectedCheer: 0,
CheerHelpful: 1,
}
expect(signed[0]).toMatchObject({ playerId: 7109, data: played })
@@ -491,30 +497,33 @@ describe('public endpoints', () => {
// the durable copy), and 7121 is in another instance entirely.
expect(signed[1]).toMatchObject({ playerIds: [7108, 7120], ephemeral: true, data: played })
// The giver's second frame is about THEM: spent credit, no effect, no cheer selected.
// The giver's second frame is about THEM: their record with the spent credit.
expect(signed[2]).toMatchObject({
playerId: 7108,
data: {
AccountId: 7108,
IsCheerful: false,
IsCheerful: true,
SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1,
},
})
// An anonymous cheer reaches exactly the same people and moves the same counter —
// it just doesn't announce itself.
const anonymous = await framesFor('True')
// it just doesn't announce who gave it: the message is the anonymous type from
// sender 0. The reputation frames are the same records as before.
const allAnonymous = await framesFor('True')
expect(allAnonymous).toHaveLength(4)
expect(allAnonymous[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: { FromPlayerId: 0, ToPlayerId: 7109, Type: MessageType.PlayerCheerAnonymous, Data: '10' },
})
const anonymous = allAnonymous.slice(1)
expect(anonymous[0]).toMatchObject({
playerId: 7109,
data: {
AccountId: 7109,
IsCheerful: false,
SelectedCheer: CheerCategory.Helpful,
CheerHelpful: 2,
},
data: { AccountId: 7109, IsCheerful: true, SelectedCheer: 0, CheerHelpful: 2 },
})
expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: false } })
expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: true } })
// The frame carries only the fields the client's decoder has — no Noteriety or
// subscriber counts, which live on the profile DTO alone.
@@ -556,15 +565,66 @@ describe('public endpoints', () => {
const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
data: { AccountId: number; SelectedCheer: number | null }
data: Record<string, unknown>
}>
// Two sends, both durable and both addressed: nothing was broadcast.
expect(frames.map((f) => f.playerId)).toEqual([7131, 7130])
// Three sends — the target's cheer message and reputation, then the giver's
// reputation — all durable and all addressed: nothing was broadcast.
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
expect(frames.some((f) => f.ephemeral)).toBe(false)
expect(frames[0]!.data).toMatchObject({
AccountId: 7131,
SelectedCheer: CheerCategory.Creative,
expect(frames[0]!.data).toMatchObject({ ToPlayerId: 7131, Type: MessageType.PlayerCheer, Data: '40' })
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
})
test('SetSelectedCheer pins a cheer to the profile and pushes the record', async () => {
const pin = async (CheerCategory: string, sub: string) =>
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ CheerCategory }),
})
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// 7140 has never been cheered — pinning still works, creating their row.
const res = await pin(String(CheerCategory.GreatHost), '7140')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
IsCheerful: true,
CheerGreatHost: 0,
})
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
data: Record<string, unknown>
}>
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({
playerId: 7140,
data: { AccountId: 7140, SelectedCheer: CheerCategory.GreatHost },
})
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: CheerCategory.GreatHost })
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: 0 })
expect(await (await pin('7', '7140')).json()).toEqual({
Success: false,
Message: 'CheerCategory is not a cheer category',
})
expect((await pin('0', '7140').then((r) => r.status))).toBe(200)
expect(
(
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
body: new URLSearchParams({ CheerCategory: '0' }),
})
).status
).toBe(401)
})
test('cheering needs a token', async () => {
@@ -5299,6 +5359,7 @@ describe('openapi', () => {
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /voice/config',
'POST /api/PlayerCheer/v1/SetSelectedCheer',
'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',