This commit is contained in:
2026-04-26 14:27:48 +08:00
parent f68f4914ec
commit ea33413187
155 changed files with 8130 additions and 1740 deletions

View File

@@ -17,15 +17,18 @@ import {
applyQuestProgressFromSpar,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { getScenePresetById } from '../../data/scenePresets';
import { resolveFunctionOption } from '../../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
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 { createHistoryMoment } from '../../services/storyHistory';
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
import type {
Character,
Encounter,
@@ -50,15 +53,6 @@ type CommitGeneratedStateWithEncounterEntry = (
lastFunctionId?: string,
) => Promise<void> | void;
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type NpcInteractionFlowActions = {
openTradeModal: (encounter: Encounter, actionText: string) => void;
openGiftModal: (encounter: Encounter, actionText: string) => void;
@@ -82,6 +76,16 @@ type NpcChatDirective = {
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<
@@ -119,11 +123,9 @@ export function createStoryNpcEncounterActions({
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
generateStoryForState,
getStoryGenerationHostileNpcs,
getAvailableOptionsForState,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
cloneInventoryItemForOwner,
@@ -169,7 +171,6 @@ export function createStoryNpcEncounterActions({
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
@@ -185,7 +186,6 @@ export function createStoryNpcEncounterActions({
) => StoryOption[];
sortOptions: (options: StoryOption[]) => StoryOption[];
buildContinueAdventureOption: () => StoryOption;
getNpcEncounterKey: (encounter: Encounter) => string;
getResolvedNpcState: (
state: GameState,
encounter: Encounter,
@@ -315,13 +315,6 @@ export function createStoryNpcEncounterActions({
}`;
};
const buildPostQuestOfferChatSuggestions = (encounter: Encounter) =>
[
'那先继续聊聊你刚才没说完的部分',
'除了委托,你对眼前局势还有什么判断',
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
history
.slice(-6)
@@ -365,12 +358,6 @@ export function createStoryNpcEncounterActions({
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const chatDirective = resolveLimitedPrimaryNpcChatState({
state: params.nextState,
npcId: params.encounter.id ?? params.encounter.npcName,
affinity: reopenedNpcState.affinity,
nextTurnCount: 0,
});
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
@@ -389,6 +376,16 @@ export function createStoryNpcEncounterActions({
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({
@@ -591,6 +588,29 @@ export function createStoryNpcEncounterActions({
},
}));
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) =>
@@ -639,9 +659,9 @@ export function createStoryNpcEncounterActions({
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [
sanitizeNpcChatSuggestion(`你刚才那句是什么意思`),
sanitizeNpcChatSuggestion('我愿意先听你说完'),
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
sanitizeNpcChatSuggestion('你愿意再说清楚点吗'),
sanitizeNpcChatSuggestion('你别再避重就轻'),
];
};
@@ -706,11 +726,103 @@ export function createStoryNpcEncounterActions({
...fallbackSuggestions.filter(
(suggestion) => !seenActionTexts.has(suggestion),
),
].slice(0, 3);
];
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']>;
@@ -742,6 +854,9 @@ export function createStoryNpcEncounterActions({
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,
},
@@ -773,7 +888,6 @@ export function createStoryNpcEncounterActions({
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentNpcKey = encounter.id ?? encounter.npcName;
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
@@ -808,6 +922,124 @@ export function createStoryNpcEncounterActions({
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}看着你,像是在等你把话接下去。`;
@@ -968,6 +1200,14 @@ export function createStoryNpcEncounterActions({
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(
@@ -981,7 +1221,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective,
chatDirective: resolvedChatDirective,
openingSource,
}),
);
@@ -1006,6 +1246,11 @@ export function createStoryNpcEncounterActions({
}
const npcState = getResolvedNpcState(gameState, encounter);
const resolvedChatDirective = toNpcChatDirectiveWithFunctionOptions(
chatDirective ?? null,
encounter,
playerCharacter,
);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
@@ -1027,7 +1272,7 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: 0,
chatDirective,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
@@ -1067,12 +1312,12 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: 0,
chatDirective,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
},
chatDirective,
chatDirective: resolvedChatDirective,
npcInitiatesConversation: true,
},
);
@@ -1091,15 +1336,17 @@ export function createStoryNpcEncounterActions({
text: chatTurn.npcReply,
},
],
options: buildNpcChatTurnOptions(
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: openingOptions.map((option) => option.actionText),
chatTurn.functionSuggestions,
),
streaming: false,
turnCount: 0,
chatDirective,
chatDirective: resolvedChatDirective,
openingSource: 'npc_initiated',
}),
);
@@ -1122,6 +1369,9 @@ export function createStoryNpcEncounterActions({
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
options: {
forcePlayerExit?: boolean;
} = {},
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
@@ -1157,6 +1407,14 @@ export function createStoryNpcEncounterActions({
affinity: npcState.affinity,
nextTurnCount,
});
const chatDirective = toNpcChatDirectiveWithFunctionOptions(
limitedChatDirective,
encounter,
playerCharacter,
{
forcePlayerExit: options.forcePlayerExit,
},
);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
@@ -1172,7 +1430,7 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
chatDirective,
combatContext: currentCombatContext,
}),
);
@@ -1212,18 +1470,18 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
chatDirective,
combatContext: currentCombatContext,
}),
);
},
questOfferContext: limitedChatDirective
questOfferContext: chatDirective?.isHostileChat
? null
: {
state: gameState,
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
chatDirective,
combatContext: currentCombatContext,
},
);
@@ -1283,21 +1541,31 @@ export function createStoryNpcEncounterActions({
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
null;
const resolvedChatDirective = limitedChatDirective
const resolvedChatDirective = chatDirective
? {
sceneActId: limitedChatDirective.sceneActId ?? null,
sceneActId: chatDirective.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
limitedChatDirective.turnLimit ??
chatDirective.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
limitedChatDirective.remainingTurns ??
chatDirective.remainingTurns ??
null,
limitReason: limitedChatDirective.limitReason ?? null,
forceExitAfterTurn:
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 ??
limitedChatDirective.forceExitAfterTurn ??
chatDirective.forceExitAfterTurn ??
false,
}
: null;
@@ -1308,11 +1576,11 @@ export function createStoryNpcEncounterActions({
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
{
speaker: 'system' as const,
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
},
];
const progressionResult = buildPostNpcChatProgressionOptions(
encounter,
playerCharacter,
);
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: [buildContinueAdventureOption()],
@@ -1320,6 +1588,9 @@ export function createStoryNpcEncounterActions({
dialogue: closingDialogue,
streaming: false,
npcAffinityEffect: latestAffinityEffect,
deferredOptions: progressionResult.options,
deferredRuntimeState:
progressionResult.deferredRuntimeState ?? undefined,
});
return true;
}
@@ -1355,11 +1626,13 @@ export function createStoryNpcEncounterActions({
buildNpcChatStoryMoment({
encounter,
dialogue: nextDialogue,
options: buildNpcChatTurnOptions(
options: buildNpcChatMixedTurnOptions(
encounter,
playerCharacter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: buildFallbackNpcChatSuggestions(playerMessage),
chatTurn.functionSuggestions,
),
streaming: false,
turnCount: nextTurnCount,
@@ -1382,7 +1655,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
chatDirective,
combatContext: currentCombatContext,
}),
);
@@ -1392,7 +1665,7 @@ export function createStoryNpcEncounterActions({
}
};
const exitNpcChat = () => {
const continueAfterNpcChatClosure = async () => {
const playerCharacter = gameState.playerCharacter;
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
@@ -1400,48 +1673,50 @@ export function createStoryNpcEncounterActions({
}
setAiError(null);
setIsLoading(true);
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,
};
void (async () => {
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
setGameState(nextState);
setCurrentStory({
text: currentStory?.dialogue?.at(-1)?.text ?? currentStory?.text ?? '',
options: progressionResult.options,
displayMode: 'narrative',
});
return true;
};
try {
const postChatOptionCatalog = buildPostNpcChatOptionCatalog(
encounter,
playerCharacter,
);
const nextStory = await generateStoryForState({
state: gameState,
character: playerCharacter,
history: gameState.storyHistory,
choice: choiceText,
lastFunctionId: 'npc_chat',
optionCatalog: postChatOptionCatalog,
});
const nextHistory = [
...gameState.storyHistory,
createHistoryMoment(choiceText, 'action'),
createHistoryMoment(nextStory.text, 'result', nextStory.options),
];
const recoveredState = applyStoryReasoningRecovery({
...gameState,
storyHistory: nextHistory,
});
setGameState(recoveredState);
setCurrentStory(nextStory);
} catch (error) {
console.error(
'Failed to continue story after exiting npc chat:',
error,
);
setAiError(
error instanceof Error ? error.message : '退出聊天后的剧情推理失败',
);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
} finally {
setIsLoading(false);
}
})();
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
void handleNpcChatTurn(
encounter,
`我先结束这轮交谈,继续往前走。`,
{
forcePlayerExit: true,
},
);
return true;
};
@@ -1694,6 +1969,13 @@ export function createStoryNpcEncounterActions({
};
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;
@@ -1800,6 +2082,7 @@ export function createStoryNpcEncounterActions({
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
continueAfterNpcChatClosure,
replacePendingNpcQuestOffer,
abandonPendingNpcQuestOffer,
acceptPendingNpcQuestOffer,