156 lines
4.2 KiB
TypeScript
156 lines
4.2 KiB
TypeScript
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<PlayerProgressionState> | 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),
|
|
};
|
|
}
|