mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api] wip reputation
This commit is contained in:
@@ -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<App>, 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<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
|
||||
* 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'],
|
||||
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<App>({ 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<App>({ 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<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(
|
||||
'/api/players/v1/progression/bulk',
|
||||
|
||||
Reference in New Issue
Block a user