Files
Genarrative/src/services/rpg-runtime/rpgRuntimeStoryClient.ts

495 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}