From 199c34d1cb40ca441555da97757c799df151cd42 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 22:22:13 -0400 Subject: [PATCH] [api] #29 fix player cheers --- CLAUDE.md | 32 ++-- apps/api/migrations/0013_reputation.sql | 6 +- .../0014_reputation_selected_cheer.sql | 12 ++ apps/api/src/openapi.ts | 14 +- apps/api/src/reputation-db.ts | 88 ++++++----- apps/api/src/routes/progression.ts | 147 +++++++++++++----- apps/api/src/test/integration/api.test.ts | 121 ++++++++++---- 7 files changed, 287 insertions(+), 133 deletions(-) create mode 100644 apps/api/migrations/0014_reputation_selected_cheer.sql diff --git a/CLAUDE.md b/CLAUDE.md index 6c1c7d0..03ffff9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/apps/api/migrations/0013_reputation.sql b/apps/api/migrations/0013_reputation.sql index 8eb94d8..9561985 100644 --- a/apps/api/migrations/0013_reputation.sql +++ b/apps/api/migrations/0013_reputation.sql @@ -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, diff --git a/apps/api/migrations/0014_reputation_selected_cheer.sql b/apps/api/migrations/0014_reputation_selected_cheer.sql new file mode 100644 index 0000000..d26e636 --- /dev/null +++ b/apps/api/migrations/0014_reputation_selected_cheer.sql @@ -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; diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index 9e3b94a..e98c1d6 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -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 diff --git a/apps/api/src/reputation-db.ts b/apps/api/src/reputation-db.ts index 3a425c7..96a9361 100644 --- a/apps/api/src/reputation-db.ts +++ b/apps/api/src/reputation-db.ts @@ -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 { + 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(), + getCheerCredit(db, accountId, now), + ]) + return toReputation(row!, credit) +} diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index 0c69c6c..347681a 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -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, 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, + fromId: number, + toId: number, + category: number, + anonymous: boolean +): Promise { + 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({ 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 caller’s 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({ 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({ 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 caller’s `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) + 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({ diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index c8fd724..8205f62 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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 }> } 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 }> - // 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 + }> + 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',