mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[econ] levels
This commit is contained in:
@@ -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