抽离runtime story可替换传输层

This commit is contained in:
2026-04-20 09:53:05 +00:00
parent 06a8853167
commit e8beb0a988
4 changed files with 370 additions and 71 deletions

View File

@@ -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: '服务端返回的新故事',

View File

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