From 4ff9609a8ffa86e0b04d9f5ee5794b46fd1792b9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 21:37:07 -0400 Subject: [PATCH] [api] wip reputation --- CLAUDE.md | 20 ++ apps/api/migrations/0013_reputation.sql | 52 ++++ apps/api/src/openapi.ts | 51 +++- apps/api/src/reputation-db.ts | 331 ++++++++++++++++++++++ apps/api/src/routes/progression.ts | 292 +++++++++++++++++-- apps/api/src/test/integration/api.test.ts | 296 +++++++++++++++++++ apps/api/vitest.config.ts | 9 + packages/domain/src/presence-db.ts | 25 ++ 8 files changed, 1041 insertions(+), 35 deletions(-) create mode 100644 apps/api/migrations/0013_reputation.sql create mode 100644 apps/api/src/reputation-db.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4772a26..6c1c7d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,26 @@ 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. +- 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. - Accessibility is sent as the `RoomAccessibility` enum NAME on `rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members diff --git a/apps/api/migrations/0013_reputation.sql b/apps/api/migrations/0013_reputation.sql new file mode 100644 index 0000000..8eb94d8 --- /dev/null +++ b/apps/api/migrations/0013_reputation.sql @@ -0,0 +1,52 @@ +-- Player reputation (the cheer counters on a profile) and the daily cheer credit that +-- pays for handing one out. Both owned by the `api` worker, which is the only reader and +-- the only writer — `POST /api/PlayerCheer/v1/create` writes them and the +-- `/api/playerReputation/…` reads serve them. Generated from src/reputation-db.ts +-- (SCHEMA_DDL) — keep in sync. +-- +-- Two tables because they answer two different questions. `reputation` is what a player +-- has RECEIVED: one counter per cheer category. `player_cheer` is what they have left to +-- GIVE, refilling to 20 once the window in `created` is a day old — a lazy reset, so +-- nothing has to run on a schedule. +-- +-- Neither row is created until it is needed: a missing `reputation` row means nobody has +-- cheered that player, which is the all-zero record the endpoints already served, and a +-- missing `player_cheer` row means they have never spent a cheer, i.e. full credit. So +-- reads fall back to the defaults instead of inserting on a GET. +-- +-- `CheerCredit` on the DTO is NOT a column here even though the client's record carries it +-- next to the counters: it is `player_cheer.cheers_left` with the rollover applied. Storing +-- it in both places would let the number a player reads drift from the one the spend checks. +-- +-- `noteriety` keeps the reference's spelling (the client's field is `Noteriety`). It and +-- the subscriber counts are stored but nothing writes them yet — they are per-player +-- 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. + +CREATE TABLE IF NOT EXISTS reputation ( + account_id INTEGER PRIMARY KEY, + noteriety INTEGER NOT NULL DEFAULT 0, + cheer_general INTEGER NOT NULL DEFAULT 0, + cheer_helpful INTEGER NOT NULL DEFAULT 0, + cheer_creative INTEGER NOT NULL DEFAULT 0, + 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 + ); + +-- `created` is the START of the live credit window, not the row's creation time: spending +-- a cheer inside a window leaves it alone, so a player refills 24h after their FIRST cheer +-- rather than sliding the deadline forward with every one they hand out. Stored as an +-- ISO-8601 UTC string, which is fixed-width and so orders correctly under SQLite's plain +-- string comparison — the spend compares against a cutoff without any date functions. +CREATE TABLE IF NOT EXISTS player_cheer ( + player_id INTEGER PRIMARY KEY, + cheers_left INTEGER NOT NULL, + created TEXT NOT NULL + ); diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts index c40aaec..9e3b94a 100644 --- a/apps/api/src/openapi.ts +++ b/apps/api/src/openapi.ts @@ -250,10 +250,14 @@ export const MutualFriendDto = z.object({ // ---- Progression ----------------------------------------------------------- /** - * A player's reputation (cheer counters). Nobody has earned cheers yet, so every - * counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none), - * not null, and `IsCheerful` is true — the client reads it to decide whether the player - * may hand out cheers at all. + * A player's reputation (cheer counters), read from the `reputation` table. A player + * nobody has cheered yet has no row and reads back all-zero with full cheer credit. + * `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client + * reads to decide whether the player may hand out cheers at all. + * + * `CheerCredit` is the odd one out: it is what the player has left to GIVE (out of 20 per + * day), not something they have received, and it comes from `player_cheer` rather than + * from the reputation row. */ export const ReputationDto = z.object({ AccountId: z.int(), @@ -277,6 +281,45 @@ export const ProgressionDto = z.object({ XP: z.int(), }) +/** + * The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the + * counter the cheer increments: `Anonymous` is spent on the notification it triggers and + * `RoomId` is dropped outright — see the route. + */ +export const CheerPlayerRequest = z.object({ + PlayerIdTo: z.string().describe('The account being cheered'), + CheerCategory: z + .string() + .describe('0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative'), + RoomId: z + .string() + .optional() + .describe( + 'The room it happened in. Accepted but NOT used — the audience for the cheer’s ' + + 'effect comes from the caller’s live presence, so a client cannot aim it at a room ' + + 'it is not in' + ), + Anonymous: z + .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' + ), +}) + +/** + * 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 + * where that one sends an empty string. On a refusal it names the reason (out of credit, + * bad category, cheering yourself), which the client shows the player. + */ +export const CheerPlayerResponse = z.object({ + Success: z.boolean(), + Message: z.string().nullable().describe('Null when the cheer landed'), +}) + /** The `Ids` form body the bulk POST endpoints take. */ export const BulkIdsRequest = z.object({ Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'), diff --git a/apps/api/src/reputation-db.ts b/apps/api/src/reputation-db.ts new file mode 100644 index 0000000..3a425c7 --- /dev/null +++ b/apps/api/src/reputation-db.ts @@ -0,0 +1,331 @@ +/** + * Player reputation — the cheer counters on a profile — and the daily cheer credit that + * pays for handing one out, on the shared `recflare` D1 database. + * + * Two tables, because they answer two different questions: + * + * - `reputation` is what a player has RECEIVED: one counter per cheer category, plus the + * assorted profile numbers the DTO carries. One row per account, created the first time + * somebody cheers them — a missing row means "nobody has cheered them yet", which is + * exactly the all-zero default the reputation endpoints already served, so reads fall + * back to it rather than inserting on a GET. + * - `player_cheer` is what a player has left to GIVE: a credit that refills to + * {@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 + * 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}.) + * + * 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). + */ + +/** Schema DDL (mirror of migrations/0013_reputation.sql). */ +export const SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS reputation ( + account_id INTEGER PRIMARY KEY, + noteriety INTEGER NOT NULL DEFAULT 0, + cheer_general INTEGER NOT NULL DEFAULT 0, + cheer_helpful INTEGER NOT NULL DEFAULT 0, + cheer_creative INTEGER NOT NULL DEFAULT 0, + 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 + )`, + `CREATE TABLE IF NOT EXISTS player_cheer ( + player_id INTEGER PRIMARY KEY, + cheers_left INTEGER NOT NULL, + created TEXT NOT NULL + )`, +] + +/** + * The cheer categories the client posts as `CheerCategory`. The gaps are the client's — + * the enum steps by ten, so it can grow without renumbering. + */ +export enum CheerCategory { + None = -1, + General = 0, + Helpful = 10, + Sportmanship = 20, + GreatHost = 30, + Creative = 40, +} + +/** + * The `reputation` column each category counts into. Doubles as the whitelist the spend + * interpolates into its SQL: a category that isn't a key here never reaches the query. + * `None` is absent deliberately — it is the client's "no category", not a counter. + */ +const CHEER_COLUMN: Partial> = { + [CheerCategory.General]: 'cheer_general', + [CheerCategory.Helpful]: 'cheer_helpful', + [CheerCategory.Sportmanship]: 'cheer_sportsman', + [CheerCategory.GreatHost]: 'cheer_great_host', + [CheerCategory.Creative]: 'cheer_creative', +} + +/** Whether `value` names a category that counts — i.e. anything but `None`. */ +export function isCheerCategory(value: number): value is CheerCategory { + return value in CHEER_COLUMN +} + +/** How many cheers a player may hand out per window. */ +export const DAILY_CHEER_CREDIT = 20 + +/** How long a credit window lasts before it refills. */ +export const CHEER_WINDOW_MS = 24 * 60 * 60 * 1000 + +/** A stored reputation row (snake_case columns, one row per account). */ +interface ReputationRow { + account_id: number + noteriety: number + cheer_general: number + cheer_helpful: number + cheer_creative: number + cheer_great_host: number + cheer_sportsman: number + subscriber_count: number + subscribed_count: number +} + +/** + * A player's reputation as the client's DTO renders it — and, trimmed and with `IsCheerful` + * overridden, 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. + */ +export interface Reputation { + AccountId: number + IsCheerful: boolean + Noteriety: number + SelectedCheer: number + CheerCredit: number + CheerGeneral: number + CheerHelpful: number + CheerCreative: number + CheerGreatHost: number + CheerSportsman: number + SubscriberCount: 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. + * `credit` is passed in rather than defaulted because a player can have spent cheers + * without having received any — the two tables are independent. + */ +export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation { + return { + AccountId: accountId, + IsCheerful: IS_CHEERFUL, + Noteriety: 0, + SelectedCheer: SELECTED_CHEER, + CheerCredit: credit, + CheerGeneral: 0, + CheerHelpful: 0, + CheerCreative: 0, + CheerGreatHost: 0, + CheerSportsman: 0, + SubscriberCount: 0, + SubscribedCount: 0, + } +} + +/** Project a stored row onto the DTO, with the credit read from `player_cheer`. */ +function toReputation(row: ReputationRow, credit: number): Reputation { + return { + AccountId: row.account_id, + IsCheerful: IS_CHEERFUL, + Noteriety: row.noteriety, + SelectedCheer: SELECTED_CHEER, + CheerCredit: credit, + CheerGeneral: row.cheer_general, + CheerHelpful: row.cheer_helpful, + CheerCreative: row.cheer_creative, + CheerGreatHost: row.cheer_great_host, + CheerSportsman: row.cheer_sportsman, + SubscriberCount: row.subscriber_count, + SubscribedCount: row.subscribed_count, + } +} + +/** + * The instant a credit window has to have started AFTER for the stored `cheers_left` to + * still apply. Anything at or before it has rolled over. ISO-8601 UTC is fixed-width, so + * SQLite's string comparison orders these correctly — no date functions needed. + */ +function windowCutoff(now: Date): string { + return new Date(now.getTime() - CHEER_WINDOW_MS).toISOString() +} + +/** + * How many cheers each of `playerIds` has left to give, in the order asked — a read, so + * a window that has rolled over reads as a full credit WITHOUT writing the reset back. + * The reset is the spend's job; doing it here would refill a player's credit every time + * somebody looked at their profile. + */ +export async function getCheerCredits( + db: D1Database, + playerIds: number[], + now: Date = new Date() +): Promise> { + const credits = new Map() + if (playerIds.length === 0) return credits + const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ') + const { results } = await db + .prepare( + `SELECT player_id, cheers_left FROM player_cheer + WHERE created > ?1 AND player_id IN (${placeholders})` + ) + .bind(windowCutoff(now), ...playerIds) + .all<{ player_id: number; cheers_left: number }>() + for (const row of results) credits.set(row.player_id, row.cheers_left) + return credits +} + +/** One player's remaining cheer credit (see {@link getCheerCredits} — also a pure read). */ +export async function getCheerCredit( + db: D1Database, + playerId: number, + now: Date = new Date() +): Promise { + const credits = await getCheerCredits(db, [playerId], now) + return credits.get(playerId) ?? DAILY_CHEER_CREDIT +} + +/** + * Reputations for a list of ids, in the order asked and one per id — the bulk lookups + * render a profile card per entry, so an id with no row still gets its default rather + * than being dropped from the list. + */ +export async function getReputations( + db: D1Database, + accountIds: number[], + now: Date = new Date() +): Promise { + if (accountIds.length === 0) return [] + const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ') + const [{ results }, credits] = await Promise.all([ + db + .prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`) + .bind(...accountIds) + .all(), + getCheerCredits(db, accountIds, now), + ]) + const stored = new Map(results.map((r) => [r.account_id, r])) + return accountIds.map((id) => { + const credit = credits.get(id) ?? DAILY_CHEER_CREDIT + const row = stored.get(id) + return row === undefined ? defaultReputation(id, credit) : toReputation(row, credit) + }) +} + +/** One player's reputation, defaulted when nobody has cheered them yet. */ +export async function getReputation( + db: D1Database, + accountId: number, + now: Date = new Date() +): Promise { + const [reputation] = await getReputations(db, [accountId], now) + return reputation! +} + +/** + * Take one cheer out of a player's daily credit, resolving the credit they have left, or + * null when they had none to spend. + * + * One statement, so two cheers fired off together can't both read the same stale credit + * and write it back — the client lets a player cheer several people in a row. The three + * cases fold into the upsert: + * + * - no row: insert one at `DAILY_CHEER_CREDIT - 1`, window starting now; + * - the window rolled over (`created` at or before the cutoff): reset to + * `DAILY_CHEER_CREDIT - 1` and start a fresh window, which is what makes the credit + * refill lazily rather than needing a cron; + * - the window is live: decrement, keeping the window's original start so a player who + * spends all day still refills 24h after their FIRST cheer, not their last. + * + * The `WHERE` on the update is the refusal: a live window with nothing left updates no + * row, so `RETURNING` yields nothing and the caller answers "out of cheers". + */ +export async function spendCheerCredit( + db: D1Database, + playerId: number, + now: Date = new Date() +): Promise { + const cutoff = windowCutoff(now) + const row = await db + .prepare( + `INSERT INTO player_cheer (player_id, cheers_left, created) VALUES (?1, ?2, ?3) + ON CONFLICT (player_id) DO UPDATE SET + cheers_left = CASE WHEN player_cheer.created <= ?4 + THEN ?2 ELSE player_cheer.cheers_left - 1 END, + created = CASE WHEN player_cheer.created <= ?4 + THEN ?3 ELSE player_cheer.created END + WHERE player_cheer.created <= ?4 OR player_cheer.cheers_left > 0 + RETURNING cheers_left` + ) + .bind(playerId, DAILY_CHEER_CREDIT - 1, now.toISOString(), cutoff) + .first<{ cheers_left: number }>() + return row === null ? null : row.cheers_left +} + +/** + * Count a received cheer against `accountId`'s category counter, returning the reputation + * they now hold. Creates the row on the first cheer they ever receive. + * + * The column is looked up in {@link CHEER_COLUMN} rather than built from the category, so + * only the five known names can reach the SQL; an unknown category is rejected by the + * route before it gets here. + */ +export async function addCheer( + db: D1Database, + accountId: number, + category: CheerCategory, + now: Date = new Date() +): Promise { + const column = CHEER_COLUMN[category] + if (column === undefined) throw new Error(`unknown cheer category ${category}`) + const [row, credit] = await Promise.all([ + db + .prepare( + `INSERT INTO reputation (account_id, ${column}) VALUES (?1, 1) + ON CONFLICT (account_id) DO UPDATE SET ${column} = reputation.${column} + 1 + RETURNING *` + ) + .bind(accountId) + .first(), + getCheerCredit(db, accountId, now), + ]) + // RETURNING always yields the upserted row; the non-null assert keeps the caller from + // having to handle an impossible null. + return toReputation(row!, credit) +} diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index 29369d6..0c69c6c 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -1,15 +1,18 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' -import { getProgression, getProgressions } from '@repo/domain' +import { getPlayerIdsInInstance, getPresence, getProgression, getProgressions } from '@repo/domain' import { logger } from '@repo/hono-helpers' // The notification-type ids the hub carries (owned by the `notify` worker). Imported as a // value — the enum has no runtime dependencies. import { NotificationType } from '../../../notify/src/notification-types' -import { parseFormIds, queryIds } from '../http' +import { authedId, parseFormIds, queryIds, unauthorized } from '../http' import { + AUTHED, BulkIdsRequest, + CheerPlayerRequest, + CheerPlayerResponse, form, idParam, intQuery, @@ -17,11 +20,22 @@ import { JsonArray, ProgressionDto, ReputationDto, + UNAUTHORIZED_RESPONSE, } from '../openapi' +import { + addCheer, + DAILY_CHEER_CREDIT, + getReputation, + getReputations, + isCheerCategory, + spendCheerCredit, +} from '../reputation-db' import type { Context } from 'hono' import type { Progression } from '@repo/domain' +import type { ReputationPayload } from '../../../notify/src/notification-payloads' import type { App } from '../context' +import type { Reputation } from '../reputation-db' /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' @@ -49,28 +63,134 @@ async function pushProgression(c: Context, progression: Progression): Promi } /** - * Default reputation for an account — the fallback used with no DB. Nobody has - * earned cheers yet, so every counter is 0 and everyone has their full cheer credit. - * `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true: - * the client reads it to decide whether the player may hand out cheers at all. + * 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}. */ -function defaultReputation(id: number) { +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 +} + +/** + * 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. + * + * `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. + */ +function reputationFrame(reputation: Reputation, effect: CheerEffect): ReputationPayload { + const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation return { - AccountId: id, - IsCheerful: true, - Noteriety: 0, - SelectedCheer: 0, - CheerCredit: 20, - CheerGeneral: 0, - CheerHelpful: 0, - CheerCreative: 0, - CheerGreatHost: 0, - CheerSportsman: 0, - SubscriberCount: 0, - SubscribedCount: 0, + ...payload, + IsCheerful: effect.isCheerful, + SelectedCheer: effect.selectedCheer ?? payload.SelectedCheer, } } +/** + * Push a `ReputationUpdate` frame to one player, durably — it survives them being offline + * and lands on their next connect. For the frames that report a real change to the player + * they name: their counters moved, or their credit did. + * + * Best-effort: the cheer is already stored by the time this runs, so a hub hiccup must not + * fail the request — the numbers are right on the next read either way. + */ +async function pushReputation( + c: Context, + playerId: number, + frame: ReputationPayload +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + playerId, + NotificationType.ReputationUpdate, + { ...frame } + ) + } catch (err) { + logger.error('failed to push ReputationUpdate notification', { + playerId, + accountId: frame.AccountId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * Push the same frame to a roomful of players, EPHEMERALLY — delivered to whoever is + * connected and dropped for anyone who isn't. + * + * That is the right send for an audience frame. A cheer's effect belongs to the moment it + * happened; queueing it would play someone else's cheer at a bystander when they next log + * in, hours later and somewhere else. The people the cheer actually changed something for + * get their own durable frame instead. + */ +async function pushReputationToRoom( + c: Context, + playerIds: number[], + frame: ReputationPayload +): Promise { + if (playerIds.length === 0) return + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral( + playerIds, + NotificationType.ReputationUpdate, + { ...frame } + ) + } catch (err) { + logger.error('failed to push ReputationUpdate notification to room', { + playerIds, + accountId: frame.AccountId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * Read one field of the cheer form. The client posts it form-encoded, but the same names + * also arrive as a query string on some builds, so both are accepted (same helper the + * moderation routes use on their forms). + */ +function formField( + body: Record, + c: Context, + name: string +): string | undefined { + const raw = body[name] + if (typeof raw === 'string' && raw !== '') return raw + return c.req.query(name) || undefined +} + +/** Parse a form field as an integer, or null when absent / not a number. */ +function asInt(value: string | undefined): number | null { + if (value === undefined) return null + const n = Number.parseInt(value, 10) + return Number.isNaN(n) ? null : n +} + +/** + * Parse a form field as a bool. The client sends .NET's `True`/`False`, so the match is + * case-insensitive; anything else — an absent field included — reads as false, which is the + * safe default for `Anonymous` (a cheer nobody asked to hide is a signed one). + */ +function asBool(value: string | undefined): boolean { + return value !== undefined && /^(true|1)$/i.test(value.trim()) +} + +/** + * The `{ Success, Message }` body a cheer answers with — PascalCase, as the reference, and + * `Message` is NULL on success rather than an empty string. That is not the same envelope + * as the lowercase `{ success, error: "" }` the reports and warnings use; don't unify them. + */ +function cheerResult(c: Context, message: string | null = null) { + return c.json({ Success: message === null, Message: message }) +} + /** * The repeated `id` query param the 2023 client uses on the bulk GET forms — each value * may itself be a comma-separated list, so `?id=1,2&id=3` is three ids. @@ -90,12 +210,15 @@ export const progressionRoutes = new Hono({ strict: false }) tags: ['Progression'], summary: 'A player’s reputation', description: - 'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' + - 'every player gets the same all-zero record with full cheer credit.', + 'The cheer counters shown on a player’s profile, from the `reputation` table. A ' + + 'player nobody has cheered has no row and reads back all-zero. `CheerCredit` is ' + + 'the odd one out — what they have left to GIVE today, out of ' + + `${DAILY_CHEER_CREDIT}; it refills lazily, so a stale window reads as full ` + + 'without being reset here.', parameters: [idParam('id', 'Account id')], responses: { 200: json(ReputationDto, 'The player’s reputation') }, }), - (c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10))) + async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10))) ) .get( '/api/players/v1/progression/:id', @@ -131,23 +254,19 @@ export const progressionRoutes = new Hono({ strict: false }) }), (c) => c.json([]) ) - // Synthesize a default reputation per requested id (the intended behavior; - // the DB-less fallback reads a static JSON file instead). .post( '/api/playerReputation/v2/bulk', describeRoute({ tags: ['Progression'], summary: 'Reputations in bulk', description: - 'One default reputation per requested id, in request order. Ids that name no ' + - 'account still get a record — the client renders a profile card from it.', + 'One reputation per requested id, in request order. Ids that name no account — or ' + + 'that nobody has cheered — still get an all-zero record rather than being dropped: ' + + 'the client renders a profile card from each entry.', requestBody: BULK_ID_BODY, responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') }, }), - async (c) => { - const ids = await parseFormIds(c) - return c.json(ids.map(defaultReputation)) - } + async (c) => c.json(await getReputations(c.env.DB, await parseFormIds(c))) ) // The 2023 client calls this as a GET with repeated `id` query params. .get( @@ -161,7 +280,118 @@ export const progressionRoutes = new Hono({ strict: false }) parameters: BULK_ID_QUERY, responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') }, }), - (c) => c.json(queryIds(c).map(defaultReputation)) + async (c) => c.json(await getReputations(c.env.DB, queryIds(c))) + ) + // Cheering another player: spend one of the caller's daily credits, count it against the + // target's category counter, and play it in front of the room. + // + // Nothing stores an individual cheer — this keeps a per-player counter, not a log of who + // cheered whom. So neither `RoomId` nor `Anonymous` reaches storage: both are spent + // immediately on the notification, one deciding who sees it and the other whether it is + // seen at all. + .post( + '/api/PlayerCheer/v1/create', + describeRoute({ + tags: ['Progression'], + summary: 'Cheer another player', + description: + 'Hands one cheer to `PlayerIdTo` in the category `CheerCategory` names (0 General, ' + + '10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative), counting it on their ' + + '`reputation` row.\n\n' + + `A player may give ${DAILY_CHEER_CREDIT} cheers per day. The credit refills lazily: ` + + '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' + + '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' + + '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' + + 'Refusals (no credit left, an unknown category, cheering yourself) answer 200 with ' + + '`{ Success: false, Message }` rather than an error status — the client shows the ' + + 'message.', + security: AUTHED, + requestBody: form(CheerPlayerRequest, 'The cheer'), + responses: { + 200: json( + CheerPlayerResponse, + '`{ Success: true, Message: null }` — see above for refusals' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const fromId = await authedId(c) + if (fromId === null) return unauthorized(c) + + const body = await c.req.parseBody().catch(() => ({}) as Record) + const toId = asInt(formField(body, c, 'PlayerIdTo')) + if (toId === null) return cheerResult(c, 'PlayerIdTo is required') + if (toId === fromId) return cheerResult(c, 'You cannot cheer yourself') + + // Validated BEFORE the credit is spent: a category we can't count would otherwise + // take a cheer off the caller and give nothing to anyone. + const category = asInt(formField(body, c, 'CheerCategory')) + if (category === null || !isCheerCategory(category)) { + return cheerResult(c, 'CheerCategory is not a cheer category') + } + + const remaining = await spendCheerCredit(c.env.DB, fromId) + if (remaining === null) return cheerResult(c, 'You are out of cheers for today') + + 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 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 + // someone else's. A cheer given outside a room instance (from a profile screen) + // simply has no audience. + const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, fromId) + const instanceId = presence?.roomInstance?.roomInstanceId + const audience = + instanceId === undefined + ? [] + : (await getPlayerIdsInInstance(c.env.DB, instanceId)).filter((id) => id !== toId) + + // The cheered player is deliberately not in that list: for them this is a real + // change to their own record, so they get it durably and get it whether or not + // they were in the room — the room gets a copy that expires with the moment. + await pushReputation(c, toId, frame) + await pushReputationToRoom(c, audience, frame) + + // 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. + await pushReputation( + c, + fromId, + reputationFrame( + { ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining }, + { isCheerful: false } + ) + ) + + return cheerResult(c) + } ) .post( '/api/players/v1/progression/bulk', diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 2108cbc..c8fd724 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -44,6 +44,14 @@ import { isPlayerBanned, SCHEMA_DDL as REPORTS_SCHEMA_DDL, } from '../../reports-db' +import { + CheerCategory, + DAILY_CHEER_CREDIT, + getCheerCredit, + getReputation, + SCHEMA_DDL as REPUTATION_SCHEMA_DDL, + spendCheerCredit, +} from '../../reputation-db' import { charadesWordsFor } from '../../routes/gameplay' import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db' @@ -136,6 +144,9 @@ beforeAll(async () => { // Player events table (owned by the api worker) — scheduled events live here. for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + + // Reputation + cheer credit (owned by the api worker) — cheering writes both. + for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -319,6 +330,290 @@ describe('public endpoints', () => { expect(reps.map((r) => r.AccountId)).toEqual([1, 2]) }) + // Cheering: `POST /api/PlayerCheer/v1/create`. The giver comes from the token, so these + // use ids of their own (71xx) rather than the shared 42 — a spent credit is durable + // state, and the reputation reads above assert all-zero records. + const cheer = async (fields: Record, sub = '7100') => + exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, { + method: 'POST', + headers: { + ...(await bearer(sub)), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(fields), + }) + + const reputationOf = async (id: number) => + (await (await exports.default.fetch(`${ORIGIN}/api/playerReputation/v1/${id}`)).json()) as { + CheerCredit: number + CheerGeneral: number + CheerHelpful: number + } + + test('a cheer counts on the target and spends the giver’s credit', async () => { + // The body the client posts, verbatim from the live request. + const res = await cheer({ + PlayerIdTo: '7101', + CheerCategory: '0', + RoomId: '112', + Anonymous: 'False', + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Success: true, Message: null }) + + // The target's counter moved and nothing else did — in particular their OWN credit is + // untouched, since receiving a cheer doesn't pay for giving one. + expect(await reputationOf(7101)).toMatchObject({ + CheerGeneral: 1, + CheerHelpful: 0, + CheerCredit: DAILY_CHEER_CREDIT, + }) + // The giver paid, and has no counters of their own. + expect(await reputationOf(7100)).toMatchObject({ + CheerGeneral: 0, + CheerCredit: DAILY_CHEER_CREDIT - 1, + }) + }) + + test('each category counts into its own column', async () => { + for (const category of [ + CheerCategory.General, + CheerCategory.Helpful, + CheerCategory.Sportmanship, + CheerCategory.GreatHost, + CheerCategory.Creative, + ]) { + expect( + (await cheer({ PlayerIdTo: '7102', CheerCategory: String(category) }, '7103')).status + ).toBe(200) + } + expect(await getReputation(env.DB, 7102)).toEqual({ + AccountId: 7102, + IsCheerful: true, + Noteriety: 0, + SelectedCheer: 0, + CheerCredit: DAILY_CHEER_CREDIT, + CheerGeneral: 1, + CheerHelpful: 1, + CheerCreative: 1, + CheerGreatHost: 1, + CheerSportsman: 1, + SubscriberCount: 0, + SubscribedCount: 0, + }) + }) + + test('a cheer the server can’t count is refused before it costs anything', async () => { + // Each refusal answers 200 with the reason — the client shows `Message` — and none of + // them may take a credit off the caller, which is what the closing assertion checks. + for (const [fields, Message] of [ + [{ PlayerIdTo: '7105' }, 'CheerCategory is not a cheer category'], + // -1 is the enum's `None`: a real member, but not a counter. + [{ PlayerIdTo: '7105', CheerCategory: '-1' }, 'CheerCategory is not a cheer category'], + [{ PlayerIdTo: '7105', CheerCategory: '5' }, 'CheerCategory is not a cheer category'], + [{ CheerCategory: '0' }, 'PlayerIdTo is required'], + [{ PlayerIdTo: '7104', CheerCategory: '0' }, 'You cannot cheer yourself'], + ] as Array<[Record, string]>) { + const res = await cheer(fields, '7104') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ Success: false, Message }) + } + expect(await getCheerCredit(env.DB, 7104)).toBe(DAILY_CHEER_CREDIT) + expect(await reputationOf(7105)).toMatchObject({ CheerGeneral: 0 }) + }) + + test('the cheer frame plays in front of the whole room instance', async () => { + // Presence is written by the `match` worker; seeded straight into the table here. + // 7108 (the giver), 7109 (the target) and 7120 (a bystander) share instance 8800; + // 7121 stands in a different instance and must hear nothing. + const standIn = async (accountId: number, roomInstanceId: number | null) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: roomInstanceId === null ? null : { roomInstanceId, roomId: 112 }, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 0, + appVersion: GAME_VERSION, + expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS, + }) + ) + .run() + + // The notify DO is stubbed to record every notifyPlayer / notifyPlayersEphemeral call + // (see vitest.config). + const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + const framesFor = async (anonymous: string) => { + await cheerHub().fetch('http://do/all', { method: 'DELETE' }) + expect( + ( + await cheer( + { PlayerIdTo: '7109', CheerCategory: '10', RoomId: '112', Anonymous: anonymous }, + '7108' + ) + ).status + ).toBe(200) + return (await (await cheerHub().fetch('http://do/all')).json()) as Array<{ + playerId?: number + playerIds?: number[] + ephemeral?: boolean + notificationType: string + data: { + AccountId: number + IsCheerful: boolean + SelectedCheer: number | null + CheerHelpful: number + CheerCredit: number + } + }> + } + + 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) + + // `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. + const played = { + AccountId: 7109, + IsCheerful: true, + SelectedCheer: CheerCategory.Helpful, + CheerHelpful: 1, + } + expect(signed[0]).toMatchObject({ playerId: 7109, data: played }) + // The bystander and the giver see it; the target is not in the room list (they got + // 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. + expect(signed[2]).toMatchObject({ + playerId: 7108, + data: { + AccountId: 7108, + IsCheerful: false, + 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') + expect(anonymous[0]).toMatchObject({ + playerId: 7109, + data: { + AccountId: 7109, + IsCheerful: false, + SelectedCheer: CheerCategory.Helpful, + CheerHelpful: 2, + }, + }) + expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: false } }) + + // The frame carries only the fields the client's decoder has — no Noteriety or + // subscriber counts, which live on the profile DTO alone. + expect(Object.keys(anonymous[0]!.data).sort()).toEqual([ + 'AccountId', + 'CheerCreative', + 'CheerCredit', + 'CheerGeneral', + 'CheerGreatHost', + 'CheerHelpful', + 'CheerSportsman', + 'IsCheerful', + 'SelectedCheer', + ]) + }) + + test('a cheer with no room instance still reaches the player cheered', async () => { + // Cheering from a profile screen: the giver has lobby presence (roomInstance null), + // so there is no audience — but the target's own frame is not the room's to lose. + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 7130, + roomInstance: null, + statusVisibility: 0, + deviceClass: 0, + vrMovementMode: 0, + platform: 0, + appVersion: GAME_VERSION, + expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS, + }) + ) + .run() + const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await cheerHub().fetch('http://do/all', { method: 'DELETE' }) + + expect((await cheer({ PlayerIdTo: '7131', CheerCategory: '40' }, '7130')).status).toBe(200) + + const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{ + playerId?: number + ephemeral?: boolean + data: { AccountId: number; SelectedCheer: number | null } + }> + // Two sends, both durable and both addressed: nothing was broadcast. + expect(frames.map((f) => f.playerId)).toEqual([7131, 7130]) + expect(frames.some((f) => f.ephemeral)).toBe(false) + expect(frames[0]!.data).toMatchObject({ + AccountId: 7131, + SelectedCheer: CheerCategory.Creative, + }) + }) + + test('cheering needs a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ PlayerIdTo: '7101', CheerCategory: '0' }), + }) + expect(res.status).toBe(401) + }) + + test('the daily credit runs out and refills a day after the FIRST cheer', async () => { + // Driven through spendCheerCredit with an injected clock: burning 20 cheers over HTTP + // says nothing more than this does, and the rollover can't be tested any other way. + const start = new Date('2026-08-25T09:00:00.000Z') + const at = (hours: number) => new Date(start.getTime() + hours * 60 * 60 * 1000) + + // The first spend opens the window; the credit counts down to nothing. + for (let spent = 1; spent <= DAILY_CHEER_CREDIT; spent++) { + // Spread across the window — spending inside it must not slide the deadline. + expect(await spendCheerCredit(env.DB, 7110, at(spent === 1 ? 0 : 12))).toBe( + DAILY_CHEER_CREDIT - spent + ) + } + expect(await spendCheerCredit(env.DB, 7110, at(12))).toBeNull() + expect(await getCheerCredit(env.DB, 7110, at(12))).toBe(0) + + // 23 hours in, still empty: the window is measured from the first cheer, not the last. + expect(await spendCheerCredit(env.DB, 7110, at(23))).toBeNull() + + // A day after that first cheer it refills — lazily, on the spend itself, so nothing + // has to run on a schedule. + expect(await getCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT) + expect(await spendCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT - 1) + expect(await getCheerCredit(env.DB, 7110, at(25))).toBe(DAILY_CHEER_CREDIT - 1) + }) + + test('a player out of credit is refused, and the target keeps their counters', async () => { + // Empty 7106's credit directly, then try to cheer over HTTP. + for (let i = 0; i < DAILY_CHEER_CREDIT; i++) await spendCheerCredit(env.DB, 7106) + const res = await cheer({ PlayerIdTo: '7107', CheerCategory: '10' }, '7106') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Success: false, + Message: 'You are out of cheers for today', + }) + expect(await reputationOf(7107)).toMatchObject({ CheerHelpful: 0 }) + }) + test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`) expect(res.status).toBe(200) @@ -5004,6 +5299,7 @@ describe('openapi', () => { 'GET /outfits/me', 'GET /outfits/me/saved', 'GET /voice/config', + 'POST /api/PlayerCheer/v1/create', 'POST /api/PlayerReporting/v1/deviceId', 'POST /api/PlayerReporting/v1/hile', 'POST /api/PlayerReporting/v1/moderationBlockDetails', diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index aefddd2..81a7cf9 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -25,6 +25,11 @@ export default defineConfig({ // worker pushed (type + payload). GET the DO for the most recent one, // GET /all for the whole list (friend-graph changes notify both players), // DELETE to reset it between assertions. + // + // notifyPlayersEphemeral lands in the same list, tagged `ephemeral` and + // carrying `playerIds` rather than `playerId` — the two sends differ in + // whether an offline recipient gets the frame later, which is a thing worth + // asserting (a cheer's effect is broadcast to a room this way). script: ` import { DurableObject } from 'cloudflare:workers' export class NotificationsHub extends DurableObject { @@ -33,6 +38,10 @@ export default defineConfig({ this.sent.push({ playerId, notificationType, data }) return { delivered: 0, queued: true } } + async notifyPlayersEphemeral(playerIds, notificationType, data) { + this.sent.push({ playerIds, ephemeral: true, notificationType, data }) + return { delivered: 0 } + } async broadcast() { return { delivered: 0 } } async fetch(request) { if (request.method === 'DELETE') { diff --git a/packages/domain/src/presence-db.ts b/packages/domain/src/presence-db.ts index 1a18e23..cc2d5ba 100644 --- a/packages/domain/src/presence-db.ts +++ b/packages/domain/src/presence-db.ts @@ -175,6 +175,31 @@ export async function countPlayersInInstance( return row?.n ?? 0 } +/** + * WHO is standing in a room instance right now — the sibling of + * {@link countPlayersInInstance}, for when the answer has to be addressed rather than + * counted. The fan-out set for anything that happens in front of a room: a cheer plays its + * effect on every client in the instance, not just the two players involved. + * + * Reads only unexpired presence, so it is the LIVE occupancy — a player who crashed out + * drops off it when their row lapses. Ordered by account id for a stable result. + */ +export async function getPlayerIdsInInstance( + db: D1Database, + roomInstanceId: number, + now = nowSeconds() +): Promise { + const { results } = await db + .prepare( + `SELECT account_id AS accountId FROM presence + WHERE room_instance_id = ?1 AND expires_at > ?2 + ORDER BY account_id` + ) + .bind(roomInstanceId, now) + .all<{ accountId: number }>() + return results.map((r) => r.accountId) +} + /** * How many players are online right now, anywhere — one row per account, so this is * the player count a status page means. Counts unexpired presence only: rows outlive