This commit is contained in:
2026-04-28 19:36:39 +08:00
parent a9febe7678
commit f0471a4f8d
206 changed files with 18456 additions and 10133 deletions

View File

@@ -1,10 +1,3 @@
export {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
rpgSnapshotClient,
type RuntimeRequestOptions,
} from './rpgSnapshotClient';
export {
getRpgCharacterChatSuggestions,
getRpgCharacterChatSummary,
@@ -21,12 +14,20 @@ export {
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
rpgRuntimeStoryClient,
shouldUseRpgRuntimeServerOptions,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
type RpgRuntimeStoryClientOptions,
type RuntimeStorySnapshotRequest,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
type RuntimeStoryResponse,
shouldUseRpgRuntimeServerOptions,
} from './rpgRuntimeStoryClient';
export {
deleteRpgSaveSnapshot,
getRpgSaveSnapshot,
putRpgSaveSnapshot,
rpgSnapshotClient,
type RuntimeRequestOptions,
} from './rpgSnapshotClient';

View File

@@ -30,6 +30,8 @@ export function requestRpgRuntimeJson<T>(
options: RuntimeRequestOptions = {},
) {
const method = (init.method ?? 'GET').toUpperCase();
// 中文注释:运行时读请求和写请求的重试策略分开配置;
// GET 更保守,写请求允许 unsafe method retry用来兜底瞬时网络抖动。
const retry =
options.retry ??
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);

View File

@@ -15,12 +15,14 @@ vi.mock('../apiClient', async () => {
import { AnimationState } from '../../types';
import {
beginRpgRuntimeStorySession,
buildStoryMomentFromRuntimeOptions,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
shouldUseRpgRuntimeServerOptions,
@@ -31,6 +33,52 @@ describe('rpgRuntimeStoryClient', () => {
requestJsonMock.mockReset();
});
it('starts runtime sessions through the backend bootstrap endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-server-1',
serverVersion: 1,
snapshot: {
version: 2,
savedAt: '2026-04-28T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {
runtimeSessionId: 'runtime-server-1',
currentScene: 'Story',
playerCharacter: { id: 'role-1', name: '沈砺' },
playerEquipment: { weapon: null, armor: null, relic: null },
},
currentStory: null,
},
});
const result = await beginRpgRuntimeStorySession({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' } as never,
character: { id: 'role-1', name: '沈砺' } as never,
runtimeMode: 'play',
disablePersistence: false,
});
expect(result.snapshot.gameState.runtimeSessionId).toBe(
'runtime-server-1',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/sessions',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' },
character: { id: 'role-1', name: '沈砺' },
runtimeMode: 'play',
disablePersistence: false,
}),
}),
'初始化运行时开局失败',
expect.any(Object),
);
});
it('builds runtime action requests against the dedicated story endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
@@ -76,7 +124,6 @@ describe('rpgRuntimeStoryClient', () => {
optionText: '继续交谈',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -131,7 +178,6 @@ describe('rpgRuntimeStoryClient', () => {
itemId: 'focus-tonic',
},
},
snapshot: undefined,
}),
}),
'执行运行时动作失败',
@@ -139,7 +185,7 @@ describe('rpgRuntimeStoryClient', () => {
);
});
it('submits runtime state resolution with snapshot context to the server', async () => {
it('reads runtime story state by server session id', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 4,
@@ -179,34 +225,103 @@ describe('rpgRuntimeStoryClient', () => {
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',
'/api/runtime/story/state/runtime-main',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 7,
snapshot: {
gameState: {
currentScene: 'Story',
method: 'GET',
}),
'读取运行时故事状态失败',
expect.any(Object),
);
});
it('loads backend inventory view from runtime story state', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-inventory',
serverVersion: 5,
viewModel: {
player: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
},
encounter: null,
companions: [],
inventory: {
playerCurrency: 90,
currencyText: '90 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
currencyText: '18 铜钱',
requirements: [
{
id: 'material:any',
label: '任意材料',
quantity: 3,
owned: 3,
},
],
canCraft: true,
action: {
functionId: 'forge_craft',
actionText: '制作精炼锭材',
payload: { recipeId: 'synthesis-refined-ingot' },
enabled: true,
},
},
bottomTab: 'adventure',
currentStory: {
text: '本地故事',
options: [],
},
},
}),
],
},
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: {
runtimeSessionId: 'runtime-inventory',
runtimeActionVersion: 5,
},
currentStory: null,
},
});
const view = await loadRpgRuntimeInventoryView({
gameState: {
runtimeSessionId: 'runtime-inventory',
runtimeActionVersion: 5,
} as never,
});
expect(view.forgeRecipes[0]?.action.functionId).toBe('forge_craft');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/state/runtime-inventory',
expect.objectContaining({
method: 'GET',
}),
'读取运行时故事状态失败',
expect.any(Object),
@@ -336,6 +451,14 @@ describe('rpgRuntimeStoryClient', () => {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
inventory: {
playerCurrency: 0,
currencyText: '0 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
availableOptions: [],
status: {
inBattle: false,

View File

@@ -1,9 +1,3 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryOptionView,
RuntimeStoryStateRequest,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
RuntimeStoryChoicePayload,
ServerRuntimeFunctionId,
@@ -13,6 +7,13 @@ import {
SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_FUNCTION_IDS,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryBootstrapRequest,
RuntimeStoryBootstrapResponse,
RuntimeStoryOptionView,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { GameState, StoryMoment, StoryOption } from '../../types';
@@ -44,11 +45,13 @@ export type RuntimeStoryResponse = RuntimeStoryActionResponse<
GameState,
StoryMoment
>;
export type { RuntimeStoryChoicePayload };
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
export type RuntimeStoryBootstrapResult = RuntimeStoryBootstrapResponse<
GameState,
StoryMoment
>['snapshot'];
>;
export type RuntimeStoryInventoryView =
RuntimeStoryResponse['viewModel']['inventory'];
export type { RuntimeStoryChoicePayload };
function requestRuntimeStoryJson<T>(
path: string,
@@ -56,6 +59,8 @@ function requestRuntimeStoryJson<T>(
fallbackMessage: string,
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释runtime story 请求默认带一层轻量重试,
// 因为这里既有 state 拉取,也有动作结算,请求失败会直接影响当前回合体验。
return requestJson<T>(
`${RUNTIME_STORY_API_BASE}${path}`,
{
@@ -71,6 +76,8 @@ function createRuntimeStoryOption(
option: RuntimeStoryOptionView,
_gameState?: Pick<GameState, 'currentEncounter'>,
): StoryOption {
// 中文注释:服务端 viewModel 当前只返回动作层字段,
// 前端在这里补齐 StoryOption 所需的基础表现字段,保持冒险面板消费接口稳定。
return {
functionId: option.functionId,
actionText: option.actionText,
@@ -118,6 +125,8 @@ export function isServerRuntimeFunctionId(
}
export function shouldUseServerRuntimeOptions(options: StoryOption[] | null) {
// 中文注释:只有当整组选项都已经切换到服务端 function id 体系时,
// 前端才把这轮视为“纯服务端 runtime 选项”,避免本地/服务端动作混用。
return Boolean(
options?.length &&
options.every((option) => isServerRuntimeFunctionId(option.functionId)),
@@ -152,6 +161,8 @@ export function resolveRuntimeStoryMoment(params: {
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
fallbackStoryText?: string;
}) {
// 中文注释:对话态 story 往往包含 deferredOptions / dialogue 结构,
// 这类内容如果已经存进快照,应优先使用快照,避免被普通 presentation 选项覆盖。
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
return params.hydratedSnapshot.currentStory!;
}
@@ -178,32 +189,18 @@ 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,
);
// 中文注释runtime story 状态读取只按服务端持久化 sessionId 拉取,
// 不再允许前端上传本地 GameState 快照参与解析。
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
`/state/${encodeURIComponent(normalizedSessionId)}`,
{ method: 'GET' },
'读取运行时故事状态失败',
options,
);
return {
...response,
@@ -213,6 +210,51 @@ export async function getRuntimeStoryState(
} satisfies RuntimeStoryResponse;
}
export async function loadRuntimeInventoryView(
params: {
gameState: Pick<GameState, 'runtimeSessionId' | 'runtimeActionVersion'>;
},
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释:背包 / 装备 / 锻造 view 只读取后端已持久化的 runtime session
// 前端不再用本地背包、货币或装备状态重算配方可用性。
const response = await getRuntimeStoryState(
{
sessionId: getRuntimeSessionId(params.gameState),
clientVersion: getRuntimeClientVersion(params.gameState),
},
options,
);
return response.viewModel.inventory;
}
export async function beginRuntimeStorySession(
params: RuntimeStoryBootstrapRequest<
GameState['customWorldProfile'],
NonNullable<GameState['playerCharacter']>
>,
options: RpgRuntimeStoryClientOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryBootstrapResult>(
'/sessions',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'初始化运行时开局失败',
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
),
} satisfies RuntimeStoryBootstrapResult;
}
export async function resolveRuntimeStoryAction(
params: {
sessionId?: string;
@@ -220,10 +262,11 @@ export async function resolveRuntimeStoryAction(
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
payload?: RuntimeStoryChoicePayload;
snapshot?: RuntimeStorySnapshotRequest;
},
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释story_choice 是当前前端统一提交给服务端的动作包裹格式,
// optionText 会一起带上,方便服务端日志、提示词和调试链查看用户当轮选择。
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/actions/resolve',
{
@@ -241,7 +284,6 @@ export async function resolveRuntimeStoryAction(
...(params.payload ?? {}),
},
},
snapshot: params.snapshot,
} satisfies RuntimeStoryActionRequest),
},
'执行运行时动作失败',
@@ -260,19 +302,23 @@ export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
}
export const beginRpgRuntimeStorySession = beginRuntimeStorySession;
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 loadRpgRuntimeInventoryView = loadRuntimeInventoryView;
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
export const rpgRuntimeStoryClient = {
beginSession: beginRpgRuntimeStorySession,
getActionSnapshot: getRpgRuntimeActionSnapshot,
getClientVersion: getRpgRuntimeClientVersion,
getInventoryView: loadRpgRuntimeInventoryView,
getSessionId: getRpgRuntimeSessionId,
getState: getRpgRuntimeStoryState,
resolveAction: resolveRpgRuntimeStoryAction,

View File

@@ -34,7 +34,7 @@ describe('rpgSnapshotClient routes', () => {
);
});
it('writes the current save snapshot through the runtime save route', async () => {
it('requests a backend checkpoint instead of uploading the runtime snapshot', async () => {
requestJsonMock.mockResolvedValueOnce({
version: 2,
savedAt: '2026-04-21T09:00:00.000Z',
@@ -46,13 +46,19 @@ describe('rpgSnapshotClient routes', () => {
});
await putRpgSaveSnapshot({
sessionId: 'runtime-main',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
} as never,
});
const [, init] = requestJsonMock.mock.calls[0];
const body = JSON.parse(init.body as string);
expect(body).toEqual({
sessionId: 'runtime-main',
bottomTab: 'adventure',
});
expect(body.gameState).toBeUndefined();
expect(body.currentStory).toBeUndefined();
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/save/snapshot',
expect.objectContaining({

View File

@@ -1,5 +1,5 @@
import type { BasicOkResult } from '../../../packages/shared/src/contracts/runtime';
import type { SavedGameSnapshotInput } from '../../persistence/gameSaveStorage';
import type { RuntimeSaveCheckpointInput } from '../../persistence/gameSaveStorage';
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
@@ -13,29 +13,32 @@ export type { RuntimeRequestOptions };
* RPG 运行时快照 client。
* 工作包 C 起由新域目录承载真实实现,旧 `storageService` 仅保留兼容转发。
*/
export async function getRpgSaveSnapshot(
options: RuntimeRequestOptions = {},
) {
const snapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
export async function getRpgSaveSnapshot(options: RuntimeRequestOptions = {}) {
// 中文注释:远端返回的是可序列化快照;
// 客户端每次读取后都先做一次 rehydrate恢复 Date / 枚举 / 运行时默认字段。
const snapshot =
await requestRpgRuntimeJson<HydratedSavedGameSnapshot | null>(
'/save/snapshot',
{ method: 'GET' },
'读取存档失败',
options,
);
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
}
export async function putRpgSaveSnapshot(
snapshot: SavedGameSnapshotInput,
checkpoint: RuntimeSaveCheckpointInput,
options: RuntimeRequestOptions = {},
) {
// 中文注释:自动/手动存档只提交 checkpoint 元数据;
// 运行时真相由服务端读取已持久化快照后刷新,避免浏览器上传整份 GameState。
const savedSnapshot = await requestRpgRuntimeJson<HydratedSavedGameSnapshot>(
'/save/snapshot',
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snapshot),
body: JSON.stringify(checkpoint),
},
'保存存档失败',
options,