1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-18 17:28:23 +08:00
parent b3066c7bc1
commit 54b3d3c490
21 changed files with 731 additions and 156 deletions

View File

@@ -62,6 +62,8 @@ interface AdventurePanelProps {
canRefreshOptions: boolean;
onRefreshOptions: () => void;
onChoice: (option: StoryOption) => void;
onSubmitNpcChatInput?: (input: string) => boolean;
onExitNpcChat?: () => boolean;
onOpenCharacter: () => void;
onOpenInventory: () => void;
playerCharacter: Character;
@@ -149,12 +151,22 @@ function getOptionActionTextClass(option: StoryOption) {
function getDialogueTurnAlignmentClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return 'justify-center';
}
return turn.speaker === 'player' ? 'justify-end' : 'justify-start';
}
function getDialogueTurnBubbleClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
: 'border-white/12 bg-white/[0.06] text-zinc-100';
}
if (turn.speaker === 'player') {
return 'border-sky-400/20 bg-sky-500/10 text-sky-50';
}
@@ -169,6 +181,10 @@ function getDialogueTurnBubbleClass(
function getDialogueTurnBubbleShapeClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return 'rounded-full';
}
if (turn.speaker === 'player') {
return 'rounded-2xl rounded-br-none';
}
@@ -183,6 +199,10 @@ function getDialogueTurnBubbleShapeClass(
function getDialogueTurnLabel(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0 ? '关系变化' : '系统';
}
if (turn.speaker === 'player') {
return '\u4f60';
}
@@ -597,6 +617,8 @@ export function AdventurePanel({
canRefreshOptions,
onRefreshOptions,
onChoice,
onSubmitNpcChatInput,
onExitNpcChat,
onOpenCharacter,
onOpenInventory,
playerCharacter,
@@ -622,6 +644,8 @@ export function AdventurePanel({
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
const npcChatState = currentStory.npcChatState ?? null;
const isNpcChatMode = Boolean(npcChatState);
const isStoryStreaming = Boolean(currentStory.streaming);
const shouldHideChoiceUi = hideOptions;
const storyScrollContainerRef = useRef<HTMLDivElement | null>(null);
@@ -656,6 +680,7 @@ export function AdventurePanel({
const [selectedBattleRewardItemId, setSelectedBattleRewardItemId] = useState<
string | null
>(null);
const [npcChatDraft, setNpcChatDraft] = useState('');
const lastAutoOpenedGoalRef = useRef<string | null>(null);
const lastAutoOpenedPulseRef = useRef<string | null>(null);
const battleReward = battleRewardUi.reward;
@@ -734,6 +759,10 @@ export function AdventurePanel({
setSelectedBattleRewardItemId(null);
}, [battleReward]);
useEffect(() => {
setNpcChatDraft('');
}, [npcChatState?.npcId, npcChatState?.turnCount]);
useEffect(() => {
if (!primaryQuestGoal) {
return;
@@ -887,6 +916,18 @@ export function AdventurePanel({
onDismissGoalPulse();
};
const submitNpcChatDraft = () => {
const nextInput = npcChatDraft.trim();
if (!nextInput || !onSubmitNpcChatInput) {
return;
}
const submitted = onSubmitNpcChatInput(nextInput);
if (submitted) {
setNpcChatDraft('');
}
};
return (
<div className="relative flex min-h-0 flex-1 flex-col">
<button

View File

@@ -45,6 +45,8 @@ interface GameShellStoryProps {
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
characterChatUi: CharacterChatUi;
@@ -200,6 +202,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
canRefreshOptions,
handleRefreshOptions,
handleChoice,
handleNpcChatInput,
exitNpcChat,
handleMapTravelToScene,
npcUi,
characterChatUi,
@@ -533,6 +537,8 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
onChoice={handleSceneTransitionChoice}
onSubmitNpcChatInput={handleNpcChatInput}
onExitNpcChat={exitNpcChat}
onOpenCharacter={() => openOverlayPanel('character')}
onOpenInventory={() => openOverlayPanel('inventory')}
playerCharacter={visibleGameState.playerCharacter}

View File

@@ -74,6 +74,8 @@ export function GameShellMainContent({
canRefreshOptions,
handleRefreshOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
characterChatUi,
inventoryUi,
battleRewardUi,
@@ -112,6 +114,8 @@ export function GameShellMainContent({
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
@@ -198,6 +202,8 @@ export function GameShellMainContent({
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}

View File

@@ -34,6 +34,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
displayedOptions,
canRefreshOptions,
handleRefreshOptions,
handleNpcChatInput,
exitNpcChat,
handleMapTravelToScene,
npcUi,
characterChatUi,
@@ -151,6 +153,8 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
canRefreshOptions={canRefreshOptions}
handleRefreshOptions={handleRefreshOptions}
handleSceneTransitionChoice={handleSceneTransitionChoice}
handleNpcChatInput={handleNpcChatInput}
exitNpcChat={exitNpcChat}
characterChatUi={characterChatUi}
inventoryUi={inventoryUi}
battleRewardUi={battleRewardUi}

View File

@@ -51,6 +51,8 @@ export function GameShellStoryPanels({
canRefreshOptions,
handleRefreshOptions,
handleSceneTransitionChoice,
handleNpcChatInput,
exitNpcChat,
characterChatUi,
inventoryUi,
battleRewardUi,
@@ -77,6 +79,8 @@ export function GameShellStoryPanels({
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleSceneTransitionChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
characterChatUi: CharacterChatUi;
inventoryUi: InventoryFlowUi;
battleRewardUi: BattleRewardUi;
@@ -181,6 +185,8 @@ export function GameShellStoryPanels({
canRefreshOptions={canRefreshOptions}
onRefreshOptions={handleRefreshOptions}
onChoice={handleSceneTransitionChoice}
onSubmitNpcChatInput={handleNpcChatInput}
onExitNpcChat={exitNpcChat}
onOpenCharacter={() => openOverlayPanel('character')}
onOpenInventory={() => openOverlayPanel('inventory')}
playerCharacter={playerCharacter}

View File

@@ -33,6 +33,8 @@ export interface GameShellStoryProps {
canRefreshOptions: boolean;
handleRefreshOptions: () => void;
handleChoice: (option: StoryOption) => void;
handleNpcChatInput: (input: string) => boolean;
exitNpcChat: () => boolean;
handleMapTravelToScene: (sceneId: string) => boolean;
npcUi: StoryGenerationNpcUi;
characterChatUi: CharacterChatUi;

View File

@@ -40,7 +40,11 @@ import {
resolveSceneEncounterPreview,
} from '../../data/sceneEncounterPreviews';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { generateNextStep, streamNpcChatDialogue } from '../../services/aiService';
import {
generateNextStep,
streamNpcChatDialogue,
streamNpcChatTurn,
} from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import {
@@ -572,6 +576,249 @@ export function createStoryNpcEncounterActions({
}
};
const buildNpcChatTurnOptions = (
encounter: Encounter,
suggestions: string[],
): StoryOption[] =>
suggestions.slice(0, 3).map((suggestion) => ({
functionId: 'npc_chat',
actionText: suggestion,
text: suggestion,
detailText: '',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
interaction: {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
},
}));
const buildFallbackNpcChatSuggestions = (playerMessage: string) => {
const topic = playerMessage.trim() || '刚才那句话';
return [
`顺着“${topic}”继续追问`,
'先表明你的判断,再看对方反应',
'换个更轻松的语气把话接下去',
];
};
const buildNpcChatStoryMoment = (params: {
encounter: Encounter;
dialogue: NonNullable<StoryMoment['dialogue']>;
options: StoryOption[];
streaming: boolean;
turnCount: number;
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
displayMode: 'dialogue',
dialogue: params.dialogue,
streaming: params.streaming,
npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return false;
}
const npcState = getResolvedNpcState(gameState, encounter);
const currentNpcChatState =
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
? currentStory.npcChatState
: null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
: [];
const dialogueWithPlayer = [
...existingDialogue,
{
speaker: 'player' as const,
text: playerMessage,
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: dialogueWithPlayer,
options: [],
streaming: true,
turnCount: nextTurnCount,
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
playerMessage,
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...dialogueWithPlayer,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: nextTurnCount,
}),
);
},
},
);
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + chatTurn.affinityDelta;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain: chatTurn.affinityDelta },
),
};
},
);
const finalHistory = appendHistory(
gameState,
playerMessage,
chatTurn.npcReply,
);
const finalState = {
...nextState,
quests: applyQuestProgressFromNpcTalk(
nextState.quests,
encounter.id ?? encounter.npcName,
),
storyHistory: finalHistory,
};
setGameState(finalState);
const affinityTurn =
chatTurn.affinityDelta !== 0
? [
{
speaker: 'system' as const,
text:
chatTurn.affinityDelta > 0
? `${chatTurn.affinityText} 好感 +${chatTurn.affinityDelta}`
: chatTurn.affinityText,
affinityDelta: chatTurn.affinityDelta,
},
]
: [];
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...dialogueWithPlayer,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
...affinityTurn,
],
options: buildNpcChatTurnOptions(
encounter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: buildFallbackNpcChatSuggestions(playerMessage),
),
streaming: false,
turnCount: nextTurnCount,
}),
);
return true;
} catch (error) {
console.error('Failed to stream npc chat turn:', error);
setAiError(
error instanceof Error ? error.message : 'NPC 聊天续写失败',
);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: dialogueWithPlayer,
options: buildNpcChatTurnOptions(
encounter,
buildFallbackNpcChatSuggestions(playerMessage),
),
streaming: false,
turnCount: nextTurnCount,
}),
);
return false;
} finally {
setIsLoading(false);
}
};
const exitNpcChat = () => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !isNpcEncounter(gameState.currentEncounter)) {
return false;
}
setAiError(null);
setCurrentStory(buildFallbackStoryForState(gameState, playerCharacter));
return true;
};
const enterNpcInteraction = (encounter: Encounter, actionText: string) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter) return false;
@@ -824,57 +1071,7 @@ export function createStoryNpcEncounterActions({
return true;
}
case 'chat': {
const chatOutcome = getChatAffinityOutcome({
playerCharacter,
encounter,
npcState,
actionText: option.actionText,
worldType: gameState.worldType,
customWorldProfile: gameState.customWorldProfile,
});
const affinityGain = chatOutcome.affinityGain;
const attributeSummary = chatOutcome.summary;
let nextAffinity = npcState.affinity;
const nextState = updateNpcState(
gameState,
encounter,
(currentNpcState) => {
nextAffinity = currentNpcState.affinity + affinityGain;
return {
...markNpcFirstMeaningfulContactResolved(currentNpcState),
affinity: nextAffinity,
relationState: buildRelationState(nextAffinity),
chattedCount: currentNpcState.chattedCount + 1,
stanceProfile: applyStoryChoiceToStanceProfile(
currentNpcState.stanceProfile,
'npc_chat',
{ affinityGain },
),
};
},
);
void commitNpcChatState(
nextState,
playerCharacter,
encounter,
option.actionText,
npcState.recruited
? buildCampCompanionChatResultText(
encounter,
affinityGain,
nextAffinity,
)
: buildNpcChatResultText(
encounter,
affinityGain,
nextAffinity,
attributeSummary,
),
option.functionId,
{
contextNpcStateOverride: npcState,
},
);
void handleNpcChatTurn(encounter, option.actionText);
return true;
}
case 'quest_accept': {
@@ -1029,5 +1226,7 @@ export function createStoryNpcEncounterActions({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
handleNpcChatTurn,
exitNpcChat,
};
}

View File

@@ -137,6 +137,8 @@ export function useStoryFlowCoordinator({
npcUi,
inventoryUi,
clearStoryInteractionUi,
handleNpcChatInput,
exitNpcChat,
} = useStoryInteractionCoordinator({
gameState,
isLoading,
@@ -186,5 +188,7 @@ export function useStoryFlowCoordinator({
goalUi,
npcUi,
inventoryUi,
handleNpcChatInput,
exitNpcChat,
};
}

View File

@@ -101,11 +101,16 @@ export function useStoryInteractionCoordinator({
const npcInteractionFlow = useStoryNpcInteractionFlow(
interactionConfig.npcInteractionFlow,
);
const { enterNpcInteraction, handleNpcInteraction, finalizeNpcBattleResult } =
createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
});
const {
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
handleNpcChatTurn,
exitNpcChat,
} = createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
});
const choiceRuntimeController: Parameters<
typeof useStoryChoiceCoordinator
>[0]['runtimeController'] = {
@@ -199,5 +204,15 @@ export function useStoryInteractionCoordinator({
npcUi: npcInteractionFlow.npcUi,
inventoryUi,
clearStoryInteractionUi,
handleNpcChatInput: (input: string) => {
const encounter = interactionConfig.npcEncounterActions.gameState.currentEncounter;
if (!encounter || encounter.kind !== 'npc') {
return false;
}
void handleNpcChatTurn(encounter, input);
return true;
},
exitNpcChat,
};
}

View File

@@ -148,6 +148,8 @@ export function useGameShellRuntime(): GameShellProps {
canRefreshOptions: storyFlow.canRefreshOptions,
handleRefreshOptions: storyFlow.handleRefreshOptions,
handleChoice: storyFlow.handleChoice,
handleNpcChatInput: storyFlow.handleNpcChatInput,
exitNpcChat: storyFlow.exitNpcChat,
handleMapTravelToScene: storyFlow.travelToSceneFromMap,
npcUi: storyFlow.npcUi,
characterChatUi: storyFlow.characterChatUi,

View File

@@ -97,6 +97,8 @@ export function useStoryGeneration({
goalUi,
npcUi,
inventoryUi,
handleNpcChatInput,
exitNpcChat,
} = useStoryFlowCoordinator({
gameState,
setGameState,
@@ -141,5 +143,7 @@ export function useStoryGeneration({
npcUi,
characterChatUi,
inventoryUi,
handleNpcChatInput,
exitNpcChat,
};
}

View File

@@ -23,6 +23,8 @@ import type {
CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
NpcChatTurnRequest,
NpcChatTurnResult,
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/story';
@@ -156,6 +158,37 @@ async function requestPlainTextStream(
return accumulatedText.trim();
}
type ParsedSseEvent = {
event: string | null;
data: string;
};
function parseSseEventBlock(eventBlock: string): ParsedSseEvent | null {
let eventName: string | null = null;
const dataLines: string[] = [];
for (const rawLine of eventBlock.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('event:')) {
eventName = line.slice(6).trim() || null;
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trim());
}
}
if (dataLines.length === 0) {
return null;
}
return {
event: eventName,
data: dataLines.join('\n'),
};
}
export async function generateInitialStory(
world: WorldType,
character: Character,
@@ -893,6 +926,109 @@ export async function streamNpcChatDialogue(
return dialogue.trim();
}
export async function streamNpcChatTurn(
world: WorldType,
character: Character,
encounter: Encounter,
monsters: SceneHostileNpc[],
history: StoryMoment[],
context: StoryGenerationContext,
conversationHistory: StoryMoment['dialogue'],
playerMessage: string,
npcState: Record<string, unknown>,
options: {
onReplyUpdate?: (text: string) => void;
} = {},
) {
const payload = {
worldType: world,
character,
encounter,
monsters,
history,
context,
conversationHistory: conversationHistory ?? [],
playerMessage,
npcState,
} satisfies NpcChatTurnRequest;
const response = await fetchWithApiAuth(`${RUNTIME_API_BASE}/chat/npc/turn/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(parseApiErrorMessage(responseText, 'NPC 聊天续写失败'));
}
if (!response.body) {
throw new Error('streaming response body is unavailable');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
let accumulatedReply = '';
let completedResult: NpcChatTurnResult | null = null;
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
while (buffer.includes('\n\n')) {
const boundary = buffer.indexOf('\n\n');
const eventBlock = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const parsedEvent = parseSseEventBlock(eventBlock);
if (!parsedEvent) {
continue;
}
if (parsedEvent.data === '[DONE]') {
continue;
}
if (parsedEvent.event === 'reply_delta') {
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
const nextText =
typeof payloadRecord.text === 'string' ? payloadRecord.text : '';
accumulatedReply = nextText;
options.onReplyUpdate?.(accumulatedReply);
continue;
}
if (parsedEvent.event === 'complete') {
completedResult = JSON.parse(parsedEvent.data) as NpcChatTurnResult;
accumulatedReply = completedResult.npcReply;
options.onReplyUpdate?.(accumulatedReply);
continue;
}
if (parsedEvent.event === 'error') {
const payloadRecord = JSON.parse(parsedEvent.data) as Record<string, unknown>;
throw new Error(
typeof payloadRecord.message === 'string'
? payloadRecord.message
: 'NPC 聊天续写失败',
);
}
}
}
if (!completedResult) {
throw new Error('NPC 聊天续写结果为空');
}
return completedResult;
}
export async function streamNpcRecruitDialogue(
world: WorldType,
character: Character,

View File

@@ -101,9 +101,17 @@ export interface QuestLogEntry {
}
export interface StoryDialogueTurn {
speaker: 'player' | 'npc' | 'companion';
speaker: 'player' | 'npc' | 'companion' | 'system';
speakerName?: string;
text: string;
affinityDelta?: number;
}
export interface StoryNpcChatState {
npcId: string;
npcName: string;
turnCount: number;
customInputPlaceholder?: string;
}
export interface CharacterChatTurn {
@@ -127,6 +135,7 @@ export interface StoryMoment {
streaming?: boolean;
deferredOptions?: StoryOption[];
historyRole?: StoryHistoryRole;
npcChatState?: StoryNpcChatState;
}
export type StoryOptionInteraction =