1
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import {type Dispatch, type SetStateAction,useState} from 'react';
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react';
|
||||
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
} from '../../services/aiService';
|
||||
import type {StoryGenerationContext} from '../../services/aiTypes';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
Character,
|
||||
CharacterChatRecord,
|
||||
@@ -47,12 +47,17 @@ export interface CharacterChatUi {
|
||||
sendDraft: () => void;
|
||||
}
|
||||
|
||||
export function getCharacterChatRecord(state: GameState, characterId: string): CharacterChatRecord {
|
||||
return state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
};
|
||||
export function getCharacterChatRecord(
|
||||
state: GameState,
|
||||
characterId: string,
|
||||
): CharacterChatRecord {
|
||||
return (
|
||||
state.characterChats[characterId] ?? {
|
||||
history: [],
|
||||
summary: '',
|
||||
updatedAt: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function trimCharacterChatHistory(history: CharacterChatTurn[]) {
|
||||
@@ -66,7 +71,10 @@ export function buildLocalCharacterChatSummary(
|
||||
) {
|
||||
const latestTurns = history
|
||||
.slice(-4)
|
||||
.map(turn => `${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`)
|
||||
.map(
|
||||
(turn) =>
|
||||
`${turn.speaker === 'player' ? '玩家' : character.name}: ${turn.text}`,
|
||||
)
|
||||
.join(' ');
|
||||
|
||||
const currentSummary = latestTurns
|
||||
@@ -111,7 +119,9 @@ type CharacterChatTargetStatus = {
|
||||
affinity?: number | null;
|
||||
};
|
||||
|
||||
function buildTargetStatus(target: CharacterChatTarget): CharacterChatTargetStatus {
|
||||
function buildTargetStatus(
|
||||
target: CharacterChatTarget,
|
||||
): CharacterChatTargetStatus {
|
||||
return {
|
||||
roleLabel: target.roleLabel,
|
||||
hp: target.hp,
|
||||
@@ -129,9 +139,13 @@ export function useCharacterChatFlow({
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
buildStoryContextFromState: (state: GameState) => StoryGenerationContext;
|
||||
buildStoryContextFromState: (
|
||||
state: GameState,
|
||||
extras?: { currentStory?: null },
|
||||
) => StoryGenerationContext;
|
||||
}) {
|
||||
const [characterChatModal, setCharacterChatModal] = useState<CharacterChatModalState | null>(null);
|
||||
const [characterChatModal, setCharacterChatModal] =
|
||||
useState<CharacterChatModalState | null>(null);
|
||||
|
||||
const loadCharacterChatSuggestions = async (
|
||||
target: CharacterChatTarget,
|
||||
@@ -139,7 +153,7 @@ export function useCharacterChatFlow({
|
||||
summary: string,
|
||||
) => {
|
||||
if (!gameState.worldType || !gameState.playerCharacter) {
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -151,7 +165,7 @@ export function useCharacterChatFlow({
|
||||
return;
|
||||
}
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -172,7 +186,7 @@ export function useCharacterChatFlow({
|
||||
buildTargetStatus(target),
|
||||
);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -183,7 +197,7 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate character chat suggestions:', error);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -213,7 +227,11 @@ export function useCharacterChatFlow({
|
||||
};
|
||||
|
||||
const sendCharacterChatDraft = async () => {
|
||||
if (!characterChatModal || !gameState.worldType || !gameState.playerCharacter) {
|
||||
if (
|
||||
!characterChatModal ||
|
||||
!gameState.worldType ||
|
||||
!gameState.playerCharacter
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,7 +241,10 @@ export function useCharacterChatFlow({
|
||||
}
|
||||
|
||||
const target = characterChatModal.target;
|
||||
const existingRecord = getCharacterChatRecord(gameState, target.character.id);
|
||||
const existingRecord = getCharacterChatRecord(
|
||||
gameState,
|
||||
target.character.id,
|
||||
);
|
||||
const baseMessages = trimCharacterChatHistory(characterChatModal.messages);
|
||||
const nextMessages = trimCharacterChatHistory([
|
||||
...baseMessages,
|
||||
@@ -233,12 +254,12 @@ export function useCharacterChatFlow({
|
||||
},
|
||||
]);
|
||||
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
draft: '',
|
||||
messages: [...nextMessages, {speaker: 'character', text: ''}],
|
||||
messages: [...nextMessages, { speaker: 'character', text: '' }],
|
||||
suggestions: [],
|
||||
isSending: true,
|
||||
isLoadingSuggestions: true,
|
||||
@@ -261,12 +282,12 @@ export function useCharacterChatFlow({
|
||||
draft,
|
||||
buildTargetStatus(target),
|
||||
{
|
||||
onUpdate: text => {
|
||||
setCharacterChatModal(current =>
|
||||
onUpdate: (text) => {
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
messages: [...nextMessages, {speaker: 'character', text}],
|
||||
messages: [...nextMessages, { speaker: 'character', text }],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
@@ -275,7 +296,7 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to stream character panel chat reply:', error);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -283,10 +304,12 @@ export function useCharacterChatFlow({
|
||||
messages: baseMessages,
|
||||
isSending: false,
|
||||
isLoadingSuggestions: false,
|
||||
error: error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions: current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
error:
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
suggestions:
|
||||
current.suggestions.length > 0
|
||||
? current.suggestions
|
||||
: buildLocalCharacterChatSuggestions(target.character),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
@@ -315,7 +338,11 @@ export function useCharacterChatFlow({
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to summarize character chat:', error);
|
||||
nextSummary = buildLocalCharacterChatSummary(target.character, finalMessages, existingRecord.summary);
|
||||
nextSummary = buildLocalCharacterChatSummary(
|
||||
target.character,
|
||||
finalMessages,
|
||||
existingRecord.summary,
|
||||
);
|
||||
}
|
||||
|
||||
const nextRecord: CharacterChatRecord = {
|
||||
@@ -324,10 +351,10 @@ export function useCharacterChatFlow({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setGameState(current =>
|
||||
setGameState((current) =>
|
||||
buildCharacterChatRecordUpdate(current, target.character.id, nextRecord),
|
||||
);
|
||||
setCharacterChatModal(current =>
|
||||
setCharacterChatModal((current) =>
|
||||
current && current.target.character.id === target.character.id
|
||||
? {
|
||||
...current,
|
||||
@@ -346,8 +373,14 @@ export function useCharacterChatFlow({
|
||||
modal: characterChatModal,
|
||||
openChat: openCharacterChat,
|
||||
closeChat: () => setCharacterChatModal(null),
|
||||
setDraft: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
useSuggestion: (value: string) => setCharacterChatModal(current => (current ? {...current, draft: value} : current)),
|
||||
setDraft: (value: string) =>
|
||||
setCharacterChatModal((current) =>
|
||||
current ? { ...current, draft: value } : current,
|
||||
),
|
||||
useSuggestion: (value: string) =>
|
||||
setCharacterChatModal((current) =>
|
||||
current ? { ...current, draft: value } : current,
|
||||
),
|
||||
refreshSuggestions: () => {
|
||||
if (!characterChatModal) {
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
@@ -25,7 +22,12 @@ import {
|
||||
} from './storyChoiceRuntime';
|
||||
import type { BattleRewardSummary } from './uiTypes';
|
||||
|
||||
type RuntimeStatsIncrements = Partial<Pick<GameState['runtimeStats'], 'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'>>;
|
||||
type RuntimeStatsIncrements = Partial<
|
||||
Pick<
|
||||
GameState['runtimeStats'],
|
||||
'hostileNpcsDefeated' | 'questsAccepted' | 'itemsUsed' | 'scenesTraveled'
|
||||
>
|
||||
>;
|
||||
|
||||
type BuildFallbackStoryForState = (
|
||||
state: GameState,
|
||||
@@ -63,6 +65,7 @@ type BuildStoryContextFromState = (
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -115,7 +118,11 @@ export function createStoryChoiceActions({
|
||||
setAiError: Dispatch<SetStateAction<string | null>>;
|
||||
setIsLoading: Dispatch<SetStateAction<boolean>>;
|
||||
setBattleReward: Dispatch<SetStateAction<BattleRewardSummary | null>>;
|
||||
buildResolvedChoiceState: (state: GameState, option: StoryOption, character: Character) => ResolvedChoiceState;
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
character: Character,
|
||||
) => ResolvedChoiceState;
|
||||
playResolvedChoice: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -127,14 +134,24 @@ export function createStoryChoiceActions({
|
||||
buildStoryFromResponse: BuildStoryFromResponse;
|
||||
buildFallbackStoryForState: BuildFallbackStoryForState;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getAvailableOptionsForState: (state: GameState, character: Character) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getAvailableOptionsForState: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => StoryOption[] | null;
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getResolvedSceneHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
buildNpcStory: BuildNpcStory;
|
||||
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
|
||||
updateQuestLog: UpdateQuestLog;
|
||||
incrementRuntimeStats: IncrementRuntimeStats;
|
||||
getCampCompanionTravelScene?: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
|
||||
getCampCompanionTravelScene?: (
|
||||
state: GameState,
|
||||
character: Character,
|
||||
) => GameState['currentScenePreset'] | null;
|
||||
enterNpcInteraction: (encounter: Encounter, actionText: string) => boolean;
|
||||
handleNpcInteraction: (option: StoryOption) => boolean | Promise<boolean>;
|
||||
handleTreasureInteraction: (
|
||||
@@ -149,8 +166,12 @@ export function createStoryChoiceActions({
|
||||
) => { nextState: GameState; resultText: string } | null;
|
||||
isContinueAdventureOption: (option: StoryOption) => boolean;
|
||||
isCampTravelHomeOption?: (option: StoryOption) => boolean;
|
||||
isRegularNpcEncounter: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isNpcEncounter?: (encounter: GameState['currentEncounter']) => encounter is Encounter;
|
||||
isRegularNpcEncounter: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
isNpcEncounter?: (
|
||||
encounter: GameState['currentEncounter'],
|
||||
) => encounter is Encounter;
|
||||
npcPreviewTalkFunctionId: string;
|
||||
fallbackCompanionName?: string;
|
||||
turnVisualMs: number;
|
||||
@@ -160,7 +181,10 @@ export function createStoryChoiceActions({
|
||||
if (!gameState.worldType || !character || isLoading) return;
|
||||
if (option.disabled) return;
|
||||
|
||||
if (currentStory?.deferredOptions?.length && isContinueAdventureOption(option)) {
|
||||
if (
|
||||
currentStory?.deferredOptions?.length &&
|
||||
isContinueAdventureOption(option)
|
||||
) {
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
...gameState,
|
||||
@@ -209,9 +233,9 @@ export function createStoryChoiceActions({
|
||||
}
|
||||
|
||||
if (
|
||||
option.functionId === npcPreviewTalkFunctionId
|
||||
&& isRegularNpcEncounter(gameState.currentEncounter)
|
||||
&& !gameState.npcInteractionActive
|
||||
option.functionId === npcPreviewTalkFunctionId &&
|
||||
isRegularNpcEncounter(gameState.currentEncounter) &&
|
||||
!gameState.npcInteractionActive
|
||||
) {
|
||||
setAiError(null);
|
||||
enterNpcInteraction(gameState.currentEncounter, option.actionText);
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
getCharacterById,
|
||||
} from '../../data/characterPresets';
|
||||
import { getCharacterById } from '../../data/characterPresets';
|
||||
import {
|
||||
buildNpcGiftModalState,
|
||||
buildNpcRecruitModalState,
|
||||
buildNpcTradeModalIntroText,
|
||||
} from '../../data/functionCatalog';
|
||||
import {
|
||||
buildNpcTradeTransactionActionText,
|
||||
} from '../../data/npcInteractions';
|
||||
import { buildNpcTradeTransactionActionText } from '../../data/npcInteractions';
|
||||
import { streamNpcRecruitDialogue } from '../../services/aiService';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type {
|
||||
@@ -52,6 +45,7 @@ type StoryNpcInteractionRuntime = {
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
@@ -67,11 +61,16 @@ type StoryNpcInteractionRuntime = {
|
||||
streaming?: boolean,
|
||||
) => StoryMoment;
|
||||
generateStoryForState: GenerateStoryForState;
|
||||
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
|
||||
getStoryGenerationHostileNpcs: (
|
||||
state: GameState,
|
||||
) => GameState['sceneHostileNpcs'];
|
||||
getTypewriterDelay: (char: string) => number;
|
||||
};
|
||||
|
||||
function buildOfflineRecruitDialogue(encounter: Encounter, releasedCompanionName?: string | null) {
|
||||
function buildOfflineRecruitDialogue(
|
||||
encounter: Encounter,
|
||||
releasedCompanionName?: string | null,
|
||||
) {
|
||||
const releaseLine = releasedCompanionName
|
||||
? `你:如果你愿意加入,我会先让${releasedCompanionName}暂时离队,把位置腾给你。`
|
||||
: '你:如果你愿意加入,我希望接下来能和你并肩行动。';
|
||||
@@ -92,10 +91,11 @@ function normalizeRecruitDialogue(
|
||||
const rawLines = dialogueText
|
||||
.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const refusalPattern = /拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter(line => !refusalPattern.test(line));
|
||||
const refusalPattern =
|
||||
/拒绝|不加入|不答应|不能加入|不会加入|暂不加入|以后再说|改天再说|再考虑|观望|算了|不同路/u;
|
||||
const sanitizedLines = rawLines.filter((line) => !refusalPattern.test(line));
|
||||
const npcPrefix = `${encounter.npcName}:`;
|
||||
const playerPrefix = '你:';
|
||||
const releaseLine = releasedCompanionName
|
||||
@@ -108,14 +108,17 @@ function normalizeRecruitDialogue(
|
||||
`${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`,
|
||||
];
|
||||
|
||||
const workingLines = sanitizedLines.length > 0 ? sanitizedLines.slice(0, 5) : defaultLines.slice(0, 3);
|
||||
if (!workingLines.some(line => line.startsWith(playerPrefix))) {
|
||||
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))) {
|
||||
if (!workingLines.some((line) => line.startsWith(npcPrefix))) {
|
||||
const secondDefaultLine = defaultLines[1];
|
||||
if (secondDefaultLine) {
|
||||
workingLines.push(secondDefaultLine);
|
||||
@@ -125,9 +128,9 @@ function normalizeRecruitDialogue(
|
||||
const acceptanceLine = `${npcPrefix}好,我答应加入你的队伍。从现在开始,我就与你一同行动。`;
|
||||
const lastWorkingLine = workingLines[workingLines.length - 1];
|
||||
if (
|
||||
workingLines.length === 0
|
||||
|| !lastWorkingLine?.startsWith(npcPrefix)
|
||||
|| refusalPattern.test(lastWorkingLine)
|
||||
workingLines.length === 0 ||
|
||||
!lastWorkingLine?.startsWith(npcPrefix) ||
|
||||
refusalPattern.test(lastWorkingLine)
|
||||
) {
|
||||
workingLines.push(acceptanceLine);
|
||||
} else {
|
||||
@@ -158,11 +161,16 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
getNpcEncounterKey: (encounter: Encounter) => string;
|
||||
getResolvedNpcState: (state: GameState, encounter: Encounter) => GameState['npcStates'][string];
|
||||
getResolvedNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
) => GameState['npcStates'][string];
|
||||
updateNpcState: (
|
||||
state: GameState,
|
||||
encounter: Encounter,
|
||||
updater: (npcState: GameState['npcStates'][string]) => GameState['npcStates'][string],
|
||||
updater: (
|
||||
npcState: GameState['npcStates'][string],
|
||||
) => GameState['npcStates'][string],
|
||||
) => GameState;
|
||||
cloneInventoryItemForOwner: (
|
||||
item: InventoryItem,
|
||||
@@ -173,7 +181,9 @@ export function useStoryNpcInteractionFlow({
|
||||
}) {
|
||||
const [tradeModal, setTradeModal] = useState<TradeModalState | null>(null);
|
||||
const [giftModal, setGiftModal] = useState<GiftModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(null);
|
||||
const [recruitModal, setRecruitModal] = useState<RecruitModalState | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const resolveRecruitmentOnServer = async (params: {
|
||||
encounter: Encounter;
|
||||
@@ -221,7 +231,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc recruit action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc recruit action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 招募执行失败',
|
||||
);
|
||||
@@ -245,9 +258,11 @@ export function useStoryNpcInteractionFlow({
|
||||
|
||||
const releasedCompanionName = releasedNpcId
|
||||
? (() => {
|
||||
const releasedCompanion = gameState.companions.find(item => item.npcId === releasedNpcId);
|
||||
const releasedCompanion = gameState.companions.find(
|
||||
(item) => item.npcId === releasedNpcId,
|
||||
);
|
||||
return releasedCompanion?.characterId
|
||||
? getCharacterById(releasedCompanion.characterId)?.name ?? null
|
||||
? (getCharacterById(releasedCompanion.characterId)?.name ?? null)
|
||||
: null;
|
||||
})()
|
||||
: null;
|
||||
@@ -261,7 +276,9 @@ export function useStoryNpcInteractionFlow({
|
||||
setRecruitModal(null);
|
||||
runtime.setAiError(null);
|
||||
runtime.setIsLoading(true);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(encounter.npcName, '', [], true),
|
||||
);
|
||||
|
||||
let dialogueText = '';
|
||||
let streamedTargetText = '';
|
||||
@@ -269,20 +286,32 @@ export function useStoryNpcInteractionFlow({
|
||||
let streamCompleted = false;
|
||||
|
||||
const typewriterPromise = (async () => {
|
||||
while (!streamCompleted || displayedText.length < streamedTargetText.length) {
|
||||
while (
|
||||
!streamCompleted ||
|
||||
displayedText.length < streamedTargetText.length
|
||||
) {
|
||||
if (displayedText.length >= streamedTargetText.length) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextChar = streamedTargetText[displayedText.length];
|
||||
if (!nextChar) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 40));
|
||||
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)));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
displayedText,
|
||||
[],
|
||||
true,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
window.setTimeout(resolve, runtime.getTypewriterDelay(nextChar)),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -294,12 +323,13 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.getStoryGenerationHostileNpcs(provisionalState),
|
||||
gameState.storyHistory,
|
||||
runtime.buildStoryContextFromState(provisionalState, {
|
||||
currentStory: runtime.currentStory,
|
||||
lastFunctionId: 'npc_recruit',
|
||||
}),
|
||||
actionText,
|
||||
recruitPromptSummary,
|
||||
{
|
||||
onUpdate: text => {
|
||||
onUpdate: (text) => {
|
||||
streamedTargetText = text;
|
||||
},
|
||||
},
|
||||
@@ -311,17 +341,30 @@ export function useStoryNpcInteractionFlow({
|
||||
streamCompleted = true;
|
||||
await typewriterPromise;
|
||||
console.error('Failed to stream recruit dialogue:', error);
|
||||
dialogueText = displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(error instanceof Error ? error.message : '未知智能生成错误');
|
||||
dialogueText =
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : '未知智能生成错误',
|
||||
);
|
||||
}
|
||||
|
||||
const finalDialogueText = normalizeRecruitDialogue(
|
||||
encounter,
|
||||
dialogueText || displayedText || buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
dialogueText ||
|
||||
displayedText ||
|
||||
buildOfflineRecruitDialogue(encounter, releasedCompanionName),
|
||||
releasedCompanionName,
|
||||
);
|
||||
runtime.setCurrentStory(runtime.buildDialogueStoryMoment(encounter.npcName, finalDialogueText, [], false));
|
||||
await new Promise(resolve => window.setTimeout(resolve, 260));
|
||||
runtime.setCurrentStory(
|
||||
runtime.buildDialogueStoryMoment(
|
||||
encounter.npcName,
|
||||
finalDialogueText,
|
||||
[],
|
||||
false,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 260));
|
||||
await resolveRecruitmentOnServer({
|
||||
encounter,
|
||||
actionText,
|
||||
@@ -334,8 +377,8 @@ export function useStoryNpcInteractionFlow({
|
||||
mode: 'buy' | 'sell',
|
||||
): RuntimeNpcTradeItemView[] =>
|
||||
mode === 'buy'
|
||||
? gameState.runtimeNpcInteraction?.trade.buyItems ?? []
|
||||
: gameState.runtimeNpcInteraction?.trade.sellItems ?? [];
|
||||
? (gameState.runtimeNpcInteraction?.trade.buyItems ?? [])
|
||||
: (gameState.runtimeNpcInteraction?.trade.sellItems ?? []);
|
||||
|
||||
const findRuntimeTradeItem = (modal: TradeModalState) => {
|
||||
const itemId =
|
||||
@@ -360,27 +403,25 @@ export function useStoryNpcInteractionFlow({
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
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) => {
|
||||
@@ -390,17 +431,13 @@ export function useStoryNpcInteractionFlow({
|
||||
gameState.runtimeNpcInteraction?.gift.items[0]?.itemId ??
|
||||
null;
|
||||
|
||||
setGiftModal(
|
||||
buildNpcGiftModalState(
|
||||
encounter,
|
||||
actionText,
|
||||
selectedItemId,
|
||||
),
|
||||
);
|
||||
setGiftModal(buildNpcGiftModalState(encounter, actionText, selectedItemId));
|
||||
};
|
||||
|
||||
const openRecruitModal = (encounter: Encounter, actionText: string) => {
|
||||
setRecruitModal(buildNpcRecruitModalState(gameState, encounter, actionText));
|
||||
setRecruitModal(
|
||||
buildNpcRecruitModalState(gameState, encounter, actionText),
|
||||
);
|
||||
};
|
||||
|
||||
const clearNpcInteractionUi = () => {
|
||||
@@ -448,7 +485,10 @@ export function useStoryNpcInteractionFlow({
|
||||
runtime.setCurrentStory(nextStory);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve npc runtime action on the server:', error);
|
||||
console.error(
|
||||
'Failed to resolve npc runtime action on the server:',
|
||||
error,
|
||||
);
|
||||
runtime.setAiError(
|
||||
error instanceof Error ? error.message : 'NPC 交互执行失败',
|
||||
);
|
||||
@@ -514,50 +554,62 @@ export function useStoryNpcInteractionFlow({
|
||||
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
|
||||
? {
|
||||
setTradeMode: (mode: 'buy' | 'sell') =>
|
||||
setTradeModal((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
selectedQuantity: normalizeTradeQuantity(quantity),
|
||||
}
|
||||
: 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),
|
||||
selectGiftItem: (itemId: string) =>
|
||||
setGiftModal((current) =>
|
||||
current ? { ...current, selectedItemId: itemId } : current,
|
||||
),
|
||||
closeGiftModal: () => setGiftModal(null),
|
||||
confirmGift,
|
||||
selectRecruitRelease: (npcId: string) => setRecruitModal(current => current ? { ...current, selectedReleaseNpcId: npcId } : current),
|
||||
selectRecruitRelease: (npcId: string) =>
|
||||
setRecruitModal((current) =>
|
||||
current ? { ...current, selectedReleaseNpcId: npcId } : current,
|
||||
),
|
||||
closeRecruitModal: () => setRecruitModal(null),
|
||||
confirmRecruit: () => {
|
||||
if (!recruitModal) return;
|
||||
|
||||
@@ -55,6 +55,7 @@ type BuildStoryContextFromState = (
|
||||
lastFunctionId?: string | null;
|
||||
observeSignsRequested?: boolean;
|
||||
recentActionResult?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -161,7 +162,10 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
params.option,
|
||||
params.character,
|
||||
);
|
||||
if (resolvedChoice.optionKind === 'battle' || resolvedChoice.optionKind === 'escape') {
|
||||
if (
|
||||
resolvedChoice.optionKind === 'battle' ||
|
||||
resolvedChoice.optionKind === 'escape'
|
||||
) {
|
||||
throw new Error(
|
||||
`战斗与逃脱动作必须由后端结算,禁止进入本地 continuation:${params.option.functionId}`,
|
||||
);
|
||||
@@ -194,6 +198,7 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
observeSignsRequested:
|
||||
params.option.functionId === 'idle_observe_signs',
|
||||
recentActionResult: combatResolutionContextText,
|
||||
currentStory: params.currentStory,
|
||||
}),
|
||||
projectedAvailableOptions
|
||||
? { availableOptions: projectedAvailableOptions }
|
||||
@@ -239,11 +244,11 @@ export async function runLocalStoryChoiceContinuation(params: {
|
||||
lastObserveSignsSceneId:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? (afterSequence.currentScenePreset?.id ?? null)
|
||||
: afterSequence.lastObserveSignsSceneId ?? null,
|
||||
: (afterSequence.lastObserveSignsSceneId ?? null),
|
||||
lastObserveSignsReport:
|
||||
params.option.functionId === 'idle_observe_signs'
|
||||
? response.storyText
|
||||
: afterSequence.lastObserveSignsReport ?? null,
|
||||
: (afterSequence.lastObserveSignsReport ?? null),
|
||||
storyHistory: nextHistory,
|
||||
},
|
||||
{},
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ChoiceRuntimeController = {
|
||||
recentActionResult?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
57
src/hooks/rpg-runtime-story/storyContextBuilder.test.ts
Normal file
57
src/hooks/rpg-runtime-story/storyContextBuilder.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnimationState, type GameState, type StoryMoment } from '../../types';
|
||||
import { buildStoryContextFromState } from './storyContextBuilder';
|
||||
|
||||
function createState(overrides: Partial<GameState> = {}): GameState {
|
||||
return {
|
||||
worldType: 'WUXIA',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
runtimeActionVersion: 3,
|
||||
runtimeMode: 'play',
|
||||
runtimePersistenceDisabled: false,
|
||||
playerHp: 30,
|
||||
playerMaxHp: 40,
|
||||
playerMana: 12,
|
||||
playerMaxMana: 20,
|
||||
inBattle: false,
|
||||
playerX: 0,
|
||||
playerFacing: 'right',
|
||||
animationState: AnimationState.IDLE,
|
||||
playerSkillCooldowns: {},
|
||||
currentScenePreset: {
|
||||
id: 'forest-trail',
|
||||
name: '林间小径',
|
||||
description: '风声穿过树梢。',
|
||||
},
|
||||
...overrides,
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
describe('storyContextBuilder', () => {
|
||||
it('keeps normal play context lightweight', () => {
|
||||
const context = buildStoryContextFromState(createState());
|
||||
|
||||
expect(context.runtimeSessionId).toBe('runtime-main');
|
||||
expect(context.runtimeSnapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it('attaches transient snapshot for disabled persistence runtime', () => {
|
||||
const state = createState({
|
||||
runtimeSessionId: 'runtime-preview',
|
||||
runtimePersistenceDisabled: true,
|
||||
});
|
||||
const currentStory: StoryMoment = {
|
||||
text: '断桥客站在风口,等你先开口。',
|
||||
options: [],
|
||||
};
|
||||
|
||||
const context = buildStoryContextFromState(state, { currentStory });
|
||||
|
||||
expect(context.runtimeSnapshot).toEqual({
|
||||
bottomTab: 'adventure',
|
||||
gameState: state,
|
||||
currentStory,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import type { GameState } from '../../types';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
|
||||
export type StoryContextBuilderExtras = {
|
||||
pendingSceneEncounter?: boolean;
|
||||
@@ -9,8 +9,17 @@ export type StoryContextBuilderExtras = {
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
};
|
||||
|
||||
function shouldAttachTransientRuntimeSnapshot(state: GameState) {
|
||||
return (
|
||||
state.runtimePersistenceDisabled === true ||
|
||||
state.runtimeMode === 'preview' ||
|
||||
state.runtimeMode === 'test'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行时 story prompt context 的正式投影已经迁到 server-rs。
|
||||
* 前端只保留 session 与少量请求元信息,方便旧调用面继续复用同一个函数签名。
|
||||
@@ -22,6 +31,15 @@ export function buildStoryContextFromState(
|
||||
return {
|
||||
runtimeSessionId: state.runtimeSessionId ?? null,
|
||||
runtimeActionVersion: state.runtimeActionVersion,
|
||||
runtimeSnapshot: shouldAttachTransientRuntimeSnapshot(state)
|
||||
? {
|
||||
// 中文注释:禁存运行态不会写入正式 runtime_snapshot,
|
||||
// 聊天/续写请求需要携带本地临时快照供 server-rs 投影上下文。
|
||||
bottomTab: 'adventure',
|
||||
gameState: state,
|
||||
currentStory: extras.currentStory ?? null,
|
||||
}
|
||||
: undefined,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
playerMana: state.playerMana,
|
||||
|
||||
@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -18,6 +18,7 @@ type BuildStoryContextFromState = (
|
||||
state: GameState,
|
||||
extras?: {
|
||||
lastFunctionId?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
},
|
||||
) => StoryGenerationContext;
|
||||
|
||||
@@ -163,8 +164,11 @@ export async function generateStoryForStateWithCoordinator(params: {
|
||||
const context = params.choice
|
||||
? params.buildStoryContextFromState(params.state, {
|
||||
lastFunctionId: params.lastFunctionId,
|
||||
currentStory: params.currentStory,
|
||||
})
|
||||
: params.buildStoryContextFromState(params.state);
|
||||
: params.buildStoryContextFromState(params.state, {
|
||||
currentStory: params.currentStory,
|
||||
});
|
||||
const response = params.choice
|
||||
? await params.requestNextStep(
|
||||
worldType,
|
||||
|
||||
@@ -63,6 +63,7 @@ type BuildStoryContextExtras = {
|
||||
lastFunctionId?: string | null;
|
||||
openingCampBackground?: string | null;
|
||||
openingCampDialogue?: string | null;
|
||||
currentStory?: StoryMoment | null;
|
||||
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
|
||||
};
|
||||
|
||||
@@ -259,7 +260,9 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
});
|
||||
|
||||
const buildPendingQuestOfferOptions = (encounter: Encounter): StoryOption[] => [
|
||||
const buildPendingQuestOfferOptions = (
|
||||
encounter: Encounter,
|
||||
): StoryOption[] => [
|
||||
buildNpcChatQuestOfferOption(
|
||||
encounter,
|
||||
NPC_CHAT_QUEST_OFFER_FUNCTION_IDS.view,
|
||||
@@ -336,8 +339,7 @@ export function createStoryNpcEncounterActions({
|
||||
? `你们刚结束一场切磋,${params.resultText}`
|
||||
: `你刚赢下这场交锋,${params.resultText}`,
|
||||
logLines,
|
||||
battleOutcome:
|
||||
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
||||
battleOutcome: params.battleMode === 'spar' ? 'spar_complete' : 'victory',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -353,7 +355,10 @@ export function createStoryNpcEncounterActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
|
||||
const reopenedNpcState = getResolvedNpcState(
|
||||
params.nextState,
|
||||
params.encounter,
|
||||
);
|
||||
const baseStory = buildNpcStory(
|
||||
params.nextState,
|
||||
playerCharacter,
|
||||
@@ -365,7 +370,10 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
const fallbackChatOption =
|
||||
baseChatOptions[0] ??
|
||||
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
|
||||
buildNpcChatOption(
|
||||
params.encounter,
|
||||
`继续和${params.encounter.npcName}对话`,
|
||||
);
|
||||
const combatContext = buildNpcBattleChatCombatContext({
|
||||
battleMode: params.battleMode,
|
||||
resultText: params.resultText,
|
||||
@@ -487,7 +495,9 @@ export function createStoryNpcEncounterActions({
|
||||
);
|
||||
const restoredEncounter =
|
||||
state.sparReturnEncounter ??
|
||||
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
|
||||
(state.currentEncounter?.kind === 'npc'
|
||||
? state.currentEncounter
|
||||
: null) ??
|
||||
activeBattleHostiles[0]?.encounter ??
|
||||
({
|
||||
id: battleNpcId,
|
||||
@@ -756,7 +766,8 @@ export function createStoryNpcEncounterActions({
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
detailText: option.detailText ?? null,
|
||||
action: option.interaction?.kind === 'npc' ? option.interaction.action : null,
|
||||
action:
|
||||
option.interaction?.kind === 'npc' ? option.interaction.action : null,
|
||||
}));
|
||||
const isHostileChat =
|
||||
directive?.isHostileChat === true ||
|
||||
@@ -883,10 +894,9 @@ export function createStoryNpcEncounterActions({
|
||||
encounter: Encounter,
|
||||
playerCharacter: Character,
|
||||
) => {
|
||||
const resolvedStateOptions =
|
||||
collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
);
|
||||
const resolvedStateOptions = collapseNpcChatOptions(
|
||||
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
|
||||
);
|
||||
const currentStoryOptions = currentStory?.options ?? [];
|
||||
const currentChatOptions = currentStoryOptions.filter((option) =>
|
||||
isNpcChatOptionForEncounter(option, encounter),
|
||||
@@ -1105,7 +1115,9 @@ export function createStoryNpcEncounterActions({
|
||||
};
|
||||
};
|
||||
|
||||
const buildHostileNpcEscapeOptions = (character: Character): StoryOption[] => {
|
||||
const buildHostileNpcEscapeOptions = (
|
||||
character: Character,
|
||||
): StoryOption[] => {
|
||||
const currentScene = gameState.currentScenePreset;
|
||||
const worldType = gameState.worldType;
|
||||
const options: StoryOption[] = [];
|
||||
@@ -1120,34 +1132,24 @@ export function createStoryNpcEncounterActions({
|
||||
seenSceneIds.add(connection.sceneId);
|
||||
const targetScene = getScenePresetById(worldType, connection.sceneId);
|
||||
const targetSceneName =
|
||||
targetScene?.name ??
|
||||
connection.summary?.trim() ??
|
||||
connection.sceneId;
|
||||
targetScene?.name ?? connection.summary?.trim() ?? connection.sceneId;
|
||||
|
||||
options.push(
|
||||
buildHostileNpcEscapeOption(
|
||||
character,
|
||||
`逃往${targetSceneName}`,
|
||||
{
|
||||
targetSceneId: connection.sceneId,
|
||||
escapeTargetSceneId: connection.sceneId,
|
||||
escapeEntry: 'from_left',
|
||||
},
|
||||
),
|
||||
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',
|
||||
},
|
||||
),
|
||||
buildHostileNpcEscapeOption(character, '逃回当前场景起点', {
|
||||
targetSceneId: currentScene.id,
|
||||
escapeTargetSceneId: currentScene.id,
|
||||
escapeReturnToSceneStart: true,
|
||||
escapeEntry: 'from_left',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1326,6 +1328,7 @@ export function createStoryNpcEncounterActions({
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
currentStory,
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
@@ -1484,6 +1487,7 @@ export function createStoryNpcEncounterActions({
|
||||
getStoryGenerationHostileNpcs(gameState),
|
||||
gameState.storyHistory,
|
||||
buildStoryContextFromState(gameState, {
|
||||
currentStory,
|
||||
lastFunctionId: 'npc_chat',
|
||||
...openingCampContext,
|
||||
encounterNpcStateOverride: npcState,
|
||||
@@ -1594,17 +1598,17 @@ export function createStoryNpcEncounterActions({
|
||||
chatDirective.remainingTurns ??
|
||||
null,
|
||||
limitReason: chatDirective.limitReason ?? null,
|
||||
terminationMode: chatDirective.terminationMode ?? null,
|
||||
terminationReason:
|
||||
terminationMode: chatDirective.terminationMode ?? null,
|
||||
terminationReason:
|
||||
chatTurn.chatDirective?.terminationReason ??
|
||||
chatDirective.terminationReason ??
|
||||
null,
|
||||
isHostileChat: chatDirective.isHostileChat ?? false,
|
||||
closingMode:
|
||||
chatTurn.chatDirective?.closingMode ??
|
||||
chatDirective.closingMode ??
|
||||
'free',
|
||||
forceExitAfterTurn:
|
||||
isHostileChat: chatDirective.isHostileChat ?? false,
|
||||
closingMode:
|
||||
chatTurn.chatDirective?.closingMode ??
|
||||
chatDirective.closingMode ??
|
||||
'free',
|
||||
forceExitAfterTurn:
|
||||
chatTurn.chatDirective?.forceExit ??
|
||||
chatDirective.forceExitAfterTurn ??
|
||||
false,
|
||||
@@ -1615,9 +1619,7 @@ export function createStoryNpcEncounterActions({
|
||||
const pendingQuestIntroText =
|
||||
chatTurn.pendingQuestOffer?.introText?.trim() || '';
|
||||
if (shouldForceExitAfterTurn) {
|
||||
const closingDialogue = [
|
||||
...nextDialogue,
|
||||
];
|
||||
const closingDialogue = [...nextDialogue];
|
||||
const shouldUseHostileClosureOptions =
|
||||
shouldUseHostileNpcChatClosureOptions(
|
||||
resolvedChatDirective,
|
||||
@@ -1756,13 +1758,9 @@ export function createStoryNpcEncounterActions({
|
||||
return false;
|
||||
}
|
||||
|
||||
void handleNpcChatTurn(
|
||||
encounter,
|
||||
`我先结束这轮交谈,继续往前走。`,
|
||||
{
|
||||
forcePlayerExit: true,
|
||||
},
|
||||
);
|
||||
void handleNpcChatTurn(encounter, `我先结束这轮交谈,继续往前走。`, {
|
||||
forcePlayerExit: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1814,7 +1812,10 @@ export function createStoryNpcEncounterActions({
|
||||
},
|
||||
} satisfies StoryOption);
|
||||
|
||||
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
|
||||
if (
|
||||
!currentStory?.npcChatState &&
|
||||
!npcState.firstMeaningfulContactResolved
|
||||
) {
|
||||
void startNpcInitiatedOpening(
|
||||
encounter,
|
||||
seedChatOption,
|
||||
|
||||
Reference in New Issue
Block a user