[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 { 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 players 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 clients bar.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players 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(
+71 -4
View File
@@ -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',