2232 lines
65 KiB
TypeScript
2232 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;
|
||
|
||
// 中文注释:只有正式胜利或切磋完成才允许进入 NPC 战后收束;
|
||
// 若当前是 fight_defeat,则必须交回死亡复活链,不能继续发奖励或推进剧情幕。
|
||
if (battleMode === 'fight' && battleOutcome !== 'fight_victory') {
|
||
return null;
|
||
}
|
||
|
||
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);
|
||
}
|