63
server-node/src/modules/progression/levelBenchmarks.ts
Normal file
63
server-node/src/modules/progression/levelBenchmarks.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface LevelBenchmark {
|
||||
level: number;
|
||||
xpToNextLevel: number;
|
||||
cumulativeXpRequired: number;
|
||||
referenceStrength: number;
|
||||
baseHp: number;
|
||||
baseMana: number;
|
||||
baselineDamageScale: number;
|
||||
}
|
||||
|
||||
export const MAX_PLAYER_LEVEL = 20;
|
||||
|
||||
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]!
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createInitialPlayerProgressionState,
|
||||
grantPlayerExperience,
|
||||
normalizePlayerProgressionState,
|
||||
} from './playerProgressionService.js';
|
||||
|
||||
test('player progression starts at level 1 with the first upgrade threshold', () => {
|
||||
const initialState = createInitialPlayerProgressionState();
|
||||
|
||||
assert.deepEqual(initialState, {
|
||||
level: 1,
|
||||
currentLevelXp: 0,
|
||||
totalXp: 0,
|
||||
xpToNextLevel: 60,
|
||||
pendingLevelUps: 0,
|
||||
lastGrantedSource: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('grantPlayerExperience upgrades level state from quest rewards', () => {
|
||||
const result = grantPlayerExperience(
|
||||
{
|
||||
level: 1,
|
||||
currentLevelXp: 50,
|
||||
totalXp: 50,
|
||||
xpToNextLevel: 60,
|
||||
},
|
||||
40,
|
||||
{
|
||||
source: 'quest',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.grantedXp, 40);
|
||||
assert.equal(result.previousLevel, 1);
|
||||
assert.equal(result.nextLevel, 2);
|
||||
assert.equal(result.levelUps, 1);
|
||||
assert.equal(result.state.level, 2);
|
||||
assert.equal(result.state.currentLevelXp, 30);
|
||||
assert.equal(result.state.totalXp, 90);
|
||||
assert.equal(result.state.xpToNextLevel, 88);
|
||||
assert.equal(result.state.lastGrantedSource, 'quest');
|
||||
});
|
||||
|
||||
test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => {
|
||||
const normalized = normalizePlayerProgressionState({
|
||||
level: 3,
|
||||
currentLevelXp: 15,
|
||||
});
|
||||
|
||||
assert.equal(normalized.level, 3);
|
||||
assert.equal(normalized.currentLevelXp, 15);
|
||||
assert.equal(normalized.totalXp, 163);
|
||||
assert.equal(normalized.xpToNextLevel, 132);
|
||||
});
|
||||
192
server-node/src/modules/progression/playerProgressionService.ts
Normal file
192
server-node/src/modules/progression/playerProgressionService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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(',')}。`;
|
||||
}
|
||||
Reference in New Issue
Block a user