[econ] tweak progression slope

This commit is contained in:
Devin Zuczek
2026-08-11 01:18:12 -04:00
parent 9164df98a9
commit 4b17b007e9
5 changed files with 110 additions and 125 deletions
+3 -2
View File
@@ -311,8 +311,9 @@ describe('public endpoints', () => {
}) })
test('progression reads back the XP game rewards banked, levelled up', async () => { test('progression reads back the XP game rewards banked, levelled up', async () => {
// What `econ` writes when a game reward is claimed — the two workers share the table. // The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
// 25 XP from level 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5. // at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
// 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5.
expect(await addXp(env.DB, 4242, 25)).toEqual({ expect(await addXp(env.DB, 4242, 25)).toEqual({
progression: { PlayerId: 4242, Level: 3, XP: 5 }, progression: { PlayerId: 4242, Level: 3, XP: 5 },
levelsGained: 2, levelsGained: 2,
+16 -13
View File
@@ -46,7 +46,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 → 25 XP + gift box | | POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 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` | | Room keys for a room (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) | | POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
@@ -381,7 +381,7 @@ keyed by type.
- **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is - **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is
per type, shared across activities, so it is not part of the key. per type, shared across activities, so it is not part of the key.
**What a claim pays: 25 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in **What a claim pays: 5 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
@@ -391,6 +391,9 @@ failure can't leave a box promising XP nobody was credited.
- **One flat amount for every reward type**, matching the one flat cooldown they share. - **One flat amount for every reward type**, matching the one flat cooldown they share.
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type, Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
the same shape the per-type cooldown would take. the same shape the per-type cooldown would take.
- **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.
- **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.
@@ -404,9 +407,9 @@ inserts.
**Levelling spends the XP.** `xp` is progress into the current level, not a lifetime total: **Levelling spends the XP.** `xp` is progress into the current level, not a lifetime total:
`addXp` adds the grant, then walks the ladder in `LEVEL_REQUIRED_XP`, subtracting each `addXp` adds the grant, then walks the ladder in `LEVEL_REQUIRED_XP`, subtracting each
level's cost while it's covered. One 25 XP reward takes a fresh player from level 1 to level level's cost while it's covered — so a big enough grant can cross several levels at once.
**3** with 5 XP over (10 + 10 spent), because the early tiers are cheap — the ladder steps The ladder steps 10 → 20 → 45 → 115 → 360 1080 every ten levels and stops at 50, so the
10 → 20 → 45 → 115 → 360 → 1080 every ten levels and stops at 50. first level costs 10 XP and the last costs a hundred times that.
That table is copied from the `LevelProgressionMaps` the client is served in That table is copied from the `LevelProgressionMaps` the client is served in
`apps/api/static/api-config-v2.json`, and **both sides have to agree** or the bar fills to a `apps/api/static/api-config-v2.json`, and **both sides have to agree** or the bar fills to a
@@ -429,9 +432,10 @@ costs are easy to edit one at a time and hard to eyeball as a curve.
| 40 49 | 4-Star Clothing (rarity 30) | | 40 49 | 4-Star Clothing (rarity 30) |
| 50 | 5-Star Clothing (rarity 50) — the only one | | 50 | 5-Star Clothing (rarity 50) — the only one |
**One reward per level crossed**, so the 25 XP that takes a fresh player from 1 to 3 hands **One reward per level crossed** — a grant spanning several levels pays each of them. In
over three boxes: the XP reward itself, 2-Star Clothing for level 2, and a consumable for practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
level 3. Each arrives as a gift box announced like any other (`Level 3!`). 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!`).
- **"Clothing" is why the roll passes `avatarItemsOnly`** — the prize has to be something the - **"Clothing" is why the roll passes `avatarItemsOnly`** — the prize has to be something the
player can wear and be seen in, never an equipment skin for a weapon they may not own. player can wear and be seen in, never an equipment skin for a weapon they may not own.
@@ -487,9 +491,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 (room keys, wishlist, equipment, room consumables/currencies) are - Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores. empty-list stubs pending their own stores.
- Game rewards pay a flat 25 XP; there is no daily XP cap beyond the hourly cooldown (the - 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`). reference caps activity XP per day in `daily_xp_ledgers`).
- The weekly-challenge gift is granted but not announced: the box appears in the gifts list - The level-reward table and the served config's `GiftRarity` disagree in places (see the
with no `GiftPackageReceived` notification, so the player sees it the next time the client level section); we grant from the table and leave the config as captured, so a client that
reads that list rather than the moment they finish the set. Same gap as gifting to another previews an upcoming reward would preview the config's answer, not ours.
player, and the same reason — the frame's payload shape hasn't been captured.
+8 -4
View File
@@ -708,8 +708,12 @@ async function grantGiftDrop(
* 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, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are 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. * 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.
*/ */
const GAME_REWARD_XP = 25 const GAME_REWARD_XP = 5
/** /**
* `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
@@ -769,9 +773,9 @@ function toLevelUpDrop(rarity: number): StoreGiftDrop {
/** /**
* Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the * Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the
* published table names a reward for every level and a single grant can cross several (25 XP * published table names a reward for every level and a single grant can cross several (a
* takes a fresh player from 1 to 3, so two rewards). Each arrives as a gift box, announced * large enough grant could clear the first three levels at 10 XP each). Each arrives as a
* like any other unasked-for gift. * gift box, announced like any other unasked-for gift.
* *
* Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay * Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay
* clothing at a rising star rating. The catalog is read once and shared across the boxes. * clothing at a rising star rating. The catalog is read once and shared across the boxes.
+70 -53
View File
@@ -1765,6 +1765,13 @@ describe('econ endpoints', () => {
}, },
body, body,
}) })
/** Age the cooldown so the next ask is eligible again. */
const passAnHour = () =>
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())
.run()
await drainFrames() await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0) expect((await getProgression(env.DB, 82)).XP).toBe(0)
@@ -1772,83 +1779,93 @@ describe('econ endpoints', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([]) expect(await res.json()).toEqual([])
// The XP is banked and spent on levels: 25 pays the 10 to reach level 2 and the 10 to // 5 XP is deliberately less than the 10 the first level costs, so one action moves the
// reach 3, leaving 5 as progress into the next. // bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 5 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// Three boxes: the XP reward itself, then one per level it crossed. // One box: the XP reward itself, carrying the message the client asked to show and no
const boxes = await giftBoxes('82') // item — a game reward is not an item.
expect(boxes).toHaveLength(3) const first = await giftBoxes('82')
expect(first).toHaveLength(1)
// The reward box carries the XP and the message the client asked to show, and nothing expect(first[0]).toMatchObject({
// else — a game reward is not an item. Xp: 5,
expect(boxes[0]).toMatchObject({
Xp: 25,
Message: 'First Game of the Day', Message: 'First Game of the Day',
AvatarItemDesc: '', AvatarItemDesc: '',
EquipmentModificationGuid: '', EquipmentModificationGuid: '',
ConsumableItemDesc: '', ConsumableItemDesc: '',
}) })
// The published table pays 2-Star Clothing for level 2 and a Consumable for level 3. // The box, then the bar — no level-up box, since no level was crossed.
const [clothingBox, consumableBox] = boxes.slice(1) const frames = await drainFrames()
expect(boxes.slice(1).map((b) => b.Message)).toEqual(['Level 2!', 'Level 3!']) expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
])
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({
Id: first[0]?.Id,
FromPlayerId: 1,
Xp: 5,
// 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 })
// Clothing is an AVATAR ITEM — never an equipment skin, which is what the avatar-only // An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
// roll is for — at the star tier the table names (2-Star = rarity 10). 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 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()
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')
expect(afterLevel2).toHaveLength(3)
const clothingBox = afterLevel2[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('')
expect(clothingBox?.ConsumableItemDesc).toBe('') expect(clothingBox?.ConsumableItemDesc).toBe('')
expect(clothingBox?.GiftRarity).toBe(10) expect(clothingBox?.GiftRarity).toBe(10)
// The consumable level rolls a consumable instead, at no particular rarity.
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
expect(consumableBox?.AvatarItemDesc).toBe('')
expect(consumableBox?.EquipmentModificationGuid).toBe('')
// …and both are owned, not just pictured on an unopened box.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, { const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('82'), headers: await bearer('82'),
}) })
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 drainFrames()).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
// clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) {
await passAnHour()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82')
const consumableBox = afterLevel3[afterLevel3.length - 1]
expect(consumableBox?.Message).toBe('Level 3!')
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
expect(consumableBox?.AvatarItemDesc).toBe('')
expect(consumableBox?.EquipmentModificationGuid).toBe('')
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('82'), headers: await bearer('82'),
}) })
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }> const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc) expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
// One frame per box, plus the progression update between them.
const frames = await drainFrames()
expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
NotificationType.GiftPackageReceivedImmediate,
])
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({
Id: boxes[0]?.Id,
FromPlayerId: 1,
Xp: 25,
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50,
Message: 'First Game of the Day',
})
// The bar moves, which is the only thing that tells the client it levelled.
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 3, XP: 5 })
expect(frames[3]?.payload).toMatchObject({
Id: consumableBox?.Id,
ConsumableItemDesc: consumableBox?.ConsumableItemDesc,
Message: 'Level 3!',
})
// 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: 3, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(3)
expect(await drainFrames()).toEqual([])
}) })
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 () => {
+13 -53
View File
@@ -64,64 +64,22 @@ export const CONSUMABLE_REWARD = -1
* 20 there — and this table is the one we grant from, being per-level and explicit. See the * 20 there — and this table is the one we grant from, being per-level and explicit. See the
* econ README. * econ README.
*/ */
// prettier-ignore — the row-per-decade layout is the table; packing it hides the shape.
// prettier-ignore
export const LEVEL_REWARDS: readonly number[] = [ export const LEVEL_REWARDS: readonly number[] = [
// Level 0 is not a level anyone reaches; 0 is "no reward" rather than a rarity. // Level 0 is not a level anyone reaches; 0 is "no reward" rather than a rarity.
0, 0,
// 110: consumables interleaved with the first clothing drops. // 110: consumables interleaved with the first clothing drops.
CONSUMABLE_REWARD, CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD,
10, CONSUMABLE_REWARD, CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD, 10,
CONSUMABLE_REWARD,
10,
CONSUMABLE_REWARD,
CONSUMABLE_REWARD,
CONSUMABLE_REWARD,
10,
CONSUMABLE_REWARD,
10,
// 1120: 2-Star clothing all the way. // 1120: 2-Star clothing all the way.
10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
// 2130: 2-Star alternating with 3-Star. // 2130: 2-Star alternating with 3-Star.
10, 10, 20, 10, 20, 10, 20, 10, 20, 10, 20,
20,
10,
20,
10,
20,
10,
20,
10,
20,
// 3140: 3-Star with a 4-Star every few levels. // 3140: 3-Star with a 4-Star every few levels.
30, 30, 20, 20, 20, 30, 20, 20, 20, 20, 30,
20,
20,
20,
30,
20,
20,
20,
20,
30,
// 4150: 4-Star to the top, then the game's only 5-Star. // 4150: 4-Star to the top, then the game's only 5-Star.
30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 50,
30,
30,
30,
30,
30,
30,
30,
30,
50,
] ]
/** What reaching a level pays out, or null when it pays nothing. */ /** What reaching a level pays out, or null when it pays nothing. */
@@ -145,7 +103,8 @@ export const MAX_LEVEL = LEVEL_REQUIRED_XP.length - 1
/** /**
* Spend XP on levels: while the current level's cost is met, subtract it and step up. The * Spend XP on levels: while the current level's cost is met, subtract it and step up. The
* remainder stays as progress into the next level, and a big enough grant can cross several * remainder stays as progress into the next level, and a big enough grant can cross several
* at once (25 XP takes a fresh player from level 1 to level 3). * at once (a 25 XP grant would take a fresh player from level 1 to level 3, the first two
* levels costing 10 each).
* *
* A cost of 0 or less stops the loop rather than looping forever — the level-0 entry is 0, * A cost of 0 or less stops the loop rather than looping forever — the level-0 entry is 0,
* and a future config could zero one by mistake. * and a future config could zero one by mistake.
@@ -181,9 +140,10 @@ export interface XpGrant {
} }
/** /**
* The levels a grant took the player THROUGH, in order — `[2, 3]` for the 25 XP that lifts a * The levels a grant took the player THROUGH, in order — `[2, 3]` for a grant that lifts a
* fresh player from level 1 to level 3. One entry per level reached, which is one reward * fresh player from level 1 to level 3. One entry per level reached, which is one reward
* each; an empty list when the grant only moved the bar. * each; an empty list when the grant only moved the bar, which is the common case for a
* game reward.
*/ */
export function levelsReached(grant: XpGrant): number[] { export function levelsReached(grant: XpGrant): number[] {
const from = grant.progression.Level - grant.levelsGained const from = grant.progression.Level - grant.levelsGained