import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/rpgRuntimeChat'; import type { CustomWorldProfile, GameState, SceneActBlueprint, SceneActRuntimeState, SceneChapterBlueprint, SceneConnectionInfo, StoryEngineMemoryState, } from '../types'; import { resolveCustomWorldRoleIdReferences } from './customWorldRoleReferences'; function toSet(values: string[]) { return new Set(values.map((value) => value.trim()).filter(Boolean)); } function resolveCustomWorldRuntimeSceneAliases( profile: CustomWorldProfile, sceneId: string, ) { const aliases = toSet([sceneId]); const campId = profile.camp?.id?.trim() || 'custom-scene-camp'; if (sceneId === 'custom-scene-camp' || sceneId === campId) { aliases.add(campId); aliases.add('custom-scene-camp'); } // 中文注释:部分单元测试和旧快照会传入精简 profile,运行态解析不能假设 landmarks 始终存在。 (profile.landmarks ?? []).forEach((landmark, index) => { const runtimeSceneId = `custom-scene-landmark-${index + 1}`; if (sceneId === runtimeSceneId || sceneId === landmark.id) { aliases.add(runtimeSceneId); aliases.add(landmark.id); } }); return aliases; } function doesSceneMatchChapter( profile: CustomWorldProfile, sceneId: string, chapter: SceneChapterBlueprint, ) { const sceneAliases = resolveCustomWorldRuntimeSceneAliases(profile, sceneId); const chapterSceneIds = toSet([ chapter.sceneId, ...(chapter.linkedLandmarkIds ?? []), ...(chapter.acts ?? []).map((act) => act.sceneId), ]); return [...sceneAliases].some((id) => chapterSceneIds.has(id)); } export function resolveSceneChapterBlueprint( profile: CustomWorldProfile | null | undefined, sceneId: string | null | undefined, ): SceneChapterBlueprint | null { if (!profile || !sceneId) { return null; } return ( profile.sceneChapterBlueprints?.find((entry) => doesSceneMatchChapter(profile, sceneId, entry), ) ?? null ); } export function resolveActiveSceneActBlueprint(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }): SceneActBlueprint | null { const runtimeState = params.storyEngineMemory?.currentSceneActState; const runtimeChapter = params.profile && runtimeState?.chapterId ? params.profile.sceneChapterBlueprints?.find( (entry) => entry.id === runtimeState.chapterId && Boolean(params.sceneId) && doesSceneMatchChapter(params.profile!, params.sceneId!, entry), ) ?? null : null; const chapter = runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId); if (!chapter || chapter.acts.length === 0) { return null; } if ( runtimeState && runtimeState.chapterId === chapter.id ) { const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId); if (matchedAct) { return matchedAct; } } return chapter.acts[0] ?? null; } export function resolveSceneActProgression(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }): { chapter: SceneChapterBlueprint; runtimeState: SceneActRuntimeState; activeAct: SceneActBlueprint; nextAct: SceneActBlueprint | null; isLastAct: boolean; } | null { const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId); if (!chapter || chapter.acts.length === 0) { return null; } const runtimeState = buildInitialSceneActRuntimeState(params); if (!runtimeState) { return null; } const activeActIndex = chapter.acts.findIndex( (entry) => entry.id === runtimeState.currentActId, ); const resolvedActIndex = activeActIndex >= 0 ? activeActIndex : Math.min( Math.max(runtimeState.currentActIndex, 0), chapter.acts.length - 1, ); const activeAct = chapter.acts[resolvedActIndex] ?? chapter.acts[0]!; const nextAct = chapter.acts[resolvedActIndex + 1] ?? null; return { chapter, runtimeState: { ...runtimeState, currentActId: activeAct.id, currentActIndex: resolvedActIndex, }, activeAct, nextAct, isLastAct: !nextAct, }; } export function advanceSceneActRuntimeState(params: { progress: NonNullable>; }): SceneActRuntimeState | null { const { progress } = params; if (!progress.nextAct) { return null; } const completedActIds = toSet([ ...(progress.runtimeState.completedActIds ?? []), progress.activeAct.id, ]); const visitedActIds = toSet([ ...(progress.runtimeState.visitedActIds ?? []), progress.nextAct.id, ]); return { sceneId: progress.chapter.sceneId, chapterId: progress.chapter.id, currentActId: progress.nextAct.id, currentActIndex: progress.runtimeState.currentActIndex + 1, completedActIds: [...completedActIds], visitedActIds: [...visitedActIds], }; } export function buildInitialSceneActRuntimeState(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }): SceneActRuntimeState | null { const runtimeState = params.storyEngineMemory?.currentSceneActState; const runtimeChapter = params.profile && params.sceneId && runtimeState?.chapterId ? params.profile.sceneChapterBlueprints?.find( (entry) => entry.id === runtimeState.chapterId && doesSceneMatchChapter(params.profile!, params.sceneId!, entry), ) ?? null : null; const chapter = runtimeChapter ?? resolveSceneChapterBlueprint(params.profile, params.sceneId); if (!chapter || chapter.acts.length === 0) { return null; } if ( runtimeState && runtimeState.chapterId === chapter.id && chapter.acts.some((entry) => entry.id === runtimeState.currentActId) ) { return { ...runtimeState, completedActIds: [...toSet(runtimeState.completedActIds ?? [])], visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])], }; } const firstAct = chapter.acts[0]!; return { sceneId: chapter.sceneId, chapterId: chapter.id, currentActId: firstAct.id, currentActIndex: 0, completedActIds: [], visitedActIds: [firstAct.id], }; } export function resolveActiveSceneActEncounterNpcIds(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }) { const activeAct = resolveActiveSceneActBlueprint(params); if (!activeAct) { return []; } return resolveCustomWorldRoleIdReferences(params.profile, [ activeAct.primaryNpcId, activeAct.oppositeNpcId, ...activeAct.encounterNpcIds, ]); } export function resolveActiveSceneActPrimaryNpcId(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }) { return resolveCustomWorldRoleIdReferences(params.profile, [ resolveActiveSceneActBlueprint(params)?.primaryNpcId, ])[0] ?? null; } export function resolveActiveSceneActOppositeNpcId(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }) { return resolveCustomWorldRoleIdReferences(params.profile, [ resolveActiveSceneActBlueprint(params)?.oppositeNpcId, ])[0] ?? null; } export function resolveActiveSceneActEncounterFocusNpcId(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }) { const activeAct = resolveActiveSceneActBlueprint(params); return resolveCustomWorldRoleIdReferences(params.profile, [ activeAct?.oppositeNpcId, activeAct?.primaryNpcId, activeAct?.encounterNpcIds[0], ])[0] ?? null; } export function resolveActiveSceneActBackgroundImage(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; }) { return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null; } export function canUseLimitedPrimaryNpcChat(params: { profile: CustomWorldProfile | null | undefined; sceneId: string | null | undefined; storyEngineMemory?: StoryEngineMemoryState | null; npcId: string | null | undefined; affinity: number; }) { if (params.affinity >= 0 || !params.npcId) { return false; } const activeAct = resolveActiveSceneActBlueprint({ profile: params.profile, sceneId: params.sceneId, storyEngineMemory: params.storyEngineMemory, }); const limitedChatNpcIds = toSet( resolveCustomWorldRoleIdReferences(params.profile, [ activeAct?.primaryNpcId, activeAct?.oppositeNpcId, ]), ); const normalizedNpcId = resolveCustomWorldRoleIdReferences(params.profile, [params.npcId])[0] ?? params.npcId; // 中文注释:第一幕对面角色即使是负好感,也必须先进入剧情对话;普通敌人仍按战斗处理。 if (limitedChatNpcIds.has(normalizedNpcId)) { return true; } return ( resolveActiveSceneActPrimaryNpcId({ profile: params.profile, sceneId: params.sceneId, storyEngineMemory: params.storyEngineMemory, }) === normalizedNpcId ); } export function resolveLimitedPrimaryNpcChatState(params: { state: Pick; npcId: string | null | undefined; affinity: number; nextTurnCount: number; }): NpcChatTurnDirective | null { if ( !canUseLimitedPrimaryNpcChat({ profile: params.state.customWorldProfile, sceneId: params.state.currentScenePreset?.id ?? null, storyEngineMemory: params.state.storyEngineMemory, npcId: params.npcId, affinity: params.affinity, }) ) { return null; } const activeAct = resolveActiveSceneActBlueprint({ profile: params.state.customWorldProfile, sceneId: params.state.currentScenePreset?.id ?? null, storyEngineMemory: params.state.storyEngineMemory, }); return { sceneActId: activeAct?.id ?? null, turnLimit: null, remainingTurns: null, limitReason: 'negative_affinity' as const, closingMode: 'free' as const, forceExitAfterTurn: false, terminationMode: 'hostile_model' as const, isHostileChat: true, }; } export function getSceneConnectionDirectionText( relativePosition: SceneConnectionInfo['relativePosition'], ) { switch (relativePosition) { case 'north': return '向北走'; case 'south': return '向南走'; case 'east': return '向东走'; case 'west': return '向西走'; case 'left': return '向左走'; case 'right': return '向右走'; case 'back': return '往回走'; case 'up': return '向上走'; case 'down': return '向下走'; case 'inside': return '向内走'; case 'outside': return '向外走'; case 'portal': return '穿过通路'; case 'forward': default: return '向前走'; } }