From 70fd0896c59c5a9b7d8e71ed6edada4a6d93bc9b Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 22 Sep 2026 18:41:59 -0400 Subject: [PATCH] [econ] add new activity bonus tokens --- apps/econ/README.md | 10 +++ apps/econ/src/econ.app.ts | 73 +++++++++++++++++- apps/econ/src/reward-db.ts | 27 +++++++ apps/econ/src/test/integration/api.test.ts | 86 ++++++++++++++++++++-- 4 files changed, 188 insertions(+), 8 deletions(-) diff --git a/apps/econ/README.md b/apps/econ/README.md index b1eff35..b4106f6 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -487,6 +487,16 @@ failure can't leave a box promising XP nobody was credited. - **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. - **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 e51d18b..b1b160d 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -147,7 +147,7 @@ import { UpdateRoomCurrencyRequest, UpsertRoomConsumableRequest, } from './openapi' -import { claimReward } from './reward-db' +import { claimReward, isNewActivity } from './reward-db' import { awardRoomConsumable, getRoomConsumable, @@ -2141,6 +2141,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50 /** Shown on the box when the client asks for a reward without saying what to call it. */ 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. + */ +const NEW_ACTIVITY_BONUS_TOKENS = 250 + +/** + * `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS}, + * on the box the new-activity bonus 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 gift-drop the new-activity bonus 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 { + return { + FriendlyName: '', + Tooltip: '', + ConsumableItemDesc: '', + AvatarItemDesc: '', + AvatarItemType: null, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + Rarity: 0, + Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS, + Currency: NEW_ACTIVITY_BONUS_TOKENS, + CurrencyType: CurrencyType.RecCenterTokens, + Xp: 0, + } +} + /** * The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is * empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants @@ -5325,6 +5365,10 @@ const app = new Hono({ strict: false }) // (`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. .post( '/api/gamerewards/v1/request', describeRoute({ @@ -5336,8 +5380,9 @@ 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 reward rides in a gift box, so a claim and a', - 'rejected (on-cooldown) ask both answer `[]`.', + '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 `[]`.', ].join(' '), security: AUTHED, requestBody: form(GameRewardRequest, 'The reward type and its display message'), @@ -5389,6 +5434,26 @@ 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( + c.env.DB, + id, + bonus.CurrencyType, + bonus.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 + } // 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. @@ -5404,6 +5469,8 @@ const app = new Hono({ strict: false }) levelsGained, levelXp: progression.XP, giftId: granted.id, + newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS, + bonusGiftId, }) return c.json([]) } diff --git a/apps/econ/src/reward-db.ts b/apps/econ/src/reward-db.ts index a74de3c..8f5abab 100644 --- a/apps/econ/src/reward-db.ts +++ b/apps/econ/src/reward-db.ts @@ -78,3 +78,30 @@ export async function claimReward( .first<{ grant_count: number }>() return row?.grant_count ?? null } + +/** + * 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. + * + * 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. + */ +export async function isNewActivity( + db: D1Database, + accountId: number, + giftContext: string, + grantCount: number +): Promise { + if (giftContext === '' || grantCount !== 1) return false + 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 +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 927c93d..fc9e860 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -4770,6 +4770,8 @@ describe('econ endpoints', () => { ConsumableItemDesc: string GiftRarity: number GiftContext: number + Currency: number + CurrencyType: number }> } @@ -5187,10 +5189,13 @@ 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. const boxes = await giftBoxes('83') - expect(boxes).toHaveLength(1) + expect(boxes).toHaveLength(2) const box = boxes[0] expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 }) + expect(boxes[1]).toMatchObject({ GiftContext: 51, Currency: 250 }) expect(box?.AvatarItemDesc).not.toBe('') const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc) expect(row).toBeDefined() @@ -5220,7 +5225,9 @@ describe('econ endpoints', () => { expect( await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS) ).toBe(before + 50) - const ticketBox = (await giftBoxes('83'))[1] + const ticketBox = (await giftBoxes('83')).find( + (b) => b.CurrencyType === CurrencyType.LaserTagTickets + ) expect(ticketBox).toMatchObject({ Currency: 50, CurrencyType: CurrencyType.LaserTagTickets, @@ -5232,10 +5239,10 @@ describe('econ endpoints', () => { NotificationType.StorefrontBalanceUpdate ) - // An activity the table doesn't know pays the plain XP box, as before. (The LAST box: - // the two claims above also crossed level 1, and that level-up box sits in between.) + // 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. expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200) - const plain = (await giftBoxes('83')).at(-1) + const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50) expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 }) // A reward the player already owns is never drawn again: Dodgeball has three rows, so @@ -5257,6 +5264,75 @@ 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 () => { + const request = async (body: string) => + exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + method: 'POST', + headers: { + ...(await bearer('84')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + const tokens = () => + getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + const bonusBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51) + 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) + + // First time in Soccer: +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({ + Currency: 250, + CurrencyType: CurrencyType.RecCenterTokens, + Xp: 0, + AvatarItemDesc: '', + Message: 'New 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.) + const frames = await drainFrames() + expect( + frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload + ).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 }) + expect( + frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate) + ).toContainEqual( + expect.objectContaining({ + payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: bonus[0]?.Id }), + }) + ) + + // 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. + await env.DB.prepare( + "UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND gift_context = 'Soccer'" + ) + .bind(new Date(Date.now() - 61 * 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) + }) + 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',