mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
227 lines
9.6 KiB
TypeScript
227 lines
9.6 KiB
TypeScript
/**
|
||
* Player progression — the level and XP shown on a profile — on the shared `recflare` D1.
|
||
* One row per account, created on the first grant.
|
||
*
|
||
* Two workers share it, so it lives here rather than in either: `econ` WRITES it (game
|
||
* rewards pay XP out) and `api` READS it (`GET /api/players/v{1,2}/progression/…`). Same
|
||
* split as the gift boxes next door.
|
||
*
|
||
* A missing row is not an error — it means "nothing earned yet", which is exactly the
|
||
* 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 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.
|
||
*/
|
||
|
||
/** Schema DDL (mirror of apps/econ/migrations/0012_progression.sql). */
|
||
export const PROGRESSION_SCHEMA_DDL: string[] = [
|
||
`CREATE TABLE IF NOT EXISTS progression (
|
||
account_id INTEGER PRIMARY KEY,
|
||
level INTEGER NOT NULL DEFAULT 1,
|
||
xp INTEGER NOT NULL DEFAULT 0
|
||
)`,
|
||
]
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
// prettier-ignore — the row-per-decade layout is the table; packing it hides the shape.
|
||
// prettier-ignore
|
||
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 (a 25 XP grant would take a fresh player from level 1 to level 3, the first two
|
||
* levels costing 10 each).
|
||
*
|
||
* 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
|
||
Level: number
|
||
XP: number
|
||
}
|
||
|
||
/** What a player with no row has: nothing earned yet. */
|
||
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
|
||
}
|
||
|
||
/**
|
||
* The levels a grant took the player THROUGH, in order — `[2, 3]` for a grant 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, which is the common case for a
|
||
* game reward.
|
||
*/
|
||
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<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)
|
||
ON CONFLICT (account_id) DO UPDATE SET xp = progression.xp + excluded.xp
|
||
RETURNING level, xp`
|
||
)
|
||
.bind(accountId, xp)
|
||
.first<{ level: number; xp: number }>()
|
||
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. */
|
||
export async function getProgression(db: D1Database, accountId: number): Promise<Progression> {
|
||
const row = await db
|
||
.prepare('SELECT level, xp FROM progression WHERE account_id = ?1')
|
||
.bind(accountId)
|
||
.first<{ level: number; xp: number }>()
|
||
if (row === null) return defaultProgression(accountId)
|
||
return { PlayerId: accountId, Level: row.level, XP: row.xp }
|
||
}
|
||
|
||
/**
|
||
* Progressions for a list of ids, in the order asked and one per id — the bulk lookups
|
||
* render a profile card per entry, so an id with no row still gets its default rather than
|
||
* being dropped from the list.
|
||
*/
|
||
export async function getProgressions(
|
||
db: D1Database,
|
||
accountIds: number[]
|
||
): Promise<Progression[]> {
|
||
if (accountIds.length === 0) return []
|
||
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
|
||
const { results } = await db
|
||
.prepare(`SELECT account_id, level, xp FROM progression WHERE account_id IN (${placeholders})`)
|
||
.bind(...accountIds)
|
||
.all<{ account_id: number; level: number; xp: number }>()
|
||
const stored = new Map(results.map((r) => [r.account_id, r]))
|
||
return accountIds.map((id) => {
|
||
const row = stored.get(id)
|
||
return row === undefined
|
||
? defaultProgression(id)
|
||
: { PlayerId: id, Level: row.level, XP: row.xp }
|
||
})
|
||
}
|