[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
+4
View File
@@ -614,6 +614,10 @@ role that role's RecCenterTokens in a gift box, every time it fires. The map is
account links its Discord, so a new supporter gets one box now rather than after the next account links its Discord, so a new supporter gets one box now rather than after the next
run. `www` decides "first" from the link table; this module keeps no ledger either way, and run. `www` decides "first" from the link table; this module keeps no ledger either way, and
the cron pays that account again on its next run like everyone else. the cron pays that account again on its next run like everyone else.
- **A whole run can be paid by hand.** `www`'s staff panel ("Bonus reward!", developer-only,
`POST /api/staff/discord-roles/supporter-gift`) calls `grantDiscordRoleGifts` with its own
bindings — the same map, audience and box as the cron — and answers with the run's summary.
No ledger here either: it pays everyone again on top of the schedule, which is the point.
One D1 read for the links, then a handful of writes and two hub calls per box, sequentially. One D1 read for the links, then a handful of writes and two hub calls per box, sequentially.
A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose
+5 -1
View File
@@ -205,9 +205,13 @@ export interface RoleGiftSummary {
* `startingTokens` is the signup grant to seed before the credit — the credit upserts the * `startingTokens` is the signup grant to seed before the credit — the credit upserts the
* balance row, so a never-touched balance would otherwise start from this gift instead of * balance row, so a never-touched balance would otherwise start from this gift instead of
* the grant plus this gift. * the grant plus this gift.
*
* Takes the {@link RoleGiftEnv} pick, not econ's whole `Env`: `www`'s staff panel runs this
* same function on a button (`POST /api/staff/discord-roles/supporter-gift`) with its own
* bindings, and the three it reads are declared on both workers.
*/ */
export async function grantDiscordRoleGifts( export async function grantDiscordRoleGifts(
env: Env, env: RoleGiftEnv,
startingTokens: number startingTokens: number
): Promise<RoleGiftSummary> { ): Promise<RoleGiftSummary> {
const summary: RoleGiftSummary = { const summary: RoleGiftSummary = {
+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 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. 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 ## Development
### Run in dev mode ### 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. */ /** 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_SCALE = 3
const PUMPKIN_W = 23 * PUMPKIN_SCALE const PUMPKIN_W = 23 * PUMPKIN_SCALE
const PUMPKIN_H = 25 * PUMPKIN_SCALE const PUMPKIN_H = 25 * PUMPKIN_SCALE
@@ -1842,8 +1843,12 @@ function HalloweenOverlay() {
<style>{HALLOWEEN_CSS}</style> <style>{HALLOWEEN_CSS}</style>
{!off && ( {!off && (
<div className="rf-halloween" aria-hidden="true"> <div className="rf-halloween" aria-hidden="true">
<span className="rf-halloween-web" style={{ left: 0 }}>🕸️</span> <span className="rf-halloween-web" style={{ left: 0 }}>
<span className="rf-halloween-web" style={{ right: 0, transform: 'scaleX(-1)' }}>🕸️</span> 🕸️
</span>
<span className="rf-halloween-web" style={{ right: 0, transform: 'scaleX(-1)' }}>
🕸️
</span>
<LilPumpkin /> <LilPumpkin />
{HALLOWEEN_SPRITES.map((emoji, i) => ( {HALLOWEEN_SPRITES.map((emoji, i) => (
<span <span
@@ -3728,6 +3733,7 @@ function Dashboard({
<> <>
<TokenDropForm /> <TokenDropForm />
<RoleTokenDropForm /> <RoleTokenDropForm />
<SupporterBonusCard />
<DiscordRoleSyncCard /> <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. */ /** What one run of the Discord role sweep did — www's `SweepSummary`, as the sync answers. */
interface SweepSummary { interface SweepSummary {
skipped: boolean skipped: boolean
+24
View File
@@ -55,6 +55,7 @@ import {
toCatalogSkin, toCatalogSkin,
} from '../../econ/src/catalog-db' } from '../../econ/src/catalog-db'
import { countConsumable, grantConsumable } from '../../econ/src/consumables-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 { getEquipment, grantEquipment } from '../../econ/src/equipment-db'
import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/inventory-custom-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 // 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) 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 * 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
+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/rooms/1/gift-tokens',
'/api/staff/online/gift-tokens', '/api/staff/online/gift-tokens',
'/api/staff/discord-roles/1/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) { 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)
@@ -888,10 +889,14 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
expect(res.status).toBe(403) expect(res.status).toBe(403)
} }
// The gifts, the Plus grant and the role sweep are narrower: a moderator is staff, but // The gifts, the Plus grant, the role sweep and the supporter gift are narrower: a
// not a developer. // moderator is staff, but not a developer.
for (const path of writes.filter( 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) 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 }) 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 () => { it('re-reads every discord link and writes the roles back', async () => {
await onlyDiscordLinks([ await onlyDiscordLinks([
[9111, '900000000000000111', [ROLE_A]], [9111, '900000000000000111', [ROLE_A]],
+3
View File
@@ -57,6 +57,7 @@ import {
playerHistoryHandler, playerHistoryHandler,
requireDeveloper, requireDeveloper,
requireStaff, requireStaff,
runDiscordRoleGiftHandler,
searchReportsHandler, searchReportsHandler,
syncDiscordRolesHandler, syncDiscordRolesHandler,
topReportedHandler, 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 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. // 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) .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. // 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/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler) .post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)