1
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user