diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index e8ab08d..104ffc0 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -2,7 +2,11 @@ import { Hono } from 'hono' import { describeRoute } from 'hono-openapi' import { getProgression, getProgressions } from '@repo/domain' +import { logger } from '@repo/hono-helpers' +// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a +// value — the enum has no runtime dependencies. +import { NotificationType } from '../../../notify/src/notification-types' import { parseFormIds, queryIds } from '../http' import { BulkIdsRequest, @@ -15,8 +19,35 @@ import { ReputationDto, } from '../openapi' +import type { Context } from 'hono' +import type { Progression } from '@repo/domain' import type { App } from '../context' +/** The notifications hub is a single global DO instance (see the `notify` worker). */ +const HUB_INSTANCE = 'global' + +/** + * Push the caller's own progression back at them over the socket, mirroring the reference's + * `HubSendProgressionUpdate` on this same read. Pushing from a GET looks odd, but it is how + * a client that just connected gets its level bar right: the frame is what the client acts + * on, the response body is only what it asked for. Best-effort — a hub failure leaves the + * body correct. + */ +async function pushProgression(c: Context, progression: Progression): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + progression.PlayerId, + NotificationType.PlayerProgressionLevelUpdate, + { PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP } + ) + } catch (err) { + logger.error('failed to push PlayerProgressionLevelUpdate notification', { + accountId: progression.PlayerId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * Default reputation for an account — the fallback used with no DB. Nobody has * earned cheers yet, so every counter is 0 and everyone has their full cheer credit. @@ -72,15 +103,19 @@ export const progressionRoutes = new Hono({ strict: false }) tags: ['Progression'], summary: 'A player’s level and XP', description: - 'The XP banked in `progression` (game rewards pay into it from the `econ` worker). ' + - 'A player who has earned none has no row and reads back as level 1 with 0 XP. ' + - 'Levelling is not wired up yet, so `Level` is always 1.', + 'The level and XP banked in `progression` (game rewards pay into it from the `econ` ' + + 'worker); `XP` is the progress into the current level, not a lifetime total. A ' + + 'player who has earned none has no row and reads back as level 1 with 0 XP. Also ' + + 'pushes the same values as a `PlayerProgressionLevelUpdate` frame, as the reference ' + + 'does — that is what moves the client’s bar.', parameters: [idParam('id', 'Account id')], responses: { 200: json(ProgressionDto, 'The player’s progression') }, }), async (c) => { const id = Number.parseInt(c.req.param('id'), 10) - return c.json(await getProgression(c.env.DB, id)) + const progression = await getProgression(c.env.DB, id) + await pushProgression(c, progression) + return c.json(progression) } ) .post( diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 6ce0758..7cb0899 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4,9 +4,13 @@ import { beforeAll, describe, expect, test } from 'vitest' import { addXp, + applyLevelUps, GAME_VERSION, grantInvention, INVENTORY_INVENTION_SCHEMA_DDL, + LEVEL_REQUIRED_XP, + LEVEL_REWARDS, + MAX_LEVEL, PROGRESSION_SCHEMA_DDL, ROOM_SCHEMA_DDL, seedRoomWithSubRooms, @@ -306,13 +310,18 @@ describe('public endpoints', () => { expect(body[0]).toMatchObject({ Level: 1, XP: 0 }) }) - test('progression reads back the XP game rewards banked', 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. - await addXp(env.DB, 4242, 25) + // 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, + }) + // The next 25 lands on 5: 10 to reach level 4, then 20 to reach 5, leaving nothing. await addXp(env.DB, 4242, 25) const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`) - expect(await single.json()).toEqual({ PlayerId: 4242, Level: 1, XP: 50 }) + expect(await single.json()).toEqual({ PlayerId: 4242, Level: 5, XP: 0 }) // A player who has earned nothing has no row, and still gets a record — the bulk form // renders a card per id, so a missing one must not shorten the list. @@ -320,11 +329,69 @@ describe('public endpoints', () => { `${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243` ) expect(await bulk.json()).toEqual([ - { PlayerId: 4242, Level: 1, XP: 50 }, + { PlayerId: 4242, Level: 5, XP: 0 }, { PlayerId: 4243, Level: 1, XP: 0 }, ]) }) + test('the level ladder the server uses is the one the client is served', async () => { + // The client draws its bar against `LevelProgressionMaps` from this config; the server + // levels by LEVEL_REQUIRED_XP. If they drift, the bar fills to a different mark than + // the level-up fires at. + const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`) + expect(res.status).toBe(200) + const config = (await res.json()) as { + LevelProgressionMaps: Array<{ Level: number; RequiredXp: number; GiftRarity: number }> + } + expect(config.LevelProgressionMaps.map((m) => m.RequiredXp)).toEqual([...LEVEL_REQUIRED_XP]) + // The config's own `GiftRarity` is deliberately NOT asserted against `LEVEL_REWARDS`: + // it is a coarse per-band tier (flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap) + // and we grant from the published per-level table instead, which disagrees in places — + // level 15 is 2-Star there and 20 here. Only the XP costs have to match. + expect(config.LevelProgressionMaps.map((m) => m.GiftRarity)).toHaveLength(LEVEL_REWARDS.length) + // Indexed by level, so entry N is what a level-N player spends to reach N+1. + expect(config.LevelProgressionMaps.map((m) => m.Level)).toEqual( + LEVEL_REQUIRED_XP.map((_, level) => level) + ) + }) + + test('the level rewards match the published reward table', async () => { + // Rec Room's published level-reward table, spot-checked at the points where it turns: + // consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30, + // 5★ = 50). These are the levels an off-by-one in the table would move. + expect(LEVEL_REWARDS[0]).toBe(0) // nobody reaches level 0 + expect([1, 3, 5, 6, 7, 9].map((level) => LEVEL_REWARDS[level])).toEqual([ + -1, -1, -1, -1, -1, -1, + ]) + expect([2, 4, 8, 10, 21].map((level) => LEVEL_REWARDS[level])).toEqual([10, 10, 10, 10, 10]) + expect([22, 30].map((level) => LEVEL_REWARDS[level])).toEqual([20, 20]) + expect([31, 35, 40, 49].map((level) => LEVEL_REWARDS[level])).toEqual([30, 30, 30, 30]) + expect(LEVEL_REWARDS[50]).toBe(50) // the only 5-Star in the progression + expect(LEVEL_REWARDS).toHaveLength(51) + }) + + test('the ladder matches the published XP curve', async () => { + // Rec Room's own level-curve chart, read at its gridlines: cumulative XP to finish each + // level. The per-level costs are easy to edit one at a time and hard to eyeball as a + // curve, so the milestones are what actually pin the shape. + const cumulative = LEVEL_REQUIRED_XP.reduce((totals, cost, level) => { + totals[level] = level === 0 ? 0 : (totals[level - 1] ?? 0) + cost + return totals + }, []) + expect(cumulative[10]).toBe(170) + expect(cumulative[20]).toBe(620) + expect(cumulative[30]).toBe(1770) + expect(cumulative[40]).toBe(5370) + expect(cumulative[50]).toBe(16170) + }) + + test('levelling stops at the top of the ladder', async () => { + // Nothing above MAX_LEVEL to buy, so a huge grant banks XP and stays put. + expect(applyLevelUps(MAX_LEVEL, 100_000)).toEqual({ level: MAX_LEVEL, xp: 100_000 }) + // …and a grant that doesn't cover the current level's cost just accrues. + expect(applyLevelUps(1, 9)).toEqual({ level: 1, xp: 9 }) + }) + test('POST /api/players/v2/progression/bulk returns an array', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, { method: 'POST', diff --git a/apps/econ/README.md b/apps/econ/README.md index 85d959d..8c768cb 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -120,6 +120,8 @@ weekly gift — hand over a real item rather than an unopenable box: - **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a box), and so are consumables: they stack, so "don't have" never becomes false and they'd crowd out the real prizes. +- **`avatarItemsOnly` narrows it to worn items**, dropping equipment skins from the pool. + Level-up boxes use it; storefront boxes don't, since "a random 4-star item" means both. - **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they agree; sf3's boxes carry only `Rarity`. - **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of @@ -396,9 +398,58 @@ failure can't leave a box promising XP nobody was credited. **Progression (`progression`) is shared.** `econ` writes it here; `api` reads it back for `GET /api/players/v{1,2}/progression/…`. It lives in `@repo/domain` for that reason, the same split as gift boxes. A player with no row reads as level 1 / 0 XP, so a GET never -inserts. `Level` is stored but never moves: the reference levels up by subtracting a tier's -`RequiredXp` from the running XP, with thresholds from a config file (`configv2.json`'s -`LevelProgressionMaps`) we don't have. +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. + +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 +different mark than the level-up fires at; an `api` test asserts they stay identical. + +It is also the real game's curve, checked against Rec Room's own published level chart — +cumulative XP to finish a level: 170 by 10, 620 by 20, 1,770 by 30, 5,370 by 40, 16,170 by 50. Nearly flat to level 20, then a knee at 30–40 and a steep climb to the cap; a third of +the whole grind sits in the last ten levels. A test pins those milestones, since per-level +costs are easy to edit one at a time and hard to eyeball as a curve. + +**Every level pays out a reward**, from Rec Room's published level-reward table +(`LEVEL_REWARDS` in `@repo/domain`) — per level, not per band: + +| Levels | Reward | +| ---------------- | ------------------------------------------ | +| 1, 3, 5, 6, 7, 9 | Consumable | +| 2, 4, 8, 10 – 21 | 2-Star Clothing (rarity 10) | +| 22 – 30 | 3-Star on even levels, 2-Star between | +| 31 – 39 | 3-Star, with 4-Star at 31 and 35 | +| 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!`). + +- **"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. +- **Consumable levels don't roll a rarity.** The table names no star tier for them, and + consumables stack, so there's no ownership filter either — a second Confetti Cannon is a + fine prize. It's picked as a concrete drop rather than through the query path. +- **This table is not the served config's `GiftRarity`.** That one is a coarse per-band tier + (flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap) with no notion of consumables, and + the two disagree — level 15 is 2-Star in the published table and 20 in the config. We grant + from the published table; the config is left as captured, so the drift test asserts only + the XP costs. If the client previews an upcoming reward from `GiftRarity`, aligning the two + is an edit to the static config. +- The reference server carries the config data and never reads it: granting anything for a + level is ours. + +**The client is told, or it shows nothing.** A grant pushes `PlayerProgressionLevelUpdate` +(`{ PlayerId, Level, XP }`) — without it the bar sits still until something else refreshes +it, which is what "levelling does nothing" looks like from the game. `api`'s +`GET /api/players/v1/progression/:id` pushes the same frame on read, as the reference does, +so a client that just connected gets its bar right. **Not ported:** the reference's `request` doesn't grant at all — it offers **three** drops, pushes a `RewardSelectionReceived` frame and waits for `POST /api/gamerewards/v1/select` to @@ -434,8 +485,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 into `progression`; levelling never happens (no curve) and - there is no daily XP cap beyond the hourly cooldown. +- Game rewards pay a flat 25 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 diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index e7402a0..579508c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -9,6 +9,8 @@ import { getGift, getPendingGifts, grantInvention, + levelReward, + levelsReached, ownsInvention, } from '@repo/domain' import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' @@ -84,7 +86,7 @@ import { getOutfits, setOutfit } from './outfit-db' import { claimReward } from './reward-db' import type { Context } from 'hono' -import type { GiftContent, StoredGift } from '@repo/domain' +import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain' import type { Avatar } from './avatar-db' import type { ConsumeResult } from './consumables-db' import type { App } from './context' @@ -470,6 +472,34 @@ async function pushGiftReceived( } } +/** + * Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead + * of waiting for its next progression read. `XP` is the progress into the current level (the + * ladder spends the rest on the level-ups), which is what the bar draws against the + * `LevelProgressionMaps` the client is served. + * + * Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the + * reward. + */ +async function pushProgressionUpdate( + c: Context, + accountId: number, + progression: Progression +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + accountId, + NotificationType.PlayerProgressionLevelUpdate, + { PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP } + ) + } catch (err) { + logger.error('failed to push PlayerProgressionLevelUpdate notification', { + accountId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * The catalog a query drop rolls from: sf3, the general store. It is the only catalog with * a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the @@ -515,6 +545,20 @@ async function ownsGiftDrop( return true } +/** How a query drop is rolled — what it may land on, and whose catalog copy to use. */ +interface RollOptions { + /** + * Restrict the roll to avatar items, leaving equipment skins out of the pool. Off by + * default: a bought box says "a random item", and the catalog's own boxes mean both. + */ + avatarItemsOnly?: boolean + /** + * The roll catalog, when the caller has already read it — it's the big one (sf3), and a + * caller granting several boxes at once shouldn't re-read it per box. + */ + rollCatalog?: StoreItem[] +} + /** * Roll a query drop: pick, uniformly at random, one item of `rarity` from the roll catalog * that the player doesn't already own. Returns null when the pool is empty — an unreadable @@ -525,17 +569,22 @@ async function ownsGiftDrop( * avatar item or a piece of equipment: "an item you don't have" only means anything for * things owned once, and consumables stack, so a consumable would be rollable forever and * would crowd out the real prizes. + * + * `avatarItemsOnly` narrows it further to things worn on the avatar, leaving equipment + * skins out — a level-up prize should be something the player can see on themselves, not a + * skin for a weapon they may not own. It also skips the equipment read entirely, since + * nothing in the pool can match it. */ async function rollQueryDrop( c: Context, accountId: number, rarity: number, - rollCatalog?: StoreItem[] + options: RollOptions = {} ): Promise { const [catalog, ownedItems, ownedEquipment] = await Promise.all([ - rollCatalog ?? loadRollCatalog(c), + options.rollCatalog ?? loadRollCatalog(c), getInventory(c.env.DB, accountId), - getEquipment(c.env.DB, accountId), + options.avatarItemsOnly === true ? [] : getEquipment(c.env.DB, accountId), ]) const haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc)) const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid)) @@ -544,6 +593,7 @@ async function rollQueryDrop( if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') { return !haveItem.has(drop.AvatarItemDesc) } + if (options.avatarItemsOnly === true) return false if ( typeof drop.EquipmentModificationGuid === 'string' && drop.EquipmentModificationGuid !== '' @@ -566,6 +616,25 @@ interface GrantedGift { drop: StoreGiftDrop } +/** + * Pick a random consumable from the roll catalog — the reward the published level table + * hands out for the early levels. + * + * Unlike a clothing roll this one has no rarity and no ownership filter: the table names no + * star tier for a consumable, and consumables STACK, so "one you don't have" is meaningless + * (a second Confetti Cannon is a fine prize). Returns a concrete drop rather than a query + * one, so the grant path just grants it. + */ +function rollConsumableDrop(catalog: StoreItem[]): StoreGiftDrop | null { + const pool = catalog.filter( + ({ GiftDrop: drop }) => + drop.IsQuery !== true && + typeof drop.ConsumableItemDesc === 'string' && + drop.ConsumableItemDesc !== '' + ) + return pool[Math.floor(Math.random() * pool.length)]?.GiftDrop ?? null +} + /** * Hand a gift-drop to a player: grant whatever it turns out to carry (an avatar item, an * equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet) @@ -585,14 +654,12 @@ async function grantGiftDrop( accountId: number, drop: StoreGiftDrop, message: string, - // The roll catalog, when the caller has already read it — it's the big one (sf3), and - // the weekly gift has to consult it before it knows whether it's rolling at all. - rollCatalog?: StoreItem[] + options: RollOptions = {} ): Promise { let giftDrop = drop if (drop.IsQuery === true) { const rarity = drop.QueryRedirectRarity ?? drop.Rarity - const rolled = await rollQueryDrop(c, accountId, rarity, rollCatalog) + const rolled = await rollQueryDrop(c, accountId, rarity, options) if (rolled === null) { logger.warn('query gift-drop rolled nothing', { accountId, @@ -677,6 +744,87 @@ function toGameRewardDrop(): StoreGiftDrop { } } +/** + * The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from + * AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so 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. This is the one roll that narrows the pool that far. + */ +function toLevelUpDrop(rarity: number): StoreGiftDrop { + return { + FriendlyName: '', + Tooltip: '', + ConsumableItemDesc: '', + AvatarItemDesc: '', + AvatarItemType: null, + EquipmentPrefabName: '', + EquipmentModificationGuid: '', + Rarity: rarity, + Context: GIFT_CONTEXT_GAME_REWARDS, + Currency: 0, + CurrencyType: 0, + IsQuery: true, + } +} + +/** + * 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. + * + * 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. + * Best-effort as a whole: the XP is banked and the levels are already stored, so a failed + * roll costs a prize, not the level. + */ +async function grantLevelUpGifts( + c: Context, + accountId: number, + grant: XpGrant +): Promise { + const levels = levelsReached(grant) + if (levels.length === 0) return + try { + const rollCatalog = await loadRollCatalog(c) + for (const level of levels) { + const reward = levelReward(level) + if (reward === null) continue + const message = `Level ${level}!` + // A consumable is rolled to a concrete drop up front; clothing rides the query path, + // which rolls it against what the player already owns. + const drop = + reward.kind === 'consumable' + ? rollConsumableDrop(rollCatalog) + : toLevelUpDrop(reward.rarity) + if (drop === null) { + logger.warn('level up reward rolled nothing', { accountId, level, kind: reward.kind }) + continue + } + const granted = await grantGiftDrop(c, accountId, drop, message, { + avatarItemsOnly: reward.kind === 'clothing', + rollCatalog, + }) + await pushGiftReceived(c, accountId, granted, message, COACH_ACCOUNT_ID) + logger.info('level up gift granted', { + accountId, + level, + kind: reward.kind, + rarity: reward.kind === 'clothing' ? reward.rarity : null, + giftId: granted.id, + avatarItemDesc: granted.drop.AvatarItemDesc, + consumableItemDesc: granted.drop.ConsumableItemDesc, + }) + } + } catch (err) { + logger.error('failed to grant level up gift', { + accountId, + levels, + error: err instanceof Error ? err.message : String(err), + }) + } +} + /** * The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as * a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`, @@ -848,7 +996,7 @@ async function awardChallengeGift(c: Context, accountId: number): Promise({ strict: false }) : DEFAULT_GAME_REWARD_MESSAGE // Bank the XP first: it is the reward, and the box is the wrapper the client shows. // A failure here must not leave a box promising XP that was never credited. - const progression = await addXp(c.env.DB, id, GAME_REWARD_XP) + const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP) const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message) await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) + // Every grant moves the bar, whether or not it crossed a level. + await pushProgressionUpdate(c, id, progression) + // …and every level crossed is worth a box of its own tier. + await grantLevelUpGifts(c, id, { progression, levelsGained }) logger.info('game reward claimed', { accountId: id, rewardType, grantCount: claimed, message, xp: GAME_REWARD_XP, - totalXp: progression.XP, + level: progression.Level, + levelsGained, + levelXp: progression.XP, giftId: granted.id, }) return c.json([]) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index f0e8f4e..9bda268 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1481,6 +1481,7 @@ describe('econ endpoints', () => { Message: string EquipmentModificationGuid: string AvatarItemDesc: string + ConsumableItemDesc: string GiftRarity: number }> } @@ -1723,13 +1724,16 @@ describe('econ endpoints', () => { expect(res.status).toBe(200) expect(await res.json()).toEqual([]) - // The XP is banked, not just displayed on the box. - expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 25 }) + // 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 }) - // The box carries the XP and the message the client asked to show, and nothing else — - // a game reward is not an item. + // Three boxes: the XP reward itself, then one per level it crossed. const boxes = await giftBoxes('82') - expect(boxes).toHaveLength(1) + 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, Message: 'First Game of the Day', @@ -1738,10 +1742,44 @@ describe('econ endpoints', () => { 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!']) + + // 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). + 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) + + 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).toHaveLength(1) + expect(frames.map((f) => f.notificationType)).toEqual([ + NotificationType.GiftPackageReceivedImmediate, + NotificationType.PlayerProgressionLevelUpdate, + NotificationType.GiftPackageReceivedImmediate, + NotificationType.GiftPackageReceivedImmediate, + ]) expect(frames[0]?.accountId).toBe(82) - expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate) expect(frames[0]?.payload).toMatchObject({ Id: boxes[0]?.Id, FromPlayerId: 1, @@ -1750,11 +1788,18 @@ describe('econ endpoints', () => { 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 second box, no second frame, no more XP. + // 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)).XP).toBe(25) - expect(await giftBoxes('82')).toHaveLength(1) + expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 5 }) + expect(await giftBoxes('82')).toHaveLength(3) expect(await drainFrames()).toEqual([]) }) diff --git a/apps/notify/src/notification-types.ts b/apps/notify/src/notification-types.ts index 4fcede3..610cecf 100644 --- a/apps/notify/src/notification-types.ts +++ b/apps/notify/src/notification-types.ts @@ -50,6 +50,14 @@ export enum NotificationType { GiftPackageReceived = 30, GiftPackageReceivedImmediate = 31, GiftPackageRewardSelectionReceived = 32, + /** + * A player's level/XP changed — `{ PlayerId, Level, XP }`, where XP is the progress into + * the current level, not a lifetime total. STRING-valued: the reference's hub sends the + * wire name and its enum has no number for this one at all. It pushes the frame both when + * progression changes and when the player reads it back, which is how a client that just + * connected gets its bar right. + */ + PlayerProgressionLevelUpdate = 'PlayerProgressionLevelUpdate', ProfileJuniorStatusUpdate = 40, RelationshipsInvalid = 50, StorefrontBalanceAdd = 60, diff --git a/packages/domain/src/progression-db.ts b/packages/domain/src/progression-db.ts index 3ac318c..d456708 100644 --- a/packages/domain/src/progression-db.ts +++ b/packages/domain/src/progression-db.ts @@ -10,11 +10,9 @@ * level-1/0-XP default the progression endpoints already served, so reads fall back to it * rather than inserting on a GET. * - * `level` is stored, not derived. The reference server levels a player up by subtracting - * the tier's `RequiredXp` from the running XP, with the thresholds coming from a config - * file (`configv2.json`'s `LevelProgressionMaps`) that we don't have — so XP accumulates - * here and everyone stays level 1 until those numbers exist. The column is present so - * turning the curve on later is a write, not a migration. + * `level` is stored rather than derived, because `xp` is NOT lifetime XP: a level-up spends + * the tier's cost out of it (see {@link LEVEL_REQUIRED_XP}), so the pair is a level plus the + * progress into the next one — which is exactly what the client's bar draws. * * The `econ` worker owns the migration (apps/econ/migrations/0012_progression.sql), being * the writer. @@ -29,6 +27,141 @@ export const PROGRESSION_SCHEMA_DDL: string[] = [ )`, ] +/** + * XP to leave each level, indexed BY LEVEL — `LEVEL_REQUIRED_XP[1]` is what a level-1 player + * spends to reach level 2. Copied from the `LevelProgressionMaps` the client is served in + * `apps/api/static/api-config-v2.json`, which is the same ladder the reference reads out of + * `configv2.json`: both sides have to agree or the client's bar fills to a different mark + * than the server levels at. An `api` test asserts the two stay identical. + * + * Index 0 is the level-0 entry the config carries (cost 0, unreachable — players start at + * level 1), and the tiers step 10 → 20 → 45 → 115 → 360 → 1080 every ten levels. + * + * What each level PAYS OUT is a separate table, {@link LEVEL_REWARDS}. + */ +export const LEVEL_REQUIRED_XP: readonly number[] = [ + 0, 10, 10, 10, 20, 20, 20, 20, 20, 20, 20, 45, 45, 45, 45, 45, 45, 45, 45, 45, 45, 115, 115, 115, + 115, 115, 115, 115, 115, 115, 115, 360, 360, 360, 360, 360, 360, 360, 360, 360, 360, 1080, 1080, + 1080, 1080, 1080, 1080, 1080, 1080, 1080, 1080, +] + +/** A consumable reward rather than a clothing item — no star tier of its own. */ +export const CONSUMABLE_REWARD = -1 + +/** + * The reward for REACHING each level, indexed by level: a `Rarity` for a clothing item, or + * {@link CONSUMABLE_REWARD} for a consumable. Transcribed from Rec Room's published + * level-reward table, in the star ratings it uses — 2-Star is rarity 10, 3-Star 20, 4-Star + * 30, 5-Star 50 (the ladder in the econ worker's query-drop section). + * + * The shape is worth reading: consumables carry the first ten levels (six of them), which + * are minutes apart at 10–20 XP each; clothing takes over and holds 2-Star until 21; the + * 20s alternate 2- and 3-Star; the 30s alternate 3- and 4-Star; the 40s are solid 4-Star, + * and level 50 is the only 5-Star in the game's progression. + * + * This is NOT the coarse `GiftRarity` the served config carries (a flat 10 to level 14, 20 + * to 39, 30 to 49, 50 at the cap). The two disagree in places — level 15 is 2-Star here and + * 20 there — and this table is the one we grant from, being per-level and explicit. See the + * econ README. + */ +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, + // 11–20: 2-Star clothing all the way. + 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, + // 31–40: 3-Star with a 4-Star every few levels. + 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, +] + +/** What reaching a level pays out, or null when it pays nothing. */ +export type LevelReward = { kind: 'consumable' } | { kind: 'clothing'; rarity: number } + +/** + * The reward for reaching `level`, or null for a level that carries none (level 0, or any + * level past the end of the table). + */ +export function levelReward(level: number): LevelReward | null { + const reward = LEVEL_REWARDS[level] + if (reward === undefined || reward === 0) return null + return reward === CONSUMABLE_REWARD + ? { kind: 'consumable' } + : { kind: 'clothing', rarity: reward } +} + +/** The last level the ladder defines. At the top XP still accrues, but nothing levels. */ +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). + * + * 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. + */ +export function applyLevelUps(level: number, xp: number): { level: number; xp: number } { + let currentLevel = level + let remaining = xp + while (currentLevel < MAX_LEVEL) { + const cost = LEVEL_REQUIRED_XP[currentLevel] ?? 0 + if (cost <= 0 || remaining < cost) break + remaining -= cost + currentLevel += 1 + } + return { level: currentLevel, xp: remaining } +} + /** A player's progression, as the client's progression DTO renders it. */ export interface Progression { PlayerId: number @@ -41,16 +174,39 @@ export function defaultProgression(accountId: number): Progression { return { PlayerId: accountId, Level: 1, XP: 0 } } +/** What a player holds after a grant, plus how many levels the grant took them up. */ +export interface XpGrant { + progression: Progression + levelsGained: number +} + /** - * Add XP to a player and return what they now hold. The add is one statement, so two - * rewards landing together can't both read the same stale total and write it back — the - * client fires reward requests off right after a match. + * The levels a grant took the player THROUGH, in order — `[2, 3]` for the 25 XP 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. + */ +export function levelsReached(grant: XpGrant): number[] { + const from = grant.progression.Level - grant.levelsGained + return Array.from({ length: grant.levelsGained }, (_, i) => from + i + 1) +} + +/** + * Add XP to a player, spend it on any levels it now pays for, and return what they hold — + * with the levels gained, which is what a caller announces ("you reached level 3") and what + * a future level-up reward would hang off. + * + * The XP add is one statement, so two rewards landing together can't both read the same + * stale total and write it back — the client fires reward requests off right after a match. + * The level-up is a second write on the row the first one returned: the ladder is a pure + * function of that row, so a concurrent grant either lands before it (and is included) or + * after it (and levels up itself). Neither loses XP; the worst case is a level-up announced + * one grant late. * * Non-positive amounts are dropped rather than written: nothing takes XP away, and a 0 XP * grant would otherwise create a row that says the same as no row at all. */ -export async function addXp(db: D1Database, accountId: number, xp: number): Promise { - if (xp <= 0) return await getProgression(db, accountId) +export async function addXp(db: D1Database, accountId: number, xp: number): Promise { + if (xp <= 0) return { progression: await getProgression(db, accountId), levelsGained: 0 } const row = await db .prepare( `INSERT INTO progression (account_id, level, xp) VALUES (?1, 1, ?2) @@ -59,8 +215,20 @@ export async function addXp(db: D1Database, accountId: number, xp: number): Prom ) .bind(accountId, xp) .first<{ level: number; xp: number }>() - if (row === null) return defaultProgression(accountId) - return { PlayerId: accountId, Level: row.level, XP: row.xp } + if (row === null) return { progression: defaultProgression(accountId), levelsGained: 0 } + + const leveled = applyLevelUps(row.level, row.xp) + const levelsGained = leveled.level - row.level + if (levelsGained > 0) { + await db + .prepare('UPDATE progression SET level = ?2, xp = ?3 WHERE account_id = ?1') + .bind(accountId, leveled.level, leveled.xp) + .run() + } + return { + progression: { PlayerId: accountId, Level: leveled.level, XP: leveled.xp }, + levelsGained, + } } /** One player's progression, defaulted when they've earned nothing yet. */