[econ] change token award to once per activity per day

This commit is contained in:
Devin Zuczek
2026-10-14 14:04:32 -04:00
parent eb5c0d28ff
commit 0f757b2c4f
4 changed files with 258 additions and 142 deletions
+21 -18
View File
@@ -466,17 +466,19 @@ rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
Since the client asks rather than the server offering, whether a reward is actually **owed** Since the client asks rather than the server offering, whether a reward is actually **owed**
is decided here, from `reward_status` — one row per (account, reward type, gift context) is decided here, from `reward_status` — one row per (account, reward type, gift context)
holding the last claim and a count. One claim per type per activity per hour holding the last claim and a count. One claim per type per activity per hour
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay` (`REWARD_COOLDOWN_MS`) — except `FirstActivityOfDay`, which is one per PLAYER per rolling
suggests; per-type windows would be a map keyed by type. 24h (`DAILY_REWARD_COOLDOWN_MS`): it is always claimed on the `''` context, whichever
activity asked, so its row's `granted_at` is simply the player's last one.
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires - **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
these off right after a match, so two can land together; a read-then-write would let both these off right after a match, so two can land together; a read-then-write would let both
see the same stale `granted_at` and pay out twice. see the same stale `granted_at` and pay out twice.
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp - **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
forward, a client that retries in a loop would never become eligible. forward, a client that retries in a loop would never become eligible.
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of - **`giftContext` (the activity, e.g. `Soccer`) is part of the key** for the hourly types,
the day" is per activity, so a player who moves from Soccer to Paintball is owed another so a player who moves from Soccer to Paintball is owed another reward while a second Soccer
reward while a second Soccer match inside the hour is not. match inside the hour is not. The prize is still picked from the `giftContext` that asked,
`FirstActivityOfDay` included.
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs - **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
instead of hitting the conflict, and the cooldown would never apply. Migration instead of hitting the conflict, and the cooldown would never apply. Migration
@@ -491,22 +493,23 @@ empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
failure can't leave a box promising XP nobody was credited. failure can't leave a box promising XP nobody was credited.
- **One flat amount for every reward type**, matching the one flat cooldown they share. - **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type, `PostGameActivity` is a map keyed by type.
the same shape the per-type cooldown would take.
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action - **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
by the hourly cooldown rather than cleared in one match. by the hourly cooldown rather than cleared in one match.
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim - **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the (`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the
player's rows in that context right after the claim and is true when the claim's own row `giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day
is the only one. Counting after the write is what stops two asks landing together claim already limits it, so it has no allowance row. The running total
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race is one row per player in `reward_status` under the reserved type `$tokens` (context `''`):
can skip the bonus, never double it. The tokens are credited to the RecCenterTokens `grant_count` is the TOKENS paid in the window and `granted_at` when the window opened.
balance (`StorefrontBalanceUpdate` with the resulting total) and arrive in a **second box** `claimDailyTokens` adds to it in one atomic upsert, refusing a grant that would cross the cap
of their own — `GiftContext` 51 (`GameRewardsTokens`), `Currency` 250, no XP — after the (all or nothing, so the cap is a multiple of the amount), and resets it in place once the
reward's, so the player sees what they were for. The contextless `''` bucket is not an window is 24h old — a rolling window, so no time zone's midnight decides it. The tokens are
activity and never pays it. credited to the RecCenterTokens balance (`StorefrontBalanceUpdate` with the resulting total)
and arrive in a **second box** of their own — `GiftContext` 51 (`GameRewardsTokens`),
`Currency` 250, no XP — after the reward's, so the player sees what they were for.
- **The response stays `[]`.** It's what the client already accepts, and the reward is - **The response stays `[]`.** It's what the client already accepts, and the reward is
delivered as a box, so there's nothing to put in the body. The reference answers its own delivered as a box, so there's nothing to put in the body. The reference answers its own
(different) flow with `{ error, success, value: null }`, not a list of rewards. (different) flow with `{ error, success, value: null }`, not a list of rewards.
+79 -43
View File
@@ -168,7 +168,7 @@ import {
WishlistItemList, WishlistItemList,
WishlistItemRequest, WishlistItemRequest,
} from './openapi' } from './openapi'
import { claimReward, isNewActivity } from './reward-db' import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import { import {
awardRoomConsumable, awardRoomConsumable,
getRoomConsumable, getRoomConsumable,
@@ -2285,29 +2285,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!' const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
/** /**
* Tokens paid the FIRST time a player completes an activity — a `giftContext` no row of * Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
* theirs in `reward_status` has seen before, whatever the reward type. A one-off per * {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
* activity on top of the hourly XP: the reward for trying something new, sized as a * The running total is the player's `$tokens` row in `reward_status` (`claimDailyTokens`).
* visible nudge (a cheap catalog item) rather than an income, since the catalog of
* activities is finite and each pays it exactly once.
*/ */
const NEW_ACTIVITY_BONUS_TOKENS = 250 const GAME_REWARD_TOKENS = 250
/**
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
*/
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
/**
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
*/
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
/**
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
* daily cap: the claim itself is what limits it, so it needs no allowance row.
*/
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
/** /**
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS}, * `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
* on the box the new-activity bonus arrives in. * on the box the token payout arrives in.
*/ */
const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51 const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51
/** The message on the new-activity bonus box. */ /** The message on the token payout's box. */
const NEW_ACTIVITY_BONUS_MESSAGE = 'New activity bonus!' const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
/** /**
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like * The gift-drop a claimed game reward's token payout hands over: tokens in a box, no item, no XP. Like
* {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are * {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are
* credited to the balance separately, and the copy here is what the box displays. * credited to the balance separately, and the copy here is what the box displays.
*/ */
function toNewActivityBonusDrop(): StoreGiftDrop { function toGameRewardTokensDrop(): StoreGiftDrop {
return { return {
FriendlyName: '', FriendlyName: '',
Tooltip: '', Tooltip: '',
@@ -2318,7 +2335,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
EquipmentModificationGuid: '', EquipmentModificationGuid: '',
Rarity: 0, Rarity: 0,
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS, Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
Currency: NEW_ACTIVITY_BONUS_TOKENS, Currency: GAME_REWARD_TOKENS,
CurrencyType: CurrencyType.RecCenterTokens, CurrencyType: CurrencyType.RecCenterTokens,
Xp: 0, Xp: 0,
} }
@@ -6031,7 +6048,8 @@ const app = new Hono<App>({ strict: false })
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay& // posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity // Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided // completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
// here, from `reward_status`: one claim per type per activity per hour, atomically. // here, from `reward_status`: one claim per type per activity per hour, atomically —
// except FirstActivityOfDay, one per player per 24h.
// //
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that // A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses — // XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
@@ -6043,19 +6061,20 @@ const app = new Hono<App>({ strict: false })
// reference answers its own (different, selection-based) flow with a success envelope, // reference answers its own (different, selection-based) flow with a success envelope,
// not a list of rewards. // not a list of rewards.
// //
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first // `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, so a player who
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is // moves from Soccer to Paintball is owed another reward while a second Soccer match inside
// owed another reward while a second Soccer match inside the hour is not. An ask that // the hour is not. An ask that sends no context keys on `''` — as FirstActivityOfDay always
// sends no context keys on `''`. // does, being the player's first activity of the day, not each activity's.
// //
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json` // It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item // (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with // granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box. // the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
// //
// And the FIRST claim ever in a context — a new activity for this player, whichever type // A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
// asked — pays NEW_ACTIVITY_BONUS_TOKENS on top, credited to the balance and delivered in // GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
// a second box of its own so the player sees what the tokens were for. // FirstActivityOfDay claim pays them on top of the cap — credited to the balance and
// delivered in a second box of its own so the player sees what the tokens were for.
.post( .post(
'/api/gamerewards/v1/request', '/api/gamerewards/v1/request',
describeRoute({ describeRoute({
@@ -6067,9 +6086,11 @@ const app = new Hono<App>({ strict: false })
'owed another reward while the same one is not; an ask with no `giftContext` keys on', 'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`', 'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;', '(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
'any other claim pays XP only. The first claim ever in a `giftContext` — a new activity', 'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
'for the player — also pays a one-off 250 tokens, in a box of its own. The reward rides', 'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
'in a gift box, so a claim and a rejected (on-cooldown) ask both answer `[]`.', 'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
'the `giftContext`, and pays 250 tokens outside that cap. The reward rides in a gift',
'box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'), requestBody: form(GameRewardRequest, 'The reward type and its display message'),
@@ -6086,7 +6107,12 @@ const app = new Hono<App>({ strict: false })
// No type, nothing to gate: don't write a row keyed on an empty string. // No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json([]) if (rewardType === '') return c.json([])
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : '' const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext) // FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
// and a day's cooldown. Its prize is still picked from the activity that asked.
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
const claimed = daily
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
: await claimReward(c.env.DB, id, rewardType, giftContext)
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced. // On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
if (claimed === null) return c.json([]) if (claimed === null) return c.json([])
const message = const message =
@@ -6107,8 +6133,8 @@ const app = new Hono<App>({ strict: false })
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants // A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the // items, not balances. Seed the signup grant first — `creditCurrency` upserts the
// row, and a never-touched RecCenterTokens balance would otherwise lose it. // row, and a never-touched RecCenterTokens balance would otherwise lose it.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) { if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
await ensureStartingBalances(c.env.DB, id, startingTokens) await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency( const balance = await creditCurrency(
c.env.DB, c.env.DB,
@@ -6121,25 +6147,34 @@ const app = new Hono<App>({ strict: false })
} }
const granted = await grantGiftDrop(c, id, drop, message) const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// A new activity pays its token bonus in a second box — after the reward's own, so // The tokens ride in a second box — after the reward's own, so the player sees the
// the player sees the reward they asked for first and the bonus as the extra it is. // reward they asked for first — while the player's daily allowance has room. Same
// Same order as the currency reward above: balance first, then the box that shows it. // order as the currency reward above: balance first, then the box that shows it.
let bonusGiftId: number | null = null let tokensGiftId: number | null = null
if (await isNewActivity(c.env.DB, id, giftContext, claimed)) { let tokensToday: number | null = null
const bonus = toNewActivityBonusDrop() if (TOKEN_REWARD_TYPES.has(rewardType)) {
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) tokensToday = await claimDailyTokens(
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
c.env.DB, c.env.DB,
id, id,
bonus.CurrencyType, GAME_REWARD_TOKENS,
bonus.Currency, GAME_REWARD_DAILY_TOKEN_CAP
)
}
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
if (daily || tokensToday !== null) {
const tokensDrop = toGameRewardTokensDrop()
await ensureStartingBalances(c.env.DB, id, startingTokens)
const tokenBalance = await creditCurrency(
c.env.DB,
id,
tokensDrop.CurrencyType,
tokensDrop.Currency,
startingTokens startingTokens
) )
await pushBalanceUpdate(c, id, bonus.CurrencyType, balance) await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
const bonusGift = await grantGiftDrop(c, id, bonus, NEW_ACTIVITY_BONUS_MESSAGE) const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
await pushGiftReceived(c, id, bonusGift, NEW_ACTIVITY_BONUS_MESSAGE, COACH_ACCOUNT_ID) await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
bonusGiftId = bonusGift.id tokensGiftId = tokensGift.id
} }
// Every grant moves the bar, whether or not it crossed a level. // Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression) await pushProgressionUpdate(c, id, progression)
@@ -6156,8 +6191,9 @@ const app = new Hono<App>({ strict: false })
levelsGained, levelsGained,
levelXp: progression.XP, levelXp: progression.XP,
giftId: granted.id, giftId: granted.id,
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS, tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
bonusGiftId, tokensToday,
tokensGiftId,
}) })
return c.json([]) return c.json([])
} }
+55 -25
View File
@@ -30,13 +30,29 @@ export const REWARD_STATUS_SCHEMA_DDL: string[] = [
] ]
/** /**
* How long a player must wait between rewards of the same type in the same activity. One * How long a player must wait between rewards of the same type in the same activity: one
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests. * hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}.
* Per-type windows would be a map keyed by reward type; there's one window until a reward
* type needs its own.
*/ */
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000 export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
/** `FirstActivityOfDay`'s cooldown: a rolling 24h since the player's last one. */
export const DAILY_REWARD_COOLDOWN_MS = 24 * 60 * 60 * 1000
/**
* The game reward's tokens are a per-PLAYER daily allowance, kept in `reward_status` as one
* row under this reserved type and the empty context: `grant_count` is the TOKENS paid in the
* current window (not a count of claims) and `granted_at` is when that window opened. The `$`
* keeps it apart from the client's own type names (`FirstActivityOfDay`, `PostGameActivity`),
* which a client-sent type could otherwise forge.
*/
export const TOKEN_REWARD_TYPE = '$tokens'
/**
* How long a token window lasts: a rolling 24h from the first grant in it, rather than a
* calendar day — so no time zone's midnight decides when a player's allowance comes back.
*/
export const TOKEN_REWARD_WINDOW_MS = 24 * 60 * 60 * 1000
/** /**
* Claim a reward if the player is due one, returning how many of that type they have now * Claim a reward if the player is due one, returning how many of that type they have now
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was * claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
@@ -61,9 +77,10 @@ export async function claimReward(
accountId: number, accountId: number,
rewardType: string, rewardType: string,
giftContext = '', giftContext = '',
now: Date = new Date() now: Date = new Date(),
cooldownMs: number = REWARD_COOLDOWN_MS
): Promise<number | null> { ): Promise<number | null> {
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString() const cutoff = new Date(now.getTime() - cooldownMs).toISOString()
const row = await db const row = await db
.prepare( .prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count) `INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
@@ -80,28 +97,41 @@ export async function claimReward(
} }
/** /**
* Whether the claim just written by {@link claimReward} is the player's FIRST in * Add `amount` tokens to the player's daily allowance if it fits under `cap`, returning the
* `giftContext` — across every reward type, so an activity is "new" once, not once per * window's new total — or `null` when it doesn't fit and nothing was granted.
* type it happens to be asked under. Call it right after the claim with the count it
* returned: it is true when that claim INSERTED its row (`grantCount` 1; a repeat past the
* cooldown is 2+) and the player's rows in that context are exactly that one.
* *
* Counting AFTER the claim is what keeps two asks landing together (say * A window older than {@link TOKEN_REWARD_WINDOW_MS} is reset in place (the row's key allows
* `FirstActivityOfDay` and `PostGameActivity` for the same match) from both reading "new": * one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the
* D1 serializes the inserts, so at most the first insert's count is 1 and the second's is * amount is added to the running total, and `granted_at` is left alone so the window keeps its
* 2 — a race can skip the bonus, never pay it twice. The contextless `''` bucket is not an * start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the
* activity and is never new. * remainder, so `cap` should be a multiple of `amount`.
*
* One statement, for the same reason as {@link claimReward}: asks landing together can't both
* read a total under the cap and both pay. SQLite evaluates every `SET` against the row as it
* was, so both `CASE`s see the old `granted_at`.
*/ */
export async function isNewActivity( export async function claimDailyTokens(
db: D1Database, db: D1Database,
accountId: number, accountId: number,
giftContext: string, amount: number,
grantCount: number cap: number,
): Promise<boolean> { now: Date = new Date()
if (giftContext === '' || grantCount !== 1) return false ): Promise<number | null> {
if (amount > cap) return null
const cutoff = new Date(now.getTime() - TOKEN_REWARD_WINDOW_MS).toISOString()
const row = await db const row = await db
.prepare('SELECT COUNT(*) AS n FROM reward_status WHERE account_id = ?1 AND gift_context = ?2') .prepare(
.bind(accountId, giftContext) `INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
.first<{ n: number }>() VALUES (?1, ?2, '', ?3, ?4)
return row?.n === 1 ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
granted_at = CASE WHEN reward_status.granted_at <= ?5
THEN excluded.granted_at ELSE reward_status.granted_at END,
grant_count = CASE WHEN reward_status.granted_at <= ?5
THEN ?4 ELSE reward_status.grant_count + ?4 END
WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
RETURNING grant_count`
)
.bind(accountId, TOKEN_REWARD_TYPE, now.toISOString(), amount, cutoff, cap)
.first<{ grant_count: number }>()
return row?.grant_count ?? null
} }
+103 -56
View File
@@ -6580,7 +6580,7 @@ describe('econ endpoints', () => {
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc) expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
}) })
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => { test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity — FirstActivityOfDay once a day per player', async () => {
const headers = { const headers = {
...(await bearer('80')), ...(await bearer('80')),
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@@ -6608,10 +6608,16 @@ describe('econ endpoints', () => {
const claimed = await statusOf('FirstActivityOfDay') const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1) expect(claimed?.grant_count).toBe(1)
// Asking again inside the hour claims nothing — and must not push the cooldown out, // Asking again inside the day claims nothing — and must not push the cooldown out,
// or a client that retries in a loop would never become eligible. // or a client that retries in a loop would never become eligible. It is the PLAYER's
// first activity of the day, so another activity doesn't claim it either, and writes
// no row of its own.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(
(await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status
).toBe(200)
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed) expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
expect(await statusOf('FirstActivityOfDay', 'Soccer')).toBeNull()
// A different type has its own cooldown — and so does each `giftContext` within a type: // A different type has its own cooldown — and so does each `giftContext` within a type:
// Soccer and Paintball are separate rows that each claim once. // Soccer and Paintball are separate rows that each claim once.
@@ -6643,12 +6649,21 @@ describe('econ endpoints', () => {
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200) expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
// Once the hour has passed, the same type claims again. // Once the hour has passed, an hourly type claims again — but FirstActivityOfDay waits
await env.DB.prepare( // out the whole day.
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'" const age = (rewardType: string, ms: number) =>
) env.DB.prepare(
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) 'UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = ?2'
.run() )
.bind(new Date(Date.now() - ms).toISOString(), rewardType)
.run()
await age('PostGameActivity', 61 * 60 * 1000)
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(2)
await age('FirstActivityOfDay', 61 * 60 * 1000)
expect((await request('rewardType=FirstActivityOfDay&Message=later')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(1)
await age('FirstActivityOfDay', 25 * 60 * 60 * 1000)
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2) expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
}) })
@@ -6664,12 +6679,21 @@ describe('econ endpoints', () => {
body, body,
}) })
/** Age the cooldown so the next ask is eligible again. */ /** Age the cooldown so the next ask is eligible again. */
const passAnHour = () => const passADay = () =>
env.DB.prepare( env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'" "UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
) )
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) .bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run() .run()
/** The reward's own boxes — not the token box (GiftContext 51) each claim also pays. */
const rewardBoxes = async () => (await giftBoxes('82')).filter((b) => b.GiftContext !== 51)
/** Frames bar the token payout's balance update and box. */
const rewardFrames = async () =>
(await drainFrames()).filter(
(f) =>
f.notificationType !== NotificationType.StorefrontBalanceUpdate &&
(f.payload as { GiftContext?: number }).GiftContext !== 51
)
await drainFrames() await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0) expect((await getProgression(env.DB, 82)).XP).toBe(0)
@@ -6681,9 +6705,9 @@ describe('econ endpoints', () => {
// bar without levelling anyone up. // bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// One box: the XP reward itself, carrying the message the client asked to show and no // One reward box: the XP itself, carrying the message the client asked to show and no
// item — a game reward is not an item. // item — a game reward is not an item. (Its tokens come in a box of their own.)
const first = await giftBoxes('82') const first = await rewardBoxes()
expect(first).toHaveLength(1) expect(first).toHaveLength(1)
expect(first[0]).toMatchObject({ expect(first[0]).toMatchObject({
Xp: 5, Xp: 5,
@@ -6694,7 +6718,7 @@ describe('econ endpoints', () => {
}) })
// The box, then the bar — no level-up box, since no level was crossed. // The box, then the bar — no level-up box, since no level was crossed.
const frames = await drainFrames() const frames = await rewardFrames()
expect(frames.map((f) => f.notificationType)).toEqual([ expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate, NotificationType.PlayerProgressionLevelUpdate,
@@ -6713,18 +6737,18 @@ describe('econ endpoints', () => {
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP. // An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(1) expect(await giftBoxes('82')).toHaveLength(2)
expect(await drainFrames()).toEqual([]) expect(await drainFrames()).toEqual([])
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which // A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
// is the pacing the smaller grant buys. // is the pacing the smaller grant buys.
await passAnHour() await passADay()
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an // …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
// equipment skin, which is what the avatar-only roll is for. // equipment skin, which is what the avatar-only roll is for.
const afterLevel2 = await giftBoxes('82') const afterLevel2 = await rewardBoxes()
expect(afterLevel2).toHaveLength(3) expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[2] const clothingBox = afterLevel2[2]
expect(clothingBox?.Message).toBe('Level 2!') expect(clothingBox?.Message).toBe('Level 2!')
@@ -6739,7 +6763,7 @@ describe('econ endpoints', () => {
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box. // v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }> const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc) expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([ expect((await rewardFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate, NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
@@ -6748,7 +6772,7 @@ describe('econ endpoints', () => {
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than // Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
// clothing — rolled without a rarity, since the table names none for them. // clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) { for (const message of ['Third', 'Fourth']) {
await passAnHour() await passADay()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200) expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
} }
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
@@ -6785,8 +6809,7 @@ describe('econ endpoints', () => {
) )
expect(goblin.status).toBe(200) expect(goblin.status).toBe(200)
expect(await goblin.json()).toEqual([]) expect(await goblin.json()).toEqual([])
// Two boxes: the quest reward, and the new-activity token bonus (covered below) that a // Two boxes: the quest reward, and the PostGameActivity tokens (covered below).
// first-ever claim in a context also pays.
const boxes = await giftBoxes('83') const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(2) expect(boxes).toHaveLength(2)
const box = boxes[0] const box = boxes[0]
@@ -6836,7 +6859,7 @@ describe('econ endpoints', () => {
) )
// An activity the table doesn't know pays the plain XP box, as before — the last // An activity the table doesn't know pays the plain XP box, as before — the last
// GameRewards box, behind Bowling's own new-activity bonus. // GameRewards box, behind Bowling's own tokens.
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200) expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50) const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50)
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 }) expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
@@ -6860,7 +6883,7 @@ describe('econ endpoints', () => {
expect(handed.toSorted()).toEqual(dodgeball.toSorted()) expect(handed.toSorted()).toEqual(dodgeball.toSorted())
}) })
test('the first claim ever in a giftContext pays a one-off 250-token bonus', async () => { test('PostGameActivity/PostQuestActivity pay 250 tokens, up to 1250 per player per 24h', async () => {
const request = async (body: string) => const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST', method: 'POST',
@@ -6872,61 +6895,85 @@ describe('econ endpoints', () => {
}) })
const tokens = () => const tokens = () =>
getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
const bonusBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51) const tokenBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
const allowance = () =>
env.DB.prepare(
"SELECT granted_at, grant_count FROM reward_status WHERE account_id = 84 AND reward_type = '$tokens'"
).first<{ granted_at: string; grant_count: number }>()
await drainFrames() await drainFrames()
const start = await tokens() const start = await tokens()
// A contextless ask is not an activity: XP box only, no tokens. // FirstActivityOfDay pays its 250 OUTSIDE the allowance: no allowance row, and the cap
expect((await request('rewardType=FirstActivityOfDay&Message=First')).status).toBe(200) // below is still five claims' worth on top of it.
expect(await tokens()).toBe(start) expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
expect(await bonusBoxes()).toHaveLength(0) expect(await tokens()).toBe(start + 250)
expect(await allowance()).toBeNull()
await drainFrames()
const base = start + 250
// First time in Soccer: +250, in a box of its own after the reward's, credited to the // PostGameActivity: +250, in a box of its own after the reward's, credited to the
// balance and announced as such. // balance and announced as such.
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
expect(await tokens()).toBe(start + 250) expect(await tokens()).toBe(base + 250)
const bonus = await bonusBoxes() const boxes = (await tokenBoxes()).slice(1)
expect(bonus).toHaveLength(1) expect(boxes).toHaveLength(1)
expect(bonus[0]).toMatchObject({ expect(boxes[0]).toMatchObject({
Currency: 250, Currency: 250,
CurrencyType: CurrencyType.RecCenterTokens, CurrencyType: CurrencyType.RecCenterTokens,
Xp: 0, Xp: 0,
AvatarItemDesc: '', AvatarItemDesc: '',
Message: 'New activity bonus!', Message: 'Activity bonus!',
}) })
// The balance frame carries the resulting total, and the bonus box is announced like // The balance frame carries the resulting total, and the token box is announced like
// any other. (This being the second 5 XP, a level-2 box rides along too.) // any other.
const frames = await drainFrames() const frames = await drainFrames()
expect( expect(
frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload frames.findLast((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 }) ?.payload
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: base + 250 })
expect( expect(
frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate) frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate)
).toContainEqual( ).toContainEqual(
expect.objectContaining({ expect.objectContaining({
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: bonus[0]?.Id }), payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: boxes[0]?.Id }),
}) })
) )
const opened = (await allowance())?.granted_at
expect(await allowance()).toMatchObject({ grant_count: 250 })
// Soccer under ANOTHER type is the same activity — no second bonus… // PostQuestActivity counts the same, and the allowance is the player's, whatever the
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200) // activity — a contextless ask included.
expect(await tokens()).toBe(start + 250) for (const body of [
// …and neither is Soccer again once its cooldown has passed. 'rewardType=PostQuestActivity&giftContext=Quest_Goblin_S',
'rewardType=PostGameActivity',
'rewardType=PostGameActivity&giftContext=Paintball',
'rewardType=PostGameActivity&giftContext=Bowling',
]) {
expect((await request(body)).status).toBe(200)
}
expect(await tokens()).toBe(base + 1250)
// The window keeps its start as the total grows.
expect(await allowance()).toEqual({ granted_at: opened, grant_count: 1250 })
// At the cap: the claim still pays its XP box, but no tokens.
const before = (await giftBoxes('84')).length
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
expect(await tokens()).toBe(base + 1250)
expect(await tokenBoxes()).toHaveLength(6)
expect((await giftBoxes('84')).length).toBeGreaterThan(before)
expect(await allowance()).toMatchObject({ grant_count: 1250 })
// Once the window is 24h old, the next claim opens a fresh one.
await env.DB.prepare( await env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND gift_context = 'Soccer'" "UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND reward_type = '$tokens'"
) )
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) .bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run() .run()
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200)
expect(await tokens()).toBe(start + 250) expect(await tokens()).toBe(base + 1500)
// An on-cooldown ask in a context never claimed pays nothing — it wrote no row. const reopened = await allowance()
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) expect(reopened?.grant_count).toBe(250)
expect(await tokens()).toBe(start + 250) expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true)
// A different activity is new again.
expect((await request('rewardType=PostGameActivity&giftContext=Paintball')).status).toBe(200)
expect(await tokens()).toBe(start + 500)
expect(await bonusBoxes()).toHaveLength(2)
}) })
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => { test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {