[econ] add new activity bonus tokens

This commit is contained in:
Devin Zuczek
2026-09-22 18:41:59 -04:00
parent 811a42f189
commit 70fd0896c5
4 changed files with 188 additions and 8 deletions
+10
View File
@@ -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.
+70 -3
View File
@@ -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<App>({ 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<App>({ 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<App>({ 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<App>({ strict: false })
levelsGained,
levelXp: progression.XP,
giftId: granted.id,
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
bonusGiftId,
})
return c.json([])
}
+27
View File
@@ -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<boolean> {
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
}
+81 -5
View File
@@ -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',