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

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -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: {