import type { Dispatch, SetStateAction } from 'react'; import { useCallback, useEffect, useState } from 'react'; import { getCharacterAdventureOpening, getCharacterById, getCharacterHomeSceneId, } from '../data/characterPresets'; import { buildCampTravelHomeOption, buildContinueAdventureOption, buildNpcPreviewTalkOption, isCampTravelHomeOption, isContinueAdventureOption, NPC_CHAT_FUNCTION, NPC_FIGHT_FUNCTION, NPC_LEAVE_FUNCTION, NPC_PREVIEW_TALK_FUNCTION, NPC_RECRUIT_FUNCTION, STORY_OPENING_CAMP_DIALOGUE_FUNCTION, } from '../data/functionCatalog'; import { buildInitialNpcState, buildNpcEncounterStoryMoment, describeNpcAffinityInWords, getNpcConversationDirective, isNpcFirstMeaningfulContact, normalizeNpcPersistentState, } from '../data/npcInteractions'; import { applyQuestProgressFromTreasure } from '../data/questFlow'; import { incrementGameRuntimeStats } from '../data/runtimeStats'; import { buildSceneEntityCatalogText, getForwardScenePreset, getScenePresetById, getTravelScenePreset, getWorldCampScenePreset, } from '../data/scenePresets'; import { getDefaultFunctionIdsForContext, resolveFunctionOption, sortStoryOptionsByPriority, } from '../data/stateFunctions'; import { generateInitialStory, generateNextStep } from '../services/ai'; import { Character, Encounter, GameState, InventoryItem, StoryDialogueTurn, StoryMoment, StoryOption, WorldType, } from '../types'; import type { EscapePlaybackSync as ResolvedChoicePlaybackSync } from './combat/escapeFlow'; import type { ResolvedChoiceState } from './combat/resolvedChoice'; import { buildFallbackStoryMoment, normalizeSkillProbabilities, } from './combatStoryUtils'; import { getCharacterChatRecord, useCharacterChatFlow, } from './story/characterChat'; import { createStoryChoiceActions } from './story/choiceActions'; import { useStoryInventoryActions } from './story/inventoryActions'; import { createStoryNpcEncounterActions } from './story/npcEncounterActions'; import { useStoryNpcInteractionFlow } from './story/npcInteraction'; import { buildPreparedOpeningAdventure as buildPreparedOpeningAdventureState, playOpeningAdventureSequence, type PreparedOpeningAdventure, } from './story/openingAdventure'; import { appendStoryHistory, createStoryProgressionActions, } from './story/progressionActions'; import { createStorySessionActions } from './story/sessionActions'; import { resolveNpcInteractionDecision } from './story/storyGenerationState'; import type { BattleRewardSummary, BattleRewardUi, QuestFlowUi, } from './story/uiTypes'; import { useStoryOptions } from './useStoryOptions'; import { buildTreasureStory, isTreasureEncounter, useTreasureFlow, } from './useTreasureFlow'; const MIN_OPTION_POOL_SIZE = 6; const TURN_VISUAL_MS = 820; const OPENING_CAMP_DIALOGUE_FUNCTION_ID = STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id; const NPC_PREVIEW_TALK_FUNCTION_ID = NPC_PREVIEW_TALK_FUNCTION.id; const FALLBACK_COMPANION_NAME = '同伴'; export type { CharacterChatModalState, CharacterChatTarget, CharacterChatUi, } from './story/characterChat'; export type { BattleRewardSummary, BattleRewardUi, GiftModalState, InventoryFlowUi, QuestFlowUi, RecruitModalState, StoryGenerationNpcUi, TradeModalState, } from './story/uiTypes'; type CampCompanionEncounter = Encounter & { specialBehavior: 'camp_companion'; }; function dedupeStoryOptions(options: StoryOption[]) { const seen = new Set(); return options.filter((option) => { const identity = `${option.functionId}::${option.actionText}::${option.text}`; if (seen.has(identity)) return false; seen.add(identity); return true; }); } function _buildLocalCharacterChatSummary( character: Character, history: Array<{ speaker: 'player' | 'character'; text: string }>, previousSummary: string, ) { const latestTurns = history .slice(-4) .map( (turn) => `${turn.speaker === 'player' ? 'Player' : character.name}: ${turn.text}`, ) .join(' '); const currentSummary = latestTurns ? `${character.name} is becoming more open in private conversation. Recent exchange: ${latestTurns}` : `${character.name} is willing to continue private conversation and gradually trusts the player more.`; if (!previousSummary) { return currentSummary.slice(0, 118); } return `${previousSummary} ${currentSummary}`.slice(0, 118); } function _buildLocalCharacterChatSuggestions(character: Character) { return [ '我想听你把这件事再说得更明白一点。', `${character.name},你现在真正担心的是什么?`, '先把外面的局势放一放,我想更了解你一些。', ]; } function buildPartyRelationshipNotes(state: GameState) { const lines: string[] = []; const seenCharacterIds = new Set(); const appendNote = (characterId: string, roleLabel: string) => { if (seenCharacterIds.has(characterId)) return; const character = getCharacterById(characterId); const summary = getCharacterChatRecord(state, characterId).summary.trim(); if (!character || !summary) return; seenCharacterIds.add(characterId); lines.push( `- ${character.name} (${character.title} / ${roleLabel}): ${summary}`, ); }; state.companions.forEach((companion) => appendNote(companion.characterId, '当前同行'), ); state.roster.forEach((companion) => appendNote(companion.characterId, '营地待命'), ); return lines.length > 0 ? lines.join('\n') : null; } function buildRecentConversationEventText(state: GameState) { const recentText = state.storyHistory .slice(-6) .map((item) => item.text) .join('\n'); if ( /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) ) { return '你们刚经历过一场交锋或切磋,空气里的紧张感还没有完全散去。'; } if (/携手|相助|帮你|并肩/u.test(recentText)) { return '你们刚并肩配合过一次,彼此之间的距离感稍微淡了一些。'; } return null; } function inferConversationSituation( state: GameState, extras: { lastFunctionId?: string | null; openingCampDialogue?: string | null; }, ) { if (state.inBattle) return 'shared_danger_coordination' as const; if (extras.lastFunctionId === OPENING_CAMP_DIALOGUE_FUNCTION_ID) return 'camp_first_contact' as const; if ( state.currentEncounter?.specialBehavior === 'camp_companion' && extras.openingCampDialogue?.trim() ) { return 'camp_followup' as const; } const recentText = state.storyHistory .slice(-6) .map((item) => item.text) .join('\n'); if ( /击败|怪物|战斗|切磋|交手|脱身/u.test(recentText) ) { return 'post_battle_breath' as const; } if (extras.lastFunctionId === NPC_CHAT_FUNCTION.id) return 'private_followup' as const; return 'first_contact_cautious' as const; } function inferConversationPressure( state: GameState, situation: ReturnType, ) { const hpRatio = state.playerHp / Math.max(state.playerMaxHp, 1); if (state.inBattle || hpRatio < 0.35) return 'high' as const; if ( situation === 'post_battle_breath' || situation === 'shared_danger_coordination' ) return 'medium' as const; if (situation === 'camp_first_contact' || situation === 'camp_followup') return 'low' as const; return 'medium' as const; } function describeConversationSituation( situation: ReturnType, ) { switch (situation) { case 'camp_first_contact': return 'This is the first quiet moment at camp, so the tone should stay careful, observant, and lightly probing.'; case 'camp_followup': return 'The first camp exchange already happened, so this can pick up the previous thread and go a little deeper.'; case 'post_battle_breath': return 'A fight just ended. The immediate danger is lower, but both sides are still tense and catching their breath.'; case 'shared_danger_coordination': return 'Danger is still active, so the conversation should stay short, direct, and practical.'; case 'private_followup': return 'This is not a strict first meeting anymore. It works best as a continuation of something half-said a moment ago.'; default: return 'They have only just met, and both sides are still deciding how much they can trust the other.'; } } function describeConversationTalkPriority( situation: ReturnType, ) { switch (situation) { case 'camp_first_contact': return 'Start with immediate impressions, mutual attitude, and the atmosphere at camp instead of over-explaining motives.'; case 'camp_followup': return 'Start by picking up the unresolved thread from the last exchange, then decide whether to press further.'; case 'post_battle_breath': return 'Talk about the clash that just happened and how each side judged the other before moving deeper.'; case 'shared_danger_coordination': return 'Focus on the most useful judgment, danger, and next step instead of expanding into long background exposition.'; case 'private_followup': return 'Pick up the current thread and relationship shift instead of resetting the conversation back to a first meeting.'; default: return 'Probe stance and现场 judgment first instead of fully exposing motive and secrets.'; } } function buildStoryContextFromState( state: GameState, extras: { pendingSceneEncounter?: boolean; lastFunctionId?: string | null; observeSignsRequested?: boolean; openingCampBackground?: string | null; openingCampDialogue?: string | null; encounterNpcStateOverride?: GameState['npcStates'][string] | null; } = {}, ) { const conversationSituation = inferConversationSituation(state, extras); const conversationPressure = inferConversationPressure( state, conversationSituation, ); const recentSharedEvent = buildRecentConversationEventText(state); const encounterNpcState = state.currentEncounter?.kind === 'npc' ? (() => { const encounter = state.currentEncounter; return extras.encounterNpcStateOverride ?? state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state); })() : null; const encounterDirective = state.currentEncounter?.kind === 'npc' ? (() => { const encounter = state.currentEncounter; return encounterNpcState ? getNpcConversationDirective(encounter, encounterNpcState) : null; })() : null; const isFirstMeaningfulContact = state.currentEncounter?.kind === 'npc' ? (() => { const encounter = state.currentEncounter; return encounterNpcState ? isNpcFirstMeaningfulContact(encounter, encounterNpcState) : false; })() : false; const firstContactRelationStance = (() => { if ( !isFirstMeaningfulContact || !state.currentEncounter || state.currentEncounter.kind !== 'npc' ) { return null; } const stance = encounterNpcState?.relationState?.stance ?? null; if ( stance === 'guarded' || stance === 'neutral' || stance === 'cooperative' || stance === 'bonded' ) { return stance; } return null; })(); const encounterAffinityText = state.currentEncounter?.kind === 'npc' ? (() => { const encounter = state.currentEncounter; return encounterNpcState ? describeNpcAffinityInWords(encounter, encounterNpcState.affinity, { recruited: encounterNpcState.recruited, }) : null; })() : null; const baseSceneDescription = state.currentScenePreset?.description ?? null; const observeSignsSceneDescription = extras.observeSignsRequested && state.worldType ? [ baseSceneDescription, 'Observed entity pool:', buildSceneEntityCatalogText( state.worldType, state.currentScenePreset?.id ?? null, ), ] .filter(Boolean) .join('\n') : baseSceneDescription; return { playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, playerMaxMana: state.playerMaxMana, inBattle: state.inBattle, playerX: state.playerX, playerFacing: state.playerFacing, playerAnimation: state.animationState, skillCooldowns: state.playerSkillCooldowns, sceneId: state.currentScenePreset?.id ?? null, sceneName: state.currentScenePreset?.name ?? null, sceneDescription: observeSignsSceneDescription, pendingSceneEncounter: extras.pendingSceneEncounter ?? false, lastFunctionId: extras.lastFunctionId ?? null, observeSignsRequested: extras.observeSignsRequested ?? false, lastObserveSignsReport: state.lastObserveSignsSceneId === (state.currentScenePreset?.id ?? null) ? (state.lastObserveSignsReport ?? null) : null, encounterKind: state.currentEncounter?.kind ?? null, encounterName: state.currentEncounter?.npcName ?? null, encounterDescription: state.currentEncounter?.npcDescription ?? null, encounterContext: state.currentEncounter?.context ?? null, encounterCharacterId: state.currentEncounter?.characterId ?? null, encounterGender: state.currentEncounter?.gender ?? null, encounterAffinity: encounterDirective?.affinity ?? null, encounterAffinityText, encounterConversationStyle: encounterDirective?.style ?? null, encounterDisclosureStage: encounterDirective?.disclosureStage ?? null, encounterWarmthStage: encounterDirective?.warmthStage ?? null, encounterAnswerMode: encounterDirective?.answerMode ?? null, encounterAllowedTopics: encounterDirective?.allowTopics ?? null, encounterBlockedTopics: encounterDirective?.blockedTopics ?? null, isFirstMeaningfulContact, firstContactRelationStance, conversationSituation, conversationPressure, recentSharedEvent: recentSharedEvent ?? describeConversationSituation(conversationSituation), talkPriority: describeConversationTalkPriority(conversationSituation), encounterRelationshipSummary: state.currentEncounter?.characterId ? getCharacterChatRecord(state, state.currentEncounter.characterId) .summary || null : null, partyRelationshipNotes: buildPartyRelationshipNotes(state), customWorldProfile: state.customWorldProfile ?? null, openingCampBackground: extras.openingCampBackground ?? null, openingCampDialogue: extras.openingCampDialogue ?? null, }; } function buildNpcPreviewStory( state: GameState, character: Character, encounter: Encounter, overrideText?: string, ): StoryMoment { if (!state.worldType) { return { text: overrideText ?? `${encounter.npcName} waits ahead, as if letting you decide whether to engage first.`, options: [buildNpcPreviewTalkOption(encounter)], }; } const functionContext = { worldType: state.worldType, playerCharacter: character, inBattle: false, currentSceneId: state.currentScenePreset?.id ?? null, currentSceneName: state.currentScenePreset?.name ?? null, monsters: [], playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, playerMaxMana: state.playerMaxMana, }; const locationOptions = getDefaultFunctionIdsForContext(functionContext) .filter((functionId) => functionId !== 'idle_call_out') .map((functionId) => resolveFunctionOption(functionId, functionContext)) .filter((option): option is StoryOption => Boolean(option)); return { text: overrideText ?? encounter.npcName + ' appears near ' + (state.currentScenePreset?.name ?? 'the path ahead') + ', but you have not fully committed your attention to them yet.', options: [buildNpcPreviewTalkOption(encounter), ...locationOptions], }; } function getStoryGenerationHostileNpcs(state: GameState) { return state.inBattle ? getResolvedSceneHostileNpcs(state) : []; } function getResolvedSceneHostileNpcs(state: GameState) { return state.sceneHostileNpcs ?? state.sceneMonsters; } function sanitizeOptions( options: StoryOption[], character: Character, state: GameState, ) { const normalizedOptions = dedupeStoryOptions( options.map((option) => normalizeSkillProbabilities(option, character)), ); if (normalizedOptions.length === 0) { return buildFallbackStoryMoment(state, character).options; } if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) { return normalizedOptions; } return sortStoryOptionsByPriority( dedupeStoryOptions([ ...normalizedOptions, ...buildFallbackStoryMoment(state, character).options, ]).slice(0, MIN_OPTION_POOL_SIZE), ); } function escapeRegExp(value: string) { const specialChars = [ '\\', '^', '$', '*', '+', '?', '.', '(', ')', '|', '[', ']', '{', '}', ]; return specialChars.reduce( (escaped, char) => escaped.split(char).join('\\' + char), value, ); } function normalizeDialogueSpeakerName(rawSpeakerName: string) { return rawSpeakerName .trim() .replace( /^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u, '', ) .replace( /[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u, '', ) .replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '') .trim(); } function parseDialogueTurns( text: string, npcName: string, ): StoryDialogueTurn[] { const turns: StoryDialogueTurn[] = []; const dialogueColonPattern = '(?:\\uFF1A|:)'; const playerPrefixPattern = new RegExp( '^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const npcPrefixPattern = new RegExp( '^' + escapeRegExp(npcName) + '\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const namedSpeakerPattern = new RegExp( '^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$', 'u', ); const lines = text .replace(/\r/g, '') .split('\n') .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { const playerMatch = line.match(playerPrefixPattern); const playerText = playerMatch?.[1]?.trim(); if (playerText) { turns.push({ speaker: 'player', text: playerText }); continue; } const npcMatch = line.match(npcPrefixPattern); const npcText = npcMatch?.[1]?.trim(); if (npcText) { turns.push({ speaker: 'npc', speakerName: npcName, text: npcText }); continue; } const namedSpeakerMatch = line.match(namedSpeakerPattern); if (namedSpeakerMatch) { const rawSpeakerName = namedSpeakerMatch[1]; const rawSpeakerText = namedSpeakerMatch[2]; if (!rawSpeakerName || !rawSpeakerText) { continue; } const speakerName = normalizeDialogueSpeakerName(rawSpeakerName); const speakerText = rawSpeakerText.trim(); if (speakerName && speakerText) { turns.push({ speaker: speakerName === npcName ? 'npc' : 'companion', speakerName, text: speakerText, }); continue; } } if (line.startsWith('你:') || line.startsWith('你:')) { turns.push({ speaker: 'player', text: line.slice(2).trim() }); continue; } if (line.startsWith(npcName + ':') || line.startsWith(npcName + ':')) { turns.push({ speaker: 'npc', text: line.slice(npcName.length + 1).trim(), }); continue; } if (line.startsWith('主角:') || line.startsWith('主角:')) { turns.push({ speaker: 'player', text: line.slice(3).trim() }); continue; } if (turns.length > 0) { const lastTurnIndex = turns.length - 1; const lastTurn = turns[lastTurnIndex]; if (lastTurn) { turns[lastTurnIndex] = { ...lastTurn, text: lastTurn.text + line, }; } } } return turns.filter((turn) => turn.text.length > 0); } function buildDialogueStoryMoment( npcName: string, text: string, options: StoryOption[], streaming = false, ): StoryMoment { return { text, options, displayMode: 'dialogue', dialogue: parseDialogueTurns(text, npcName), streaming, }; } function hasRenderableDialogueTurns(text: string, npcName: string) { return parseDialogueTurns(text, npcName).length >= 2; } function getTypewriterDelay(char: string) { if (/[。!?!?]/u.test(char)) return 240; if (/[,、;;:]/u.test(char)) return 150; if (/\s/u.test(char)) return 45; return 90; } function isCampCompanionEncounter( encounter: GameState['currentEncounter'], ): encounter is CampCompanionEncounter { return Boolean( encounter?.kind === 'npc' && encounter.specialBehavior === 'camp_companion', ); } function isInitialCompanionEncounter( encounter: GameState['currentEncounter'], ): encounter is Encounter { return Boolean( encounter?.kind === 'npc' && encounter.specialBehavior === 'initial_companion', ); } function _buildInitialCompanionResultText( character: Character, encounter: Encounter, worldType: WorldType | null, ) { const opening = getCharacterAdventureOpening(character, worldType); if (!opening) { return `${encounter.npcName}从不远处走近,先静静打量着你的反应。`; } return `${encounter.npcName}现身在你眼前,周围的局势也随之悄然变化。`; } function buildInitialCompanionDialogueText( character: Character, encounter: Encounter, worldType: WorldType | null, ) { const opening = getCharacterAdventureOpening(character, worldType); const surfaceHook = opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。'; const immediateConcern = opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。'; const guardedMotive = opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。'; return [ `你:${surfaceHook}`, `${encounter.npcName}:那就不要说得太快太多。前方的道路并不稳定,贸然冲进去只会最先遇到最糟糕的情况。`, `你:${immediateConcern}`, `${encounter.npcName}:我能看出你不是误闯此地的。${guardedMotive} 剩下的我们可以在路上再梳理清楚。`, ].join('\n'); } function buildCampCompanionOpeningResultText( character: Character, encounter: Encounter, worldType: WorldType | null, ) { const opening = getCharacterAdventureOpening(character, worldType); if (!opening) { return `${encounter.npcName} 已经来到你身边。在营地,你稍作停顿,决定下一步去向何方。`; } return `${encounter.npcName} 在营地来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`; } function _buildCampCompanionChatResultText( encounter: Encounter, affinityGain: number, nextAffinity: number, ) { const teamworkText = affinityGain > 0 ? '你也对接下来的合作感到更加自信了一些。' : '至少你们为接下来的行动重新调整了节奏。'; return `${encounter.npcName}和你交换了一番想法,${describeNpcAffinityInWords(encounter, nextAffinity)}${teamworkText}`; } function isNpcEncounter( encounter: GameState['currentEncounter'], ): encounter is Encounter { return Boolean(encounter?.kind === 'npc'); } function isRegularNpcEncounter( encounter: GameState['currentEncounter'], ): encounter is Encounter { return Boolean(encounter?.kind === 'npc' && !encounter.specialBehavior); } function getNpcEncounterKey(encounter: Encounter) { return encounter.id ?? encounter.npcName; } function cloneInventoryItemForOwner( item: InventoryItem, owner: 'player' | 'npc', quantity = 1, ) { const preserveIdentity = Boolean( item.runtimeMetadata || item.buildProfile || item.equipmentSlotId || item.statProfile || item.attributeResonance, ); return { ...item, id: preserveIdentity ? `${owner}:${item.id}:${quantity}` : `${owner}:${encodeURIComponent(`${item.category}-${item.name}`)}`, quantity, runtimeMetadata: item.runtimeMetadata ? { ...item.runtimeMetadata, seedKey: `${item.runtimeMetadata.seedKey}:${owner}`, } : item.runtimeMetadata, }; } function tickCooldownMap(cooldowns: Record) { return Object.fromEntries( Object.entries(cooldowns).map(([skillId, turns]) => [ skillId, Math.max(0, turns - 1), ]), ); } export function useStoryGeneration({ gameState, setGameState, buildResolvedChoiceState, playResolvedChoice, }: { gameState: GameState; setGameState: Dispatch>; buildResolvedChoiceState: ( state: GameState, option: StoryOption, character: Character, ) => ResolvedChoiceState; playResolvedChoice: ( state: GameState, option: StoryOption, character: Character, resolvedChoice: ResolvedChoiceState, sync?: ResolvedChoicePlaybackSync, ) => Promise; }) { const [currentStory, setCurrentStory] = useState(null); const [aiError, setAiError] = useState(null); const [isLoading, setIsLoading] = useState(false); const [battleReward, setBattleReward] = useState( null, ); const [preparedOpeningAdventure, setPreparedOpeningAdventure] = useState(null); const { characterChatUi, clearCharacterChatModal } = useCharacterChatFlow({ gameState, setGameState, buildStoryContextFromState, }); const getResolvedNpcState = (state: GameState, encounter: Encounter) => state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state); const buildNpcStory = useCallback( ( state: GameState, character: Character, encounter: Encounter, overrideText?: string, ) => buildNpcEncounterStoryMoment({ state, encounter, npcState: getResolvedNpcState(state, encounter), playerCharacter: character, playerInventory: state.playerInventory, activeQuests: state.quests, scene: state.currentScenePreset, partySize: state.companions.length, overrideText, worldType: state.worldType, }), [], ); const getCampCompanionHomeScene = ( state: GameState, character: Character, ) => { if (!state.worldType) return null; const sceneId = getCharacterHomeSceneId(state.worldType, character.id); return getScenePresetById(state.worldType, sceneId); }; const getCampCompanionTravelScene = useCallback( (state: GameState, character: Character) => { if (!state.worldType) return null; const campScene = getWorldCampScenePreset(state.worldType); const homeScene = getCampCompanionHomeScene(state, character); if ( homeScene && homeScene.id !== campScene?.id && homeScene.id !== state.currentScenePreset?.id ) { return homeScene; } const fallbackSceneId = campScene?.id ?? state.currentScenePreset?.id ?? null; return ( getForwardScenePreset(state.worldType, fallbackSceneId) ?? getTravelScenePreset(state.worldType, fallbackSceneId) ?? homeScene ); }, [], ); const buildCampCompanionOpeningOptions = useCallback( (state: GameState, character: Character, encounter: Encounter) => { const targetScene = getCampCompanionTravelScene(state, character); const baseOptions = buildNpcStory(state, character, encounter).options; const chatOptions = baseOptions .filter((option) => option.functionId === NPC_CHAT_FUNCTION.id) .slice(0, 1); const recruitOption = baseOptions.find( (option) => option.functionId === NPC_RECRUIT_FUNCTION.id, ) ?? null; const openingOptions = recruitOption ? [...chatOptions, recruitOption] : chatOptions; if (!targetScene) { return openingOptions; } return [...openingOptions, buildCampTravelHomeOption(targetScene.name)]; }, [buildNpcStory, getCampCompanionTravelScene], ); const inferOpeningCampFollowupOptions = useCallback( async ( state: GameState, character: Character, baseOptions: StoryOption[], openingBackground: string, openingDialogue: string, ) => { if (!state.worldType || baseOptions.length === 0) { return baseOptions; } try { const response = await generateNextStep( state.worldType, character, getStoryGenerationHostileNpcs(state), state.storyHistory, '继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。', buildStoryContextFromState(state, { openingCampBackground: openingBackground, openingCampDialogue: openingDialogue, }), { availableOptions: baseOptions, }, ); return sortStoryOptionsByPriority(response.options); } catch (error) { console.error('Failed to infer opening camp follow-up options:', error); return baseOptions; } }, [], ); const buildOpeningCampChatContext = ( state: GameState, character: Character, encounter: Encounter, ) => { if (encounter.specialBehavior !== 'camp_companion') { return {}; } const npcState = state.npcStates[getNpcEncounterKey(encounter)] ?? buildInitialNpcState(encounter, state.worldType, state); if (npcState.chattedCount > 2) { return {}; } const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`; let openingDialogue: string | null = null; for (let index = 0; index < state.storyHistory.length - 1; index += 1) { const entry = state.storyHistory[index]; if (!entry) { continue; } if (entry.historyRole !== 'action' || entry.text !== openingActionText) { continue; } for ( let nextIndex = index + 1; nextIndex < state.storyHistory.length; nextIndex += 1 ) { const nextEntry = state.storyHistory[nextIndex]; if (!nextEntry) { continue; } if (nextEntry.historyRole === 'action') { break; } if (nextEntry.text.trim()) { openingDialogue = nextEntry.text; break; } } if (openingDialogue) { break; } } if (!openingDialogue) { return {}; } return { openingCampBackground: buildCampCompanionOpeningResultText( character, encounter, state.worldType, ), openingCampDialogue: openingDialogue, }; }; const buildCampCompanionIdleOptions = useCallback( ( state: GameState, character: Character, encounter: Encounter, overrideText?: string, ): StoryMoment => { const targetScene = getCampCompanionTravelScene(state, character); const baseStory = buildNpcStory( state, character, encounter, overrideText, ); const filteredOptions = baseStory.options.filter( (option) => option.functionId !== NPC_LEAVE_FUNCTION.id && option.functionId !== NPC_FIGHT_FUNCTION.id, ); if (!targetScene) { return { ...baseStory, options: filteredOptions, }; } return { ...baseStory, options: [ ...filteredOptions.slice(0, 2), buildCampTravelHomeOption(targetScene.name), ...filteredOptions.slice(2), ], }; }, [buildNpcStory, getCampCompanionTravelScene], ); const getAvailableOptionsForState = useCallback( (state: GameState, character: Character) => { if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { return buildCampCompanionIdleOptions( state, character, state.currentEncounter, ).options; } if ( isInitialCompanionEncounter(state.currentEncounter) && !state.inBattle && !state.npcInteractionActive ) { return buildNpcPreviewStory(state, character, state.currentEncounter) .options; } if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { if (!state.npcInteractionActive) { return buildNpcPreviewStory(state, character, state.currentEncounter) .options; } return buildNpcStory(state, character, state.currentEncounter).options; } if (isNpcEncounter(state.currentEncounter) && !state.inBattle) { return buildNpcStory(state, character, state.currentEncounter).options; } if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) { return buildTreasureStory(state, character, state.currentEncounter) .options; } return null; }, [buildCampCompanionIdleOptions, buildNpcStory], ); const buildFallbackStoryForState = useCallback( (state: GameState, character: Character, fallbackText?: string) => { if (isCampCompanionEncounter(state.currentEncounter) && !state.inBattle) { return buildCampCompanionIdleOptions( state, character, state.currentEncounter, fallbackText, ); } if ( isInitialCompanionEncounter(state.currentEncounter) && !state.inBattle && !state.npcInteractionActive ) { return buildNpcPreviewStory( state, character, state.currentEncounter, fallbackText, ); } if (isRegularNpcEncounter(state.currentEncounter) && !state.inBattle) { if (!state.npcInteractionActive) { return buildNpcPreviewStory( state, character, state.currentEncounter, fallbackText, ); } return buildNpcStory( state, character, state.currentEncounter, fallbackText, ); } if (isNpcEncounter(state.currentEncounter) && !state.inBattle) { return buildNpcStory( state, character, state.currentEncounter, fallbackText, ); } if (isTreasureEncounter(state.currentEncounter) && !state.inBattle) { return buildTreasureStory( state, character, state.currentEncounter, fallbackText, ); } const fallback = buildFallbackStoryMoment(state, character); return fallbackText ? { ...fallback, text: fallbackText, } : fallback; }, [buildCampCompanionIdleOptions, buildNpcStory], ); const buildStoryFromResponse = useCallback( ( state: GameState, character: Character, response: StoryMoment, availableOptions: StoryOption[] | null, optionCatalog: StoryOption[] | null = null, ) => ({ text: response.text, options: sortStoryOptionsByPriority( availableOptions ? isCampCompanionEncounter(state.currentEncounter) ? availableOptions : response.options : optionCatalog ? response.options.length > 0 ? response.options : optionCatalog : sanitizeOptions(response.options, character, state), ), }), [], ); const generateStoryForState = useCallback( async ({ state, character, history, choice, lastFunctionId, optionCatalog, }: { state: GameState; character: Character; history: StoryMoment[]; choice?: string; lastFunctionId?: string | null; optionCatalog?: StoryOption[] | null; }) => { if (!state.worldType) { throw new Error( 'The current world is not initialized, so story generation cannot continue.', ); } const resolvedOptionCatalog = optionCatalog && optionCatalog.length > 0 ? optionCatalog : null; const availableOptions = resolvedOptionCatalog ? null : getAvailableOptionsForState(state, character); const response = choice ? await generateNextStep( state.worldType, character, getStoryGenerationHostileNpcs(state), history, choice, buildStoryContextFromState(state, { lastFunctionId, }), availableOptions ? { availableOptions } : resolvedOptionCatalog ? { optionCatalog: resolvedOptionCatalog } : undefined, ) : await generateInitialStory( state.worldType, character, getStoryGenerationHostileNpcs(state), buildStoryContextFromState(state), availableOptions ? { availableOptions } : resolvedOptionCatalog ? { optionCatalog: resolvedOptionCatalog } : undefined, ); return buildStoryFromResponse( state, character, { text: response.storyText, options: response.options, }, availableOptions, resolvedOptionCatalog, ); }, [buildStoryFromResponse, getAvailableOptionsForState], ); const updateNpcState = ( state: GameState, encounter: Encounter, updater: ( npcState: ReturnType, ) => ReturnType, ) => ({ ...state, npcStates: { ...state.npcStates, [getNpcEncounterKey(encounter)]: normalizeNpcPersistentState( updater(getResolvedNpcState(state, encounter)), ), }, }); const updateQuestLog = ( state: GameState, updater: (quests: GameState['quests']) => GameState['quests'], ) => ({ ...state, quests: updater(state.quests), }); const incrementRuntimeStats = ( state: GameState, increments: Parameters[1], ) => ({ ...state, runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments), }); const progressTreasureQuest = (state: GameState, sceneId: string | null) => updateQuestLog(state, (quests) => applyQuestProgressFromTreasure(quests, sceneId), ); const appendHistory = useCallback(appendStoryHistory, []); const prepareOpeningAdventure = useCallback( (state: GameState, character: Character): PreparedOpeningAdventure | null => buildPreparedOpeningAdventureState({ state, character, getNpcEncounterKey, appendHistory, buildCampCompanionOpeningOptions, buildCampCompanionOpeningResultText, buildInitialCompanionDialogueText, }), [appendHistory, buildCampCompanionOpeningOptions], ); useEffect(() => { if ( gameState.currentScene !== 'Story' || !gameState.playerCharacter || gameState.storyHistory.length > 0 || currentStory || !isNpcEncounter(gameState.currentEncounter) || gameState.currentEncounter.specialBehavior !== 'initial_companion' ) { setPreparedOpeningAdventure(null); return; } setPreparedOpeningAdventure( prepareOpeningAdventure(gameState, gameState.playerCharacter), ); }, [ prepareOpeningAdventure, currentStory, gameState, gameState.currentEncounter, gameState.currentScene, gameState.playerCharacter, gameState.storyHistory, ]); const { commitGeneratedState, commitGeneratedStateWithEncounterEntry } = createStoryProgressionActions({ gameState, setGameState, setCurrentStory, setAiError, setIsLoading, generateStoryForState, buildFallbackStoryForState, }); const startOpeningAdventure = useCallback(async () => { if ( !gameState.playerCharacter || !isNpcEncounter(gameState.currentEncounter) ) { return; } const encounter = gameState.currentEncounter; if (encounter.specialBehavior !== 'initial_companion') { return; } const preparedStory = preparedOpeningAdventure?.encounterKey === getNpcEncounterKey(encounter) ? preparedOpeningAdventure : prepareOpeningAdventure(gameState, gameState.playerCharacter); if (!preparedStory) { return; } await playOpeningAdventureSequence({ gameState, character: gameState.playerCharacter, encounter, preparedStory, setGameState, setCurrentStory, setAiError, setIsLoading, buildDialogueStoryMoment, buildStoryContextFromState, getStoryGenerationHostileNpcs, hasRenderableDialogueTurns, inferOpeningCampFollowupOptions, getTypewriterDelay, }); }, [ gameState, inferOpeningCampFollowupOptions, prepareOpeningAdventure, preparedOpeningAdventure, setGameState, ]); const { handleTreasureInteraction } = useTreasureFlow({ gameState, commitGeneratedState, progressTreasureQuest, }); const { inventoryUi } = useStoryInventoryActions({ gameState, commitGeneratedState, tickCooldowns: tickCooldownMap, }); const npcInteractionFlow = useStoryNpcInteractionFlow({ gameState, setGameState, commitGeneratedState, getNpcEncounterKey, getResolvedNpcState, updateNpcState, cloneInventoryItemForOwner, runtime: { setCurrentStory, setAiError, setIsLoading, buildStoryContextFromState, buildFallbackStoryForState, buildDialogueStoryMoment, generateStoryForState, getStoryGenerationHostileNpcs, getTypewriterDelay, }, }); const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } = createStoryNpcEncounterActions({ gameState, setGameState, setCurrentStory, setAiError, setIsLoading, commitGeneratedState, commitGeneratedStateWithEncounterEntry, appendHistory, buildOpeningCampChatContext, buildStoryContextFromState, buildFallbackStoryForState, buildDialogueStoryMoment, getStoryGenerationHostileNpcs, getTypewriterDelay, getAvailableOptionsForState, sanitizeOptions, sortOptions: sortStoryOptionsByPriority, buildContinueAdventureOption, getNpcEncounterKey, getResolvedNpcState, updateNpcState, cloneInventoryItemForOwner, resolveNpcInteractionDecision, npcInteractionFlow, }); useEffect(() => { const startStory = async () => { if ( gameState.currentScene !== 'Story' || !gameState.worldType || !gameState.playerCharacter || currentStory || isLoading ) { return; } if ( gameState.storyHistory.length === 0 && isInitialCompanionEncounter(gameState.currentEncounter) && !gameState.npcInteractionActive ) { setAiError(null); void startOpeningAdventure(); return; } setIsLoading(true); try { setAiError(null); const nextStory = await generateStoryForState({ state: gameState, character: gameState.playerCharacter, history: [], }); setCurrentStory(nextStory); } catch (error) { console.error('Failed to start story:', error); setAiError(error instanceof Error ? error.message : '未知智能生成错误'); setCurrentStory( buildFallbackStoryForState(gameState, gameState.playerCharacter), ); } finally { setIsLoading(false); } }; startStory(); }, [ buildFallbackStoryForState, generateStoryForState, currentStory, gameState, gameState.currentEncounter, gameState.currentScene, gameState.inBattle, gameState.playerCharacter, gameState.playerX, gameState.sceneHostileNpcs, gameState.worldType, isLoading, startOpeningAdventure, ]); const { displayedOptions, canRefreshOptions, handleRefreshOptions, resetStoryOptions, } = useStoryOptions(currentStory); const { handleChoice } = 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: NPC_PREVIEW_TALK_FUNCTION_ID, fallbackCompanionName: FALLBACK_COMPANION_NAME, turnVisualMs: TURN_VISUAL_MS, }); const clearStoryRuntimeUi = useCallback(() => { resetStoryOptions(); setAiError(null); setIsLoading(false); setPreparedOpeningAdventure(null); setBattleReward(null); npcInteractionFlow.clearNpcInteractionUi(); clearCharacterChatModal(); }, [clearCharacterChatModal, npcInteractionFlow, resetStoryOptions]); const { acknowledgeQuestCompletion, claimQuestReward, resetStoryState, hydrateStoryState, travelToSceneFromMap, } = createStorySessionActions({ gameState, isLoading, setGameState, setCurrentStory, clearStoryRuntimeUi, commitGeneratedState, buildFallbackStoryForState, }); return { currentStory, isLoading, aiError, displayedOptions, canRefreshOptions, handleRefreshOptions, handleChoice, startOpeningAdventure, isOpeningAdventureReady: Boolean(preparedOpeningAdventure), resetStoryState, hydrateStoryState, travelToSceneFromMap, battleRewardUi: { reward: battleReward, dismiss: () => setBattleReward(null), } satisfies BattleRewardUi, questUi: { acknowledgeQuestCompletion, claimQuestReward, } satisfies QuestFlowUi, npcUi: npcInteractionFlow.npcUi, characterChatUi, inventoryUi, }; }