import type { Dispatch, SetStateAction, } from 'react'; import { useState } from 'react'; import { getCharacterById, } from '../../data/characterPresets'; import { buildNpcGiftModalState, buildNpcRecruitModalState, buildNpcTradeModalIntroText, } from '../../data/functionCatalog'; import { buildNpcTradeTransactionActionText, } from '../../data/npcInteractions'; import { streamNpcRecruitDialogue } from '../../services/aiService'; import type { StoryGenerationContext } from '../../services/aiTypes'; import type { Character, Encounter, GameState, InventoryItem, RuntimeNpcTradeItemView, StoryMoment, StoryOption, } from '../../types'; import { resolveRpgRuntimeChoice } from '.'; import type { GiftModalState, RecruitModalState, StoryGenerationNpcUi, TradeModalState, } from './uiTypes'; type GenerateStoryForState = (params: { state: GameState; character: Character; history: StoryMoment[]; choice?: string; lastFunctionId?: string | null; optionCatalog?: StoryOption[] | null; }) => Promise; type StoryNpcInteractionRuntime = { currentStory: StoryMoment | null; setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; buildStoryContextFromState: ( state: GameState, extras?: { lastFunctionId?: string | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; }, ) => 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; }; function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) { const releaseLine = releasedCompanionName ? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。` : '你:如果你愿意加入,我希望接下来能和你并肩行动。'; return [ '你:我不是一时起意,我想正式邀请你加入我的队伍。', `${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`, releaseLine, `${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`, ].join('\n'); } function normalizeRecruitDialogue( encounter: Encounter, dialogueText: string, releasedCompanionName?: string | null, ) { const rawLines = dialogueText .replace(/\r/g, '') .split('\n') .map(line => line.trim()) .filter(Boolean); const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u; const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line)); const npcPrefix = `${encounter.npcName}:`; const playerPrefix = '你:'; const releaseLine = releasedCompanionName ? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。` : `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`; const defaultLines = [ `${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`, `${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`, releaseLine, `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`, ]; const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3); if (!workingLines.some(line => line.startsWith(playerPrefix))) { const firstDefaultLine = defaultLines[0]; if (firstDefaultLine) { workingLines.unshift(firstDefaultLine); } } if (!workingLines.some(line => line.startsWith(npcPrefix))) { const secondDefaultLine = defaultLines[1]; if (secondDefaultLine) { workingLines.push(secondDefaultLine); } } const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`; const lastWorkingLine = workingLines[workingLines.length - 1]; if ( workingLines.length === 0 || !lastWorkingLine?.startsWith(npcPrefix) || refusalPattern.test(lastWorkingLine) ) { workingLines.push(acceptanceLine); } else { workingLines[workingLines.length - 1] = acceptanceLine; } const compactLines = workingLines.slice(0, 5); if (compactLines[compactLines.length - 1] !== acceptanceLine) { compactLines.push(acceptanceLine); } return compactLines.slice(0, 6).join('\n'); } function normalizeTradeQuantity(quantity: number) { return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1)); } export function useStoryNpcInteractionFlow({ gameState, setGameState, getNpcEncounterKey, getResolvedNpcState: _getResolvedNpcState, updateNpcState: _updateNpcState, cloneInventoryItemForOwner: _cloneInventoryItemForOwner, runtime, }: { gameState: GameState; setGameState: Dispatch>; 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; runtime: StoryNpcInteractionRuntime; }) { const [tradeModal, setTradeModal] = useState(null); const [giftModal, setGiftModal] = useState(null); const [recruitModal, setRecruitModal] = useState(null); const resolveRecruitmentOnServer = async (params: { encounter: Encounter; actionText: string; releasedNpcId?: string | null; preludeText?: string | null; }) => { const playerCharacter = gameState.playerCharacter; if ( !playerCharacter || !gameState.worldType || gameState.currentScene !== 'Story' ) { return false; } try { const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({ gameState, currentStory: runtime.currentStory, option: { functionId: 'npc_recruit', actionText: params.actionText, interaction: { kind: 'npc', npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter), action: 'recruit', }, }, payload: { ...(params.releasedNpcId ? { releaseNpcId: params.releasedNpcId, } : {}), ...(params.preludeText ? { preludeText: params.preludeText, } : {}), }, }); setGameState(hydratedSnapshot.gameState); runtime.setCurrentStory(nextStory); return true; } catch (error) { console.error('Failed to resolve npc recruit action on the server:', error); runtime.setAiError( error instanceof Error ? error.message : 'NPC 招募执行失败', ); if (!runtime.currentStory) { runtime.setCurrentStory( runtime.buildFallbackStoryForState(gameState, playerCharacter), ); } return false; } finally { runtime.setIsLoading(false); } }; const startRecruitmentSequence = async ( encounter: Encounter, actionText: string, releasedNpcId?: string | null, ) => { if (!gameState.playerCharacter) return; const releasedCompanionName = releasedNpcId ? (() => { const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId); return releasedCompanion?.characterId ? getCharacterById(releasedCompanion.characterId)?.name ?? null : null; })() : null; const provisionalState = { ...gameState, }; const recruitPromptSummary = releasedCompanionName ? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。` : '如果对方答应加入,你们将立刻结伴同行。'; setRecruitModal(null); runtime.setAiError(null); runtime.setIsLoading(true); runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true)); let dialogueText = ''; let streamedTargetText = ''; let displayedText = ''; let streamCompleted = false; const typewriterPromise = (async () => { while (!streamCompleted || displayedText.length < streamedTargetText.length) { if (displayedText.length >= streamedTargetText.length) { await new Promise(resolve => window.setTimeout(resolve, 40)); continue; } const nextChar = streamedTargetText[displayedText.length]; if (!nextChar) { await new Promise(resolve => window.setTimeout(resolve, 40)); continue; } displayedText += nextChar; runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true)); await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar))); } })(); try { dialogueText = await streamNpcRecruitDialogue( gameState.worldType!, gameState.playerCharacter, encounter, runtime.getStoryGenerationHostileNpcs(provisionalState), gameState.storyHistory, runtime.buildStoryContextFromState(provisionalState, { lastFunctionId: 'npc_recruit', }), actionText, recruitPromptSummary, { onUpdate: text => { streamedTargetText = text; }, }, ); streamedTargetText = dialogueText; streamCompleted = true; await typewriterPromise; } catch (error) { streamCompleted = true; await typewriterPromise; console.error('Failed to stream recruit dialogue:', error); dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName); runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误'); } const finalDialogueText = normalizeRecruitDialogue( encounter, dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName), releasedCompanionName, ); runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false)); await new Promise(resolve => window.setTimeout(resolve, 260)); await resolveRecruitmentOnServer({ encounter, actionText, releasedNpcId, preludeText: finalDialogueText, }); }; const getRuntimeTradeItems = ( mode: 'buy' | 'sell', ): RuntimeNpcTradeItemView[] => mode === 'buy' ? gameState.runtimeNpcInteraction?.trade.buyItems ?? [] : gameState.runtimeNpcInteraction?.trade.sellItems ?? []; const findRuntimeTradeItem = (modal: TradeModalState) => { const itemId = modal.mode === 'buy' ? modal.selectedNpcItemId : modal.selectedPlayerItemId; if (!itemId) return null; return ( getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ?? null ); }; const findRuntimeGiftItem = (itemId: string | null) => { if (!itemId) return null; return ( gameState.runtimeNpcInteraction?.gift.items.find( (item) => item.itemId === itemId, ) ?? null ); }; const openTradeModal = (encounter: Encounter, actionText: string) => { setTradeModal( { encounter, actionText, introText: buildNpcTradeModalIntroText(encounter), mode: 'buy', selectedNpcItemId: gameState.runtimeNpcInteraction?.trade.buyItems.find( (item) => item.canSubmit, )?.itemId ?? gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? null, selectedPlayerItemId: gameState.runtimeNpcInteraction?.trade.sellItems.find( (item) => item.canSubmit, )?.itemId ?? gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? null, selectedQuantity: 1, }, ); }; const openGiftModal = (encounter: Encounter, actionText: string) => { const selectedItemId = gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit) ?.itemId ?? gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ?? null; setGiftModal( buildNpcGiftModalState( encounter, actionText, selectedItemId, ), ); }; const openRecruitModal = (encounter: Encounter, actionText: string) => { setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText)); }; const clearNpcInteractionUi = () => { setTradeModal(null); setGiftModal(null); setRecruitModal(null); }; const resolveServerNpcAction = async (params: { encounter: Encounter; actionText: string; functionId: string; action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in'; payload?: Record; }) => { const playerCharacter = gameState.playerCharacter; if ( !playerCharacter || !gameState.worldType || gameState.currentScene !== 'Story' ) { return false; } runtime.setAiError(null); runtime.setIsLoading(true); try { const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({ gameState, currentStory: runtime.currentStory, option: { functionId: params.functionId, actionText: params.actionText, interaction: { kind: 'npc', npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter), action: params.action, }, }, payload: params.payload, }); setGameState(hydratedSnapshot.gameState); runtime.setCurrentStory(nextStory); return true; } catch (error) { console.error('Failed to resolve npc runtime action on the server:', error); runtime.setAiError( error instanceof Error ? error.message : 'NPC 交互执行失败', ); if (!runtime.currentStory) { runtime.setCurrentStory( runtime.buildFallbackStoryForState(gameState, playerCharacter), ); } return false; } finally { runtime.setIsLoading(false); } }; const confirmTrade = () => { if (!tradeModal || !gameState.playerCharacter) return; const encounter = tradeModal.encounter; const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity); const tradeItem = findRuntimeTradeItem(tradeModal); if (!tradeItem) return; setTradeModal(null); void resolveServerNpcAction({ encounter, actionText: buildNpcTradeTransactionActionText({ encounter, mode: tradeModal.mode, item: tradeItem.item, quantity, }), functionId: 'npc_trade', action: 'trade', payload: { mode: tradeModal.mode, itemId: tradeItem.itemId, quantity, }, }); }; const confirmGift = () => { if (!giftModal || !gameState.playerCharacter) return; const encounter = giftModal.encounter; const giftItem = findRuntimeGiftItem(giftModal.selectedItemId); if (!giftItem) return; setGiftModal(null); void resolveServerNpcAction({ encounter, actionText: `把${giftItem.item.name}赠给${encounter.npcName}`, functionId: 'npc_gift', action: 'gift', payload: { itemId: giftItem.itemId, }, }); }; return { npcUi: { tradeModal, giftModal, recruitModal, setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => { if (!current) return current; return { ...current, mode, selectedNpcItemId: current.selectedNpcItemId ?? gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ?? null, selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ?? null, selectedQuantity: 1, }; }), selectTradeNpcItem: (itemId: string) => setTradeModal(current => { if (!current) return current; return { ...current, selectedNpcItemId: itemId, selectedQuantity: 1, }; }), selectTradePlayerItem: (itemId: string) => setTradeModal(current => { if (!current) return current; return { ...current, selectedPlayerItemId: itemId, selectedQuantity: 1, }; }), setTradeQuantity: (quantity: number) => setTradeModal(current => current ? { ...current, selectedQuantity: normalizeTradeQuantity(quantity), } : current), closeTradeModal: () => setTradeModal(null), confirmTrade, selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current), closeGiftModal: () => setGiftModal(null), confirmGift, selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current), closeRecruitModal: () => setRecruitModal(null), confirmRecruit: () => { if (!recruitModal) return; void startRecruitmentSequence( recruitModal.encounter, recruitModal.actionText, recruitModal.selectedReleaseNpcId, ); }, } satisfies StoryGenerationNpcUi, openTradeModal, openGiftModal, openRecruitModal, startRecruitmentSequence, clearNpcInteractionUi, }; }