mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] add button for supporter boosts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user