import type { Dispatch, SetStateAction, } from 'react'; import { useState } from 'react'; import { buildRelationState } from '../../data/attributeResolver'; import { buildCompanionState, getCharacterById, resolveEncounterRecruitCharacter, } from '../../data/characterPresets'; import { recruitCompanionToParty } from '../../data/companionRoster'; import { getNpcBuybackPrice, getNpcPurchasePrice, } from '../../data/economy'; import { addInventoryItems, buildNpcGiftCommitActionText, buildNpcGiftResultText, buildNpcRecruitResultText, buildNpcTradeTransactionActionText, buildNpcTradeTransactionResultText, getGiftCandidates, getPreferredGiftItemId, markNpcFirstMeaningfulContactResolved, removeInventoryItem, syncNpcTradeInventory, } from '../../data/npcInteractions'; import { streamNpcRecruitDialogue } from '../../services/ai'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { createHistoryMoment } from '../../services/storyHistory'; import type { Character, Encounter, GameState, InventoryItem, StoryMoment, StoryOption, } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; 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 = { setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; buildStoryContextFromState: ( state: GameState, extras?: { lastFunctionId?: 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['sceneMonsters']; 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'); } export function useStoryNpcInteractionFlow({ gameState, setGameState, commitGeneratedState, getNpcEncounterKey, getResolvedNpcState, updateNpcState, cloneInventoryItemForOwner, runtime, }: { gameState: GameState; setGameState: Dispatch>; commitGeneratedState: CommitGeneratedState; 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 getTradeNpcItem = (state: GameState, modal: TradeModalState) => { const npcState = getResolvedNpcState(state, modal.encounter); return npcState.inventory.find(item => item.id === modal.selectedNpcItemId) ?? null; }; const getTradePlayerItem = (state: GameState, modal: TradeModalState) => state.playerInventory.find(item => item.id === modal.selectedPlayerItemId) ?? null; const getTradeUnitPrice = (state: GameState, modal: TradeModalState) => { if (modal.mode === 'buy') { const npcItem = getTradeNpcItem(state, modal); const npcState = getResolvedNpcState(state, modal.encounter); return npcItem ? getNpcPurchasePrice(npcItem, npcState.affinity) : 0; } const playerItem = getTradePlayerItem(state, modal); const npcState = getResolvedNpcState(state, modal.encounter); return playerItem ? getNpcBuybackPrice(playerItem, npcState.affinity) : 0; }; const getTradeMaxQuantity = (state: GameState, modal: TradeModalState) => { if (modal.mode === 'buy') { return getTradeNpcItem(state, modal)?.quantity ?? 0; } return getTradePlayerItem(state, modal)?.quantity ?? 0; }; const clampTradeQuantity = (state: GameState, modal: TradeModalState, quantity: number) => { const maxQuantity = getTradeMaxQuantity(state, modal); if (maxQuantity <= 0) return 1; return Math.max(1, Math.min(maxQuantity, Math.floor(quantity))); }; const buildRecruitmentOutcome = ( encounter: Encounter, releasedNpcId?: string | null, ) => { if (!gameState.playerCharacter) return null; const npcState = getResolvedNpcState(gameState, encounter); const recruitKey = getNpcEncounterKey(encounter); let releasedCompanionName: string | null = null; const nextNpcStates = { ...gameState.npcStates, [recruitKey]: { ...markNpcFirstMeaningfulContactResolved(npcState), recruited: true, }, }; if (releasedNpcId) { const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId); releasedCompanionName = releasedCompanion?.characterId ? getCharacterById(releasedCompanion.characterId)?.name ?? null : null; } const recruitCharacter = resolveEncounterRecruitCharacter(encounter); if (!recruitCharacter) return null; const recruitedCompanion = buildCompanionState( recruitKey, recruitCharacter, npcState.affinity, ); const rosterState = recruitCompanionToParty(gameState, recruitedCompanion, releasedNpcId); const nextState: GameState = { ...rosterState, npcStates: nextNpcStates, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: gameState.animationState, scrollWorld: false, inBattle: false, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, ambientIdleMode: undefined, activeCombatEffects: [], }; return { nextState, releasedCompanionName, }; }; const executeRecruitment = ( encounter: Encounter, actionText: string, releasedNpcId?: string | null, preludeText?: string | null, ) => { if (!gameState.playerCharacter) return; const outcome = buildRecruitmentOutcome(encounter, releasedNpcId); if (!outcome) return; const recruitResultText = buildNpcRecruitResultText(encounter, outcome.releasedCompanionName); setRecruitModal(null); if (!preludeText) { void commitGeneratedState( outcome.nextState, gameState.playerCharacter, actionText, recruitResultText, 'npc_recruit', ); return; } const nextHistory = [ ...gameState.storyHistory, createHistoryMoment(actionText, 'action'), createHistoryMoment(preludeText, 'result'), createHistoryMoment(recruitResultText, 'result'), ]; const stateWithHistory = { ...outcome.nextState, storyHistory: nextHistory, }; setGameState(stateWithHistory); runtime.setAiError(null); void runtime.generateStoryForState({ state: stateWithHistory, character: gameState.playerCharacter, history: nextHistory, choice: actionText, lastFunctionId: 'npc_recruit', }) .then(nextStory => { runtime.setCurrentStory(nextStory); }) .catch(error => { console.error('Failed to continue recruit story:', error); runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误'); runtime.setCurrentStory( runtime.buildFallbackStoryForState(stateWithHistory, gameState.playerCharacter!, recruitResultText), ); }) .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)); executeRecruitment(encounter, actionText, releasedNpcId, finalDialogueText); }; const openTradeModal = (encounter: Encounter, actionText: string) => { const currentNpcState = getResolvedNpcState(gameState, encounter); const npcState = syncNpcTradeInventory( gameState, encounter, currentNpcState, ); if ( gameState.npcStates[getNpcEncounterKey(encounter)] !== npcState || npcState !== currentNpcState ) { setGameState(updateNpcState(gameState, encounter, () => npcState)); } setTradeModal({ encounter, actionText, mode: 'buy', selectedNpcItemId: npcState.inventory[0]?.id ?? null, selectedPlayerItemId: gameState.playerInventory[0]?.id ?? null, selectedQuantity: 1, }); }; const openGiftModal = (encounter: Encounter, actionText: string) => { const selectedItemId = getPreferredGiftItemId( gameState.playerInventory, encounter, { worldType: gameState.worldType, customWorldProfile: gameState.customWorldProfile, }, ); if (!selectedItemId) return; setGiftModal({ encounter, actionText, selectedItemId, }); }; const openRecruitModal = (encounter: Encounter, actionText: string) => { setRecruitModal({ encounter, actionText, selectedReleaseNpcId: gameState.companions[0]?.npcId ?? null, }); }; const clearNpcInteractionUi = () => { setTradeModal(null); setGiftModal(null); setRecruitModal(null); }; const confirmTrade = () => { if (!tradeModal || !gameState.playerCharacter) return; const encounter = tradeModal.encounter; const quantity = clampTradeQuantity(gameState, tradeModal, tradeModal.selectedQuantity); const unitPrice = getTradeUnitPrice(gameState, tradeModal); const totalPrice = unitPrice * quantity; if (tradeModal.mode === 'buy') { const npcItem = getTradeNpcItem(gameState, tradeModal); if (!npcItem || quantity <= 0) return; if (npcItem.quantity < quantity || gameState.playerCurrency < totalPrice) return; let nextState = updateNpcState( gameState, encounter, currentNpcState => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), inventory: removeInventoryItem(currentNpcState.inventory, npcItem.id, quantity), }), ); nextState = { ...nextState, playerCurrency: nextState.playerCurrency - totalPrice, playerInventory: addInventoryItems( nextState.playerInventory, [cloneInventoryItemForOwner(npcItem, 'player', quantity)], ), }; setTradeModal(null); void commitGeneratedState( nextState, gameState.playerCharacter, buildNpcTradeTransactionActionText({ encounter, mode: 'buy', item: npcItem, quantity, }), buildNpcTradeTransactionResultText({ encounter, mode: 'buy', item: npcItem, quantity, totalPrice, worldType: gameState.worldType, }), 'npc_trade', ); return; } const playerItem = getTradePlayerItem(gameState, tradeModal); if (!playerItem || quantity <= 0) return; if (playerItem.quantity < quantity) return; let nextState = updateNpcState( gameState, encounter, currentNpcState => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), inventory: addInventoryItems( currentNpcState.inventory, [cloneInventoryItemForOwner(playerItem, 'npc', quantity)], ), }), ); nextState = { ...nextState, playerCurrency: nextState.playerCurrency + totalPrice, playerInventory: removeInventoryItem(nextState.playerInventory, playerItem.id, quantity), }; setTradeModal(null); void commitGeneratedState( nextState, gameState.playerCharacter, buildNpcTradeTransactionActionText({ encounter, mode: 'sell', item: playerItem, quantity, }), buildNpcTradeTransactionResultText({ encounter, mode: 'sell', item: playerItem, quantity, totalPrice, worldType: gameState.worldType, }), 'npc_trade', ); }; const confirmGift = () => { if (!giftModal || !gameState.playerCharacter) return; const encounter = giftModal.encounter; const giftItem = gameState.playerInventory.find(item => item.id === giftModal.selectedItemId); if (!giftItem) return; const giftCandidate = getGiftCandidates(gameState.playerInventory, encounter, { worldType: gameState.worldType, customWorldProfile: gameState.customWorldProfile, }) .find(candidate => candidate.item.id === giftItem.id); const affinityGain = giftCandidate?.affinityGain ?? 0; const attributeSummary = giftCandidate?.attributeInsight?.reasonText ?? null; let nextAffinity = 0; let nextState = updateNpcState( gameState, encounter, currentNpcState => { nextAffinity = currentNpcState.affinity + affinityGain; return { ...markNpcFirstMeaningfulContactResolved(currentNpcState), affinity: nextAffinity, relationState: buildRelationState(nextAffinity), giftsGiven: currentNpcState.giftsGiven + 1, inventory: addInventoryItems( currentNpcState.inventory, [cloneInventoryItemForOwner(giftItem, 'npc')], ), }; }, ); nextState = { ...nextState, playerInventory: removeInventoryItem(nextState.playerInventory, giftItem.id, 1), }; setGiftModal(null); void commitGeneratedState( nextState, gameState.playerCharacter, buildNpcGiftCommitActionText(encounter, giftItem), buildNpcGiftResultText(encounter, giftItem, affinityGain, nextAffinity, attributeSummary ?? undefined), 'npc_gift', ); }; return { npcUi: { tradeModal, giftModal, recruitModal, setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => { if (!current) return current; const nextModal = { ...current, mode, selectedNpcItemId: current.selectedNpcItemId ?? getResolvedNpcState(gameState, current.encounter).inventory[0]?.id ?? null, selectedPlayerItemId: current.selectedPlayerItemId ?? gameState.playerInventory[0]?.id ?? null, selectedQuantity: 1, }; return { ...nextModal, selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), }; }), selectTradeNpcItem: (itemId: string) => setTradeModal(current => { if (!current) return current; const nextModal = { ...current, selectedNpcItemId: itemId, }; return { ...nextModal, selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), }; }), selectTradePlayerItem: (itemId: string) => setTradeModal(current => { if (!current) return current; const nextModal = { ...current, selectedPlayerItemId: itemId, }; return { ...nextModal, selectedQuantity: clampTradeQuantity(gameState, nextModal, 1), }; }), setTradeQuantity: (quantity: number) => setTradeModal(current => current ? { ...current, selectedQuantity: clampTradeQuantity(gameState, current, 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, }; }