[econ] tweak xp rewards

This commit is contained in:
Devin Zuczek
2026-10-14 14:49:28 -04:00
parent 0f757b2c4f
commit 6f8e1182e8
4 changed files with 202 additions and 93 deletions
+14 -9
View File
@@ -52,7 +52,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
@@ -486,7 +486,7 @@ activity asked, so its row's `granted_at` is simply the player's last one.
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
before it keep counting.
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
**What a claim pays: 10 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
`progression` and the box is the wrapper the client shows for it — no item, every item field
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
@@ -495,21 +495,26 @@ failure can't leave a box promising XP nobody was credited.
- **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
`PostGameActivity` is a map keyed by type.
- **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.
- **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh
player's first rewards each level them up; from level 4 (20 XP a level) it takes two or
more, and the daily XP cap paces it from there.
- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
(`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the
`giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day
claim already limits it, so it has no allowance row. The running total
is one row per player in `reward_status` under the reserved type `$tokens` (context `''`):
`grant_count` is the TOKENS paid in the window and `granted_at` when the window opened.
`claimDailyTokens` adds to it in one atomic upsert, refusing a grant that would cross the cap
`claimAllowance` adds to it in one atomic upsert, refusing a grant that would cross the cap
(all or nothing, so the cap is a multiple of the amount), and resets it in place once the
window is 24h old — a rolling window, so no time zone's midnight decides 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.
- **XP is capped too: 150 a day** (`GAME_REWARD_DAILY_XP_CAP`), fifteen claims' worth, on the
same machinery — the player's `$xp` row, `grant_count` the XP paid in the window. Every
type's XP draws on it but `FirstActivityOfDay`'s, a bonus on top like its tokens. Past the
cap a claim still counts against its cooldown and still pays its activity's item (in a box
with `Xp` 0) and its tokens; a claim with nothing left to carry gets no box at all.
- **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.
@@ -549,7 +554,7 @@ costs are easy to edit one at a time and hard to eyeball as a curve.
| 50 | 5-Star Clothing (rarity 50) — the only one |
**One reward per level crossed** — a grant spanning several levels pays each of them. In
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
practice a 10 XP game reward crosses at most one, so the first reward a fresh player claims
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
arrives as a gift box announced like any other (`Level 2!`).
@@ -652,8 +657,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (equipment, room consumables/currencies) are empty-list stubs pending
their own stores.
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
reference caps activity XP per day in `daily_xp_ledgers`).
- Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day
in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
level section); we grant from the table and leave the config as captured, so a client that
previews an upcoming reward would preview the config's answer, not ours.
+61 -27
View File
@@ -168,7 +168,7 @@ import {
WishlistItemList,
WishlistItemRequest,
} from './openapi'
import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import {
awardRoomConsumable,
getRoomConsumable,
@@ -2264,15 +2264,24 @@ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is B
}
/**
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
* same size of pat on the back until there's reason to price them apart.
* XP paid for a claimed game reward. One flat amount for every reward type — "First Game of
* the Day" and "Activity completed!" are the same size of pat on the back until there's
* reason to price them apart.
*
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
* levels are paced by the hourly cooldown rather than cleared in one match.
* Exactly what each of the first three levels costs, so a fresh player's first rewards each
* level them up; from level 4 (20 XP a level) it takes two or more. The daily
* {@link GAME_REWARD_DAILY_XP_CAP} paces it from there.
*/
const GAME_REWARD_XP = 5
const GAME_REWARD_XP = 10
/**
* The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth,
* whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like
* its tokens. The running total is the player's `$xp` row in `reward_status`
* (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays
* no XP.
*/
const GAME_REWARD_DAILY_XP_CAP = 150
/**
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
@@ -2287,7 +2296,7 @@ const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
/**
* Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
* {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
* The running total is the player's `$tokens` row in `reward_status` (`claimDailyTokens`).
* The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`).
*/
const GAME_REWARD_TOKENS = 250
@@ -2347,7 +2356,7 @@ function toGameRewardTokensDrop(): StoreGiftDrop {
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
* the copy here is what the box and its notification display.
*/
function toGameRewardDrop(): StoreGiftDrop {
function toGameRewardDrop(xp: number): StoreGiftDrop {
return {
FriendlyName: '',
Tooltip: '',
@@ -2360,7 +2369,7 @@ function toGameRewardDrop(): StoreGiftDrop {
Context: GIFT_CONTEXT_GAME_REWARDS,
Currency: 0,
CurrencyType: 0,
Xp: GAME_REWARD_XP,
Xp: xp,
}
}
@@ -2425,10 +2434,14 @@ async function pickQuestReward(
* 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.
* The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
* the XP is the same pat on the back every claim gets while the daily cap has room.
*/
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
function toQuestRewardDrop(
reward: QuestReward,
catalog: CatalogRow | null,
xp: number
): StoreGiftDrop {
return {
FriendlyName: catalog?.friendly_name ?? '',
Tooltip: catalog?.tooltip ?? '',
@@ -2441,7 +2454,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto
Context: reward.Context,
Currency: reward.Currency,
CurrencyType: reward.CurrencyType,
Xp: GAME_REWARD_XP,
Xp: xp,
}
}
@@ -6051,7 +6064,8 @@ const app = new Hono<App>({ strict: false })
// here, from `reward_status`: one claim per type per activity per hour, atomically —
// except FirstActivityOfDay, one per player per 24h.
//
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
// A claim pays GAME_REWARD_XP into `progression` (up to GAME_REWARD_DAILY_XP_CAP per player
// per rolling 24h, FirstActivityOfDay's on top) and hands over a gift box carrying that
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
// nothing and pays nothing.
@@ -6119,17 +6133,27 @@ const app = new Hono<App>({ strict: false })
typeof body.Message === 'string' && body.Message !== ''
? body.Message
: DEFAULT_GAME_REWARD_MESSAGE
// The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top,
// its once-a-day claim being limit enough. Past the cap the claim pays no XP.
const xpToday = daily
? null
: await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP)
const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0
// 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 gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null
// 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)
? toGameRewardDrop(xp)
: toQuestRewardDrop(
questReward,
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
xp
)
// 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.
@@ -6145,17 +6169,24 @@ const app = new Hono<App>({ strict: false })
)
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
}
// A box with nothing in it — the XP capped and no item or currency to carry — is not
// handed over at all.
let rewardGiftId: number | null = null
if (questReward !== null || xp > 0) {
const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
rewardGiftId = granted.id
}
// The tokens ride in a second box — after the reward's own, so the player sees the
// reward they asked for first — while the player's daily allowance has room. Same
// order as the currency reward above: balance first, then the box that shows it.
let tokensGiftId: number | null = null
let tokensToday: number | null = null
if (TOKEN_REWARD_TYPES.has(rewardType)) {
tokensToday = await claimDailyTokens(
tokensToday = await claimAllowance(
c.env.DB,
id,
'$tokens',
GAME_REWARD_TOKENS,
GAME_REWARD_DAILY_TOKEN_CAP
)
@@ -6176,21 +6207,24 @@ const app = new Hono<App>({ strict: false })
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
tokensGiftId = tokensGift.id
}
if (gained !== null) {
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
await pushProgressionUpdate(c, id, gained.progression)
// …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, { progression, levelsGained })
await grantLevelUpGifts(c, id, gained)
}
logger.info('game reward claimed', {
accountId: id,
rewardType,
giftContext,
grantCount: claimed,
message,
xp: GAME_REWARD_XP,
level: progression.Level,
levelsGained,
levelXp: progression.XP,
giftId: granted.id,
xp,
xpToday,
level: gained?.progression.Level,
levelsGained: gained?.levelsGained,
levelXp: gained?.progression.XP,
giftId: rewardGiftId,
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
tokensToday,
tokensGiftId,
+14 -13
View File
@@ -39,19 +39,19 @@ export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
export const DAILY_REWARD_COOLDOWN_MS = 24 * 60 * 60 * 1000
/**
* The game reward's tokens are a per-PLAYER daily allowance, kept in `reward_status` as one
* row under this reserved type and the empty context: `grant_count` is the TOKENS paid in the
* current window (not a count of claims) and `granted_at` is when that window opened. The `$`
* keeps it apart from the client's own type names (`FirstActivityOfDay`, `PostGameActivity`),
* which a client-sent type could otherwise forge.
* The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under
* its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current
* window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened.
* The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`,
* `PostGameActivity`), which a client-sent type could otherwise forge.
*/
export const TOKEN_REWARD_TYPE = '$tokens'
export type Allowance = '$tokens' | '$xp'
/**
* How long a token window lasts: a rolling 24h from the first grant in it, rather than a
* How long an allowance window lasts: a rolling 24h from the first grant in it, rather than a
* calendar day — so no time zone's midnight decides when a player's allowance comes back.
*/
export const TOKEN_REWARD_WINDOW_MS = 24 * 60 * 60 * 1000
export const ALLOWANCE_WINDOW_MS = 24 * 60 * 60 * 1000
/**
* Claim a reward if the player is due one, returning how many of that type they have now
@@ -97,10 +97,10 @@ export async function claimReward(
}
/**
* Add `amount` tokens to the player's daily allowance if it fits under `cap`, returning the
* Add `amount` to the player's daily `allowance` if it fits under `cap`, returning the
* window's new total — or `null` when it doesn't fit and nothing was granted.
*
* A window older than {@link TOKEN_REWARD_WINDOW_MS} is reset in place (the row's key allows
* A window older than {@link ALLOWANCE_WINDOW_MS} is reset in place (the row's key allows
* one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the
* amount is added to the running total, and `granted_at` is left alone so the window keeps its
* start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the
@@ -110,15 +110,16 @@ export async function claimReward(
* read a total under the cap and both pay. SQLite evaluates every `SET` against the row as it
* was, so both `CASE`s see the old `granted_at`.
*/
export async function claimDailyTokens(
export async function claimAllowance(
db: D1Database,
accountId: number,
allowance: Allowance,
amount: number,
cap: number,
now: Date = new Date()
): Promise<number | null> {
if (amount > cap) return null
const cutoff = new Date(now.getTime() - TOKEN_REWARD_WINDOW_MS).toISOString()
const cutoff = new Date(now.getTime() - ALLOWANCE_WINDOW_MS).toISOString()
const row = await db
.prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
@@ -131,7 +132,7 @@ export async function claimDailyTokens(
WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
RETURNING grant_count`
)
.bind(accountId, TOKEN_REWARD_TYPE, now.toISOString(), amount, cutoff, cap)
.bind(accountId, allowance, now.toISOString(), amount, cutoff, cap)
.first<{ grant_count: number }>()
return row?.grant_count ?? null
}
+108 -39
View File
@@ -6701,56 +6701,43 @@ describe('econ endpoints', () => {
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
// bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// 10 XP is exactly what level 1 costs, so a fresh player's first reward is a level-up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// One reward box: the XP itself, carrying the message the client asked to show and no
// item — a game reward is not an item. (Its tokens come in a box of their own.)
// Two reward boxes: the XP itself, carrying the message the client asked to show and no
// item — a game reward is not an item — then the level-up's. (The tokens come in a box
// of their own.)
const first = await rewardBoxes()
expect(first).toHaveLength(1)
expect(first).toHaveLength(2)
expect(first[0]).toMatchObject({
Xp: 5,
Xp: 10,
Message: 'First Game of the Day',
AvatarItemDesc: '',
EquipmentModificationGuid: '',
ConsumableItemDesc: '',
})
// The box, then the bar — no level-up box, since no level was crossed.
// The box, then the bar, then the level-up box.
const frames = await rewardFrames()
expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
])
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({
Id: first[0]?.Id,
FromPlayerId: 1,
Xp: 5,
Xp: 10,
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50,
Message: 'First Game of the Day',
})
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(2)
expect(await drainFrames()).toEqual([])
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
// is the pacing the smaller grant buys.
await passADay()
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
// Level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
// equipment skin, which is what the avatar-only roll is for.
const afterLevel2 = await rewardBoxes()
expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[2]
const clothingBox = first[1]
expect(clothingBox?.Message).toBe('Level 2!')
expect(clothingBox?.AvatarItemDesc).not.toBe('')
expect(clothingBox?.EquipmentModificationGuid).toBe('')
@@ -6763,18 +6750,18 @@ describe('econ endpoints', () => {
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
expect((await rewardFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
])
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
const boxCount = (await giftBoxes('82')).length
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
expect(await giftBoxes('82')).toHaveLength(boxCount)
expect(await drainFrames()).toEqual([])
// The next reward reaches level 3, which the table pays as a CONSUMABLE rather than
// clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) {
await passADay()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82')
@@ -6809,12 +6796,14 @@ describe('econ endpoints', () => {
)
expect(goblin.status).toBe(200)
expect(await goblin.json()).toEqual([])
// Two boxes: the quest reward, and the PostGameActivity tokens (covered below).
// Three boxes: the quest reward, the PostGameActivity tokens (covered below), and — 10 XP
// being level 1's whole cost — the level-2 box.
const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(2)
expect(boxes).toHaveLength(3)
const box = boxes[0]
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
expect(box).toMatchObject({ Xp: 10, Message: 'Quest complete', GiftContext: 4003 })
expect(boxes[1]).toMatchObject({ GiftContext: 51, Currency: 250 })
expect(boxes[2]?.Message).toBe('Level 2!')
expect(box?.AvatarItemDesc).not.toBe('')
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
expect(row).toBeDefined()
@@ -6862,7 +6851,7 @@ describe('econ endpoints', () => {
// GameRewards box, behind Bowling's own tokens.
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50)
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
expect(plain).toMatchObject({ Xp: 10, 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
@@ -6976,6 +6965,86 @@ describe('econ endpoints', () => {
expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true)
})
test('game-reward XP stops at 150 per player per 24h, FirstActivityOfDay aside', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('85')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
const xp = async () => {
const { Level, XP } = await getProgression(env.DB, 85)
return { Level, XP }
}
const allowance = () =>
env.DB.prepare(
"SELECT granted_at, grant_count FROM reward_status WHERE account_id = 85 AND reward_type = '$xp'"
).first<{ granted_at: string; grant_count: number }>()
const rewardBoxes = async () =>
(await giftBoxes('85')).filter((b) => b.GiftContext !== 51 && !b.Message?.startsWith('Level'))
// A claim's XP draws on the allowance.
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
expect(await allowance()).toMatchObject({ grant_count: 10 })
expect(await xp()).toEqual({ Level: 2, XP: 0 })
// One claim short of the cap: the next pays its 10 and fills it.
await env.DB.prepare(
"UPDATE reward_status SET grant_count = 140 WHERE account_id = 85 AND reward_type = '$xp'"
).run()
expect((await request('rewardType=PostGameActivity&giftContext=Paintball')).status).toBe(200)
expect(await allowance()).toMatchObject({ grant_count: 150 })
expect(await xp()).toEqual({ Level: 3, XP: 0 })
const boxesAtCap = (await rewardBoxes()).length
await drainFrames()
// At the cap: no XP, no progression frame, and — nothing else to carry — no reward box.
// The tokens are their own allowance and still pay.
const tokensBefore = await getBalance(
env.DB,
85,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
expect(await xp()).toEqual({ Level: 3, XP: 0 })
expect(await allowance()).toMatchObject({ grant_count: 150 })
expect(await rewardBoxes()).toHaveLength(boxesAtCap)
expect(
await getBalance(env.DB, 85, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(tokensBefore + 250)
expect((await drainFrames()).map((f) => f.notificationType)).not.toContain(
NotificationType.PlayerProgressionLevelUpdate
)
// An activity with a prize still hands it over, in a box carrying no XP.
expect(
(await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status
).toBe(200)
const quest = (await rewardBoxes()).at(-1)
expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 })
expect(quest?.AvatarItemDesc).not.toBe('')
expect(await xp()).toEqual({ Level: 3, XP: 0 })
// FirstActivityOfDay's XP is a bonus on top of the cap.
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
expect(await xp()).toEqual({ Level: 4, XP: 0 })
expect(await allowance()).toMatchObject({ grant_count: 150 })
// Once the window is 24h old, the next claim opens a fresh one.
await env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 85 AND reward_type = '$xp'"
)
.bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200)
expect(await allowance()).toMatchObject({ grant_count: 10 })
expect(await xp()).toEqual({ Level: 4, XP: 10 })
})
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',