1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 19:40:33 +08:00
parent 54b3d3c490
commit 8c3fbd9bcf
15 changed files with 904 additions and 65 deletions

View File

@@ -6,7 +6,6 @@ import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
buildNpcChatResultText,
buildNpcHelpCommitActionText,
buildNpcHelpResultText,
buildNpcHelpReward,
@@ -15,7 +14,6 @@ import {
createNpcBattleMonster,
describeNpcAffinityInWords,
generateNpcHelpReward,
getChatAffinityOutcome,
getNpcLootItems,
getNpcSparMaxHp,
markNpcFirstMeaningfulContactResolved,
@@ -609,6 +607,60 @@ export function createStoryNpcEncounterActions({
];
};
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,
) => {
const candidateOptions = [
selectedOption,
...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
),
];
const dedupedOptions: StoryOption[] = [];
const seenActionTexts = new Set<string>();
for (const option of candidateOptions) {
const actionText = option.actionText?.trim();
if (!actionText || seenActionTexts.has(actionText)) {
continue;
}
seenActionTexts.add(actionText);
dedupedOptions.push(option);
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)),
].slice(0, 3);
return buildNpcChatTurnOptions(encounter, mergedSuggestions);
};
const buildNpcChatStoryMoment = (params: {
encounter: Encounter;
dialogue: NonNullable<StoryMoment['dialogue']>;
@@ -629,6 +681,37 @@ export function createStoryNpcEncounterActions({
},
});
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
) => {
const openingDialogue =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: currentStory?.dialogue && currentStory.dialogue.length > 0
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
setAiError(null);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: openingDialogue,
options: buildNpcChatEntryOptions(encounter, selectedOption),
streaming: false,
turnCount: 0,
}),
);
return true;
};
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
@@ -1071,8 +1154,14 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'chat': {
void handleNpcChatTurn(encounter, option.actionText);
return true;
if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, option.actionText);
return true;
}
return enterNpcChat(encounter, option);
}
case 'quest_accept': {
void resolveServerNpcStoryAction({

View File

@@ -90,8 +90,6 @@ export function useStoryInteractionCoordinator({
fallbackCompanionName,
turnVisualMs,
}: StoryInteractionCoordinatorParams) {
const { buildNpcStory } = runtimeSupport;
const { handleTreasureInteraction } = useTreasureFlow(
interactionConfig.treasureFlow,
);