[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
+180 -12
View File
@@ -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 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. */
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. */