[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
+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',