mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] auto award discord role tokens
This commit is contained in:
@@ -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
|
||||
# `<roleId>=<tokens>` 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user