[www] auto reward on join

This commit is contained in:
Devin Zuczek
2026-09-27 21:04:59 -04:00
parent b32b16c1f0
commit 3324a935b4
9 changed files with 263 additions and 32 deletions
+22 -7
View File
@@ -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
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
: 'Verified — Rec Room Plus is now on your account.'
)
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)))
+10
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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 ---------------------------------------------