import type { RuntimeStoryActionResponse, RuntimeStoryChoicePayload, RuntimeStoryOptionView, ServerRuntimeFunctionId, Task5RuntimeFunctionId, } from '../../packages/shared/src/contracts/story'; import { SERVER_RUNTIME_FUNCTION_IDS, TASK5_RUNTIME_FUNCTION_IDS, } from '../../packages/shared/src/contracts/story'; import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot'; import type { HydratedGameState, 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( TASK5_RUNTIME_FUNCTION_IDS, ); const SERVER_RUNTIME_FUNCTION_ID_SET = new Set([ ...SERVER_RUNTIME_FUNCTION_IDS, ]); export type RuntimeStoryServiceOptions = { signal?: AbortSignal; retry?: ApiRetryOptions; }; export type RuntimeStoryResponse = RuntimeStoryActionResponse< HydratedGameState, StoryMoment >; export type { RuntimeStoryChoicePayload }; function requestRuntimeStoryJson( path: string, init: RequestInit, fallbackMessage: string, options: RuntimeStoryServiceOptions = {}, ) { return requestJson( `${RUNTIME_STORY_API_BASE}${path}`, { ...init, signal: options.signal, }, fallbackMessage, { retry: options.retry ?? RUNTIME_STORY_RETRY }, ); } function createRuntimeStoryOption( option: RuntimeStoryOptionView, _gameState?: Pick, ): 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, ) { return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID; } 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) { 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; } 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; }) { 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( sessionId: string, options: RuntimeStoryServiceOptions = {}, ) { const response = await requestRuntimeStoryJson( `/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`, { method: 'GET' }, '读取运行时故事状态失败', options, ); return { ...response, snapshot: rehydrateSavedSnapshot( response.snapshot as HydratedSavedGameSnapshot, ), } satisfies RuntimeStoryResponse; } export async function resolveRuntimeStoryAction( params: { sessionId?: string; clientVersion?: number; option: Pick; targetId?: string; payload?: RuntimeStoryChoicePayload; }, options: RuntimeStoryServiceOptions = {}, ) { const response = await requestRuntimeStoryJson( '/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 ?? {}), }, }, }), }, '执行运行时动作失败', options, ); return { ...response, snapshot: rehydrateSavedSnapshot( response.snapshot as HydratedSavedGameSnapshot, ), } satisfies RuntimeStoryResponse; } export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) { return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot); }