Files
Genarrative/src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts
2026-04-28 02:05:12 +08:00

2232 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
// 中文注释:只有正式胜利或切磋完成才允许进入 NPC 战后收束;
// 若当前是 fight_defeat则必须交回死亡复活链不能继续发奖励或推进剧情幕。
if (battleMode === 'fight' && battleOutcome !== 'fight_victory') {
return null;
}
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);
}