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 { getScenePresetById } from '../../data/scenePresets'; import { resolveFunctionOption } from '../../data/stateFunctions'; import { streamNpcChatTurn } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { advanceSceneActRuntimeState, getSceneConnectionDirectionText, resolveLimitedPrimaryNpcChatState, resolveSceneActProgression, } from '../../services/customWorldSceneActRuntime'; import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory'; import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine'; import type { Character, Encounter, GameState, InventoryItem, NpcBattleMode, NpcBattleOutcome, QuestLogEntry, StoryMoment, StoryOption, } from '../../types'; import { AnimationState } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; import { resolveRpgRuntimeChoice } from './rpgRuntimeStoryGateway'; type CommitGeneratedStateWithEncounterEntry = ( entryState: GameState, resolvedState: GameState, character: Character, actionText: string, resultText: string, lastFunctionId?: string, ) => Promise | void; 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; closingMode?: 'free' | 'foreshadow_close' | null; terminationMode?: 'none' | 'hostile_model' | null; terminationReason?: 'hostile_breakoff' | 'player_exit' | null; isHostileChat?: boolean; functionOptions?: Array<{ functionId: string; actionText: string; detailText?: string | null; action?: string | null; }>; } | 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; /** * RPG runtime NPC 交互主链。 * 负责 NPC 对话、委托处理、战斗后续对话重开,以及需要服务端结算的正式动作派发。 */ export function createStoryNpcEncounterActions({ gameState, currentStory, setGameState, setCurrentStory, setAiError, setIsLoading, appendHistory, buildNpcStory, buildOpeningCampChatContext, buildStoryContextFromState, buildFallbackStoryForState, getStoryGenerationHostileNpcs, getAvailableOptionsForState, buildContinueAdventureOption, 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; 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; 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 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 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, }); const chatDirective = toNpcChatDirectiveWithFunctionOptions( resolveLimitedPrimaryNpcChatState({ state: params.nextState, npcId: params.encounter.id ?? params.encounter.npcName, affinity: reopenedNpcState.affinity, nextTurnCount: 0, }), params.encounter, playerCharacter, ); 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 cloneNpcChatFunctionOption = (option: StoryOption): StoryOption => ({ ...option, visuals: { ...option.visuals, monsterChanges: option.visuals.monsterChanges.map((change) => ({ ...change, })), }, interaction: option.interaction ? { ...option.interaction } : undefined, runtimePayload: option.runtimePayload ? { ...option.runtimePayload } : option.runtimePayload, }); const rewriteNpcChatFunctionOption = ( option: StoryOption, actionText: string, ): StoryOption => ({ ...cloneNpcChatFunctionOption(option), actionText, text: actionText, }); 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), ), ]; return buildNpcChatTurnOptions(encounter, mergedSuggestions); }; const buildNpcChatFunctionOptionCatalog = ( encounter: Encounter, playerCharacter: Character, ) => buildPostNpcChatOptionCatalog(encounter, playerCharacter) .filter((option) => option.functionId !== 'battle_escape_breakout') .filter((option) => !isNpcChatOptionForEncounter(option, encounter)) .filter((option) => option.interaction?.kind === 'npc') .map(cloneNpcChatFunctionOption); const toNpcChatDirectiveWithFunctionOptions = ( directive: NpcChatDirective, encounter: Encounter, playerCharacter: Character, options?: { forcePlayerExit?: boolean; }, ): NpcChatDirective => { const functionOptions = buildNpcChatFunctionOptionCatalog( encounter, playerCharacter, ).map((option) => ({ functionId: option.functionId, actionText: option.actionText, detailText: option.detailText ?? null, action: option.interaction?.kind === 'npc' ? option.interaction.action : null, })); const isHostileChat = directive?.isHostileChat === true || directive?.terminationMode === 'hostile_model'; return { ...(directive ?? {}), terminationMode: isHostileChat ? 'hostile_model' : 'none', isHostileChat, terminationReason: options?.forcePlayerExit ? 'player_exit' : (directive?.terminationReason ?? null), closingMode: options?.forcePlayerExit ? 'foreshadow_close' : (directive?.closingMode ?? 'free'), forceExitAfterTurn: options?.forcePlayerExit || directive?.forceExitAfterTurn || false, functionOptions, }; }; const buildNpcChatMixedTurnOptions = ( encounter: Encounter, playerCharacter: Character, suggestions: string[], functionSuggestions?: Array<{ functionId?: string; actionText?: string; }>, ) => { const chatOptions = buildNpcChatTurnOptions(encounter, suggestions); const functionCatalog = buildNpcChatFunctionOptionCatalog( encounter, playerCharacter, ); const functionOptions = (functionSuggestions ?? []) .map((suggestion) => { if (!suggestion.functionId || !suggestion.actionText) return null; const matchedOption = functionCatalog.find( (option) => option.functionId === suggestion.functionId, ); return matchedOption ? rewriteNpcChatFunctionOption( matchedOption, sanitizeNpcChatSuggestion(suggestion.actionText) || matchedOption.actionText, ) : null; }) .filter((option): option is StoryOption => Boolean(option)); const mergedOptions = [...chatOptions, ...functionOptions]; const seen = new Set(); return mergedOptions.filter((option) => { const key = [ option.functionId, option.actionText, option.interaction?.kind === 'npc' ? option.interaction.action : '', ].join('::'); if (seen.has(key)) return false; seen.add(key); return true; }); }; 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, terminationMode: params.chatDirective?.terminationMode ?? null, terminationReason: params.chatDirective?.terminationReason ?? null, isHostileChat: params.chatDirective?.isHostileChat ?? 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 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 buildSceneConnectionTravelOptions = (state: GameState) => { if (!state.worldType || !state.currentScenePreset) { return []; } const seenSceneIds = new Set(); return (state.currentScenePreset.connections ?? []) .filter((connection) => { if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) { return false; } seenSceneIds.add(connection.sceneId); return true; }) .map((connection) => { const targetScene = getScenePresetById( state.worldType!, connection.sceneId, ); const targetSceneName = targetScene?.name ?? connection.sceneId; const directionText = getSceneConnectionDirectionText( connection.relativePosition, ); const actionText = `${directionText},前往${targetSceneName}`; return { functionId: 'idle_travel_next_scene', actionText, text: actionText, detailText: connection.summary, priority: 12, visuals: { playerAnimation: AnimationState.RUN, playerMoveMeters: 1.1, playerOffsetY: 0, playerFacing: connection.relativePosition === 'west' || connection.relativePosition === 'left' || connection.relativePosition === 'back' ? 'left' : 'right', scrollWorld: false, monsterChanges: [], }, runtimePayload: { targetSceneId: connection.sceneId, }, } satisfies StoryOption; }); }; const buildPostNpcChatProgressionOptions = ( encounter: Encounter, playerCharacter: Character, ) => { const progression = resolveSceneActProgression({ profile: gameState.customWorldProfile, sceneId: gameState.currentScenePreset?.id ?? null, storyEngineMemory: gameState.storyEngineMemory, }); if (!progression) { return { deferredRuntimeState: null, options: currentStory?.deferredOptions?.length ? currentStory.deferredOptions : buildPostNpcChatOptionCatalog(encounter, playerCharacter), }; } if (!progression.isLastAct) { const nextActState = advanceSceneActRuntimeState({ progress: progression }); const nextStoryEngineMemory = nextActState ? { ...(gameState.storyEngineMemory ?? createEmptyStoryEngineMemoryState()), currentSceneActState: nextActState, } : gameState.storyEngineMemory; const nextState = { ...gameState, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], inBattle: false, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, storyEngineMemory: nextStoryEngineMemory, }; const nextOptions = collapseNpcChatOptions( getAvailableOptionsForState(nextState, playerCharacter) ?? [], ); return { deferredRuntimeState: { currentScenePreset: nextState.currentScenePreset, storyEngineMemory: nextState.storyEngineMemory, }, options: nextOptions.length > 0 ? nextOptions : buildPostNpcChatOptionCatalog(encounter, playerCharacter), }; } const travelOptions = buildSceneConnectionTravelOptions(gameState); return { deferredRuntimeState: null, options: travelOptions.length > 0 ? travelOptions : buildPostNpcChatOptionCatalog(encounter, playerCharacter), }; }; 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, actionText = '逃跑', runtimePayload?: StoryOption['runtimePayload'], ): 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: actionText, detailText: '', runtimePayload: { ...(resolvedOption.runtimePayload ?? {}), ...(runtimePayload ?? {}), }, }; } return { functionId: 'battle_escape_breakout', actionText, text: actionText, detailText: '', visuals: { playerAnimation: AnimationState.RUN, playerMoveMeters: -0.6, playerOffsetY: 0, playerFacing: 'left', scrollWorld: true, monsterChanges: [], }, runtimePayload, }; }; const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => { const currentScene = gameState.currentScenePreset; const worldType = gameState.worldType; const options: StoryOption[] = []; const seenSceneIds = new Set(); if (worldType && currentScene) { for (const connection of currentScene.connections ?? []) { if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) { continue; } seenSceneIds.add(connection.sceneId); const targetScene = getScenePresetById(worldType, connection.sceneId); const targetSceneName = targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId; options.push( buildHostileNpcEscapeOption( character, `逃往${targetSceneName}`, { targetSceneId: connection.sceneId, escapeTargetSceneId: connection.sceneId, escapeEntry: 'from_left', }, ), ); } options.push( buildHostileNpcEscapeOption( character, '逃回当前场景起点', { targetSceneId: currentScene.id, escapeTargetSceneId: currentScene.id, escapeReturnToSceneStart: true, escapeEntry: 'from_left', }, ), ); } return options.length > 0 ? options : [buildHostileNpcEscapeOption(character)]; }; 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: [ buildHostileNpcFightOption(encounter), ...buildHostileNpcEscapeOptions(character), ], displayMode: 'dialogue', dialogue: [ { speaker: 'npc', speakerName: encounter.npcName, text: declarationText, }, ], streaming: false, }; }; const shouldUseHostileNpcChatClosureOptions = ( directive: NpcChatDirective, affinity: number, ) => affinity < 0 || directive?.limitReason === 'negative_affinity' || directive?.terminationMode === 'hostile_model' || directive?.isHostileChat === true; // 负好感聊天结束后不能回到普通冒险分支,只允许玩家立刻战斗或逃离。 const buildNpcChatClosureOptions = ( encounter: Encounter, character: Character, directive: NpcChatDirective, affinity: number, ) => { if (!shouldUseHostileNpcChatClosureOptions(directive, affinity)) { return [buildContinueAdventureOption()]; } const fightOption = buildHostileNpcFightOption(encounter); return [ { ...fightOption, actionText: '战斗', text: '战斗', }, ...buildHostileNpcEscapeOptions(character), ]; }; const enterNpcChat = ( encounter: Encounter, selectedOption: StoryOption, extraOptions: StoryOption[] = [], chatDirective?: NpcChatDirective, openingSource: 'npc_initiated' | 'player_reply' = 'player_reply', ) => { const openingDialogue = buildNpcChatDialogueHistory(encounter, 0); const playerCharacter = gameState.playerCharacter; const resolvedChatDirective = playerCharacter ? toNpcChatDirectiveWithFunctionOptions( chatDirective ?? null, encounter, playerCharacter, ) : chatDirective; setAiError(null); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: openingDialogue, options: buildNpcChatEntryOptions( encounter, selectedOption, extraOptions, ), streaming: false, turnCount: 0, chatDirective: resolvedChatDirective, 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 resolvedChatDirective = toNpcChatDirectiveWithFunctionOptions( chatDirective ?? null, encounter, playerCharacter, ); 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: resolvedChatDirective, 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, '', { 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: resolvedChatDirective, openingSource: 'npc_initiated', }), ); }, chatDirective: resolvedChatDirective, npcInitiatesConversation: true, }, ); if (!chatTurn?.npcReply?.trim()) { throw new Error('NPC 主动开场结果为空'); } setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: [ ...existingDialogue, { speaker: 'npc', speakerName: encounter.npcName, text: chatTurn.npcReply, }, ], options: buildNpcChatMixedTurnOptions( encounter, playerCharacter, chatTurn.suggestions.length > 0 ? chatTurn.suggestions : openingOptions.map((option) => option.actionText), chatTurn.functionSuggestions, ), streaming: false, turnCount: 0, chatDirective: resolvedChatDirective, 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, options: { forcePlayerExit?: boolean; } = {}, ) => { 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 chatDirective = toNpcChatDirectiveWithFunctionOptions( limitedChatDirective, encounter, playerCharacter, { forcePlayerExit: options.forcePlayerExit, }, ); const openingCampContext = buildOpeningCampChatContext( gameState, playerCharacter, encounter, ); setAiError(null); setIsLoading(true); setCurrentStory( buildNpcChatStoryMoment({ encounter, dialogue: dialogueWithPlayer, options: [], streaming: true, turnCount: nextTurnCount, chatDirective, 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, combatContext: currentCombatContext, }), ); }, questOfferContext: chatDirective?.isHostileChat ? null : { state: gameState, turnCount: nextTurnCount, }, chatDirective, 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 = chatDirective ? { sceneActId: chatDirective.sceneActId ?? null, turnLimit: chatTurn.chatDirective?.turnLimit ?? chatDirective.turnLimit ?? null, remainingTurns: chatTurn.chatDirective?.remainingTurns ?? chatDirective.remainingTurns ?? null, limitReason: chatDirective.limitReason ?? null, terminationMode: chatDirective.terminationMode ?? null, terminationReason: chatTurn.chatDirective?.terminationReason ?? chatDirective.terminationReason ?? null, isHostileChat: chatDirective.isHostileChat ?? false, closingMode: chatTurn.chatDirective?.closingMode ?? chatDirective.closingMode ?? 'free', forceExitAfterTurn: chatTurn.chatDirective?.forceExit ?? chatDirective.forceExitAfterTurn ?? false, } : null; const shouldForceExitAfterTurn = resolvedChatDirective?.forceExitAfterTurn === true; const pendingQuestIntroText = chatTurn.pendingQuestOffer?.introText?.trim() || ''; if (shouldForceExitAfterTurn) { const closingDialogue = [ ...nextDialogue, ]; const shouldUseHostileClosureOptions = shouldUseHostileNpcChatClosureOptions( resolvedChatDirective, Math.min(npcState.affinity, nextAffinity), ); const progressionResult = shouldUseHostileClosureOptions ? null : buildPostNpcChatProgressionOptions(encounter, playerCharacter); setCurrentStory({ text: closingDialogue.map((turn) => turn.text).join('\n'), options: buildNpcChatClosureOptions( encounter, playerCharacter, resolvedChatDirective, Math.min(npcState.affinity, nextAffinity), ), displayMode: 'dialogue', dialogue: closingDialogue, streaming: false, npcAffinityEffect: latestAffinityEffect, deferredOptions: progressionResult?.options, deferredRuntimeState: progressionResult?.deferredRuntimeState ?? undefined, }); 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: buildNpcChatMixedTurnOptions( encounter, playerCharacter, chatTurn.suggestions.length > 0 ? chatTurn.suggestions : buildFallbackNpcChatSuggestions(playerMessage), chatTurn.functionSuggestions, ), 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, combatContext: currentCombatContext, }), ); return false; } finally { setIsLoading(false); } }; const continueAfterNpcChatClosure = async () => { const playerCharacter = gameState.playerCharacter; const encounter = gameState.currentEncounter; if (!playerCharacter || !isNpcEncounter(encounter)) { return false; } setAiError(null); const progressionResult = buildPostNpcChatProgressionOptions( encounter, playerCharacter, ); const nextState = { ...gameState, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], inBattle: false, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, currentScenePreset: progressionResult.deferredRuntimeState?.currentScenePreset ?? gameState.currentScenePreset, storyEngineMemory: progressionResult.deferredRuntimeState?.storyEngineMemory ?? gameState.storyEngineMemory, }; setGameState(nextState); setCurrentStory({ text: currentStory?.dialogue?.at(-1)?.text ?? currentStory?.text ?? '', options: progressionResult.options, displayMode: 'narrative', }); return true; }; const exitNpcChat = () => { const playerCharacter = gameState.playerCharacter; const encounter = gameState.currentEncounter; if (!playerCharacter || !isNpcEncounter(encounter)) { return false; } void handleNpcChatTurn( encounter, `我先结束这轮交谈,继续往前走。`, { forcePlayerExit: true, }, ); 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, }); 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; } if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) { setCurrentStory( buildHostileNpcStoryMoment( encounter, playerCharacter, npcState.affinity, ), ); 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 resolveRpgRuntimeChoice({ 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 = () => { const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; if (!encounter || !pendingQuestOffer) { return false; } void resolveServerNpcStoryAction({ option: { functionId: 'npc_chat_quest_offer_replace', 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_offer_replace', }, }, }); return true; }; const abandonPendingNpcQuestOffer = () => { const encounter = gameState.currentEncounter; const pendingQuestOffer = isNpcEncounter(encounter) ? getPendingQuestOffer(currentStory, encounter) : null; if (!encounter || !pendingQuestOffer) { return false; } void resolveServerNpcStoryAction({ option: { functionId: 'npc_chat_quest_offer_abandon', 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_offer_abandon', }, }, }); 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) => { if ( currentStory?.deferredOptions?.length && option.functionId === 'story_continue_adventure' ) { void continueAfterNpcChatClosure(); return true; } 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; } const npcState = getResolvedNpcState(gameState, encounter); const limitedChatDirective = resolveLimitedPrimaryNpcChatState({ state: gameState, npcId: encounter.id ?? encounter.npcName, affinity: npcState.affinity, nextTurnCount: 0, }); if (!npcState.firstMeaningfulContactResolved) { void startNpcInitiatedOpening( encounter, resolvedOption, [], limitedChatDirective, ); 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, continueAfterNpcChatClosure, replacePendingNpcQuestOffer, abandonPendingNpcQuestOffer, acceptPendingNpcQuestOffer, }; } export type UseRpgRuntimeNpcInteractionParams = Parameters< typeof createStoryNpcEncounterActions >[0]; export type RpgRuntimeNpcInteractionResult = ReturnType< typeof createStoryNpcEncounterActions >; export const createRpgRuntimeNpcEncounterActions = createStoryNpcEncounterActions; export function useRpgRuntimeNpcInteraction( params: UseRpgRuntimeNpcInteractionParams, ) { return createStoryNpcEncounterActions(params); }