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',
|
||||
|
||||
Reference in New Issue
Block a user