mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] #29 fix player cheers
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 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<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 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<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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user