Files
Genarrative/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
2026-04-27 22:50:18 +08:00

2226 lines
65 KiB
TypeScript

import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { NPC_FIGHT_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcSparResultText,
getNpcLootItems,
markNpcFirstMeaningfulContactResolved,
NPC_SPAR_AFFINITY_GAIN,
removeInventoryItem,
} from '../../data/npcInteractions';
import {
applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk,
applyQuestProgressFromSpar,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { getScenePresetById } from '../../data/scenePresets';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
advanceSceneActRuntimeState,
getSceneConnectionDirectionText,
resolveLimitedPrimaryNpcChatState,
resolveSceneActProgression,
} from '../../services/customWorldSceneActRuntime';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
GameState,
InventoryItem,
NpcBattleMode,
NpcBattleOutcome,
QuestLogEntry,
StoryMoment,
StoryOption,
} from '../../types';
import { AnimationState } from '../../types';
import type { CommitGeneratedState } from '../generatedState';
import { resolveRpgRuntimeChoice } from './rpgRuntimeStoryGateway';
type CommitGeneratedStateWithEncounterEntry = (
entryState: GameState,
resolvedState: GameState,
character: Character,
actionText: string,
resultText: string,
lastFunctionId?: string,
) => Promise<void> | void;
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<void>;
};
type BuildStoryContextExtras = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
type NpcChatDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
closingMode?: 'free' | 'foreshadow_close' | null;
terminationMode?: 'none' | 'hostile_model' | null;
terminationReason?: 'hostile_breakoff' | 'player_exit' | null;
isHostileChat?: boolean;
functionOptions?: Array<{
functionId: string;
actionText: string;
detailText?: string | null;
action?: string | null;
}>;
} | null;
type NpcChatCombatContext = NonNullable<
NonNullable<StoryMoment['npcChatState']>['combatContext']
>;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
return Boolean(encounter?.kind === 'npc');
}
const NPC_CHAT_QUEST_OFFER_FUNCTION_IDS = {
view: 'npc_chat_quest_offer_view',
replace: 'npc_chat_quest_offer_replace',
abandon: 'npc_chat_quest_offer_abandon',
} as const;
type NpcChatQuestOfferPayloadAction =
keyof typeof NPC_CHAT_QUEST_OFFER_FUNCTION_IDS;
/**
* RPG runtime NPC 交互主链。
* 负责 NPC 对话、委托处理、战斗后续对话重开,以及需要服务端结算的正式动作派发。
*/
export function createStoryNpcEncounterActions({
gameState,
currentStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
appendHistory,
buildNpcStory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
getStoryGenerationHostileNpcs,
getAvailableOptionsForState,
buildContinueAdventureOption,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
resolveNpcInteractionDecision,
npcInteractionFlow,
}: {
gameState: GameState;
currentStory: StoryMoment | null;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
commitGeneratedState: CommitGeneratedState;
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildNpcStory: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
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;
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;
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 incrementRuntimeStats = (
state: GameState,
increments: Parameters<typeof incrementGameRuntimeStats>[1],
) => ({
...state,
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
});
const buildNpcChatOption = (
encounter: Encounter,
actionText: string,
): StoryOption => ({
functionId: 'npc_chat',
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
});
const buildNpcChatQuestOfferOption = (
encounter: Encounter,
functionId: string,
actionText: string,
action: NpcChatQuestOfferPayloadAction,
): StoryOption => ({
functionId,
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
runtimePayload: {
npcId: encounter.id ?? encounter.npcName,
npcChatQuestOfferAction: action,
},
});
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
buildNpcChatQuestOfferOption(
encounter,
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
'查看任务',
'view',
),
buildNpcChatQuestOfferOption(
encounter,
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.replace,
'更换任务',
'replace',
),
buildNpcChatQuestOfferOption(
encounter,
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.abandon,
'放弃任务',
'abandon',
),
];
const getPendingQuestOffer = (
story: StoryMoment | null,
encounter?: Encounter,
) => {
const pendingQuestOffer = story?.npcChatState?.pendingQuestOffer ?? null;
if (!pendingQuestOffer) {
return null;
}
if (
encounter &&
story?.npcChatState?.npcId !== (encounter.id ?? encounter.npcName)
) {
return null;
}
return pendingQuestOffer;
};
const buildQuestOfferDialogueText = (
encounter: Encounter,
quest: QuestLogEntry,
) => {
const summaryText = quest.summary.trim() || quest.description.trim();
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
summaryText
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
: '如果你愿意,我想把眼前这件事正式交给你。'
}`;
};
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
history
.slice(-6)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-4);
const buildNpcBattleChatCombatContext = (params: {
battleMode: NpcBattleMode;
resultText: string;
actionText: string;
historyBase: GameState['storyHistory'];
}): NpcChatCombatContext => {
const logLines = [
...extractRecentCombatLogLines(params.historyBase),
params.actionText,
params.resultText,
].filter((line, index, lines) => lines.indexOf(line) === index);
return {
summary:
params.battleMode === 'spar'
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
const reopenNpcChatAfterBattle = (params: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => {
const playerCharacter = params.nextState.playerCharacter;
if (!playerCharacter) {
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
params.encounter,
params.resultText,
);
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, params.encounter),
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
actionText: params.actionText,
historyBase: params.nextState.storyHistory,
});
const chatDirective = toNpcChatDirectiveWithFunctionOptions(
resolveLimitedPrimaryNpcChatState({
state: params.nextState,
npcId: params.encounter.id ?? params.encounter.npcName,
affinity: reopenedNpcState.affinity,
nextTurnCount: 0,
}),
params.encounter,
playerCharacter,
);
setCurrentStory(
buildNpcChatStoryMoment({
encounter: params.encounter,
dialogue: [
{
speaker: 'system',
text: params.resultText,
},
],
options: buildNpcChatEntryOptions(
params.encounter,
fallbackChatOption,
baseChatOptions.slice(1),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'player_reply',
combatContext,
}),
);
return true;
};
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 restoredEncounter =
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
kind: 'npc',
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
npcDescription: '',
npcAvatar: '',
context: '',
hostile: false,
} satisfies Encounter);
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: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
npcStates: {
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: npcState.affinity,
relationState: buildRelationState(npcState.affinity),
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 buildNpcChatTurnOptions = (
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
.filter(Boolean)
.slice(0, 3)
.map((suggestion) => ({
functionId: 'npc_chat',
actionText: suggestion,
text: suggestion,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
}));
const cloneNpcChatFunctionOption = (option: StoryOption): StoryOption => ({
...option,
visuals: {
...option.visuals,
monsterChanges: option.visuals.monsterChanges.map((change) => ({
...change,
})),
},
interaction: option.interaction ? { ...option.interaction } : undefined,
runtimePayload: option.runtimePayload
? { ...option.runtimePayload }
: option.runtimePayload,
});
const rewriteNpcChatFunctionOption = (
option: StoryOption,
actionText: string,
): StoryOption => ({
...cloneNpcChatFunctionOption(option),
actionText,
text: actionText,
});
const NPC_CHAT_SUGGESTION_LIMIT = 20;
const trimNpcChatSuggestion = (text: string) =>
text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
const clampNpcChatSuggestionLength = (text: string) =>
Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join('');
const isDirectNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return false;
}
const behaviorPrefixes = [
'先',
'再',
'换个',
'顺着',
'试着',
'表明',
'告诉',
'问问',
'追问',
'继续聊',
'继续交谈',
'继续谈',
];
return !behaviorPrefixes.some((prefix) =>
normalizedText.startsWith(prefix),
);
};
const sanitizeNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return '';
}
return clampNpcChatSuggestionLength(normalizedText);
};
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = clampNpcChatSuggestionLength(
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [
sanitizeNpcChatSuggestion('我愿意先听你说完'),
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
sanitizeNpcChatSuggestion('你别再避重就轻'),
];
};
const isNpcChatOptionForEncounter = (
option: StoryOption,
encounter: Encounter,
) => {
if (option.functionId !== 'npc_chat') {
return false;
}
if (option.interaction?.kind !== 'npc') {
return true;
}
return (
option.interaction.action === 'chat' &&
option.interaction.npcId === (encounter.id ?? encounter.npcName)
);
};
const buildNpcChatEntryOptions = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const candidateOptions = [
selectedOption,
...extraOptions,
...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
),
];
const dedupedOptions: StoryOption[] = [];
const seenActionTexts = new Set<string>();
for (const option of candidateOptions) {
const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
if (
!actionText ||
!isDirectNpcChatSuggestion(actionText) ||
seenActionTexts.has(actionText)
) {
continue;
}
seenActionTexts.add(actionText);
dedupedOptions.push({
...option,
actionText,
text: actionText,
});
if (dedupedOptions.length === 3) {
return dedupedOptions;
}
}
const fallbackSuggestions = buildFallbackNpcChatSuggestions(
currentStory?.text?.trim() || selectedOption.actionText,
);
const mergedSuggestions = [
...dedupedOptions.map((option) => option.actionText),
...fallbackSuggestions.filter(
(suggestion) => !seenActionTexts.has(suggestion),
),
];
return buildNpcChatTurnOptions(encounter, mergedSuggestions);
};
const buildNpcChatFunctionOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) =>
buildPostNpcChatOptionCatalog(encounter, playerCharacter)
.filter((option) => option.functionId !== 'battle_escape_breakout')
.filter((option) => !isNpcChatOptionForEncounter(option, encounter))
.filter((option) => option.interaction?.kind === 'npc')
.map(cloneNpcChatFunctionOption);
const toNpcChatDirectiveWithFunctionOptions = (
directive: NpcChatDirective,
encounter: Encounter,
playerCharacter: Character,
options?: {
forcePlayerExit?: boolean;
},
): NpcChatDirective => {
const functionOptions = buildNpcChatFunctionOptionCatalog(
encounter,
playerCharacter,
).map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: option.detailText ?? null,
action: option.interaction?.kind === 'npc' ? option.interaction.action : null,
}));
const isHostileChat =
directive?.isHostileChat === true ||
directive?.terminationMode === 'hostile_model';
return {
...(directive ?? {}),
terminationMode: isHostileChat ? 'hostile_model' : 'none',
isHostileChat,
terminationReason: options?.forcePlayerExit
? 'player_exit'
: (directive?.terminationReason ?? null),
closingMode: options?.forcePlayerExit
? 'foreshadow_close'
: (directive?.closingMode ?? 'free'),
forceExitAfterTurn:
options?.forcePlayerExit || directive?.forceExitAfterTurn || false,
functionOptions,
};
};
const buildNpcChatMixedTurnOptions = (
encounter: Encounter,
playerCharacter: Character,
suggestions: string[],
functionSuggestions?: Array<{
functionId?: string;
actionText?: string;
}>,
) => {
const chatOptions = buildNpcChatTurnOptions(encounter, suggestions);
const functionCatalog = buildNpcChatFunctionOptionCatalog(
encounter,
playerCharacter,
);
const functionOptions = (functionSuggestions ?? [])
.map((suggestion) => {
if (!suggestion.functionId || !suggestion.actionText) return null;
const matchedOption = functionCatalog.find(
(option) => option.functionId === suggestion.functionId,
);
return matchedOption
? rewriteNpcChatFunctionOption(
matchedOption,
sanitizeNpcChatSuggestion(suggestion.actionText) ||
matchedOption.actionText,
)
: null;
})
.filter((option): option is StoryOption => Boolean(option));
const mergedOptions = [...chatOptions, ...functionOptions];
const seen = new Set<string>();
return mergedOptions.filter((option) => {
const key = [
option.functionId,
option.actionText,
option.interaction?.kind === 'npc' ? option.interaction.action : '',
].join('::');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const buildNpcChatStoryMoment = (params: {
encounter: Encounter;
dialogue: NonNullable<StoryMoment['dialogue']>;
options: StoryOption[];
streaming: boolean;
turnCount: number;
chatDirective?: NpcChatDirective;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
combatContext?: NpcChatCombatContext | null;
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
displayMode: 'dialogue',
dialogue: params.dialogue,
streaming: params.streaming,
npcAffinityEffect: params.latestAffinityEffect ?? null,
npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: params.openingSource ?? 'player_reply',
sceneActId: params.chatDirective?.sceneActId ?? null,
turnLimit: params.chatDirective?.turnLimit ?? null,
remainingTurns: params.chatDirective?.remainingTurns ?? null,
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
terminationMode: params.chatDirective?.terminationMode ?? null,
terminationReason: params.chatDirective?.terminationReason ?? null,
isHostileChat: params.chatDirective?.isHostileChat ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
combatContext: params.combatContext ?? null,
},
});
const collapseNpcChatOptions = (options: StoryOption[]) => {
let hasKeptNpcChat = false;
return options.filter((option) => {
if (option.functionId !== 'npc_chat') {
return true;
}
if (hasKeptNpcChat) {
return false;
}
hasKeptNpcChat = true;
return true;
});
};
const buildPostNpcChatOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
const nonChatCurrentOptions = currentStoryOptions.filter(
(option) => !currentChatOptions.includes(option),
);
const nonChatResolvedOptions = resolvedStateOptions.filter(
(option) => !isNpcChatOptionForEncounter(option, encounter),
);
const mergedOptions: StoryOption[] = [];
const seenOptionIdentity = new Set<string>();
const pushUniqueOption = (option: StoryOption) => {
const optionIdentity = [
option.functionId,
option.interaction?.kind ?? '',
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
option.interaction?.kind === 'npc' ? option.interaction.action : '',
].join('::');
if (seenOptionIdentity.has(optionIdentity)) {
return;
}
seenOptionIdentity.add(optionIdentity);
mergedOptions.push(option);
};
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
nonChatCurrentOptions.forEach(pushUniqueOption);
nonChatResolvedOptions.forEach(pushUniqueOption);
return mergedOptions;
};
const buildSceneConnectionTravelOptions = (state: GameState) => {
if (!state.worldType || !state.currentScenePreset) {
return [];
}
const seenSceneIds = new Set<string>();
return (state.currentScenePreset.connections ?? [])
.filter((connection) => {
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
return false;
}
seenSceneIds.add(connection.sceneId);
return true;
})
.map((connection) => {
const targetScene = getScenePresetById(
state.worldType!,
connection.sceneId,
);
const targetSceneName = targetScene?.name ?? connection.sceneId;
const directionText = getSceneConnectionDirectionText(
connection.relativePosition,
);
const actionText = `${directionText},前往${targetSceneName}`;
return {
functionId: 'idle_travel_next_scene',
actionText,
text: actionText,
detailText: connection.summary,
priority: 12,
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: 1.1,
playerOffsetY: 0,
playerFacing:
connection.relativePosition === 'west' ||
connection.relativePosition === 'left' ||
connection.relativePosition === 'back'
? 'left'
: 'right',
scrollWorld: false,
monsterChanges: [],
},
runtimePayload: {
targetSceneId: connection.sceneId,
},
} satisfies StoryOption;
});
};
const buildPostNpcChatProgressionOptions = (
encounter: Encounter,
playerCharacter: Character,
) => {
const progression = resolveSceneActProgression({
profile: gameState.customWorldProfile,
sceneId: gameState.currentScenePreset?.id ?? null,
storyEngineMemory: gameState.storyEngineMemory,
});
if (!progression) {
return {
deferredRuntimeState: null,
options: currentStory?.deferredOptions?.length
? currentStory.deferredOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
};
}
if (!progression.isLastAct) {
const nextActState = advanceSceneActRuntimeState({ progress: progression });
const nextStoryEngineMemory = nextActState
? {
...(gameState.storyEngineMemory ??
createEmptyStoryEngineMemoryState()),
currentSceneActState: nextActState,
}
: gameState.storyEngineMemory;
const nextState = {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
storyEngineMemory: nextStoryEngineMemory,
};
const nextOptions = collapseNpcChatOptions(
getAvailableOptionsForState(nextState, playerCharacter) ?? [],
);
return {
deferredRuntimeState: {
currentScenePreset: nextState.currentScenePreset,
storyEngineMemory: nextState.storyEngineMemory,
},
options:
nextOptions.length > 0
? nextOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
};
}
const travelOptions = buildSceneConnectionTravelOptions(gameState);
return {
deferredRuntimeState: null,
options:
travelOptions.length > 0
? travelOptions
: buildPostNpcChatOptionCatalog(encounter, playerCharacter),
};
};
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
const sanitizeNpcChatDialogueHistory = (
encounter: Encounter,
dialogue: NonNullable<StoryMoment['dialogue']>,
turnCount: number,
openingSource?: StoryMoment['npcChatState'] extends infer T
? T extends { openingSource?: infer U }
? U
: never
: never,
) => {
const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter);
return dialogue.filter((turn, index) => {
if (index !== 0 || turn.speaker !== 'npc') {
return true;
}
if (turn.text.trim() === legacyOpeningText) {
return false;
}
if (turnCount === 0 && dialogue.length === 1) {
return openingSource === 'npc_initiated';
}
return true;
});
};
const buildNpcChatDialogueHistory = (
encounter: Encounter,
turnCount: number,
) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
turnCount,
currentStory.npcChatState?.openingSource,
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
actionText = '逃跑',
runtimePayload?: StoryOption['runtimePayload'],
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: gameState.currentScenePreset?.id ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
monsters: [],
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption('battle_escape_breakout', functionContext, '逃跑')
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText,
text: actionText,
detailText: '',
runtimePayload: {
...(resolvedOption.runtimePayload ?? {}),
...(runtimePayload ?? {}),
},
};
}
return {
functionId: 'battle_escape_breakout',
actionText,
text: actionText,
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
runtimePayload,
};
};
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
const currentScene = gameState.currentScenePreset;
const worldType = gameState.worldType;
const options: StoryOption[] = [];
const seenSceneIds = new Set<string>();
if (worldType && currentScene) {
for (const connection of currentScene.connections ?? []) {
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
continue;
}
seenSceneIds.add(connection.sceneId);
const targetScene = getScenePresetById(worldType, connection.sceneId);
const targetSceneName =
targetScene?.name ??
connection.summary?.trim() ??
connection.sceneId;
options.push(
buildHostileNpcEscapeOption(
character,
`逃往${targetSceneName}`,
{
targetSceneId: connection.sceneId,
escapeTargetSceneId: connection.sceneId,
escapeEntry: 'from_left',
},
),
);
}
options.push(
buildHostileNpcEscapeOption(
character,
'逃回当前场景起点',
{
targetSceneId: currentScene.id,
escapeTargetSceneId: currentScene.id,
escapeReturnToSceneStart: true,
escapeEntry: 'from_left',
},
),
);
}
return options.length > 0
? options
: [buildHostileNpcEscapeOption(character)];
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
text: '与他对战',
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'fight',
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(encounter, affinity);
return {
text: declarationText,
options: [
buildHostileNpcFightOption(encounter),
...buildHostileNpcEscapeOptions(character),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const shouldUseHostileNpcChatClosureOptions = (
directive: NpcChatDirective,
affinity: number,
) =>
affinity < 0 ||
directive?.limitReason === 'negative_affinity' ||
directive?.terminationMode === 'hostile_model' ||
directive?.isHostileChat === true;
// 负好感聊天结束后不能回到普通冒险分支,只允许玩家立刻战斗或逃离。
const buildNpcChatClosureOptions = (
encounter: Encounter,
character: Character,
directive: NpcChatDirective,
affinity: number,
) => {
if (!shouldUseHostileNpcChatClosureOptions(directive, affinity)) {
return [buildContinueAdventureOption()];
}
const fightOption = buildHostileNpcFightOption(encounter);
return [
{
...fightOption,
actionText: '战斗',
text: '战斗',
},
...buildHostileNpcEscapeOptions(character),
];
};
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
) => {
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
const playerCharacter = gameState.playerCharacter;
const resolvedChatDirective = playerCharacter
? toNpcChatDirectiveWithFunctionOptions(
chatDirective ?? null,
encounter,
playerCharacter,
)
: chatDirective;
setAiError(null);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: openingDialogue,
options: buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
),
streaming: false,
turnCount: 0,
chatDirective: resolvedChatDirective,
openingSource,
}),
);
return true;
};
const startNpcInitiatedOpening = async (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
}
const npcState = getResolvedNpcState(gameState, encounter);
const resolvedChatDirective = toNpcChatDirectiveWithFunctionOptions(
chatDirective ?? null,
encounter,
playerCharacter,
);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
const existingDialogue = buildNpcChatDialogueHistory(encounter, 0);
const openingOptions = buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: existingDialogue,
options: [],
streaming: true,
turnCount: 0,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
'',
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: 0,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
},
chatDirective: resolvedChatDirective,
npcInitiatesConversation: true,
},
);
if (!chatTurn?.npcReply?.trim()) {
throw new Error('NPC 主动开场结果为空');
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: openingOptions.map((option) => option.actionText),
chatTurn.functionSuggestions,
),
streaming: false,
turnCount: 0,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
return true;
} catch (error) {
console.error('Failed to start npc initiated opening:', error);
setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败');
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
} finally {
setIsLoading(false);
}
};
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
options: {
forcePlayerExit?: boolean;
} = {},
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return false;
}
const npcState = getResolvedNpcState(gameState, encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
? currentStory.npcChatState
: null;
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
currentNpcChatState.turnCount ?? 0,
currentNpcChatState.openingSource,
)
: [];
const dialogueWithPlayer = [
...existingDialogue,
{
speaker: 'player' as const,
text: playerMessage,
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount,
});
const chatDirective = toNpcChatDirectiveWithFunctionOptions(
limitedChatDirective,
encounter,
playerCharacter,
{
forcePlayerExit: options.forcePlayerExit,
},
);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: dialogueWithPlayer,
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective,
combatContext: currentCombatContext,
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
playerMessage,
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...dialogueWithPlayer,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective,
combatContext: currentCombatContext,
}),
);
},
questOfferContext: chatDirective?.isHostileChat
? null
: {
state: gameState,
turnCount: nextTurnCount,
},
chatDirective,
combatContext: currentCombatContext,
},
);
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain: chatTurn.affinityDelta },
),
};
},
);
const finalHistory = appendHistory(
gameState,
playerMessage,
chatTurn.npcReply,
);
const finalState = {
...nextState,
quests: applyQuestProgressFromNpcTalk(
nextState.quests,
encounter.id ?? encounter.npcName,
),
storyHistory: finalHistory,
};
setGameState(finalState);
// 好感变化只保留为一次性表现事件,不再插入聊天消息流。
const latestAffinityEffect =
chatTurn.affinityDelta !== 0
? {
eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
npcId: encounter.id ?? encounter.npcName,
delta: chatTurn.affinityDelta,
}
: null;
const nextDialogue = [
...dialogueWithPlayer,
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
];
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
null;
const resolvedChatDirective = chatDirective
? {
sceneActId: chatDirective.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
chatDirective.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
chatDirective.remainingTurns ??
null,
limitReason: chatDirective.limitReason ?? null,
terminationMode: chatDirective.terminationMode ?? null,
terminationReason:
chatTurn.chatDirective?.terminationReason ??
chatDirective.terminationReason ??
null,
isHostileChat: chatDirective.isHostileChat ?? false,
closingMode:
chatTurn.chatDirective?.closingMode ??
chatDirective.closingMode ??
'free',
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
chatDirective.forceExitAfterTurn ??
false,
}
: null;
const shouldForceExitAfterTurn =
resolvedChatDirective?.forceExitAfterTurn === true;
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
];
const shouldUseHostileClosureOptions =
shouldUseHostileNpcChatClosureOptions(
resolvedChatDirective,
Math.min(npcState.affinity, nextAffinity),
);
const progressionResult = shouldUseHostileClosureOptions
? null
: buildPostNpcChatProgressionOptions(encounter, playerCharacter);
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: buildNpcChatClosureOptions(
encounter,
playerCharacter,
resolvedChatDirective,
Math.min(npcState.affinity, nextAffinity),
),
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
npcAffinityEffect: latestAffinityEffect,
deferredOptions: progressionResult?.options,
deferredRuntimeState:
progressionResult?.deferredRuntimeState ?? undefined,
});
return true;
}
if (pendingQuest) {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...nextDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text:
pendingQuestIntroText ||
buildQuestOfferDialogueText(encounter, pendingQuest),
},
],
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
pendingQuestOffer: {
quest: pendingQuest,
},
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: nextDialogue,
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: buildFallbackNpcChatSuggestions(playerMessage),
chatTurn.functionSuggestions,
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
} catch (error) {
console.error('Failed to stream npc chat turn:', error);
setAiError(error instanceof Error ? error.message : 'NPC 聊天续写失败');
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: dialogueWithPlayer,
options: buildNpcChatTurnOptions(
encounter,
buildFallbackNpcChatSuggestions(playerMessage),
),
streaming: false,
turnCount: nextTurnCount,
chatDirective,
combatContext: currentCombatContext,
}),
);
return false;
} finally {
setIsLoading(false);
}
};
const continueAfterNpcChatClosure = async () => {
const playerCharacter = gameState.playerCharacter;
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
setAiError(null);
const progressionResult = buildPostNpcChatProgressionOptions(
encounter,
playerCharacter,
);
const nextState = {
...gameState,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
inBattle: false,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentScenePreset:
progressionResult.deferredRuntimeState?.currentScenePreset ??
gameState.currentScenePreset,
storyEngineMemory:
progressionResult.deferredRuntimeState?.storyEngineMemory ??
gameState.storyEngineMemory,
};
setGameState(nextState);
setCurrentStory({
text: currentStory?.dialogue?.at(-1)?.text ?? currentStory?.text ?? '',
options: progressionResult.options,
displayMode: 'narrative',
});
return true;
};
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
void handleNpcChatTurn(
encounter,
`我先结束这轮交谈,继续往前走。`,
{
forcePlayerExit: true,
},
);
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const npcState = getResolvedNpcState(gameState, encounter);
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
setGameState(nextState);
setAiError(null);
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: nextState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
const npcInteractionOptions =
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
const chatOptions = npcInteractionOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
const seedChatOption =
chatOptions[0] ??
({
functionId: 'npc_chat',
actionText: actionText || `${encounter.npcName}搭话`,
text: actionText || `${encounter.npcName}搭话`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc' as const,
npcId: encounter.id ?? encounter.npcName,
action: 'chat' as const,
},
} satisfies StoryOption);
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
playerCharacter,
npcState.affinity,
),
);
return true;
}
return enterNpcChat(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
};
const resolveServerNpcStoryAction = async (params: {
option: StoryOption;
payload?: Record<string, unknown>;
}) => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
setAiError(null);
setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory,
option: params.option,
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc story action on the server:', error);
setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败');
if (!currentStory) {
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
}
return false;
} finally {
setIsLoading(false);
}
};
const replacePendingNpcQuestOffer = () => {
const encounter = gameState.currentEncounter;
const pendingQuestOffer = isNpcEncounter(encounter)
? getPendingQuestOffer(currentStory, encounter)
: null;
if (!encounter || !pendingQuestOffer) {
return false;
}
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_replace',
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
text: `你请${encounter.npcName}换一份更合适的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_replace',
},
},
});
return true;
};
const abandonPendingNpcQuestOffer = () => {
const encounter = gameState.currentEncounter;
const pendingQuestOffer = isNpcEncounter(encounter)
? getPendingQuestOffer(currentStory, encounter)
: null;
if (!encounter || !pendingQuestOffer) {
return false;
}
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_chat_quest_offer_abandon',
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_offer_abandon',
},
},
});
return true;
};
const acceptPendingNpcQuestOffer = () => {
const encounter = gameState.currentEncounter;
const pendingQuestOffer = isNpcEncounter(encounter)
? getPendingQuestOffer(currentStory, encounter)
: null;
if (!encounter || !pendingQuestOffer) {
return null;
}
const questId = pendingQuestOffer.quest.id?.trim();
if (!questId) {
return null;
}
void resolveServerNpcStoryAction({
option: {
functionId: 'npc_quest_accept',
actionText: `你答应接下${encounter.npcName}的委托。`,
text: `你答应接下${encounter.npcName}的委托。`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'quest_accept',
},
},
});
return questId;
};
const inferNpcInteractionFromOption = (
encounter: Encounter,
option: StoryOption,
): StoryOption['interaction'] => {
const npcId = encounter.id ?? encounter.npcName;
const actionByFunctionId: Record<string, StoryOption['interaction']> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: {
kind: 'npc',
npcId,
action: 'quest_turn_in',
questId:
option.interaction?.kind === 'npc'
? option.interaction.questId
: undefined,
},
};
return option.interaction ?? actionByFunctionId[option.functionId];
};
const handleNpcInteraction = (option: StoryOption) => {
if (
currentStory?.deferredOptions?.length &&
option.functionId === 'story_continue_adventure'
) {
void continueAfterNpcChatClosure();
return true;
}
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
const encounter = gameState.currentEncounter;
const resolvedInteraction = inferNpcInteractionFromOption(
encounter,
option,
);
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
return false;
}
const resolvedOption = {
...option,
interaction: resolvedInteraction,
} satisfies StoryOption;
const interactionDecision = resolveNpcInteractionDecision(
gameState,
resolvedOption,
);
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 (resolvedOption.interaction.action) {
case 'help': {
void resolveServerNpcStoryAction({
option: resolvedOption,
});
return true;
}
case 'chat': {
if (
currentStory?.npcChatState?.npcId ===
(encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, resolvedOption.actionText);
return true;
}
const npcState = getResolvedNpcState(gameState, encounter);
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
if (!npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
resolvedOption,
[],
limitedChatDirective,
);
return true;
}
return enterNpcChat(encounter, resolvedOption);
}
case 'quest_accept': {
void resolveServerNpcStoryAction({
option: resolvedOption,
});
return true;
}
case 'quest_turn_in': {
const questId = resolvedOption.interaction.questId;
void resolveServerNpcStoryAction({
option: resolvedOption,
payload: questId
? {
questId,
}
: undefined,
});
return true;
}
case 'leave': {
void resolveServerNpcStoryAction({
option: resolvedOption,
});
return true;
}
case 'fight':
case 'spar': {
void resolveServerNpcStoryAction({
option: resolvedOption,
});
return true;
}
default:
return false;
}
};
return {
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
continueAfterNpcChatClosure,
replacePendingNpcQuestOffer,
abandonPendingNpcQuestOffer,
acceptPendingNpcQuestOffer,
};
}
export type UseRpgRuntimeNpcInteractionParams = Parameters<
typeof createStoryNpcEncounterActions
>[0];
export type RpgRuntimeNpcInteractionResult = ReturnType<
typeof createStoryNpcEncounterActions
>;
export const createRpgRuntimeNpcEncounterActions =
createStoryNpcEncounterActions;
export function useRpgRuntimeNpcInteraction(
params: UseRpgRuntimeNpcInteractionParams,
) {
return createStoryNpcEncounterActions(params);
}