mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -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
|
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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 caller’s live presence, not from `RoomId`, which is ' +
|
'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. ' +
|
'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 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(
|
.post(
|
||||||
'/api/players/v1/progression/bulk',
|
'/api/players/v1/progression/bulk',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user