diff --git a/.env.example b/.env.example index 27104b6..fc01e2a 100644 --- a/.env.example +++ b/.env.example @@ -213,3 +213,15 @@ RECFLARE_DOMAIN=rec.example.com # # The binding has to resolve for `www` to deploy, so create it with a placeholder even if # you don't want the sweep — it then does nothing. It never touches hasPlus. + +# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the +# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as +# `=` pairs. How often is the schedule's call: `triggers.crons` in +# apps/econ/wrangler.jsonc ships weekly (Monday 05:00 UTC); change that line for daily. ONE +# box per member per run — a member holding several mapped roles is paid the HIGHEST amount +# among them, not the sum, so the map reads as tiers. Same rules as ROLE_IDS above: +# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits +# claim and the daily sweep above keep, so it reaches only players who have claimed on the +# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the +# day they claimed). Unset — or parsing to no roles — leaves the cron a logged no-op. +# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000 diff --git a/apps/econ/README.md b/apps/econ/README.md index f25eeb7..742601b 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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 `=` 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. diff --git a/apps/econ/src/context.ts b/apps/econ/src/context.ts index ffbfda6..40c1ccd 100644 --- a/apps/econ/src/context.ts +++ b/apps/econ/src/context.ts @@ -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, `=` 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 */ diff --git a/apps/econ/src/discord-role-gift.ts b/apps/econ/src/discord-role-gift.ts new file mode 100644 index 0000000..1b22428 --- /dev/null +++ b/apps/econ/src/discord-role-gift.ts @@ -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`, + * `=,=` — 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`: `=` 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() + 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 { + 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 =` + ) + } + 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 { + 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)}` + ) + } +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 7ff90c7..46f2b6d 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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 = (_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 diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index cafa190..d7c4928 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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 }> + > => + ( + env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as { + drainFrames(): Promise< + Array<{ accountId: number; notificationType: number; payload: Record }> + > + } + ).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) + }) +}) diff --git a/apps/econ/wrangler.jsonc b/apps/econ/wrangler.jsonc index 1fae82e..a74c7fe 100644 --- a/apps/econ/wrangler.jsonc +++ b/apps/econ/wrangler.jsonc @@ -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