|
|
|
|
@@ -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: '这件事我先不接,咱们还是先聊别的。',
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|