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( TASK5_RUNTIME_FUNCTION_IDS, ); const SERVER_RUNTIME_FUNCTION_ID_SET = new Set([ ...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( path: string, init: RequestInit, fallbackMessage: string, options: RpgRuntimeStoryClientOptions = {}, ) { return requestJson( `${STORY_SESSIONS_API_BASE}${path}`, { ...init, signal: options.signal, }, fallbackMessage, { retry: options.retry ?? RUNTIME_STORY_RETRY }, ); } function createRuntimeStoryOption( option: RuntimeStoryOptionView, _gameState?: Pick, ): 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, ) { return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID; } export function getRuntimeStorySessionId( gameState: Pick, ) { 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, ) { 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; }): StoryMoment { return { text: params.storyText, options: params.options.map((option) => createRuntimeStoryOption(option, params.gameState), ), } satisfies StoryMoment; } export function buildStoryMomentFromRuntimeProjection(params: { projection: StoryRuntimeProjectionResponse; gameState?: Pick; }): 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; 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( '', { 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( '/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( `/${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( `/${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; }, 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 >, options: RpgRuntimeStoryClientOptions = {}, ) { const response = await requestStorySessionJson( '/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; targetId?: string; payload?: RuntimeStoryChoicePayload; }, options: RpgRuntimeStoryClientOptions = {}, ) { const storySessionId = normalizeStorySessionId( params.storySessionId, '故事会话不存在,无法执行运行时动作', ); // 中文注释:写入 DTO 采用 story session scoped 扁平字段; // optionText 仍放进 payload,方便服务端日志、提示词和调试链查看用户当轮选择。 const response = await requestStorySessionJson( `/${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; }