From 3be206652689c48c2bc51713ae53c6ac95c9c4c0 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 30 Aug 2026 02:46:08 -0400 Subject: [PATCH] [econ] better quest rewards (for now until chests figured out) --- apps/econ/src/econ.app.ts | 119 ++++++++++++++++++++- apps/econ/src/openapi.ts | 4 +- apps/econ/src/test/integration/api.test.ts | 88 +++++++++++++++ 3 files changed, 207 insertions(+), 4 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 0149d36..651c075 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -40,6 +40,7 @@ import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' import defaultBaseAvatarItems from '../static/default-base-avatar-items.json' import myProgress from '../static/my-progress.json' +import questRewards from '../static/quest-rewards.json' import { getAvatar, setAvatar } from './avatar-db' import { ALL_PLATFORMS, @@ -51,6 +52,7 @@ import { isSpendable, spendCurrency, } from './balance-db' +import { getCatalogItem } from './catalog-db' // `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks // which store FILE a caller is served here, and which ITEMS go in that file there. The two must // name the same moment or a build gets a store built to a different cutoff. @@ -1723,6 +1725,87 @@ function toGameRewardDrop(): StoreGiftDrop { } } +/** + * One row of `static/quest-rewards.json`: the reward table of the live game's activities, + * keyed by the `giftContext` the client posts with a game-reward ask (`Dodgeball`, + * `Quest_Goblin_S`, `Paintball_Dam`, …). Each row is the gift-drop as the game's own reward + * server shaped it — a comma-laden `AvatarItemDesc` (the catalog's `item_key`), or for the + * Laser Tag entry a currency payout — with `GiftRarity` and the activity's own `Context` + * (8000 for dodgeball, 4003 for the goblin quest's S rank) spelled the way the client's box + * reads them. Untyped fields (`Id`, `Level`, `Message`) are carried but unused. + */ +interface QuestReward { + AvatarItemDesc: string + ConsumableItemDesc: string + EquipmentPrefabName: string + EquipmentModificationGuid: string + CurrencyType: number + Currency: number + Xp: number + GiftRarity: number + Context: number +} + +const QUEST_REWARDS: Record = questRewards + +/** + * The reward an activity pays, when `giftContext` names an entry in `quest-rewards.json`: + * one row drawn at random from that key's list, among the rows the player DOESN'T ALREADY + * OWN — the table is "what this activity can give you", and handing over a duplicate gives + * nothing (the inventory is a set). A currency row is never "owned", so it always stays in + * the pool. + * + * Null for a context the table doesn't know (or one whose every reward the player already + * has), which the caller pays as the plain XP box — the cooldown key is the same string + * either way, so an unknown or exhausted context is still rate-limited. + */ +async function pickQuestReward( + db: D1Database, + accountId: number, + giftContext: string +): Promise { + if (!Object.hasOwn(QUEST_REWARDS, giftContext)) return null + const rows = QUEST_REWARDS[giftContext] ?? [] + if (rows.length === 0) return null + const ownedItems = new Set((await getInventory(db, accountId)).map((i) => i.AvatarItemDesc)) + const ownedGuids = new Set((await getEquipment(db, accountId)).map((e) => e.ModificationGuid)) + const pool = rows.filter( + (r) => + !(r.AvatarItemDesc !== '' && ownedItems.has(r.AvatarItemDesc)) && + !(r.EquipmentModificationGuid !== '' && ownedGuids.has(r.EquipmentModificationGuid)) + ) + if (pool.length === 0) { + logger.info('quest rewards exhausted for player', { accountId, giftContext }) + return null + } + return pool[Math.floor(Math.random() * pool.length)] ?? null +} + +/** + * A quest reward as the gift-drop `grantGiftDrop` hands over. The item fields come off the + * 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. + */ +function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop { + return { + FriendlyName: catalog?.friendly_name ?? '', + Tooltip: catalog?.tooltip ?? '', + ConsumableItemDesc: reward.ConsumableItemDesc, + AvatarItemDesc: reward.AvatarItemDesc, + AvatarItemType: catalog?.avatar_item_type ?? null, + EquipmentPrefabName: reward.EquipmentPrefabName, + EquipmentModificationGuid: reward.EquipmentModificationGuid, + Rarity: reward.GiftRarity, + Context: reward.Context, + Currency: reward.Currency, + CurrencyType: reward.CurrencyType, + Xp: GAME_REWARD_XP, + } +} + /** * The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from * AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize @@ -3737,6 +3820,11 @@ const app = new Hono({ strict: false }) // 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 `''`. + // + // 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. .post( '/api/gamerewards/v1/request', describeRoute({ @@ -3746,8 +3834,10 @@ const app = new Hono({ strict: false }) 'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in', '`reward_status`. The cooldown is per (type, activity), so a different activity is', 'owed another reward while the same one is not; an ask with no `giftContext` keys on', - 'the empty context. The reward rides in a gift box, so a claim and a rejected', - '(on-cooldown) ask both answer `[]`.', + '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 `[]`.', ].join(' '), security: AUTHED, requestBody: form(GameRewardRequest, 'The reward type and its display message'), @@ -3774,7 +3864,30 @@ const app = new Hono({ strict: false }) // 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 granted = await grantGiftDrop(c, id, toGameRewardDrop(), message) + // 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) + // 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. + 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, + id, + drop.CurrencyType, + drop.Currency, + startingTokens + ) + await pushBalanceUpdate(c, id, drop.CurrencyType, balance) + } + const granted = await grantGiftDrop(c, id, drop, message) await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) // Every grant moves the bar, whether or not it crossed a level. await pushProgressionUpdate(c, id, progression) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index efb6992..027e4d8 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -592,7 +592,9 @@ export const GameRewardRequest = z.object({ giftContext: z .string() .optional() - .describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'), + .describe( + 'The activity it came from, e.g. `Soccer` — part of the cooldown key. A key of `quest-rewards.json` (`Dodgeball`, `Quest_Goblin_S`, …) also picks the prize from that activity’s table' + ), }) /** diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 8159253..0f49fde 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -33,6 +33,7 @@ import avatarItemsJson from '../../../static/db/avatar-items.json' // caller's build, and these assertions are about the file's CONTENTS. import carriedItems from '../../../static/db/consumables.json' import skinsJson from '../../../static/db/skins.json' +import questRewards from '../../../static/quest-rewards.json' import sf32025 from '../../../static/storefronts/sf3-2025.json' import sf3 from '../../../static/storefronts/sf3.json' import { SCHEMA_DDL } from '../../avatar-db' @@ -3113,6 +3114,7 @@ describe('econ endpoints', () => { AvatarItemDesc: string ConsumableItemDesc: string GiftRarity: number + GiftContext: number }> } @@ -3512,6 +3514,92 @@ describe('econ endpoints', () => { expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc) }) + test('a giftContext naming a quest-rewards.json key pays one of that activity’s rewards', async () => { + const request = async (body: string) => + exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + method: 'POST', + headers: { + ...(await bearer('83')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + await drainFrames() + + // Quest_Goblin_S: forty avatar-item rewards, all at the goblin quest's S-rank context. + const goblin = await request( + 'rewardType=PostGameActivity&Message=Quest%20complete&giftContext=Quest_Goblin_S' + ) + expect(goblin.status).toBe(200) + expect(await goblin.json()).toEqual([]) + const boxes = await giftBoxes('83') + expect(boxes).toHaveLength(1) + const box = boxes[0] + expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 }) + expect(box?.AvatarItemDesc).not.toBe('') + const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc) + expect(row).toBeDefined() + expect(box?.GiftRarity).toBe(row?.GiftRarity) + // …and the item is in the inventory, not just on the box. + const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { + headers: await bearer('83'), + }) + const owned = (await items.json()) as Array<{ avatarItemDesc: string }> + expect(owned.map((i) => i.avatarItemDesc)).toContain(box?.AvatarItemDesc) + // The box announces the activity's context, not the generic GameRewards one. + const frames = await drainFrames() + expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate) + expect(frames[0]?.payload).toMatchObject({ + GiftContext: 4003, + AvatarItemDesc: box?.AvatarItemDesc, + }) + + // Lasertag's single reward is 50 Laser Tag tickets: credited to the balance, no item. + const before = await getBalance( + env.DB, + 83, + CurrencyType.LaserTagTickets, + DEFAULT_STARTING_TOKENS + ) + expect((await request('rewardType=PostGameActivity&giftContext=Lasertag')).status).toBe(200) + expect( + await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS) + ).toBe(before + 50) + const ticketBox = (await giftBoxes('83'))[1] + expect(ticketBox).toMatchObject({ + Currency: 50, + CurrencyType: CurrencyType.LaserTagTickets, + AvatarItemDesc: '', + GiftContext: 9000, + }) + const ticketFrames = await drainFrames() + expect(ticketFrames.map((f) => f.notificationType)).toContain( + 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.) + expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200) + const plain = (await giftBoxes('83')).at(-1) + 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 + // three claims hand over all three, and a fourth — nothing left to give — pays the + // plain XP box rather than a duplicate. + const dodgeball = questRewards.Dodgeball.map((r) => r.AvatarItemDesc) + const handed: string[] = [] + for (let i = 0; i < 4; i++) { + await env.DB.prepare( + "DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'" + ).run() + expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200) + const latest = (await giftBoxes('83')).findLast((b) => b.GiftContext === 8000 || b.GiftContext === 50) + if (i < 3) handed.push(latest?.AvatarItemDesc as string) + else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 }) + } + expect(handed.toSorted()).toEqual(dodgeball.toSorted()) + }) + 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',