mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api] wip reputation
This commit is contained in:
@@ -151,6 +151,26 @@ 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
|
||||||
|
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
|
- Accessibility is sent as the `RoomAccessibility` enum NAME on
|
||||||
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
|
||||||
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
+47
-4
@@ -250,10 +250,14 @@ export const MutualFriendDto = z.object({
|
|||||||
// ---- Progression -----------------------------------------------------------
|
// ---- Progression -----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
|
* A player's reputation (cheer counters), read from the `reputation` table. A player
|
||||||
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
|
* nobody has cheered yet has no row and reads back all-zero with full cheer credit.
|
||||||
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
|
* `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client
|
||||||
* may hand out cheers at all.
|
* 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({
|
export const ReputationDto = z.object({
|
||||||
AccountId: z.int(),
|
AccountId: z.int(),
|
||||||
@@ -277,6 +281,45 @@ export const ProgressionDto = z.object({
|
|||||||
XP: z.int(),
|
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. */
|
/** The `Ids` form body the bulk POST endpoints take. */
|
||||||
export const BulkIdsRequest = z.object({
|
export const BulkIdsRequest = z.object({
|
||||||
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
|
||||||
|
|||||||
@@ -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<Record<CheerCategory, string>> = {
|
||||||
|
[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<Map<number, number>> {
|
||||||
|
const credits = new Map<number, number>()
|
||||||
|
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<number> {
|
||||||
|
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<Reputation[]> {
|
||||||
|
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<ReputationRow>(),
|
||||||
|
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<Reputation> {
|
||||||
|
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<number | null> {
|
||||||
|
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<Reputation> {
|
||||||
|
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<ReputationRow>(),
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { describeRoute } from 'hono-openapi'
|
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'
|
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
|
||||||
// value — the enum has no runtime dependencies.
|
// value — the enum has no runtime dependencies.
|
||||||
import { NotificationType } from '../../../notify/src/notification-types'
|
import { NotificationType } from '../../../notify/src/notification-types'
|
||||||
import { parseFormIds, queryIds } from '../http'
|
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
|
AUTHED,
|
||||||
BulkIdsRequest,
|
BulkIdsRequest,
|
||||||
|
CheerPlayerRequest,
|
||||||
|
CheerPlayerResponse,
|
||||||
form,
|
form,
|
||||||
idParam,
|
idParam,
|
||||||
intQuery,
|
intQuery,
|
||||||
@@ -17,11 +20,22 @@ import {
|
|||||||
JsonArray,
|
JsonArray,
|
||||||
ProgressionDto,
|
ProgressionDto,
|
||||||
ReputationDto,
|
ReputationDto,
|
||||||
|
UNAUTHORIZED_RESPONSE,
|
||||||
} from '../openapi'
|
} from '../openapi'
|
||||||
|
import {
|
||||||
|
addCheer,
|
||||||
|
DAILY_CHEER_CREDIT,
|
||||||
|
getReputation,
|
||||||
|
getReputations,
|
||||||
|
isCheerCategory,
|
||||||
|
spendCheerCredit,
|
||||||
|
} from '../reputation-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { Progression } from '@repo/domain'
|
import type { Progression } from '@repo/domain'
|
||||||
|
import type { ReputationPayload } from '../../../notify/src/notification-payloads'
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
import type { Reputation } from '../reputation-db'
|
||||||
|
|
||||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||||
const HUB_INSTANCE = 'global'
|
const HUB_INSTANCE = 'global'
|
||||||
@@ -49,28 +63,134 @@ async function pushProgression(c: Context<App>, progression: Progression): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default reputation for an account — the fallback used with no DB. Nobody has
|
* The two fields a `ReputationUpdate` frame carries as INSTRUCTIONS rather than as facts
|
||||||
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
|
* about the player named in it. Both wear the name of a profile field and mean something
|
||||||
* `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true:
|
* else here, which is why they are passed per send instead of read off the record — see
|
||||||
* the client reads it to decide whether the player may hand out cheers at all.
|
* {@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 {
|
return {
|
||||||
AccountId: id,
|
...payload,
|
||||||
IsCheerful: true,
|
IsCheerful: effect.isCheerful,
|
||||||
Noteriety: 0,
|
SelectedCheer: effect.selectedCheer ?? payload.SelectedCheer,
|
||||||
SelectedCheer: 0,
|
|
||||||
CheerCredit: 20,
|
|
||||||
CheerGeneral: 0,
|
|
||||||
CheerHelpful: 0,
|
|
||||||
CheerCreative: 0,
|
|
||||||
CheerGreatHost: 0,
|
|
||||||
CheerSportsman: 0,
|
|
||||||
SubscriberCount: 0,
|
|
||||||
SubscribedCount: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<App>,
|
||||||
|
playerId: number,
|
||||||
|
frame: ReputationPayload
|
||||||
|
): Promise<void> {
|
||||||
|
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<App>,
|
||||||
|
playerIds: number[],
|
||||||
|
frame: ReputationPayload
|
||||||
|
): Promise<void> {
|
||||||
|
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<string, unknown>,
|
||||||
|
c: Context<App>,
|
||||||
|
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<App>, 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
|
* 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.
|
* 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<App>({ strict: false })
|
|||||||
tags: ['Progression'],
|
tags: ['Progression'],
|
||||||
summary: 'A player’s reputation',
|
summary: 'A player’s reputation',
|
||||||
description:
|
description:
|
||||||
'The cheer counters shown on a player’s profile. No cheers are stored yet, so ' +
|
'The cheer counters shown on a player’s profile, from the `reputation` table. A ' +
|
||||||
'every player gets the same all-zero record with full cheer credit.',
|
'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')],
|
parameters: [idParam('id', 'Account id')],
|
||||||
responses: { 200: json(ReputationDto, 'The player’s reputation') },
|
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(
|
.get(
|
||||||
'/api/players/v1/progression/:id',
|
'/api/players/v1/progression/:id',
|
||||||
@@ -131,23 +254,19 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
|||||||
}),
|
}),
|
||||||
(c) => c.json([])
|
(c) => c.json([])
|
||||||
)
|
)
|
||||||
// Synthesize a default reputation per requested id (the intended behavior;
|
|
||||||
// the DB-less fallback reads a static JSON file instead).
|
|
||||||
.post(
|
.post(
|
||||||
'/api/playerReputation/v2/bulk',
|
'/api/playerReputation/v2/bulk',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Progression'],
|
tags: ['Progression'],
|
||||||
summary: 'Reputations in bulk',
|
summary: 'Reputations in bulk',
|
||||||
description:
|
description:
|
||||||
'One default reputation per requested id, in request order. Ids that name no ' +
|
'One reputation per requested id, in request order. Ids that name no account — or ' +
|
||||||
'account still get a record — the client renders a profile card from it.',
|
'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,
|
requestBody: BULK_ID_BODY,
|
||||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => c.json(await getReputations(c.env.DB, await parseFormIds(c)))
|
||||||
const ids = await parseFormIds(c)
|
|
||||||
return c.json(ids.map(defaultReputation))
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
// The 2023 client calls this as a GET with repeated `id` query params.
|
// The 2023 client calls this as a GET with repeated `id` query params.
|
||||||
.get(
|
.get(
|
||||||
@@ -161,7 +280,118 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
|||||||
parameters: BULK_ID_QUERY,
|
parameters: BULK_ID_QUERY,
|
||||||
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
|
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<string, unknown>)
|
||||||
|
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(
|
.post(
|
||||||
'/api/players/v1/progression/bulk',
|
'/api/players/v1/progression/bulk',
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ import {
|
|||||||
isPlayerBanned,
|
isPlayerBanned,
|
||||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||||
} from '../../reports-db'
|
} 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 { charadesWordsFor } from '../../routes/gameplay'
|
||||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
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.
|
// 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()
|
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
|
// 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])
|
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<string, string>, 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, string>, 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 () => {
|
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`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
@@ -5004,6 +5299,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/create',
|
||||||
'POST /api/PlayerReporting/v1/deviceId',
|
'POST /api/PlayerReporting/v1/deviceId',
|
||||||
'POST /api/PlayerReporting/v1/hile',
|
'POST /api/PlayerReporting/v1/hile',
|
||||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export default defineConfig({
|
|||||||
// worker pushed (type + payload). GET the DO for the most recent one,
|
// worker pushed (type + payload). GET the DO for the most recent one,
|
||||||
// GET /all for the whole list (friend-graph changes notify both players),
|
// GET /all for the whole list (friend-graph changes notify both players),
|
||||||
// DELETE to reset it between assertions.
|
// 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: `
|
script: `
|
||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
export class NotificationsHub extends DurableObject {
|
export class NotificationsHub extends DurableObject {
|
||||||
@@ -33,6 +38,10 @@ export default defineConfig({
|
|||||||
this.sent.push({ playerId, notificationType, data })
|
this.sent.push({ playerId, notificationType, data })
|
||||||
return { delivered: 0, queued: true }
|
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 broadcast() { return { delivered: 0 } }
|
||||||
async fetch(request) {
|
async fetch(request) {
|
||||||
if (request.method === 'DELETE') {
|
if (request.method === 'DELETE') {
|
||||||
|
|||||||
@@ -175,6 +175,31 @@ export async function countPlayersInInstance(
|
|||||||
return row?.n ?? 0
|
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<number[]> {
|
||||||
|
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
|
* 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
|
* the player count a status page means. Counts unexpired presence only: rows outlive
|
||||||
|
|||||||
Reference in New Issue
Block a user