[econ] better quest rewards (for now until chests figured out)

This commit is contained in:
Devin Zuczek
2026-08-30 02:46:08 -04:00
parent 8e452f23eb
commit 3be2066526
3 changed files with 207 additions and 4 deletions
+116 -3
View File
@@ -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<string, QuestReward[]> = 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<QuestReward | null> {
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<App>({ 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<App>({ 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 activitys 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<App>({ 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)
+3 -1
View File
@@ -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 activitys table'
),
})
/**
@@ -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 activitys 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',