This commit is contained in:
32
src/services/rpg-runtime/index.ts
Normal file
32
src/services/rpg-runtime/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
rpgSnapshotClient,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgSnapshotClient';
|
||||
export {
|
||||
getRpgCharacterChatSuggestions,
|
||||
getRpgCharacterChatSummary,
|
||||
rpgRuntimeChatClient,
|
||||
streamRpgCharacterChatReply,
|
||||
streamRpgNpcChatDialogue,
|
||||
streamRpgNpcChatTurn,
|
||||
streamRpgNpcRecruitDialogue,
|
||||
} from './rpgRuntimeChatClient';
|
||||
export {
|
||||
getRpgRuntimeActionSnapshot,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
rpgRuntimeStoryClient,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
type RpgRuntimeStoryClientOptions,
|
||||
type RuntimeStorySnapshotRequest,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
29
src/services/rpg-runtime/rpgRuntimeChatClient.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
generateCharacterPanelChatSuggestions,
|
||||
generateCharacterPanelChatSummary,
|
||||
streamCharacterPanelChatReply,
|
||||
streamNpcChatDialogue,
|
||||
streamNpcChatTurn,
|
||||
streamNpcRecruitDialogue,
|
||||
} from '../aiService';
|
||||
|
||||
/**
|
||||
* RPG 运行时聊天相关 client 的兼容收口层。
|
||||
* 当前仍桥接旧 `aiService`,避免工作包 A 提前改动聊天与 NPC 对话请求语义。
|
||||
*/
|
||||
export const getRpgCharacterChatSuggestions =
|
||||
generateCharacterPanelChatSuggestions;
|
||||
export const getRpgCharacterChatSummary = generateCharacterPanelChatSummary;
|
||||
export const streamRpgCharacterChatReply = streamCharacterPanelChatReply;
|
||||
export const streamRpgNpcChatDialogue = streamNpcChatDialogue;
|
||||
export const streamRpgNpcChatTurn = streamNpcChatTurn;
|
||||
export const streamRpgNpcRecruitDialogue = streamNpcRecruitDialogue;
|
||||
|
||||
export const rpgRuntimeChatClient = {
|
||||
getCharacterChatSuggestions: getRpgCharacterChatSuggestions,
|
||||
getCharacterChatSummary: getRpgCharacterChatSummary,
|
||||
streamCharacterChatReply: streamRpgCharacterChatReply,
|
||||
streamNpcChatDialogue: streamRpgNpcChatDialogue,
|
||||
streamNpcChatTurn: streamRpgNpcChatTurn,
|
||||
streamNpcRecruitDialogue: streamRpgNpcRecruitDialogue,
|
||||
};
|
||||
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
66
src/services/rpg-runtime/rpgRuntimeRequest.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
skipAuth?: boolean;
|
||||
skipRefresh?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装 RPG 运行时域的请求重试与鉴权透传,避免各 client 重复维护同一套规则。
|
||||
*/
|
||||
export function requestRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{
|
||||
retry,
|
||||
skipAuth: options.skipAuth,
|
||||
skipRefresh: options.skipRefresh,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公共世界广场等匿名接口统一走公开请求入口,避免误附带鉴权状态。
|
||||
*/
|
||||
export function requestPublicRpgRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRpgRuntimeJson<T>(path, init, fallbackMessage, {
|
||||
...options,
|
||||
skipAuth: true,
|
||||
skipRefresh: true,
|
||||
});
|
||||
}
|
||||
424
src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
Normal file
424
src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('../apiClient')>('../apiClient');
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { AnimationState } from '../../types';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStoryState,
|
||||
isRpgRuntimeServerFunctionId,
|
||||
isRpgRuntimeTaskFunctionId,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
shouldUseRpgRuntimeServerOptions,
|
||||
} from './rpgRuntimeStoryClient';
|
||||
|
||||
describe('rpgRuntimeStoryClient', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('builds runtime action requests against the dedicated story endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
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 resolveRpgRuntimeStoryAction({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
option: {
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-custom',
|
||||
clientVersion: 9,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'npc_chat',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '继续交谈',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges custom runtime payload fields into the action request body', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
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 resolveRpgRuntimeStoryAction({
|
||||
option: {
|
||||
functionId: 'inventory_use',
|
||||
actionText: '使用凝神灵液',
|
||||
},
|
||||
payload: {
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/actions/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: undefined,
|
||||
action: {
|
||||
type: 'story_choice',
|
||||
functionId: 'inventory_use',
|
||||
targetId: undefined,
|
||||
payload: {
|
||||
optionText: '使用凝神灵液',
|
||||
itemId: 'focus-tonic',
|
||||
},
|
||||
},
|
||||
snapshot: undefined,
|
||||
}),
|
||||
}),
|
||||
'执行运行时动作失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('submits runtime state resolution with snapshot context to the server', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '',
|
||||
resultText: '',
|
||||
storyText: '服务端故事',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {},
|
||||
currentStory: null,
|
||||
},
|
||||
});
|
||||
|
||||
await getRpgRuntimeStoryState({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: { currentScene: 'Story' } as never,
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
} as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/story/state/resolve',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'runtime-main',
|
||||
clientVersion: 7,
|
||||
snapshot: {
|
||||
gameState: {
|
||||
currentScene: 'Story',
|
||||
},
|
||||
bottomTab: 'adventure',
|
||||
currentStory: {
|
||||
text: '本地故事',
|
||||
options: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'读取运行时故事状态失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps disabled runtime options when rebuilding a story moment', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
scope: 'npc',
|
||||
},
|
||||
{
|
||||
functionId: 'npc_recruit',
|
||||
actionText: '邀请加入队伍',
|
||||
scope: 'npc',
|
||||
disabled: true,
|
||||
reason: '队伍已满',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.text).toBe('服务端返回的新故事');
|
||||
expect(story.options).toHaveLength(2);
|
||||
expect(story.options[0]?.functionId).toBe('npc_chat');
|
||||
expect(story.options[1]?.functionId).toBe('npc_recruit');
|
||||
expect(story.options[1]?.disabled).toBe(true);
|
||||
expect(story.options[1]?.disabledReason).toBe('队伍已满');
|
||||
});
|
||||
|
||||
it('recognizes server-runtime option pools for server-side legality checks', () => {
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_chat')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('battle_attack_basic')).toBe(true);
|
||||
expect(isRpgRuntimeTaskFunctionId('npc_trade')).toBe(false);
|
||||
expect(isRpgRuntimeServerFunctionId('npc_trade')).toBe(true);
|
||||
expect(isRpgRuntimeServerFunctionId('unknown_action')).toBe(false);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
text: '交易',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldUseRpgRuntimeServerOptions([
|
||||
{
|
||||
functionId: 'unknown_action',
|
||||
actionText: '未知动作',
|
||||
text: '未知动作',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toBe(false);
|
||||
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
|
||||
'runtime-main',
|
||||
);
|
||||
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
|
||||
});
|
||||
|
||||
it('preserves runtime option interaction metadata from the server response', () => {
|
||||
const story = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: '服务端返回的新故事',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_trade',
|
||||
actionText: '交易',
|
||||
scope: 'npc',
|
||||
interaction: {
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(story.options[0]?.interaction).toEqual({
|
||||
kind: 'npc',
|
||||
npcId: 'npc-merchant',
|
||||
action: 'trade',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||
const story = resolveRpgRuntimeStoryMoment({
|
||||
response: {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '普通文本',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
fallbackStoryText: '普通文本',
|
||||
});
|
||||
|
||||
expect(story.displayMode).toBe('dialogue');
|
||||
expect(story.deferredOptions).toHaveLength(1);
|
||||
expect(story.text).toContain('梁伯');
|
||||
});
|
||||
});
|
||||
281
src/services/rpg-runtime/rpgRuntimeStoryClient.ts
Normal file
281
src/services/rpg-runtime/rpgRuntimeStoryClient.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryActionResponse,
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
ServerRuntimeFunctionId,
|
||||
Task5RuntimeFunctionId,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import {
|
||||
SERVER_RUNTIME_FUNCTION_IDS,
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { AnimationState } from '../../types';
|
||||
import { type ApiRetryOptions, requestJson } from '../apiClient';
|
||||
|
||||
const RUNTIME_STORY_API_BASE = '/api/runtime/story';
|
||||
const DEFAULT_SESSION_ID = 'runtime-main';
|
||||
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 220,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
|
||||
const TASK5_RUNTIME_FUNCTION_ID_SET = new Set<string>(
|
||||
TASK5_RUNTIME_FUNCTION_IDS,
|
||||
);
|
||||
const SERVER_RUNTIME_FUNCTION_ID_SET = new Set<string>([
|
||||
...SERVER_RUNTIME_FUNCTION_IDS,
|
||||
]);
|
||||
|
||||
export type RpgRuntimeStoryClientOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
|
||||
GameState,
|
||||
StoryMoment
|
||||
>['snapshot'];
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: option.detailText,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: option.interaction as StoryOption['interaction'] | undefined,
|
||||
runtimePayload: option.payload,
|
||||
disabled: option.disabled,
|
||||
disabledReason: option.reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRuntimeSessionId(
|
||||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||||
) {
|
||||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||||
}
|
||||
|
||||
export function getRuntimeClientVersion(
|
||||
gameState: Pick<GameState, 'runtimeActionVersion'>,
|
||||
) {
|
||||
return typeof gameState.runtimeActionVersion === 'number'
|
||||
? gameState.runtimeActionVersion
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function isTask5RuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is Task5RuntimeFunctionId {
|
||||
return TASK5_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function isServerRuntimeFunctionId(
|
||||
functionId: string,
|
||||
): functionId is ServerRuntimeFunctionId {
|
||||
return SERVER_RUNTIME_FUNCTION_ID_SET.has(functionId);
|
||||
}
|
||||
|
||||
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
|
||||
return Boolean(
|
||||
options?.length &&
|
||||
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
storyText: string;
|
||||
options: RuntimeStoryOptionView[];
|
||||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||||
}): StoryMoment {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options.map((option) =>
|
||||
createRuntimeStoryOption(option, params.gameState),
|
||||
),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
(story.displayMode === 'dialogue' ||
|
||||
story.deferredOptions?.length ||
|
||||
story.dialogue?.length),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRuntimeStoryMoment(params: {
|
||||
response: RuntimeStoryResponse;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
|
||||
fallbackStoryText?: string;
|
||||
}) {
|
||||
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
|
||||
return params.hydratedSnapshot.currentStory!;
|
||||
}
|
||||
|
||||
const options =
|
||||
params.response.viewModel.availableOptions.length > 0
|
||||
? params.response.viewModel.availableOptions
|
||||
: params.response.presentation.options;
|
||||
|
||||
return buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
params.response.presentation.storyText ||
|
||||
params.hydratedSnapshot.currentStory?.text ||
|
||||
params.fallbackStoryText ||
|
||||
'',
|
||||
options,
|
||||
gameState: params.hydratedSnapshot.gameState.currentEncounter
|
||||
? params.hydratedSnapshot.gameState
|
||||
: params.fallbackGameState,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
params: {
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID;
|
||||
const response = params.snapshot
|
||||
? await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
'/state/resolve',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId: normalizedSessionId,
|
||||
clientVersion: params.clientVersion,
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
|
||||
},
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
)
|
||||
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(normalizedSessionId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
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;
|
||||
snapshot?: RuntimeStorySnapshotRequest;
|
||||
},
|
||||
options: RpgRuntimeStoryClientOptions = {},
|
||||
) {
|
||||
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 ?? {}),
|
||||
},
|
||||
},
|
||||
snapshot: params.snapshot,
|
||||
} satisfies RuntimeStoryActionRequest),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
...response,
|
||||
snapshot: rehydrateSavedSnapshot(
|
||||
response.snapshot as HydratedSavedGameSnapshot,
|
||||
),
|
||||
} satisfies RuntimeStoryResponse;
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
|
||||
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
|
||||
export const getRpgRuntimeSessionId = getRuntimeSessionId;
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
|
||||
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
|
||||
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
|
||||
|
||||
export const rpgRuntimeStoryClient = {
|
||||
getActionSnapshot: getRpgRuntimeActionSnapshot,
|
||||
getClientVersion: getRpgRuntimeClientVersion,
|
||||
getSessionId: getRpgRuntimeSessionId,
|
||||
getState: getRpgRuntimeStoryState,
|
||||
resolveAction: resolveRpgRuntimeStoryAction,
|
||||
resolveMoment: resolveRpgRuntimeStoryMoment,
|
||||
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
|
||||
};
|
||||
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
89
src/services/rpg-runtime/rpgSnapshotClient.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
deleteRpgSaveSnapshot,
|
||||
getRpgSaveSnapshot,
|
||||
putRpgSaveSnapshot,
|
||||
} from './rpgSnapshotClient';
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
describe('rpgSnapshotClient routes', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
it('reads the current save snapshot from the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce(null);
|
||||
|
||||
await getRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
version: 2,
|
||||
savedAt: '2026-04-21T09:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
},
|
||||
});
|
||||
|
||||
await putRpgSaveSnapshot({
|
||||
bottomTab: 'adventure',
|
||||
currentStory: null,
|
||||
gameState: {
|
||||
worldType: 'CUSTOM',
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'保存存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the current save snapshot through the runtime save route', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await deleteRpgSaveSnapshot();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/save/snapshot',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'删除存档失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
60
src/services/rpg-runtime/rpgSnapshotClient.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
requestRpgRuntimeJson,
|
||||
type RuntimeRequestOptions,
|
||||
} from './rpgRuntimeRequest';
|
||||
|
||||
export type { RuntimeRequestOptions };
|
||||
|
||||
/**
|
||||
* RPG 运行时快照 client。
|
||||
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
|
||||
*/
|
||||
export async function getRpgSaveSnapshot(
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
}
|
||||
|
||||
export async function putRpgSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
}
|
||||
|
||||
export function deleteRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRpgRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export const rpgSnapshotClient = {
|
||||
getSnapshot: getRpgSaveSnapshot,
|
||||
putSnapshot: putRpgSaveSnapshot,
|
||||
deleteSnapshot: deleteRpgSaveSnapshot,
|
||||
};
|
||||
Reference in New Issue
Block a user