This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -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([

View File

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

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ export interface QuestFlowUi {
}
export interface NpcChatQuestOfferUi {
replacePendingOffer: () => Promise<boolean>;
replacePendingOffer: () => boolean;
abandonPendingOffer: () => boolean;
acceptPendingOffer: () => string | null;
}