import type { Dispatch, SetStateAction } from 'react'; import { buildRelationState } from '../../data/attributeResolver'; import { NPC_FIGHT_FUNCTION } from '../../data/functionCatalog'; import { addInventoryItems, applyStoryChoiceToStanceProfile, buildNpcSparResultText, getNpcLootItems, markNpcFirstMeaningfulContactResolved, NPC_SPAR_AFFINITY_GAIN, removeInventoryItem, } from '../../data/npcInteractions'; import { applyQuestProgressFromHostileNpcDefeat, applyQuestProgressFromNpcTalk, applyQuestProgressFromSpar, } from '../../data/questFlow'; import { incrementGameRuntimeStats } from '../../data/runtimeStats'; import { resolveFunctionOption } from '../../data/stateFunctions'; import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; import { streamNpcChatTurn } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { resolveLimitedPrimaryNpcChatState, } from '../../services/customWorldSceneActRuntime'; import { generateQuestForNpcEncounter } from '../../services/questDirector'; import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { createHistoryMoment } from '../../services/storyHistory'; import type { Character, Encounter, GameState, InventoryItem, NpcBattleMode, NpcBattleOutcome, QuestLogEntry, StoryMoment, StoryOption, } from '../../types'; import { AnimationState } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; import { resolveServerRuntimeChoice } from './runtimeStoryCoordinator'; type CommitGeneratedStateWithEncounterEntry = ( entryState: GameState, resolvedState: GameState, character: Character, actionText: string, resultText: string, lastFunctionId?: string, ) => Promise | void; type GenerateStoryForState = (params: { state: GameState; character: Character; history: StoryMoment[]; choice?: string; lastFunctionId?: string | null; optionCatalog?: StoryOption[] | null; }) => Promise; type NpcInteractionFlowActions = { openTradeModal: (encounter: Encounter, actionText: string) => void; openGiftModal: (encounter: Encounter, actionText: string) => void; openRecruitModal: (encounter: Encounter, actionText: string) => void; startRecruitmentSequence: ( encounter: Encounter, actionText: string, ) => Promise; }; type BuildStoryContextExtras = { lastFunctionId?: string | null; openingCampBackground?: string | null; openingCampDialogue?: string | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }; type NpcChatDirective = { sceneActId?: string | null; turnLimit?: number | null; remainingTurns?: number | null; limitReason?: 'negative_affinity' | null; forceExitAfterTurn?: boolean; } | null; type NpcChatCombatContext = NonNullable< NonNullable['combatContext'] >; function isNpcEncounter( encounter: GameState['currentEncounter'], ): encounter is Encounter { return Boolean(encounter?.kind === 'npc'); } const NPC_CHAT_QUEST_OFFER_FUNCTION_IDS = { view: 'npc_chat_quest_offer_view', replace: 'npc_chat_quest_offer_replace', abandon: 'npc_chat_quest_offer_abandon', } as const; type NpcChatQuestOfferPayloadAction = keyof typeof NPC_CHAT_QUEST_OFFER_FUNCTION_IDS; export function createStoryNpcEncounterActions({ gameState, currentStory, setGameState, setCurrentStory, setAiError, setIsLoading, appendHistory, buildNpcStory, buildOpeningCampChatContext, buildStoryContextFromState, buildFallbackStoryForState, generateStoryForState, getStoryGenerationHostileNpcs, getAvailableOptionsForState, buildContinueAdventureOption, getNpcEncounterKey, getResolvedNpcState, updateNpcState, cloneInventoryItemForOwner, resolveNpcInteractionDecision, npcInteractionFlow, }: { gameState: GameState; currentStory: StoryMoment | null; setGameState: Dispatch>; setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; commitGeneratedState: CommitGeneratedState; commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry; appendHistory: ( state: GameState, actionText: string, resultText: string, ) => GameState['storyHistory']; buildNpcStory: ( state: GameState, character: Character, encounter: Encounter, overrideText?: string, ) => StoryMoment; buildOpeningCampChatContext: ( state: GameState, character: Character, encounter: Encounter, ) => BuildStoryContextExtras; buildStoryContextFromState: ( state: GameState, extras?: BuildStoryContextExtras, ) => StoryGenerationContext; buildFallbackStoryForState: ( state: GameState, character: Character, fallbackText?: string, ) => StoryMoment; buildDialogueStoryMoment: ( npcName: string, text: string, options: StoryOption[], streaming?: boolean, ) => StoryMoment; generateStoryForState: GenerateStoryForState; getStoryGenerationHostileNpcs: ( state: GameState, ) => GameState['sceneHostileNpcs']; getTypewriterDelay: (char: string) => number; getAvailableOptionsForState: ( state: GameState, character: Character, ) => StoryOption[] | null; sanitizeOptions: ( options: StoryOption[], character: Character, state: GameState, ) => StoryOption[]; sortOptions: (options: StoryOption[]) => StoryOption[]; buildContinueAdventureOption: () => StoryOption; getNpcEncounterKey: (encounter: Encounter) => string; getResolvedNpcState: ( state: GameState, encounter: Encounter, ) => GameState['npcStates'][string]; updateNpcState: ( state: GameState, encounter: Encounter, updater: ( npcState: GameState['npcStates'][string], ) => GameState['npcStates'][string], ) => GameState; cloneInventoryItemForOwner: ( item: InventoryItem, owner: 'player' | 'npc', quantity?: number, ) => InventoryItem; resolveNpcInteractionDecision: ( state: GameState, option: StoryOption, ) => { kind: string }; npcInteractionFlow: NpcInteractionFlowActions; }) { const incrementRuntimeStats = ( state: GameState, increments: Parameters[1], ) => ({ ...state, runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments), }); const buildNpcChatOption = ( encounter: Encounter, actionText: string, ): StoryOption => ({ functionId: 'npc_chat', actionText, text: actionText, detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc', npcId: encounter.id ?? encounter.npcName, action: 'chat', }, }); const buildNpcChatQuestOfferOption = ( encounter: Encounter, functionId: string, actionText: string, action: NpcChatQuestOfferPayloadAction, ): StoryOption => ({ functionId, actionText, text: actionText, detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, runtimePayload: { npcId: encounter.id ?? encounter.npcName, npcChatQuestOfferAction: action, }, }); const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [ buildNpcChatQuestOfferOption( encounter, NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view, '查看任务', 'view', ), buildNpcChatQuestOfferOption( encounter, NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.replace, '更换任务', 'replace', ), buildNpcChatQuestOfferOption( encounter, NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.abandon, '放弃任务', 'abandon', ), ]; const getPendingQuestOffer = ( story: StoryMoment | null, encounter?: Encounter, ) => { const pendingQuestOffer = story?.npcChatState?.pendingQuestOffer ?? null; if (!pendingQuestOffer) { return null; } if ( encounter && story?.npcChatState?.npcId !== (encounter.id ?? encounter.npcName) ) { return null; } return pendingQuestOffer; }; const buildQuestOfferDialogueText = ( encounter: Encounter, quest: QuestLogEntry, ) => { const summaryText = quest.summary.trim() || quest.description.trim(); return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ summaryText ? `如果你愿意,我想把这件事正式交给你:${summaryText}` : '如果你愿意,我想把眼前这件事正式交给你。' }`; }; const buildPostQuestOfferChatSuggestions = (encounter: Encounter) => [ '那先继续聊聊你刚才没说完的部分', '除了委托,你对眼前局势还有什么判断', '先把这附近真正危险的地方说清楚', ].map((actionText) => buildNpcChatOption(encounter, actionText)); const extractRecentCombatLogLines = (history: GameState['storyHistory']) => history .slice(-6) .map((moment) => moment.text.trim()) .filter(Boolean) .slice(-4); const buildNpcBattleChatCombatContext = (params: { battleMode: NpcBattleMode; resultText: string; actionText: string; historyBase: GameState['storyHistory']; }): NpcChatCombatContext => { const logLines = [ ...extractRecentCombatLogLines(params.historyBase), params.actionText, params.resultText, ].filter((line, index, lines) => lines.indexOf(line) === index); return { summary: params.battleMode === 'spar' ? `你们刚结束一场切磋,${params.resultText}` : `你刚赢下这场交锋,${params.resultText}`, logLines, battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory', }; }; const reopenNpcChatAfterBattle = (params: { nextState: GameState; encounter: Encounter; actionText: string; resultText: string; battleMode: NpcBattleMode; }) => { const playerCharacter = params.nextState.playerCharacter; if (!playerCharacter) { return false; } const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter); const chatDirective = resolveLimitedPrimaryNpcChatState({ state: params.nextState, npcId: params.encounter.id ?? params.encounter.npcName, affinity: reopenedNpcState.affinity, nextTurnCount: 0, }); const baseStory = buildNpcStory( params.nextState, playerCharacter, params.encounter, params.resultText, ); const baseChatOptions = (baseStory.options ?? []).filter((option) => isNpcChatOptionForEncounter(option, params.encounter), ); const fallbackChatOption = baseChatOptions[0] ?? buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`); const combatContext = buildNpcBattleChatCombatContext({ battleMode: params.battleMode, resultText: params.resultText, actionText: params.actionText, historyBase: params.nextState.storyHistory, }); setCurrentStory( buildNpcChatStoryMoment({ encounter: params.encounter, dialogue: [ { speaker: 'system', text: params.resultText, }, ], options: buildNpcChatEntryOptions( params.encounter, fallbackChatOption, baseChatOptions.slice(1), ), streaming: false, turnCount: 0, chatDirective, openingSource: 'player_reply', combatContext, }), ); return true; }; const finalizeNpcBattleResult = ( state: GameState, character: Character, battleMode: NpcBattleMode, battleOutcome: NpcBattleOutcome | null, ) => { if (!state.currentBattleNpcId) return null; const battleNpcId = state.currentBattleNpcId; const npcState = state.npcStates[battleNpcId]; if (!npcState) return null; const activeBattleHostiles = state.sceneHostileNpcs; if (battleMode === 'spar' && battleOutcome === 'spar_complete') { const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN; const restoredEncounter = state.sparReturnEncounter; const progressedQuests = applyQuestProgressFromSpar( state.quests, battleNpcId, ); const nextState = { ...state, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, currentEncounter: restoredEncounter, npcInteractionActive: true, sceneHostileNpcs: [], npcStates: { ...state.npcStates, [battleNpcId]: { ...markNpcFirstMeaningfulContactResolved(npcState), affinity: nextAffinity, relationState: buildRelationState(nextAffinity), stanceProfile: applyStoryChoiceToStanceProfile( npcState.stanceProfile, 'npc_chat', { affinityGain: NPC_SPAR_AFFINITY_GAIN }, ), }, }, quests: progressedQuests, playerX: 0, playerHp: state.sparPlayerHpBefore ?? state.playerHp, playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp, playerFacing: 'right' as const, animationState: state.animationState, activeCombatEffects: [], scrollWorld: false, inBattle: false, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; return { nextState, resultText: buildNpcSparResultText( activeBattleHostiles[0]?.name ?? '对方', NPC_SPAR_AFFINITY_GAIN, nextAffinity, ), }; } const lootItems = getNpcLootItems(npcState, character).map((item) => cloneInventoryItemForOwner(item, 'player'), ); const defeatedHostileNpcIds = activeBattleHostiles.map( (hostileNpc) => hostileNpc.id, ); const restoredEncounter = (state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ?? activeBattleHostiles[0]?.encounter ?? ({ id: battleNpcId, kind: 'npc', npcName: activeBattleHostiles[0]?.name ?? battleNpcId, npcDescription: '', npcAvatar: '', context: '', hostile: false, } satisfies Encounter); const progressedQuests = applyQuestProgressFromHostileNpcDefeat( state.quests, state.currentScenePreset?.id ?? null, defeatedHostileNpcIds, ); let nextNpcInventory = npcState.inventory; for (const item of lootItems) { nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1); } const nextState: GameState = appendStoryEngineCarrierMemory( incrementRuntimeStats( { ...state, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, currentEncounter: restoredEncounter, npcInteractionActive: true, sceneHostileNpcs: [], playerInventory: addInventoryItems(state.playerInventory, lootItems), quests: progressedQuests, npcStates: { ...state.npcStates, [battleNpcId]: { ...markNpcFirstMeaningfulContactResolved(npcState), affinity: npcState.affinity, relationState: buildRelationState(npcState.affinity), recruited: false, inventory: nextNpcInventory, }, }, playerX: 0, playerFacing: 'right' as const, animationState: state.animationState, activeCombatEffects: [], scrollWorld: false, inBattle: false, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }, { hostileNpcsDefeated: defeatedHostileNpcIds.length, }, ), lootItems, ); const lootText = lootItems.length > 0 ? lootItems.map((item) => item.name).join(', ') : '无战利品'; const defeatedNames = activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') || battleNpcId || '对手'; return { nextState, resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}。`, }; }; const buildNpcChatTurnOptions = ( encounter: Encounter, suggestions: string[], ): StoryOption[] => suggestions .map((suggestion) => sanitizeNpcChatSuggestion(suggestion)) .filter(Boolean) .slice(0, 3) .map((suggestion) => ({ functionId: 'npc_chat', actionText: suggestion, text: suggestion, detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc', npcId: encounter.id ?? encounter.npcName, action: 'chat', }, })); const NPC_CHAT_SUGGESTION_LIMIT = 20; const trimNpcChatSuggestion = (text: string) => text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, ''); const clampNpcChatSuggestionLength = (text: string) => Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join(''); const isDirectNpcChatSuggestion = (text: string) => { const normalizedText = trimNpcChatSuggestion(text); if (!normalizedText) { return false; } const behaviorPrefixes = [ '先', '再', '换个', '顺着', '试着', '表明', '告诉', '问问', '追问', '继续聊', '继续交谈', '继续谈', ]; return !behaviorPrefixes.some((prefix) => normalizedText.startsWith(prefix), ); }; const sanitizeNpcChatSuggestion = (text: string) => { const normalizedText = trimNpcChatSuggestion(text); if (!normalizedText) { return ''; } return clampNpcChatSuggestionLength(normalizedText); }; const buildFallbackNpcChatSuggestions = (playerMessage: string) => { const topic = clampNpcChatSuggestionLength( sanitizeNpcChatSuggestion(playerMessage) || '刚才那句', ); return [ sanitizeNpcChatSuggestion(`你刚才那句是什么意思`), sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`), sanitizeNpcChatSuggestion('你愿意再说清楚点吗'), ]; }; const isNpcChatOptionForEncounter = ( option: StoryOption, encounter: Encounter, ) => { if (option.functionId !== 'npc_chat') { return false; } if (option.interaction?.kind !== 'npc') { return true; } return ( option.interaction.action === 'chat' && option.interaction.npcId === (encounter.id ?? encounter.npcName) ); }; const buildNpcChatEntryOptions = ( encounter: Encounter, selectedOption: StoryOption, extraOptions: StoryOption[] = [], ) => { const candidateOptions = [ selectedOption, ...extraOptions, ...(currentStory?.options ?? []).filter((option) => isNpcChatOptionForEncounter(option, encounter), ), ]; const dedupedOptions: StoryOption[] = []; const seenActionTexts = new Set(); for (const option of candidateOptions) { const actionText = sanitizeNpcChatSuggestion(option.actionText ?? ''); if ( !actionText || !isDirectNpcChatSuggestion(actionText) || seenActionTexts.has(actionText) ) { continue; } seenActionTexts.add(actionText); dedupedOptions.push({ ...option, actionText, text: actionText, }); if (dedupedOptions.length === 3) { return dedupedOptions; } } const fallbackSuggestions = buildFallbackNpcChatSuggestions( currentStory?.text?.trim() || selectedOption.actionText, ); const mergedSuggestions = [ ...dedupedOptions.map((option) => option.actionText), ...fallbackSuggestions.filter( (suggestion) => !seenActionTexts.has(suggestion), ), ].slice(0, 3); return buildNpcChatTurnOptions(encounter, mergedSuggestions); }; const buildNpcChatStoryMoment = (params: { encounter: Encounter; dialogue: NonNullable; options: StoryOption[]; streaming: boolean; turnCount: number; chatDirective?: NpcChatDirective; pendingQuestOffer?: { quest: QuestLogEntry; } | null; openingSource?: 'npc_initiated' | 'player_reply'; combatContext?: NpcChatCombatContext | null; latestAffinityEffect?: StoryMoment['npcAffinityEffect']; }): StoryMoment => ({ text: params.dialogue.map((turn) => turn.text).join('\n'), options: params.options, displayMode: 'dialogue', dialogue: params.dialogue, streaming: params.streaming, npcAffinityEffect: params.latestAffinityEffect ?? null, npcChatState: { npcId: params.encounter.id ?? params.encounter.npcName, npcName: params.encounter.npcName, turnCount: params.turnCount, customInputPlaceholder: '输入你想对 TA 说的话', openingSource: params.openingSource ?? 'player_reply', sceneActId: params.chatDirective?.sceneActId ?? null, turnLimit: params.chatDirective?.turnLimit ?? null, remainingTurns: params.chatDirective?.remainingTurns ?? null, limitReason: params.chatDirective?.limitReason ?? null, forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false, pendingQuestOffer: params.pendingQuestOffer ?? null, combatContext: params.combatContext ?? null, }, }); const collapseNpcChatOptions = (options: StoryOption[]) => { let hasKeptNpcChat = false; return options.filter((option) => { if (option.functionId !== 'npc_chat') { return true; } if (hasKeptNpcChat) { return false; } hasKeptNpcChat = true; return true; }); }; const buildPostNpcChatOptionCatalog = ( encounter: Encounter, playerCharacter: Character, ) => { const resolvedStateOptions = collapseNpcChatOptions( getAvailableOptionsForState(gameState, playerCharacter) ?? [], ); const currentStoryOptions = currentStory?.options ?? []; const currentNpcKey = encounter.id ?? encounter.npcName; const currentChatOptions = currentStoryOptions.filter((option) => isNpcChatOptionForEncounter(option, encounter), ); const nonChatCurrentOptions = currentStoryOptions.filter( (option) => !currentChatOptions.includes(option), ); const nonChatResolvedOptions = resolvedStateOptions.filter( (option) => !isNpcChatOptionForEncounter(option, encounter), ); const mergedOptions: StoryOption[] = []; const seenOptionIdentity = new Set(); const pushUniqueOption = (option: StoryOption) => { const optionIdentity = [ option.functionId, option.interaction?.kind ?? '', option.interaction?.kind === 'npc' ? option.interaction.npcId : '', option.interaction?.kind === 'npc' ? option.interaction.action : '', ].join('::'); if (seenOptionIdentity.has(optionIdentity)) { return; } seenOptionIdentity.add(optionIdentity); mergedOptions.push(option); }; currentChatOptions.slice(0, 1).forEach(pushUniqueOption); nonChatCurrentOptions.forEach(pushUniqueOption); nonChatResolvedOptions.forEach(pushUniqueOption); return mergedOptions; }; const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) => `${encounter.npcName}看着你,像是在等你把话接下去。`; const sanitizeNpcChatDialogueHistory = ( encounter: Encounter, dialogue: NonNullable, turnCount: number, openingSource?: StoryMoment['npcChatState'] extends infer T ? T extends { openingSource?: infer U } ? U : never : never, ) => { const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter); return dialogue.filter((turn, index) => { if (index !== 0 || turn.speaker !== 'npc') { return true; } if (turn.text.trim() === legacyOpeningText) { return false; } if (turnCount === 0 && dialogue.length === 1) { return openingSource === 'npc_initiated'; } return true; }); }; const buildNpcChatDialogueHistory = ( encounter: Encounter, turnCount: number, ) => currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) && currentStory.dialogue ? sanitizeNpcChatDialogueHistory( encounter, currentStory.dialogue, turnCount, currentStory.npcChatState?.openingSource, ) : []; const buildHostileNpcDeclarationText = ( encounter: Encounter, affinity: number, ) => { const hostilityText = affinity <= -20 ? '旧账就留到今天一起清。' : affinity <= -10 ? '我们之间已经没什么可谈的了。' : '你再往前一步,我就当你是在挑衅。'; const contextText = encounter.context?.trim() ? `你居然还敢带着${encounter.context}的事来见我,` : ''; return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`; }; const buildHostileNpcEscapeOption = (character: Character): StoryOption => { const functionContext = gameState.worldType ? { worldType: gameState.worldType, playerCharacter: character, inBattle: false, currentSceneId: gameState.currentScenePreset?.id ?? null, currentSceneName: gameState.currentScenePreset?.name ?? null, monsters: [], playerHp: gameState.playerHp, playerMaxHp: gameState.playerMaxHp, playerMana: gameState.playerMana, playerMaxMana: gameState.playerMaxMana, } : null; const resolvedOption = functionContext ? resolveFunctionOption('battle_escape_breakout', functionContext, '逃跑') : null; if (resolvedOption) { return { ...resolvedOption, actionText: '逃跑', text: '逃跑', detailText: '', }; } return { functionId: 'battle_escape_breakout', actionText: '逃跑', text: '逃跑', detailText: '', visuals: { playerAnimation: AnimationState.RUN, playerMoveMeters: -0.6, playerOffsetY: 0, playerFacing: 'left', scrollWorld: true, monsterChanges: [], }, }; }; const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({ functionId: NPC_FIGHT_FUNCTION.id, actionText: '与他对战', text: '与他对战', detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc', npcId: encounter.id ?? encounter.npcName, action: 'fight', }, }); const buildHostileNpcStoryMoment = ( encounter: Encounter, character: Character, affinity: number, ): StoryMoment => { const declarationText = buildHostileNpcDeclarationText(encounter, affinity); return { text: declarationText, options: [ buildHostileNpcEscapeOption(character), buildHostileNpcFightOption(encounter), ], displayMode: 'dialogue', dialogue: [ { speaker: 'npc', speakerName: encounter.npcName, text: declarationText, }, ], streaming: false, }; }; const enterNpcChat = ( encounter: Encounter, selectedOption: StoryOption, extraOptions: StoryOption[] = [], chatDirective?: NpcChatDirective, openingSource: 'npc_initiated' | 'player_reply' = 'player_reply', ) => { const openingDialogue = buildNpcChatDialogueHistory(encounter, 0); setAiError(null); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: openingDialogue, options: buildNpcChatEntryOptions( encounter, selectedOption, extraOptions, ), streaming: false, turnCount: 0, chatDirective, openingSource, }), ); return true; }; const startNpcInitiatedOpening = async ( encounter: Encounter, selectedOption: StoryOption, extraOptions: StoryOption[] = [], chatDirective?: NpcChatDirective, ) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter || !gameState.worldType) { return enterNpcChat( encounter, selectedOption, extraOptions, chatDirective, 'npc_initiated', ); } const npcState = getResolvedNpcState(gameState, encounter); const openingCampContext = buildOpeningCampChatContext( gameState, playerCharacter, encounter, ); const existingDialogue = buildNpcChatDialogueHistory(encounter, 0); const openingOptions = buildNpcChatEntryOptions( encounter, selectedOption, extraOptions, ); setAiError(null); setIsLoading(true); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: existingDialogue, options: [], streaming: true, turnCount: 0, chatDirective, openingSource: 'npc_initiated', }), ); try { const chatTurn = await streamNpcChatTurn( gameState.worldType, playerCharacter, encounter, getStoryGenerationHostileNpcs(gameState), gameState.storyHistory, buildStoryContextFromState(gameState, { lastFunctionId: 'npc_chat', ...openingCampContext, encounterNpcStateOverride: npcState, }), existingDialogue, '【NPC 主动开场】', { affinity: npcState.affinity, chattedCount: npcState.chattedCount, recruited: npcState.recruited, }, { onReplyUpdate: (text) => { setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...existingDialogue, { speaker: 'npc', speakerName: encounter.npcName, text, }, ], options: [], streaming: true, turnCount: 0, chatDirective, openingSource: 'npc_initiated', }), ); }, chatDirective, npcInitiatesConversation: true, }, ); if (!chatTurn?.npcReply?.trim()) { throw new Error('NPC 主动开场结果为空'); } setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...existingDialogue, { speaker: 'npc', speakerName: encounter.npcName, text: chatTurn.npcReply, }, ], options: buildNpcChatTurnOptions( encounter, chatTurn.suggestions.length > 0 ? chatTurn.suggestions : openingOptions.map((option) => option.actionText), ), streaming: false, turnCount: 0, chatDirective, openingSource: 'npc_initiated', }), ); return true; } catch (error) { console.error('Failed to start npc initiated opening:', error); setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败'); return enterNpcChat( encounter, selectedOption, extraOptions, chatDirective, 'npc_initiated', ); } finally { setIsLoading(false); } }; const handleNpcChatTurn = async ( encounter: Encounter, playerMessage: string, ) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter || !gameState.worldType) { return false; } const npcState = getResolvedNpcState(gameState, encounter); const currentNpcChatState = currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) ? currentStory.npcChatState : null; const currentCombatContext = currentNpcChatState?.combatContext ?? null; const existingDialogue = currentStory?.dialogue && currentNpcChatState ? sanitizeNpcChatDialogueHistory( encounter, currentStory.dialogue, currentNpcChatState.turnCount ?? 0, currentNpcChatState.openingSource, ) : []; const dialogueWithPlayer = [ ...existingDialogue, { speaker: 'player' as const, text: playerMessage, }, ]; const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1; const limitedChatDirective = resolveLimitedPrimaryNpcChatState({ state: gameState, npcId: encounter.id ?? encounter.npcName, affinity: npcState.affinity, nextTurnCount, }); const openingCampContext = buildOpeningCampChatContext( gameState, playerCharacter, encounter, ); setAiError(null); setIsLoading(true); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: dialogueWithPlayer, options: [], streaming: true, turnCount: nextTurnCount, chatDirective: limitedChatDirective, combatContext: currentCombatContext, }), ); try { const chatTurn = await streamNpcChatTurn( gameState.worldType, playerCharacter, encounter, getStoryGenerationHostileNpcs(gameState), gameState.storyHistory, buildStoryContextFromState(gameState, { lastFunctionId: 'npc_chat', ...openingCampContext, encounterNpcStateOverride: npcState, }), existingDialogue, playerMessage, { affinity: npcState.affinity, chattedCount: npcState.chattedCount, recruited: npcState.recruited, }, { onReplyUpdate: (text) => { setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...dialogueWithPlayer, { speaker: 'npc', speakerName: encounter.npcName, text, }, ], options: [], streaming: true, turnCount: nextTurnCount, chatDirective: limitedChatDirective, combatContext: currentCombatContext, }), ); }, questOfferContext: limitedChatDirective ? null : { state: gameState, turnCount: nextTurnCount, }, chatDirective: limitedChatDirective, combatContext: currentCombatContext, }, ); let nextAffinity = npcState.affinity; const nextState = updateNpcState( gameState, encounter, (currentNpcState) => { nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta; return { ...markNpcFirstMeaningfulContactResolved(currentNpcState), affinity: nextAffinity, relationState: buildRelationState(nextAffinity), chattedCount: currentNpcState.chattedCount + 1, stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_chat', { affinityGain: chatTurn.affinityDelta }, ), }; }, ); const finalHistory = appendHistory( gameState, playerMessage, chatTurn.npcReply, ); const finalState = { ...nextState, quests: applyQuestProgressFromNpcTalk( nextState.quests, encounter.id ?? encounter.npcName, ), storyHistory: finalHistory, }; setGameState(finalState); // 好感变化只保留为一次性表现事件,不再插入聊天消息流。 const latestAffinityEffect = chatTurn.affinityDelta !== 0 ? { eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`, npcId: encounter.id ?? encounter.npcName, delta: chatTurn.affinityDelta, } : null; const nextDialogue = [ ...dialogueWithPlayer, { speaker: 'npc' as const, speakerName: encounter.npcName, text: chatTurn.npcReply, }, ]; const pendingQuest = (chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ?? null; const resolvedChatDirective = limitedChatDirective ? { sceneActId: limitedChatDirective.sceneActId ?? null, turnLimit: chatTurn.chatDirective?.turnLimit ?? limitedChatDirective.turnLimit ?? null, remainingTurns: chatTurn.chatDirective?.remainingTurns ?? limitedChatDirective.remainingTurns ?? null, limitReason: limitedChatDirective.limitReason ?? null, forceExitAfterTurn: chatTurn.chatDirective?.forceExit ?? limitedChatDirective.forceExitAfterTurn ?? false, } : null; const shouldForceExitAfterTurn = resolvedChatDirective?.forceExitAfterTurn === true; const pendingQuestIntroText = chatTurn.pendingQuestOffer?.introText?.trim() || ''; if (shouldForceExitAfterTurn) { const closingDialogue = [ ...nextDialogue, { speaker: 'system' as const, text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。', }, ]; setCurrentStory({ text: closingDialogue.map((turn) => turn.text).join('\n'), options: [buildContinueAdventureOption()], displayMode: 'dialogue', dialogue: closingDialogue, streaming: false, npcAffinityEffect: latestAffinityEffect, }); return true; } if (pendingQuest) { setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...nextDialogue, { speaker: 'npc', speakerName: encounter.npcName, text: pendingQuestIntroText || buildQuestOfferDialogueText(encounter, pendingQuest), }, ], options: buildPendingQuestOfferOptions(encounter), streaming: false, turnCount: nextTurnCount, chatDirective: resolvedChatDirective, pendingQuestOffer: { quest: pendingQuest, }, combatContext: currentCombatContext, latestAffinityEffect, }), ); return true; } setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: nextDialogue, options: buildNpcChatTurnOptions( encounter, chatTurn.suggestions.length > 0 ? chatTurn.suggestions : buildFallbackNpcChatSuggestions(playerMessage), ), streaming: false, turnCount: nextTurnCount, chatDirective: resolvedChatDirective, combatContext: currentCombatContext, latestAffinityEffect, }), ); return true; } catch (error) { console.error('Failed to stream npc chat turn:', error); setAiError(error instanceof Error ? error.message : 'NPC 聊天续写失败'); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: dialogueWithPlayer, options: buildNpcChatTurnOptions( encounter, buildFallbackNpcChatSuggestions(playerMessage), ), streaming: false, turnCount: nextTurnCount, chatDirective: limitedChatDirective, combatContext: currentCombatContext, }), ); return false; } finally { setIsLoading(false); } }; const exitNpcChat = () => { const playerCharacter = gameState.playerCharacter; const encounter = gameState.currentEncounter; if (!playerCharacter || !isNpcEncounter(encounter)) { return false; } setAiError(null); setIsLoading(true); void (async () => { const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`; try { const postChatOptionCatalog = buildPostNpcChatOptionCatalog( encounter, playerCharacter, ); const nextStory = await generateStoryForState({ state: gameState, character: playerCharacter, history: gameState.storyHistory, choice: choiceText, lastFunctionId: 'npc_chat', optionCatalog: postChatOptionCatalog, }); const nextHistory = [ ...gameState.storyHistory, createHistoryMoment(choiceText, 'action'), createHistoryMoment(nextStory.text, 'result', nextStory.options), ]; const recoveredState = applyStoryReasoningRecovery({ ...gameState, storyHistory: nextHistory, }); setGameState(recoveredState); setCurrentStory(nextStory); } catch (error) { console.error( 'Failed to continue story after exiting npc chat:', error, ); setAiError( error instanceof Error ? error.message : '退出聊天后的剧情推理失败', ); setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter)); } finally { setIsLoading(false); } })(); return true; }; const enterNpcInteraction = (encounter: Encounter, actionText: string) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter) return false; const npcState = getResolvedNpcState(gameState, encounter); const nextState: GameState = { ...gameState, npcInteractionActive: true, }; setGameState(nextState); setAiError(null); const limitedChatDirective = resolveLimitedPrimaryNpcChatState({ state: nextState, npcId: encounter.id ?? encounter.npcName, affinity: npcState.affinity, nextTurnCount: 0, }); if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) { setCurrentStory( buildHostileNpcStoryMoment( encounter, playerCharacter, npcState.affinity, ), ); return true; } const npcInteractionOptions = getAvailableOptionsForState(nextState, playerCharacter) ?? []; const chatOptions = npcInteractionOptions.filter((option) => isNpcChatOptionForEncounter(option, encounter), ); const seedChatOption = chatOptions[0] ?? ({ functionId: 'npc_chat', actionText: actionText || `和${encounter.npcName}搭话`, text: actionText || `和${encounter.npcName}搭话`, detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc' as const, npcId: encounter.id ?? encounter.npcName, action: 'chat' as const, }, } satisfies StoryOption); if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) { void startNpcInitiatedOpening( encounter, seedChatOption, chatOptions.slice(1), limitedChatDirective, ); return true; } return enterNpcChat( encounter, seedChatOption, chatOptions.slice(1), limitedChatDirective, ); }; const resolveServerNpcStoryAction = async (params: { option: StoryOption; payload?: Record; }) => { const playerCharacter = gameState.playerCharacter; if ( !playerCharacter || !gameState.worldType || gameState.currentScene !== 'Story' ) { return false; } setAiError(null); setIsLoading(true); try { const { hydratedSnapshot, nextStory } = await resolveServerRuntimeChoice({ gameState, currentStory, option: params.option, payload: params.payload, }); setGameState(hydratedSnapshot.gameState); setCurrentStory(nextStory); return true; } catch (error) { console.error('Failed to resolve npc story action on the server:', error); setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败'); if (!currentStory) { setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter)); } return false; } finally { setIsLoading(false); } }; const replacePendingNpcQuestOffer = async () => { const playerCharacter = gameState.playerCharacter; const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; if (!playerCharacter || !encounter || !pendingQuestOffer) { return false; } const encounterKey = getNpcEncounterKey(encounter); const currentNpcChatState = currentStory?.npcChatState?.npcId === encounterKey ? currentStory.npcChatState : null; const currentDialogue = currentStory?.dialogue && currentNpcChatState ? [...currentStory.dialogue] : []; const turnCount = currentNpcChatState?.turnCount ?? 0; const playerLine = '能不能换一份更适合眼下局势的委托?'; const generationState = { ...gameState, storyHistory: appendHistory( gameState, `你请${encounter.npcName}换一份更合适的委托。`, `${encounter.npcName}重新斟酌起该交给你的事。`, ), }; setAiError(null); setIsLoading(true); try { const nextQuest = await generateQuestForNpcEncounter({ state: generationState, encounter, }); if (!nextQuest) { setAiError('当前没有更合适的委托可供更换。'); return false; } setGameState(generationState); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...currentDialogue, { speaker: 'player', text: playerLine, }, { speaker: 'npc', speakerName: encounter.npcName, text: buildQuestOfferDialogueText(encounter, nextQuest), }, ], options: buildPendingQuestOfferOptions(encounter), streaming: false, turnCount, pendingQuestOffer: { quest: nextQuest, }, }), ); return true; } catch (error) { console.error('Failed to replace pending npc quest offer:', error); setAiError(error instanceof Error ? error.message : '更换任务失败'); return false; } finally { setIsLoading(false); } }; const abandonPendingNpcQuestOffer = () => { const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; if (!encounter || !pendingQuestOffer) { return false; } const encounterKey = getNpcEncounterKey(encounter); const currentNpcChatState = currentStory?.npcChatState?.npcId === encounterKey ? currentStory.npcChatState : null; const currentDialogue = currentStory?.dialogue && currentNpcChatState ? [...currentStory.dialogue] : []; const turnCount = currentNpcChatState?.turnCount ?? 0; const playerLine = '这件事我先不接,咱们还是先聊别的。'; const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`; const nextState = { ...gameState, storyHistory: appendHistory( gameState, `你暂时没有接下${encounter.npcName}提出的委托。`, npcReply, ), }; setGameState(nextState); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...currentDialogue, { speaker: 'player', text: playerLine, }, { speaker: 'npc', speakerName: encounter.npcName, text: npcReply, }, ], options: buildPostQuestOfferChatSuggestions(encounter), streaming: false, turnCount, }), ); return true; }; const acceptPendingNpcQuestOffer = () => { const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; if (!encounter || !pendingQuestOffer) { return null; } const questId = pendingQuestOffer.quest.id?.trim(); if (!questId) { return null; } void resolveServerNpcStoryAction({ option: { functionId: 'npc_quest_accept', actionText: `你答应接下${encounter.npcName}的委托。`, text: `你答应接下${encounter.npcName}的委托。`, detailText: '', visuals: { playerAnimation: AnimationState.IDLE, playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, interaction: { kind: 'npc', npcId: encounter.id ?? encounter.npcName, action: 'quest_accept', }, }, }); return questId; }; const inferNpcInteractionFromOption = ( encounter: Encounter, option: StoryOption, ): StoryOption['interaction'] => { const npcId = encounter.id ?? encounter.npcName; const actionByFunctionId: Record = { npc_chat: { kind: 'npc', npcId, action: 'chat' }, npc_help: { kind: 'npc', npcId, action: 'help' }, npc_fight: { kind: 'npc', npcId, action: 'fight' }, npc_leave: { kind: 'npc', npcId, action: 'leave' }, npc_recruit: { kind: 'npc', npcId, action: 'recruit' }, npc_spar: { kind: 'npc', npcId, action: 'spar' }, npc_trade: { kind: 'npc', npcId, action: 'trade' }, npc_gift: { kind: 'npc', npcId, action: 'gift' }, npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in', questId: option.interaction?.kind === 'npc' ? option.interaction.questId : undefined, }, }; return option.interaction ?? actionByFunctionId[option.functionId]; }; const handleNpcInteraction = (option: StoryOption) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) { return false; } const encounter = gameState.currentEncounter; const resolvedInteraction = inferNpcInteractionFromOption( encounter, option, ); if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') { return false; } const resolvedOption = { ...option, interaction: resolvedInteraction, } satisfies StoryOption; const interactionDecision = resolveNpcInteractionDecision( gameState, resolvedOption, ); if (interactionDecision.kind === 'trade_modal') { npcInteractionFlow.openTradeModal(encounter, option.actionText); return true; } if (interactionDecision.kind === 'gift_modal') { npcInteractionFlow.openGiftModal(encounter, option.actionText); return true; } if (interactionDecision.kind === 'recruit_modal') { npcInteractionFlow.openRecruitModal(encounter, option.actionText); return true; } if (interactionDecision.kind === 'recruit_immediate') { void npcInteractionFlow.startRecruitmentSequence( encounter, option.actionText, ); return true; } switch (resolvedOption.interaction.action) { case 'help': { void resolveServerNpcStoryAction({ option: resolvedOption, }); return true; } case 'chat': { if ( currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) ) { void handleNpcChatTurn(encounter, resolvedOption.actionText); return true; } return enterNpcChat(encounter, resolvedOption); } case 'quest_accept': { void resolveServerNpcStoryAction({ option: resolvedOption, }); return true; } case 'quest_turn_in': { const questId = resolvedOption.interaction.questId; void resolveServerNpcStoryAction({ option: resolvedOption, payload: questId ? { questId, } : undefined, }); return true; } case 'leave': { void resolveServerNpcStoryAction({ option: resolvedOption, }); return true; } case 'fight': case 'spar': { void resolveServerNpcStoryAction({ option: resolvedOption, }); return true; } default: return false; } }; return { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult, reopenNpcChatAfterBattle, handleNpcChatTurn, exitNpcChat, replacePendingNpcQuestOffer, abandonPendingNpcQuestOffer, acceptPendingNpcQuestOffer, }; }