[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
+104 -1
View File
@@ -3306,7 +3306,19 @@ function Dashboard({
: []),
// Narrower than the two above: the drop mints tokens, and www's route is developer-only.
...(isDeveloper()
? [{ id: 'tokens', label: 'Token drop', render: () => <TokenDropForm /> }]
? [
{
id: 'tokens',
label: 'Token drop',
// Two audiences for one gift: whoever is online now, or whoever holds a Discord role.
render: () => (
<>
<TokenDropForm />
<RoleTokenDropForm />
</>
),
},
]
: []),
]
const [active, setActive] = useState(sections[0].id)
@@ -3682,6 +3694,97 @@ function TokenDropForm() {
)
}
/**
* Developer-only: give every player whose Discord link holds a role the same number of
* tokens, in a gift box carrying the operator's message — the token drop's sibling, with a
* different audience. Offline is the point: the box waits for whoever isn't signed in.
*
* The audience is the roles the website recorded at each player's benefits claim and
* refreshes once a day, so it trails Discord by up to a day and never includes a member who
* hasn't claimed; the copy says so. Same cap as the online drop, repeated on the input.
*/
function RoleTokenDropForm() {
const [roleId, setRoleId] = useState('')
const [message, setMessage] = useState('')
const [tokens, setTokens] = useState('')
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Discord role drop</h2>
<p className="muted">
Give everyone holding a Discord role the same number of tokens, whether or not they are
online — a player who is signed out finds the gift box waiting. Only players who have
claimed benefits on this site are counted, and their roles are refreshed once a day, so a
role given or taken since then isn&apos;t seen yet. Up to {MAX_TOKEN_DROP.toLocaleString()}{' '}
tokens each.
</p>
<form
onSubmit={(e) => {
e.preventDefault()
void run(async () => {
const amount = Number(tokens)
const role = roleId.trim()
const res = await call<{ paid: number[] }>(
`/api/staff/discord-roles/${encodeURIComponent(role)}/gift-tokens`,
{ authed: true, json: { amount, message: message.trim() } }
)
setTokens('')
setMessage('')
const n = res.paid.length
return `Sent ${amount.toLocaleString()} tokens to ${n} player${n === 1 ? '' : 's'} holding role ${role}.`
})
}}
>
<label>
Discord role id
<input
type="text"
inputMode="numeric"
pattern="[0-9]+"
value={roleId}
placeholder="1077000000000000001"
required
onChange={(e) => setRoleId(e.target.value)}
/>
<span className="hint">
The role&apos;s numeric id, from Discord&apos;s role settings (Copy Role ID), not its
name.
</span>
</label>
<label>
Gift box message
<textarea
value={message}
rows={3}
maxLength={256}
placeholder="Thanks for supporting the server!"
onChange={(e) => setMessage(e.target.value)}
required
/>
</label>
<label>
Tokens per player
<input
type="number"
min={1}
max={MAX_TOKEN_DROP}
step={1}
value={tokens}
required
onChange={(e) => setTokens(e.target.value)}
/>
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send to everyone with the role'}
</button>
</form>
</section>
)
}
/**
* Change the account's username — the name used to sign in, here and in the game.
*
+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
+100 -1
View File
@@ -860,7 +860,11 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/players/1/username-changes',
'/api/staff/players/1/clear-password',
]
writes.push('/api/staff/rooms/1/gift-tokens', '/api/staff/online/gift-tokens')
writes.push(
'/api/staff/rooms/1/gift-tokens',
'/api/staff/online/gift-tokens',
'/api/staff/discord-roles/1/gift-tokens'
)
for (const path of writes) {
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
const res = await SELF.fetch(`https://example.com${path}`, {
@@ -1808,6 +1812,101 @@ it('refuses a token drop over the cap, without a message, or with nobody online'
expect((await drop({ amount: 1_000, message: 'hi' })).status).toBe(404)
})
// The role drop's audience is the Discord links that record the role — offline included,
// since that is the point — and nobody else: a link holding other roles, a link holding none,
// a player with no link at all. The same Discord identity on two accounts pays both.
it('drops tokens on every account whose Discord link holds the role, online or not', async () => {
const ROLE = '1077000000000000777'
const OTHER = '1077000000000000778'
await linkPlatformIdentity(env.DB, 8390, PlatformType.Discord, '900000000000008390', [ROLE])
await linkPlatformIdentity(env.DB, 8391, PlatformType.Discord, '900000000000008391', [
OTHER,
ROLE,
])
await linkPlatformIdentity(env.DB, 8392, PlatformType.Discord, '900000000000008392', [OTHER])
await linkPlatformIdentity(env.DB, 8393, PlatformType.Discord, '900000000000008393', [])
// One Discord user, two accounts: both links hold the role.
await linkPlatformIdentity(env.DB, 8394, PlatformType.Discord, '900000000000008394', [ROLE])
await linkPlatformIdentity(env.DB, 8395, PlatformType.Discord, '900000000000008394', [ROLE])
// Only one of them is online; the rest are paid anyway.
await env.DB.prepare('DELETE FROM presence').run()
await setPresence(env.DB, {
accountId: 8390,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 4,
appVersion: 'test',
})
const res = await devPost(`/api/staff/discord-roles/${ROLE}/gift-tokens`, 8110, {
amount: 250,
message: 'Thanks for supporting the server!',
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
roleId: string
amount: number
message: string
paid: number[]
}
expect(body.roleId).toBe(ROLE)
expect(body.amount).toBe(250)
expect(body.message).toBe('Thanks for supporting the server!')
expect([...body.paid].sort((a, b) => a - b)).toEqual([8390, 8391, 8394, 8395])
for (const playerId of [8390, 8391, 8394, 8395]) {
await expect(
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).resolves.toBe(DEFAULT_STARTING_TOKENS + 250)
const gifts = await getPendingGifts(env.DB, playerId)
expect(gifts).toHaveLength(1)
expect(gifts[0]).toMatchObject({
Currency: 250,
CurrencyType: CurrencyType.RecCenterTokens,
AvatarItemType: null,
Message: 'Thanks for supporting the server!',
})
}
for (const playerId of [8392, 8393]) {
expect(await getPendingGifts(env.DB, playerId)).toEqual([])
await expect(
getBalance(env.DB, playerId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).resolves.toBe(DEFAULT_STARTING_TOKENS)
}
const { results } = await env.DB.prepare(
`SELECT player_id, data FROM audit_log WHERE action = 'gift_tokens_role'`
).all<{ player_id: number; data: string }>()
expect(results).toHaveLength(1)
expect(results[0].player_id).toBe(8110)
expect(JSON.parse(results[0].data)).toMatchObject({
roleId: ROLE,
amount: 250,
message: 'Thanks for supporting the server!',
})
})
// Same bounds as the online drop, plus the role has to be a snowflake, and a role nobody's
// link records is a 404 rather than a silent success.
it('refuses a role drop over the cap, without a message, with a bad role, or with no holders', async () => {
const drop = (role: string, body: unknown) =>
devPost(`/api/staff/discord-roles/${role}/gift-tokens`, 8110, body)
await linkPlatformIdentity(env.DB, 8396, PlatformType.Discord, '900000000000008396', [
'1077000000000000779',
])
expect((await drop('1077000000000000779', { amount: 1_001, message: 'hi' })).status).toBe(400)
expect((await drop('1077000000000000779', { amount: 0, message: 'hi' })).status).toBe(400)
expect((await drop('1077000000000000779', { amount: -5, message: 'hi' })).status).toBe(400)
expect((await drop('1077000000000000779', { amount: 10, message: ' ' })).status).toBe(400)
expect((await drop('1077000000000000779', { amount: 10 })).status).toBe(400)
expect((await drop('supporters', { amount: 10, message: 'hi' })).status).toBe(400)
expect((await drop('1077000000000000780', { amount: 10, message: 'hi' })).status).toBe(404)
// Nothing above paid anyone.
expect(await getPendingGifts(env.DB, 8396)).toEqual([])
})
// Zero is a real gift here: nothing moves, and the player gets an empty box.
it('sends an empty box on a zero token gift', async () => {
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
+4
View File
@@ -48,6 +48,7 @@ import {
getReportHandler,
giftItemHandler,
giftOnlineTokensHandler,
giftRoleTokensHandler,
giftRoomTokensHandler,
giftTokensHandler,
giftXpHandler,
@@ -556,6 +557,9 @@ const app = new Hono<App>()
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
// The account page's token drop: the same gift to everyone online at once.
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
// Its sibling: the same gift to every account whose Discord link holds a role, offline
// included — the supporter cron's audience, with an operator's amount and message.
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)