[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 | | GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress | | POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | | 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/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed | | 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) | | 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 primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
before it keep counting. 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 `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 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 (`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 - **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
`PostGameActivity` is a map keyed by type. `PostGameActivity` is a map keyed by type.
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action - **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced player's first rewards each level them up; from level 4 (20 XP a level) it takes two or
by the hourly cooldown rather than cleared in one match. more, and the daily XP cap paces it from there.
- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day** - **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
(`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the (`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 `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 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 `''`): 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. `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 (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 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) credited to the RecCenterTokens balance (`StorefrontBalanceUpdate` with the resulting total)
and arrive in a **second box** of their own — `GiftContext` 51 (`GameRewardsTokens`), 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. `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 - **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 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. (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 | | 50 | 5-Star Clothing (rarity 50) — the only one |
**One reward per level crossed** — a grant spanning several levels pays each of them. In **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 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!`). 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. - 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 - Several routes (equipment, room consumables/currencies) are empty-list stubs pending
their own stores. their own stores.
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the - Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day
reference caps activity XP per day in `daily_xp_ledgers`). in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the - 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 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. previews an upcoming reward would preview the config's answer, not ours.
+65 -31
View File
@@ -168,7 +168,7 @@ import {
WishlistItemList, WishlistItemList,
WishlistItemRequest, WishlistItemRequest,
} from './openapi' } from './openapi'
import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import { import {
awardRoomConsumable, awardRoomConsumable,
getRoomConsumable, 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 * XP paid for a claimed game reward. One flat amount for every reward type — "First Game of
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the * the Day" and "Activity completed!" are the same size of pat on the back until there's
* same size of pat on the back until there's reason to price them apart. * reason to price them apart.
* *
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a * Exactly what each of the first three levels costs, so a fresh player's first rewards each
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early * level them up; from level 4 (20 XP a level) it takes two or more. The daily
* levels are paced by the hourly cooldown rather than cleared in one match. * {@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 * `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 * 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. * {@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 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`; * 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. * the copy here is what the box and its notification display.
*/ */
function toGameRewardDrop(): StoreGiftDrop { function toGameRewardDrop(xp: number): StoreGiftDrop {
return { return {
FriendlyName: '', FriendlyName: '',
Tooltip: '', Tooltip: '',
@@ -2360,7 +2369,7 @@ function toGameRewardDrop(): StoreGiftDrop {
Context: GIFT_CONTEXT_GAME_REWARDS, Context: GIFT_CONTEXT_GAME_REWARDS,
Currency: 0, Currency: 0,
CurrencyType: 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, 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, * 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. * `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, * The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
* and the XP is the same pat on the back every claim gets. * 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 { return {
FriendlyName: catalog?.friendly_name ?? '', FriendlyName: catalog?.friendly_name ?? '',
Tooltip: catalog?.tooltip ?? '', Tooltip: catalog?.tooltip ?? '',
@@ -2441,7 +2454,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto
Context: reward.Context, Context: reward.Context,
Currency: reward.Currency, Currency: reward.Currency,
CurrencyType: reward.CurrencyType, 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 — // here, from `reward_status`: one claim per type per activity per hour, atomically —
// except FirstActivityOfDay, one per player per 24h. // 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 — // 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 // the client posted the message to show, so the box wears it. An on-cooldown ask changes
// nothing and pays nothing. // nothing and pays nothing.
@@ -6119,17 +6133,27 @@ const app = new Hono<App>({ strict: false })
typeof body.Message === 'string' && body.Message !== '' typeof body.Message === 'string' && body.Message !== ''
? body.Message ? body.Message
: DEFAULT_GAME_REWARD_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. // 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 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 // 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. // 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 questReward = await pickQuestReward(c.env.DB, id, giftContext)
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
const drop = const drop =
questReward === null questReward === null
? toGameRewardDrop() ? toGameRewardDrop(xp)
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null) : toQuestRewardDrop(
questReward,
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
xp
)
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants // A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the // items, not balances. Seed the signup grant first — `creditCurrency` upserts the
// row, and a never-touched RecCenterTokens balance would otherwise lose it. // 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) await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
} }
const granted = await grantGiftDrop(c, id, drop, message) // A box with nothing in it — the XP capped and no item or currency to carry — is not
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) // 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 // 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 // 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. // order as the currency reward above: balance first, then the box that shows it.
let tokensGiftId: number | null = null let tokensGiftId: number | null = null
let tokensToday: number | null = null let tokensToday: number | null = null
if (TOKEN_REWARD_TYPES.has(rewardType)) { if (TOKEN_REWARD_TYPES.has(rewardType)) {
tokensToday = await claimDailyTokens( tokensToday = await claimAllowance(
c.env.DB, c.env.DB,
id, id,
'$tokens',
GAME_REWARD_TOKENS, GAME_REWARD_TOKENS,
GAME_REWARD_DAILY_TOKEN_CAP 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) await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
tokensGiftId = tokensGift.id tokensGiftId = tokensGift.id
} }
// Every grant moves the bar, whether or not it crossed a level. if (gained !== null) {
await pushProgressionUpdate(c, id, progression) // Every grant moves the bar, whether or not it crossed a level.
// …and every level crossed is worth a box of its own tier. await pushProgressionUpdate(c, id, gained.progression)
await grantLevelUpGifts(c, id, { progression, levelsGained }) // …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, gained)
}
logger.info('game reward claimed', { logger.info('game reward claimed', {
accountId: id, accountId: id,
rewardType, rewardType,
giftContext, giftContext,
grantCount: claimed, grantCount: claimed,
message, message,
xp: GAME_REWARD_XP, xp,
level: progression.Level, xpToday,
levelsGained, level: gained?.progression.Level,
levelXp: progression.XP, levelsGained: gained?.levelsGained,
giftId: granted.id, levelXp: gained?.progression.XP,
giftId: rewardGiftId,
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS, tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
tokensToday, tokensToday,
tokensGiftId, 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 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 * The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under
* row under this reserved type and the empty context: `grant_count` is the TOKENS paid in the * its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current
* current window (not a count of claims) and `granted_at` is when that window opened. The `$` * window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened.
* keeps it apart from the client's own type names (`FirstActivityOfDay`, `PostGameActivity`), * The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`,
* which a client-sent type could otherwise forge. * `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. * 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 * 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. * 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 * 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 * 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 * 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 * 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`. * was, so both `CASE`s see the old `granted_at`.
*/ */
export async function claimDailyTokens( export async function claimAllowance(
db: D1Database, db: D1Database,
accountId: number, accountId: number,
allowance: Allowance,
amount: number, amount: number,
cap: number, cap: number,
now: Date = new Date() now: Date = new Date()
): Promise<number | null> { ): Promise<number | null> {
if (amount > cap) return 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 const row = await db
.prepare( .prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count) `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 WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
RETURNING grant_count` 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 }>() .first<{ grant_count: number }>()
return row?.grant_count ?? null return row?.grant_count ?? null
} }
+109 -40
View File
@@ -6701,56 +6701,43 @@ describe('econ endpoints', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([]) expect(await res.json()).toEqual([])
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the // 10 XP is exactly what level 1 costs, so a fresh player's first reward is a level-up.
// bar without levelling anyone up. expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// One reward box: the XP itself, carrying the message the client asked to show and no // Two reward boxes: 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.) // 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() const first = await rewardBoxes()
expect(first).toHaveLength(1) expect(first).toHaveLength(2)
expect(first[0]).toMatchObject({ expect(first[0]).toMatchObject({
Xp: 5, Xp: 10,
Message: 'First Game of the Day', Message: 'First Game of the Day',
AvatarItemDesc: '', AvatarItemDesc: '',
EquipmentModificationGuid: '', EquipmentModificationGuid: '',
ConsumableItemDesc: '', 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() const frames = await rewardFrames()
expect(frames.map((f) => f.notificationType)).toEqual([ expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate, NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
]) ])
expect(frames[0]?.accountId).toBe(82) expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({ expect(frames[0]?.payload).toMatchObject({
Id: first[0]?.Id, Id: first[0]?.Id,
FromPlayerId: 1, FromPlayerId: 1,
Xp: 5, Xp: 10,
// GiftContext.GameRewards — the box came from gameplay, not a purchase. // GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50, GiftContext: 50,
Message: 'First Game of the Day', 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. // Level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
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
// equipment skin, which is what the avatar-only roll is for. // equipment skin, which is what the avatar-only roll is for.
const afterLevel2 = await rewardBoxes() const clothingBox = first[1]
expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[2]
expect(clothingBox?.Message).toBe('Level 2!') expect(clothingBox?.Message).toBe('Level 2!')
expect(clothingBox?.AvatarItemDesc).not.toBe('') expect(clothingBox?.AvatarItemDesc).not.toBe('')
expect(clothingBox?.EquipmentModificationGuid).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. // v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }> const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc) 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. // clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) { await passADay()
await passADay() expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82') const afterLevel3 = await giftBoxes('82')
@@ -6809,12 +6796,14 @@ describe('econ endpoints', () => {
) )
expect(goblin.status).toBe(200) expect(goblin.status).toBe(200)
expect(await goblin.json()).toEqual([]) 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') const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(2) expect(boxes).toHaveLength(3)
const box = boxes[0] 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[1]).toMatchObject({ GiftContext: 51, Currency: 250 })
expect(boxes[2]?.Message).toBe('Level 2!')
expect(box?.AvatarItemDesc).not.toBe('') expect(box?.AvatarItemDesc).not.toBe('')
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc) const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
expect(row).toBeDefined() expect(row).toBeDefined()
@@ -6862,7 +6851,7 @@ describe('econ endpoints', () => {
// GameRewards box, behind Bowling's own tokens. // GameRewards box, behind Bowling's own tokens.
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200) expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50) 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 // 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 // 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) 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 () => { 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',