This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,
},
{},

View File

@@ -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;

View 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,
});
});
});

View File

@@ -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,

View File

@@ -24,6 +24,7 @@ type StoryInteractionCoordinatorParams = {
lastFunctionId?: string | null;
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
currentStory?: StoryMoment | null;
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
},
) => StoryGenerationContext;

View File

@@ -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,

View File

@@ -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,