Files
Genarrative/src/hooks/rpg-runtime-story/npcInteraction.ts
2026-04-28 19:36:39 +08:00

578 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
Dispatch,
SetStateAction,
} from 'react';
import { useState } from 'react';
import {
getCharacterById,
} from '../../data/characterPresets';
import {
buildNpcGiftModalState,
buildNpcRecruitModalState,
buildNpcTradeModalIntroText,
} from '../../data/functionCatalog';
import {
buildNpcTradeTransactionActionText,
} from '../../data/npcInteractions';
import { streamNpcRecruitDialogue } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import type {
Character,
Encounter,
GameState,
InventoryItem,
RuntimeNpcTradeItemView,
StoryMoment,
StoryOption,
} from '../../types';
import { resolveRpgRuntimeChoice } from '.';
import type {
GiftModalState,
RecruitModalState,
StoryGenerationNpcUi,
TradeModalState,
} from './uiTypes';
type GenerateStoryForState = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
type StoryNpcInteractionRuntime = {
currentStory: StoryMoment | null;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildStoryContextFromState: (
state: GameState,
extras?: {
lastFunctionId?: string | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;
buildFallbackStoryForState: (
state: GameState,
character: Character,
fallbackText?: string,
) => StoryMoment;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
generateStoryForState: GenerateStoryForState;
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getTypewriterDelay: (char: string) => number;
};
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
const releaseLine = releasedCompanionName
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
return [
'你:我不是一时起意,我想正式邀请你加入我的队伍。',
`${encounter.npcName}:你这话说得够直接,不过我更在意,你是否真的清楚自己接下来要面对什么。`,
releaseLine,
`${encounter.npcName}:既然你已经想清楚了,那我就跟你走这一程,看看你能把这条路走到哪里。`,
].join('\n');
}
function normalizeRecruitDialogue(
encounter: Encounter,
dialogueText: string,
releasedCompanionName?: string | null,
) {
const rawLines = dialogueText
.replace(/\r/g, '')
.split('\n')
.map(line => line.trim())
.filter(Boolean);
const refusalPattern = /|||||||||||/u;
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
const npcPrefix = `${encounter.npcName}`;
const playerPrefix = '你:';
const releaseLine = releasedCompanionName
? `${playerPrefix}我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
: `${playerPrefix}我会把接下来的路安排好,和你并肩前行。`;
const defaultLines = [
`${playerPrefix}我是真心邀请你加入队伍,不是随口一提。`,
`${npcPrefix}你的意思我已经听明白了,我更在意你是否真的准备好了。`,
releaseLine,
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
];
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
const firstDefaultLine = defaultLines[0];
if (firstDefaultLine) {
workingLines.unshift(firstDefaultLine);
}
}
if (!workingLines.some(line => line.startsWith(npcPrefix))) {
const secondDefaultLine = defaultLines[1];
if (secondDefaultLine) {
workingLines.push(secondDefaultLine);
}
}
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
const lastWorkingLine = workingLines[workingLines.length - 1];
if (
workingLines.length === 0
|| !lastWorkingLine?.startsWith(npcPrefix)
|| refusalPattern.test(lastWorkingLine)
) {
workingLines.push(acceptanceLine);
} else {
workingLines[workingLines.length - 1] = acceptanceLine;
}
const compactLines = workingLines.slice(0, 5);
if (compactLines[compactLines.length - 1] !== acceptanceLine) {
compactLines.push(acceptanceLine);
}
return compactLines.slice(0, 6).join('\n');
}
function normalizeTradeQuantity(quantity: number) {
return Math.max(1, Math.floor(Number.isFinite(quantity) ? quantity : 1));
}
export function useStoryNpcInteractionFlow({
gameState,
setGameState,
getNpcEncounterKey,
getResolvedNpcState: _getResolvedNpcState,
updateNpcState: _updateNpcState,
cloneInventoryItemForOwner: _cloneInventoryItemForOwner,
runtime,
}: {
gameState: GameState;
setGameState: Dispatch<SetStateAction<GameState>>;
getNpcEncounterKey: (encounter: Encounter) => string;
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;
runtime: StoryNpcInteractionRuntime;
}) {
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
const resolveRecruitmentOnServer = async (params: {
encounter: Encounter;
actionText: string;
releasedNpcId?: string | null;
preludeText?: string | null;
}) => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
try {
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory: runtime.currentStory,
option: {
functionId: 'npc_recruit',
actionText: params.actionText,
interaction: {
kind: 'npc',
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
action: 'recruit',
},
},
payload: {
...(params.releasedNpcId
? {
releaseNpcId: params.releasedNpcId,
}
: {}),
...(params.preludeText
? {
preludeText: params.preludeText,
}
: {}),
},
});
setGameState(hydratedSnapshot.gameState);
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc recruit action on the server:', error);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 招募执行失败',
);
if (!runtime.currentStory) {
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(gameState, playerCharacter),
);
}
return false;
} finally {
runtime.setIsLoading(false);
}
};
const startRecruitmentSequence = async (
encounter: Encounter,
actionText: string,
releasedNpcId?: string | null,
) => {
if (!gameState.playerCharacter) return;
const releasedCompanionName = releasedNpcId
? (() => {
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
return releasedCompanion?.characterId
? getCharacterById(releasedCompanion.characterId)?.name ?? null
: null;
})()
: null;
const provisionalState = {
...gameState,
};
const recruitPromptSummary = releasedCompanionName
? `如果对方答应加入,你会先让${releasedCompanionName}离队,为新同伴腾出位置。`
: '如果对方答应加入,你们将立刻结伴同行。';
setRecruitModal(null);
runtime.setAiError(null);
runtime.setIsLoading(true);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
let dialogueText = '';
let streamedTargetText = '';
let displayedText = '';
let streamCompleted = false;
const typewriterPromise = (async () => {
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
if (displayedText.length >= streamedTargetText.length) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
const nextChar = streamedTargetText[displayedText.length];
if (!nextChar) {
await new Promise(resolve => window.setTimeout(resolve, 40));
continue;
}
displayedText += nextChar;
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, displayedText, [], true));
await new Promise(resolve => window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)));
}
})();
try {
dialogueText = await streamNpcRecruitDialogue(
gameState.worldType!,
gameState.playerCharacter,
encounter,
runtime.getStoryGenerationHostileNpcs(provisionalState),
gameState.storyHistory,
runtime.buildStoryContextFromState(provisionalState, {
lastFunctionId: 'npc_recruit',
}),
actionText,
recruitPromptSummary,
{
onUpdate: text => {
streamedTargetText = text;
},
},
);
streamedTargetText = dialogueText;
streamCompleted = true;
await typewriterPromise;
} catch (error) {
streamCompleted = true;
await typewriterPromise;
console.error('Failed to stream recruit dialogue:', error);
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
}
const finalDialogueText = normalizeRecruitDialogue(
encounter,
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
releasedCompanionName,
);
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
await new Promise(resolve => window.setTimeout(resolve, 260));
await resolveRecruitmentOnServer({
encounter,
actionText,
releasedNpcId,
preludeText: finalDialogueText,
});
};
const getRuntimeTradeItems = (
mode: 'buy' | 'sell',
): RuntimeNpcTradeItemView[] =>
mode === 'buy'
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
const findRuntimeTradeItem = (modal: TradeModalState) => {
const itemId =
modal.mode === 'buy'
? modal.selectedNpcItemId
: modal.selectedPlayerItemId;
if (!itemId) return null;
return (
getRuntimeTradeItems(modal.mode).find((item) => item.itemId === itemId) ??
null
);
};
const findRuntimeGiftItem = (itemId: string | null) => {
if (!itemId) return null;
return (
gameState.runtimeNpcInteraction?.gift.items.find(
(item) => item.itemId === itemId,
) ?? null
);
};
const openTradeModal = (encounter: Encounter, actionText: string) => {
setTradeModal(
{
encounter,
actionText,
introText: buildNpcTradeModalIntroText(encounter),
mode: 'buy',
selectedNpcItemId:
gameState.runtimeNpcInteraction?.trade.buyItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
gameState.runtimeNpcInteraction?.trade.sellItems.find(
(item) => item.canSubmit,
)?.itemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
},
);
};
const openGiftModal = (encounter: Encounter, actionText: string) => {
const selectedItemId =
gameState.runtimeNpcInteraction?.gift.items.find((item) => item.canSubmit)
?.itemId ??
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
null;
setGiftModal(
buildNpcGiftModalState(
encounter,
actionText,
selectedItemId,
),
);
};
const openRecruitModal = (encounter: Encounter, actionText: string) => {
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
};
const clearNpcInteractionUi = () => {
setTradeModal(null);
setGiftModal(null);
setRecruitModal(null);
};
const resolveServerNpcAction = async (params: {
encounter: Encounter;
actionText: string;
functionId: string;
action: 'trade' | 'gift' | 'quest_accept' | 'quest_turn_in';
payload?: Record<string, unknown>;
}) => {
const playerCharacter = gameState.playerCharacter;
if (
!playerCharacter ||
!gameState.worldType ||
gameState.currentScene !== 'Story'
) {
return false;
}
runtime.setAiError(null);
runtime.setIsLoading(true);
try {
const { hydratedSnapshot, nextStory } = await resolveRpgRuntimeChoice({
gameState,
currentStory: runtime.currentStory,
option: {
functionId: params.functionId,
actionText: params.actionText,
interaction: {
kind: 'npc',
npcId: params.encounter.id ?? getNpcEncounterKey(params.encounter),
action: params.action,
},
},
payload: params.payload,
});
setGameState(hydratedSnapshot.gameState);
runtime.setCurrentStory(nextStory);
return true;
} catch (error) {
console.error('Failed to resolve npc runtime action on the server:', error);
runtime.setAiError(
error instanceof Error ? error.message : 'NPC 交互执行失败',
);
if (!runtime.currentStory) {
runtime.setCurrentStory(
runtime.buildFallbackStoryForState(gameState, playerCharacter),
);
}
return false;
} finally {
runtime.setIsLoading(false);
}
};
const confirmTrade = () => {
if (!tradeModal || !gameState.playerCharacter) return;
const encounter = tradeModal.encounter;
const quantity = normalizeTradeQuantity(tradeModal.selectedQuantity);
const tradeItem = findRuntimeTradeItem(tradeModal);
if (!tradeItem) return;
setTradeModal(null);
void resolveServerNpcAction({
encounter,
actionText: buildNpcTradeTransactionActionText({
encounter,
mode: tradeModal.mode,
item: tradeItem.item,
quantity,
}),
functionId: 'npc_trade',
action: 'trade',
payload: {
mode: tradeModal.mode,
itemId: tradeItem.itemId,
quantity,
},
});
};
const confirmGift = () => {
if (!giftModal || !gameState.playerCharacter) return;
const encounter = giftModal.encounter;
const giftItem = findRuntimeGiftItem(giftModal.selectedItemId);
if (!giftItem) return;
setGiftModal(null);
void resolveServerNpcAction({
encounter,
actionText: `${giftItem.item.name}赠给${encounter.npcName}`,
functionId: 'npc_gift',
action: 'gift',
payload: {
itemId: giftItem.itemId,
},
});
};
return {
npcUi: {
tradeModal,
giftModal,
recruitModal,
setTradeMode: (mode: 'buy' | 'sell') => setTradeModal(current => {
if (!current) return current;
return {
...current,
mode,
selectedNpcItemId:
current.selectedNpcItemId ??
gameState.runtimeNpcInteraction?.trade.buyItems[0]?.itemId ??
null,
selectedPlayerItemId:
current.selectedPlayerItemId ??
gameState.runtimeNpcInteraction?.trade.sellItems[0]?.itemId ??
null,
selectedQuantity: 1,
};
}),
selectTradeNpcItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedNpcItemId: itemId,
selectedQuantity: 1,
};
}),
selectTradePlayerItem: (itemId: string) => setTradeModal(current => {
if (!current) return current;
return {
...current,
selectedPlayerItemId: itemId,
selectedQuantity: 1,
};
}),
setTradeQuantity: (quantity: number) => setTradeModal(current => current
? {
...current,
selectedQuantity: normalizeTradeQuantity(quantity),
}
: current),
closeTradeModal: () => setTradeModal(null),
confirmTrade,
selectGiftItem: (itemId: string) => setGiftModal(current => current ? { ...current, selectedItemId: itemId } : current),
closeGiftModal: () => setGiftModal(null),
confirmGift,
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
closeRecruitModal: () => setRecruitModal(null),
confirmRecruit: () => {
if (!recruitModal) return;
void startRecruitmentSequence(
recruitModal.encounter,
recruitModal.actionText,
recruitModal.selectedReleaseNpcId,
);
},
} satisfies StoryGenerationNpcUi,
openTradeModal,
openGiftModal,
openRecruitModal,
startRecruitmentSequence,
clearNpcInteractionUi,
};
}