mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] levels
This commit is contained in:
@@ -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<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
|
||||
* 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'],
|
||||
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(
|
||||
|
||||
@@ -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<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 () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
|
||||
method: 'POST',
|
||||
|
||||
+56
-5
@@ -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
|
||||
|
||||
+165
-11
@@ -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 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<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([])
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Progression> {
|
||||
if (xp <= 0) return await getProgression(db, accountId)
|
||||
export async function addXp(db: D1Database, accountId: number, xp: number): Promise<XpGrant> {
|
||||
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. */
|
||||
|
||||
Reference in New Issue
Block a user