193 lines
4.8 KiB
TypeScript
193 lines
4.8 KiB
TypeScript
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
|
||
|
||
type JsonRecord = Record<string, unknown>;
|
||
|
||
export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc';
|
||
|
||
export interface PlayerProgressionState {
|
||
level: number;
|
||
currentLevelXp: number;
|
||
totalXp: number;
|
||
xpToNextLevel: number;
|
||
pendingLevelUps?: number;
|
||
lastGrantedSource?: PlayerProgressionGrantSource | null;
|
||
}
|
||
|
||
export interface PlayerExperienceGrantResult {
|
||
state: PlayerProgressionState;
|
||
grantedXp: number;
|
||
previousLevel: number;
|
||
nextLevel: number;
|
||
levelUps: number;
|
||
leveledUp: boolean;
|
||
reachedMaxLevel: boolean;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is JsonRecord {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
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 normalizeLastGrantedSource(value: unknown) {
|
||
return value === 'quest' || value === 'hostile_npc' ? value : null;
|
||
}
|
||
|
||
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: PlayerProgressionGrantSource | null = 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: unknown,
|
||
): PlayerProgressionState {
|
||
if (!isRecord(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,
|
||
getLevelBenchmark(explicitLevel).xpToNextLevel,
|
||
);
|
||
|
||
return {
|
||
...buildProgressionStateFromTotalXp(
|
||
derivedTotalXp,
|
||
normalizeLastGrantedSource(value.lastGrantedSource),
|
||
),
|
||
pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps),
|
||
};
|
||
}
|
||
|
||
export function grantPlayerExperience(
|
||
value: unknown,
|
||
amount: number,
|
||
options: {
|
||
source: PlayerProgressionGrantSource;
|
||
},
|
||
): PlayerExperienceGrantResult {
|
||
const currentState = normalizePlayerProgressionState(value);
|
||
const grantedXp = clampNonNegativeInteger(amount);
|
||
|
||
if (grantedXp <= 0) {
|
||
return {
|
||
state: {
|
||
...currentState,
|
||
pendingLevelUps: 0,
|
||
},
|
||
grantedXp: 0,
|
||
previousLevel: currentState.level,
|
||
nextLevel: currentState.level,
|
||
levelUps: 0,
|
||
leveledUp: false,
|
||
reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL,
|
||
};
|
||
}
|
||
|
||
const nextState = buildProgressionStateFromTotalXp(
|
||
currentState.totalXp + grantedXp,
|
||
options.source,
|
||
);
|
||
const levelUps = Math.max(0, nextState.level - currentState.level);
|
||
|
||
return {
|
||
state: {
|
||
...nextState,
|
||
pendingLevelUps: 0,
|
||
},
|
||
grantedXp,
|
||
previousLevel: currentState.level,
|
||
nextLevel: nextState.level,
|
||
levelUps,
|
||
leveledUp: levelUps > 0,
|
||
reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL,
|
||
};
|
||
}
|
||
|
||
export function buildExperienceGrantResultText(
|
||
result: PlayerExperienceGrantResult,
|
||
) {
|
||
if (result.grantedXp <= 0) {
|
||
return '';
|
||
}
|
||
|
||
const parts = [`经验 +${result.grantedXp}`];
|
||
|
||
if (result.leveledUp) {
|
||
parts.push(
|
||
result.levelUps > 1
|
||
? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}`
|
||
: `升至 Lv.${result.nextLevel}`,
|
||
);
|
||
}
|
||
|
||
return `${parts.join(',')}。`;
|
||
}
|