[econ] add button for supporter boosts

This commit is contained in:
Devin Zuczek
2026-10-10 01:05:42 -04:00
parent ef65b505cc
commit e9af56aff4
7 changed files with 180 additions and 8 deletions
+9
View File
@@ -278,6 +278,15 @@ leaving a silent log line on a worker with observability off. Use it after chang
bot token or inviting the bot, or when the role drop needs this morning's roles. The run
is written to `audit_log` as `sync_discord_roles` against the developer who pressed it.
A developer can also pay `econ`'s scheduled supporter gift again, now, with
`POST /api/staff/discord-roles/supporter-gift` (developer role required, empty body) — the
"Bonus reward!" card on the account page's Token drop tab. It runs econ's
`grantDiscordRoleGifts` with www's own bindings: every account whose Discord link holds a
role mapped in `DISCORD_ROLE_TOKENS` is boxed that role's tokens, exactly as the cron does,
and the response is the run's summary — `{ skipped, roles, links, granted, tokens, failed }`.
The gift keeps no ledger, so each press pays everyone again on top of the schedule (the panel
asks first). The run is written to `audit_log` as `run_discord_role_gift`.
## Development
### Run in dev mode
+91 -3
View File
@@ -1606,7 +1606,8 @@ function halloweenActive(): boolean {
}
/** The sprite, 23x25 native pixels, drawn at 3x with hard edges. */
const PUMPKIN_SRC = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
const PUMPKIN_SRC =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABcAAAAZCAYAAADaILXQAAAFD0lEQVR42pWVX2xcxRXGf2fu3Lu7jh0bO3FsnMQ2idP8gZoIGkootZsIGrUlVEVCTVW1qVREUPtSiTckYgQPqEIg1FZU6gtP8EJVAaZpIqE4UtrQOCSIkj+YVgRsp3E2mzisnb27OzOnD+uuE2yJZJ7u6J755jvf+eYc4SbX7qHBbNyjpe5VS5k4Nw3VZj4evcrFiWn+9fZxuTbW3Cw43WcxBMBCNYtYRYwiujDU3ijmwK++rWv7G7BSpLdrGf8Zv8TY+54kCuQ/v4IsckZuBPg7Twzq2ntq1IwXjvzlCz4cPv6VZxcN2LttTfXh9phKLPxtMmVnW7BqAiPnU2aipTzUFnjzonMiFhFn9747vihO9P+PoW29Dz7Yt3LD/sdl3dRl/9NmLRu1GJPzpqfFk2SV6ZIhGwlrVoBP1MQO0xQrQ/1N76/qXL7u4KeXxxbV3Jjwuveu1USGH98XOH44EIopu+7PECJBXGBmVpFKhr71JfqAD0cTVMGY5G1MukAJC/DM9l597IEmVsRTAJz4u2XrG+cxQGnPHuyOP6L797AxOYh1KdGmnYAhHPsrrjrLlq05dNTCtm71Jd82dGTiUh3cKDV7ac2ZAUP60moEB6ffQvfvQeucQt3Bd+3eAZlb+OCV1+n/Zpl3hpsAP8/86QdW6VM/tEjlCmWf8sizGcaiHB9thYQZgg+sfngYMPxuR46VpkS/exMRRQWE5bWL1fD9LsuhcV/Yu707febdz3I29gapBMg0467maG5pI3JlitaSAZaEIk1tHbXqNyxBowTT4IjK0zW72Qoyl7GmJdTPu8SC4dg/Krw6GrhcbeUDrYIY7v+th4uTnHxuaT3NR157j0CVvoN3cvo303PyBPRLjz3MSWiDwMvvrQACEpRg5v6oQVs3sfHFgEpg7NzxufRhbPIE0ZNw+NG1xDbHvnxA/+mR+kVz4Af9p/G31vjq5NkGyppjszpOYFFVQBERfmkNfvVmHIqo4e5cBBiy4RIVyhiRmhnUAG6+cY2M4FzSMNyQc9XWlgoiwi0mkAhkgO4AagUTZVkeWZZloSnyeHzNTdfIMeWgHMmBGbtkX12o5w+ceWj5ra7Y1j4LQK8qwZfRNGVbDgKWINCei+kVT1+Yt6XB4VGEwNEZoZKaXS8cOPWjBV1RCKAWpMLPlrYg1at4CWzf/DkXTt7K16qeFhNxVCI6br/Avrwl+Dw/6Grl5zMrWNbXQFquwpGJhf1cTJYnt16ou8BVpxm8q7bvvCPPW50T/L6rQMcd+TmPQ5rt4RezLdz7aCNrtwi5ZlMYeGKgdF0v2NR/W8FFcatUS/ykI8POzhasVf787/N8rzNi31TKRyu7KSyJicXw30/ykFiCEZbd1k6aFvn6fRmSOEsZ5ZPDbl4Wn2QxCmQa0VCiWrlMKjE+00g5MlQyEaXY4GyEw+EaDZFP8FJG1WGTLGhMpeJAPS5yNeYb7t543ZA6fexUPaP139ioEULcnqGtu5mKKOu3eFDDn84eihiiXt7BXw/o4HdXYnIlxicK12uuaHotMMCZ0VMSVClPpRTOzlL4rMjYUaGnq5nHewb8Y68O1ImpKucu5BmfrNXkpgb0lUK+oz3X1IRa1MiCoSwnTay2dEmI+fio1jRXleFIwYupLgZqggwDJMXkKhtwEEhLji+uKEhdFUZGRtztu3YeMBI1Znzxxgb0tWtw92BWGyitu8dzZlQxCof+cHhRnP8B5aopRAfi2ZcAAAAASUVORK5CYII='
const PUMPKIN_SCALE = 3
const PUMPKIN_W = 23 * PUMPKIN_SCALE
const PUMPKIN_H = 25 * PUMPKIN_SCALE
@@ -1842,8 +1843,12 @@ function HalloweenOverlay() {
<style>{HALLOWEEN_CSS}</style>
{!off && (
<div className="rf-halloween" aria-hidden="true">
<span className="rf-halloween-web" style={{ left: 0 }}>🕸️</span>
<span className="rf-halloween-web" style={{ right: 0, transform: 'scaleX(-1)' }}>🕸️</span>
<span className="rf-halloween-web" style={{ left: 0 }}>
🕸️
</span>
<span className="rf-halloween-web" style={{ right: 0, transform: 'scaleX(-1)' }}>
🕸️
</span>
<LilPumpkin />
{HALLOWEEN_SPRITES.map((emoji, i) => (
<span
@@ -3728,6 +3733,7 @@ function Dashboard({
<>
<TokenDropForm />
<RoleTokenDropForm />
<SupporterBonusCard />
<DiscordRoleSyncCard />
</>
),
@@ -4203,6 +4209,88 @@ function RoleTokenDropForm() {
)
}
/** What one run of the supporter gift did — econ's `RoleGiftSummary`, as the route answers. */
interface RoleGiftSummary {
skipped: boolean
roles: number
links: number
granted: number
tokens: number
failed: number
}
/**
* Developer-only: pay econ's scheduled supporter gift again, right now — a bonus round. Every
* player whose linked Discord holds a role in the server's role→tokens map is boxed that
* role's tokens, exactly as the weekly cron does it; the amounts and the box message are the
* server's, not typed here, which is what tells this apart from the role drop above.
*
* The gift keeps no ledger, so pressing this pays everyone again on top of whatever the
* schedule paid — that is the point, and why the button asks first. A `skipped` run means
* no role is mapped on the server, shown as the error it is; a run that went through is
* summed up in a sentence.
*/
function SupporterBonusCard() {
const [confirming, setConfirming] = useState(false)
const { pending, error, done, run } = useAction()
return (
<section className="card">
<h2>Bonus reward</h2>
<p className="muted">
Run the scheduled supporter reward again now. Everyone whose linked Discord holds a rewarded
role gets their role&apos;s tokens in a gift box, online or not, using the amounts and
message the server is configured with. It pays on top of the scheduled run, every time it is
pressed.
</p>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
{confirming ? (
<>
<button
type="button"
disabled={pending}
onClick={() =>
void run(async () => {
const s = await call<RoleGiftSummary>('/api/staff/discord-roles/supporter-gift', {
authed: true,
method: 'POST',
})
setConfirming(false)
if (s.skipped) {
throw new Error(
'The supporter reward is off: no Discord role is mapped to tokens on the server.'
)
}
const parts = [
`Sent ${s.granted} gift box${s.granted === 1 ? '' : 'es'} for ${s.tokens.toLocaleString()} tokens`,
`${s.roles} rewarded role${s.roles === 1 ? '' : 's'} over ${s.links} linked Discord${s.links === 1 ? '' : 's'}`,
]
if (s.failed > 0) parts.push(`${s.failed} failed — see the worker log`)
return `${parts.join(', ')}.`
})
}
>
{pending ? 'Sending…' : 'Confirm: pay every supporter again'}
</button>
<button
type="button"
className="linkish"
disabled={pending}
onClick={() => setConfirming(false)}
>
Cancel
</button>
</>
) : (
<button type="button" onClick={() => setConfirming(true)}>
Bonus reward!
</button>
)}
</section>
)
}
/** What one run of the Discord role sweep did — www's `SweepSummary`, as the sync answers. */
interface SweepSummary {
skipped: boolean
+24
View File
@@ -55,6 +55,7 @@ import {
toCatalogSkin,
} from '../../econ/src/catalog-db'
import { countConsumable, grantConsumable } from '../../econ/src/consumables-db'
import { grantDiscordRoleGifts } from '../../econ/src/discord-role-gift'
import { getEquipment, grantEquipment } from '../../econ/src/equipment-db'
import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-db'
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
@@ -1132,6 +1133,29 @@ export async function syncDiscordRolesHandler(c: Context<App>) {
return c.json(summary)
}
/**
* Run econ's supporter gift NOW — the weekly cron (econ's discord-role-gift.ts) on a button,
* a bonus round for a developer who wants every supporter paid again today. Same function,
* same map (`DISCORD_ROLE_TOKENS`), same box: every account whose Discord link holds a mapped
* role is boxed its best role's tokens, online or not. The role drop above is the same
* audience with an operator's amount and message; this one pays what the map says and
* says so on the box.
*
* It pays everyone it finds, every time — the gift keeps no ledger, and that module is
* explicit that a hand run is an extra gift on purpose. So the panel asks before sending,
* and the run is on the audit log with how many boxes and tokens went out.
*
* Always a 200 with the run's summary, as the sweep's sync is: a `skipped` run (no role
* mapped) is the gift reporting on its configuration, and the panel shows the reason.
*/
export async function runDiscordRoleGiftHandler(c: Context<App>) {
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const summary = await grantDiscordRoleGifts(c.env, startingTokens)
await recordPlayerAudit(c, 'run_discord_role_gift', { ...summary })
logger.info('staff ran the discord supporter gift', { moderatorId: staffId(c), ...summary })
return c.json(summary)
}
/**
* 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
+44 -4
View File
@@ -877,7 +877,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
'/api/staff/rooms/1/gift-tokens',
'/api/staff/online/gift-tokens',
'/api/staff/discord-roles/1/gift-tokens',
'/api/staff/discord-roles/sync'
'/api/staff/discord-roles/sync',
'/api/staff/discord-roles/supporter-gift'
)
for (const path of writes) {
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
@@ -888,10 +889,14 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
expect(res.status).toBe(403)
}
// The gifts, the Plus grant and the role sweep are narrower: a moderator is staff, but
// not a developer.
// The gifts, the Plus grant, the role sweep and the supporter gift are narrower: a
// moderator is staff, but not a developer.
for (const path of writes.filter(
(p) => p.includes('/gift-') || p.includes('/grant-plus') || p.endsWith('/sync')
(p) =>
p.includes('/gift-') ||
p.includes('/grant-plus') ||
p.endsWith('/sync') ||
p.endsWith('/supporter-gift')
)) {
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
}
@@ -2661,6 +2666,41 @@ it('runs the sweep on demand for a developer and answers with the summary', asyn
expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true })
})
// The supporter gift on a button: econ's cron run (`grantDiscordRoleGifts`, covered in econ's
// own tests) with www's bindings, answered with its summary and written to the audit log.
// The test bindings map no role, so the run is a `skipped` one and pays nothing — which is
// also what the panel must be able to tell the developer.
it('runs the supporter gift on demand for a developer and answers with the summary', async () => {
await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
const before = await getBalance(
env.DB,
9100,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const res = await devPost('/api/staff/discord-roles/supporter-gift', 8110, {})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
skipped: true,
roles: 0,
links: 0,
granted: 0,
tokens: 0,
failed: 0,
})
expect(
await getBalance(env.DB, 9100, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(before)
const row = await env.DB.prepare(
`SELECT player_id, data FROM audit_log WHERE action = 'run_discord_role_gift'
ORDER BY audit_log_id DESC LIMIT 1`
).first<{ player_id: number; data: string }>()
expect(row?.player_id).toBe(8110)
expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true, granted: 0 })
})
it('re-reads every discord link and writes the roles back', async () => {
await onlyDiscordLinks([
[9111, '900000000000000111', [ROLE_A]],
+3
View File
@@ -57,6 +57,7 @@ import {
playerHistoryHandler,
requireDeveloper,
requireStaff,
runDiscordRoleGiftHandler,
searchReportsHandler,
syncDiscordRolesHandler,
topReportedHandler,
@@ -576,6 +577,8 @@ const app = new Hono<App>()
// The daily role sweep, run now and answered with its summary (halt reason included) —
// the cron reports only to a log line, and this is the way to see why it wrote nothing.
.post('/api/staff/discord-roles/sync', requireDeveloper, syncDiscordRolesHandler)
// Econ's weekly supporter gift, paid now: a bonus round for everyone holding a mapped role.
.post('/api/staff/discord-roles/supporter-gift', requireDeveloper, runDiscordRoleGiftHandler)
// Rec Room Plus is worth tokens and a discount, so granting it is developer-only too.
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)