import type { CustomWorldProfile, SceneActBlueprint, SceneActStage, SceneChapterBlueprint, } from '../custom-world/runtimeTypes.js'; type JsonRecord = Record; 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; }