import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions'; import { buildFallbackQuestIntent, compileQuestIntentToQuest, evaluateQuestOpportunity, } from '../data/questFlow'; import type { Encounter, GameState, QuestLogEntry, } from '../types'; import type {QuestGenerationContext} from './aiTypes'; import { requestJson } from './apiClient'; import {requestChatMessageContent} from './llmClient'; import {parseJsonResponseText} from './llmParsers'; import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt'; import type {QuestIntent, QuestPreviewRequest} from './questTypes'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, } from './storyEngine/actorNarrativeProfile'; import { buildThemePackFromWorldProfile } from './storyEngine/themePack'; import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph'; const QUEST_DIRECTOR_TIMEOUT_MS = 12000; function coerceString(value: unknown, fallback: string) { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } function coerceQuestTitle(value: unknown, fallback: string) { const title = coerceString(value, fallback) .replace(/[《》「」“”"']/gu, '') .replace(/[,。!?;:,.!?;:].*$/u, '') .trim(); if (title.length <= 12) { return title; } return fallback.length <= 12 ? fallback : fallback.slice(0, 10); } function coerceStringArray(value: unknown, fallback: string[]) { if (!Array.isArray(value)) { return fallback; } const items = value .map(item => (typeof item === 'string' ? item.trim() : '')) .filter(Boolean); return items.length > 0 ? items : fallback; } function resolveIssuerNarrativeProfile( state: GameState, encounter: Encounter, ) { if (encounter.narrativeProfile) { return encounter.narrativeProfile; } if (!state.customWorldProfile) { return null; } const role = state.customWorldProfile.storyNpcs.find((npc) => npc.id === encounter.id || npc.name === encounter.npcName, ) ?? state.customWorldProfile.playableNpcs.find((npc) => npc.id === encounter.id || npc.name === encounter.npcName, ); if (!role) { return null; } const themePack = state.customWorldProfile.themePack ?? buildThemePackFromWorldProfile(state.customWorldProfile); const storyGraph = state.customWorldProfile.storyGraph ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); return normalizeActorNarrativeProfile( role.narrativeProfile, buildFallbackActorNarrativeProfile(role, storyGraph, themePack), ); } function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent { if (!rawIntent || typeof rawIntent !== 'object') { return fallback; } const intent = rawIntent as Record; return { title: coerceQuestTitle(intent.title, fallback.title), description: coerceString(intent.description, fallback.description), summary: coerceString(intent.summary, fallback.summary), narrativeType: ( typeof intent.narrativeType === 'string' && ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType) ) ? intent.narrativeType as QuestIntent['narrativeType'] : fallback.narrativeType, dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), playerHook: coerceString(intent.playerHook, fallback.playerHook), worldReason: coerceString(intent.worldReason, fallback.worldReason), recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds) .filter(kind => [ 'defeat_hostile_npc', 'inspect_treasure', 'spar_with_npc', 'talk_to_npc', 'reach_scene', 'deliver_item', ].includes(kind)) as QuestIntent['recommendedObjectiveKinds'], urgency: ( typeof intent.urgency === 'string' && ['low', 'medium', 'high'].includes(intent.urgency) ) ? intent.urgency as QuestIntent['urgency'] : fallback.urgency, intimacy: ( typeof intent.intimacy === 'string' && ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) ) ? intent.intimacy as QuestIntent['intimacy'] : fallback.intimacy, rewardTheme: ( typeof intent.rewardTheme === 'string' && ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme) ) ? intent.rewardTheme as QuestIntent['rewardTheme'] : fallback.rewardTheme, followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), }; } export function buildQuestGenerationContextFromState(params: { state: GameState; encounter: Encounter; }): QuestGenerationContext { const {state, encounter} = params; const issuerNpcId = encounter.id ?? encounter.npcName; const issuerState = state.npcStates[issuerNpcId]; const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter); return { worldType: state.worldType, customWorldProfile: state.customWorldProfile ?? null, actState: state.storyEngineMemory?.actState ?? null, currentSceneId: state.currentScenePreset?.id ?? null, currentSceneName: state.currentScenePreset?.name ?? null, currentSceneDescription: state.currentScenePreset?.description ?? null, issuerNpcId, issuerNpcName: encounter.npcName, issuerNpcContext: encounter.context, issuerAffinity: issuerState?.affinity ?? 0, issuerNarrativeProfile, issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0), issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0), activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ?? [], encounterKind: encounter.kind ?? 'npc', currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0, currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? []) .filter(npc => Boolean(npc.hostile || npc.monsterPresetId)) .map(npc => npc.id), recentStoryMoments: state.storyHistory.slice(-6), playerCharacter: state.playerCharacter, playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, playerMaxMana: state.playerMaxMana, playerInventory: state.playerInventory, playerEquipment: state.playerEquipment, activeCompanions: state.companions, rosterCompanions: state.roster, currentQuestSummary: state.quests.map(quest => ({ id: quest.id, title: quest.title, status: quest.status, issuerNpcId: quest.issuerNpcId, })), }; } export async function generateQuestForNpcEncounter(params: { state: GameState; encounter: Encounter; }): Promise { const {state, encounter} = params; const issuerNpcId = encounter.id ?? encounter.npcName; const request: QuestPreviewRequest = { issuerNpcId, issuerNpcName: encounter.npcName, roleText: encounter.context, scene: state.currentScenePreset, worldType: state.worldType, currentQuests: state.quests.map(quest => ({ id: quest.id, issuerNpcId: quest.issuerNpcId, status: quest.status, })), context: buildQuestGenerationContextFromState({state, encounter}), origin: 'ai_compiled', }; const opportunity = evaluateQuestOpportunity(request); if (!opportunity.shouldOffer) { return null; } const fallbackIntent = buildFallbackQuestIntent(request); if (typeof window !== 'undefined') { try { return await requestJson( '/api/runtime/quests/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params), }, '任务生成失败', ); } catch (error) { console.warn('[QuestDirector] backend quest generation failed, falling back', error); } } try { const content = await requestChatMessageContent( QUEST_INTENT_SYSTEM_PROMPT, buildQuestIntentPrompt({ context: request.context!, scene: request.scene, opportunity, }), { timeoutMs: QUEST_DIRECTOR_TIMEOUT_MS, debugLabel: 'quest-intent', }, ); const parsed = parseJsonResponseText(content) as {intent?: unknown}; const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); return compileQuestIntentToQuest( { ...request, origin: 'ai_compiled', }, intent, ); } catch (error) { console.warn('[QuestDirector] falling back to deterministic quest intent', error); return compileQuestIntentToQuest( { ...request, origin: 'fallback_builder', }, fallbackIntent, ); } }