[econ] auto award discord role tokens

This commit is contained in:
Devin Zuczek
2026-09-26 11:52:03 -04:00
parent 3037043a00
commit 65365d8fc3
7 changed files with 567 additions and 6 deletions
+36
View File
@@ -570,6 +570,41 @@ is our cap.
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected.
## Discord supporter gift (`src/discord-role-gift.ts`)
A cron (`triggers.crons` in `wrangler.jsonc`) hands every account holding a mapped Discord
role that role's RecCenterTokens in a gift box, every time it fires. The map is the
`DISCORD_ROLE_TOKENS` var — `RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`, see
`.env.example` — as `<roleId>=<tokens>` pairs separated by commas:
```
928457923857943795=2500,2938479238479234=10000
```
- **The schedule is the cadence.** It ships weekly (Monday 05:00 UTC); make it daily by
changing the cron line. There is no ledger and no notion of a period in the code — every
run pays everyone it finds, so running it twice pays twice, and a hand run is an extra
gift on purpose.
- **One box per account per run, the highest amount wins.** An account holding both roles
above gets one box of 10,000, not two boxes or 12,500, so the map reads as tiers however
Discord stacks the roles. A role named twice keeps its last amount; an entry that isn't
`digits=positive integer` is logged and skipped, not fatal. No roles at all — unset, empty,
all rejected — leaves the cron a logged no-op.
- **Roles come from `platform_account.role`**, the snapshot `www`'s benefits claim writes and
its daily role sweep (04:30 UTC, with a bot token) refreshes. This cron never asks Discord
anything, which is why the default fires half an hour after that sweep. A player who has
never claimed on the website has no link and is never paid, whatever they hold in the guild.
- **The grant is econ's usual three moves**: seed the signup grant (`STARTING_TOKENS`), credit
the balance, store a token box from the Coach (context 0, `AvatarItemType` null), then
push `StorefrontBalanceUpdate` with the resulting total and `GiftPackageReceivedImmediate`
for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`.
- A grant that fails partway is logged with the account, role and amount and is not retried —
a retry that also failed partway is how a balance gets credited twice.
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
supporters outgrow that is the point to batch the run.
## Bindings
| Binding | Type | Notes |
@@ -579,6 +614,7 @@ waiting to be collected.
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
| `DISCORD_ROLE_TOKENS` | var | Optional; Discord role → token gift per cron run (above) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
+11
View File
@@ -24,6 +24,17 @@ export type Env = SharedHonoEnv & {
* read it through `intVar`, never as a bare number.
*/
STARTING_TOKENS?: string | number
/**
* The Discord supporter gift (see discord-role-gift.ts): Discord ROLE ids mapped to the
* RecCenterTokens a holder is boxed each time the cron fires, `<roleId>=<tokens>` pairs
* separated by commas, e.g. `928457923857943795=2500,2938479238479234=10000`. Role ids
* are snowflakes — all digits, never a role's name — kept as strings because one
* exceeds 2^53. One box per account per run: a holder of several mapped roles is paid
* the HIGHEST amount. Optional: unset, or parsing to no roles, leaves the cron off.
* Roles are read from `platform_account.role`, the snapshot `www`'s claim and daily
* sweep maintain, so this only ever pays players who have claimed on the website.
*/
DISCORD_ROLE_TOKENS?: string
}
/** Variables can be extended */
+269
View File
@@ -0,0 +1,269 @@
/**
* The Discord supporter gift, behind the cron in wrangler.jsonc.
*
* An operator maps Discord ROLE ids to a token amount — `DISCORD_ROLE_TOKENS`,
* `<roleId>=<tokens>,<roleId>=<tokens>` — and every time the cron fires, every account
* holding one of those roles is handed that many RecCenterTokens in a gift box. ONE box per
* account per run: an account holding several mapped roles is paid the HIGHEST amount among
* them, not the sum, so the map reads as tiers however Discord stacks the roles.
*
* HOW OFTEN is the cron schedule's business, not this module's: `triggers.crons` in
* wrangler.jsonc says weekly, and an operator who wants a daily gift changes that line. There
* is no ledger and no notion of a period here — a run pays everyone it finds, every time, so
* running it twice pays twice. That is deliberate: the schedule IS the policy, and a hand run
* is an extra gift on purpose.
*
* Which roles an account holds is read from `platform_account.role`, the snapshot the
* `www` worker's benefits claim writes and its daily role sweep refreshes
* (apps/www/src/discord-roles.ts). This never asks Discord anything: the roles are as fresh
* as that sweep, which is why the default schedule fires just after it. A player who has
* never claimed on the website has no Discord link, and so holds no roles here, however
* many they hold in the guild.
*
* Each grant is the same three moves econ's other server-handed tokens make (the game
* reward's tickets, the new-activity bonus): seed the signup grant, credit the balance,
* then a box that displays the amount — announced with `StorefrontBalanceUpdate` (the
* RESULTING total; the frame sets the bucket) and `GiftPackageReceivedImmediate`, in that
* order so the box lands on a balance that already includes it. An offline player meets
* the box on their next read of `GET /api/avatar/v2/gifts`.
*
* Logs via `console`: a cron has no request, so the tagged request logger has nothing to
* tag (the same reason `www`'s sweep and `match`'s presence sweep do).
*/
import { createGift } from '@repo/domain'
import { PlatformType } from '@repo/domain/src/enums'
import { getLinksForPlatform } from '../../auth/src/platform-db'
import { NotificationType } from '../../notify/src/notification-types'
import { ALL_PLATFORMS, creditCurrency, CurrencyType, ensureStartingBalances } from './balance-db'
import type { GiftContent } from '@repo/domain'
import type { PlatformLink } from '../../auth/src/platform-db'
import type {
BalanceResponsePayload,
GiftPackagePayload,
} from '../../notify/src/notification-payloads'
import type { Env } from './context'
/** The system "Coach" account — who a box the server hands over is from. */
const COACH_ACCOUNT_ID = 1
/** The notify worker's hub instance every worker addresses (see econ.app.ts). */
const HUB_INSTANCE = 'global'
/** The message on the box. */
export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!'
/** One role's gift, as configured. */
export interface RoleTokens {
/** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */
roleId: string
/** How many RecCenterTokens the box carries. */
tokens: number
}
/**
* Parse `DISCORD_ROLE_TOKENS`: `<roleId>=<tokens>` pairs separated by commas and/or
* whitespace, e.g. `928457923857943795=2500,2938479238479234=10000`.
*
* Tolerant per entry, strict per field: an entry that isn't `digits=positive integer` is
* reported in `rejected` and skipped rather than failing the whole setting, so one typo
* costs one role's gift and not everyone's. A role named twice keeps its LAST amount. An
* empty or unset value parses to no roles, which turns the gift off.
*/
export function parseRoleTokens(raw: string | undefined): {
roles: RoleTokens[]
rejected: string[]
} {
const roles = new Map<string, number>()
const rejected: string[] = []
for (const entry of (raw ?? '').split(/[\s,]+/)) {
if (entry === '') continue
const match = /^(\d+)=(\d+)$/.exec(entry)
const tokens = match === null ? NaN : Number(match[2])
if (match === null || !Number.isSafeInteger(tokens) || tokens <= 0) {
rejected.push(entry)
continue
}
roles.set(match[1] as string, tokens)
}
return {
roles: [...roles].map(([roleId, tokens]) => ({ roleId, tokens })),
rejected,
}
}
/**
* The gift a link earns: of the mapped roles it holds, the one paying the most — or null
* when it holds none. Ties go to whichever is mapped first; they pay the same either way.
*/
export function bestRoleGift(link: PlatformLink, roles: readonly RoleTokens[]): RoleTokens | null {
let best: RoleTokens | null = null
for (const role of roles) {
if (!link.roles.includes(role.roleId)) continue
if (best === null || role.tokens > best.tokens) best = role
}
return best
}
/** The stored box: tokens from the Coach, nothing else in it. */
function roleGiftContent(tokens: number): GiftContent {
return {
FromPlayerId: COACH_ACCOUNT_ID,
// `Default` (0): a token box renders as it is under this context — the same one the
// staff token gift uses — and the client shows "cannot display" for contexts it
// doesn't know how to draw.
GiftContext: 0,
ConsumableItemDesc: '',
ConsumableCount: 0,
AvatarItemDesc: '',
// NULL, never 0: the client routes a box on this field before reading the rest, and 0
// sends it after "avatar item type 0", which fails with "can't find avatar item".
AvatarItemType: null,
CurrencyType: CurrencyType.RecCenterTokens,
Currency: tokens,
Xp: 0,
PackageType: 0,
Message: DISCORD_ROLE_GIFT_MESSAGE,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
GiftRarity: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: null,
}
}
/** What a run did, for the log line and the tests. */
export interface RoleGiftSummary {
/** True when no role is configured and the run did nothing. */
skipped: boolean
/** Configured roles the run paid for. */
roles: number
/** Discord links whose roles were looked at. */
links: number
/** Boxes handed out this run. */
granted: number
/** Tokens handed out this run, across every box. */
tokens: number
/** Grants that failed partway — logged with the account and role to sort out by hand. */
failed: number
}
/**
* One run of the gift: every Discord link holding a mapped role is paid its best role's
* tokens. Sequential: each grant is a handful of D1 writes and two hub calls, and a cron
* invocation has a fixed subrequest budget (50 free, 1000 paid) — a community whose
* supporters outgrow that is the point to batch it, not before.
*
* `startingTokens` is the signup grant to seed before the credit — `creditCurrency` upserts
* the balance row, so a never-touched balance would otherwise start from this gift instead
* of the grant plus this gift.
*/
export async function grantDiscordRoleGifts(
env: Env,
startingTokens: number
): Promise<RoleGiftSummary> {
const summary: RoleGiftSummary = {
skipped: false,
roles: 0,
links: 0,
granted: 0,
tokens: 0,
failed: 0,
}
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
for (const entry of rejected) {
console.error(
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
)
}
if (roles.length === 0) {
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
return { ...summary, skipped: true }
}
summary.roles = roles.length
const links = await getLinksForPlatform(env.DB, PlatformType.Discord)
summary.links = links.length
for (const link of links) {
const role = bestRoleGift(link, roles)
if (role === null) continue
try {
await grantOne(env, link, role, startingTokens)
summary.granted++
summary.tokens += role.tokens
} catch (err) {
// Logged with everything needed to check what did land rather than retried: a
// retry that ALSO failed partway is how a balance gets credited twice.
summary.failed++
console.error(
`discord role gift: account ${link.accountId}'s grant for role ${role.roleId} (${role.tokens} tokens) failed: ${err instanceof Error ? err.message : String(err)}`
)
}
}
console.log(
`discord role gift: ${summary.roles} role(s) over ${summary.links} discord link(s): ${summary.granted} box(es) for ${summary.tokens} tokens, ${summary.failed} failed`
)
return summary
}
/**
* Pay one account its role's gift: balance, box, then the two frames. The frames are
* best-effort — the tokens are credited and the box stored before either is sent, and an
* offline player meets both on their next login.
*/
async function grantOne(
env: Env,
link: PlatformLink,
role: RoleTokens,
startingTokens: number
): Promise<void> {
await ensureStartingBalances(env.DB, link.accountId, startingTokens)
const balance = await creditCurrency(
env.DB,
link.accountId,
CurrencyType.RecCenterTokens,
role.tokens,
startingTokens
)
const content = roleGiftContent(role.tokens)
const gift = await createGift(env.DB, link.accountId, content)
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
try {
// The balance first, so the box's announcement lands on a total that includes it.
// `Balance` is the RESULTING total, into the one -2 bucket: the frame SETS it.
await hub.notifyPlayer(link.accountId, NotificationType.StorefrontBalanceUpdate, {
Balance: balance,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload)
await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, {
Id: gift.id,
FromPlayerId: COACH_ACCOUNT_ID,
ConsumableItemDesc: content.ConsumableItemDesc,
AvatarItemType: content.AvatarItemType,
AvatarItemDesc: content.AvatarItemDesc,
EquipmentPrefabName: content.EquipmentPrefabName,
EquipmentModificationGuid: content.EquipmentModificationGuid,
CurrencyType: content.CurrencyType,
Currency: content.Currency,
Xp: content.Xp,
GiftContext: content.GiftContext ?? 0,
GiftRarity: content.GiftRarity,
Message: content.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload)
} catch (err) {
console.error(
`discord role gift: could not notify account ${link.accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
+15 -2
View File
@@ -67,6 +67,7 @@ import {
getConsumables,
grantConsumable,
} from './consumables-db'
import { grantDiscordRoleGifts } from './discord-role-gift'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import {
getOwnedCustomAvatarItems,
@@ -177,7 +178,7 @@ import type {
WeeklyChallengeRotation,
} from './challenge-rotation'
import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { App, Env } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db'
@@ -5905,4 +5906,16 @@ app.get(
)
)
export default app
export { app }
// The cron (wrangler.jsonc `triggers`, which sets how often): box every Discord supporter
// the tokens their role is mapped to in DISCORD_ROLE_TOKENS. A no-op, logged once, until the
// operator maps a role — see discord-role-gift.ts. The signup grant is seeded before the credit,
// as every other faucet here does, so it reads the same knob.
export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env, ctx) => {
ctx.waitUntil(grantDiscordRoleGifts(env, intVar(env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)))
}
// A Worker only runs `scheduled` when it's on the default export, so econ takes the object
// form the runtime requires (as `www` does for its role sweep and `match` for presence).
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>
+214 -3
View File
@@ -1,11 +1,16 @@
import { adminSecretsStore, env } from 'cloudflare:test'
import {
adminSecretsStore,
createExecutionContext,
createScheduledController,
env,
waitOnExecutionContext,
} from 'cloudflare:test'
import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import '../../econ.app'
import {
getOwnedInventionIds,
getPendingGifts,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL,
@@ -14,6 +19,7 @@ import {
RECEIVED_GIFT_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
} from '@repo/domain'
import { PlatformType } from '@repo/domain/src/enums'
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
@@ -23,6 +29,9 @@ import {
importCustomAvatarItem,
} from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The Discord link table (owned by `auth`, kept current by `www`) is what the weekly
// supporter gift reads its roles from.
import { linkPlatformIdentity, PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
@@ -80,6 +89,12 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch
// since rolled over.
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import {
DISCORD_ROLE_GIFT_MESSAGE,
grantDiscordRoleGifts,
parseRoleTokens,
} from '../../discord-role-gift'
import { scheduled } from '../../econ.app'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import {
grantCustomAvatarItem,
@@ -198,6 +213,9 @@ beforeAll(async () => {
for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// The platform link table (owned by `auth`) — the Discord supporter gift reads the roles
// `www` records on each Discord link.
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence (owned by the `match` worker) — a new room currency is pushed to everyone
// standing in the room, which is read from here.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -6605,3 +6623,196 @@ describe('plus token reload', () => {
expect(() => plusReloadSql(100, -1)).toThrow('non-negative integer')
})
})
// ---- Discord supporter gift ----------------------------------------------------------------
// The cron: every account whose Discord link holds a role mapped in DISCORD_ROLE_TOKENS is
// boxed its best role's tokens, every time it fires. Driven through `grantDiscordRoleGifts`
// with the var set on a copy of the bindings.
describe('discord role gift', () => {
const ROLE_A = '1077000000000000001'
const ROLE_B = '1077000000000000002'
const UNMAPPED = '1077000000000000009'
const MAP = `${ROLE_A}=2500,${ROLE_B}=10000`
const giftEnv = (map: string | undefined): Env => ({ ...env, DISCORD_ROLE_TOKENS: map }) as Env
const drainFrames = async (): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
>
}
).drainFrames()
/**
* The run reads EVERY Discord link, and the table is shared across this file, so each
* test starts from only its own rows.
*/
const onlyDiscordLinks = async (
links: Array<[accountId: number, userId: string, roles: string[]]>
) => {
await env.DB.prepare('DELETE FROM platform_account WHERE platform = ?1')
.bind(PlatformType.Discord)
.run()
for (const [accountId, userId, roles] of links) {
await linkPlatformIdentity(env.DB, accountId, PlatformType.Discord, userId, roles)
}
}
const tokens = (accountId: number) =>
getBalance(env.DB, accountId, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
test('parses the role map, keeping the last amount for a repeated role and skipping bad entries', () => {
expect(parseRoleTokens(MAP)).toEqual({
roles: [
{ roleId: ROLE_A, tokens: 2500 },
{ roleId: ROLE_B, tokens: 10000 },
],
rejected: [],
})
// Whitespace and commas both separate; a role named twice keeps its LAST amount.
expect(parseRoleTokens(` ${ROLE_A}=1, ${ROLE_B}=2\n${ROLE_A}=3 `).roles).toEqual([
{ roleId: ROLE_A, tokens: 3 },
{ roleId: ROLE_B, tokens: 2 },
])
// A role's NAME, a zero, a negative, a fraction and a bare id are each one rejected
// entry, and the good ones beside them still parse.
expect(
parseRoleTokens(`supporter=100,${ROLE_A}=0,${ROLE_B}=-5,1=1.5,${UNMAPPED},${ROLE_A}=250`)
).toEqual({
roles: [{ roleId: ROLE_A, tokens: 250 }],
rejected: ['supporter=100', `${ROLE_A}=0`, `${ROLE_B}=-5`, '1=1.5', UNMAPPED],
})
expect(parseRoleTokens(undefined)).toEqual({ roles: [], rejected: [] })
expect(parseRoleTokens('')).toEqual({ roles: [], rejected: [] })
})
test('does nothing until a role is mapped', async () => {
await onlyDiscordLinks([[9301, '900000000000000301', [ROLE_A]]])
await drainFrames()
await expect(
grantDiscordRoleGifts(giftEnv(undefined), DEFAULT_STARTING_TOKENS)
).resolves.toMatchObject({ skipped: true, granted: 0 })
// A map with nothing valid in it is off too, not a run that pays nobody.
await expect(
grantDiscordRoleGifts(giftEnv('supporter=100'), DEFAULT_STARTING_TOKENS)
).resolves.toMatchObject({ skipped: true, granted: 0 })
expect(await tokens(9301)).toBe(DEFAULT_STARTING_TOKENS)
expect(await getPendingGifts(env.DB, 9301)).toEqual([])
expect(await drainFrames()).toEqual([])
})
// The deployed test config maps no role, so the real handler takes the "off" path: pinned
// so a deploy without the var is a logged no-op and not a scheduled error.
test('runs from the scheduled handler and stays off without the var', async () => {
await onlyDiscordLinks([[9301, '900000000000000301', [ROLE_A]]])
const ctx = createExecutionContext()
await scheduled(createScheduledController(), env, ctx)
await waitOnExecutionContext(ctx)
expect(await tokens(9301)).toBe(DEFAULT_STARTING_TOKENS)
expect(await getPendingGifts(env.DB, 9301)).toEqual([])
})
test('boxes each holder of a mapped role its best role’s tokens, one box per account', async () => {
await onlyDiscordLinks([
[9311, '900000000000000311', [ROLE_A]],
[9312, '900000000000000312', [ROLE_B, UNMAPPED]],
[9313, '900000000000000313', [ROLE_A, ROLE_B]],
[9314, '900000000000000314', [UNMAPPED]],
[9315, '900000000000000315', []],
])
// A Steam link on a paid account is not a Discord link and carries no roles.
await linkPlatformIdentity(env.DB, 9311, PlatformType.Steam, '76561190000009311')
await drainFrames()
const summary = await grantDiscordRoleGifts(giftEnv(MAP), DEFAULT_STARTING_TOKENS)
expect(summary).toEqual({
skipped: false,
roles: 2,
links: 5,
granted: 3,
tokens: 2500 + 10000 + 10000,
failed: 0,
})
// The balance is the signup grant PLUS the gift: the grant is seeded first, so a
// never-touched balance doesn't start from the gift alone. Two mapped roles pay the
// HIGHER one, not both.
expect(await tokens(9311)).toBe(DEFAULT_STARTING_TOKENS + 2500)
expect(await tokens(9312)).toBe(DEFAULT_STARTING_TOKENS + 10000)
expect(await tokens(9313)).toBe(DEFAULT_STARTING_TOKENS + 10000)
expect(await tokens(9314)).toBe(DEFAULT_STARTING_TOKENS)
expect(await tokens(9315)).toBe(DEFAULT_STARTING_TOKENS)
// One box: from the Coach, tokens and nothing else, `AvatarItemType` NULL so the
// client doesn't go looking for an avatar item.
const boxes = await getPendingGifts(env.DB, 9313)
expect(boxes).toHaveLength(1)
expect(boxes[0]).toMatchObject({
FromPlayerId: 1,
GiftContext: 0,
CurrencyType: CurrencyType.RecCenterTokens,
Currency: 10000,
AvatarItemDesc: '',
AvatarItemType: null,
ConsumableItemDesc: '',
EquipmentModificationGuid: '',
Xp: 0,
Message: DISCORD_ROLE_GIFT_MESSAGE,
})
expect(await getPendingGifts(env.DB, 9314)).toEqual([])
// Per box: the balance frame first — the RESULTING total into the -2 bucket, keyed
// `Platform` — then the box's announcement carrying the same amount the box does.
const frames = await drainFrames()
expect(frames.filter((f) => f.accountId === 9311)).toEqual([
{
accountId: 9311,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: {
Balance: DEFAULT_STARTING_TOKENS + 2500,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 9311,
notificationType: NotificationType.GiftPackageReceivedImmediate,
payload: expect.objectContaining({
Id: (await getPendingGifts(env.DB, 9311))[0]!.Id,
FromPlayerId: 1,
Currency: 2500,
CurrencyType: CurrencyType.RecCenterTokens,
AvatarItemType: null,
GiftContext: 0,
Message: DISCORD_ROLE_GIFT_MESSAGE,
BalanceType: -2,
}),
},
])
expect(frames.map((f) => f.accountId)).toEqual([9311, 9311, 9312, 9312, 9313, 9313])
})
test('every run pays again — the schedule is the cadence — and a lapsed role is not paid', async () => {
await onlyDiscordLinks([
[9321, '900000000000000321', [ROLE_A]],
[9322, '900000000000000322', [ROLE_A]],
])
await grantDiscordRoleGifts(giftEnv(MAP), DEFAULT_STARTING_TOKENS)
// www's sweep found 9322 no longer holds the role; 9323 claimed in between.
await linkPlatformIdentity(env.DB, 9322, PlatformType.Discord, '900000000000000322', [])
await linkPlatformIdentity(env.DB, 9323, PlatformType.Discord, '900000000000000323', [ROLE_B])
const summary = await grantDiscordRoleGifts(giftEnv(MAP), DEFAULT_STARTING_TOKENS)
expect(summary).toMatchObject({ granted: 2, tokens: 2500 + 10000 })
expect(await tokens(9321)).toBe(DEFAULT_STARTING_TOKENS + 5000)
expect(await tokens(9322)).toBe(DEFAULT_STARTING_TOKENS + 2500)
expect(await tokens(9323)).toBe(DEFAULT_STARTING_TOKENS + 10000)
expect(await getPendingGifts(env.DB, 9321)).toHaveLength(2)
expect(await getPendingGifts(env.DB, 9322)).toHaveLength(1)
})
})
+10 -1
View File
@@ -48,6 +48,14 @@
}
]
},
// The Discord supporter gift (src/discord-role-gift.ts): every account holding a role
// mapped in DISCORD_ROLE_TOKENS is boxed that role's tokens EACH TIME this fires, so this
// line is the cadence — weekly as shipped; `0 5 * * *` makes it daily. Monday 05:00 UTC
// is half an hour after `www`'s daily role sweep (04:30), so it pays against roles
// refreshed that morning. A no-op, logged, until the var maps a role.
"triggers": {
"crons": ["0 5 * * 1"]
},
"upload_source_maps": true,
"observability": {
"enabled": false
@@ -55,7 +63,8 @@
// The signup token grant (STARTING_TOKENS) is deliberately NOT set here. It's injected
// at deploy time from the gitignored .env (RECFLARE_STARTING_TOKENS, see .env.example),
// so tuning it never means editing a versioned file. Unset — the default — falls back
// to DEFAULT_STARTING_TOKENS in src/balance-db.ts.
// to DEFAULT_STARTING_TOKENS in src/balance-db.ts. DISCORD_ROLE_TOKENS (the supporter
// gift's role map) arrives the same way; unset leaves the cron off.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment