import type { Dispatch, SetStateAction } from 'react'; import { buildRelationState } from '../../data/attributeResolver'; import { hasEncounterEntity } from '../../data/encounterTransition'; import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog'; import { addInventoryItems, applyStoryChoiceToStanceProfile, buildNpcChatResultText, buildNpcHelpCommitActionText, buildNpcHelpResultText, buildNpcHelpReward, buildNpcLeaveResultText, buildNpcSparResultText, createNpcBattleMonster, describeNpcAffinityInWords, generateNpcHelpReward, getChatAffinityOutcome, getNpcLootItems, getNpcSparMaxHp, markNpcFirstMeaningfulContactResolved, NPC_SPAR_AFFINITY_GAIN, removeInventoryItem, } from '../../data/npcInteractions'; import { acceptQuest, applyQuestProgressFromHostileNpcDefeat, applyQuestProgressFromNpcTalk, applyQuestProgressFromSpar, buildQuestAcceptResultText, buildQuestForEncounter, buildQuestTurnInResultText, findQuestById, getQuestForIssuer, markQuestTurnedIn, } from '../../data/questFlow'; import { incrementGameRuntimeStats } from '../../data/runtimeStats'; import { createSceneCallOutEncounter, resolveSceneEncounterPreview, } from '../../data/sceneEncounterPreviews'; import { applyStoryReasoningRecovery } from '../../data/storyRecovery'; import { generateNextStep, streamNpcChatDialogue } from '../../services/ai'; import type { StoryGenerationContext } from '../../services/aiTypes'; import { generateQuestForNpcEncounter } from '../../services/questDirector'; import { appendStoryEngineCarrierMemory, syncNpcNarrativeState, } from '../../services/storyEngine/echoMemory'; import { createHistoryMoment } from '../../services/storyHistory'; import type { Character, Encounter, GameState, InventoryItem, NpcBattleMode, NpcBattleOutcome, StoryMoment, StoryOption, } from '../../types'; import { AnimationState } from '../../types'; import type { CommitGeneratedState } from '../generatedState'; type CommitGeneratedStateWithEncounterEntry = ( entryState: GameState, resolvedState: GameState, character: Character, actionText: string, resultText: string, lastFunctionId?: string, ) => Promise | void; type GenerateStoryForState = (params: { state: GameState; character: Character; history: StoryMoment[]; choice?: string; lastFunctionId?: string | null; optionCatalog?: StoryOption[] | null; }) => Promise; 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; }; 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'); } export function createStoryNpcEncounterActions({ gameState, setGameState, setCurrentStory, setAiError, setIsLoading, commitGeneratedState, commitGeneratedStateWithEncounterEntry, appendHistory, buildOpeningCampChatContext, buildStoryContextFromState, buildFallbackStoryForState, buildDialogueStoryMoment, generateStoryForState, getStoryGenerationHostileNpcs, getTypewriterDelay, getAvailableOptionsForState, sanitizeOptions, sortOptions, buildContinueAdventureOption, getNpcEncounterKey, getResolvedNpcState, updateNpcState, cloneInventoryItemForOwner, resolveNpcInteractionDecision, npcInteractionFlow, }: { gameState: GameState; setGameState: Dispatch>; setCurrentStory: Dispatch>; setAiError: Dispatch>; setIsLoading: Dispatch>; commitGeneratedState: CommitGeneratedState; commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry; appendHistory: ( state: GameState, actionText: string, resultText: string, ) => GameState['storyHistory']; 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; generateStoryForState: GenerateStoryForState; 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; 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; resolveNpcInteractionDecision: ( state: GameState, option: StoryOption, ) => { kind: string }; npcInteractionFlow: NpcInteractionFlowActions; }) { 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 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 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: null, npcInteractionActive: false, sceneHostileNpcs: [], playerInventory: addInventoryItems(state.playerInventory, lootItems), quests: progressedQuests, npcStates: { ...state.npcStates, [battleNpcId]: { ...markNpcFirstMeaningfulContactResolved(npcState), affinity: 0, relationState: buildRelationState(0), 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 commitNpcChatState = async ( nextState: GameState, character: Character, encounter: Encounter, actionText: string, resultText: string, lastFunctionId?: string, options: { contextNpcStateOverride?: GameState['npcStates'][string] | null; preserveResultTextInHistory?: boolean; revealMode?: 'deferred_options' | 'immediate_story'; } = {}, ) => { const provisionalHistory = appendHistory(gameState, actionText, resultText); const provisionalState = { ...nextState, storyHistory: provisionalHistory, }; const provisionalOpeningCampContext = buildOpeningCampChatContext( provisionalState, character, encounter, ); setGameState(provisionalState); setAiError(null); setIsLoading(true); setCurrentStory(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; setCurrentStory( buildDialogueStoryMoment(encounter.npcName, displayedText, [], true), ); await new Promise((resolve) => window.setTimeout(resolve, getTypewriterDelay(nextChar)), ); } })(); try { dialogueText = await streamNpcChatDialogue( gameState.worldType!, character, encounter, getStoryGenerationHostileNpcs(provisionalState), provisionalHistory, buildStoryContextFromState(provisionalState, { lastFunctionId, ...provisionalOpeningCampContext, encounterNpcStateOverride: options.contextNpcStateOverride, }), actionText, resultText, { onUpdate: (text) => { streamedTargetText = text; }, }, ); streamedTargetText = dialogueText; streamCompleted = true; await typewriterPromise; const finalDialogueText = dialogueText || resultText; const finalHistory = options.preserveResultTextInHistory ? finalDialogueText && finalDialogueText !== resultText ? [ ...provisionalHistory, createHistoryMoment(finalDialogueText, 'result'), ] : provisionalHistory : appendHistory(gameState, actionText, finalDialogueText); const progressedQuests = applyQuestProgressFromNpcTalk( nextState.quests, encounter.id ?? encounter.npcName, ); const finalState = { ...nextState, quests: progressedQuests, storyHistory: finalHistory, }; const finalOpeningCampContext = buildOpeningCampChatContext( finalState, character, encounter, ); setGameState(finalState); if (options.revealMode === 'immediate_story') { setCurrentStory( buildDialogueStoryMoment( encounter.npcName, finalDialogueText, [], false, ), ); await new Promise((resolve) => window.setTimeout(resolve, 260)); const nextStory = await generateStoryForState({ state: finalState, character, history: finalHistory, choice: actionText, lastFunctionId, }); const recoveredState = applyStoryReasoningRecovery(finalState); setGameState(recoveredState); setCurrentStory(nextStory); return; } const availableOptions = getAvailableOptionsForState( finalState, character, ); const response = await generateNextStep( gameState.worldType!, character, getStoryGenerationHostileNpcs(finalState), finalHistory, actionText, buildStoryContextFromState(finalState, { lastFunctionId, ...finalOpeningCampContext, }), availableOptions ? { availableOptions } : undefined, ); const resolvedOptions = sortOptions( availableOptions ? response.options : sanitizeOptions(response.options, character, finalState), ); const recoveredState = applyStoryReasoningRecovery(finalState); setGameState(recoveredState); setCurrentStory({ ...buildDialogueStoryMoment( encounter.npcName, dialogueText || resultText, [buildContinueAdventureOption()], false, ), deferredOptions: resolvedOptions, }); } catch (error) { streamCompleted = true; await typewriterPromise; console.error('Failed to stream npc chat story:', error); setAiError( error instanceof Error ? error.message : '角色对话智能生成不可用。', ); if (options.revealMode === 'immediate_story') { setCurrentStory( buildFallbackStoryForState(provisionalState, character, resultText), ); return; } const fallbackOptions = getAvailableOptionsForState(provisionalState, character) ?? []; setCurrentStory( displayedText ? { ...buildDialogueStoryMoment( encounter.npcName, displayedText, fallbackOptions.length > 0 ? [buildContinueAdventureOption()] : [], false, ), deferredOptions: fallbackOptions.length > 0 ? sortOptions(fallbackOptions) : undefined, } : buildFallbackStoryForState(provisionalState, character, resultText), ); } finally { setIsLoading(false); } }; const enterNpcInteraction = (encounter: Encounter, actionText: string) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter) return false; const nextState: GameState = { ...gameState, npcInteractionActive: true, }; void commitGeneratedState( nextState, playerCharacter, actionText, `${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`, NPC_PREVIEW_TALK_FUNCTION.id, ); return true; }; const handleNpcInteraction = (option: StoryOption) => { const playerCharacter = gameState.playerCharacter; if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) { return false; } const encounter = gameState.currentEncounter; const npcState = getResolvedNpcState(gameState, encounter); const interactionDecision = resolveNpcInteractionDecision( gameState, option, ); 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 (option.interaction.action) { case 'help': { setAiError(null); setIsLoading(true); void (async () => { let committed = false; try { const reward = await generateNpcHelpReward(encounter, gameState); let cooldowns = gameState.playerSkillCooldowns; for ( let index = 0; index < (reward.cooldownBonus ?? 0); index += 1 ) { cooldowns = Object.fromEntries( Object.entries(cooldowns).map(([skillId, turns]) => [ skillId, Math.max(0, turns - 1), ]), ); } let nextState = updateNpcState( gameState, encounter, (currentNpcState) => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), helpUsed: true, stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_help', ), }), ); nextState = appendStoryEngineCarrierMemory({ ...nextState, playerHp: Math.min( nextState.playerMaxHp, nextState.playerHp + (reward.hp ?? 0), ), playerMana: Math.min( nextState.playerMaxMana, nextState.playerMana + (reward.mana ?? 0), ), playerSkillCooldowns: cooldowns, playerInventory: reward.items.length > 0 ? addInventoryItems( nextState.playerInventory, reward.items.map((item) => cloneInventoryItemForOwner( item, 'player', item.quantity, ), ), ) : nextState.playerInventory, } as GameState, reward.items); await commitNpcChatState( nextState, playerCharacter, encounter, buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpResultText(encounter, reward), option.functionId, { contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, preserveResultTextInHistory: true, revealMode: 'immediate_story', }, ); committed = true; } catch (error) { console.error('Failed to resolve npc help reward:', error); const reward = buildNpcHelpReward(encounter, gameState); let cooldowns = gameState.playerSkillCooldowns; for ( let index = 0; index < (reward.cooldownBonus ?? 0); index += 1 ) { cooldowns = Object.fromEntries( Object.entries(cooldowns).map(([skillId, turns]) => [ skillId, Math.max(0, turns - 1), ]), ); } let nextState = updateNpcState( gameState, encounter, (currentNpcState) => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), helpUsed: true, stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_help', ), }), ); nextState = appendStoryEngineCarrierMemory({ ...nextState, playerHp: Math.min( nextState.playerMaxHp, nextState.playerHp + (reward.hp ?? 0), ), playerMana: Math.min( nextState.playerMaxMana, nextState.playerMana + (reward.mana ?? 0), ), playerSkillCooldowns: cooldowns, playerInventory: reward.items.length > 0 ? addInventoryItems( nextState.playerInventory, reward.items.map((item) => cloneInventoryItemForOwner( item, 'player', item.quantity, ), ), ) : nextState.playerInventory, } as GameState, reward.items); await commitNpcChatState( nextState, playerCharacter, encounter, buildNpcHelpCommitActionText(encounter, reward), buildNpcHelpResultText(encounter, reward), option.functionId, { contextNpcStateOverride: nextState.npcStates[getNpcEncounterKey(encounter)] ?? null, preserveResultTextInHistory: true, revealMode: 'immediate_story', }, ); committed = true; } finally { if (!committed) { setIsLoading(false); } } })(); return true; } case 'chat': { const chatOutcome = getChatAffinityOutcome({ playerCharacter, encounter, npcState, actionText: option.actionText, worldType: gameState.worldType, customWorldProfile: gameState.customWorldProfile, }); const affinityGain = chatOutcome.affinityGain; const attributeSummary = chatOutcome.summary; let nextAffinity = npcState.affinity; const nextState = updateNpcState( gameState, encounter, (currentNpcState) => { nextAffinity = currentNpcState.affinity + affinityGain; return { ...markNpcFirstMeaningfulContactResolved(currentNpcState), affinity: nextAffinity, relationState: buildRelationState(nextAffinity), chattedCount: currentNpcState.chattedCount + 1, stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_chat', { affinityGain }, ), }; }, ); void commitNpcChatState( nextState, playerCharacter, encounter, option.actionText, npcState.recruited ? buildCampCompanionChatResultText( encounter, affinityGain, nextAffinity, ) : buildNpcChatResultText( encounter, affinityGain, nextAffinity, attributeSummary, ), option.functionId, { contextNpcStateOverride: npcState, }, ); return true; } case 'quest_accept': { const existingQuest = getQuestForIssuer( gameState.quests, getNpcEncounterKey(encounter), ); if (existingQuest) return true; setAiError(null); setIsLoading(true); void (async () => { let committed = false; try { const quest = (await generateQuestForNpcEncounter({ state: gameState, encounter, })) ?? buildQuestForEncounter({ issuerNpcId: getNpcEncounterKey(encounter), issuerNpcName: encounter.npcName, roleText: encounter.context, scene: gameState.currentScenePreset, worldType: gameState.worldType, }); if (!quest) { return; } const nextState = incrementRuntimeStats( updateNpcState( updateQuestLog(gameState, (quests) => acceptQuest(quests, quest)), encounter, (currentNpcState) => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_quest_accept', ), }), ), {questsAccepted: 1}, ); await commitGeneratedState( nextState, playerCharacter, option.actionText, buildQuestAcceptResultText(quest), option.functionId, ); committed = true; } catch (error) { console.error('Failed to accept npc quest:', error); const fallbackQuest = buildQuestForEncounter({ issuerNpcId: getNpcEncounterKey(encounter), issuerNpcName: encounter.npcName, roleText: encounter.context, scene: gameState.currentScenePreset, worldType: gameState.worldType, }); if (!fallbackQuest) { return; } const nextState = incrementRuntimeStats( updateNpcState( updateQuestLog(gameState, (quests) => acceptQuest(quests, fallbackQuest), ), encounter, (currentNpcState) => ({ ...markNpcFirstMeaningfulContactResolved(currentNpcState), stanceProfile: applyStoryChoiceToStanceProfile( currentNpcState.stanceProfile, 'npc_quest_accept', ), }), ), {questsAccepted: 1}, ); await commitGeneratedState( nextState, playerCharacter, option.actionText, buildQuestAcceptResultText(fallbackQuest), option.functionId, ); committed = true; } finally { if (!committed) { setIsLoading(false); } } })(); return true; } case 'quest_turn_in': { const questId = option.interaction.questId; const quest = questId ? findQuestById(gameState.quests, questId) : null; if (!quest || quest.status !== 'completed') return true; const nextState = appendStoryEngineCarrierMemory({ ...updateQuestLog(gameState, (quests) => markQuestTurnedIn(quests, quest.id), ), npcStates: { ...gameState.npcStates, [getNpcEncounterKey(encounter)]: { ...syncNpcNarrativeState({ encounter, npcState: { ...npcState, ...markNpcFirstMeaningfulContactResolved(npcState), affinity: npcState.affinity + quest.reward.affinityBonus, relationState: buildRelationState( npcState.affinity + quest.reward.affinityBonus, ), }, customWorldProfile: gameState.customWorldProfile, storyEngineMemory: gameState.storyEngineMemory, }), }, }, playerCurrency: gameState.playerCurrency + quest.reward.currency, playerInventory: addInventoryItems( gameState.playerInventory, quest.reward.items, ), } as GameState, quest.reward.items); void commitGeneratedState( nextState, playerCharacter, option.actionText, buildQuestTurnInResultText(quest), option.functionId, ); return true; } case 'leave': { const baseState: GameState = { ...gameState, ambientIdleMode: undefined, 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, }; const entryState = { ...baseState, ...createSceneCallOutEncounter(baseState), } as GameState; const resolvedState = hasEncounterEntity(entryState) ? resolveSceneEncounterPreview(entryState) : baseState; void commitGeneratedStateWithEncounterEntry( entryState, resolvedState, playerCharacter, option.actionText, buildNpcLeaveResultText(encounter), option.functionId, ); return true; } case 'fight': { const battleMonster = createNpcBattleMonster(encounter, npcState, 'fight', { worldType: gameState.worldType, customWorldProfile: gameState.customWorldProfile, }); const nextState = { ...gameState, npcStates: { ...gameState.npcStates, [getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved( npcState, ), }, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [battleMonster], playerX: 0, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, scrollWorld: false, inBattle: true, currentBattleNpcId: getNpcEncounterKey(encounter), currentNpcBattleMode: 'fight' as const, currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, }; void commitGeneratedState( nextState, playerCharacter, option.actionText, `You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`, option.functionId, ); return true; } case 'spar': { const sparPlayerMaxHp = getNpcSparMaxHp( playerCharacter, gameState.worldType, gameState.customWorldProfile, ); const battleMonster = createNpcBattleMonster(encounter, npcState, 'spar', { worldType: gameState.worldType, customWorldProfile: gameState.customWorldProfile, }); const nextState = { ...gameState, npcStates: { ...gameState.npcStates, [getNpcEncounterKey(encounter)]: markNpcFirstMeaningfulContactResolved( npcState, ), }, currentEncounter: null, npcInteractionActive: false, sceneHostileNpcs: [battleMonster], playerX: 0, playerHp: sparPlayerMaxHp, playerMaxHp: sparPlayerMaxHp, playerFacing: 'right' as const, animationState: AnimationState.IDLE, playerActionMode: 'idle' as const, scrollWorld: false, inBattle: true, currentBattleNpcId: getNpcEncounterKey(encounter), currentNpcBattleMode: 'spar' as const, currentNpcBattleOutcome: null, sparReturnEncounter: encounter, sparPlayerHpBefore: gameState.playerHp, sparPlayerMaxHpBefore: gameState.playerMaxHp, sparStoryHistoryBefore: gameState.storyHistory, }; void commitGeneratedState( nextState, playerCharacter, option.actionText, `${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`, option.functionId, ); return true; } default: return false; } }; return { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult, }; }