1
This commit is contained in:
@@ -3,11 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const {
|
||||
resolveServerRuntimeChoiceMock,
|
||||
streamNpcChatTurnMock,
|
||||
generateQuestForNpcEncounterMock,
|
||||
} = vi.hoisted(() => ({
|
||||
resolveServerRuntimeChoiceMock: vi.fn(),
|
||||
streamNpcChatTurnMock: vi.fn(),
|
||||
generateQuestForNpcEncounterMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./runtimeStoryCoordinator', () => ({
|
||||
@@ -18,10 +16,6 @@ vi.mock('../../services/aiService', () => ({
|
||||
streamNpcChatTurn: streamNpcChatTurnMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/questDirector', () => ({
|
||||
generateQuestForNpcEncounter: generateQuestForNpcEncounterMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -593,7 +587,6 @@ describe('npcEncounterActions', () => {
|
||||
beforeEach(() => {
|
||||
resolveServerRuntimeChoiceMock.mockReset();
|
||||
streamNpcChatTurnMock.mockReset();
|
||||
generateQuestForNpcEncounterMock.mockReset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -1371,8 +1364,6 @@ describe('npcEncounterActions', () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(generateQuestForNpcEncounterMock).not.toHaveBeenCalled();
|
||||
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
|
||||
expect(lastStory.npcAffinityEffect).toEqual({
|
||||
@@ -1393,18 +1384,38 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('replaces a pending quest offer by reusing the existing quest generator', async () => {
|
||||
it('replaces a pending quest offer through the server runtime resolver', async () => {
|
||||
const currentQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
const nextQuest = createQuest('quest-bridge-replaced', '断桥夜巡');
|
||||
generateQuestForNpcEncounterMock.mockResolvedValueOnce(nextQuest);
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState(),
|
||||
},
|
||||
nextStory: createPendingQuestOfferStory(nextQuest),
|
||||
});
|
||||
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(currentQuest),
|
||||
});
|
||||
|
||||
await expect(actions.replacePendingNpcQuestOffer()).resolves.toBe(true);
|
||||
expect(actions.replacePendingNpcQuestOffer()).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(generateQuestForNpcEncounterMock).toHaveBeenCalledTimes(1);
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_chat_quest_offer_replace',
|
||||
actionText: '你请断桥客换一份更合适的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_offer_replace',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
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([
|
||||
@@ -1485,14 +1496,78 @@ describe('npcEncounterActions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('abandons a pending quest offer and returns to free npc chat', () => {
|
||||
it('abandons a pending quest offer through the server runtime resolver', async () => {
|
||||
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
|
||||
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
|
||||
hydratedSnapshot: {
|
||||
gameState: createState(),
|
||||
},
|
||||
nextStory: {
|
||||
...createPendingQuestOfferStory(pendingQuest),
|
||||
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',
|
||||
}),
|
||||
],
|
||||
dialogue: [
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '这件事我只想托给你。',
|
||||
},
|
||||
{
|
||||
speaker: 'player',
|
||||
text: '这件事我先不接,咱们还是先聊别的。',
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: '断桥客',
|
||||
text: '那就先聊别的。',
|
||||
},
|
||||
],
|
||||
npcChatState: {
|
||||
npcId: 'npc-rival',
|
||||
npcName: '断桥客',
|
||||
turnCount: 2,
|
||||
customInputPlaceholder: '输入你想对 TA 说的话',
|
||||
pendingQuestOffer: null,
|
||||
},
|
||||
} satisfies StoryMoment,
|
||||
});
|
||||
const actions = createNpcEncounterActions({
|
||||
currentStory: createPendingQuestOfferStory(pendingQuest),
|
||||
});
|
||||
|
||||
expect(actions.abandonPendingNpcQuestOffer()).toBe(true);
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(resolveServerRuntimeChoiceMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
gameState: actions.gameState,
|
||||
currentStory: actions.currentStory,
|
||||
option: expect.objectContaining({
|
||||
functionId: 'npc_chat_quest_offer_abandon',
|
||||
actionText: '你暂时没有接下断桥客提出的委托。',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-rival',
|
||||
action: 'quest_offer_abandon',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
|
||||
expect(lastStory.npcChatState?.pendingQuestOffer).toBeNull();
|
||||
expect(lastStory.options.map((option) => option.actionText)).toEqual([
|
||||
|
||||
@@ -24,7 +24,6 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { generateQuestForNpcEncounter } from '../../services/questDirector';
|
||||
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
|
||||
import { createHistoryMoment } from '../../services/storyHistory';
|
||||
import type {
|
||||
@@ -1558,81 +1557,36 @@ export function createStoryNpcEncounterActions({
|
||||
}
|
||||
};
|
||||
|
||||
const replacePendingNpcQuestOffer = async () => {
|
||||
const playerCharacter = gameState.playerCharacter;
|
||||
const replacePendingNpcQuestOffer = () => {
|
||||
const encounter = gameState.currentEncounter;
|
||||
const pendingQuestOffer = isNpcEncounter(encounter)
|
||||
? getPendingQuestOffer(currentStory, encounter)
|
||||
: null;
|
||||
if (!playerCharacter || !encounter || !pendingQuestOffer) {
|
||||
if (!encounter || !pendingQuestOffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounterKey = getNpcEncounterKey(encounter);
|
||||
const currentNpcChatState =
|
||||
currentStory?.npcChatState?.npcId === encounterKey
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const currentDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
: [];
|
||||
const turnCount = currentNpcChatState?.turnCount ?? 0;
|
||||
const playerLine = '能不能换一份更适合眼下局势的委托?';
|
||||
const generationState = {
|
||||
...gameState,
|
||||
storyHistory: appendHistory(
|
||||
gameState,
|
||||
`你请${encounter.npcName}换一份更合适的委托。`,
|
||||
`${encounter.npcName}重新斟酌起该交给你的事。`,
|
||||
),
|
||||
};
|
||||
|
||||
setAiError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const nextQuest = await generateQuestForNpcEncounter({
|
||||
state: generationState,
|
||||
encounter,
|
||||
});
|
||||
if (!nextQuest) {
|
||||
setAiError('当前没有更合适的委托可供更换。');
|
||||
return false;
|
||||
}
|
||||
|
||||
setGameState(generationState);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...currentDialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: playerLine,
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: buildQuestOfferDialogueText(encounter, nextQuest),
|
||||
},
|
||||
],
|
||||
options: buildPendingQuestOfferOptions(encounter),
|
||||
streaming: false,
|
||||
turnCount,
|
||||
pendingQuestOffer: {
|
||||
quest: nextQuest,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to replace pending npc quest offer:', error);
|
||||
setAiError(error instanceof Error ? error.message : '更换任务失败');
|
||||
return false;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
void resolveServerNpcStoryAction({
|
||||
option: {
|
||||
functionId: 'npc_chat_quest_offer_replace',
|
||||
actionText: `你请${encounter.npcName}换一份更合适的委托。`,
|
||||
text: `你请${encounter.npcName}换一份更合适的委托。`,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'quest_offer_replace',
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const abandonPendingNpcQuestOffer = () => {
|
||||
@@ -1643,49 +1597,27 @@ export function createStoryNpcEncounterActions({
|
||||
if (!encounter || !pendingQuestOffer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const encounterKey = getNpcEncounterKey(encounter);
|
||||
const currentNpcChatState =
|
||||
currentStory?.npcChatState?.npcId === encounterKey
|
||||
? currentStory.npcChatState
|
||||
: null;
|
||||
const currentDialogue =
|
||||
currentStory?.dialogue && currentNpcChatState
|
||||
? [...currentStory.dialogue]
|
||||
: [];
|
||||
const turnCount = currentNpcChatState?.turnCount ?? 0;
|
||||
const playerLine = '这件事我先不接,咱们还是先聊别的。';
|
||||
const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`;
|
||||
const nextState = {
|
||||
...gameState,
|
||||
storyHistory: appendHistory(
|
||||
gameState,
|
||||
`你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
npcReply,
|
||||
),
|
||||
};
|
||||
|
||||
setGameState(nextState);
|
||||
setCurrentStory(
|
||||
buildNpcChatStoryMoment({
|
||||
encounter,
|
||||
dialogue: [
|
||||
...currentDialogue,
|
||||
{
|
||||
speaker: 'player',
|
||||
text: playerLine,
|
||||
},
|
||||
{
|
||||
speaker: 'npc',
|
||||
speakerName: encounter.npcName,
|
||||
text: npcReply,
|
||||
},
|
||||
],
|
||||
options: buildPostQuestOfferChatSuggestions(encounter),
|
||||
streaming: false,
|
||||
turnCount,
|
||||
}),
|
||||
);
|
||||
void resolveServerNpcStoryAction({
|
||||
option: {
|
||||
functionId: 'npc_chat_quest_offer_abandon',
|
||||
actionText: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
text: `你暂时没有接下${encounter.npcName}提出的委托。`,
|
||||
detailText: '',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: encounter.id ?? encounter.npcName,
|
||||
action: 'quest_offer_abandon',
|
||||
},
|
||||
},
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
putSaveSnapshotMock,
|
||||
getRuntimeStoryStateMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
putSaveSnapshotMock: vi.fn(),
|
||||
getRuntimeStoryStateMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/storageService', () => ({
|
||||
putSaveSnapshot: putSaveSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeStoryService', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../../services/runtimeStoryService')>(
|
||||
@@ -149,7 +143,6 @@ function createRuntimeNpcBattleSnapshot(
|
||||
|
||||
describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
putSaveSnapshotMock.mockReset();
|
||||
getRuntimeStoryStateMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
@@ -209,12 +202,15 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentStory,
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(options).toEqual([
|
||||
expect.objectContaining({
|
||||
functionId: 'npc_chat',
|
||||
@@ -306,11 +302,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(putSaveSnapshotMock).toHaveBeenCalledWith({
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
@@ -319,6 +310,11 @@ describe('runtimeStoryCoordinator', () => {
|
||||
payload: {
|
||||
note: 'server-runtime-test',
|
||||
},
|
||||
snapshot: {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
},
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(hydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
@@ -414,7 +410,9 @@ describe('runtimeStoryCoordinator', () => {
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith('runtime-main');
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot).toBe(serverHydratedSnapshot);
|
||||
expect(result.nextStory).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -614,6 +612,9 @@ describe('runtimeStoryCoordinator', () => {
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
expect(getRuntimeStoryStateMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
});
|
||||
expect(result.hydratedSnapshot.gameState.sceneHostileNpcs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'npc-bandit',
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
resolveRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
import { putSaveSnapshot } from '../../services/storageService';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
@@ -18,26 +18,26 @@ function getRuntimeResponseOptions(response: RuntimeStoryResponse) {
|
||||
: response.presentation.options;
|
||||
}
|
||||
|
||||
async function syncRuntimeSnapshot(
|
||||
function buildRuntimeSnapshotRequest(
|
||||
gameState: GameState,
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
await putSaveSnapshot({
|
||||
return {
|
||||
gameState,
|
||||
bottomTab: 'adventure',
|
||||
currentStory,
|
||||
});
|
||||
} satisfies RuntimeStorySnapshotRequest;
|
||||
}
|
||||
|
||||
export async function loadServerRuntimeOptionCatalog(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(params.gameState),
|
||||
);
|
||||
const response = await getRuntimeStoryState({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const options = resolveRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
@@ -64,9 +64,9 @@ export async function resumeServerRuntimeStory(
|
||||
};
|
||||
}
|
||||
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
);
|
||||
const response = await getRuntimeStoryState({
|
||||
sessionId: getRuntimeSessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const resumedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
@@ -96,8 +96,6 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
Partial<Pick<StoryOption, 'interaction'>>;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
}) {
|
||||
await syncRuntimeSnapshot(params.gameState, params.currentStory);
|
||||
|
||||
const response = await resolveRuntimeStoryAction({
|
||||
sessionId: getRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
@@ -107,6 +105,7 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
? params.option.interaction.npcId
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
snapshot: buildRuntimeSnapshotRequest(params.gameState, params.currentStory),
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export interface QuestFlowUi {
|
||||
}
|
||||
|
||||
export interface NpcChatQuestOfferUi {
|
||||
replacePendingOffer: () => Promise<boolean>;
|
||||
replacePendingOffer: () => boolean;
|
||||
abandonPendingOffer: () => boolean;
|
||||
acceptPendingOffer: () => string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user