mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] auto reward on join
This commit is contained in:
+3
-1
@@ -223,5 +223,7 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# 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.
|
||||
# day they claimed). The website also pays the same box ONCE, on the spot, the first time an
|
||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||
|
||||
@@ -600,6 +600,11 @@ role that role's RecCenterTokens in a gift box, every time it fires. The map is
|
||||
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.
|
||||
- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift`
|
||||
— one account, the roles Discord just served, the same map and box — the first time an
|
||||
account links its Discord, so a new supporter gets one box now rather than after the next
|
||||
run. `www` decides "first" from the link table; this module keeps no ledger either way, and
|
||||
the cron pays that account again on its next run like everyone else.
|
||||
|
||||
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
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
* never claimed on the website has no Discord link, and so holds no roles here, however
|
||||
* many they hold in the guild.
|
||||
*
|
||||
* The same box is ALSO handed once, on demand, the first time an account links its Discord
|
||||
* (`www`'s benefits claim calls `grantDiscordRoleGift` with the roles it just read), so a
|
||||
* supporter who claims the day after a run isn't waiting a whole schedule for their first
|
||||
* gift. That is the one grant here with a "first time" to it, and `www` decides it — this
|
||||
* module keeps no ledger either way.
|
||||
*
|
||||
* 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
|
||||
@@ -39,7 +45,6 @@ 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,
|
||||
@@ -55,6 +60,13 @@ const HUB_INSTANCE = 'global'
|
||||
/** The message on the box. */
|
||||
export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!'
|
||||
|
||||
/**
|
||||
* What a grant needs off the bindings: the database, the hub, and the role map. Named as a
|
||||
* Pick so `www` — whose `Env` declares the same three — can hand its own bindings to
|
||||
* `grantDiscordRoleGift` without pretending to be econ.
|
||||
*/
|
||||
export type RoleGiftEnv = Pick<Env, 'DB' | 'RECFLARE_NOTIFICATIONS_HUB' | 'DISCORD_ROLE_TOKENS'>
|
||||
|
||||
/** One role's gift, as configured. */
|
||||
export interface RoleTokens {
|
||||
/** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */
|
||||
@@ -95,13 +107,32 @@ export function parseRoleTokens(raw: string | undefined): {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The role map off the bindings, with every rejected entry logged — once per run for the
|
||||
* cron, once per claim for the on-demand grant, which is seldom enough to be a reminder
|
||||
* rather than noise. Empty when the gift is off.
|
||||
*/
|
||||
export function bestRoleGift(link: PlatformLink, roles: readonly RoleTokens[]): RoleTokens | null {
|
||||
function configuredRoles(env: RoleGiftEnv): RoleTokens[] {
|
||||
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>`
|
||||
)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
/**
|
||||
* The gift a set of held roles earns: of the mapped roles among them, the one paying the
|
||||
* most — or null when none is mapped. Ties go to whichever is mapped first; they pay the
|
||||
* same either way.
|
||||
*/
|
||||
export function bestRoleGift(
|
||||
held: readonly string[],
|
||||
roles: readonly RoleTokens[]
|
||||
): RoleTokens | null {
|
||||
let best: RoleTokens | null = null
|
||||
for (const role of roles) {
|
||||
if (!link.roles.includes(role.roleId)) continue
|
||||
if (!held.includes(role.roleId)) continue
|
||||
if (best === null || role.tokens > best.tokens) best = role
|
||||
}
|
||||
return best
|
||||
@@ -174,12 +205,7 @@ export async function grantDiscordRoleGifts(
|
||||
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>`
|
||||
)
|
||||
}
|
||||
const roles = configuredRoles(env)
|
||||
if (roles.length === 0) {
|
||||
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
|
||||
return { ...summary, skipped: true }
|
||||
@@ -190,10 +216,10 @@ export async function grantDiscordRoleGifts(
|
||||
summary.links = links.length
|
||||
|
||||
for (const link of links) {
|
||||
const role = bestRoleGift(link, roles)
|
||||
const role = bestRoleGift(link.roles, roles)
|
||||
if (role === null) continue
|
||||
try {
|
||||
await grantOne(env, link, role, startingTokens)
|
||||
await grantOne(env, link.accountId, role, startingTokens)
|
||||
summary.granted++
|
||||
summary.tokens += role.tokens
|
||||
} catch (err) {
|
||||
@@ -212,38 +238,61 @@ export async function grantDiscordRoleGifts(
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-demand grant: pay ONE account the gift its roles earn right now, exactly as a cron
|
||||
* run would — same map, same best-role pick, same box. Returns the role paid, or null when
|
||||
* none of `held` is mapped (which includes the map being off), in which case nothing is
|
||||
* written. Throws as `grantOne` does, so the caller decides what a half-landed grant means.
|
||||
*
|
||||
* `www`'s benefits claim calls this with the roles Discord just served, the first time an
|
||||
* account links its Discord; whether it IS the first time is the caller's call, since this
|
||||
* keeps no ledger. It reads the roles it is handed rather than the stored link so the
|
||||
* caller can pay before, after or without writing the row.
|
||||
*/
|
||||
export async function grantDiscordRoleGift(
|
||||
env: RoleGiftEnv,
|
||||
accountId: number,
|
||||
held: readonly string[],
|
||||
startingTokens: number
|
||||
): Promise<RoleTokens | null> {
|
||||
const role = bestRoleGift(held, configuredRoles(env))
|
||||
if (role === null) return null
|
||||
await grantOne(env, accountId, role, startingTokens)
|
||||
return role
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
env: RoleGiftEnv,
|
||||
accountId: number,
|
||||
role: RoleTokens,
|
||||
startingTokens: number
|
||||
): Promise<void> {
|
||||
await ensureStartingBalances(env.DB, link.accountId, startingTokens)
|
||||
await ensureStartingBalances(env.DB, accountId, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
env.DB,
|
||||
link.accountId,
|
||||
accountId,
|
||||
CurrencyType.RecCenterTokens,
|
||||
role.tokens,
|
||||
startingTokens
|
||||
)
|
||||
const content = roleGiftContent(role.tokens)
|
||||
const gift = await createGift(env.DB, link.accountId, content)
|
||||
const gift = await createGift(env.DB, 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, {
|
||||
await hub.notifyPlayer(accountId, NotificationType.StorefrontBalanceUpdate, {
|
||||
Balance: balance,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload)
|
||||
await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, {
|
||||
await hub.notifyPlayer(accountId, NotificationType.GiftPackageReceivedImmediate, {
|
||||
Id: gift.id,
|
||||
FromPlayerId: COACH_ACCOUNT_ID,
|
||||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||||
@@ -263,7 +312,7 @@ async function grantOne(
|
||||
} 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)}`
|
||||
`discord role gift: could not notify account ${accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-ro
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import {
|
||||
DISCORD_ROLE_GIFT_MESSAGE,
|
||||
grantDiscordRoleGift,
|
||||
grantDiscordRoleGifts,
|
||||
parseRoleTokens,
|
||||
} from '../../discord-role-gift'
|
||||
@@ -6797,6 +6798,30 @@ describe('discord role gift', () => {
|
||||
expect(frames.map((f) => f.accountId)).toEqual([9311, 9311, 9312, 9312, 9313, 9313])
|
||||
})
|
||||
|
||||
// The same grant, paid to ONE account on demand: what `www`'s benefits claim calls on an
|
||||
// account's first Discord link, with the roles Discord just served rather than a stored
|
||||
// link. Same map, same best-role pick, same box; null — and no writes — when nothing maps.
|
||||
test('pays one account its role’s gift on demand', async () => {
|
||||
await onlyDiscordLinks([])
|
||||
await drainFrames()
|
||||
await expect(grantDiscordRoleGift(giftEnv(undefined), 9331, [ROLE_A], 100)).resolves.toBeNull()
|
||||
await expect(grantDiscordRoleGift(giftEnv(MAP), 9331, [UNMAPPED], 100)).resolves.toBeNull()
|
||||
expect(await getPendingGifts(env.DB, 9331)).toEqual([])
|
||||
expect(await drainFrames()).toEqual([])
|
||||
|
||||
await expect(
|
||||
grantDiscordRoleGift(giftEnv(MAP), 9331, [ROLE_A, ROLE_B], DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toEqual({ roleId: ROLE_B, tokens: 10000 })
|
||||
expect(await tokens(9331)).toBe(DEFAULT_STARTING_TOKENS + 10000)
|
||||
expect(await getPendingGifts(env.DB, 9331)).toMatchObject([
|
||||
{ Currency: 10000, AvatarItemType: null, Message: DISCORD_ROLE_GIFT_MESSAGE },
|
||||
])
|
||||
expect((await drainFrames()).map((f) => [f.accountId, f.notificationType])).toEqual([
|
||||
[9331, NotificationType.StorefrontBalanceUpdate],
|
||||
[9331, NotificationType.GiftPackageReceivedImmediate],
|
||||
])
|
||||
})
|
||||
|
||||
test('every run pays again — the schedule is the cadence — and a lapsed role is not paid', async () => {
|
||||
await onlyDiscordLinks([
|
||||
[9321, '900000000000000321', [ROLE_A]],
|
||||
|
||||
@@ -134,6 +134,18 @@ IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to
|
||||
reload. Nothing revokes Plus: losing the role later leaves the flag set, so it
|
||||
records "held the role once", not "holds it today".
|
||||
|
||||
**The first link pays the supporter gift once.** `econ`'s cron hands every account holding
|
||||
a role mapped in `DISCORD_ROLE_TOKENS` (`RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`,
|
||||
see the `econ` README) that role's tokens in a gift box each time it fires. The claim pays
|
||||
the same box, through the same `grantDiscordRoleGift`, the first time an account links a
|
||||
Discord — so a new supporter isn't waiting a whole schedule for their first one. "First"
|
||||
is decided from the link table before the write: a re-claim, and a claim that puts a second
|
||||
Discord identity on an account that already has one, both pay nothing. The response's
|
||||
`tokensAwarded` says what was paid (null when nothing was), and the claim page mentions the
|
||||
box. The grant runs after Plus is granted and the link written, and a failure in it is
|
||||
logged rather than failing the claim. Unset, the var awards nothing and the claim is as
|
||||
before.
|
||||
|
||||
The link also records the member's **roles** (`platform_account.role`, a JSON array of
|
||||
role ids). The claim writes them from the member record it read; on their own they'd be
|
||||
a snapshot from claim time, because the claim revokes the player's token the moment it's
|
||||
|
||||
@@ -493,9 +493,20 @@ interface BenefitsStatus {
|
||||
const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
|
||||
call<BenefitsStatus>('/api/benefits/status', { authed: true })
|
||||
|
||||
/** What a successful claim answers. */
|
||||
interface ClaimResult {
|
||||
discordUsername?: string
|
||||
/**
|
||||
* The supporter gift paid on this claim — the tokens the server hands a linked role once,
|
||||
* the FIRST time an account links its Discord. Null on a re-claim, when no role is mapped
|
||||
* to a gift, or when the server doesn't run one.
|
||||
*/
|
||||
tokensAwarded?: number | null
|
||||
}
|
||||
|
||||
/** Redeem the code Discord sent us back with. The access token never reaches this page. */
|
||||
const claimBenefits = (code: string): Promise<{ discordUsername?: string }> =>
|
||||
call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true })
|
||||
const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
|
||||
|
||||
/**
|
||||
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
||||
@@ -695,11 +706,15 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site
|
||||
claimBenefits(code)
|
||||
.then((result) => {
|
||||
setStatus({ hasPlus: true, linked: true })
|
||||
setDone(
|
||||
result.discordUsername
|
||||
const verified = result.discordUsername
|
||||
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
|
||||
: 'Verified — Rec Room Plus is now on your account.'
|
||||
)
|
||||
// The first-link gift, when one was paid: it's sitting in a box in the game, and
|
||||
// the player would otherwise only find it by opening their gifts.
|
||||
const gift = result.tokensAwarded
|
||||
? ` A welcome gift of ${result.tokensAwarded.toLocaleString()} tokens is waiting in your gift boxes.`
|
||||
: ''
|
||||
setDone(verified + gift)
|
||||
setRelogin(true)
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
||||
|
||||
@@ -45,6 +45,16 @@ export type Env = SharedHonoEnv & {
|
||||
* DEFAULT_STARTING_TOKENS, as it does in econ.
|
||||
*/
|
||||
STARTING_TOKENS?: string | number
|
||||
/**
|
||||
* The Discord supporter gift's role map — `<roleId>=<tokens>` pairs — the same knob
|
||||
* `econ`'s cron reads, injected into every worker from RECFLARE_DISCORD_ROLE_TOKENS at
|
||||
* deploy. www reads it for ONE grant: the first time an account links its Discord through
|
||||
* the benefits claim, it is boxed the amount its role would earn from one cron run, so a
|
||||
* new supporter isn't waiting a whole schedule for their first gift (see
|
||||
* `grantDiscordRoleGift` in econ's discord-role-gift.ts, which does the paying). Unset —
|
||||
* or mapping none of the member's roles — awards nothing and the claim proceeds as before.
|
||||
*/
|
||||
DISCORD_ROLE_TOKENS?: string
|
||||
/**
|
||||
* The most RecCenterTokens one staff token gift can carry (see src/staff.ts). A typo guard
|
||||
* rather than a policy, since a credit can't be taken back. Unset means
|
||||
|
||||
@@ -56,6 +56,10 @@ import {
|
||||
} from '../../../../econ/src/balance-db'
|
||||
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
||||
import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db'
|
||||
import {
|
||||
DISCORD_ROLE_GIFT_MESSAGE,
|
||||
grantDiscordRoleGift,
|
||||
} from '../../../../econ/src/discord-role-gift'
|
||||
import { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db'
|
||||
import {
|
||||
grantCustomAvatarItem,
|
||||
@@ -658,6 +662,64 @@ it('stores the discord identity on a platform the login picker will not list', (
|
||||
expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord)
|
||||
})
|
||||
|
||||
// The one-time supporter gift the claim pays on an account's FIRST Discord link: econ's
|
||||
// cron grant, called on demand with the roles Discord just served, against www's own
|
||||
// bindings. Exercised at the grant, since the route's path to it runs through discord.com;
|
||||
// the claim decides "first" from the link table before it writes (see www.app.ts).
|
||||
it('pays the discord role gift once, on demand, from www’s bindings', async () => {
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
const supporter = '1077000000000000001'
|
||||
const booster = '1077000000000000002'
|
||||
const giftEnv = { ...env, DISCORD_ROLE_TOKENS: `${supporter}=2500,${booster}=10000` } as Env
|
||||
|
||||
// No map, or a member holding no mapped role: nothing is paid and nothing is written.
|
||||
await expect(
|
||||
grantDiscordRoleGift({ ...env, DISCORD_ROLE_TOKENS: undefined } as Env, 4010, [supporter], 100)
|
||||
).resolves.toBeNull()
|
||||
await expect(
|
||||
grantDiscordRoleGift(giftEnv, 4010, ['1077000000000000009'], 100)
|
||||
).resolves.toBeNull()
|
||||
expect(await getPendingGifts(env.DB, 4010)).toEqual([])
|
||||
expect(await (await hub().fetch('http://do/all')).json()).toEqual([])
|
||||
|
||||
// The best of the member's mapped roles pays — one box, on top of the signup grant, which
|
||||
// is seeded first so a never-touched balance doesn't start from the gift alone.
|
||||
await expect(
|
||||
grantDiscordRoleGift(giftEnv, 4010, [supporter, booster], DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toEqual({ roleId: booster, tokens: 10000 })
|
||||
await expect(
|
||||
getBalance(env.DB, 4010, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).resolves.toBe(DEFAULT_STARTING_TOKENS + 10000)
|
||||
const gifts = await getPendingGifts(env.DB, 4010)
|
||||
expect(gifts).toHaveLength(1)
|
||||
expect(gifts[0]).toMatchObject({
|
||||
FromPlayerId: 1,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Currency: 10000,
|
||||
AvatarItemType: null,
|
||||
Message: DISCORD_ROLE_GIFT_MESSAGE,
|
||||
})
|
||||
|
||||
// Announced through www's hub binding exactly as econ's cron announces it: the resulting
|
||||
// total into the -2 bucket, then the box.
|
||||
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([
|
||||
[4010, 61],
|
||||
[4010, 31],
|
||||
])
|
||||
expect(frames[0].data).toEqual({
|
||||
Balance: DEFAULT_STARTING_TOKENS + 10000,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: -2,
|
||||
})
|
||||
expect(frames[1].data).toMatchObject({ Id: gifts[0]!.Id, Currency: 10000, BalanceType: -2 })
|
||||
})
|
||||
|
||||
// The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run
|
||||
// against, and a reviewer only sees the rendered page — so the four things they look
|
||||
// for are pinned here. If a section is renamed, re-read the VRC before loosening the
|
||||
|
||||
+54
-3
@@ -5,7 +5,7 @@ import { getAccount, updateAccount } from '@repo/domain/src/accounts-db'
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { getStatSeries } from '@repo/domain/src/stats-db'
|
||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
} from '../../auth/src/platform-db'
|
||||
// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is
|
||||
// econ's cron paid on demand: the first time an account links its Discord, the claim hands
|
||||
// it the box one cron run would, so the amount and the box are econ's and not a copy.
|
||||
import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db'
|
||||
import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift'
|
||||
import { authUnreachable } from './auth-messages'
|
||||
import {
|
||||
AUTHORIZE_URL,
|
||||
@@ -363,6 +368,13 @@ const app = new Hono<App>()
|
||||
* That's deliberate for now — a sweep would need a bot token to enumerate the guild,
|
||||
* which this design specifically avoids — but it does mean the flag records "held the
|
||||
* role once", not "holds it today".
|
||||
*
|
||||
* The FIRST time an account links a Discord, the claim also pays the supporter gift
|
||||
* econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just
|
||||
* read) — once, so a new supporter isn't waiting a whole schedule for their first box.
|
||||
* "First" means the account had no Discord link at all before this claim: a re-claim,
|
||||
* and a claim that swaps in a second Discord identity, both pay nothing. The response's
|
||||
* `tokensAwarded` says what was paid (null when nothing was).
|
||||
*/
|
||||
.post('/api/benefits/claim', async (c) => {
|
||||
const config = await discordConfig(c.env)
|
||||
@@ -419,6 +431,12 @@ const app = new Hono<App>()
|
||||
PlatformType.Discord,
|
||||
membership.userId
|
||||
)
|
||||
// Whether this claim is the account's FIRST Discord link — what the one-time gift below
|
||||
// keys on. Decided before the write, from the table: an account re-claiming with the
|
||||
// same Discord is not first (`alreadyMine`), and neither is one that already carries a
|
||||
// DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when
|
||||
// it could be true, to spare the re-claim a query.
|
||||
let firstLink = false
|
||||
if (!alreadyMine) {
|
||||
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||
c.env.DB,
|
||||
@@ -434,6 +452,8 @@ const app = new Hono<App>()
|
||||
409
|
||||
)
|
||||
}
|
||||
const links = await getLinksForAccount(c.env.DB, accountId)
|
||||
firstLink = !links.some((link) => link.platform === PlatformType.Discord)
|
||||
}
|
||||
|
||||
// Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the
|
||||
@@ -446,7 +466,7 @@ const app = new Hono<App>()
|
||||
// under a role that has since been retired from the config. Roles are the one part of
|
||||
// the row a re-claim REFRESHES, because they're a snapshot of a membership that moves
|
||||
// and this reading is the fresher one.
|
||||
await linkPlatformIdentity(
|
||||
const created = await linkPlatformIdentity(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
PlatformType.Discord,
|
||||
@@ -456,9 +476,40 @@ const app = new Hono<App>()
|
||||
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
||||
logger.info('granted plus from a discord benefits claim', { accountId })
|
||||
|
||||
// The one-time supporter gift: the box econ's cron would hand this member, paid now,
|
||||
// once. Gated on the insert having happened too (`created`), so two claims racing on
|
||||
// one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is
|
||||
// written: a grant that fails partway is logged with what to check, as econ's cron
|
||||
// logs its own, and the claim still answers — the player got what they came for, and
|
||||
// a retry that also failed partway is how a balance gets credited twice.
|
||||
let tokensAwarded: number | null = null
|
||||
if (created && firstLink) {
|
||||
try {
|
||||
const role = await grantDiscordRoleGift(
|
||||
c.env,
|
||||
accountId,
|
||||
membership.roles,
|
||||
intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
)
|
||||
if (role !== null) {
|
||||
tokensAwarded = role.tokens
|
||||
logger.info('awarded the discord role gift on a first link', {
|
||||
accountId,
|
||||
roleId: role.roleId,
|
||||
tokens: role.tokens,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('the discord role gift on a first link failed partway', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The username is echoed for the confirmation line only — it is never stored, and a
|
||||
// Discord member who has since renamed themselves is not a problem to solve here.
|
||||
return c.json({ hasPlus: true, discordUsername: membership.username })
|
||||
return c.json({ hasPlus: true, discordUsername: membership.username, tokensAwarded })
|
||||
})
|
||||
|
||||
// ---- Staff moderation panel ---------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user