[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 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 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. 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 - What PLAYS a cheer on the cheered player's client is a `MessageReceived` frame carrying a
people, so the `ReputationUpdate` frame naming the cheered player goes to everyone in the Message of type 50 `PlayerCheer` (51 `PlayerCheerAnonymous`, `FromPlayerId` 0, when the
room instance, not just the two players — the effect plays on every client that gets it. body says `Anonymous`) with `Data` = the category as a string — the same frame every
Two of the frame's fields are per-send instructions that wear the name of a profile field reference server (meownet-api, DorkNet, E12354) sends. `ReputationUpdate` alone refreshes
on the reputation DTO (`api`: `/api/playerReputation/…`) and mean something else: the counters and shows nothing: the cheer "worked" server-side and nobody saw it. The
- `IsCheerful` on the DTO is a profile flag; on the frame, true plays the visual effect `ReputationUpdate` frames the cheer (`api`: `POST /api/PlayerCheer/v1/create`) sends are
and false moves the counters silently. It comes from the request's `Anonymous`, the RECORD, trimmed — `IsCheerful` (a profile flag, always true) and `SelectedCheer` (the
INVERTED — never off the stored record, which doesn't vary. The second frame, the one cheer pinned via `POST /api/PlayerCheer/v1/SetSelectedCheer`, stored on `reputation`) come
refreshing the giver's own `CheerCredit`, sends false: nothing was cheered at them. off the row exactly as the DTO serves them. This server once overrode both per frame to
- `SelectedCheer` on the DTO is the player's pinned cheer; on the frame it is WHICH cheer "play" the cheer; no reference does, and it played nothing.
plays the category just given. - A cheer is a thing that happens in FRONT of people, so the `ReputationUpdate` naming the
The cheered player gets the frame durably (their counters moved); the rest of the room cheered player goes to everyone in the room instance, not just the two players. The
gets it ephemerally, since an effect queued for someone offline would play out of cheered player gets it durably (their counters moved); the rest of the room gets it
nowhere hours later. The audience comes from the giver's live `presence` row, NOT the ephemerally. The audience comes from the giver's live `presence` row, NOT the body's
body's `RoomId`, which is accepted and unused — otherwise a client could play its effect `RoomId`, which is accepted and unused. Neither `RoomId` nor `Anonymous` is stored.
in a room it isn't in. Neither `RoomId` nor `Anonymous` is stored; the frame is the whole
of their effect.
- 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.
+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 -- 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. -- is a write rather than a migration.
-- --
-- `IsCheerful` and `SelectedCheer` are NOT columns. They ride along on the DTO and the -- `IsCheerful` and `SelectedCheer` were left off here on the theory that nothing varied
-- `ReputationUpdate` frame, but nothing on this server varies them per player — they are -- them per player; 0014 adds them — `SelectedCheer` is written by `SetSelectedCheer`.
-- 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.
CREATE TABLE IF NOT EXISTS reputation ( CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY, 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(), AccountId: z.int(),
IsCheerful: z.boolean(), IsCheerful: z.boolean(),
Noteriety: z.int(), 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(), CheerCredit: z.int(),
CheerGeneral: z.int(), CheerGeneral: z.int(),
CheerHelpful: z.int(), CheerHelpful: z.int(),
@@ -303,12 +303,18 @@ export const CheerPlayerRequest = z.object({
.string() .string()
.optional() .optional()
.describe( .describe(
'`True`/`False` (default `False`). Not stored — it sets `IsCheerful` (inverted) on ' + '`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
'the `ReputationUpdate` frame, which is what plays the cheer effect on the clients ' + 'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
'that receive it'
), ),
}) })
/** 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 * 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 * 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 * {@link DAILY_CHEER_CREDIT} once the window in `created` is a day old. One row per
* account, created the first time they spend one. * 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 * 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 * 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; * spend checks. `SelectedCheer` (the cheer pinned to the profile, set by
* they exist only to fill out the DTO. (On the `ReputationUpdate` frame `IsCheerful` is a * `POST /api/PlayerCheer/v1/SetSelectedCheer`) and `IsCheerful` (a profile flag every
* different thing wearing the same name — see {@link IS_CHEERFUL}.) * 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 * The `api` worker owns the schema/migrations (migrations/0013_reputation.sql and
* under its own `migrations_table` so it doesn't clash with the other workers' migrations * 0014_reputation_selected_cheer.sql, applied under its own `migrations_table` so they
* that share the database). * 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[] = [ export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation ( `CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY, account_id INTEGER PRIMARY KEY,
@@ -36,7 +37,9 @@ export const SCHEMA_DDL: string[] = [
cheer_great_host INTEGER NOT NULL DEFAULT 0, cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0, cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count 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 ( `CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY, player_id INTEGER PRIMARY KEY,
@@ -93,15 +96,18 @@ interface ReputationRow {
cheer_sportsman: number cheer_sportsman: number
subscriber_count: number subscriber_count: number
subscribed_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` * A player's reputation as the client's DTO renders it — and, trimmed, as the
* overridden, as the `ReputationUpdate` frame carries it. * `ReputationUpdate` frame carries it.
* *
* Not all of it is stored. `Noteriety` (the reference's spelling), `SubscriberCount` and * `Noteriety` (the reference's spelling), `SubscriberCount` and `SubscribedCount` are
* `SubscribedCount` are columns nothing writes yet. {@link IS_CHEERFUL} and * columns nothing writes yet. `IsCheerful` is a column nothing writes either, defaulted
* {@link SELECTED_CHEER} aren't columns at all — see their comments. * true like every reference serves it. `SelectedCheer` is the pinned cheer, 0 = none.
*/ */
export interface Reputation { export interface Reputation {
AccountId: number AccountId: number
@@ -118,25 +124,6 @@ export interface Reputation {
SubscribedCount: number 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. * 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 * `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 { export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation {
return { return {
AccountId: accountId, AccountId: accountId,
IsCheerful: IS_CHEERFUL, IsCheerful: true,
Noteriety: 0, Noteriety: 0,
SelectedCheer: SELECTED_CHEER, SelectedCheer: 0,
CheerCredit: credit, CheerCredit: credit,
CheerGeneral: 0, CheerGeneral: 0,
CheerHelpful: 0, CheerHelpful: 0,
@@ -163,9 +150,9 @@ export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT
function toReputation(row: ReputationRow, credit: number): Reputation { function toReputation(row: ReputationRow, credit: number): Reputation {
return { return {
AccountId: row.account_id, AccountId: row.account_id,
IsCheerful: IS_CHEERFUL, IsCheerful: row.is_cheerful !== 0,
Noteriety: row.noteriety, Noteriety: row.noteriety,
SelectedCheer: SELECTED_CHEER, SelectedCheer: row.selected_cheer,
CheerCredit: credit, CheerCredit: credit,
CheerGeneral: row.cheer_general, CheerGeneral: row.cheer_general,
CheerHelpful: row.cheer_helpful, CheerHelpful: row.cheer_helpful,
@@ -329,3 +316,30 @@ export async function addCheer(
// having to handle an impossible null. // having to handle an impossible null.
return toReputation(row!, credit) 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 { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' 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' import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
@@ -13,6 +19,7 @@ import {
BulkIdsRequest, BulkIdsRequest,
CheerPlayerRequest, CheerPlayerRequest,
CheerPlayerResponse, CheerPlayerResponse,
SetSelectedCheerRequest,
form, form,
idParam, idParam,
intQuery, intQuery,
@@ -24,10 +31,12 @@ import {
} from '../openapi' } from '../openapi'
import { import {
addCheer, addCheer,
CheerCategory,
DAILY_CHEER_CREDIT, DAILY_CHEER_CREDIT,
getReputation, getReputation,
getReputations, getReputations,
isCheerCategory, isCheerCategory,
setSelectedCheer,
spendCheerCredit, spendCheerCredit,
} from '../reputation-db' } 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 * Push the `MessageReceived` frame that actually plays a cheer on the cheered player's
* about the player named in it. Both wear the name of a profile field and mean something * client — a Message of type `PlayerCheer` (or its anonymous twin). That message, NOT
* else here, which is why they are passed per send instead of read off the record — see * `ReputationUpdate`, is what the client renders the cheer from; every reference server
* {@link reputationFrame}. * (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 { async function pushCheerMessage(
/** True plays the cheer's visual effect on the receiving client; false is silent. */ c: Context<App>,
isCheerful: boolean fromId: number,
/** WHICH cheer plays — the category just given, not the player's pinned cheer. */ toId: number,
selectedCheer?: 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 * 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` * decoder has no `Noteriety` or subscriber counts on this payload. Nothing else changes:
* is nullable where the DTO's is not. Built against the recovered interface so a renamed * `IsCheerful` and `SelectedCheer` are the record's, exactly as the profile DTO serves them.
* key fails the build rather than vanishing on the wire. * (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 * `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's frame
* frame names the player being cheered and goes to everyone watching. * 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 const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation
return { return payload
...payload,
IsCheerful: effect.isCheerful,
SelectedCheer: effect.selectedCheer ?? payload.SelectedCheer,
}
} }
/** /**
@@ -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 ' + '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 ' + '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' + '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 ' + '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, ' + '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 ' + '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 ' + '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 ' + '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' + '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 ' + '`Anonymous` swaps the message for its anonymous twin (type 51, sender 0) and ' +
'it: `IsCheerful` plays the effect — set from `Anonymous`, inverted, so an ' + 'nothing else — the counters move the same either way.\n\n' +
'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' +
'The audience comes from the callers live presence, not from `RoomId`, which is ' + '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. ' + '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' + '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) const cheered = await addCheer(c.env.DB, toId, category)
// The frame the room sees. It is ABOUT the player cheered — `AccountId` is theirs, // The frame that PLAYS the cheer on the cheered player's client — a Message of
// and so are the counters — but it goes to everyone standing there, because the // type PlayerCheer (or its anonymous twin). `ReputationUpdate` alone moves the
// cheer is a thing that visibly happens in front of people. `IsCheerful` is what // numbers and shows nothing.
// plays it (so an anonymous cheer moves the numbers in silence) and `SelectedCheer` await pushCheerMessage(c, fromId, toId, category, asBool(formField(body, c, 'Anonymous')))
// says WHICH cheer plays: the category just given, not anyone's pinned one.
const frame = reputationFrame(cheered, { // The frame the room sees: the cheered player's record, so everyone's copy of
isCheerful: !asBool(formField(body, c, 'Anonymous')), // their counters moves. It is ABOUT them — `AccountId` is theirs — but goes to
selectedCheer: category, // everyone standing there.
}) const frame = reputationFrame(cheered)
// The audience is read from the GIVER's live presence, not from the body's // 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 // `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 // 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 // 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 // credit they no longer have.
// nothing was cheered at them.
await pushReputation( await pushReputation(
c, c,
fromId, fromId,
reputationFrame( reputationFrame({ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining })
{ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining },
{ isCheerful: false }
)
) )
return cheerResult(c) 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( .post(
'/api/players/v1/progression/bulk', '/api/players/v1/progression/bulk',
describeRoute({ describeRoute({
+91 -30
View File
@@ -14,6 +14,7 @@ import {
LEVEL_REQUIRED_XP, LEVEL_REQUIRED_XP,
LEVEL_REWARDS, LEVEL_REWARDS,
MAX_LEVEL, MAX_LEVEL,
MessageType,
OUTFIT_SCHEMA_DDL, OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL, PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS, PRESENCE_TTL_SECONDS,
@@ -460,30 +461,35 @@ describe('public endpoints', () => {
playerIds?: number[] playerIds?: number[]
ephemeral?: boolean ephemeral?: boolean
notificationType: string notificationType: string
data: { data: Record<string, unknown>
AccountId: number
IsCheerful: boolean
SelectedCheer: number | null
CheerHelpful: number
CheerCredit: number
}
}> }>
} }
for (const id of [7108, 7109, 7120]) await standIn(id, 8800) for (const id of [7108, 7109, 7120]) await standIn(id, 8800)
await standIn(7121, 8801) await standIn(7121, 8801)
// A signed cheer. Three sends: the target durably, the rest of their instance // A signed cheer. Four sends: the PlayerCheer message that plays the cheer on the
// ephemerally, then the giver's own credit refresh. // target's client, then the ReputationUpdate for the target durably, the rest of their
const signed = await framesFor('False') // instance ephemerally, and the giver's own credit refresh.
expect(signed).toHaveLength(3) 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 // `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 = { const played = {
AccountId: 7109, AccountId: 7109,
IsCheerful: true, IsCheerful: true,
SelectedCheer: CheerCategory.Helpful, SelectedCheer: 0,
CheerHelpful: 1, CheerHelpful: 1,
} }
expect(signed[0]).toMatchObject({ playerId: 7109, data: played }) 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. // the durable copy), and 7121 is in another instance entirely.
expect(signed[1]).toMatchObject({ playerIds: [7108, 7120], ephemeral: true, data: played }) 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({ expect(signed[2]).toMatchObject({
playerId: 7108, playerId: 7108,
data: { data: {
AccountId: 7108, AccountId: 7108,
IsCheerful: false, IsCheerful: true,
SelectedCheer: 0, SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1, CheerCredit: DAILY_CHEER_CREDIT - 1,
}, },
}) })
// An anonymous cheer reaches exactly the same people and moves the same counter — // An anonymous cheer reaches exactly the same people and moves the same counter —
// it just doesn't announce itself. // it just doesn't announce who gave it: the message is the anonymous type from
const anonymous = await framesFor('True') // 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({ expect(anonymous[0]).toMatchObject({
playerId: 7109, playerId: 7109,
data: { data: { AccountId: 7109, IsCheerful: true, SelectedCheer: 0, CheerHelpful: 2 },
AccountId: 7109,
IsCheerful: false,
SelectedCheer: CheerCategory.Helpful,
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 // The frame carries only the fields the client's decoder has — no Noteriety or
// subscriber counts, which live on the profile DTO alone. // 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<{ const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number playerId?: number
ephemeral?: boolean ephemeral?: boolean
data: { AccountId: number; SelectedCheer: number | null } data: Record<string, unknown>
}> }>
// Two sends, both durable and both addressed: nothing was broadcast. // Three sends — the target's cheer message and reputation, then the giver's
expect(frames.map((f) => f.playerId)).toEqual([7131, 7130]) // 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.some((f) => f.ephemeral)).toBe(false)
expect(frames[0]!.data).toMatchObject({ expect(frames[0]!.data).toMatchObject({ ToPlayerId: 7131, Type: MessageType.PlayerCheer, Data: '40' })
AccountId: 7131, expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
SelectedCheer: CheerCategory.Creative, })
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 () => { test('cheering needs a token', async () => {
@@ -5299,6 +5359,7 @@ describe('openapi', () => {
'GET /outfits/me', 'GET /outfits/me',
'GET /outfits/me/saved', 'GET /outfits/me/saved',
'GET /voice/config', 'GET /voice/config',
'POST /api/PlayerCheer/v1/SetSelectedCheer',
'POST /api/PlayerCheer/v1/create', 'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile', 'POST /api/PlayerReporting/v1/hile',