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

This commit is contained in:
2026-04-18 17:28:23 +08:00
parent b3066c7bc1
commit 54b3d3c490
21 changed files with 731 additions and 156 deletions

View File

@@ -40,7 +40,11 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
import {
generateNextStep,
streamNpcChatDialogue,
streamNpcChatTurn,
} from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {
@@ -572,6 +576,249 @@ export function createStoryNpcEncounterActions({
}
};
const buildNpcChatTurnOptions = (
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions.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 buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话';
return [
`顺着“${topic}”继续追问`,
'先表明你的判断,再看对方反应',
'换个更轻松的语气把话接下去',
];
};
const buildNpcChatStoryMoment = (params: {
encounter: Encounter;
dialogue: NonNullable<StoryMoment['dialogue']>;
options: StoryOption[];
streaming: boolean;
turnCount: number;
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
displayMode: 'dialogue',
dialogue: params.dialogue,
streaming: params.streaming,
npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
) => {
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 existingDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const dialogueWithPlayer = [
...existingDialogue,
{
speaker: 'player' as const,
text: playerMessage,
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: dialogueWithPlayer,
options: [],
streaming: true,
turnCount: nextTurnCount,
}),
);
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,
}),
);
},
},
);
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 affinityTurn =
chatTurn.affinityDelta !== 0
? [
{
speaker: 'system' as const,
text:
chatTurn.affinityDelta > 0
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
: chatTurn.affinityText,
affinityDelta: chatTurn.affinityDelta,
},
]
: [];
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...dialogueWithPlayer,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
...affinityTurn,
],
options: buildNpcChatTurnOptions(
encounter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: buildFallbackNpcChatSuggestions(playerMessage),
),
streaming: false,
turnCount: nextTurnCount,
}),
);
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,
}),
);
return false;
} finally {
setIsLoading(false);
}
};
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
@@ -824,57 +1071,7 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter,
encounter,
npcState,
actionText: option.actionText,
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const affinityGain = chatOutcome.affinityGain;
const attributeSummary = chatOutcome.summary;
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain },
),
};
},
);
void commitNpcChatState(
nextState,
playerCharacter,
encounter,
option.actionText,
npcState.recruited
? buildCampCompanionChatResultText(
encounter,
affinityGain,
nextAffinity,
)
: buildNpcChatResultText(
encounter,
affinityGain,
nextAffinity,
attributeSummary,
),
option.functionId,
{
contextNpcStateOverride: npcState,
},
);
void handleNpcChatTurn(encounter, option.actionText);
return true;
}
case 'quest_accept': {
@@ -1029,5 +1226,7 @@ export function createStoryNpcEncounterActions({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
handleNpcChatTurn,
exitNpcChat,
};
}