import type { PlayerProgressionState } from '../types'; export interface LevelBenchmark { level: number; xpToNextLevel: number; cumulativeXpRequired: number; referenceStrength: number; baseHp: number; baseMana: number; baselineDamageScale: number; } export const MAX_PLAYER_LEVEL = 20; function clampNonNegativeInteger(value: unknown) { if (typeof value !== 'number' || !Number.isFinite(value)) { return 0; } return Math.max(0, Math.floor(value)); } function clampLevel(value: unknown) { if (typeof value !== 'number' || !Number.isFinite(value)) { return 1; } return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); } function roundMetric(value: number, digits = 3) { return Number(value.toFixed(digits)); } function computeXpToNextLevel(level: number) { const scale = Math.max(0, level - 1); return 60 + 20 * scale + 8 * scale * scale; } function buildLevelBenchmarks(maxLevel: number) { const benchmarks: LevelBenchmark[] = []; let cumulativeXpRequired = 0; for (let level = 1; level <= maxLevel; level += 1) { const scale = level - 1; const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level); benchmarks.push({ level, xpToNextLevel, cumulativeXpRequired, referenceStrength: 100 + 16 * scale + 6 * scale * scale, baseHp: 180 + 24 * scale + 10 * scale * scale, baseMana: 80 + 14 * scale + 6 * scale * scale, baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale), }); cumulativeXpRequired += xpToNextLevel; } return benchmarks; } const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL); const LEVEL_BENCHMARKS_BY_LEVEL = new Map( LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]), ); export function getLevelBenchmark(level: number) { return ( LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]! ); } export function getPlayerXpToNextLevel(level: number) { return getLevelBenchmark(level).xpToNextLevel; } function resolveLevelFromTotalXp(totalXp: number) { let resolvedLevel = 1; for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) { if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) { break; } resolvedLevel = level; } return resolvedLevel; } function buildProgressionStateFromTotalXp( totalXp: number, lastGrantedSource: PlayerProgressionState['lastGrantedSource'] = null, ): PlayerProgressionState { const normalizedTotalXp = clampNonNegativeInteger(totalXp); const level = resolveLevelFromTotalXp(normalizedTotalXp); const benchmark = getLevelBenchmark(level); if (level >= MAX_PLAYER_LEVEL) { return { level, currentLevelXp: 0, totalXp: normalizedTotalXp, xpToNextLevel: 0, pendingLevelUps: 0, lastGrantedSource, }; } return { level, currentLevelXp: Math.max( 0, normalizedTotalXp - benchmark.cumulativeXpRequired, ), totalXp: normalizedTotalXp, xpToNextLevel: benchmark.xpToNextLevel, pendingLevelUps: 0, lastGrantedSource, }; } export function createInitialPlayerProgressionState(): PlayerProgressionState { return buildProgressionStateFromTotalXp(0); } export function normalizePlayerProgressionState( value: Partial | null | undefined, ): PlayerProgressionState { if (!value) { return createInitialPlayerProgressionState(); } const explicitLevel = clampLevel(value.level); const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp); const totalXp = clampNonNegativeInteger(value.totalXp); const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0; const derivedTotalXp = totalXp > 0 || !hasExplicitProgress ? totalXp : getLevelBenchmark(explicitLevel).cumulativeXpRequired + Math.min(explicitCurrentLevelXp, getPlayerXpToNextLevel(explicitLevel)); const lastGrantedSource = value.lastGrantedSource === 'quest' || value.lastGrantedSource === 'hostile_npc' ? value.lastGrantedSource : null; return { ...buildProgressionStateFromTotalXp(derivedTotalXp, lastGrantedSource), pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps), }; }