Files
Genarrative/server-node/src/modules/progression/chapterProgressionPlanner.ts
高物 1c72066bab
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 15:45:14 +08:00

481 lines
12 KiB
TypeScript

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