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
|
# 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.
|
# 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
|
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
|
||||||
waiting to be collected.
|
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
|
## Bindings
|
||||||
|
|
||||||
| Binding | Type | Notes |
|
| Binding | Type | Notes |
|
||||||
@@ -579,6 +614,7 @@ waiting to be collected.
|
|||||||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
| `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) |
|
| `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.
|
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.
|
* read it through `intVar`, never as a bare number.
|
||||||
*/
|
*/
|
||||||
STARTING_TOKENS?: string | 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 */
|
/** 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,
|
getConsumables,
|
||||||
grantConsumable,
|
grantConsumable,
|
||||||
} from './consumables-db'
|
} from './consumables-db'
|
||||||
|
import { grantDiscordRoleGifts } from './discord-role-gift'
|
||||||
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||||
import {
|
import {
|
||||||
getOwnedCustomAvatarItems,
|
getOwnedCustomAvatarItems,
|
||||||
@@ -177,7 +178,7 @@ import type {
|
|||||||
WeeklyChallengeRotation,
|
WeeklyChallengeRotation,
|
||||||
} from './challenge-rotation'
|
} from './challenge-rotation'
|
||||||
import type { ConsumeResult } from './consumables-db'
|
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 { Equipment } from './equipment-db'
|
||||||
import type { AvatarItem } from './inventory-db'
|
import type { AvatarItem } from './inventory-db'
|
||||||
import type { RoomConsumable } from './room-consumable-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 { exports } from 'cloudflare:workers'
|
||||||
import { beforeAll, describe, expect, test } from 'vitest'
|
import { beforeAll, describe, expect, test } from 'vitest'
|
||||||
|
|
||||||
import '../../econ.app'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getOwnedInventionIds,
|
getOwnedInventionIds,
|
||||||
|
getPendingGifts,
|
||||||
getProgression,
|
getProgression,
|
||||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||||
OUTFIT_SCHEMA_DDL,
|
OUTFIT_SCHEMA_DDL,
|
||||||
@@ -14,6 +19,7 @@ import {
|
|||||||
RECEIVED_GIFT_SCHEMA_DDL,
|
RECEIVED_GIFT_SCHEMA_DDL,
|
||||||
ROOM_SCHEMA_DDL,
|
ROOM_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
import { PlatformType } from '@repo/domain/src/enums'
|
||||||
|
|
||||||
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
// 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).
|
// is built here too (see the same cross-worker import in econ.app.ts).
|
||||||
@@ -23,6 +29,9 @@ import {
|
|||||||
importCustomAvatarItem,
|
importCustomAvatarItem,
|
||||||
} from '../../../../api/src/custom-avatar-items-db'
|
} from '../../../../api/src/custom-avatar-items-db'
|
||||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-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
|
// 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.
|
// against the enum rather than a copied number is what keeps these frames honest.
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
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.
|
// since rolled over.
|
||||||
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
|
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
|
||||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
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 { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
|
||||||
import {
|
import {
|
||||||
grantCustomAvatarItem,
|
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_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_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of ROOM_KEY_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
|
// Presence (owned by the `match` worker) — a new room currency is pushed to everyone
|
||||||
// standing in the room, which is read from here.
|
// standing in the room, which is read from here.
|
||||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
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')
|
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,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
@@ -55,7 +63,8 @@
|
|||||||
// The signup token grant (STARTING_TOKENS) is deliberately NOT set here. It's injected
|
// 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),
|
// 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
|
// 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": {
|
"vars": {
|
||||||
"ENVIRONMENT": "development", // overridden during deployment
|
"ENVIRONMENT": "development", // overridden during deployment
|
||||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||||
|
|||||||
Reference in New Issue
Block a user