1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View 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]!
);
}

View File

@@ -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);
});

View 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('')}`;
}