mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -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
|
# 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
|
# 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
|
# 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
|
# 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`.
|
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 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.
|
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.
|
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
|
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
|
* never claimed on the website has no Discord link, and so holds no roles here, however
|
||||||
* many they hold in the guild.
|
* 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
|
* 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,
|
* 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
|
* 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 { ALL_PLATFORMS, creditCurrency, CurrencyType, ensureStartingBalances } from './balance-db'
|
||||||
|
|
||||||
import type { GiftContent } from '@repo/domain'
|
import type { GiftContent } from '@repo/domain'
|
||||||
import type { PlatformLink } from '../../auth/src/platform-db'
|
|
||||||
import type {
|
import type {
|
||||||
BalanceResponsePayload,
|
BalanceResponsePayload,
|
||||||
GiftPackagePayload,
|
GiftPackagePayload,
|
||||||
@@ -55,6 +60,13 @@ const HUB_INSTANCE = 'global'
|
|||||||
/** The message on the box. */
|
/** The message on the box. */
|
||||||
export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!'
|
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. */
|
/** One role's gift, as configured. */
|
||||||
export interface RoleTokens {
|
export interface RoleTokens {
|
||||||
/** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */
|
/** 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
|
* The role map off the bindings, with every rejected entry logged — once per run for the
|
||||||
* when it holds none. Ties go to whichever is mapped first; they pay the same either way.
|
* 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
|
let best: RoleTokens | null = null
|
||||||
for (const role of roles) {
|
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
|
if (best === null || role.tokens > best.tokens) best = role
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
@@ -174,12 +205,7 @@ export async function grantDiscordRoleGifts(
|
|||||||
failed: 0,
|
failed: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
|
const roles = configuredRoles(env)
|
||||||
for (const entry of rejected) {
|
|
||||||
console.error(
|
|
||||||
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (roles.length === 0) {
|
if (roles.length === 0) {
|
||||||
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
|
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
|
||||||
return { ...summary, skipped: true }
|
return { ...summary, skipped: true }
|
||||||
@@ -190,10 +216,10 @@ export async function grantDiscordRoleGifts(
|
|||||||
summary.links = links.length
|
summary.links = links.length
|
||||||
|
|
||||||
for (const link of links) {
|
for (const link of links) {
|
||||||
const role = bestRoleGift(link, roles)
|
const role = bestRoleGift(link.roles, roles)
|
||||||
if (role === null) continue
|
if (role === null) continue
|
||||||
try {
|
try {
|
||||||
await grantOne(env, link, role, startingTokens)
|
await grantOne(env, link.accountId, role, startingTokens)
|
||||||
summary.granted++
|
summary.granted++
|
||||||
summary.tokens += role.tokens
|
summary.tokens += role.tokens
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -212,38 +238,61 @@ export async function grantDiscordRoleGifts(
|
|||||||
return summary
|
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
|
* 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
|
* best-effort — the tokens are credited and the box stored before either is sent, and an
|
||||||
* offline player meets both on their next login.
|
* offline player meets both on their next login.
|
||||||
*/
|
*/
|
||||||
async function grantOne(
|
async function grantOne(
|
||||||
env: Env,
|
env: RoleGiftEnv,
|
||||||
link: PlatformLink,
|
accountId: number,
|
||||||
role: RoleTokens,
|
role: RoleTokens,
|
||||||
startingTokens: number
|
startingTokens: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await ensureStartingBalances(env.DB, link.accountId, startingTokens)
|
await ensureStartingBalances(env.DB, accountId, startingTokens)
|
||||||
const balance = await creditCurrency(
|
const balance = await creditCurrency(
|
||||||
env.DB,
|
env.DB,
|
||||||
link.accountId,
|
accountId,
|
||||||
CurrencyType.RecCenterTokens,
|
CurrencyType.RecCenterTokens,
|
||||||
role.tokens,
|
role.tokens,
|
||||||
startingTokens
|
startingTokens
|
||||||
)
|
)
|
||||||
const content = roleGiftContent(role.tokens)
|
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)
|
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||||
try {
|
try {
|
||||||
// The balance first, so the box's announcement lands on a total that includes it.
|
// 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.
|
// `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,
|
Balance: balance,
|
||||||
CurrencyType: CurrencyType.RecCenterTokens,
|
CurrencyType: CurrencyType.RecCenterTokens,
|
||||||
Platform: ALL_PLATFORMS,
|
Platform: ALL_PLATFORMS,
|
||||||
} satisfies BalanceResponsePayload)
|
} satisfies BalanceResponsePayload)
|
||||||
await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, {
|
await hub.notifyPlayer(accountId, NotificationType.GiftPackageReceivedImmediate, {
|
||||||
Id: gift.id,
|
Id: gift.id,
|
||||||
FromPlayerId: COACH_ACCOUNT_ID,
|
FromPlayerId: COACH_ACCOUNT_ID,
|
||||||
ConsumableItemDesc: content.ConsumableItemDesc,
|
ConsumableItemDesc: content.ConsumableItemDesc,
|
||||||
@@ -263,7 +312,7 @@ async function grantOne(
|
|||||||
} satisfies GiftPackagePayload)
|
} satisfies GiftPackagePayload)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
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 { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||||
import {
|
import {
|
||||||
DISCORD_ROLE_GIFT_MESSAGE,
|
DISCORD_ROLE_GIFT_MESSAGE,
|
||||||
|
grantDiscordRoleGift,
|
||||||
grantDiscordRoleGifts,
|
grantDiscordRoleGifts,
|
||||||
parseRoleTokens,
|
parseRoleTokens,
|
||||||
} from '../../discord-role-gift'
|
} 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])
|
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 () => {
|
test('every run pays again — the schedule is the cadence — and a lapsed role is not paid', async () => {
|
||||||
await onlyDiscordLinks([
|
await onlyDiscordLinks([
|
||||||
[9321, '900000000000000321', [ROLE_A]],
|
[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
|
reload. Nothing revokes Plus: losing the role later leaves the flag set, so it
|
||||||
records "held the role once", not "holds it today".
|
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
|
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
|
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
|
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> =>
|
const fetchBenefitsStatus = (): Promise<BenefitsStatus> =>
|
||||||
call<BenefitsStatus>('/api/benefits/status', { authed: true })
|
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. */
|
/** Redeem the code Discord sent us back with. The access token never reaches this page. */
|
||||||
const claimBenefits = (code: string): Promise<{ discordUsername?: string }> =>
|
const claimBenefits = (code: string): Promise<ClaimResult> =>
|
||||||
call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true })
|
call<ClaimResult>('/api/benefits/claim', { json: { code }, authed: true })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage.
|
* 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)
|
claimBenefits(code)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
setStatus({ hasPlus: true, linked: true })
|
setStatus({ hasPlus: true, linked: true })
|
||||||
setDone(
|
const verified = result.discordUsername
|
||||||
result.discordUsername
|
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
|
||||||
? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.`
|
: 'Verified — 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)
|
setRelogin(true)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : String(err)))
|
.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.
|
* DEFAULT_STARTING_TOKENS, as it does in econ.
|
||||||
*/
|
*/
|
||||||
STARTING_TOKENS?: string | number
|
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
|
* 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
|
* rather than a policy, since a credit can't be taken back. Unset means
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ import {
|
|||||||
} from '../../../../econ/src/balance-db'
|
} from '../../../../econ/src/balance-db'
|
||||||
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db'
|
||||||
import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-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 { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db'
|
||||||
import {
|
import {
|
||||||
grantCustomAvatarItem,
|
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)
|
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
|
// 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
|
// 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
|
// 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 { PlatformType } from '@repo/domain/src/enums'
|
||||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||||
import { getStatSeries } from '@repo/domain/src/stats-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'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
// The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed
|
||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
isPlatformIdentityLinked,
|
isPlatformIdentityLinked,
|
||||||
linkPlatformIdentity,
|
linkPlatformIdentity,
|
||||||
} from '../../auth/src/platform-db'
|
} 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 { authUnreachable } from './auth-messages'
|
||||||
import {
|
import {
|
||||||
AUTHORIZE_URL,
|
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,
|
* 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
|
* which this design specifically avoids — but it does mean the flag records "held the
|
||||||
* role once", not "holds it today".
|
* 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) => {
|
.post('/api/benefits/claim', async (c) => {
|
||||||
const config = await discordConfig(c.env)
|
const config = await discordConfig(c.env)
|
||||||
@@ -419,6 +431,12 @@ const app = new Hono<App>()
|
|||||||
PlatformType.Discord,
|
PlatformType.Discord,
|
||||||
membership.userId
|
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) {
|
if (!alreadyMine) {
|
||||||
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
const claimedElsewhere = await countAccountsForPlatformIdentity(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
@@ -434,6 +452,8 @@ const app = new Hono<App>()
|
|||||||
409
|
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
|
// 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
|
// 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
|
// the row a re-claim REFRESHES, because they're a snapshot of a membership that moves
|
||||||
// and this reading is the fresher one.
|
// and this reading is the fresher one.
|
||||||
await linkPlatformIdentity(
|
const created = await linkPlatformIdentity(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
accountId,
|
accountId,
|
||||||
PlatformType.Discord,
|
PlatformType.Discord,
|
||||||
@@ -456,9 +476,40 @@ const app = new Hono<App>()
|
|||||||
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
await updateAccount(c.env.DB, accountId, { hasPlus: true })
|
||||||
logger.info('granted plus from a discord benefits claim', { accountId })
|
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
|
// 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.
|
// 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 ---------------------------------------------
|
// ---- Staff moderation panel ---------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user