495 lines
15 KiB
TypeScript
495 lines
15 KiB
TypeScript
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 type {
|
||
RuntimeStoryOptionView,
|
||
RuntimeBattlePresentation,
|
||
RuntimeStoryInventoryViewModel,
|
||
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||
import type {
|
||
BeginStoryRuntimeSessionRequest,
|
||
BeginStorySessionRequest,
|
||
ContinueStoryRequest,
|
||
ResolveStoryRuntimeActionRequest,
|
||
StoryRuntimeMutationResponse,
|
||
StorySessionMutationResponse,
|
||
StorySessionStateResponse,
|
||
StoryRuntimeOptionProjection,
|
||
StoryRuntimeProjectionResponse,
|
||
} from '../../../packages/shared/src/contracts/story';
|
||
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 STORY_SESSIONS_API_BASE = '/api/story/sessions';
|
||
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 RuntimeStoryActionPresentation = {
|
||
battle?: RuntimeBattlePresentation | null;
|
||
resultText?: string;
|
||
storyText?: string;
|
||
};
|
||
export type RuntimeStoryInventoryView = RuntimeStoryInventoryViewModel;
|
||
export type RuntimeStoryResponse = {
|
||
sessionId: string;
|
||
serverVersion: number;
|
||
projection: StoryRuntimeProjectionResponse;
|
||
snapshot: HydratedSavedGameSnapshot;
|
||
inventoryView: RuntimeStoryInventoryView;
|
||
presentation?: RuntimeStoryActionPresentation;
|
||
};
|
||
export type StorySessionMutationResult = StorySessionMutationResponse;
|
||
export type StorySessionStateResult = StorySessionStateResponse;
|
||
export type RuntimeStoryProjectionResult = StoryRuntimeProjectionResponse;
|
||
export type { RuntimeStoryChoicePayload };
|
||
|
||
function requestStorySessionJson<T>(
|
||
path: string,
|
||
init: RequestInit,
|
||
fallbackMessage: string,
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
return requestJson<T>(
|
||
`${STORY_SESSIONS_API_BASE}${path}`,
|
||
{
|
||
...init,
|
||
signal: options.signal,
|
||
},
|
||
fallbackMessage,
|
||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||
);
|
||
}
|
||
|
||
function createRuntimeStoryOption(
|
||
option: RuntimeStoryOptionView,
|
||
_gameState?: Pick<GameState, 'currentEncounter'>,
|
||
): StoryOption {
|
||
// 中文注释:服务端投影当前只返回动作层字段,
|
||
// 前端在这里补齐 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,
|
||
};
|
||
}
|
||
|
||
function normalizeProjectionOptionScope(
|
||
scope: string,
|
||
): RuntimeStoryOptionView['scope'] {
|
||
return scope === 'combat' || scope === 'npc' ? scope : 'story';
|
||
}
|
||
|
||
function mapRuntimeProjectionOption(
|
||
option: StoryRuntimeOptionProjection,
|
||
): RuntimeStoryOptionView {
|
||
return {
|
||
functionId: option.functionId,
|
||
actionText: option.actionText,
|
||
detailText: option.detailText ?? undefined,
|
||
scope: normalizeProjectionOptionScope(option.scope),
|
||
payload: option.payload ?? undefined,
|
||
disabled: option.enabled ? undefined : true,
|
||
reason: option.enabled ? undefined : (option.reason ?? undefined),
|
||
};
|
||
}
|
||
|
||
function mapRuntimeProjectionInventory(
|
||
projection: StoryRuntimeProjectionResponse,
|
||
): RuntimeStoryInventoryView {
|
||
return {
|
||
playerCurrency: projection.actor.currency,
|
||
currencyText: projection.actor.currencyText,
|
||
inBattle: projection.status.inBattle,
|
||
backpackItems:
|
||
projection.inventory
|
||
.backpackItems as RuntimeStoryInventoryView['backpackItems'],
|
||
equipmentSlots:
|
||
projection.inventory
|
||
.equipmentSlots as RuntimeStoryInventoryView['equipmentSlots'],
|
||
forgeRecipes:
|
||
projection.inventory
|
||
.forgeRecipes as RuntimeStoryInventoryView['forgeRecipes'],
|
||
};
|
||
}
|
||
|
||
function getRuntimeProjectionStoryText(
|
||
projection: Pick<
|
||
StoryRuntimeProjectionResponse,
|
||
'currentNarrativeText' | 'storySession'
|
||
>,
|
||
) {
|
||
return (
|
||
projection.currentNarrativeText?.trim() ||
|
||
projection.storySession.latestNarrativeText.trim()
|
||
);
|
||
}
|
||
|
||
export function buildRuntimeSnapshotFromProjection(
|
||
projection: StoryRuntimeProjectionResponse,
|
||
bottomTab: HydratedSavedGameSnapshot['bottomTab'] = 'adventure',
|
||
): HydratedSavedGameSnapshot {
|
||
const gameState = {
|
||
...(projection.gameState as unknown as GameState),
|
||
runtimeSessionId: projection.storySession.runtimeSessionId,
|
||
storySessionId: projection.storySession.storySessionId,
|
||
runtimeActionVersion: projection.serverVersion,
|
||
} satisfies GameState;
|
||
const currentStory = buildStoryMomentFromRuntimeProjection({
|
||
projection,
|
||
gameState,
|
||
});
|
||
|
||
// 中文注释:新写入接口只返回 story runtime 投影,前端在边界层
|
||
// 还原为已有运行时快照格式,避免下游 hooks 继续认识旧 action response。
|
||
return rehydrateSavedSnapshot({
|
||
version: projection.serverVersion,
|
||
savedAt: projection.storySession.updatedAt,
|
||
bottomTab,
|
||
gameState,
|
||
currentStory,
|
||
} as HydratedSavedGameSnapshot);
|
||
}
|
||
|
||
function normalizeRuntimeMutationResponse(
|
||
response: StoryRuntimeMutationResponse,
|
||
): RuntimeStoryResponse {
|
||
const { projection } = response;
|
||
const snapshot = buildRuntimeSnapshotFromProjection(projection);
|
||
|
||
return {
|
||
sessionId: projection.storySession.runtimeSessionId,
|
||
serverVersion: projection.serverVersion,
|
||
projection,
|
||
snapshot,
|
||
inventoryView: mapRuntimeProjectionInventory(projection),
|
||
};
|
||
}
|
||
|
||
export function getRuntimeSessionId(
|
||
gameState: Pick<GameState, 'runtimeSessionId'>,
|
||
) {
|
||
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
||
}
|
||
|
||
export function getRuntimeStorySessionId(
|
||
gameState: Pick<GameState, 'storySessionId'>,
|
||
) {
|
||
return normalizeStorySessionId(
|
||
gameState.storySessionId,
|
||
'运行时故事会话不存在,无法读取服务端投影',
|
||
);
|
||
}
|
||
|
||
function normalizeStorySessionId(
|
||
storySessionId: string | null | undefined,
|
||
message: string,
|
||
) {
|
||
const normalizedStorySessionId = storySessionId?.trim();
|
||
if (!normalizedStorySessionId) {
|
||
throw new Error(message);
|
||
}
|
||
|
||
return normalizedStorySessionId;
|
||
}
|
||
|
||
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) {
|
||
// 中文注释:只有当整组选项都已经切换到服务端 function id 体系时,
|
||
// 前端才把这轮视为“纯服务端 runtime 选项”,避免本地/服务端动作混用。
|
||
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;
|
||
}
|
||
|
||
export function buildStoryMomentFromRuntimeProjection(params: {
|
||
projection: StoryRuntimeProjectionResponse;
|
||
gameState?: Pick<GameState, 'currentEncounter'>;
|
||
}): StoryMoment {
|
||
return buildStoryMomentFromRuntimeOptions({
|
||
storyText: getRuntimeProjectionStoryText(params.projection),
|
||
options: params.projection.options.map(mapRuntimeProjectionOption),
|
||
gameState: params.gameState,
|
||
});
|
||
}
|
||
|
||
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;
|
||
}) {
|
||
// 中文注释:对话态 story 往往包含 deferredOptions / dialogue 结构,
|
||
// 这类内容如果已经存进快照,应优先使用快照,避免被普通 presentation 选项覆盖。
|
||
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
|
||
return params.hydratedSnapshot.currentStory!;
|
||
}
|
||
|
||
return buildStoryMomentFromRuntimeOptions({
|
||
storyText:
|
||
params.response.presentation?.storyText ||
|
||
params.hydratedSnapshot.currentStory?.text ||
|
||
params.fallbackStoryText ||
|
||
'',
|
||
options: params.response.projection.options.map(mapRuntimeProjectionOption),
|
||
gameState: params.hydratedSnapshot.gameState.currentEncounter
|
||
? params.hydratedSnapshot.gameState
|
||
: params.fallbackGameState,
|
||
});
|
||
}
|
||
|
||
export async function beginStorySession(
|
||
params: BeginStorySessionRequest,
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
return requestStorySessionJson<StorySessionMutationResult>(
|
||
'',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
},
|
||
'创建故事会话失败',
|
||
options,
|
||
);
|
||
}
|
||
|
||
export async function continueStorySession(
|
||
params: ContinueStoryRequest,
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
const storySessionId = normalizeStorySessionId(
|
||
params.storySessionId,
|
||
'故事会话不存在,无法继续故事',
|
||
);
|
||
|
||
return requestStorySessionJson<StorySessionMutationResult>(
|
||
'/continue',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
...params,
|
||
storySessionId,
|
||
}),
|
||
},
|
||
'继续故事会话失败',
|
||
options,
|
||
);
|
||
}
|
||
|
||
export async function getStorySessionState(
|
||
params: { storySessionId: string },
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
const storySessionId = normalizeStorySessionId(
|
||
params.storySessionId,
|
||
'故事会话不存在,无法读取故事会话状态',
|
||
);
|
||
|
||
return requestStorySessionJson<StorySessionStateResult>(
|
||
`/${encodeURIComponent(storySessionId)}/state`,
|
||
{ method: 'GET' },
|
||
'读取故事会话状态失败',
|
||
options,
|
||
);
|
||
}
|
||
|
||
export async function getStoryRuntimeProjection(
|
||
params: {
|
||
storySessionId: string;
|
||
clientVersion?: number;
|
||
},
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
const storySessionId = normalizeStorySessionId(
|
||
params.storySessionId,
|
||
'运行时故事会话不存在,无法读取服务端投影',
|
||
);
|
||
|
||
// 中文注释:当前 BFF route 以 storySessionId 为唯一读取键;
|
||
// clientVersion 保留在调用签名里,等待后端增量投影契约稳定后再接查询参数。
|
||
return requestStorySessionJson<RuntimeStoryProjectionResult>(
|
||
`/${encodeURIComponent(storySessionId)}/runtime-projection`,
|
||
{ method: 'GET' },
|
||
'读取运行时故事投影失败',
|
||
options,
|
||
);
|
||
}
|
||
|
||
export async function getRuntimeStoryState(
|
||
params: {
|
||
storySessionId: string;
|
||
clientVersion?: number;
|
||
},
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
// 中文注释:读取侧正式切到 story session scoped 投影;
|
||
// 这里不允许用 runtimeSessionId 兜底,避免两个会话主键被悄悄混用。
|
||
return getStoryRuntimeProjection(params, options);
|
||
}
|
||
|
||
export async function loadRuntimeInventoryView(
|
||
params: {
|
||
gameState: Pick<GameState, 'storySessionId' | 'runtimeActionVersion'>;
|
||
},
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
// 中文注释:背包 / 装备 / 锻造 view 只读取 story runtime 投影;
|
||
// 前端不再用本地背包、货币或装备状态重算配方可用性。
|
||
const response = await getRuntimeStoryState(
|
||
{
|
||
storySessionId: getRuntimeStorySessionId(params.gameState),
|
||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||
},
|
||
options,
|
||
);
|
||
|
||
return mapRuntimeProjectionInventory(response);
|
||
}
|
||
|
||
export async function beginRuntimeStorySession(
|
||
params: BeginStoryRuntimeSessionRequest<
|
||
GameState['customWorldProfile'],
|
||
NonNullable<GameState['playerCharacter']>
|
||
>,
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
const response = await requestStorySessionJson<StoryRuntimeMutationResponse>(
|
||
'/runtime',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(params),
|
||
},
|
||
'初始化运行时开局失败',
|
||
options,
|
||
);
|
||
|
||
return normalizeRuntimeMutationResponse(response);
|
||
}
|
||
|
||
export async function resolveRuntimeStoryAction(
|
||
params: {
|
||
storySessionId: string;
|
||
clientVersion?: number;
|
||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||
targetId?: string;
|
||
payload?: RuntimeStoryChoicePayload;
|
||
},
|
||
options: RpgRuntimeStoryClientOptions = {},
|
||
) {
|
||
const storySessionId = normalizeStorySessionId(
|
||
params.storySessionId,
|
||
'故事会话不存在,无法执行运行时动作',
|
||
);
|
||
// 中文注释:写入 DTO 采用 story session scoped 扁平字段;
|
||
// optionText 仍放进 payload,方便服务端日志、提示词和调试链查看用户当轮选择。
|
||
const response = await requestStorySessionJson<StoryRuntimeMutationResponse>(
|
||
`/${encodeURIComponent(storySessionId)}/actions/resolve`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
storySessionId,
|
||
clientVersion: params.clientVersion,
|
||
functionId: params.option.functionId,
|
||
actionText: params.option.actionText,
|
||
targetId: params.targetId,
|
||
payload: {
|
||
optionText: params.option.actionText,
|
||
...(params.payload ?? {}),
|
||
},
|
||
} satisfies ResolveStoryRuntimeActionRequest),
|
||
},
|
||
'执行运行时动作失败',
|
||
options,
|
||
);
|
||
|
||
return normalizeRuntimeMutationResponse(response);
|
||
}
|
||
|
||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||
return response.snapshot;
|
||
}
|