diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 7cb0899..28aa059 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -311,8 +311,9 @@ describe('public endpoints', () => { }) 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. - // 25 XP from level 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5. + // The two workers share this table; `econ` writes it when a game reward is claimed (5 XP + // 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({ progression: { PlayerId: 4242, Level: 3, XP: 5 }, levelsGained: 2, diff --git a/apps/econ/README.md b/apps/econ/README.md index 337ee9b..fa5cf0f 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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 | | 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 → 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/room` | | Room keys for a room (stub `[]`) | | 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 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 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 @@ -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. Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type, 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 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. @@ -404,9 +407,9 @@ inserts. **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 -level's cost while it's covered. One 25 XP reward takes a fresh player from level 1 to level -**3** with 5 XP over (10 + 10 spent), because the early tiers are cheap — the ladder steps -10 → 20 → 45 → 115 → 360 → 1080 every ten levels and stops at 50. +level's cost while it's covered — so a big enough grant can cross several levels at once. +The ladder steps 10 → 20 → 45 → 115 → 360 → 1080 every ten levels and stops at 50, so the +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 `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) | | 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 -over three boxes: the XP reward itself, 2-Star Clothing for level 2, and a consumable for -level 3. Each arrives as a gift box announced like any other (`Level 3!`). +**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 +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 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. - Several routes (room keys, wishlist, equipment, room consumables/currencies) are 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`). -- The weekly-challenge gift is granted but not announced: the box appears in the gifts list - with no `GiftPackageReceived` notification, so the player sees it the next time the client - reads that list rather than the moment they finish the set. Same gap as gifting to another - player, and the same reason — the frame's payload shape hasn't been captured. +- 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. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 61b776e..cf042b3 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -708,8 +708,12 @@ async function grantGiftDrop( * 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. + * + * 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 @@ -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 - * published table names a reward for every level and a single grant can cross several (25 XP - * takes a fresh player from 1 to 3, so two rewards). Each arrives as a gift box, announced - * like any other unasked-for gift. + * published table names a reward for every level and a single grant can cross several (a + * large enough grant could clear the first three levels at 10 XP each). Each arrives as a + * 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 * clothing at a rising star rating. The catalog is read once and shared across the boxes. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 8e8ac5d..7b09a40 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1765,6 +1765,13 @@ describe('econ endpoints', () => { }, 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() expect((await getProgression(env.DB, 82)).XP).toBe(0) @@ -1772,83 +1779,93 @@ describe('econ endpoints', () => { expect(res.status).toBe(200) 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 - // reach 3, leaving 5 as progress into the next. - expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 5 }) + // 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 }) - // Three boxes: the XP reward itself, then one per level it crossed. - const boxes = await giftBoxes('82') - expect(boxes).toHaveLength(3) - - // The reward box carries the XP and the message the client asked to show, and nothing - // else — a game reward is not an item. - expect(boxes[0]).toMatchObject({ - Xp: 25, + // 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') + expect(first).toHaveLength(1) + expect(first[0]).toMatchObject({ + Xp: 5, Message: 'First Game of the Day', AvatarItemDesc: '', EquipmentModificationGuid: '', ConsumableItemDesc: '', }) - // The published table pays 2-Star Clothing for level 2 and a Consumable for level 3. - const [clothingBox, consumableBox] = boxes.slice(1) - expect(boxes.slice(1).map((b) => b.Message)).toEqual(['Level 2!', 'Level 3!']) + // The box, then the bar — no level-up box, since no level was crossed. + const frames = await drainFrames() + 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 - // roll is for — at the star tier the table names (2-Star = rarity 10). + // 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 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?.EquipmentModificationGuid).toBe('') expect(clothingBox?.ConsumableItemDesc).toBe('') 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`, { headers: await bearer('82'), }) 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([ + 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`, { headers: await bearer('82'), }) const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }> 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 () => { diff --git a/packages/domain/src/progression-db.ts b/packages/domain/src/progression-db.ts index d456708..088c35f 100644 --- a/packages/domain/src/progression-db.ts +++ b/packages/domain/src/progression-db.ts @@ -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 * 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[] = [ // Level 0 is not a level anyone reaches; 0 is "no reward" rather than a rarity. 0, // 1–10: consumables interleaved with the first clothing drops. - CONSUMABLE_REWARD, - 10, - CONSUMABLE_REWARD, - 10, - CONSUMABLE_REWARD, - CONSUMABLE_REWARD, - CONSUMABLE_REWARD, - 10, - CONSUMABLE_REWARD, - 10, + CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD, + CONSUMABLE_REWARD, CONSUMABLE_REWARD, 10, CONSUMABLE_REWARD, 10, // 11–20: 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, // 21–30: 2-Star alternating with 3-Star. - 10, - 20, - 10, - 20, - 10, - 20, - 10, - 20, - 10, - 20, + 10, 20, 10, 20, 10, 20, 10, 20, 10, 20, // 31–40: 3-Star with a 4-Star every few levels. - 30, - 20, - 20, - 20, - 30, - 20, - 20, - 20, - 20, - 30, + 30, 20, 20, 20, 30, 20, 20, 20, 20, 30, // 41–50: 4-Star to the top, then the game's only 5-Star. - 30, - 30, - 30, - 30, - 30, - 30, - 30, - 30, - 30, - 50, + 30, 30, 30, 30, 30, 30, 30, 30, 30, 50, ] /** 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 * 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, * 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 - * 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[] { const from = grant.progression.Level - grant.levelsGained