diff --git a/apps/econ/README.md b/apps/econ/README.md index 48d3285..1ab5e7d 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -52,7 +52,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali | GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress | | POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress | | GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | -| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box | +| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box | | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | The keys a room has listed | | POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) | @@ -486,7 +486,7 @@ activity asked, so its row's `granted_at` is simply the player's last one. primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from before it keep counting. -**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in +**What a claim pays: 10 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in `progression` and the box is the wrapper the client shows for it — no item, every item field empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted (`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the @@ -495,21 +495,26 @@ failure can't leave a box promising XP nobody was credited. - **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. +- **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh + player's first rewards each level them up; from level 4 (20 XP a level) it takes two or + more, and the daily XP cap paces it from there. - **`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 + `claimAllowance` 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. +- **XP is capped too: 150 a day** (`GAME_REWARD_DAILY_XP_CAP`), fifteen claims' worth, on the + same machinery — the player's `$xp` row, `grant_count` the XP paid in the window. Every + type's XP draws on it but `FirstActivityOfDay`'s, a bonus on top like its tokens. Past the + cap a claim still counts against its cooldown and still pays its activity's item (in a box + with `Xp` 0) and its tokens; a claim with nothing left to carry gets no box at all. - **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. @@ -549,7 +554,7 @@ costs are easy to edit one at a time and hard to eyeball as a curve. | 50 | 5-Star Clothing (rarity 50) — the only one | **One reward per level crossed** — a grant spanning several levels pays each of them. In -practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims +practice a 10 XP game reward crosses at most one, so the first reward a fresh player claims hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each arrives as a gift box announced like any other (`Level 2!`). @@ -652,8 +657,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod - Consumables are granted and listed but never spent by gameplay, so `Count` only grows. - Several routes (equipment, room consumables/currencies) are empty-list stubs pending their own stores. -- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the - reference caps activity XP per day in `daily_xp_ledgers`). +- Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day + in `daily_xp_ledgers`). - The level-reward table and the served config's `GiftRarity` disagree in places (see the level section); we grant from the table and leave the config as captured, so a client that previews an upcoming reward would preview the config's answer, not ours. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 4247e01..72586f7 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 { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' +import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' import { awardRoomConsumable, getRoomConsumable, @@ -2264,15 +2264,24 @@ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is B } /** - * XP paid for a claimed game reward. One flat amount for every reward type, matching the - * one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the - * same size of pat on the back until there's reason to price them apart. + * XP paid for a claimed game reward. One flat amount for every reward type — "First Game of + * the Day" and "Activity completed!" are the same size of pat on the back until there's + * reason to price them apart. * - * Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a - * level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early - * levels are paced by the hourly cooldown rather than cleared in one match. + * Exactly what each of the first three levels costs, so a fresh player's first rewards each + * level them up; from level 4 (20 XP a level) it takes two or more. The daily + * {@link GAME_REWARD_DAILY_XP_CAP} paces it from there. */ -const GAME_REWARD_XP = 5 +const GAME_REWARD_XP = 10 + +/** + * The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth, + * whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like + * its tokens. The running total is the player's `$xp` row in `reward_status` + * (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays + * no XP. + */ +const GAME_REWARD_DAILY_XP_CAP = 150 /** * `GiftContext.GameRewards` — what the box says it came from, so the client files it under @@ -2287,7 +2296,7 @@ const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!' /** * 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`). + * The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`). */ const GAME_REWARD_TOKENS = 250 @@ -2347,7 +2356,7 @@ function toGameRewardTokensDrop(): StoreGiftDrop { * nothing into the inventory and only creates the box. The XP is banked in `progression`; * the copy here is what the box and its notification display. */ -function toGameRewardDrop(): StoreGiftDrop { +function toGameRewardDrop(xp: number): StoreGiftDrop { return { FriendlyName: '', Tooltip: '', @@ -2360,7 +2369,7 @@ function toGameRewardDrop(): StoreGiftDrop { Context: GIFT_CONTEXT_GAME_REWARDS, Currency: 0, CurrencyType: 0, - Xp: GAME_REWARD_XP, + Xp: xp, } } @@ -2425,10 +2434,14 @@ async function pickQuestReward( * row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog * row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip, * `AvatarItemType`), so the inventory entry reads like a bought one rather than blank. - * The XP is the flat game-reward amount, not the row's (always 0): the reward is the item, - * and the XP is the same pat on the back every claim gets. + * The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and + * the XP is the same pat on the back every claim gets while the daily cap has room. */ -function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop { +function toQuestRewardDrop( + reward: QuestReward, + catalog: CatalogRow | null, + xp: number +): StoreGiftDrop { return { FriendlyName: catalog?.friendly_name ?? '', Tooltip: catalog?.tooltip ?? '', @@ -2441,7 +2454,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto Context: reward.Context, Currency: reward.Currency, CurrencyType: reward.CurrencyType, - Xp: GAME_REWARD_XP, + Xp: xp, } } @@ -6051,7 +6064,8 @@ const app = new Hono({ strict: false }) // 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` (up to GAME_REWARD_DAILY_XP_CAP per player + // per rolling 24h, FirstActivityOfDay's on top) and hands over a gift box carrying that // XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses — // the client posted the message to show, so the box wears it. An on-cooldown ask changes // nothing and pays nothing. @@ -6119,17 +6133,27 @@ const app = new Hono({ strict: false }) typeof body.Message === 'string' && body.Message !== '' ? body.Message : DEFAULT_GAME_REWARD_MESSAGE + // The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top, + // its once-a-day claim being limit enough. Past the cap the claim pays no XP. + const xpToday = daily + ? null + : await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP) + const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0 // Bank the XP first: it is the reward, and the box is the wrapper the client shows. // A failure here must not leave a box promising XP that was never credited. - const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP) + const gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null // An activity the reward table knows pays one of ITS rewards the player lacks — the // item rides in the box and is granted with it. Anything else gets the plain XP box. const questReward = await pickQuestReward(c.env.DB, id, giftContext) const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid const drop = questReward === null - ? toGameRewardDrop() - : toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null) + ? toGameRewardDrop(xp) + : toQuestRewardDrop( + questReward, + itemKey ? await getCatalogItem(c.env.DB, itemKey) : null, + xp + ) // 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. @@ -6145,17 +6169,24 @@ const app = new Hono({ strict: false }) ) await pushBalanceUpdate(c, id, drop.CurrencyType, balance) } - const granted = await grantGiftDrop(c, id, drop, message) - await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) + // A box with nothing in it — the XP capped and no item or currency to carry — is not + // handed over at all. + let rewardGiftId: number | null = null + if (questReward !== null || xp > 0) { + const granted = await grantGiftDrop(c, id, drop, message) + await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) + rewardGiftId = granted.id + } // 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( + tokensToday = await claimAllowance( c.env.DB, id, + '$tokens', GAME_REWARD_TOKENS, GAME_REWARD_DAILY_TOKEN_CAP ) @@ -6176,21 +6207,24 @@ const app = new Hono({ strict: false }) 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) - // …and every level crossed is worth a box of its own tier. - await grantLevelUpGifts(c, id, { progression, levelsGained }) + if (gained !== null) { + // Every grant moves the bar, whether or not it crossed a level. + await pushProgressionUpdate(c, id, gained.progression) + // …and every level crossed is worth a box of its own tier. + await grantLevelUpGifts(c, id, gained) + } logger.info('game reward claimed', { accountId: id, rewardType, giftContext, grantCount: claimed, message, - xp: GAME_REWARD_XP, - level: progression.Level, - levelsGained, - levelXp: progression.XP, - giftId: granted.id, + xp, + xpToday, + level: gained?.progression.Level, + levelsGained: gained?.levelsGained, + levelXp: gained?.progression.XP, + giftId: rewardGiftId, tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS, tokensToday, tokensGiftId, diff --git a/apps/econ/src/reward-db.ts b/apps/econ/src/reward-db.ts index 206c053..b27b704 100644 --- a/apps/econ/src/reward-db.ts +++ b/apps/econ/src/reward-db.ts @@ -39,19 +39,19 @@ export const REWARD_COOLDOWN_MS = 60 * 60 * 1000 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. + * The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under + * its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current + * window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened. + * The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`, + * `PostGameActivity`), which a client-sent type could otherwise forge. */ -export const TOKEN_REWARD_TYPE = '$tokens' +export type Allowance = '$tokens' | '$xp' /** - * How long a token window lasts: a rolling 24h from the first grant in it, rather than a + * How long an allowance 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 +export const ALLOWANCE_WINDOW_MS = 24 * 60 * 60 * 1000 /** * Claim a reward if the player is due one, returning how many of that type they have now @@ -97,10 +97,10 @@ export async function claimReward( } /** - * Add `amount` tokens to the player's daily allowance if it fits under `cap`, returning the + * Add `amount` 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. * - * A window older than {@link TOKEN_REWARD_WINDOW_MS} is reset in place (the row's key allows + * A window older than {@link ALLOWANCE_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 @@ -110,15 +110,16 @@ export async function claimReward( * 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 claimDailyTokens( +export async function claimAllowance( db: D1Database, accountId: number, + allowance: Allowance, 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 cutoff = new Date(now.getTime() - ALLOWANCE_WINDOW_MS).toISOString() const row = await db .prepare( `INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count) @@ -131,7 +132,7 @@ export async function claimDailyTokens( 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) + .bind(accountId, allowance, 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 7d708c7..4d7b9e0 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -6701,56 +6701,43 @@ describe('econ endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual([]) - // 5 XP is deliberately less than the 10 the first level costs, so one action moves the - // bar without levelling anyone up. - expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) + // 10 XP is exactly what level 1 costs, so a fresh player's first reward is a level-up. + expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 }) - // 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.) + // Two reward boxes: the XP itself, carrying the message the client asked to show and no + // item — a game reward is not an item — then the level-up's. (The tokens come in a box + // of their own.) const first = await rewardBoxes() - expect(first).toHaveLength(1) + expect(first).toHaveLength(2) expect(first[0]).toMatchObject({ - Xp: 5, + Xp: 10, Message: 'First Game of the Day', AvatarItemDesc: '', EquipmentModificationGuid: '', ConsumableItemDesc: '', }) - // The box, then the bar — no level-up box, since no level was crossed. + // The box, then the bar, then the level-up box. const frames = await rewardFrames() expect(frames.map((f) => f.notificationType)).toEqual([ NotificationType.GiftPackageReceivedImmediate, NotificationType.PlayerProgressionLevelUpdate, + NotificationType.GiftPackageReceivedImmediate, ]) expect(frames[0]?.accountId).toBe(82) expect(frames[0]?.payload).toMatchObject({ Id: first[0]?.Id, FromPlayerId: 1, - Xp: 5, + Xp: 10, // GiftContext.GameRewards — the box came from gameplay, not a purchase. GiftContext: 50, Message: 'First Game of the Day', }) - expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) + expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 2, XP: 0 }) - // 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(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 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 + // 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 rewardBoxes() - expect(afterLevel2).toHaveLength(3) - const clothingBox = afterLevel2[2] + const clothingBox = first[1] expect(clothingBox?.Message).toBe('Level 2!') expect(clothingBox?.AvatarItemDesc).not.toBe('') expect(clothingBox?.EquipmentModificationGuid).toBe('') @@ -6763,18 +6750,18 @@ 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 rewardFrames()).map((f) => f.notificationType)).toEqual([ - NotificationType.GiftPackageReceivedImmediate, - NotificationType.PlayerProgressionLevelUpdate, - NotificationType.GiftPackageReceivedImmediate, - ]) - // Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than + // An on-cooldown ask pays nothing: no more boxes, no frames, no more XP. + const boxCount = (await giftBoxes('82')).length + expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) + expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 }) + expect(await giftBoxes('82')).toHaveLength(boxCount) + expect(await drainFrames()).toEqual([]) + + // The next reward reaches 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 passADay() - expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200) - } + await passADay() + expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 }) const afterLevel3 = await giftBoxes('82') @@ -6809,12 +6796,14 @@ describe('econ endpoints', () => { ) expect(goblin.status).toBe(200) expect(await goblin.json()).toEqual([]) - // Two boxes: the quest reward, and the PostGameActivity tokens (covered below). + // Three boxes: the quest reward, the PostGameActivity tokens (covered below), and — 10 XP + // being level 1's whole cost — the level-2 box. const boxes = await giftBoxes('83') - expect(boxes).toHaveLength(2) + expect(boxes).toHaveLength(3) const box = boxes[0] - expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 }) + expect(box).toMatchObject({ Xp: 10, Message: 'Quest complete', GiftContext: 4003 }) expect(boxes[1]).toMatchObject({ GiftContext: 51, Currency: 250 }) + expect(boxes[2]?.Message).toBe('Level 2!') expect(box?.AvatarItemDesc).not.toBe('') const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc) expect(row).toBeDefined() @@ -6862,7 +6851,7 @@ describe('econ endpoints', () => { // 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 }) + expect(plain).toMatchObject({ Xp: 10, AvatarItemDesc: '', Currency: 0, GiftContext: 50 }) // A reward the player already owns is never drawn again: Dodgeball has three rows, so // three claims hand over all three, and a fourth — nothing left to give — pays the @@ -6976,6 +6965,86 @@ describe('econ endpoints', () => { expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true) }) + test('game-reward XP stops at 150 per player per 24h, FirstActivityOfDay aside', async () => { + const request = async (body: string) => + exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + method: 'POST', + headers: { + ...(await bearer('85')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + const xp = async () => { + const { Level, XP } = await getProgression(env.DB, 85) + return { Level, XP } + } + const allowance = () => + env.DB.prepare( + "SELECT granted_at, grant_count FROM reward_status WHERE account_id = 85 AND reward_type = '$xp'" + ).first<{ granted_at: string; grant_count: number }>() + const rewardBoxes = async () => + (await giftBoxes('85')).filter((b) => b.GiftContext !== 51 && !b.Message?.startsWith('Level')) + + // A claim's XP draws on the allowance. + expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) + expect(await allowance()).toMatchObject({ grant_count: 10 }) + expect(await xp()).toEqual({ Level: 2, XP: 0 }) + + // One claim short of the cap: the next pays its 10 and fills it. + await env.DB.prepare( + "UPDATE reward_status SET grant_count = 140 WHERE account_id = 85 AND reward_type = '$xp'" + ).run() + expect((await request('rewardType=PostGameActivity&giftContext=Paintball')).status).toBe(200) + expect(await allowance()).toMatchObject({ grant_count: 150 }) + expect(await xp()).toEqual({ Level: 3, XP: 0 }) + const boxesAtCap = (await rewardBoxes()).length + await drainFrames() + + // At the cap: no XP, no progression frame, and — nothing else to carry — no reward box. + // The tokens are their own allowance and still pay. + const tokensBefore = await getBalance( + env.DB, + 85, + CurrencyType.RecCenterTokens, + DEFAULT_STARTING_TOKENS + ) + expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200) + expect(await xp()).toEqual({ Level: 3, XP: 0 }) + expect(await allowance()).toMatchObject({ grant_count: 150 }) + expect(await rewardBoxes()).toHaveLength(boxesAtCap) + expect( + await getBalance(env.DB, 85, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).toBe(tokensBefore + 250) + expect((await drainFrames()).map((f) => f.notificationType)).not.toContain( + NotificationType.PlayerProgressionLevelUpdate + ) + + // An activity with a prize still hands it over, in a box carrying no XP. + expect( + (await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status + ).toBe(200) + const quest = (await rewardBoxes()).at(-1) + expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 }) + expect(quest?.AvatarItemDesc).not.toBe('') + expect(await xp()).toEqual({ Level: 3, XP: 0 }) + + // FirstActivityOfDay's XP is a bonus on top of the cap. + expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200) + expect(await xp()).toEqual({ Level: 4, XP: 0 }) + expect(await allowance()).toMatchObject({ grant_count: 150 }) + + // 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 = 85 AND reward_type = '$xp'" + ) + .bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString()) + .run() + expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200) + expect(await allowance()).toMatchObject({ grant_count: 10 }) + expect(await xp()).toEqual({ Level: 4, XP: 10 }) + }) + test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { method: 'POST',