mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 23:21:30 -07:00
[api] wip reputation
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user