[econ] change token award to once per activity per day

This commit is contained in:
Devin Zuczek
2026-10-14 14:04:32 -04:00
parent eb5c0d28ff
commit 0f757b2c4f
4 changed files with 258 additions and 142 deletions
+79 -43
View File
@@ -168,7 +168,7 @@ import {
WishlistItemList,
WishlistItemRequest,
} from './openapi'
import { claimReward, isNewActivity } from './reward-db'
import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import {
awardRoomConsumable,
getRoomConsumable,
@@ -2285,29 +2285,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
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.
* 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`).
*/
const NEW_ACTIVITY_BONUS_TOKENS = 250
const GAME_REWARD_TOKENS = 250
/**
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
*/
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
/**
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
*/
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
/**
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
* daily cap: the claim itself is what limits it, so it needs no allowance row.
*/
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
/**
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
* on the box the new-activity bonus arrives in.
* on the box the token payout 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 message on the token payout's box. */
const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
/**
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like
* The gift-drop a claimed game reward's token payout 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 {
function toGameRewardTokensDrop(): StoreGiftDrop {
return {
FriendlyName: '',
Tooltip: '',
@@ -2318,7 +2335,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
EquipmentModificationGuid: '',
Rarity: 0,
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
Currency: NEW_ACTIVITY_BONUS_TOKENS,
Currency: GAME_REWARD_TOKENS,
CurrencyType: CurrencyType.RecCenterTokens,
Xp: 0,
}
@@ -6031,7 +6048,8 @@ const app = new Hono<App>({ strict: false })
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
// here, from `reward_status`: one claim per type per activity per hour, atomically.
// 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
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
@@ -6043,19 +6061,20 @@ const app = new Hono<App>({ strict: false })
// reference answers its own (different, selection-based) flow with a success envelope,
// not a list of rewards.
//
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
// 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 `''`.
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, 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 `''` — as FirstActivityOfDay always
// does, being the player's first activity of the day, not each activity's.
//
// 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.
//
// 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.
// A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
// GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
// FirstActivityOfDay claim pays them on top of the cap — 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({
@@ -6067,9 +6086,11 @@ 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 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 `[]`.',
'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
'the `giftContext`, and pays 250 tokens outside that cap. 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'),
@@ -6086,7 +6107,12 @@ const app = new Hono<App>({ strict: false })
// No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json([])
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
// FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
// and a day's cooldown. Its prize is still picked from the activity that asked.
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
const claimed = daily
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
: await claimReward(c.env.DB, id, rewardType, giftContext)
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
if (claimed === null) return c.json([])
const message =
@@ -6107,8 +6133,8 @@ const app = new Hono<App>({ strict: false })
// 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.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
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,
@@ -6121,25 +6147,34 @@ 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(
// 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(
c.env.DB,
id,
bonus.CurrencyType,
bonus.Currency,
GAME_REWARD_TOKENS,
GAME_REWARD_DAILY_TOKEN_CAP
)
}
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
if (daily || tokensToday !== null) {
const tokensDrop = toGameRewardTokensDrop()
await ensureStartingBalances(c.env.DB, id, startingTokens)
const tokenBalance = await creditCurrency(
c.env.DB,
id,
tokensDrop.CurrencyType,
tokensDrop.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
await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
tokensGiftId = tokensGift.id
}
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
@@ -6156,8 +6191,9 @@ const app = new Hono<App>({ strict: false })
levelsGained,
levelXp: progression.XP,
giftId: granted.id,
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
bonusGiftId,
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
tokensToday,
tokensGiftId,
})
return c.json([])
}
+55 -25
View File
@@ -30,13 +30,29 @@ export const REWARD_STATUS_SCHEMA_DDL: string[] = [
]
/**
* How long a player must wait between rewards of the same type in the same activity. One
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
* Per-type windows would be a map keyed by reward type; there's one window until a reward
* type needs its own.
* How long a player must wait between rewards of the same type in the same activity: one
* hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}.
*/
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
/** `FirstActivityOfDay`'s cooldown: a rolling 24h since the player's last one. */
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.
*/
export const TOKEN_REWARD_TYPE = '$tokens'
/**
* How long a token 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
/**
* Claim a reward if the player is due one, returning how many of that type they have now
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
@@ -61,9 +77,10 @@ export async function claimReward(
accountId: number,
rewardType: string,
giftContext = '',
now: Date = new Date()
now: Date = new Date(),
cooldownMs: number = REWARD_COOLDOWN_MS
): Promise<number | null> {
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
const cutoff = new Date(now.getTime() - cooldownMs).toISOString()
const row = await db
.prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
@@ -80,28 +97,41 @@ export async function claimReward(
}
/**
* 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.
* Add `amount` tokens 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.
*
* 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.
* A window older than {@link TOKEN_REWARD_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
* remainder, so `cap` should be a multiple of `amount`.
*
* One statement, for the same reason as {@link claimReward}: asks landing together can't both
* 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 isNewActivity(
export async function claimDailyTokens(
db: D1Database,
accountId: number,
giftContext: string,
grantCount: number
): Promise<boolean> {
if (giftContext === '' || grantCount !== 1) return false
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 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
.prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
VALUES (?1, ?2, '', ?3, ?4)
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
granted_at = CASE WHEN reward_status.granted_at <= ?5
THEN excluded.granted_at ELSE reward_status.granted_at END,
grant_count = CASE WHEN reward_status.granted_at <= ?5
THEN ?4 ELSE reward_status.grant_count + ?4 END
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)
.first<{ grant_count: number }>()
return row?.grant_count ?? null
}
+103 -56
View File
@@ -6580,7 +6580,7 @@ describe('econ endpoints', () => {
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
})
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => {
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity — FirstActivityOfDay once a day per player', async () => {
const headers = {
...(await bearer('80')),
'Content-Type': 'application/x-www-form-urlencoded',
@@ -6608,10 +6608,16 @@ describe('econ endpoints', () => {
const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1)
// Asking again inside the hour claims nothing — and must not push the cooldown out,
// or a client that retries in a loop would never become eligible.
// Asking again inside the day claims nothing — and must not push the cooldown out,
// or a client that retries in a loop would never become eligible. It is the PLAYER's
// first activity of the day, so another activity doesn't claim it either, and writes
// no row of its own.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(
(await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status
).toBe(200)
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
expect(await statusOf('FirstActivityOfDay', 'Soccer')).toBeNull()
// A different type has its own cooldown — and so does each `giftContext` within a type:
// Soccer and Paintball are separate rows that each claim once.
@@ -6643,12 +6649,21 @@ describe('econ endpoints', () => {
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
// Once the hour has passed, the same type claims again.
await env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.run()
// Once the hour has passed, an hourly type claims again — but FirstActivityOfDay waits
// out the whole day.
const age = (rewardType: string, ms: number) =>
env.DB.prepare(
'UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = ?2'
)
.bind(new Date(Date.now() - ms).toISOString(), rewardType)
.run()
await age('PostGameActivity', 61 * 60 * 1000)
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(2)
await age('FirstActivityOfDay', 61 * 60 * 1000)
expect((await request('rewardType=FirstActivityOfDay&Message=later')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(1)
await age('FirstActivityOfDay', 25 * 60 * 60 * 1000)
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
})
@@ -6664,12 +6679,21 @@ describe('econ endpoints', () => {
body,
})
/** Age the cooldown so the next ask is eligible again. */
const passAnHour = () =>
const passADay = () =>
env.DB.prepare(
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
.run()
/** The reward's own boxes — not the token box (GiftContext 51) each claim also pays. */
const rewardBoxes = async () => (await giftBoxes('82')).filter((b) => b.GiftContext !== 51)
/** Frames bar the token payout's balance update and box. */
const rewardFrames = async () =>
(await drainFrames()).filter(
(f) =>
f.notificationType !== NotificationType.StorefrontBalanceUpdate &&
(f.payload as { GiftContext?: number }).GiftContext !== 51
)
await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0)
@@ -6681,9 +6705,9 @@ describe('econ endpoints', () => {
// bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// One box: the XP reward itself, carrying the message the client asked to show and no
// item — a game reward is not an item.
const first = await giftBoxes('82')
// 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.)
const first = await rewardBoxes()
expect(first).toHaveLength(1)
expect(first[0]).toMatchObject({
Xp: 5,
@@ -6694,7 +6718,7 @@ describe('econ endpoints', () => {
})
// The box, then the bar — no level-up box, since no level was crossed.
const frames = await drainFrames()
const frames = await rewardFrames()
expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
@@ -6713,18 +6737,18 @@ describe('econ endpoints', () => {
// 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(1)
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 passAnHour()
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
// equipment skin, which is what the avatar-only roll is for.
const afterLevel2 = await giftBoxes('82')
const afterLevel2 = await rewardBoxes()
expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[2]
expect(clothingBox?.Message).toBe('Level 2!')
@@ -6739,7 +6763,7 @@ 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 drainFrames()).map((f) => f.notificationType)).toEqual([
expect((await rewardFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
@@ -6748,7 +6772,7 @@ describe('econ endpoints', () => {
// Two more rewards reach 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 passAnHour()
await passADay()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
@@ -6785,8 +6809,7 @@ 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.
// Two boxes: the quest reward, and the PostGameActivity tokens (covered below).
const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(2)
const box = boxes[0]
@@ -6836,7 +6859,7 @@ describe('econ endpoints', () => {
)
// 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.
// 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 })
@@ -6860,7 +6883,7 @@ 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 () => {
test('PostGameActivity/PostQuestActivity pay 250 tokens, up to 1250 per player per 24h', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
@@ -6872,61 +6895,85 @@ describe('econ endpoints', () => {
})
const tokens = () =>
getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
const bonusBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
const tokenBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
const allowance = () =>
env.DB.prepare(
"SELECT granted_at, grant_count FROM reward_status WHERE account_id = 84 AND reward_type = '$tokens'"
).first<{ granted_at: string; grant_count: number }>()
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)
// FirstActivityOfDay pays its 250 OUTSIDE the allowance: no allowance row, and the cap
// below is still five claims' worth on top of it.
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
expect(await tokens()).toBe(start + 250)
expect(await allowance()).toBeNull()
await drainFrames()
const base = start + 250
// First time in Soccer: +250, in a box of its own after the reward's, credited to the
// PostGameActivity: +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({
expect(await tokens()).toBe(base + 250)
const boxes = (await tokenBoxes()).slice(1)
expect(boxes).toHaveLength(1)
expect(boxes[0]).toMatchObject({
Currency: 250,
CurrencyType: CurrencyType.RecCenterTokens,
Xp: 0,
AvatarItemDesc: '',
Message: 'New activity bonus!',
Message: '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.)
// The balance frame carries the resulting total, and the token box is announced like
// any other.
const frames = await drainFrames()
expect(
frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 })
frames.findLast((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)
?.payload
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: base + 250 })
expect(
frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate)
).toContainEqual(
expect.objectContaining({
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: bonus[0]?.Id }),
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: boxes[0]?.Id }),
})
)
const opened = (await allowance())?.granted_at
expect(await allowance()).toMatchObject({ grant_count: 250 })
// 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.
// PostQuestActivity counts the same, and the allowance is the player's, whatever the
// activity — a contextless ask included.
for (const body of [
'rewardType=PostQuestActivity&giftContext=Quest_Goblin_S',
'rewardType=PostGameActivity',
'rewardType=PostGameActivity&giftContext=Paintball',
'rewardType=PostGameActivity&giftContext=Bowling',
]) {
expect((await request(body)).status).toBe(200)
}
expect(await tokens()).toBe(base + 1250)
// The window keeps its start as the total grows.
expect(await allowance()).toEqual({ granted_at: opened, grant_count: 1250 })
// At the cap: the claim still pays its XP box, but no tokens.
const before = (await giftBoxes('84')).length
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
expect(await tokens()).toBe(base + 1250)
expect(await tokenBoxes()).toHaveLength(6)
expect((await giftBoxes('84')).length).toBeGreaterThan(before)
expect(await allowance()).toMatchObject({ grant_count: 1250 })
// 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 = 84 AND gift_context = 'Soccer'"
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND reward_type = '$tokens'"
)
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
.bind(new Date(Date.now() - 25 * 60 * 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)
expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200)
expect(await tokens()).toBe(base + 1500)
const reopened = await allowance()
expect(reopened?.grant_count).toBe(250)
expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true)
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {