[econ] levels

This commit is contained in:
Devin Zuczek
2026-08-11 00:34:03 -04:00
parent af64327fea
commit 7c43f2a1f3
7 changed files with 574 additions and 46 deletions
+56 -5
View File
@@ -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 3040 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
+165 -11
View File
@@ -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<App>,
accountId: number,
progression: Progression
): Promise<void> {
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 840 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<App>,
accountId: number,
rarity: number,
rollCatalog?: StoreItem[]
options: RollOptions = {}
): Promise<StoreGiftDrop | null> {
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<GrantedGift> {
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<App>,
accountId: number,
grant: XpGrant
): Promise<void> {
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<App>, accountId: number): Promise<v
accountId,
duplicate ? toChallengeFallbackDrop() : reward,
CHALLENGE_GIFT_MESSAGE,
catalog
{ rollCatalog: catalog }
)
// Nobody asked for this box, so the client has no reason to re-read the gifts list:
// the notification is what makes the reward show up at the moment the set is finished.
@@ -1997,16 +2145,22 @@ const app = new Hono<App>({ 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([])
+55 -10
View File
@@ -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([])
})