This commit is contained in:
2026-04-19 20:33:18 +08:00
parent 692643136f
commit 67c584b4df
123 changed files with 11898 additions and 4082 deletions

View File

@@ -2,7 +2,9 @@ import type { Dispatch, SetStateAction } from 'react';
import { buildRelationState } from '../../data/attributeResolver';
import { hasEncounterEntity } from '../../data/encounterTransition';
import { NPC_PREVIEW_TALK_FUNCTION } from '../../data/functionCatalog';
import {
NPC_FIGHT_FUNCTION,
} from '../../data/functionCatalog';
import {
addInventoryItems,
applyStoryChoiceToStanceProfile,
@@ -33,6 +35,7 @@ import {
markQuestTurnedIn,
} from '../../data/questFlow';
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
import { resolveFunctionOption } from '../../data/stateFunctions';
import {
createSceneCallOutEncounter,
resolveSceneEncounterPreview,
@@ -578,7 +581,11 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions.slice(0, 3).map((suggestion) => ({
suggestions
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
.filter(Boolean)
.slice(0, 3)
.map((suggestion) => ({
functionId: 'npc_chat',
actionText: suggestion,
text: suggestion,
@@ -596,14 +603,57 @@ export function createStoryNpcEncounterActions({
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
}));
}));
const NPC_CHAT_SUGGESTION_LIMIT = 20;
const trimNpcChatSuggestion = (text: string) =>
text.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, '');
const clampNpcChatSuggestionLength = (text: string) =>
Array.from(text).slice(0, NPC_CHAT_SUGGESTION_LIMIT).join('');
const isDirectNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return false;
}
const behaviorPrefixes = [
'先',
'再',
'换个',
'顺着',
'试着',
'表明',
'告诉',
'问问',
'追问',
'继续聊',
'继续交谈',
'继续谈',
];
return !behaviorPrefixes.some((prefix) => normalizedText.startsWith(prefix));
};
const sanitizeNpcChatSuggestion = (text: string) => {
const normalizedText = trimNpcChatSuggestion(text);
if (!normalizedText) {
return '';
}
return clampNpcChatSuggestionLength(normalizedText);
};
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话';
const topic = clampNpcChatSuggestionLength(
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
);
return [
`顺着“${topic}”继续追问`,
'先表明你的判断,再看对方反应',
'换个更轻松的语气把话接下去',
sanitizeNpcChatSuggestion(`你刚才那句是什么意思`),
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
sanitizeNpcChatSuggestion('你愿意再说清楚点吗'),
];
};
@@ -628,9 +678,11 @@ export function createStoryNpcEncounterActions({
const buildNpcChatEntryOptions = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const candidateOptions = [
selectedOption,
...extraOptions,
...(currentStory?.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
),
@@ -639,12 +691,20 @@ export function createStoryNpcEncounterActions({
const seenActionTexts = new Set<string>();
for (const option of candidateOptions) {
const actionText = option.actionText?.trim();
if (!actionText || seenActionTexts.has(actionText)) {
const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
if (
!actionText ||
!isDirectNpcChatSuggestion(actionText) ||
seenActionTexts.has(actionText)
) {
continue;
}
seenActionTexts.add(actionText);
dedupedOptions.push(option);
dedupedOptions.push({
...option,
actionText,
text: actionText,
});
if (dedupedOptions.length === 3) {
return dedupedOptions;
}
@@ -681,28 +741,167 @@ export function createStoryNpcEncounterActions({
},
});
const collapseNpcChatOptions = (options: StoryOption[]) => {
let hasKeptNpcChat = false;
return options.filter((option) => {
if (option.functionId !== 'npc_chat') {
return true;
}
if (hasKeptNpcChat) {
return false;
}
hasKeptNpcChat = true;
return true;
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
affinity: number,
) => {
const hostilityText =
affinity <= -20
? '旧账就留到今天一起清。'
: affinity <= -10
? '我们之间已经没什么可谈的了。'
: '你再往前一步,我就当你是在挑衅。';
const contextText = encounter.context?.trim()
? `你居然还敢带着${encounter.context}的事来见我,`
: '';
return `${contextText}${hostilityText} 要么现在转身逃开,要么就拔刀。`;
};
const buildHostileNpcEscapeOption = (
character: Character,
): StoryOption => {
const functionContext = gameState.worldType
? {
worldType: gameState.worldType,
playerCharacter: character,
inBattle: false,
currentSceneId: gameState.currentScenePreset?.id ?? null,
currentSceneName: gameState.currentScenePreset?.name ?? null,
monsters: [],
playerHp: gameState.playerHp,
playerMaxHp: gameState.playerMaxHp,
playerMana: gameState.playerMana,
playerMaxMana: gameState.playerMaxMana,
}
: null;
const resolvedOption = functionContext
? resolveFunctionOption(
'battle_escape_breakout',
functionContext,
'逃跑',
)
: null;
if (resolvedOption) {
return {
...resolvedOption,
actionText: '逃跑',
text: '逃跑',
detailText: '',
};
}
return {
functionId: 'battle_escape_breakout',
actionText: '逃跑',
text: '逃跑',
detailText: '',
visuals: {
playerAnimation: AnimationState.RUN,
playerMoveMeters: -0.6,
playerOffsetY: 0,
playerFacing: 'left',
scrollWorld: true,
monsterChanges: [],
},
};
};
const buildHostileNpcFightOption = (encounter: Encounter): StoryOption => ({
functionId: NPC_FIGHT_FUNCTION.id,
actionText: '与他对战',
text: '与他对战',
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'fight',
},
});
const buildHostileNpcStoryMoment = (
encounter: Encounter,
character: Character,
affinity: number,
): StoryMoment => {
const declarationText = buildHostileNpcDeclarationText(
encounter,
affinity,
);
return {
text: declarationText,
options: [
buildHostileNpcEscapeOption(character),
buildHostileNpcFightOption(encounter),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: declarationText,
},
],
streaming: false,
};
};
const enterNpcChat = (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
) => {
const openingDialogue =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}\u770b\u7740\u4f60\uff0c\u50cf\u662f\u5728\u7b49\u4f60\u628a\u8bdd\u63a5\u4e0b\u53bb\u3002`,
},
];
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
setAiError(null);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: openingDialogue,
options: buildNpcChatEntryOptions(encounter, selectedOption),
options: buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
),
streaming: false,
turnCount: 0,
}),
@@ -890,32 +1089,102 @@ export function createStoryNpcEncounterActions({
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
const encounter = gameState.currentEncounter;
if (!playerCharacter || !isNpcEncounter(encounter)) {
return false;
}
setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
setIsLoading(true);
void (async () => {
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, 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);
}
})();
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
const npcState = getResolvedNpcState(gameState, encounter);
const nextState: GameState = {
...gameState,
npcInteractionActive: true,
};
void commitGeneratedState(
nextState,
playerCharacter,
actionText,
`${encounter.npcName} turns their attention toward you, as if waiting for you to speak first.`,
NPC_PREVIEW_TALK_FUNCTION.id,
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
setCurrentStory(
buildHostileNpcStoryMoment(encounter, playerCharacter, npcState.affinity),
);
return true;
}
const npcInteractionOptions =
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
const chatOptions = npcInteractionOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
return true;
const seedChatOption =
chatOptions[0] ??
({
functionId: 'npc_chat',
actionText: actionText || `${encounter.npcName}搭话`,
text: actionText || `${encounter.npcName}搭话`,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc' as const,
npcId: encounter.id ?? encounter.npcName,
action: 'chat' as const,
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
};
const resolveServerNpcStoryAction = async (params: {
@@ -958,17 +1227,51 @@ export function createStoryNpcEncounterActions({
}
};
const inferNpcInteractionFromOption = (
encounter: Encounter,
option: StoryOption,
): StoryOption['interaction'] => {
const npcId = encounter.id ?? encounter.npcName;
const actionByFunctionId: Record<string, StoryOption['interaction']> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: {
kind: 'npc',
npcId,
action: 'quest_turn_in',
questId: option.interaction?.questId,
},
};
return option.interaction ?? actionByFunctionId[option.functionId];
};
const handleNpcInteraction = (option: StoryOption) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !option.interaction || !isNpcEncounter(gameState.currentEncounter)) {
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
const encounter = gameState.currentEncounter;
const resolvedInteraction = inferNpcInteractionFromOption(encounter, option);
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
return false;
}
const resolvedOption = {
...option,
interaction: resolvedInteraction,
} satisfies StoryOption;
const npcState = getResolvedNpcState(gameState, encounter);
const interactionDecision = resolveNpcInteractionDecision(
gameState,
option,
resolvedOption,
);
if (interactionDecision.kind === 'trade_modal') {
@@ -994,7 +1297,7 @@ export function createStoryNpcEncounterActions({
return true;
}
switch (option.interaction.action) {
switch (resolvedOption.interaction.action) {
case 'help': {
setAiError(null);
setIsLoading(true);
@@ -1062,7 +1365,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1133,7 +1436,7 @@ export function createStoryNpcEncounterActions({
encounter,
buildNpcHelpCommitActionText(encounter, reward),
buildNpcHelpResultText(encounter, reward),
option.functionId,
resolvedOption.functionId,
{
contextNpcStateOverride:
nextState.npcStates[getNpcEncounterKey(encounter)] ?? null,
@@ -1154,23 +1457,23 @@ export function createStoryNpcEncounterActions({
if (
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
) {
void handleNpcChatTurn(encounter, option.actionText);
void handleNpcChatTurn(encounter, resolvedOption.actionText);
return true;
}
return enterNpcChat(encounter, option);
return enterNpcChat(encounter, resolvedOption);
}
case 'quest_accept': {
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
});
return true;
}
case 'quest_turn_in': {
const questId = option.interaction.questId;
const questId = resolvedOption.interaction.questId;
void resolveServerNpcStoryAction({
option,
option: resolvedOption,
encounter,
payload: questId
? {
@@ -1212,9 +1515,9 @@ export function createStoryNpcEncounterActions({
entryState,
resolvedState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
buildNpcLeaveResultText(encounter),
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1251,9 +1554,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`You lunge at ${encounter.npcName} with clear hostile intent, and the atmosphere turns dangerous at once.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}
@@ -1297,9 +1600,9 @@ export function createStoryNpcEncounterActions({
void commitGeneratedState(
nextState,
playerCharacter,
option.actionText,
resolvedOption.actionText,
`${encounter.npcName} salutes you and agrees to keep the spar controlled and respectful.`,
option.functionId,
resolvedOption.functionId,
);
return true;
}