From 0f757b2c4f4218f12c81e3c3599d6dd1ee448976 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 14 Oct 2026 14:04:32 -0400 Subject: [PATCH] [econ] change token award to once per activity per day --- apps/econ/README.md | 39 ++--- apps/econ/src/econ.app.ts | 122 ++++++++++------ apps/econ/src/reward-db.ts | 80 +++++++---- apps/econ/src/test/integration/api.test.ts | 159 +++++++++++++-------- 4 files changed, 258 insertions(+), 142 deletions(-) diff --git a/apps/econ/README.md b/apps/econ/README.md index 31feeb7..48d3285 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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** 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 -(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay` -suggests; per-type windows would be a map keyed by type. +(`REWARD_COOLDOWN_MS`) — except `FirstActivityOfDay`, which is one per PLAYER per rolling +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 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. - **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. -- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of - the day" is per activity, so a player who moves from Soccer to Paintball is owed another - reward while a second Soccer match inside the hour is not. +- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** for the hourly types, + so a player who moves from Soccer to Paintball is owed another reward while a second Soccer + 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 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 @@ -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 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. - Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type, - the same shape the per-type cooldown would take. +- **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from + `PostGameActivity` is a map keyed by type. - **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 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 - ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the - player's rows in that context right after the claim and is true when the claim's own row - is the only one. Counting after the write is what stops two asks landing together - (`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race - can skip the bonus, never double it. The tokens are 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 contextless `''` bucket is not an - activity and never pays it. +- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day** + (`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the + `giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day + claim already limits it, so it has no allowance row. The running total + is one row per player in `reward_status` under the reserved type `$tokens` (context `''`): + `grant_count` is the TOKENS paid in the window and `granted_at` when the window opened. + `claimDailyTokens` adds to it in one atomic upsert, refusing a grant that would cross the cap + (all or nothing, so the cap is a multiple of the amount), and resets it in place once the + window is 24h old — a rolling window, so no time zone's midnight decides it. The tokens are + 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 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. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 0ca8c65..4247e01 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -168,7 +168,7 @@ import { WishlistItemList, WishlistItemRequest, } from './openapi' -import { claimReward, isNewActivity } from './reward-db' +import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' import { awardRoomConsumable, getRoomConsumable, @@ -2285,29 +2285,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50 const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!' /** - * Tokens paid the FIRST time a player completes an activity — a `giftContext` no row of - * theirs in `reward_status` has seen before, whatever the reward type. A one-off per - * activity on top of the hourly XP: the reward for trying something new, sized as a - * visible nudge (a cheap catalog item) rather than an income, since the catalog of - * activities is finite and each pays it exactly once. + * Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had + * {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity. + * The running total is the player's `$tokens` row in `reward_status` (`claimDailyTokens`). */ -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}, - * on the box the new-activity bonus arrives in. + * on the box the token payout arrives in. */ const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51 -/** The message on the new-activity bonus box. */ -const NEW_ACTIVITY_BONUS_MESSAGE = 'New activity bonus!' +/** The message on the token payout's box. */ +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 * credited to the balance separately, and the copy here is what the box displays. */ -function toNewActivityBonusDrop(): StoreGiftDrop { +function toGameRewardTokensDrop(): StoreGiftDrop { return { FriendlyName: '', Tooltip: '', @@ -2318,7 +2335,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop { EquipmentModificationGuid: '', Rarity: 0, Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS, - Currency: NEW_ACTIVITY_BONUS_TOKENS, + Currency: GAME_REWARD_TOKENS, CurrencyType: CurrencyType.RecCenterTokens, Xp: 0, } @@ -6031,7 +6048,8 @@ const app = new Hono({ strict: false }) // posting the type and the message to show for it (`rewardType=FirstActivityOfDay& // Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity // 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 // XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses — @@ -6043,19 +6061,20 @@ const app = new Hono({ strict: false }) // reference answers its own (different, selection-based) flow with a success envelope, // not a list of rewards. // - // `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first - // activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is - // owed another reward while a second Soccer match inside the hour is not. An ask that - // sends no context keys on `''`. + // `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, so a player who + // moves from Soccer to Paintball is owed another reward while a second Soccer match inside + // the hour is not. An ask that sends no context keys on `''` — as FirstActivityOfDay always + // 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` // (`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 // 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 - // asked — pays NEW_ACTIVITY_BONUS_TOKENS on top, credited to the balance and delivered in - // a second box of its own so the player sees what the tokens were for. + // A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to + // GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a + // 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( '/api/gamerewards/v1/request', describeRoute({ @@ -6067,9 +6086,11 @@ const app = new Hono({ strict: false }) '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`', '(`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', - 'for the player — also pays a one-off 250 tokens, in a box of its own. The reward rides', - 'in a gift box, so a claim and a rejected (on-cooldown) ask both answer `[]`.', + 'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also', + 'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours', + '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(' '), security: AUTHED, requestBody: form(GameRewardRequest, 'The reward type and its display message'), @@ -6086,7 +6107,12 @@ const app = new Hono({ strict: false }) // No type, nothing to gate: don't write a row keyed on an empty string. if (rewardType === '') return c.json([]) 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. if (claimed === null) return c.json([]) const message = @@ -6107,8 +6133,8 @@ const app = new Hono({ strict: false }) // A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants // items, not balances. Seed the signup grant first — `creditCurrency` upserts the // 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) { - const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) await ensureStartingBalances(c.env.DB, id, startingTokens) const balance = await creditCurrency( c.env.DB, @@ -6121,25 +6147,34 @@ const app = new Hono({ strict: false }) } const granted = await grantGiftDrop(c, id, drop, message) 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 player sees the reward they asked for first and the bonus as the extra it is. - // Same order as the currency reward above: balance first, then the box that shows it. - let bonusGiftId: number | null = null - if (await isNewActivity(c.env.DB, id, giftContext, claimed)) { - const bonus = toNewActivityBonusDrop() - const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) - await ensureStartingBalances(c.env.DB, id, startingTokens) - const balance = await creditCurrency( + // The tokens ride in a second box — after the reward's own, so the player sees the + // reward they asked for first — while the player's daily allowance has room. Same + // order as the currency reward above: balance first, then the box that shows it. + let tokensGiftId: number | null = null + let tokensToday: number | null = null + if (TOKEN_REWARD_TYPES.has(rewardType)) { + tokensToday = await claimDailyTokens( c.env.DB, id, - bonus.CurrencyType, - bonus.Currency, + GAME_REWARD_TOKENS, + 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 ) - await pushBalanceUpdate(c, id, bonus.CurrencyType, balance) - const bonusGift = await grantGiftDrop(c, id, bonus, NEW_ACTIVITY_BONUS_MESSAGE) - await pushGiftReceived(c, id, bonusGift, NEW_ACTIVITY_BONUS_MESSAGE, COACH_ACCOUNT_ID) - bonusGiftId = bonusGift.id + await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance) + const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE) + await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID) + tokensGiftId = tokensGift.id } // Every grant moves the bar, whether or not it crossed a level. await pushProgressionUpdate(c, id, progression) @@ -6156,8 +6191,9 @@ const app = new Hono({ strict: false }) levelsGained, levelXp: progression.XP, giftId: granted.id, - newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS, - bonusGiftId, + tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS, + tokensToday, + tokensGiftId, }) return c.json([]) } diff --git a/apps/econ/src/reward-db.ts b/apps/econ/src/reward-db.ts index 8f5abab..206c053 100644 --- a/apps/econ/src/reward-db.ts +++ b/apps/econ/src/reward-db.ts @@ -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 - * hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests. - * Per-type windows would be a map keyed by reward type; there's one window until a reward - * type needs its own. + * How long a player must wait between rewards of the same type in the same activity: one + * hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}. */ 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 * 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, rewardType: string, giftContext = '', - now: Date = new Date() + now: Date = new Date(), + cooldownMs: number = REWARD_COOLDOWN_MS ): Promise { - const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString() + const cutoff = new Date(now.getTime() - cooldownMs).toISOString() const row = await db .prepare( `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 - * `giftContext` — across every reward type, so an activity is "new" once, not once per - * 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. + * Add `amount` tokens to the player's daily allowance if it fits under `cap`, returning the + * window's new total — or `null` when it doesn't fit and nothing was granted. * - * Counting AFTER the claim is what keeps two asks landing together (say - * `FirstActivityOfDay` and `PostGameActivity` for the same match) from both reading "new": - * D1 serializes the inserts, so at most the first insert's count is 1 and the second's is - * 2 — a race can skip the bonus, never pay it twice. The contextless `''` bucket is not an - * activity and is never new. + * A window older than {@link TOKEN_REWARD_WINDOW_MS} is reset in place (the row's key allows + * one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the + * amount is added to the running total, and `granted_at` is left alone so the window keeps its + * start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the + * 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, accountId: number, - giftContext: string, - grantCount: number -): Promise { - if (giftContext === '' || grantCount !== 1) return false + amount: number, + cap: number, + now: Date = new Date() +): Promise { + if (amount > cap) return null + const cutoff = new Date(now.getTime() - TOKEN_REWARD_WINDOW_MS).toISOString() const row = await db - .prepare('SELECT COUNT(*) AS n FROM reward_status WHERE account_id = ?1 AND gift_context = ?2') - .bind(accountId, giftContext) - .first<{ n: number }>() - return row?.n === 1 + .prepare( + `INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count) + VALUES (?1, ?2, '', ?3, ?4) + 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 } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d456c01..7d708c7 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -6580,7 +6580,7 @@ describe('econ endpoints', () => { 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 = { ...(await bearer('80')), 'Content-Type': 'application/x-www-form-urlencoded', @@ -6608,10 +6608,16 @@ describe('econ endpoints', () => { const claimed = await statusOf('FirstActivityOfDay') expect(claimed?.grant_count).toBe(1) - // Asking again inside the hour claims nothing — and must not push the cooldown out, - // or a client that retries in a loop would never become eligible. + // 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. 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&giftContext=Soccer')).status + ).toBe(200) 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: // 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 statusOf('PostGameActivity'))?.grant_count).toBe(1) - // Once the hour has passed, the same type claims again. - await env.DB.prepare( - "UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'" - ) - .bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) - .run() + // Once the hour has passed, an hourly type claims again — but FirstActivityOfDay waits + // out the whole day. + const age = (rewardType: string, ms: number) => + env.DB.prepare( + 'UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = ?2' + ) + .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 statusOf('FirstActivityOfDay'))?.grant_count).toBe(2) }) @@ -6664,12 +6679,21 @@ describe('econ endpoints', () => { body, }) /** Age the cooldown so the next ask is eligible again. */ - const passAnHour = () => + const passADay = () => env.DB.prepare( "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() + /** 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() expect((await getProgression(env.DB, 82)).XP).toBe(0) @@ -6681,9 +6705,9 @@ describe('econ endpoints', () => { // bar without levelling anyone up. 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 - // item — a game reward is not an item. - const first = await giftBoxes('82') + // One reward box: the XP itself, carrying the message the client asked to show and no + // item — a game reward is not an item. (Its tokens come in a box of their own.) + const first = await rewardBoxes() expect(first).toHaveLength(1) expect(first[0]).toMatchObject({ Xp: 5, @@ -6694,7 +6718,7 @@ describe('econ endpoints', () => { }) // 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([ NotificationType.GiftPackageReceivedImmediate, NotificationType.PlayerProgressionLevelUpdate, @@ -6713,18 +6737,18 @@ describe('econ endpoints', () => { // 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 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([]) // A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which // is the pacing the smaller grant buys. - await passAnHour() + await passADay() expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200) 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 // equipment skin, which is what the avatar-only roll is for. - const afterLevel2 = await giftBoxes('82') + const afterLevel2 = await rewardBoxes() expect(afterLevel2).toHaveLength(3) const clothingBox = afterLevel2[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. const owned = (await items.json()) as Array<{ avatarItemDesc: string }> 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.PlayerProgressionLevelUpdate, NotificationType.GiftPackageReceivedImmediate, @@ -6748,7 +6772,7 @@ describe('econ endpoints', () => { // 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. for (const message of ['Third', 'Fourth']) { - await passAnHour() + await passADay() expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200) } 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(await goblin.json()).toEqual([]) - // Two boxes: the quest reward, and the new-activity token bonus (covered below) that a - // first-ever claim in a context also pays. + // Two boxes: the quest reward, and the PostGameActivity tokens (covered below). const boxes = await giftBoxes('83') expect(boxes).toHaveLength(2) 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 - // 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) const plain = (await giftBoxes('83')).findLast((b) => b.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()) }) - 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) => exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { method: 'POST', @@ -6872,61 +6895,85 @@ describe('econ endpoints', () => { }) const 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() const start = await tokens() - // A contextless ask is not an activity: XP box only, no tokens. - expect((await request('rewardType=FirstActivityOfDay&Message=First')).status).toBe(200) - expect(await tokens()).toBe(start) - expect(await bonusBoxes()).toHaveLength(0) + // FirstActivityOfDay pays its 250 OUTSIDE the allowance: no allowance row, and the cap + // below is still five claims' worth on top of it. + expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200) + 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. expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) - expect(await tokens()).toBe(start + 250) - const bonus = await bonusBoxes() - expect(bonus).toHaveLength(1) - expect(bonus[0]).toMatchObject({ + expect(await tokens()).toBe(base + 250) + const boxes = (await tokenBoxes()).slice(1) + expect(boxes).toHaveLength(1) + expect(boxes[0]).toMatchObject({ Currency: 250, CurrencyType: CurrencyType.RecCenterTokens, Xp: 0, AvatarItemDesc: '', - Message: 'New activity bonus!', + Message: 'Activity bonus!', }) - // The balance frame carries the resulting total, and the bonus box is announced like - // any other. (This being the second 5 XP, a level-2 box rides along too.) + // The balance frame carries the resulting total, and the token box is announced like + // any other. const frames = await drainFrames() expect( - frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload - ).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 }) + frames.findLast((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate) + ?.payload + ).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: base + 250 }) expect( frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate) ).toContainEqual( 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… - expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200) - expect(await tokens()).toBe(start + 250) - // …and neither is Soccer again once its cooldown has passed. + // PostQuestActivity counts the same, and the allowance is the player's, whatever the + // activity — a contextless ask included. + for (const body of [ + '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( - "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() - expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) - expect(await tokens()).toBe(start + 250) - // An on-cooldown ask in a context never claimed pays nothing — it wrote no row. - expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) - expect(await tokens()).toBe(start + 250) - - // 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) + expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200) + expect(await tokens()).toBe(base + 1500) + const reopened = await allowance() + expect(reopened?.grant_count).toBe(250) + expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true) }) test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {