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

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
import {
buildChapterProgressionPlans,
resolveCurrentChapterProgressionContext,
} from './chapterProgressionPlanner.js';
function createProgressionProfile() {
return {
id: 'custom-world-progression',
settingText: '测试世界',
name: '测试世界',
subtitle: '章节成长测试',
summary: '用于章节成长规划测试。',
tone: '紧张',
playerGoal: '推进章节',
templateWorldType: 'CUSTOM',
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema-1',
worldId: 'custom-world-progression',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '测试世界',
settingSummary: '测试',
tone: '紧张',
conflictCore: '推进',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc_chapter_1_raider',
name: '谷口匪徒',
title: '匪徒',
role: '敌对角色',
description: '盘踞谷口的劫匪',
backstory: '',
personality: '',
motivation: '',
combatStyle: '近战',
initialAffinity: -30,
relationshipHooks: [],
tags: ['hostile'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
{
id: 'npc_chapter_2_hunter',
name: '林地猎手',
title: '追猎者',
role: '敌对角色',
description: '在林间追猎闯入者',
backstory: '',
personality: '',
motivation: '',
combatStyle: '远程',
initialAffinity: -24,
relationshipHooks: [],
tags: ['hostile'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
{
id: 'npc_chapter_3_lord',
name: '祭坛领主',
title: '镇守者',
role: '敌对首领',
description: '守在祭坛深处的最终敌人',
backstory: '',
personality: '',
motivation: '',
combatStyle: '重压',
initialAffinity: -40,
relationshipHooks: [],
tags: ['hostile', 'boss'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'scene-1',
title: '第一章',
summary: '谷口起势',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-1-open',
sceneId: 'scene-1',
title: '谷口相撞',
summary: '初次冲突',
stageCoverage: ['opening'],
encounterNpcIds: ['npc_chapter_1_raider'],
primaryNpcId: 'npc_chapter_1_raider',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '打开局面',
transitionHook: '继续深入',
},
],
},
{
id: 'chapter-2',
sceneId: 'scene-2',
title: '第二章',
summary: '林地围猎',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-2-mid',
sceneId: 'scene-2',
title: '林地追击',
summary: '压力上升',
stageCoverage: ['expansion', 'turning_point'],
encounterNpcIds: ['npc_chapter_2_hunter'],
primaryNpcId: 'npc_chapter_2_hunter',
linkedThreadIds: [],
advanceRule: 'after_active_step_complete',
actGoal: '逼近真相',
transitionHook: '抵达深处',
},
],
},
{
id: 'chapter-3',
sceneId: 'scene-3',
title: '第三章',
summary: '祭坛对决',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-3-final',
sceneId: 'scene-3',
title: '祭坛收束',
summary: '正面收口',
stageCoverage: ['climax'],
encounterNpcIds: ['npc_chapter_3_lord'],
primaryNpcId: 'npc_chapter_3_lord',
linkedThreadIds: [],
advanceRule: 'after_chapter_resolution',
actGoal: '击败首领',
transitionHook: '收束余波',
},
],
},
],
} as unknown as CustomWorldProfile;
}
test('buildChapterProgressionPlans builds increasing chapter budgets from blueprints', () => {
const plans = buildChapterProgressionPlans(createProgressionProfile());
assert.equal(plans.length, 3);
assert.deepEqual(
plans.map((plan) => plan.chapterIndex),
[1, 2, 3],
);
assert.ok(plans[1]!.entryPseudoLevel > plans[0]!.entryPseudoLevel);
assert.ok(plans[2]!.exitPseudoLevel > plans[1]!.exitPseudoLevel);
assert.equal(
plans[0]!.questXpBudget + plans[0]!.hostileXpBudget,
plans[0]!.totalXpBudget,
);
assert.ok(plans[2]!.totalXpBudget >= plans[0]!.totalXpBudget);
assert.ok(plans[2]!.hostileXpBudget >= plans[0]!.hostileXpBudget);
});
test('resolveCurrentChapterProgressionContext follows the current act and explicit stage', () => {
const context = resolveCurrentChapterProgressionContext({
customWorldProfile: createProgressionProfile(),
sceneId: 'scene-2',
chapterState: {
id: 'chapter-2',
stage: 'turning_point',
sceneId: 'scene-2',
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-2',
stage: 'turning_point',
sceneId: 'scene-2',
},
currentSceneActState: {
sceneId: 'scene-2',
chapterId: 'chapter-2',
currentActId: 'act-2-mid',
currentActIndex: 0,
},
},
});
assert.ok(context);
assert.equal(context?.plan.chapterId, 'chapter-2');
assert.equal(context?.plan.chapterIndex, 2);
assert.equal(context?.activeAct?.id, 'act-2-mid');
assert.equal(context?.stage, 'turning_point');
});

View File

@@ -0,0 +1,480 @@
import type {
CustomWorldProfile,
SceneActBlueprint,
SceneActStage,
SceneChapterBlueprint,
} from '../custom-world/runtimeTypes.js';
type JsonRecord = Record<string, unknown>;
type ChapterStateLike = {
id: string;
stage: SceneActStage;
sceneId: string | null;
};
type SceneActRuntimeStateLike = {
sceneId: string;
chapterId: string;
currentActId: string;
currentActIndex: number;
};
export type ChapterPaceBand =
| 'opening_fast'
| 'steady'
| 'pressure'
| 'finale_dense';
export interface ChapterProgressionPlan {
chapterId: string;
chapterIndex: number;
totalChapters: number;
entryPseudoLevel: number;
exitPseudoLevel: number;
entryLevel: number;
exitLevel: number;
totalXpBudget: number;
questXpBudget: number;
hostileXpBudget: number;
expectedHostileDefeatCount: number;
paceBand: ChapterPaceBand;
}
export interface ChapterProgressionContext {
plan: ChapterProgressionPlan;
activeChapter: SceneChapterBlueprint;
activeAct: SceneActBlueprint | null;
stage: SceneActStage;
}
const DEFAULT_STAGE: SceneActStage = 'opening';
const DEFAULT_TERMINAL_STORY_LEVEL = 15;
const MIN_TERMINAL_STORY_LEVEL = 5;
const PSEUDO_LEVEL_CURVE_EXPONENT = 0.92;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function roundToNearestFive(value: number) {
return Math.round(value / 5) * 5;
}
function normalizeStage(value: unknown): SceneActStage | null {
return value === 'opening' ||
value === 'expansion' ||
value === 'turning_point' ||
value === 'climax' ||
value === 'aftermath'
? value
: null;
}
function readChapterState(value: unknown): ChapterStateLike | null {
if (!isRecord(value)) {
return null;
}
const id = readString(value.id);
const stage = normalizeStage(value.stage);
if (!id || !stage) {
return null;
}
return {
id,
stage,
sceneId: readString(value.sceneId) || null,
};
}
function readSceneActRuntimeState(value: unknown): SceneActRuntimeStateLike | null {
if (!isRecord(value)) {
return null;
}
const sceneId = readString(value.sceneId);
const chapterId = readString(value.chapterId);
const currentActId = readString(value.currentActId);
const currentActIndex = readNumber(value.currentActIndex);
if (!sceneId || !chapterId || !currentActId || currentActIndex === null) {
return null;
}
return {
sceneId,
chapterId,
currentActId,
currentActIndex: Math.max(0, Math.round(currentActIndex)),
};
}
function readStoryEngineMemoryChapter(value: unknown) {
return readChapterState(isRecord(value) ? value.currentChapter : null);
}
function readStoryEngineMemoryActState(value: unknown) {
return readSceneActRuntimeState(
isRecord(value) ? value.currentSceneActState : null,
);
}
function getChapterBlueprints(
profile: CustomWorldProfile | null | undefined,
) {
return (profile?.sceneChapterBlueprints ?? []).filter(
(entry): entry is SceneChapterBlueprint =>
Boolean(entry?.id && entry.sceneId && Array.isArray(entry.acts)),
);
}
function resolveExplicitStage(params: {
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
return (
readChapterState(params.chapterState)?.stage ??
readStoryEngineMemoryChapter(params.storyEngineMemory)?.stage ??
null
);
}
function pickActStage(act: SceneActBlueprint | null) {
if (!act) {
return null;
}
return act.stageCoverage
.map((stage) => normalizeStage(stage))
.find((stage): stage is SceneActStage => Boolean(stage)) ?? null;
}
function resolveActiveChapterBlueprint(params: {
customWorldProfile?: CustomWorldProfile | null;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
const chapters = getChapterBlueprints(params.customWorldProfile);
if (chapters.length <= 0) {
return null;
}
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
if (runtimeActState) {
const matchedByActState = chapters.find(
(chapter) =>
chapter.id === runtimeActState.chapterId &&
chapter.sceneId === runtimeActState.sceneId,
);
if (matchedByActState) {
return matchedByActState;
}
}
const requestedSceneId =
readString(params.sceneId) ||
readChapterState(params.chapterState)?.sceneId ||
readStoryEngineMemoryChapter(params.storyEngineMemory)?.sceneId ||
'';
if (requestedSceneId) {
const matchedByScene = chapters.find(
(chapter) =>
chapter.sceneId === requestedSceneId ||
chapter.linkedLandmarkIds.includes(requestedSceneId),
);
if (matchedByScene) {
return matchedByScene;
}
}
const explicitChapterId =
readChapterState(params.chapterState)?.id ||
readStoryEngineMemoryChapter(params.storyEngineMemory)?.id ||
'';
if (explicitChapterId) {
const matchedById = chapters.find((chapter) => chapter.id === explicitChapterId);
if (matchedById) {
return matchedById;
}
}
return chapters[0] ?? null;
}
function resolveActiveActBlueprint(params: {
activeChapter: SceneChapterBlueprint;
explicitStage?: SceneActStage | null;
storyEngineMemory?: unknown;
}) {
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
if (
runtimeActState &&
runtimeActState.chapterId === params.activeChapter.id &&
runtimeActState.sceneId === params.activeChapter.sceneId
) {
const matchedById = params.activeChapter.acts.find(
(act) => act.id === runtimeActState.currentActId,
);
if (matchedById) {
return matchedById;
}
const matchedByIndex = params.activeChapter.acts[runtimeActState.currentActIndex];
if (matchedByIndex) {
return matchedByIndex;
}
}
if (params.explicitStage) {
const matchedByStage = params.activeChapter.acts.find((act) =>
act.stageCoverage.includes(params.explicitStage!),
);
if (matchedByStage) {
return matchedByStage;
}
}
return params.activeChapter.acts[0] ?? null;
}
function resolveTerminalStoryLevel(totalChapters: number) {
return Math.max(
MIN_TERMINAL_STORY_LEVEL,
Math.min(
DEFAULT_TERMINAL_STORY_LEVEL,
Math.round(3 + Math.max(1, totalChapters) * 2.4),
),
);
}
function computeXpToNextLevel(level: number) {
const scale = Math.max(0, level - 1);
return 60 + 20 * scale + 8 * scale * scale;
}
function resolvePseudoLevelXp(pseudoLevel: number) {
const normalizedLevel = Math.max(1, pseudoLevel);
const lowerLevel = Math.floor(normalizedLevel);
let lowerLevelXp = 0;
for (let level = 1; level < lowerLevel; level += 1) {
lowerLevelXp += computeXpToNextLevel(level);
}
return (
lowerLevelXp +
computeXpToNextLevel(lowerLevel) * (normalizedLevel - lowerLevel)
);
}
function resolveChapterBoundaryPseudoLevel(params: {
boundaryIndex: number;
totalChapters: number;
}) {
if (params.boundaryIndex <= 0 || params.totalChapters <= 0) {
return 1;
}
const progress = Math.min(
1,
Math.max(0, params.boundaryIndex / params.totalChapters),
);
const terminalStoryLevel = resolveTerminalStoryLevel(params.totalChapters);
return (
1 +
Math.pow(progress, PSEUDO_LEVEL_CURVE_EXPONENT) *
Math.max(0, terminalStoryLevel - 1)
);
}
function resolveEncounterNpcIds(chapter: SceneChapterBlueprint) {
return [...new Set(chapter.acts.flatMap((act) => act.encounterNpcIds))];
}
function isLikelyHostileNpc(
profile: CustomWorldProfile,
npcId: string,
) {
const matchedNpc = profile.storyNpcs.find((npc) => npc.id === npcId);
if (!matchedNpc) {
return /hostile|enemy|monster|bandit|boss|elite||||/u.test(npcId);
}
if (matchedNpc.initialAffinity < 0) {
return true;
}
const fingerprint = [
matchedNpc.role,
matchedNpc.name,
matchedNpc.title,
matchedNpc.description,
...matchedNpc.tags,
].join(' ');
return /hostile|enemy|monster|bandit|boss|elite||||||/u.test(
fingerprint,
);
}
function resolveHostileShare(params: {
totalEncounterCount: number;
hostileEncounterCount: number;
}) {
if (params.hostileEncounterCount <= 0) {
return 0;
}
const hostileRatio =
params.hostileEncounterCount / Math.max(1, params.totalEncounterCount);
if (hostileRatio >= 0.55) {
return 0.45;
}
if (hostileRatio <= 0.2) {
return 0.25;
}
return 0.35;
}
function resolveChapterPaceBand(params: {
chapterIndex: number;
totalChapters: number;
hostileShare: number;
}) {
if (params.chapterIndex <= 1) {
return 'opening_fast' as const;
}
if (params.chapterIndex >= params.totalChapters) {
return 'finale_dense' as const;
}
if (params.hostileShare >= 0.45) {
return 'pressure' as const;
}
return 'steady' as const;
}
function buildChapterPlan(params: {
profile: CustomWorldProfile;
chapter: SceneChapterBlueprint;
chapterIndex: number;
totalChapters: number;
}) {
const entryPseudoLevel = resolveChapterBoundaryPseudoLevel({
boundaryIndex: params.chapterIndex - 1,
totalChapters: params.totalChapters,
});
const exitPseudoLevel = resolveChapterBoundaryPseudoLevel({
boundaryIndex: params.chapterIndex,
totalChapters: params.totalChapters,
});
const totalXpBudget = Math.max(
40,
roundToNearestFive(
resolvePseudoLevelXp(exitPseudoLevel) -
resolvePseudoLevelXp(entryPseudoLevel),
),
);
const encounterNpcIds = resolveEncounterNpcIds(params.chapter);
const hostileEncounterCount = encounterNpcIds.filter((npcId) =>
isLikelyHostileNpc(params.profile, npcId),
).length;
const hostileShare = resolveHostileShare({
totalEncounterCount: encounterNpcIds.length,
hostileEncounterCount,
});
const expectedHostileDefeatCount =
hostileEncounterCount > 0
? Math.max(hostileEncounterCount, Math.min(encounterNpcIds.length, 3))
: 0;
const hostileXpBudget =
expectedHostileDefeatCount > 0
? Math.max(5, roundToNearestFive(totalXpBudget * hostileShare))
: 0;
const questXpBudget = Math.max(0, totalXpBudget - hostileXpBudget);
return {
chapterId: params.chapter.id,
chapterIndex: params.chapterIndex,
totalChapters: params.totalChapters,
entryPseudoLevel: Number(entryPseudoLevel.toFixed(3)),
exitPseudoLevel: Number(exitPseudoLevel.toFixed(3)),
entryLevel: Math.max(1, Math.floor(entryPseudoLevel)),
exitLevel: Math.max(1, Math.round(exitPseudoLevel)),
totalXpBudget,
questXpBudget,
hostileXpBudget,
expectedHostileDefeatCount,
paceBand: resolveChapterPaceBand({
chapterIndex: params.chapterIndex,
totalChapters: params.totalChapters,
hostileShare,
}),
} satisfies ChapterProgressionPlan;
}
export function buildChapterProgressionPlans(
customWorldProfile: CustomWorldProfile | null | undefined,
) {
const chapters = getChapterBlueprints(customWorldProfile);
if (!customWorldProfile || chapters.length <= 0) {
return [];
}
return chapters.map((chapter, index) =>
buildChapterPlan({
profile: customWorldProfile,
chapter,
chapterIndex: index + 1,
totalChapters: chapters.length,
}),
);
}
export function resolveCurrentChapterProgressionContext(params: {
customWorldProfile?: CustomWorldProfile | null;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
const activeChapter = resolveActiveChapterBlueprint(params);
if (!activeChapter || !params.customWorldProfile) {
return null;
}
const plans = buildChapterProgressionPlans(params.customWorldProfile);
const plan = plans.find((entry) => entry.chapterId === activeChapter.id);
if (!plan) {
return null;
}
const explicitStage = resolveExplicitStage(params);
const activeAct = resolveActiveActBlueprint({
activeChapter,
explicitStage,
storyEngineMemory: params.storyEngineMemory,
});
return {
plan,
activeChapter,
activeAct,
stage: explicitStage ?? pickActStage(activeAct) ?? DEFAULT_STAGE,
} satisfies ChapterProgressionContext;
}

View File

@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
import { resolveHostileBattleProfile } from './hostileProgressionService.js';
function createAutoScaledProfile() {
return {
id: 'custom-world-auto-level',
settingText: '测试世界',
name: '测试世界',
subtitle: '自动定级',
summary: '用于 hostile 自动定级测试。',
tone: '压迫',
playerGoal: '推进终章',
templateWorldType: 'CUSTOM',
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema-1',
worldId: 'custom-world-auto-level',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '测试世界',
settingSummary: '测试',
tone: '压迫',
conflictCore: '推进',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc_chapter_final',
name: '祭坛领主',
title: '镇守者',
role: '敌对首领',
description: '最终守关者',
backstory: '',
personality: '',
motivation: '',
combatStyle: '重压',
initialAffinity: -40,
relationshipHooks: [],
tags: ['hostile', 'boss'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-final',
sceneId: 'scene-final',
title: '终章',
summary: '最终对决',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-final',
sceneId: 'scene-final',
title: '祭坛收束',
summary: '最终收口',
stageCoverage: ['climax'],
encounterNpcIds: ['npc_chapter_final'],
primaryNpcId: 'npc_chapter_final',
linkedThreadIds: [],
advanceRule: 'after_chapter_resolution',
actGoal: '击败首领',
transitionHook: '进入余波',
},
],
},
],
} as unknown as CustomWorldProfile;
}
test('resolveHostileBattleProfile falls back to the current player level for standard hostiles', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 5,
currentLevelXp: 0,
totalXp: 472,
xpToNextLevel: 268,
},
encounter: {
hostile: true,
monsterPresetId: 'monster-01',
},
battleMode: 'fight',
});
assert.equal(profile.levelProfile.level, 5);
assert.equal(profile.levelProfile.progressionRole, 'hostile_standard');
assert.equal(profile.levelProfile.referenceStrength, 260);
assert.equal(profile.experienceReward, 20);
assert.equal(profile.battleMaxHp, 48);
});
test('resolveHostileBattleProfile preserves explicit level metadata and rewards', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 4,
currentLevelXp: 0,
totalXp: 280,
xpToNextLevel: 192,
},
encounter: {
hostile: true,
levelProfile: {
level: 7,
referenceStrength: 412,
progressionRole: 'hostile_elite',
source: 'chapter_auto',
chapterId: 'chapter-03',
},
experienceReward: 55,
},
battleMode: 'fight',
});
assert.equal(profile.levelProfile.level, 7);
assert.equal(profile.levelProfile.referenceStrength, 412);
assert.equal(profile.levelProfile.progressionRole, 'hostile_elite');
assert.equal(profile.levelProfile.source, 'chapter_auto');
assert.equal(profile.levelProfile.chapterId, 'chapter-03');
assert.equal(profile.experienceReward, 55);
assert.equal(profile.battleMaxHp, 86);
});
test('resolveHostileBattleProfile prefers chapter auto scaling over player fallback when chapter context exists', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 2,
currentLevelXp: 0,
totalXp: 80,
xpToNextLevel: 88,
},
encounter: {
id: 'npc_chapter_final',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
customWorldProfile: createAutoScaledProfile(),
sceneId: 'scene-final',
chapterState: {
id: 'chapter-final',
stage: 'climax',
sceneId: 'scene-final',
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-final',
stage: 'climax',
sceneId: 'scene-final',
},
currentSceneActState: {
sceneId: 'scene-final',
chapterId: 'chapter-final',
currentActId: 'act-final',
currentActIndex: 0,
},
},
});
assert.equal(profile.levelProfile.source, 'chapter_auto');
assert.equal(profile.levelProfile.chapterId, 'chapter-final');
assert.equal(profile.levelProfile.chapterIndex, 1);
assert.equal(profile.levelProfile.progressionRole, 'hostile_boss');
assert.ok(profile.levelProfile.level > 2);
assert.ok(profile.experienceReward > 0);
});

View File

@@ -0,0 +1,353 @@
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
import { normalizePlayerProgressionState } from './playerProgressionService.js';
import type { CustomWorldProfile, SceneActStage } from '../custom-world/runtimeTypes.js';
import {
resolveCurrentChapterProgressionContext,
type ChapterProgressionContext,
} from './chapterProgressionPlanner.js';
import { resolveChapterAutoLevelProfile } from './npcLevelResolver.js';
type JsonRecord = Record<string, unknown>;
export type ProgressionRole =
| 'guide'
| 'ambient'
| 'support'
| 'hostile_standard'
| 'hostile_elite'
| 'hostile_boss'
| 'rival';
export interface RuntimeEntityLevelProfile {
level: number;
referenceStrength: number;
chapterId?: string | null;
chapterIndex?: number | null;
progressionRole: ProgressionRole;
source: 'chapter_auto' | 'preset_override' | 'manual';
}
export interface RuntimeHostileEncounterSeed {
id?: string | null;
hostile?: boolean;
monsterPresetId?: string | null;
levelProfile?: unknown;
experienceReward?: unknown;
}
export interface ResolvedHostileBattleProfile {
levelProfile: RuntimeEntityLevelProfile;
experienceReward: number;
battleMaxHp: number;
}
const ROLE_HP_BONUS: Record<ProgressionRole, number> = {
guide: 0,
ambient: 0,
support: 0,
hostile_standard: 0,
hostile_elite: 10,
hostile_boss: 24,
rival: 6,
};
const ROLE_XP_MULTIPLIER: Record<ProgressionRole, number> = {
guide: 0,
ambient: 0,
support: 0,
hostile_standard: 1,
hostile_elite: 1.15,
hostile_boss: 1.3,
rival: 1,
};
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function clampLevel(value: unknown) {
const parsed =
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, parsed));
}
function clampNonNegativeInteger(value: unknown) {
const parsed =
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
return Math.max(0, parsed);
}
function roundToNearestFive(value: number) {
return Math.round(value / 5) * 5;
}
function normalizeProgressionRole(
value: unknown,
fallback: ProgressionRole,
): ProgressionRole {
return value === 'guide' ||
value === 'ambient' ||
value === 'support' ||
value === 'hostile_standard' ||
value === 'hostile_elite' ||
value === 'hostile_boss' ||
value === 'rival'
? value
: fallback;
}
function normalizeLevelProfileSource(
value: unknown,
fallback: RuntimeEntityLevelProfile['source'],
) {
return value === 'chapter_auto' ||
value === 'preset_override' ||
value === 'manual'
? value
: fallback;
}
function resolveDefaultRole(params: {
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
}): ProgressionRole {
if (params.battleMode === 'spar') {
return 'rival';
}
if (
params.encounter?.hostile === true ||
readString(params.encounter?.monsterPresetId).length > 0
) {
return 'hostile_standard';
}
return 'rival';
}
function resolveLevelDeltaMultiplier(playerLevel: number, targetLevel: number) {
const delta = targetLevel - playerLevel;
if (delta <= -4) {
return 0.3;
}
if (delta <= -2) {
return 0.7;
}
if (delta >= 2) {
return 1.15;
}
return 1;
}
function resolveChapterStageMultiplier(stage: SceneActStage | null | undefined) {
switch (stage) {
case 'opening':
return 0.9;
case 'turning_point':
return 1.05;
case 'climax':
return 1.15;
case 'aftermath':
return 0.8;
case 'expansion':
default:
return 1;
}
}
function resolveCustomWorldProfile(value: unknown) {
return isRecord(value) ? (value as CustomWorldProfile) : null;
}
function resolveChapterBudgetedBaseXp(
context: ChapterProgressionContext | null,
) {
if (!context || context.plan.expectedHostileDefeatCount <= 0) {
return null;
}
return (
context.plan.hostileXpBudget / context.plan.expectedHostileDefeatCount
);
}
export function normalizeRuntimeEntityLevelProfile(
value: unknown,
fallbackRole: ProgressionRole = 'hostile_standard',
): RuntimeEntityLevelProfile | null {
if (!isRecord(value)) {
return null;
}
const levelMetric = readNumber(value.level);
if (levelMetric === null) {
return null;
}
const level = clampLevel(levelMetric);
const benchmark = getLevelBenchmark(level);
const referenceStrength = readNumber(value.referenceStrength);
return {
level,
referenceStrength:
referenceStrength !== null && referenceStrength > 0
? Math.round(referenceStrength)
: benchmark.referenceStrength,
chapterId: readString(value.chapterId) || null,
chapterIndex:
typeof value.chapterIndex === 'number' &&
Number.isFinite(value.chapterIndex)
? Math.max(0, Math.round(value.chapterIndex))
: null,
progressionRole: normalizeProgressionRole(
value.progressionRole,
fallbackRole,
),
source: normalizeLevelProfileSource(value.source, 'manual'),
};
}
export function buildHostileExperienceReward(params: {
explicitExperienceReward?: unknown;
levelProfile: RuntimeEntityLevelProfile;
playerProgression?: unknown;
battleMode: 'fight' | 'spar';
chapterStage?: SceneActStage | null;
budgetedBaseXp?: number | null;
}) {
if (params.battleMode === 'spar') {
return 0;
}
const explicitReward = clampNonNegativeInteger(
params.explicitExperienceReward,
);
if (explicitReward > 0) {
return explicitReward;
}
const playerLevel = normalizePlayerProgressionState(
params.playerProgression,
).level;
const benchmark = getLevelBenchmark(params.levelProfile.level);
const baseKillXp =
typeof params.budgetedBaseXp === 'number' &&
Number.isFinite(params.budgetedBaseXp) &&
params.budgetedBaseXp > 0
? params.budgetedBaseXp
: benchmark.xpToNextLevel * 0.08;
const scaledReward =
baseKillXp *
resolveChapterStageMultiplier(params.chapterStage) *
resolveLevelDeltaMultiplier(playerLevel, params.levelProfile.level) *
ROLE_XP_MULTIPLIER[params.levelProfile.progressionRole];
return Math.max(5, roundToNearestFive(scaledReward));
}
export function buildHostileBattleMaxHp(params: {
levelProfile: RuntimeEntityLevelProfile;
battleMode: 'fight' | 'spar';
}) {
if (params.battleMode === 'spar') {
return Math.max(
8,
Math.min(16, 8 + Math.floor((params.levelProfile.level - 1) / 2)),
);
}
const benchmark = getLevelBenchmark(params.levelProfile.level);
return Math.max(
32,
Math.round(benchmark.baseHp / 9) +
ROLE_HP_BONUS[params.levelProfile.progressionRole],
);
}
export function resolveHostileBattleProfile(params: {
playerProgression?: unknown;
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
customWorldProfile?: unknown;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}): ResolvedHostileBattleProfile {
const fallbackRole = resolveDefaultRole({
encounter: params.encounter,
battleMode: params.battleMode,
});
const normalizedPlayerProgression = normalizePlayerProgressionState(
params.playerProgression,
);
const explicitLevelProfile = normalizeRuntimeEntityLevelProfile(
params.encounter?.levelProfile,
fallbackRole,
);
const chapterContext =
explicitLevelProfile?.source === 'chapter_auto'
? null
: resolveCurrentChapterProgressionContext({
customWorldProfile: resolveCustomWorldProfile(
params.customWorldProfile,
),
sceneId: params.sceneId,
chapterState: params.chapterState,
storyEngineMemory: params.storyEngineMemory,
});
const chapterAutoLevelProfile =
explicitLevelProfile || !chapterContext
? null
: resolveChapterAutoLevelProfile({
plan: chapterContext.plan,
stage: chapterContext.stage,
encounter: params.encounter,
battleMode: params.battleMode,
primaryNpcId: chapterContext.activeAct?.primaryNpcId ?? null,
});
const level =
explicitLevelProfile?.level ??
chapterAutoLevelProfile?.level ??
clampLevel(normalizedPlayerProgression.level);
const benchmark = getLevelBenchmark(level);
const levelProfile =
explicitLevelProfile ??
chapterAutoLevelProfile ??
({
level,
referenceStrength: benchmark.referenceStrength,
chapterId: null,
chapterIndex: null,
progressionRole: fallbackRole,
source: 'manual',
} satisfies RuntimeEntityLevelProfile);
return {
levelProfile,
experienceReward: buildHostileExperienceReward({
explicitExperienceReward: params.encounter?.experienceReward,
levelProfile,
playerProgression: normalizedPlayerProgression,
battleMode: params.battleMode,
chapterStage: chapterContext?.stage ?? null,
budgetedBaseXp: resolveChapterBudgetedBaseXp(chapterContext),
}),
battleMaxHp: buildHostileBattleMaxHp({
levelProfile,
battleMode: params.battleMode,
}),
};
}

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
import {
resolveAutoProgressionRole,
resolveChapterAutoLevelProfile,
} from './npcLevelResolver.js';
const TEST_PLAN: ChapterProgressionPlan = {
chapterId: 'chapter-3',
chapterIndex: 3,
totalChapters: 4,
entryPseudoLevel: 6.2,
exitPseudoLevel: 8.8,
entryLevel: 6,
exitLevel: 9,
totalXpBudget: 560,
questXpBudget: 360,
hostileXpBudget: 200,
expectedHostileDefeatCount: 3,
paceBand: 'pressure',
};
test('resolveAutoProgressionRole upgrades current act hostile primary npc to boss in climax', () => {
assert.equal(
resolveAutoProgressionRole({
encounter: {
id: 'npc_final_lord',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
stage: 'climax',
primaryNpcId: 'npc_final_lord',
}),
'hostile_boss',
);
assert.equal(
resolveAutoProgressionRole({
encounter: {
id: 'npc_final_lord',
},
battleMode: 'spar',
stage: 'climax',
primaryNpcId: 'npc_final_lord',
}),
'rival',
);
});
test('resolveChapterAutoLevelProfile applies role offsets on top of chapter stage anchor', () => {
const standard = resolveChapterAutoLevelProfile({
plan: TEST_PLAN,
stage: 'climax',
encounter: {
id: 'npc_guard_01',
hostile: true,
monsterPresetId: 'guard',
},
battleMode: 'fight',
primaryNpcId: 'npc_final_lord',
});
const boss = resolveChapterAutoLevelProfile({
plan: TEST_PLAN,
stage: 'climax',
encounter: {
id: 'npc_final_lord',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
primaryNpcId: 'npc_final_lord',
});
assert.equal(standard.progressionRole, 'hostile_standard');
assert.equal(boss.progressionRole, 'hostile_boss');
assert.ok(boss.level >= standard.level + 2);
assert.equal(boss.chapterId, 'chapter-3');
assert.equal(boss.chapterIndex, 3);
assert.equal(boss.source, 'chapter_auto');
});

View File

@@ -0,0 +1,106 @@
import type { SceneActStage } from '../custom-world/runtimeTypes.js';
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
import type {
ProgressionRole,
RuntimeEntityLevelProfile,
RuntimeHostileEncounterSeed,
} from './hostileProgressionService.js';
const ROLE_LEVEL_OFFSETS: Record<ProgressionRole, number> = {
guide: 0,
ambient: -1,
support: 0,
hostile_standard: 0,
hostile_elite: 1,
hostile_boss: 2,
rival: 0,
};
function clampLevel(value: number) {
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.round(value)));
}
function interpolate(min: number, max: number, progress: number) {
return min + (max - min) * progress;
}
function resolveStageProgress(stage: SceneActStage) {
switch (stage) {
case 'opening':
return 0;
case 'expansion':
return 0.4;
case 'turning_point':
return 0.72;
case 'climax':
return 1;
case 'aftermath':
return 0.82;
default:
return 0;
}
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
export function resolveAutoProgressionRole(params: {
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
stage: SceneActStage;
primaryNpcId?: string | null;
}): ProgressionRole {
if (params.battleMode === 'spar') {
return 'rival';
}
const encounterId = readString(params.encounter?.id);
const primaryNpcId = readString(params.primaryNpcId);
const isHostile =
params.encounter?.hostile === true ||
readString(params.encounter?.monsterPresetId).length > 0;
if (!isHostile) {
return primaryNpcId && encounterId === primaryNpcId ? 'rival' : 'support';
}
if (primaryNpcId && encounterId === primaryNpcId) {
return params.stage === 'climax' ? 'hostile_boss' : 'hostile_elite';
}
return 'hostile_standard';
}
export function resolveChapterAutoLevelProfile(params: {
plan: ChapterProgressionPlan;
stage: SceneActStage;
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
primaryNpcId?: string | null;
}): RuntimeEntityLevelProfile {
const progressionRole = resolveAutoProgressionRole({
encounter: params.encounter,
battleMode: params.battleMode,
stage: params.stage,
primaryNpcId: params.primaryNpcId,
});
const baseStageLevel = interpolate(
params.plan.entryPseudoLevel,
params.plan.exitPseudoLevel,
resolveStageProgress(params.stage),
);
const level = clampLevel(
baseStageLevel + ROLE_LEVEL_OFFSETS[progressionRole],
);
return {
level,
referenceStrength: getLevelBenchmark(level).referenceStrength,
chapterId: params.plan.chapterId,
chapterIndex: params.plan.chapterIndex,
progressionRole,
source: 'chapter_auto',
};
}