mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] discord per-role token drop
This commit is contained in:
+104
-1
@@ -3306,7 +3306,19 @@ function Dashboard({
|
|||||||
: []),
|
: []),
|
||||||
// Narrower than the two above: the drop mints tokens, and www's route is developer-only.
|
// Narrower than the two above: the drop mints tokens, and www's route is developer-only.
|
||||||
...(isDeveloper()
|
...(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)
|
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'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's numeric id, from Discord'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.
|
* Change the account's username — the name used to sign in, here and in the game.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
movePlayerToDorm,
|
movePlayerToDorm,
|
||||||
writeAuditLog,
|
writeAuditLog,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { intVar, logger } from '@repo/hono-helpers'
|
import { intVar, logger } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
@@ -31,6 +32,9 @@ import {
|
|||||||
searchReports,
|
searchReports,
|
||||||
} from '../../api/src/reports-db'
|
} from '../../api/src/reports-db'
|
||||||
import { getWarningsAgainst } from '../../api/src/warnings-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.
|
// Balances, owned by `econ`. A staff token gift is the same credit econ's own faucets make.
|
||||||
import {
|
import {
|
||||||
ALL_PLATFORMS,
|
ALL_PLATFORMS,
|
||||||
@@ -955,6 +959,79 @@ export async function giftOnlineTokensHandler(c: Context<App>) {
|
|||||||
return c.json({ amount, message, paid })
|
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
|
* 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
|
* here (the box, like every box, grants nothing when opened) and spent on any levels it now
|
||||||
|
|||||||
@@ -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/username-changes',
|
||||||
'/api/staff/players/1/clear-password',
|
'/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) {
|
for (const path of writes) {
|
||||||
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
|
||||||
const res = await SELF.fetch(`https://example.com${path}`, {
|
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)
|
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.
|
// 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 () => {
|
it('sends an empty box on a zero token gift', async () => {
|
||||||
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
await updateAccount(env.DB, 8361, { username: 'Emptyhanded' })
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
getReportHandler,
|
getReportHandler,
|
||||||
giftItemHandler,
|
giftItemHandler,
|
||||||
giftOnlineTokensHandler,
|
giftOnlineTokensHandler,
|
||||||
|
giftRoleTokensHandler,
|
||||||
giftRoomTokensHandler,
|
giftRoomTokensHandler,
|
||||||
giftTokensHandler,
|
giftTokensHandler,
|
||||||
giftXpHandler,
|
giftXpHandler,
|
||||||
@@ -556,6 +557,9 @@ const app = new Hono<App>()
|
|||||||
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
.post('/api/staff/rooms/:roomId/gift-tokens', requireDeveloper, giftRoomTokensHandler)
|
||||||
// The account page's token drop: the same gift to everyone online at once.
|
// The account page's token drop: the same gift to everyone online at once.
|
||||||
.post('/api/staff/online/gift-tokens', requireDeveloper, giftOnlineTokensHandler)
|
.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/username-changes', addUsernameChangeHandler)
|
||||||
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
.post('/api/staff/players/:id/clear-password', clearPasswordHandler)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user