import type { RuntimeStoryActionRequest, RuntimeStoryActionResponse, RuntimeStoryOptionView, RuntimeStoryStateRequest, } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState'; 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 { 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 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 RpgRuntimeStoryClientOptions = { signal?: AbortSignal; retry?: ApiRetryOptions; }; export type RuntimeStoryResponse = RuntimeStoryActionResponse< GameState, StoryMoment >; export type { RuntimeStoryChoicePayload }; export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest< GameState, StoryMoment >['snapshot']; function requestRuntimeStoryJson( path: string, init: RequestInit, fallbackMessage: string, options: RpgRuntimeStoryClientOptions = {}, ) { 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( params: { sessionId: string; clientVersion?: number; snapshot?: RuntimeStorySnapshotRequest; }, options: RpgRuntimeStoryClientOptions = {}, ) { const normalizedSessionId = params.sessionId || DEFAULT_SESSION_ID; const response = params.snapshot ? await requestRuntimeStoryJson( '/state/resolve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: normalizedSessionId, clientVersion: params.clientVersion, snapshot: params.snapshot, } satisfies RuntimeStoryStateRequest), }, '读取运行时故事状态失败', options, ) : await requestRuntimeStoryJson( `/state/${encodeURIComponent(normalizedSessionId)}`, { 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; snapshot?: RuntimeStorySnapshotRequest; }, options: RpgRuntimeStoryClientOptions = {}, ) { 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 ?? {}), }, }, snapshot: params.snapshot, } satisfies RuntimeStoryActionRequest), }, '执行运行时动作失败', options, ); return { ...response, snapshot: rehydrateSavedSnapshot( response.snapshot as HydratedSavedGameSnapshot, ), } satisfies RuntimeStoryResponse; } export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) { return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot); } 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 resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction; export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment; export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions; export const rpgRuntimeStoryClient = { getActionSnapshot: getRpgRuntimeActionSnapshot, getClientVersion: getRpgRuntimeClientVersion, getSessionId: getRpgRuntimeSessionId, getState: getRpgRuntimeStoryState, resolveAction: resolveRpgRuntimeStoryAction, resolveMoment: resolveRpgRuntimeStoryMoment, shouldUseServerOptions: shouldUseRpgRuntimeServerOptions, };