2226 lines
65 KiB
TypeScript
2226 lines
65 KiB
TypeScript
import type { Dispatch, SetStateAction } from 'react';
|
|
|
|
import { buildRelationState } from '../../data/attributeResolver';
|
|
import { NPC_FIGHT_FUNCTION } from '../../data/functionCatalog';
|
|
import {
|
|
addInventoryItems,
|
|
applyStoryChoiceToStanceProfile,
|
|
buildNpcSparResultText,
|
|
getNpcLootItems,
|
|
markNpcFirstMeaningfulContactResolved,
|
|
NPC_SPAR_AFFINITY_GAIN,
|
|
removeInventoryItem,
|
|
} from '../../data/npcInteractions';
|
|
import {
|
|
applyQuestProgressFromHostileNpcDefeat,
|
|
applyQuestProgressFromNpcTalk,
|
|
applyQuestProgressFromSpar,
|
|
} from '../../data/questFlow';
|
|
import { incrementGameRuntimeStats } from '../../data/runtimeStats';
|
|
import { getScenePresetById } from '../../data/scenePresets';
|
|
import { resolveFunctionOption } from '../../data/stateFunctions';
|
|
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 { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
|
import type {
|
|
Character,
|
|
Encounter,
|
|
GameState,
|
|
InventoryItem,
|
|
NpcBattleMode,
|
|
NpcBattleOutcome,
|
|
QuestLogEntry,
|
|
StoryMoment,
|
|
StoryOption,
|
|
} from '../../types';
|
|
import { AnimationState } from '../../types';
|
|
import type { CommitGeneratedState } from '../generatedState';
|
|
import { resolveRpgRuntimeChoice } from './rpgRuntimeStoryGateway';
|
|
|
|
type CommitGeneratedStateWithEncounterEntry = (
|
|
entryState: GameState,
|
|
resolvedState: GameState,
|
|
character: Character,
|
|
actionText: string,
|
|
resultText: string,
|
|
lastFunctionId?: string,
|
|
) => Promise<void> | void;
|
|
|
|
type NpcInteractionFlowActions = {
|
|
openTradeModal: (encounter: Encounter, actionText: string) => void;
|
|
openGiftModal: (encounter: Encounter, actionText: string) => void;
|
|
openRecruitModal: (encounter: Encounter, actionText: string) => void;
|
|
startRecruitmentSequence: (
|
|
encounter: Encounter,
|
|
actionText: string,
|
|
) => Promise<void>;
|
|
};
|
|
|
|
type BuildStoryContextExtras = {
|
|
lastFunctionId?: string | null;
|
|
openingCampBackground?: string | null;
|
|
openingCampDialogue?: string | null;
|
|
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
|
};
|
|
|
|
type NpcChatDirective = {
|
|
sceneActId?: string | null;
|
|
turnLimit?: number | null;
|
|
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<
|
|
NonNullable<StoryMoment['npcChatState']>['combatContext']
|
|
>;
|
|
|
|
function isNpcEncounter(
|
|
encounter: GameState['currentEncounter'],
|
|
): encounter is Encounter {
|
|
return Boolean(encounter?.kind === 'npc');
|
|
}
|
|
|
|
const NPC_CHAT_QUEST_OFFER_FUNCTION_IDS = {
|
|
view: 'npc_chat_quest_offer_view',
|
|
replace: 'npc_chat_quest_offer_replace',
|
|
abandon: 'npc_chat_quest_offer_abandon',
|
|
} as const;
|
|
|
|
type NpcChatQuestOfferPayloadAction =
|
|
keyof typeof NPC_CHAT_QUEST_OFFER_FUNCTION_IDS;
|
|
|
|
/**
|
|
* RPG runtime NPC 交互主链。
|
|
* 负责 NPC 对话、委托处理、战斗后续对话重开,以及需要服务端结算的正式动作派发。
|
|
*/
|
|
export function createStoryNpcEncounterActions({
|
|
gameState,
|
|
currentStory,
|
|
setGameState,
|
|
setCurrentStory,
|
|
setAiError,
|
|
setIsLoading,
|
|
appendHistory,
|
|
buildNpcStory,
|
|
buildOpeningCampChatContext,
|
|
buildStoryContextFromState,
|
|
buildFallbackStoryForState,
|
|
getStoryGenerationHostileNpcs,
|
|
getAvailableOptionsForState,
|
|
buildContinueAdventureOption,
|
|
getResolvedNpcState,
|
|
updateNpcState,
|
|
cloneInventoryItemForOwner,
|
|
resolveNpcInteractionDecision,
|
|
npcInteractionFlow,
|
|
}: {
|
|
gameState: GameState;
|
|
currentStory: StoryMoment | null;
|
|
setGameState: Dispatch<SetStateAction<GameState>>;
|
|
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
|
|
setAiError: Dispatch<SetStateAction<string | null>>;
|
|
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
|
commitGeneratedState: CommitGeneratedState;
|
|
commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry;
|
|
appendHistory: (
|
|
state: GameState,
|
|
actionText: string,
|
|
resultText: string,
|
|
) => GameState['storyHistory'];
|
|
buildNpcStory: (
|
|
state: GameState,
|
|
character: Character,
|
|
encounter: Encounter,
|
|
overrideText?: string,
|
|
) => StoryMoment;
|
|
buildOpeningCampChatContext: (
|
|
state: GameState,
|
|
character: Character,
|
|
encounter: Encounter,
|
|
) => BuildStoryContextExtras;
|
|
buildStoryContextFromState: (
|
|
state: GameState,
|
|
extras?: BuildStoryContextExtras,
|
|
) => StoryGenerationContext;
|
|
buildFallbackStoryForState: (
|
|
state: GameState,
|
|
character: Character,
|
|
fallbackText?: string,
|
|
) => StoryMoment;
|
|
buildDialogueStoryMoment: (
|
|
npcName: string,
|
|
text: string,
|
|
options: StoryOption[],
|
|
streaming?: boolean,
|
|
) => StoryMoment;
|
|
getStoryGenerationHostileNpcs: (
|
|
state: GameState,
|
|
) => GameState['sceneHostileNpcs'];
|
|
getTypewriterDelay: (char: string) => number;
|
|
getAvailableOptionsForState: (
|
|
state: GameState,
|
|
character: Character,
|
|
) => StoryOption[] | null;
|
|
sanitizeOptions: (
|
|
options: StoryOption[],
|
|
character: Character,
|
|
state: GameState,
|
|
) => StoryOption[];
|
|
sortOptions: (options: StoryOption[]) => StoryOption[];
|
|
buildContinueAdventureOption: () => StoryOption;
|
|
getResolvedNpcState: (
|
|
state: GameState,
|
|
encounter: Encounter,
|
|
) => GameState['npcStates'][string];
|
|
updateNpcState: (
|
|
state: GameState,
|
|
encounter: Encounter,
|
|
updater: (
|
|
npcState: GameState['npcStates'][string],
|
|
) => GameState['npcStates'][string],
|
|
) => GameState;
|
|
cloneInventoryItemForOwner: (
|
|
item: InventoryItem,
|
|
owner: 'player' | 'npc',
|
|
quantity?: number,
|
|
) => InventoryItem;
|
|
resolveNpcInteractionDecision: (
|
|
state: GameState,
|
|
option: StoryOption,
|
|
) => { kind: string };
|
|
npcInteractionFlow: NpcInteractionFlowActions;
|
|
}) {
|
|
const incrementRuntimeStats = (
|
|
state: GameState,
|
|
increments: Parameters<typeof incrementGameRuntimeStats>[1],
|
|
) => ({
|
|
...state,
|
|
runtimeStats: incrementGameRuntimeStats(state.runtimeStats, increments),
|
|
});
|
|
|
|
const buildNpcChatOption = (
|
|
encounter: Encounter,
|
|
actionText: string,
|
|
): StoryOption => ({
|
|
functionId: 'npc_chat',
|
|
actionText,
|
|
text: actionText,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
action: 'chat',
|
|
},
|
|
});
|
|
|
|
const buildNpcChatQuestOfferOption = (
|
|
encounter: Encounter,
|
|
functionId: string,
|
|
actionText: string,
|
|
action: NpcChatQuestOfferPayloadAction,
|
|
): StoryOption => ({
|
|
functionId,
|
|
actionText,
|
|
text: actionText,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
runtimePayload: {
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
npcChatQuestOfferAction: action,
|
|
},
|
|
});
|
|
|
|
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
|
|
buildNpcChatQuestOfferOption(
|
|
encounter,
|
|
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
|
|
'查看任务',
|
|
'view',
|
|
),
|
|
buildNpcChatQuestOfferOption(
|
|
encounter,
|
|
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.replace,
|
|
'更换任务',
|
|
'replace',
|
|
),
|
|
buildNpcChatQuestOfferOption(
|
|
encounter,
|
|
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.abandon,
|
|
'放弃任务',
|
|
'abandon',
|
|
),
|
|
];
|
|
|
|
const getPendingQuestOffer = (
|
|
story: StoryMoment | null,
|
|
encounter?: Encounter,
|
|
) => {
|
|
const pendingQuestOffer = story?.npcChatState?.pendingQuestOffer ?? null;
|
|
if (!pendingQuestOffer) {
|
|
return null;
|
|
}
|
|
|
|
if (
|
|
encounter &&
|
|
story?.npcChatState?.npcId !== (encounter.id ?? encounter.npcName)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return pendingQuestOffer;
|
|
};
|
|
|
|
const buildQuestOfferDialogueText = (
|
|
encounter: Encounter,
|
|
quest: QuestLogEntry,
|
|
) => {
|
|
const summaryText = quest.summary.trim() || quest.description.trim();
|
|
return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${
|
|
summaryText
|
|
? `如果你愿意,我想把这件事正式交给你:${summaryText}`
|
|
: '如果你愿意,我想把眼前这件事正式交给你。'
|
|
}`;
|
|
};
|
|
|
|
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
|
|
history
|
|
.slice(-6)
|
|
.map((moment) => moment.text.trim())
|
|
.filter(Boolean)
|
|
.slice(-4);
|
|
|
|
const buildNpcBattleChatCombatContext = (params: {
|
|
battleMode: NpcBattleMode;
|
|
resultText: string;
|
|
actionText: string;
|
|
historyBase: GameState['storyHistory'];
|
|
}): NpcChatCombatContext => {
|
|
const logLines = [
|
|
...extractRecentCombatLogLines(params.historyBase),
|
|
params.actionText,
|
|
params.resultText,
|
|
].filter((line, index, lines) => lines.indexOf(line) === index);
|
|
|
|
return {
|
|
summary:
|
|
params.battleMode === 'spar'
|
|
? `你们刚结束一场切磋,${params.resultText}`
|
|
: `你刚赢下这场交锋,${params.resultText}`,
|
|
logLines,
|
|
battleOutcome:
|
|
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
|
};
|
|
};
|
|
|
|
const reopenNpcChatAfterBattle = (params: {
|
|
nextState: GameState;
|
|
encounter: Encounter;
|
|
actionText: string;
|
|
resultText: string;
|
|
battleMode: NpcBattleMode;
|
|
}) => {
|
|
const playerCharacter = params.nextState.playerCharacter;
|
|
if (!playerCharacter) {
|
|
return false;
|
|
}
|
|
|
|
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
|
|
const baseStory = buildNpcStory(
|
|
params.nextState,
|
|
playerCharacter,
|
|
params.encounter,
|
|
params.resultText,
|
|
);
|
|
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
|
|
isNpcChatOptionForEncounter(option, params.encounter),
|
|
);
|
|
const fallbackChatOption =
|
|
baseChatOptions[0] ??
|
|
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
|
|
const combatContext = buildNpcBattleChatCombatContext({
|
|
battleMode: params.battleMode,
|
|
resultText: params.resultText,
|
|
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({
|
|
encounter: params.encounter,
|
|
dialogue: [
|
|
{
|
|
speaker: 'system',
|
|
text: params.resultText,
|
|
},
|
|
],
|
|
options: buildNpcChatEntryOptions(
|
|
params.encounter,
|
|
fallbackChatOption,
|
|
baseChatOptions.slice(1),
|
|
),
|
|
streaming: false,
|
|
turnCount: 0,
|
|
chatDirective,
|
|
openingSource: 'player_reply',
|
|
combatContext,
|
|
}),
|
|
);
|
|
return true;
|
|
};
|
|
|
|
const finalizeNpcBattleResult = (
|
|
state: GameState,
|
|
character: Character,
|
|
battleMode: NpcBattleMode,
|
|
battleOutcome: NpcBattleOutcome | null,
|
|
) => {
|
|
if (!state.currentBattleNpcId) return null;
|
|
|
|
const battleNpcId = state.currentBattleNpcId;
|
|
const npcState = state.npcStates[battleNpcId];
|
|
if (!npcState) return null;
|
|
const activeBattleHostiles = state.sceneHostileNpcs;
|
|
|
|
if (battleMode === 'spar' && battleOutcome === 'spar_complete') {
|
|
const nextAffinity = npcState.affinity + NPC_SPAR_AFFINITY_GAIN;
|
|
const restoredEncounter = state.sparReturnEncounter;
|
|
const progressedQuests = applyQuestProgressFromSpar(
|
|
state.quests,
|
|
battleNpcId,
|
|
);
|
|
const nextState = {
|
|
...state,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
currentEncounter: restoredEncounter,
|
|
npcInteractionActive: true,
|
|
sceneHostileNpcs: [],
|
|
npcStates: {
|
|
...state.npcStates,
|
|
[battleNpcId]: {
|
|
...markNpcFirstMeaningfulContactResolved(npcState),
|
|
affinity: nextAffinity,
|
|
relationState: buildRelationState(nextAffinity),
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
npcState.stanceProfile,
|
|
'npc_chat',
|
|
{ affinityGain: NPC_SPAR_AFFINITY_GAIN },
|
|
),
|
|
},
|
|
},
|
|
quests: progressedQuests,
|
|
playerX: 0,
|
|
playerHp: state.sparPlayerHpBefore ?? state.playerHp,
|
|
playerMaxHp: state.sparPlayerMaxHpBefore ?? state.playerMaxHp,
|
|
playerFacing: 'right' as const,
|
|
animationState: state.animationState,
|
|
activeCombatEffects: [],
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
};
|
|
|
|
return {
|
|
nextState,
|
|
resultText: buildNpcSparResultText(
|
|
activeBattleHostiles[0]?.name ?? '对方',
|
|
NPC_SPAR_AFFINITY_GAIN,
|
|
nextAffinity,
|
|
),
|
|
};
|
|
}
|
|
|
|
const lootItems = getNpcLootItems(npcState, character).map((item) =>
|
|
cloneInventoryItemForOwner(item, 'player'),
|
|
);
|
|
const defeatedHostileNpcIds = activeBattleHostiles.map(
|
|
(hostileNpc) => hostileNpc.id,
|
|
);
|
|
const restoredEncounter =
|
|
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
|
|
activeBattleHostiles[0]?.encounter ??
|
|
({
|
|
id: battleNpcId,
|
|
kind: 'npc',
|
|
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
|
|
npcDescription: '',
|
|
npcAvatar: '',
|
|
context: '',
|
|
hostile: false,
|
|
} satisfies Encounter);
|
|
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
|
|
state.quests,
|
|
state.currentScenePreset?.id ?? null,
|
|
defeatedHostileNpcIds,
|
|
);
|
|
let nextNpcInventory = npcState.inventory;
|
|
|
|
for (const item of lootItems) {
|
|
nextNpcInventory = removeInventoryItem(nextNpcInventory, item.id, 1);
|
|
}
|
|
|
|
const nextState: GameState = appendStoryEngineCarrierMemory(
|
|
incrementRuntimeStats(
|
|
{
|
|
...state,
|
|
currentBattleNpcId: null,
|
|
currentNpcBattleMode: null,
|
|
currentNpcBattleOutcome: null,
|
|
currentEncounter: restoredEncounter,
|
|
npcInteractionActive: true,
|
|
sceneHostileNpcs: [],
|
|
playerInventory: addInventoryItems(state.playerInventory, lootItems),
|
|
quests: progressedQuests,
|
|
npcStates: {
|
|
...state.npcStates,
|
|
[battleNpcId]: {
|
|
...markNpcFirstMeaningfulContactResolved(npcState),
|
|
affinity: npcState.affinity,
|
|
relationState: buildRelationState(npcState.affinity),
|
|
recruited: false,
|
|
inventory: nextNpcInventory,
|
|
},
|
|
},
|
|
playerX: 0,
|
|
playerFacing: 'right' as const,
|
|
animationState: state.animationState,
|
|
activeCombatEffects: [],
|
|
scrollWorld: false,
|
|
inBattle: false,
|
|
sparReturnEncounter: null,
|
|
sparPlayerHpBefore: null,
|
|
sparPlayerMaxHpBefore: null,
|
|
sparStoryHistoryBefore: null,
|
|
},
|
|
{
|
|
hostileNpcsDefeated: defeatedHostileNpcIds.length,
|
|
},
|
|
),
|
|
lootItems,
|
|
);
|
|
|
|
const lootText =
|
|
lootItems.length > 0
|
|
? lootItems.map((item) => item.name).join(', ')
|
|
: '无战利品';
|
|
const defeatedNames =
|
|
activeBattleHostiles.map((hostileNpc) => hostileNpc.name).join('、') ||
|
|
battleNpcId ||
|
|
'对手';
|
|
return {
|
|
nextState,
|
|
resultText: `${defeatedNames}已经败下阵来。胜利奖励:${lootText}。`,
|
|
};
|
|
};
|
|
|
|
const buildNpcChatTurnOptions = (
|
|
encounter: Encounter,
|
|
suggestions: string[],
|
|
): StoryOption[] =>
|
|
suggestions
|
|
.map((suggestion) => sanitizeNpcChatSuggestion(suggestion))
|
|
.filter(Boolean)
|
|
.slice(0, 3)
|
|
.map((suggestion) => ({
|
|
functionId: 'npc_chat',
|
|
actionText: suggestion,
|
|
text: suggestion,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
action: 'chat',
|
|
},
|
|
}));
|
|
|
|
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) =>
|
|
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 = clampNpcChatSuggestionLength(
|
|
sanitizeNpcChatSuggestion(playerMessage) || '刚才那句',
|
|
);
|
|
return [
|
|
sanitizeNpcChatSuggestion('我愿意先听你说完'),
|
|
sanitizeNpcChatSuggestion(`这件事和${topic}有关吗`),
|
|
sanitizeNpcChatSuggestion('你别再避重就轻'),
|
|
];
|
|
};
|
|
|
|
const isNpcChatOptionForEncounter = (
|
|
option: StoryOption,
|
|
encounter: Encounter,
|
|
) => {
|
|
if (option.functionId !== 'npc_chat') {
|
|
return false;
|
|
}
|
|
|
|
if (option.interaction?.kind !== 'npc') {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
option.interaction.action === 'chat' &&
|
|
option.interaction.npcId === (encounter.id ?? encounter.npcName)
|
|
);
|
|
};
|
|
|
|
const buildNpcChatEntryOptions = (
|
|
encounter: Encounter,
|
|
selectedOption: StoryOption,
|
|
extraOptions: StoryOption[] = [],
|
|
) => {
|
|
const candidateOptions = [
|
|
selectedOption,
|
|
...extraOptions,
|
|
...(currentStory?.options ?? []).filter((option) =>
|
|
isNpcChatOptionForEncounter(option, encounter),
|
|
),
|
|
];
|
|
const dedupedOptions: StoryOption[] = [];
|
|
const seenActionTexts = new Set<string>();
|
|
|
|
for (const option of candidateOptions) {
|
|
const actionText = sanitizeNpcChatSuggestion(option.actionText ?? '');
|
|
if (
|
|
!actionText ||
|
|
!isDirectNpcChatSuggestion(actionText) ||
|
|
seenActionTexts.has(actionText)
|
|
) {
|
|
continue;
|
|
}
|
|
seenActionTexts.add(actionText);
|
|
dedupedOptions.push({
|
|
...option,
|
|
actionText,
|
|
text: actionText,
|
|
});
|
|
if (dedupedOptions.length === 3) {
|
|
return dedupedOptions;
|
|
}
|
|
}
|
|
|
|
const fallbackSuggestions = buildFallbackNpcChatSuggestions(
|
|
currentStory?.text?.trim() || selectedOption.actionText,
|
|
);
|
|
const mergedSuggestions = [
|
|
...dedupedOptions.map((option) => option.actionText),
|
|
...fallbackSuggestions.filter(
|
|
(suggestion) => !seenActionTexts.has(suggestion),
|
|
),
|
|
];
|
|
|
|
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']>;
|
|
options: StoryOption[];
|
|
streaming: boolean;
|
|
turnCount: number;
|
|
chatDirective?: NpcChatDirective;
|
|
pendingQuestOffer?: {
|
|
quest: QuestLogEntry;
|
|
} | null;
|
|
openingSource?: 'npc_initiated' | 'player_reply';
|
|
combatContext?: NpcChatCombatContext | null;
|
|
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
|
|
}): StoryMoment => ({
|
|
text: params.dialogue.map((turn) => turn.text).join('\n'),
|
|
options: params.options,
|
|
displayMode: 'dialogue',
|
|
dialogue: params.dialogue,
|
|
streaming: params.streaming,
|
|
npcAffinityEffect: params.latestAffinityEffect ?? null,
|
|
npcChatState: {
|
|
npcId: params.encounter.id ?? params.encounter.npcName,
|
|
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,
|
|
terminationMode: params.chatDirective?.terminationMode ?? null,
|
|
terminationReason: params.chatDirective?.terminationReason ?? null,
|
|
isHostileChat: params.chatDirective?.isHostileChat ?? false,
|
|
pendingQuestOffer: params.pendingQuestOffer ?? null,
|
|
combatContext: params.combatContext ?? null,
|
|
},
|
|
});
|
|
|
|
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 buildPostNpcChatOptionCatalog = (
|
|
encounter: Encounter,
|
|
playerCharacter: Character,
|
|
) => {
|
|
const resolvedStateOptions =
|
|
collapseNpcChatOptions(
|
|
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
|
);
|
|
const currentStoryOptions = currentStory?.options ?? [];
|
|
const currentChatOptions = currentStoryOptions.filter((option) =>
|
|
isNpcChatOptionForEncounter(option, encounter),
|
|
);
|
|
const nonChatCurrentOptions = currentStoryOptions.filter(
|
|
(option) => !currentChatOptions.includes(option),
|
|
);
|
|
const nonChatResolvedOptions = resolvedStateOptions.filter(
|
|
(option) => !isNpcChatOptionForEncounter(option, encounter),
|
|
);
|
|
const mergedOptions: StoryOption[] = [];
|
|
const seenOptionIdentity = new Set<string>();
|
|
|
|
const pushUniqueOption = (option: StoryOption) => {
|
|
const optionIdentity = [
|
|
option.functionId,
|
|
option.interaction?.kind ?? '',
|
|
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
|
|
option.interaction?.kind === 'npc' ? option.interaction.action : '',
|
|
].join('::');
|
|
|
|
if (seenOptionIdentity.has(optionIdentity)) {
|
|
return;
|
|
}
|
|
seenOptionIdentity.add(optionIdentity);
|
|
mergedOptions.push(option);
|
|
};
|
|
|
|
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
|
|
nonChatCurrentOptions.forEach(pushUniqueOption);
|
|
nonChatResolvedOptions.forEach(pushUniqueOption);
|
|
|
|
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}看着你,像是在等你把话接下去。`;
|
|
|
|
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
|
|
? sanitizeNpcChatDialogueHistory(
|
|
encounter,
|
|
currentStory.dialogue,
|
|
turnCount,
|
|
currentStory.npcChatState?.openingSource,
|
|
)
|
|
: [];
|
|
|
|
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,
|
|
actionText = '逃跑',
|
|
runtimePayload?: StoryOption['runtimePayload'],
|
|
): 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: actionText,
|
|
detailText: '',
|
|
runtimePayload: {
|
|
...(resolvedOption.runtimePayload ?? {}),
|
|
...(runtimePayload ?? {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
functionId: 'battle_escape_breakout',
|
|
actionText,
|
|
text: actionText,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.RUN,
|
|
playerMoveMeters: -0.6,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'left',
|
|
scrollWorld: true,
|
|
monsterChanges: [],
|
|
},
|
|
runtimePayload,
|
|
};
|
|
};
|
|
|
|
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
|
|
const currentScene = gameState.currentScenePreset;
|
|
const worldType = gameState.worldType;
|
|
const options: StoryOption[] = [];
|
|
const seenSceneIds = new Set<string>();
|
|
|
|
if (worldType && currentScene) {
|
|
for (const connection of currentScene.connections ?? []) {
|
|
if (!connection.sceneId || seenSceneIds.has(connection.sceneId)) {
|
|
continue;
|
|
}
|
|
|
|
seenSceneIds.add(connection.sceneId);
|
|
const targetScene = getScenePresetById(worldType, connection.sceneId);
|
|
const targetSceneName =
|
|
targetScene?.name ??
|
|
connection.summary?.trim() ??
|
|
connection.sceneId;
|
|
|
|
options.push(
|
|
buildHostileNpcEscapeOption(
|
|
character,
|
|
`逃往${targetSceneName}`,
|
|
{
|
|
targetSceneId: connection.sceneId,
|
|
escapeTargetSceneId: connection.sceneId,
|
|
escapeEntry: 'from_left',
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
options.push(
|
|
buildHostileNpcEscapeOption(
|
|
character,
|
|
'逃回当前场景起点',
|
|
{
|
|
targetSceneId: currentScene.id,
|
|
escapeTargetSceneId: currentScene.id,
|
|
escapeReturnToSceneStart: true,
|
|
escapeEntry: 'from_left',
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return options.length > 0
|
|
? options
|
|
: [buildHostileNpcEscapeOption(character)];
|
|
};
|
|
|
|
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: [
|
|
buildHostileNpcFightOption(encounter),
|
|
...buildHostileNpcEscapeOptions(character),
|
|
],
|
|
displayMode: 'dialogue',
|
|
dialogue: [
|
|
{
|
|
speaker: 'npc',
|
|
speakerName: encounter.npcName,
|
|
text: declarationText,
|
|
},
|
|
],
|
|
streaming: false,
|
|
};
|
|
};
|
|
|
|
const shouldUseHostileNpcChatClosureOptions = (
|
|
directive: NpcChatDirective,
|
|
affinity: number,
|
|
) =>
|
|
affinity < 0 ||
|
|
directive?.limitReason === 'negative_affinity' ||
|
|
directive?.terminationMode === 'hostile_model' ||
|
|
directive?.isHostileChat === true;
|
|
|
|
// 负好感聊天结束后不能回到普通冒险分支,只允许玩家立刻战斗或逃离。
|
|
const buildNpcChatClosureOptions = (
|
|
encounter: Encounter,
|
|
character: Character,
|
|
directive: NpcChatDirective,
|
|
affinity: number,
|
|
) => {
|
|
if (!shouldUseHostileNpcChatClosureOptions(directive, affinity)) {
|
|
return [buildContinueAdventureOption()];
|
|
}
|
|
|
|
const fightOption = buildHostileNpcFightOption(encounter);
|
|
|
|
return [
|
|
{
|
|
...fightOption,
|
|
actionText: '战斗',
|
|
text: '战斗',
|
|
},
|
|
...buildHostileNpcEscapeOptions(character),
|
|
];
|
|
};
|
|
|
|
const enterNpcChat = (
|
|
encounter: Encounter,
|
|
selectedOption: StoryOption,
|
|
extraOptions: StoryOption[] = [],
|
|
chatDirective?: NpcChatDirective,
|
|
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(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: openingDialogue,
|
|
options: buildNpcChatEntryOptions(
|
|
encounter,
|
|
selectedOption,
|
|
extraOptions,
|
|
),
|
|
streaming: false,
|
|
turnCount: 0,
|
|
chatDirective: resolvedChatDirective,
|
|
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 resolvedChatDirective = toNpcChatDirectiveWithFunctionOptions(
|
|
chatDirective ?? null,
|
|
encounter,
|
|
playerCharacter,
|
|
);
|
|
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: resolvedChatDirective,
|
|
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,
|
|
'',
|
|
{
|
|
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: resolvedChatDirective,
|
|
openingSource: 'npc_initiated',
|
|
}),
|
|
);
|
|
},
|
|
chatDirective: resolvedChatDirective,
|
|
npcInitiatesConversation: true,
|
|
},
|
|
);
|
|
if (!chatTurn?.npcReply?.trim()) {
|
|
throw new Error('NPC 主动开场结果为空');
|
|
}
|
|
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: [
|
|
...existingDialogue,
|
|
{
|
|
speaker: 'npc',
|
|
speakerName: encounter.npcName,
|
|
text: chatTurn.npcReply,
|
|
},
|
|
],
|
|
options: buildNpcChatMixedTurnOptions(
|
|
encounter,
|
|
playerCharacter,
|
|
chatTurn.suggestions.length > 0
|
|
? chatTurn.suggestions
|
|
: openingOptions.map((option) => option.actionText),
|
|
chatTurn.functionSuggestions,
|
|
),
|
|
streaming: false,
|
|
turnCount: 0,
|
|
chatDirective: resolvedChatDirective,
|
|
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,
|
|
options: {
|
|
forcePlayerExit?: boolean;
|
|
} = {},
|
|
) => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
if (!playerCharacter || !gameState.worldType) {
|
|
return false;
|
|
}
|
|
|
|
const npcState = getResolvedNpcState(gameState, encounter);
|
|
const currentNpcChatState =
|
|
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
|
|
? currentStory.npcChatState
|
|
: null;
|
|
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
|
|
const existingDialogue =
|
|
currentStory?.dialogue && currentNpcChatState
|
|
? sanitizeNpcChatDialogueHistory(
|
|
encounter,
|
|
currentStory.dialogue,
|
|
currentNpcChatState.turnCount ?? 0,
|
|
currentNpcChatState.openingSource,
|
|
)
|
|
: [];
|
|
const dialogueWithPlayer = [
|
|
...existingDialogue,
|
|
{
|
|
speaker: 'player' as const,
|
|
text: playerMessage,
|
|
},
|
|
];
|
|
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
|
|
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
|
state: gameState,
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
affinity: npcState.affinity,
|
|
nextTurnCount,
|
|
});
|
|
const chatDirective = toNpcChatDirectiveWithFunctionOptions(
|
|
limitedChatDirective,
|
|
encounter,
|
|
playerCharacter,
|
|
{
|
|
forcePlayerExit: options.forcePlayerExit,
|
|
},
|
|
);
|
|
const openingCampContext = buildOpeningCampChatContext(
|
|
gameState,
|
|
playerCharacter,
|
|
encounter,
|
|
);
|
|
|
|
setAiError(null);
|
|
setIsLoading(true);
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: dialogueWithPlayer,
|
|
options: [],
|
|
streaming: true,
|
|
turnCount: nextTurnCount,
|
|
chatDirective,
|
|
combatContext: currentCombatContext,
|
|
}),
|
|
);
|
|
|
|
try {
|
|
const chatTurn = await streamNpcChatTurn(
|
|
gameState.worldType,
|
|
playerCharacter,
|
|
encounter,
|
|
getStoryGenerationHostileNpcs(gameState),
|
|
gameState.storyHistory,
|
|
buildStoryContextFromState(gameState, {
|
|
lastFunctionId: 'npc_chat',
|
|
...openingCampContext,
|
|
encounterNpcStateOverride: npcState,
|
|
}),
|
|
existingDialogue,
|
|
playerMessage,
|
|
{
|
|
affinity: npcState.affinity,
|
|
chattedCount: npcState.chattedCount,
|
|
recruited: npcState.recruited,
|
|
},
|
|
{
|
|
onReplyUpdate: (text) => {
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: [
|
|
...dialogueWithPlayer,
|
|
{
|
|
speaker: 'npc',
|
|
speakerName: encounter.npcName,
|
|
text,
|
|
},
|
|
],
|
|
options: [],
|
|
streaming: true,
|
|
turnCount: nextTurnCount,
|
|
chatDirective,
|
|
combatContext: currentCombatContext,
|
|
}),
|
|
);
|
|
},
|
|
questOfferContext: chatDirective?.isHostileChat
|
|
? null
|
|
: {
|
|
state: gameState,
|
|
turnCount: nextTurnCount,
|
|
},
|
|
chatDirective,
|
|
combatContext: currentCombatContext,
|
|
},
|
|
);
|
|
|
|
let nextAffinity = npcState.affinity;
|
|
const nextState = updateNpcState(
|
|
gameState,
|
|
encounter,
|
|
(currentNpcState) => {
|
|
nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta;
|
|
return {
|
|
...markNpcFirstMeaningfulContactResolved(currentNpcState),
|
|
affinity: nextAffinity,
|
|
relationState: buildRelationState(nextAffinity),
|
|
chattedCount: currentNpcState.chattedCount + 1,
|
|
stanceProfile: applyStoryChoiceToStanceProfile(
|
|
currentNpcState.stanceProfile,
|
|
'npc_chat',
|
|
{ affinityGain: chatTurn.affinityDelta },
|
|
),
|
|
};
|
|
},
|
|
);
|
|
const finalHistory = appendHistory(
|
|
gameState,
|
|
playerMessage,
|
|
chatTurn.npcReply,
|
|
);
|
|
const finalState = {
|
|
...nextState,
|
|
quests: applyQuestProgressFromNpcTalk(
|
|
nextState.quests,
|
|
encounter.id ?? encounter.npcName,
|
|
),
|
|
storyHistory: finalHistory,
|
|
};
|
|
setGameState(finalState);
|
|
|
|
// 好感变化只保留为一次性表现事件,不再插入聊天消息流。
|
|
const latestAffinityEffect =
|
|
chatTurn.affinityDelta !== 0
|
|
? {
|
|
eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
delta: chatTurn.affinityDelta,
|
|
}
|
|
: null;
|
|
|
|
const nextDialogue = [
|
|
...dialogueWithPlayer,
|
|
{
|
|
speaker: 'npc' as const,
|
|
speakerName: encounter.npcName,
|
|
text: chatTurn.npcReply,
|
|
},
|
|
];
|
|
const pendingQuest =
|
|
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
|
|
null;
|
|
const resolvedChatDirective = chatDirective
|
|
? {
|
|
sceneActId: chatDirective.sceneActId ?? null,
|
|
turnLimit:
|
|
chatTurn.chatDirective?.turnLimit ??
|
|
chatDirective.turnLimit ??
|
|
null,
|
|
remainingTurns:
|
|
chatTurn.chatDirective?.remainingTurns ??
|
|
chatDirective.remainingTurns ??
|
|
null,
|
|
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 ??
|
|
chatDirective.forceExitAfterTurn ??
|
|
false,
|
|
}
|
|
: null;
|
|
const shouldForceExitAfterTurn =
|
|
resolvedChatDirective?.forceExitAfterTurn === true;
|
|
const pendingQuestIntroText =
|
|
chatTurn.pendingQuestOffer?.introText?.trim() || '';
|
|
if (shouldForceExitAfterTurn) {
|
|
const closingDialogue = [
|
|
...nextDialogue,
|
|
];
|
|
const shouldUseHostileClosureOptions =
|
|
shouldUseHostileNpcChatClosureOptions(
|
|
resolvedChatDirective,
|
|
Math.min(npcState.affinity, nextAffinity),
|
|
);
|
|
const progressionResult = shouldUseHostileClosureOptions
|
|
? null
|
|
: buildPostNpcChatProgressionOptions(encounter, playerCharacter);
|
|
setCurrentStory({
|
|
text: closingDialogue.map((turn) => turn.text).join('\n'),
|
|
options: buildNpcChatClosureOptions(
|
|
encounter,
|
|
playerCharacter,
|
|
resolvedChatDirective,
|
|
Math.min(npcState.affinity, nextAffinity),
|
|
),
|
|
displayMode: 'dialogue',
|
|
dialogue: closingDialogue,
|
|
streaming: false,
|
|
npcAffinityEffect: latestAffinityEffect,
|
|
deferredOptions: progressionResult?.options,
|
|
deferredRuntimeState:
|
|
progressionResult?.deferredRuntimeState ?? undefined,
|
|
});
|
|
return true;
|
|
}
|
|
if (pendingQuest) {
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: [
|
|
...nextDialogue,
|
|
{
|
|
speaker: 'npc',
|
|
speakerName: encounter.npcName,
|
|
text:
|
|
pendingQuestIntroText ||
|
|
buildQuestOfferDialogueText(encounter, pendingQuest),
|
|
},
|
|
],
|
|
options: buildPendingQuestOfferOptions(encounter),
|
|
streaming: false,
|
|
turnCount: nextTurnCount,
|
|
chatDirective: resolvedChatDirective,
|
|
pendingQuestOffer: {
|
|
quest: pendingQuest,
|
|
},
|
|
combatContext: currentCombatContext,
|
|
latestAffinityEffect,
|
|
}),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: nextDialogue,
|
|
options: buildNpcChatMixedTurnOptions(
|
|
encounter,
|
|
playerCharacter,
|
|
chatTurn.suggestions.length > 0
|
|
? chatTurn.suggestions
|
|
: buildFallbackNpcChatSuggestions(playerMessage),
|
|
chatTurn.functionSuggestions,
|
|
),
|
|
streaming: false,
|
|
turnCount: nextTurnCount,
|
|
chatDirective: resolvedChatDirective,
|
|
combatContext: currentCombatContext,
|
|
latestAffinityEffect,
|
|
}),
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to stream npc chat turn:', error);
|
|
setAiError(error instanceof Error ? error.message : 'NPC 聊天续写失败');
|
|
setCurrentStory(
|
|
buildNpcChatStoryMoment({
|
|
encounter,
|
|
dialogue: dialogueWithPlayer,
|
|
options: buildNpcChatTurnOptions(
|
|
encounter,
|
|
buildFallbackNpcChatSuggestions(playerMessage),
|
|
),
|
|
streaming: false,
|
|
turnCount: nextTurnCount,
|
|
chatDirective,
|
|
combatContext: currentCombatContext,
|
|
}),
|
|
);
|
|
return false;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const continueAfterNpcChatClosure = async () => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
const encounter = gameState.currentEncounter;
|
|
if (!playerCharacter || !isNpcEncounter(encounter)) {
|
|
return false;
|
|
}
|
|
|
|
setAiError(null);
|
|
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,
|
|
};
|
|
|
|
setGameState(nextState);
|
|
setCurrentStory({
|
|
text: currentStory?.dialogue?.at(-1)?.text ?? currentStory?.text ?? '',
|
|
options: progressionResult.options,
|
|
displayMode: 'narrative',
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const exitNpcChat = () => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
const encounter = gameState.currentEncounter;
|
|
if (!playerCharacter || !isNpcEncounter(encounter)) {
|
|
return false;
|
|
}
|
|
|
|
void handleNpcChatTurn(
|
|
encounter,
|
|
`我先结束这轮交谈,继续往前走。`,
|
|
{
|
|
forcePlayerExit: true,
|
|
},
|
|
);
|
|
|
|
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,
|
|
};
|
|
|
|
setGameState(nextState);
|
|
setAiError(null);
|
|
|
|
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
|
state: nextState,
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
affinity: npcState.affinity,
|
|
nextTurnCount: 0,
|
|
});
|
|
|
|
const npcInteractionOptions =
|
|
getAvailableOptionsForState(nextState, playerCharacter) ?? [];
|
|
const chatOptions = npcInteractionOptions.filter((option) =>
|
|
isNpcChatOptionForEncounter(option, encounter),
|
|
);
|
|
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);
|
|
|
|
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
|
|
void startNpcInitiatedOpening(
|
|
encounter,
|
|
seedChatOption,
|
|
chatOptions.slice(1),
|
|
limitedChatDirective,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
|
|
setCurrentStory(
|
|
buildHostileNpcStoryMoment(
|
|
encounter,
|
|
playerCharacter,
|
|
npcState.affinity,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return enterNpcChat(
|
|
encounter,
|
|
seedChatOption,
|
|
chatOptions.slice(1),
|
|
limitedChatDirective,
|
|
);
|
|
};
|
|
|
|
const resolveServerNpcStoryAction = async (params: {
|
|
option: StoryOption;
|
|
payload?: Record<string, unknown>;
|
|
}) => {
|
|
const playerCharacter = gameState.playerCharacter;
|
|
if (
|
|
!playerCharacter ||
|
|
!gameState.worldType ||
|
|
gameState.currentScene !== 'Story'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
setAiError(null);
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
|
|
gameState,
|
|
currentStory,
|
|
option: params.option,
|
|
payload: params.payload,
|
|
});
|
|
|
|
setGameState(hydratedSnapshot.gameState);
|
|
setCurrentStory(nextStory);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to resolve npc story action on the server:', error);
|
|
setAiError(error instanceof Error ? error.message : 'NPC 动作执行失败');
|
|
if (!currentStory) {
|
|
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
|
|
}
|
|
return false;
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const replacePendingNpcQuestOffer = () => {
|
|
const encounter = gameState.currentEncounter;
|
|
const pendingQuestOffer = isNpcEncounter(encounter)
|
|
? getPendingQuestOffer(currentStory, encounter)
|
|
: null;
|
|
if (!encounter || !pendingQuestOffer) {
|
|
return false;
|
|
}
|
|
void resolveServerNpcStoryAction({
|
|
option: {
|
|
functionId: 'npc_chat_quest_offer_replace',
|
|
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
|
|
text: `你请${encounter.npcName}换一份更合适的委托。`,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
action: 'quest_offer_replace',
|
|
},
|
|
},
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const abandonPendingNpcQuestOffer = () => {
|
|
const encounter = gameState.currentEncounter;
|
|
const pendingQuestOffer = isNpcEncounter(encounter)
|
|
? getPendingQuestOffer(currentStory, encounter)
|
|
: null;
|
|
if (!encounter || !pendingQuestOffer) {
|
|
return false;
|
|
}
|
|
void resolveServerNpcStoryAction({
|
|
option: {
|
|
functionId: 'npc_chat_quest_offer_abandon',
|
|
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
|
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
action: 'quest_offer_abandon',
|
|
},
|
|
},
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const acceptPendingNpcQuestOffer = () => {
|
|
const encounter = gameState.currentEncounter;
|
|
const pendingQuestOffer = isNpcEncounter(encounter)
|
|
? getPendingQuestOffer(currentStory, encounter)
|
|
: null;
|
|
if (!encounter || !pendingQuestOffer) {
|
|
return null;
|
|
}
|
|
|
|
const questId = pendingQuestOffer.quest.id?.trim();
|
|
if (!questId) {
|
|
return null;
|
|
}
|
|
|
|
void resolveServerNpcStoryAction({
|
|
option: {
|
|
functionId: 'npc_quest_accept',
|
|
actionText: `你答应接下${encounter.npcName}的委托。`,
|
|
text: `你答应接下${encounter.npcName}的委托。`,
|
|
detailText: '',
|
|
visuals: {
|
|
playerAnimation: AnimationState.IDLE,
|
|
playerMoveMeters: 0,
|
|
playerOffsetY: 0,
|
|
playerFacing: 'right',
|
|
scrollWorld: false,
|
|
monsterChanges: [],
|
|
},
|
|
interaction: {
|
|
kind: 'npc',
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
action: 'quest_accept',
|
|
},
|
|
},
|
|
});
|
|
|
|
return questId;
|
|
};
|
|
|
|
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?.kind === 'npc'
|
|
? option.interaction.questId
|
|
: undefined,
|
|
},
|
|
};
|
|
|
|
return option.interaction ?? actionByFunctionId[option.functionId];
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
const encounter = gameState.currentEncounter;
|
|
const resolvedInteraction = inferNpcInteractionFromOption(
|
|
encounter,
|
|
option,
|
|
);
|
|
if (!resolvedInteraction || resolvedInteraction.kind !== 'npc') {
|
|
return false;
|
|
}
|
|
const resolvedOption = {
|
|
...option,
|
|
interaction: resolvedInteraction,
|
|
} satisfies StoryOption;
|
|
const interactionDecision = resolveNpcInteractionDecision(
|
|
gameState,
|
|
resolvedOption,
|
|
);
|
|
|
|
if (interactionDecision.kind === 'trade_modal') {
|
|
npcInteractionFlow.openTradeModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'gift_modal') {
|
|
npcInteractionFlow.openGiftModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'recruit_modal') {
|
|
npcInteractionFlow.openRecruitModal(encounter, option.actionText);
|
|
return true;
|
|
}
|
|
|
|
if (interactionDecision.kind === 'recruit_immediate') {
|
|
void npcInteractionFlow.startRecruitmentSequence(
|
|
encounter,
|
|
option.actionText,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
switch (resolvedOption.interaction.action) {
|
|
case 'help': {
|
|
void resolveServerNpcStoryAction({
|
|
option: resolvedOption,
|
|
});
|
|
return true;
|
|
}
|
|
case 'chat': {
|
|
if (
|
|
currentStory?.npcChatState?.npcId ===
|
|
(encounter.id ?? encounter.npcName)
|
|
) {
|
|
void handleNpcChatTurn(encounter, resolvedOption.actionText);
|
|
return true;
|
|
}
|
|
|
|
const npcState = getResolvedNpcState(gameState, encounter);
|
|
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
|
|
state: gameState,
|
|
npcId: encounter.id ?? encounter.npcName,
|
|
affinity: npcState.affinity,
|
|
nextTurnCount: 0,
|
|
});
|
|
|
|
if (!npcState.firstMeaningfulContactResolved) {
|
|
void startNpcInitiatedOpening(
|
|
encounter,
|
|
resolvedOption,
|
|
[],
|
|
limitedChatDirective,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return enterNpcChat(encounter, resolvedOption);
|
|
}
|
|
case 'quest_accept': {
|
|
void resolveServerNpcStoryAction({
|
|
option: resolvedOption,
|
|
});
|
|
return true;
|
|
}
|
|
case 'quest_turn_in': {
|
|
const questId = resolvedOption.interaction.questId;
|
|
void resolveServerNpcStoryAction({
|
|
option: resolvedOption,
|
|
payload: questId
|
|
? {
|
|
questId,
|
|
}
|
|
: undefined,
|
|
});
|
|
return true;
|
|
}
|
|
case 'leave': {
|
|
void resolveServerNpcStoryAction({
|
|
option: resolvedOption,
|
|
});
|
|
return true;
|
|
}
|
|
case 'fight':
|
|
case 'spar': {
|
|
void resolveServerNpcStoryAction({
|
|
option: resolvedOption,
|
|
});
|
|
return true;
|
|
}
|
|
default:
|
|
return false;
|
|
}
|
|
};
|
|
|
|
return {
|
|
enterNpcInteraction,
|
|
handleNpcInteraction,
|
|
finalizeNpcBattleResult,
|
|
reopenNpcChatAfterBattle,
|
|
handleNpcChatTurn,
|
|
exitNpcChat,
|
|
continueAfterNpcChatClosure,
|
|
replacePendingNpcQuestOffer,
|
|
abandonPendingNpcQuestOffer,
|
|
acceptPendingNpcQuestOffer,
|
|
};
|
|
}
|
|
|
|
export type UseRpgRuntimeNpcInteractionParams = Parameters<
|
|
typeof createStoryNpcEncounterActions
|
|
>[0];
|
|
export type RpgRuntimeNpcInteractionResult = ReturnType<
|
|
typeof createStoryNpcEncounterActions
|
|
>;
|
|
|
|
export const createRpgRuntimeNpcEncounterActions =
|
|
createStoryNpcEncounterActions;
|
|
|
|
export function useRpgRuntimeNpcInteraction(
|
|
params: UseRpgRuntimeNpcInteractionParams,
|
|
) {
|
|
return createStoryNpcEncounterActions(params);
|
|
}
|