抽离runtime story可替换传输层
This commit is contained in:
@@ -17,39 +17,71 @@ import { AnimationState } from '../types';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeStoryState,
|
||||
getRuntimeSessionId,
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resetRuntimeStoryTransport,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
setRuntimeStoryTransport,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
resetRuntimeStoryTransport();
|
||||
});
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
function createMockRuntimeResponse(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 1,
|
||||
viewModel: {
|
||||
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 1,
|
||||
savedAt: '2026-04-20T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {
|
||||
inBattle: false,
|
||||
} as never,
|
||||
currentStory: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue(createMockRuntimeResponse({
|
||||
serverVersion: 2,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '后端已结算',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
@@ -83,25 +115,15 @@ describe('runtimeStoryService', () => {
|
||||
});
|
||||
|
||||
it('merges custom runtime payload fields into the action request body', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
requestJsonMock.mockResolvedValue(createMockRuntimeResponse({
|
||||
serverVersion: 3,
|
||||
viewModel: {},
|
||||
presentation: {
|
||||
actionText: '使用凝神灵液',
|
||||
resultText: '后端已结算物品使用',
|
||||
storyText: '后端已结算物品使用',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 3,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
await resolveRuntimeStoryAction({
|
||||
option: {
|
||||
@@ -136,6 +158,131 @@ describe('runtimeStoryService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('allows replacing the runtime story transport without changing callers', async () => {
|
||||
const getStateMock = vi.fn().mockResolvedValue(createMockRuntimeResponse({
|
||||
sessionId: 'runtime-transport',
|
||||
serverVersion: 11,
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '来自替换 transport 的状态',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
snapshot: {
|
||||
version: 11,
|
||||
savedAt: '2026-04-20T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {
|
||||
worldType: 'WUXIA',
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
},
|
||||
npcStates: {},
|
||||
sceneHostileNpcs: [],
|
||||
currentNpcBattleMode: 'fight',
|
||||
inBattle: true,
|
||||
},
|
||||
currentStory: null,
|
||||
},
|
||||
}));
|
||||
const resolveActionMock = vi.fn().mockResolvedValue(createMockRuntimeResponse({
|
||||
sessionId: 'runtime-transport',
|
||||
serverVersion: 12,
|
||||
viewModel: {
|
||||
player: { hp: 18, maxHp: 20, mana: 8, maxMana: 8 },
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '来自替换 transport 的动作结果',
|
||||
storyText: '来自替换 transport 的动作结果',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 12,
|
||||
savedAt: '2026-04-20T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
}));
|
||||
|
||||
setRuntimeStoryTransport({
|
||||
getState: getStateMock,
|
||||
resolveAction: resolveActionMock,
|
||||
});
|
||||
|
||||
const state = await getRuntimeStoryState('runtime-transport');
|
||||
const action = await resolveRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
});
|
||||
|
||||
expect(getStateMock).toHaveBeenCalledWith('runtime-transport', {});
|
||||
expect(resolveActionMock).toHaveBeenCalledWith(
|
||||
{
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
expect(requestJsonMock).not.toHaveBeenCalled();
|
||||
expect(state.presentation.storyText).toBe('来自替换 transport 的状态');
|
||||
expect(action.presentation.resultText).toBe('来自替换 transport 的动作结果');
|
||||
});
|
||||
|
||||
it('restores the default HTTP transport after reset', async () => {
|
||||
setRuntimeStoryTransport({
|
||||
getState: vi.fn(),
|
||||
resolveAction: vi.fn(),
|
||||
});
|
||||
resetRuntimeStoryTransport();
|
||||
|
||||
requestJsonMock.mockResolvedValue(createMockRuntimeResponse({
|
||||
serverVersion: 5,
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '恢复 HTTP transport',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
}));
|
||||
|
||||
await getRuntimeStoryState('runtime-main');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/runtime-main',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
|
||||
@@ -45,6 +45,25 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
|
||||
export type RuntimeStoryActionRequest = {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
};
|
||||
|
||||
export type RuntimeStoryTransport = {
|
||||
getState: (
|
||||
sessionId: string,
|
||||
options?: RuntimeStoryServiceOptions,
|
||||
) => Promise<RuntimeStoryResponse>;
|
||||
resolveAction: (
|
||||
params: RuntimeStoryActionRequest,
|
||||
options?: RuntimeStoryServiceOptions,
|
||||
) => Promise<RuntimeStoryResponse>;
|
||||
};
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
@@ -62,6 +81,67 @@ function requestRuntimeStoryJson<T>(
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeRuntimeStoryResponse(
|
||||
response: RuntimeStoryActionResponse<
|
||||
HydratedGameState,
|
||||
StoryMoment
|
||||
>,
|
||||
): RuntimeStoryResponse {
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
async function getRuntimeStoryStateFromHttp(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveRuntimeStoryActionFromHttp(
|
||||
params: RuntimeStoryActionRequest,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: params.sessionId || DEFAULT_SESSION_ID,
|
||||
clientVersion: params.clientVersion,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: params.option.functionId,
|
||||
targetId: params.targetId,
|
||||
payload: {
|
||||
optionText: params.option.actionText,
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
const httpRuntimeStoryTransport: RuntimeStoryTransport = {
|
||||
getState: getRuntimeStoryStateFromHttp,
|
||||
resolveAction: resolveRuntimeStoryActionFromHttp,
|
||||
};
|
||||
|
||||
let runtimeStoryTransport: RuntimeStoryTransport = httpRuntimeStoryTransport;
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
@@ -169,64 +249,30 @@ export function resolveRuntimeStoryMoment(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function setRuntimeStoryTransport(transport: RuntimeStoryTransport) {
|
||||
runtimeStoryTransport = transport;
|
||||
}
|
||||
|
||||
export function resetRuntimeStoryTransport() {
|
||||
runtimeStoryTransport = httpRuntimeStoryTransport;
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
return normalizeRuntimeStoryResponse(
|
||||
await runtimeStoryTransport.getState(sessionId, options),
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
params: {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
},
|
||||
params: RuntimeStoryActionRequest,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/actions/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: params.sessionId || DEFAULT_SESSION_ID,
|
||||
clientVersion: params.clientVersion,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: params.option.functionId,
|
||||
targetId: params.targetId,
|
||||
payload: {
|
||||
optionText: params.option.actionText,
|
||||
...(params.payload ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
return normalizeRuntimeStoryResponse(
|
||||
await runtimeStoryTransport.resolveAction(params, options),
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
|
||||
Reference in New Issue
Block a user