import type { Dispatch, SetStateAction, } from 'react'; import { buildEncounterEntryState, hasEncounterEntity, } from '../../data/encounterTransition'; import { rollHostileNpcLoot } from '../../data/hostileNpcPresets'; import { addInventoryItems } from '../../data/npcInteractions'; import { applyQuestProgressFromHostileNpcDefeat } from '../../data/questFlow'; import { CALL_OUT_ENTRY_X_METERS, createSceneEncounterPreview, resolveSceneEncounterPreview, } from '../../data/sceneEncounterPreviews'; import { generateNextStep } from '../../services/ai'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { createHistoryMoment } from '../../services/storyHistory'; import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, } from '../../types'; import type { EscapePlaybackSync } from '../combat/escapeFlow'; import type { ResolvedChoiceState } from '../combat/resolvedChoice'; import type { CommitGeneratedStateWithEncounterEntry, GenerateStoryForState, } from './progressionActions'; import type { BattleRewardSummary } from './uiTypes'; type RuntimeStatsIncrements = Partial>; type BuildFallbackStoryForState = ( state: GameState, character: Character, fallbackText?: string, ) => StoryMoment; type BuildStoryFromResponse = ( state: GameState, character: Character, response: StoryMoment, availableOptions: StoryOption[] | null, optionCatalog?: StoryOption[] | null, ) => StoryMoment; type BuildNpcStory = ( state: GameState, character: Character, encounter: Encounter, overrideText?: string, ) => StoryMoment; type BuildStoryContextFromState = ( state: GameState, extras?: { lastFunctionId?: string | null; observeSignsRequested?: boolean }, ) => StoryGenerationContext; type UpdateQuestLog = ( state: GameState, updater: (quests: GameState['quests']) => GameState['quests'], ) => GameState; type IncrementRuntimeStats = ( state: GameState, increments: RuntimeStatsIncrements, ) => GameState; function sleep(ms: number) { return new Promise(resolve => window.setTimeout(resolve, ms)); } function buildReasonedOptionCatalog(options: StoryOption[]) { const seenFunctionIds = new Set(); return options.filter(option => { if (seenFunctionIds.has(option.functionId)) { return false; } seenFunctionIds.add(option.functionId); return true; }); } function buildHostileNpcBattleReward( state: GameState, afterSequence: GameState, getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters'], ): BattleRewardSummary | null { if (!state.worldType || state.currentBattleNpcId || !state.inBattle || afterSequence.inBattle) { return null; } const activeHostileNpcs = getResolvedSceneHostileNpcs(state); const nextHostileNpcs = getResolvedSceneHostileNpcs(afterSequence); const defeatedHostileNpcs = activeHostileNpcs.filter(hostileNpc => !nextHostileNpcs.some(nextHostileNpc => nextHostileNpc.id === hostileNpc.id), ); if (defeatedHostileNpcs.length === 0) { return null; } const rolledItems = rollHostileNpcLoot( state, defeatedHostileNpcs.map(hostileNpc => ({ id: hostileNpc.id, name: hostileNpc.name, })), ); return { id: `battle-reward-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, defeatedHostileNpcs: defeatedHostileNpcs.map(hostileNpc => ({ id: hostileNpc.id, name: hostileNpc.name, })), items: addInventoryItems([], rolledItems), }; } export function createStoryChoiceActions({ gameState, currentStory, isLoading, setGameState, setCurrentStory, setAiError, setIsLoading, setBattleReward, buildResolvedChoiceState, playResolvedChoice, buildStoryContextFromState, buildStoryFromResponse, buildFallbackStoryForState, generateStoryForState, getAvailableOptionsForState, getStoryGenerationHostileNpcs, getResolvedSceneHostileNpcs, buildNpcStory, updateQuestLog, incrementRuntimeStats, getCampCompanionTravelScene, startOpeningAdventure, enterNpcInteraction, handleNpcInteraction, handleTreasureInteraction, commitGeneratedStateWithEncounterEntry, finalizeNpcBattleResult, isContinueAdventureOption, isCampTravelHomeOption, isInitialCompanionEncounter, isRegularNpcEncounter, isNpcEncounter, npcPreviewTalkFunctionId, fallbackCompanionName, turnVisualMs, }: { gameState: GameState; currentStory: StoryMoment | null; isLoading: boolean; setGameState: Dispatch>; setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; setBattleReward: Dispatch>; buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState; playResolvedChoice: ( state: GameState, option: StoryOption, character: Character, resolvedChoice: ResolvedChoiceState, sync?: EscapePlaybackSync, ) => Promise; buildStoryContextFromState: BuildStoryContextFromState; buildStoryFromResponse: BuildStoryFromResponse; buildFallbackStoryForState: BuildFallbackStoryForState; generateStoryForState: GenerateStoryForState; getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null; getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneMonsters']; getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneMonsters']; buildNpcStory: BuildNpcStory; updateQuestLog: UpdateQuestLog; incrementRuntimeStats: IncrementRuntimeStats; getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null; startOpeningAdventure: () => Promise; enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean; handleNpcInteraction: (option: StoryOption) => boolean; handleTreasureInteraction: (option: StoryOption) => void | Promise | boolean; commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry; finalizeNpcBattleResult: ( state: GameState, character: Character, battleMode: NonNullable, battleOutcome: GameState['currentNpcBattleOutcome'], ) => { nextState: GameState; resultText: string } | null; isContinueAdventureOption: (option: StoryOption) => boolean; isCampTravelHomeOption: (option: StoryOption) => boolean; isInitialCompanionEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; isNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter; npcPreviewTalkFunctionId: string; fallbackCompanionName: string; turnVisualMs: number; }) { const handleCampTravelHome = async (option: StoryOption, character: Character) => { const targetScene = getCampCompanionTravelScene(gameState, character); if (!targetScene) { return; } setBattleReward(null); setAiError(null); const companionName = isNpcEncounter(gameState.currentEncounter) ? gameState.currentEncounter.npcName : fallbackCompanionName; const travelRunState: GameState = { ...gameState, ambientIdleMode: undefined, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.RUN, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: true, inBattle: false, lastObserveSignsSceneId: null, lastObserveSignsReport: null, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; const travelBaseState: GameState = incrementRuntimeStats({ ...gameState, ambientIdleMode: undefined, currentScenePreset: targetScene, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, activeCombatEffects: [], scrollWorld: false, inBattle: false, lastObserveSignsSceneId: null, lastObserveSignsReport: null, currentBattleNpcId: null, currentNpcBattleMode: null, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }, { scenesTraveled: 1, }); const travelPreviewState: GameState = { ...travelBaseState, ...createSceneEncounterPreview(travelBaseState), }; const resolvedState = hasEncounterEntity(travelPreviewState) ? resolveSceneEncounterPreview(travelPreviewState) : travelBaseState; const entryState = buildEncounterEntryState(resolvedState, CALL_OUT_ENTRY_X_METERS); setIsLoading(true); setGameState(travelRunState); await sleep(turnVisualMs); await commitGeneratedStateWithEncounterEntry( entryState, resolvedState, character, option.actionText, `You and ${companionName} leave camp and formally step into ${targetScene.name} to begin the adventure.`, option.functionId, ); return; }; const handleChoice = async (option: StoryOption) => { const character = gameState.playerCharacter; if (!gameState.worldType || !character || isLoading) return; if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) { setCurrentStory({ ...currentStory, options: currentStory.deferredOptions, deferredOptions: undefined, }); return; } if (isCampTravelHomeOption(option)) { await handleCampTravelHome(option, character); return; } if ( option.functionId === npcPreviewTalkFunctionId && isInitialCompanionEncounter(gameState.currentEncounter) && !gameState.npcInteractionActive ) { setAiError(null); void startOpeningAdventure(); return; } if ( option.functionId === npcPreviewTalkFunctionId && isRegularNpcEncounter(gameState.currentEncounter) && !gameState.npcInteractionActive ) { setAiError(null); enterNpcInteraction(gameState.currentEncounter, option.actionText); return; } if (option.interaction?.kind === 'npc') { setAiError(null); handleNpcInteraction(option); return; } if (option.interaction?.kind === 'treasure') { setAiError(null); handleTreasureInteraction(option); return; } setBattleReward(null); setAiError(null); setIsLoading(true); const baseChoiceState = ( isRegularNpcEncounter(gameState.currentEncounter) && !gameState.npcInteractionActive && !option.interaction ) ? { ...gameState, currentEncounter: null, npcInteractionActive: false, } : gameState; let fallbackState = baseChoiceState; try { const history = baseChoiceState.storyHistory; const resolvedChoice = buildResolvedChoiceState(baseChoiceState, option, character); const projectedState = resolvedChoice.afterSequence; const shouldUseLocalNpcVictory = Boolean( baseChoiceState.currentBattleNpcId && resolvedChoice.optionKind === 'battle' && ( projectedState.currentNpcBattleOutcome || (baseChoiceState.currentNpcBattleMode === 'fight' && !projectedState.inBattle) ), ); const projectedBattleReward = shouldUseLocalNpcVictory ? null : buildHostileNpcBattleReward(baseChoiceState, projectedState, getResolvedSceneHostileNpcs); const projectedStateWithBattleReward = projectedBattleReward ? { ...projectedState, playerInventory: addInventoryItems(projectedState.playerInventory, projectedBattleReward.items), } : projectedState; fallbackState = projectedStateWithBattleReward; const projectedAvailableOptions = getAvailableOptionsForState( projectedStateWithBattleReward, character, ); const responsePromise = shouldUseLocalNpcVictory ? Promise.resolve(null) : generateNextStep( gameState.worldType, character, getStoryGenerationHostileNpcs(projectedStateWithBattleReward), history, option.actionText, buildStoryContextFromState(projectedStateWithBattleReward, { lastFunctionId: option.functionId, observeSignsRequested: option.functionId === 'idle_observe_signs', }), projectedAvailableOptions ? { availableOptions: projectedAvailableOptions } : undefined, ); const responseSettledPromise = responsePromise.then(() => undefined, () => undefined); const playbackSync: EscapePlaybackSync | undefined = resolvedChoice.optionKind === 'escape' ? { waitForStoryResponse: responseSettledPromise } : undefined; const actionPromise = playResolvedChoice( baseChoiceState, option, character, resolvedChoice, playbackSync, ); const [actionResult, responseResult] = await Promise.allSettled([actionPromise, responsePromise]); if (actionResult.status === 'rejected') { throw actionResult.reason; } let afterSequence = shouldUseLocalNpcVictory ? resolvedChoice.afterSequence : actionResult.value; if (projectedBattleReward) { afterSequence = { ...afterSequence, playerInventory: addInventoryItems(afterSequence.playerInventory, projectedBattleReward.items), }; } fallbackState = afterSequence; if (shouldUseLocalNpcVictory) { const victory = finalizeNpcBattleResult( afterSequence, character, baseChoiceState.currentNpcBattleMode!, afterSequence.currentNpcBattleOutcome, ); if (victory) { const historyBase = baseChoiceState.currentNpcBattleMode === 'spar' ? (afterSequence.sparStoryHistoryBefore ?? []) : baseChoiceState.storyHistory; const nextHistory = [ ...historyBase, createHistoryMoment(victory.resultText, 'result'), ]; const nextState = { ...victory.nextState, storyHistory: nextHistory, }; const postBattleOptionCatalog = baseChoiceState.currentNpcBattleMode === 'spar' && nextState.currentEncounter ? buildReasonedOptionCatalog( buildNpcStory( nextState, character, nextState.currentEncounter, ).options, ) : null; fallbackState = nextState; setGameState(nextState); try { const nextStory = await generateStoryForState({ state: nextState, character, history: nextHistory, choice: option.actionText, lastFunctionId: option.functionId, optionCatalog: postBattleOptionCatalog, }); setCurrentStory(nextStory); } catch (storyError) { console.error('Failed to continue npc battle resolution story:', storyError); setAiError(storyError instanceof Error ? storyError.message : '未知智能生成错误'); setCurrentStory(buildFallbackStoryForState(nextState, character, victory.resultText)); } return; } } if (responseResult.status === 'rejected') { throw responseResult.reason; } const response = responseResult.value!; const defeatedHostileNpcIds = baseChoiceState.currentBattleNpcId ? [] : getResolvedSceneHostileNpcs(baseChoiceState) .map(hostileNpc => hostileNpc.id) .filter(hostileNpcId => !getResolvedSceneHostileNpcs(afterSequence).some(hostileNpc => hostileNpc.id === hostileNpcId)); const nextHistory = [ ...baseChoiceState.storyHistory, createHistoryMoment(option.actionText, 'action'), createHistoryMoment(response.storyText, 'result', response.options), ]; const nextState = incrementRuntimeStats({ ...updateQuestLog( afterSequence, quests => applyQuestProgressFromHostileNpcDefeat( quests, baseChoiceState.currentScenePreset?.id ?? null, defeatedHostileNpcIds, ), ), lastObserveSignsSceneId: option.functionId === 'idle_observe_signs' ? (afterSequence.currentScenePreset?.id ?? null) : afterSequence.lastObserveSignsSceneId ?? null, lastObserveSignsReport: option.functionId === 'idle_observe_signs' ? response.storyText : afterSequence.lastObserveSignsReport ?? null, storyHistory: nextHistory, }, { hostileNpcsDefeated: defeatedHostileNpcIds.length, }); setGameState(nextState); if (projectedBattleReward) { setBattleReward(projectedBattleReward); } setCurrentStory( buildStoryFromResponse( nextState, character, { text: response.storyText, options: response.options, }, projectedAvailableOptions, ), ); } catch (error) { console.error('Failed to get next step:', error); setAiError(error instanceof Error ? error.message : '未知智能生成错误'); setCurrentStory(buildFallbackStoryForState(fallbackState, character)); } finally { setIsLoading(false); } }; return { handleChoice, }; }