@@ -21,6 +21,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
|
||||
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
|
||||
import { streamNpcChatTurn } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
@@ -74,6 +77,14 @@ type BuildStoryContextExtras = {
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
type NpcChatDirective = {
|
||||
sceneActId?: string | null;
|
||||
turnLimit?: number | null;
|
||||
remainingTurns?: number | null;
|
||||
limitReason?: 'negative_affinity' | null;
|
||||
forceExitAfterTurn?: boolean;
|
||||
} | null;
|
||||
|
||||
function isNpcEncounter(
|
||||
encounter: GameState['currentEncounter'],
|
||||
): encounter is Encounter {
|
||||
@@ -103,6 +114,7 @@ export function createStoryNpcEncounterActions({
|
||||
generateStoryForState,
|
||||
getStoryGenerationHostileNpcs,
|
||||
getAvailableOptionsForState,
|
||||
buildContinueAdventureOption,
|
||||
getNpcEncounterKey,
|
||||
getResolvedNpcState,
|
||||
updateNpcState,
|
||||
@@ -587,9 +599,11 @@ export function createStoryNpcEncounterActions({
|
||||
options: StoryOption[];
|
||||
streaming: boolean;
|
||||
turnCount: number;
|
||||
chatDirective?: NpcChatDirective;
|
||||
pendingQuestOffer?: {
|
||||
quest: QuestLogEntry;
|
||||
} | null;
|
||||
openingSource?: 'npc_initiated' | 'player_reply';
|
||||
}): StoryMoment => ({
|
||||
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
||||
options: params.options,
|
||||
@@ -601,6 +615,12 @@ export function createStoryNpcEncounterActions({
|
||||
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,
|
||||
pendingQuestOffer: params.pendingQuestOffer ?? null,
|
||||
},
|
||||
});
|
||||
@@ -622,17 +642,51 @@ export function createStoryNpcEncounterActions({
|
||||
});
|
||||
};
|
||||
|
||||
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
|
||||
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
|
||||
? [...currentStory.dialogue]
|
||||
: [
|
||||
{
|
||||
speaker: 'npc' as const,
|
||||
speakerName: encounter.npcName,
|
||||
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
|
||||
},
|
||||
];
|
||||
? sanitizeNpcChatDialogueHistory(
|
||||
encounter,
|
||||
currentStory.dialogue,
|
||||
turnCount,
|
||||
currentStory.npcChatState?.openingSource,
|
||||
)
|
||||
: [];
|
||||
|
||||
const buildHostileNpcDeclarationText = (
|
||||
encounter: Encounter,
|
||||
@@ -744,8 +798,10 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
selectedOption: StoryOption,
|
||||
extraOptions: StoryOption[] = [],
|
||||
chatDirective?: NpcChatDirective,
|
||||
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
|
||||
) => {
|
||||
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
|
||||
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
|
||||
|
||||
setAiError(null);
|
||||
setCurrentStory(
|
||||
@@ -759,11 +815,144 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
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 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,
|
||||
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,
|
||||
'【NPC 主动开场】',
|
||||
{
|
||||
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,
|
||||
openingSource: 'npc_initiated',
|
||||
}),
|
||||
);
|
||||
},
|
||||
chatDirective,
|
||||
npcInitiatesConversation: true,
|
||||
},
|
||||
);
|
||||
if (!chatTurn?.npcReply?.trim()) {
|
||||
throw new Error('NPC 主动开场结果为空');
|
||||
}
|
||||
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...existingDialogue,
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: chatTurn.npcReply,
|
||||
},
|
||||
],
|
||||
options: buildNpcChatTurnOptions(
|
||||
encounter,
|
||||
chatTurn.suggestions.length > 0
|
||||
? chatTurn.suggestions
|
||||
: openingOptions.map((option) => option.actionText),
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: 0,
|
||||
chatDirective,
|
||||
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,
|
||||
@@ -780,7 +969,12 @@ export function createStoryNpcEncounterActions({
|
||||
: null;
|
||||
const existingDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
? sanitizeNpcChatDialogueHistory(
|
||||
encounter,
|
||||
currentStory.dialogue,
|
||||
currentNpcChatState.turnCount ?? 0,
|
||||
currentNpcChatState.openingSource,
|
||||
)
|
||||
: [];
|
||||
const dialogueWithPlayer = [
|
||||
...existingDialogue,
|
||||
@@ -790,6 +984,12 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
];
|
||||
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
|
||||
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
||||
state: gameState,
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
affinity: npcState.affinity,
|
||||
nextTurnCount,
|
||||
});
|
||||
const openingCampContext = buildOpeningCampChatContext(
|
||||
gameState,
|
||||
playerCharacter,
|
||||
@@ -805,6 +1005,7 @@ export function createStoryNpcEncounterActions({
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -843,13 +1044,17 @@ export function createStoryNpcEncounterActions({
|
||||
options: [],
|
||||
streaming: true,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
},
|
||||
questOfferContext: {
|
||||
state: gameState,
|
||||
turnCount: nextTurnCount,
|
||||
},
|
||||
questOfferContext: limitedChatDirective
|
||||
? null
|
||||
: {
|
||||
state: gameState,
|
||||
turnCount: nextTurnCount,
|
||||
},
|
||||
chatDirective: limitedChatDirective,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -912,8 +1117,45 @@ export function createStoryNpcEncounterActions({
|
||||
const pendingQuest =
|
||||
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
|
||||
null;
|
||||
const resolvedChatDirective = limitedChatDirective
|
||||
? {
|
||||
sceneActId: limitedChatDirective.sceneActId ?? null,
|
||||
turnLimit:
|
||||
chatTurn.chatDirective?.turnLimit ??
|
||||
limitedChatDirective.turnLimit ??
|
||||
null,
|
||||
remainingTurns:
|
||||
chatTurn.chatDirective?.remainingTurns ??
|
||||
limitedChatDirective.remainingTurns ??
|
||||
null,
|
||||
limitReason: limitedChatDirective.limitReason ?? null,
|
||||
forceExitAfterTurn:
|
||||
chatTurn.chatDirective?.forceExit ??
|
||||
limitedChatDirective.forceExitAfterTurn ??
|
||||
false,
|
||||
}
|
||||
: null;
|
||||
const shouldForceExitAfterTurn =
|
||||
resolvedChatDirective?.forceExitAfterTurn === true;
|
||||
const pendingQuestIntroText =
|
||||
chatTurn.pendingQuestOffer?.introText?.trim() || '';
|
||||
if (shouldForceExitAfterTurn) {
|
||||
const closingDialogue = [
|
||||
...nextDialogue,
|
||||
{
|
||||
speaker: 'system' as const,
|
||||
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
|
||||
},
|
||||
];
|
||||
setCurrentStory({
|
||||
text: closingDialogue.map((turn) => turn.text).join('\n'),
|
||||
options: [buildContinueAdventureOption()],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: closingDialogue,
|
||||
streaming: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (pendingQuest) {
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
@@ -931,6 +1173,7 @@ export function createStoryNpcEncounterActions({
|
||||
options: buildPendingQuestOfferOptions(encounter),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: resolvedChatDirective,
|
||||
pendingQuestOffer: {
|
||||
quest: pendingQuest,
|
||||
},
|
||||
@@ -951,6 +1194,7 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: resolvedChatDirective,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -967,6 +1211,7 @@ export function createStoryNpcEncounterActions({
|
||||
),
|
||||
streaming: false,
|
||||
turnCount: nextTurnCount,
|
||||
chatDirective: limitedChatDirective,
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
@@ -1041,7 +1286,14 @@ export function createStoryNpcEncounterActions({
|
||||
setGameState(nextState);
|
||||
setAiError(null);
|
||||
|
||||
if (npcState.affinity < 0 || encounter.hostile) {
|
||||
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
||||
state: nextState,
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
affinity: npcState.affinity,
|
||||
nextTurnCount: 0,
|
||||
});
|
||||
|
||||
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
|
||||
setCurrentStory(
|
||||
buildHostileNpcStoryMoment(
|
||||
encounter,
|
||||
@@ -1079,7 +1331,22 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
} satisfies StoryOption);
|
||||
|
||||
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
|
||||
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
|
||||
void startNpcInitiatedOpening(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
chatOptions.slice(1),
|
||||
limitedChatDirective,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return enterNpcChat(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
chatOptions.slice(1),
|
||||
limitedChatDirective,
|
||||
);
|
||||
};
|
||||
|
||||
const resolveServerNpcStoryAction = async (params: {
|
||||
|
||||
Reference in New Issue
Block a user