init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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';

View 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,
};

View 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,
});
}

View 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('梁伯');
});
});

View 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,
};

View 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,
}),
}),
);
});
});

View 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,
};