mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[econ] better quest rewards (for now until chests figured out)
This commit is contained in:
+116
-3
@@ -40,6 +40,7 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
|
|||||||
import defaultAvatar from '../static/default-avatar.json'
|
import defaultAvatar from '../static/default-avatar.json'
|
||||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||||
import myProgress from '../static/my-progress.json'
|
import myProgress from '../static/my-progress.json'
|
||||||
|
import questRewards from '../static/quest-rewards.json'
|
||||||
import { getAvatar, setAvatar } from './avatar-db'
|
import { getAvatar, setAvatar } from './avatar-db'
|
||||||
import {
|
import {
|
||||||
ALL_PLATFORMS,
|
ALL_PLATFORMS,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
|
import { getCatalogItem } from './catalog-db'
|
||||||
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
// `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
|
// 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.
|
// 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
|
* 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
|
* 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
|
// 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
|
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
||||||
// sends no context keys on `''`.
|
// 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(
|
.post(
|
||||||
'/api/gamerewards/v1/request',
|
'/api/gamerewards/v1/request',
|
||||||
describeRoute({
|
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',
|
'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',
|
'`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',
|
'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',
|
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
|
||||||
'(on-cooldown) ask both answer `[]`.',
|
'(`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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
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.
|
// 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.
|
// 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 { 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)
|
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||||||
// Every grant moves the bar, whether or not it crossed a level.
|
// Every grant moves the bar, whether or not it crossed a level.
|
||||||
await pushProgressionUpdate(c, id, progression)
|
await pushProgressionUpdate(c, id, progression)
|
||||||
|
|||||||
@@ -592,7 +592,9 @@ export const GameRewardRequest = z.object({
|
|||||||
giftContext: z
|
giftContext: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import avatarItemsJson from '../../../static/db/avatar-items.json'
|
|||||||
// caller's build, and these assertions are about the file's CONTENTS.
|
// caller's build, and these assertions are about the file's CONTENTS.
|
||||||
import carriedItems from '../../../static/db/consumables.json'
|
import carriedItems from '../../../static/db/consumables.json'
|
||||||
import skinsJson from '../../../static/db/skins.json'
|
import skinsJson from '../../../static/db/skins.json'
|
||||||
|
import questRewards from '../../../static/quest-rewards.json'
|
||||||
import sf32025 from '../../../static/storefronts/sf3-2025.json'
|
import sf32025 from '../../../static/storefronts/sf3-2025.json'
|
||||||
import sf3 from '../../../static/storefronts/sf3.json'
|
import sf3 from '../../../static/storefronts/sf3.json'
|
||||||
import { SCHEMA_DDL } from '../../avatar-db'
|
import { SCHEMA_DDL } from '../../avatar-db'
|
||||||
@@ -3113,6 +3114,7 @@ describe('econ endpoints', () => {
|
|||||||
AvatarItemDesc: string
|
AvatarItemDesc: string
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
GiftRarity: number
|
GiftRarity: number
|
||||||
|
GiftContext: number
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3512,6 +3514,92 @@ describe('econ endpoints', () => {
|
|||||||
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
|
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 () => {
|
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`, {
|
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user