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

This commit is contained in:
2026-04-20 09:54:17 +08:00
parent 67c584b4df
commit 50759f3c1e
159 changed files with 16938 additions and 16925 deletions

View File

@@ -1,6 +1,37 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
const {
resolveServerRuntimeChoiceMock,
streamNpcChatTurnMock,
generateQuestForNpcEncounterMock,
} = vi.hoisted(() => ({
resolveServerRuntimeChoiceMock: vi.fn(),
streamNpcChatTurnMock: vi.fn(),
generateQuestForNpcEncounterMock: vi.fn(),
}));
vi.mock('./runtimeStoryCoordinator', () => ({
resolveServerRuntimeChoice: resolveServerRuntimeChoiceMock,
}));
vi.mock('../../services/aiService', () => ({
streamNpcChatTurn: streamNpcChatTurnMock,
}));
vi.mock('../../services/questDirector', () => ({
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
}));
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type QuestLogEntry,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions';
function createCharacter(): Character {
@@ -30,6 +61,7 @@ function createEncounter(): Encounter {
return {
id: 'npc-rival',
kind: 'npc',
characterId: 'char-rival',
npcName: '断桥客',
npcDescription: '拦路的旧敌',
npcAvatar: '/npc.png',
@@ -163,11 +195,144 @@ function createCurrentChatStory(): StoryMoment {
};
}
function createQuest(id: string, title: string): QuestLogEntry {
return {
id,
issuerNpcId: 'npc-rival',
issuerNpcName: '断桥客',
sceneId: 'scene-bridge',
title,
description: `${title}的详细说明。`,
summary: `${title}的简要目标。`,
objective: {
kind: 'inspect_treasure',
requiredCount: 1,
},
progress: 0,
status: 'active',
reward: {
affinityBonus: 6,
currency: 30,
items: [],
},
rewardText: '完成后可以领取报酬。',
steps: [
{
id: `${id}-step-1`,
title: '查清线索',
kind: 'inspect_treasure',
requiredCount: 1,
progress: 0,
revealText: '先去断桥口附近看看留下了什么痕迹。',
completeText: '线索已经查清。',
},
],
activeStepId: `${id}-step-1`,
};
}
function createPendingQuestOfferStory(quest = createQuest('quest-bridge', '断桥旧案')): StoryMoment {
return {
text: '断桥客终于把真正的委托说了出来。',
options: [
createOption('npc_chat_quest_offer_view', '查看任务'),
createOption('npc_chat_quest_offer_replace', '更换任务'),
createOption('npc_chat_quest_offer_abandon', '放弃任务'),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '这件事我只想托给你。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: {
quest,
},
},
};
}
function createAcceptedPendingQuestStory(
quest = createQuest('quest-bridge', '断桥旧案'),
): StoryMoment {
return {
text: [
'这件事我只想托给你。',
'这件事我愿意接下,你把关键要点交给我。',
'那就拜托你了。先去断桥口附近看看留下了什么痕迹。',
].join('\n'),
options: [
createOption('npc_chat', '这件事里你最担心哪一步', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '我回来时你最想先知道什么', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_chat', '除了这份委托,你还想提醒我什么', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '这件事我只想托给你。',
},
{
speaker: 'player',
text: '这件事我愿意接下,你把关键要点交给我。',
},
{
speaker: 'npc',
speakerName: '断桥客',
text:
quest.steps?.[0]?.revealText?.trim() &&
quest.steps[0].revealText.trim().length > 0
? `那就拜托你了。${quest.steps[0].revealText}`
: `那就拜托你了。${quest.summary}`,
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
pendingQuestOffer: null,
},
};
}
type GenerateStoryForStateTestDouble = (params: {
state: GameState;
character: Character;
history: StoryMoment[];
choice?: string;
lastFunctionId?: string | null;
optionCatalog?: StoryOption[] | null;
}) => Promise<StoryMoment>;
function createNpcEncounterActions(overrides: {
gameState?: GameState;
currentStory?: StoryMoment | null;
generateStoryForState?: ReturnType<typeof vi.fn>;
getAvailableOptionsForState?: ReturnType<typeof vi.fn>;
generateStoryForState?: GenerateStoryForStateTestDouble;
getAvailableOptionsForState?: (
state: GameState,
character: Character,
) => StoryOption[] | null;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
@@ -231,15 +396,15 @@ function createNpcEncounterActions(overrides: {
})),
generateStoryForState:
overrides.generateStoryForState ??
vi.fn().mockResolvedValue({
((vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
}),
}) as unknown) as GenerateStoryForStateTestDouble),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getTypewriterDelay: vi.fn(() => 0),
getAvailableOptionsForState:
overrides.getAvailableOptionsForState ??
vi.fn(() => [
(((vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
@@ -250,15 +415,39 @@ function createNpcEncounterActions(overrides: {
npcId: 'npc-rival',
action: 'chat',
}),
]),
]) as unknown) as (
state: GameState,
character: Character,
) => StoryOption[])),
sanitizeOptions: vi.fn((options: StoryOption[]) => options),
sortOptions: vi.fn((options: StoryOption[]) => options),
buildContinueAdventureOption: vi.fn(() =>
createOption('story_continue_adventure', '继续'),
),
getNpcEncounterKey: vi.fn((encounter: Encounter) => encounter.id ?? encounter.npcName),
getResolvedNpcState: vi.fn((state: GameState, encounter: Encounter) => state.npcStates[encounter.id ?? encounter.npcName]),
updateNpcState: vi.fn((state: GameState) => state),
getResolvedNpcState: vi.fn(
(state: GameState, encounter: Encounter) =>
state.npcStates[encounter.id ?? encounter.npcName]!,
),
updateNpcState: vi.fn(
(
state: GameState,
encounter: Encounter,
updater: (
npcState: GameState['npcStates'][string],
) => GameState['npcStates'][string],
) => {
const encounterKey = encounter.id ?? encounter.npcName;
const currentNpcState = state.npcStates[encounterKey]!;
return {
...state,
npcStates: {
...state.npcStates,
[encounterKey]: updater(currentNpcState),
},
};
},
),
cloneInventoryItemForOwner: vi.fn(),
resolveNpcInteractionDecision: vi.fn(() => ({ kind: 'default' })),
npcInteractionFlow: {
@@ -280,7 +469,124 @@ function createNpcEncounterActions(overrides: {
};
}
async function flushAsyncWork() {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('npcEncounterActions', () => {
beforeEach(() => {
resolveServerRuntimeChoiceMock.mockReset();
streamNpcChatTurnMock.mockReset();
generateQuestForNpcEncounterMock.mockReset();
});
it.each([
['npc_help', '请求援手', 'help'],
['npc_leave', '先离开这里', 'leave'],
['npc_fight', '直接动手', 'fight'],
['npc_spar', '先切磋一回', 'spar'],
])(
'delegates %s to the server runtime resolver instead of resolving locally',
async (functionId, actionText, action) => {
const nextGameState = createState({
playerHp: 88,
npcInteractionActive: action === 'leave' ? false : true,
});
const nextStory = {
text: `server:${functionId}`,
options: [createOption('idle_observe_signs', '观察周围动静')],
} satisfies StoryMoment;
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: nextGameState,
},
nextStory,
});
const actions = createNpcEncounterActions({});
expect(
actions.handleNpcInteraction(
createOption(functionId, actionText, {
kind: 'npc',
npcId: 'npc-rival',
action: action as 'help' | 'leave' | 'fight' | 'spar',
}),
),
).toBe(true);
await flushAsyncWork();
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId,
actionText,
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action,
},
}),
}),
);
expect(actions.setGameState).toHaveBeenCalledWith(nextGameState);
expect(actions.setCurrentStory).toHaveBeenCalledWith(nextStory);
expect(actions.setIsLoading).toHaveBeenNthCalledWith(1, true);
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
},
);
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: createState({
quests: [],
}),
},
nextStory: {
text: '后端已完成任务交付结算。',
options: [createOption('npc_leave', '离开当前角色')],
} satisfies StoryMoment,
});
const actions = createNpcEncounterActions({});
expect(
actions.handleNpcInteraction(
createOption('npc_quest_turn_in', '交付委托', {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_turn_in',
questId: 'quest-bridge',
}),
),
).toBe(true);
await flushAsyncWork();
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
option: expect.objectContaining({
functionId: 'npc_quest_turn_in',
interaction: expect.objectContaining({
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_turn_in',
questId: 'quest-bridge',
}),
}),
payload: {
questId: 'quest-bridge',
},
}),
);
});
it('re-runs story reasoning after exiting npc chat and appends the new story to history', async () => {
const gameState = createState({
storyHistory: [
@@ -323,9 +629,7 @@ describe('npcEncounterActions', () => {
});
expect(actions.exitNpcChat()).toBe(true);
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await flushAsyncWork();
expect(generateStoryForState).toHaveBeenCalledWith(
expect.objectContaining({
@@ -424,4 +728,179 @@ describe('npcEncounterActions', () => {
}),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 2,
affinityText: '断桥客的语气明显缓和下来。',
npcReply: '你既然愿意听,我就把这件事说开。',
suggestions: ['这件事最早是从什么时候开始的'],
pendingQuestOffer: {
quest: pendingQuest,
introText:
'断桥客沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:断桥口的密信',
},
});
const actions = createNpcEncounterActions({});
await expect(
actions.handleNpcChatTurn(createEncounter(), '那你先把来龙去脉讲清楚。'),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
'那你先把来龙去脉讲清楚。',
expect.anything(),
expect.objectContaining({
questOfferContext: expect.objectContaining({
turnCount: 2,
state: expect.objectContaining({
currentEncounter: expect.objectContaining({
id: 'npc-rival',
}),
}),
}),
}),
);
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'查看任务',
'更换任务',
'放弃任务',
]);
expect(lastStory.dialogue?.at(-1)).toEqual(
expect.objectContaining({
speaker: 'npc',
text: expect.stringContaining('正式交给你'),
}),
);
});
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(currentQuest),
});
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(nextQuest);
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'查看任务',
'更换任务',
'放弃任务',
]);
expect(lastStory.dialogue?.at(-2)).toEqual(
expect.objectContaining({
speaker: 'player',
text: '能不能换一份更适合眼下局势的委托?',
}),
);
});
it('forwards pending quest offer acceptance to the server runtime resolver', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
const nextState = createState({
quests: [pendingQuest],
runtimeStats: {
...createState().runtimeStats,
questsAccepted: 1,
},
});
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
gameState: nextState,
},
nextStory: createAcceptedPendingQuestStory(pendingQuest),
});
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(pendingQuest),
});
expect(actions.acceptPendingNpcQuestOffer()).toBe(pendingQuest.id);
await flushAsyncWork();
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
expect.objectContaining({
gameState: actions.gameState,
currentStory: actions.currentStory,
option: expect.objectContaining({
functionId: 'npc_quest_accept',
actionText: '你答应接下断桥客的委托。',
interaction: {
kind: 'npc',
npcId: 'npc-rival',
action: 'quest_accept',
},
}),
}),
);
expect(actions.setGameState).toHaveBeenCalledWith(
expect.objectContaining({
quests: [
expect.objectContaining({
id: pendingQuest.id,
}),
],
runtimeStats: expect.objectContaining({
questsAccepted: 1,
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'这件事里你最担心哪一步',
'我回来时你最想先知道什么',
'除了这份委托,你还想提醒我什么',
]);
expect(lastStory.dialogue?.at(-2)).toEqual(
expect.objectContaining({
speaker: 'player',
text: '这件事我愿意接下,你把关键要点交给我。',
}),
);
});
it('abandons a pending quest offer and returns to free npc chat', () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
const actions = createNpcEncounterActions({
currentStory: createPendingQuestOfferStory(pendingQuest),
});
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'那先继续聊聊你刚才没说完的部分',
'除了委托,你对眼前局势还有什么判断',
'先把这附近真正危险的地方说清楚',
]);
expect(lastStory.dialogue?.at(-2)).toEqual(
expect.objectContaining({
speaker: 'player',
text: '这件事我先不接,咱们还是先聊别的。',
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -81,6 +81,12 @@ export interface QuestFlowUi {
} | null;
}
export interface NpcChatQuestOfferUi {
replacePendingOffer: () => Promise<boolean>;
abandonPendingOffer: () => boolean;
acceptPendingOffer: () => string | null;
}
export interface GoalFlowUi {
goalStack: GoalStackState;
pulse: GoalPulseEvent | null;

View File

@@ -135,6 +135,7 @@ export function useStoryFlowCoordinator({
clearStoryInteractionUi,
handleNpcChatInput,
exitNpcChat,
npcChatQuestOfferUi,
} = useStoryInteractionCoordinator({
gameState,
isLoading,
@@ -184,5 +185,6 @@ export function useStoryFlowCoordinator({
inventoryUi,
handleNpcChatInput,
exitNpcChat,
npcChatQuestOfferUi,
};
}

View File

@@ -99,6 +99,9 @@ export function useStoryInteractionCoordinator({
finalizeNpcBattleResult,
handleNpcChatTurn,
exitNpcChat,
replacePendingNpcQuestOffer,
abandonPendingNpcQuestOffer,
acceptPendingNpcQuestOffer,
} = createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
npcInteractionFlow,
@@ -225,5 +228,10 @@ export function useStoryInteractionCoordinator({
return true;
},
exitNpcChat,
npcChatQuestOfferUi: {
replacePendingOffer: replacePendingNpcQuestOffer,
abandonPendingOffer: abandonPendingNpcQuestOffer,
acceptPendingOffer: acceptPendingNpcQuestOffer,
},
};
}

View File

@@ -158,6 +158,7 @@ export function useGameShellRuntime(): GameShellProps {
inventoryUi: storyFlow.inventoryUi,
battleRewardUi: storyFlow.battleRewardUi,
questUi: storyFlow.questUi,
npcChatQuestOfferUi: storyFlow.npcChatQuestOfferUi,
goalUi: storyFlow.goalUi,
},
entry: {

View File

@@ -45,6 +45,7 @@ export type {
GoalFlowUi,
InventoryFlowUi,
QuestFlowUi,
NpcChatQuestOfferUi,
RecruitModalState,
StoryGenerationNpcUi,
TradeModalState,
@@ -98,6 +99,7 @@ export function useStoryGeneration({
inventoryUi,
handleNpcChatInput,
exitNpcChat,
npcChatQuestOfferUi,
} = useStoryFlowCoordinator({
gameState,
setGameState,
@@ -139,5 +141,6 @@ export function useStoryGeneration({
inventoryUi,
handleNpcChatInput,
exitNpcChat,
npcChatQuestOfferUi,
};
}