[www] discord per-role token drop

This commit is contained in:
Devin Zuczek
2026-10-03 12:14:41 -04:00
parent 1458df1664
commit 602d6cea90
4 changed files with 285 additions and 2 deletions
+77
View File
@@ -10,6 +10,7 @@ import {
movePlayerToDorm,
writeAuditLog,
} from '@repo/domain'
import { PlatformType } from '@repo/domain/src/enums'
import { intVar, logger } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
@@ -31,6 +32,9 @@ import {
searchReports,
} from '../../api/src/reports-db'
import { getWarningsAgainst } from '../../api/src/warnings-db'
// The platform links, owned by `auth`: a Discord link carries the roles its member held at
// their claim or the last daily sweep (discord-roles.ts), which is what the role drop reads.
import { getLinksForPlatform } from '../../auth/src/platform-db'
// Balances, owned by `econ`. A staff token gift is the same credit econ's own faucets make.
import {
ALL_PLATFORMS,
@@ -955,6 +959,79 @@ export async function giftOnlineTokensHandler(c: Context<App>) {
return c.json({ amount, message, paid })
}
/**
* Send tokens to EVERYONE HOLDING A DISCORD ROLE — online or not. The account page's second
* drop, beside the one to everyone online: the same box, the same cap, a different audience.
*
* The audience is every account whose Discord link records the role: the snapshot the
* benefits claim wrote and the daily sweep (discord-roles.ts) refreshes, read when the button
* is pressed. Nothing asks Discord here — a member who gained the role since the last sweep
* is missed until the next one, and one who lost it is still paid until then; the form says
* as much. A player who holds the role in the guild but never claimed on the website has no
* link, and so is not in the audience however many roles they hold there. The link is the
* unit, so a Discord identity linked to two accounts pays both; an account is paid once
* whatever its links say.
*
* Offline is the point: the econ cron's supporter gift (discord-role-gift.ts) is this on a
* schedule, and this is the hand-run version with an operator's amount and message on it. A
* player who isn't signed in meets the box in `GET /api/avatar/v2/gifts` and the balance on
* their next read, as they do for every box the server hands over.
*
* The role is a Discord snowflake — all digits, compared exactly, as the sweep stores them.
* The amount is bounded as the online drop's is ({@link MAX_TOKEN_DROP} each, positive only):
* this one multiplies by a role's whole membership, which can be larger than the server has
* ever had online at once. The message is required for the same reason.
*/
export async function giftRoleTokensHandler(c: Context<App>) {
const roleId = c.req.param('roleId') ?? ''
if (!/^\d+$/.test(roleId)) {
return c.json({ error: 'A Discord role id is all digits' }, 400)
}
const body = (await c.req.json().catch(() => ({}))) as { amount?: unknown; message?: unknown }
const amount = Number(body.amount)
if (!Number.isInteger(amount) || amount <= 0) {
return c.json({ error: 'Enter a whole number of tokens greater than 0' }, 400)
}
if (amount > MAX_TOKEN_DROP) {
return c.json(
{ error: `A token drop can carry at most ${MAX_TOKEN_DROP.toLocaleString()} tokens each` },
400
)
}
if (typeof body.message !== 'string' || body.message.trim() === '') {
return c.json({ error: 'Enter the message for the gift box' }, 400)
}
const message = giftMessage(body)
if (message === undefined) return c.json({ error: GIFT_MESSAGE_TOO_LONG }, 400)
const links = await getLinksForPlatform(c.env.DB, PlatformType.Discord)
const playerIds = [
...new Set(links.filter((l) => l.roles.includes(roleId)).map((l) => l.accountId)),
]
if (playerIds.length === 0) {
return c.json({ error: 'No linked account holds that Discord role' }, 404)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const paid: number[] = []
for (const playerId of playerIds) {
// A positive amount never comes back null: nothing to skip.
if ((await sendTokens(c, playerId, amount, startingTokens, message)) !== null) {
paid.push(playerId)
}
}
await recordPlayerAudit(c, 'gift_tokens_role', { roleId, amount, message, paid })
logger.info('staff dropped tokens on a discord role', {
moderatorId: staffId(c),
roleId,
amount,
paidCount: paid.length,
})
return c.json({ roleId, amount, message, paid })
}
/**
* Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked
* here (the box, like every box, grants nothing when opened) and spent on any levels it now