[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
+39 -4
View File
@@ -2,7 +2,11 @@ import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain' 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 { parseFormIds, queryIds } from '../http'
import { import {
BulkIdsRequest, BulkIdsRequest,
@@ -15,8 +19,35 @@ import {
ReputationDto, ReputationDto,
} from '../openapi' } from '../openapi'
import type { Context } from 'hono'
import type { Progression } from '@repo/domain'
import type { App } from '../context' 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<App>, progression: Progression): Promise<void> {
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 * 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. * earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
@@ -72,15 +103,19 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'], tags: ['Progression'],
summary: 'A players level and XP', summary: 'A players level and XP',
description: description:
'The XP banked in `progression` (game rewards pay into it from the `econ` worker). ' + 'The level and XP banked in `progression` (game rewards pay into it from the `econ` ' +
'A player who has earned none has no row and reads back as level 1 with 0 XP. ' + 'worker); `XP` is the progress into the current level, not a lifetime total. A ' +
'Levelling is not wired up yet, so `Level` is always 1.', '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 clients bar.',
parameters: [idParam('id', 'Account id')], parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players progression') }, responses: { 200: json(ProgressionDto, 'The players progression') },
}), }),
async (c) => { async (c) => {
const id = Number.parseInt(c.req.param('id'), 10) 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( .post(
+71 -4
View File
@@ -4,9 +4,13 @@ import { beforeAll, describe, expect, test } from 'vitest'
import { import {
addXp, addXp,
applyLevelUps,
GAME_VERSION, GAME_VERSION,
grantInvention, grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL, INVENTORY_INVENTION_SCHEMA_DDL,
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
PROGRESSION_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
@@ -306,13 +310,18 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 }) 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. // 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) await addXp(env.DB, 4242, 25)
const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`) 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 // 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. // 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` `${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243`
) )
expect(await bulk.json()).toEqual([ expect(await bulk.json()).toEqual([
{ PlayerId: 4242, Level: 1, XP: 50 }, { PlayerId: 4242, Level: 5, XP: 0 },
{ PlayerId: 4243, Level: 1, 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<number[]>((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 () => { test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, { const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST', method: 'POST',
+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 - **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 box), and so are consumables: they stack, so "don't have" never becomes false and they'd
crowd out the real prizes. 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 - **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
agree; sf3's boxes carry only `Rarity`. agree; sf3's boxes carry only `Rarity`.
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of - **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 **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 `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 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 inserts.
`RequiredXp` from the running XP, with thresholds from a config file (`configv2.json`'s
`LevelProgressionMaps`) we don't have. **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, **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 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. - 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 into `progression`; levelling never happens (no curve) and - Game rewards pay a flat 25 XP; there is no daily XP cap beyond the hourly cooldown (the
there is no daily XP cap beyond the hourly cooldown. 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 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 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 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, getGift,
getPendingGifts, getPendingGifts,
grantInvention, grantInvention,
levelReward,
levelsReached,
ownsInvention, ownsInvention,
} from '@repo/domain' } from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' 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 { claimReward } from './reward-db'
import type { Context } from 'hono' 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 { Avatar } from './avatar-db'
import type { ConsumeResult } from './consumables-db' import type { ConsumeResult } from './consumables-db'
import type { App } from './context' 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 * 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 * 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 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 * 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 * 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 * 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 * things owned once, and consumables stack, so a consumable would be rollable forever and
* would crowd out the real prizes. * 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( async function rollQueryDrop(
c: Context<App>, c: Context<App>,
accountId: number, accountId: number,
rarity: number, rarity: number,
rollCatalog?: StoreItem[] options: RollOptions = {}
): Promise<StoreGiftDrop | null> { ): Promise<StoreGiftDrop | null> {
const [catalog, ownedItems, ownedEquipment] = await Promise.all([ const [catalog, ownedItems, ownedEquipment] = await Promise.all([
rollCatalog ?? loadRollCatalog(c), options.rollCatalog ?? loadRollCatalog(c),
getInventory(c.env.DB, accountId), 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 haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc))
const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid)) const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid))
@@ -544,6 +593,7 @@ async function rollQueryDrop(
if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') { if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') {
return !haveItem.has(drop.AvatarItemDesc) return !haveItem.has(drop.AvatarItemDesc)
} }
if (options.avatarItemsOnly === true) return false
if ( if (
typeof drop.EquipmentModificationGuid === 'string' && typeof drop.EquipmentModificationGuid === 'string' &&
drop.EquipmentModificationGuid !== '' drop.EquipmentModificationGuid !== ''
@@ -566,6 +616,25 @@ interface GrantedGift {
drop: StoreGiftDrop 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 * 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) * equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet)
@@ -585,14 +654,12 @@ async function grantGiftDrop(
accountId: number, accountId: number,
drop: StoreGiftDrop, drop: StoreGiftDrop,
message: string, message: string,
// The roll catalog, when the caller has already read it — it's the big one (sf3), and options: RollOptions = {}
// the weekly gift has to consult it before it knows whether it's rolling at all.
rollCatalog?: StoreItem[]
): Promise<GrantedGift> { ): Promise<GrantedGift> {
let giftDrop = drop let giftDrop = drop
if (drop.IsQuery === true) { if (drop.IsQuery === true) {
const rarity = drop.QueryRedirectRarity ?? drop.Rarity 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) { if (rolled === null) {
logger.warn('query gift-drop rolled nothing', { logger.warn('query gift-drop rolled nothing', {
accountId, 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 * 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`, * 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, accountId,
duplicate ? toChallengeFallbackDrop() : reward, duplicate ? toChallengeFallbackDrop() : reward,
CHALLENGE_GIFT_MESSAGE, CHALLENGE_GIFT_MESSAGE,
catalog { rollCatalog: catalog }
) )
// Nobody asked for this box, so the client has no reason to re-read the gifts list: // 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. // 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 : DEFAULT_GAME_REWARD_MESSAGE
// Bank the XP first: it is the reward, and the box is the wrapper the client shows. // Bank the XP first: it is the reward, and the box is the wrapper the client shows.
// A failure here must not leave a box promising XP that was never credited. // A failure here must not leave a box promising XP that was never credited.
const progression = 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) const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID) 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', { logger.info('game reward claimed', {
accountId: id, accountId: id,
rewardType, rewardType,
grantCount: claimed, grantCount: claimed,
message, message,
xp: GAME_REWARD_XP, xp: GAME_REWARD_XP,
totalXp: progression.XP, level: progression.Level,
levelsGained,
levelXp: progression.XP,
giftId: granted.id, giftId: granted.id,
}) })
return c.json([]) return c.json([])
+55 -10
View File
@@ -1481,6 +1481,7 @@ describe('econ endpoints', () => {
Message: string Message: string
EquipmentModificationGuid: string EquipmentModificationGuid: string
AvatarItemDesc: string AvatarItemDesc: string
ConsumableItemDesc: string
GiftRarity: number GiftRarity: number
}> }>
} }
@@ -1723,13 +1724,16 @@ 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, not just displayed on the box. // The XP is banked and spent on levels: 25 pays the 10 to reach level 2 and the 10 to
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 25 }) // 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 — // Three boxes: the XP reward itself, then one per level it crossed.
// a game reward is not an item.
const boxes = await giftBoxes('82') 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({ expect(boxes[0]).toMatchObject({
Xp: 25, Xp: 25,
Message: 'First Game of the Day', Message: 'First Game of the Day',
@@ -1738,10 +1742,44 @@ describe('econ endpoints', () => {
ConsumableItemDesc: '', 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() 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]?.accountId).toBe(82)
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toMatchObject({ expect(frames[0]?.payload).toMatchObject({
Id: boxes[0]?.Id, Id: boxes[0]?.Id,
FromPlayerId: 1, FromPlayerId: 1,
@@ -1750,11 +1788,18 @@ describe('econ endpoints', () => {
GiftContext: 50, GiftContext: 50,
Message: 'First Game of the Day', 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 request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect((await getProgression(env.DB, 82)).XP).toBe(25) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(1) expect(await giftBoxes('82')).toHaveLength(3)
expect(await drainFrames()).toEqual([]) expect(await drainFrames()).toEqual([])
}) })
+8
View File
@@ -50,6 +50,14 @@ export enum NotificationType {
GiftPackageReceived = 30, GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31, GiftPackageReceivedImmediate = 31,
GiftPackageRewardSelectionReceived = 32, 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, ProfileJuniorStatusUpdate = 40,
RelationshipsInvalid = 50, RelationshipsInvalid = 50,
StorefrontBalanceAdd = 60, StorefrontBalanceAdd = 60,
+180 -12
View File
@@ -10,11 +10,9 @@
* level-1/0-XP default the progression endpoints already served, so reads fall back to it * level-1/0-XP default the progression endpoints already served, so reads fall back to it
* rather than inserting on a GET. * rather than inserting on a GET.
* *
* `level` is stored, not derived. The reference server levels a player up by subtracting * `level` is stored rather than derived, because `xp` is NOT lifetime XP: a level-up spends
* the tier's `RequiredXp` from the running XP, with the thresholds coming from a config * the tier's cost out of it (see {@link LEVEL_REQUIRED_XP}), so the pair is a level plus the
* file (`configv2.json`'s `LevelProgressionMaps`) that we don't have — so XP accumulates * progress into the next one — which is exactly what the client's bar draws.
* 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.
* *
* The `econ` worker owns the migration (apps/econ/migrations/0012_progression.sql), being * The `econ` worker owns the migration (apps/econ/migrations/0012_progression.sql), being
* the writer. * 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 1020 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,
// 110: consumables interleaved with the first clothing drops.
CONSUMABLE_REWARD,
10,
CONSUMABLE_REWARD,
10,
CONSUMABLE_REWARD,
CONSUMABLE_REWARD,
CONSUMABLE_REWARD,
10,
CONSUMABLE_REWARD,
10,
// 1120: 2-Star clothing all the way.
10,
10,
10,
10,
10,
10,
10,
10,
10,
10,
// 2130: 2-Star alternating with 3-Star.
10,
20,
10,
20,
10,
20,
10,
20,
10,
20,
// 3140: 3-Star with a 4-Star every few levels.
30,
20,
20,
20,
30,
20,
20,
20,
20,
30,
// 4150: 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. */ /** A player's progression, as the client's progression DTO renders it. */
export interface Progression { export interface Progression {
PlayerId: number PlayerId: number
@@ -41,16 +174,39 @@ export function defaultProgression(accountId: number): Progression {
return { PlayerId: accountId, Level: 1, XP: 0 } 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 * The levels a grant took the player THROUGH, in order — `[2, 3]` for the 25 XP that lifts a
* rewards landing together can't both read the same stale total and write it back — the * fresh player from level 1 to level 3. One entry per level reached, which is one reward
* client fires reward requests off right after a match. * 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 * 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. * 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<Progression> { export async function addXp(db: D1Database, accountId: number, xp: number): Promise<XpGrant> {
if (xp <= 0) return await getProgression(db, accountId) if (xp <= 0) return { progression: await getProgression(db, accountId), levelsGained: 0 }
const row = await db const row = await db
.prepare( .prepare(
`INSERT INTO progression (account_id, level, xp) VALUES (?1, 1, ?2) `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) .bind(accountId, xp)
.first<{ level: number; xp: number }>() .first<{ level: number; xp: number }>()
if (row === null) return defaultProgression(accountId) if (row === null) return { progression: defaultProgression(accountId), levelsGained: 0 }
return { PlayerId: accountId, Level: row.level, XP: row.xp }
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. */ /** One player's progression, defaulted when they've earned nothing yet. */