282 lines
8.4 KiB
TypeScript
282 lines
8.4 KiB
TypeScript
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<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 RuntimeStoryResponse = RuntimeStoryActionResponse<
|
|
GameState,
|
|
StoryMoment
|
|
>;
|
|
export type { RuntimeStoryChoicePayload };
|
|
export type RuntimeStorySnapshotRequest = RuntimeStoryStateRequest<
|
|
GameState,
|
|
StoryMoment
|
|
>['snapshot'];
|
|
|
|
function requestRuntimeStoryJson<T>(
|
|
path: string,
|
|
init: RequestInit,
|
|
fallbackMessage: string,
|
|
options: RpgRuntimeStoryClientOptions = {},
|
|
) {
|
|
return requestJson<T>(
|
|
`${RUNTIME_STORY_API_BASE}${path}`,
|
|
{
|
|
...init,
|
|
signal: options.signal,
|
|
},
|
|
fallbackMessage,
|
|
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
|
);
|
|
}
|
|
|
|
function createRuntimeStoryOption(
|
|
option: RuntimeStoryOptionView,
|
|
_gameState?: Pick<GameState, 'currentEncounter'>,
|
|
): 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<GameState, 'runtimeSessionId'>,
|
|
) {
|
|
return gameState.runtimeSessionId?.trim() || DEFAULT_SESSION_ID;
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
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<RuntimeStoryResponse>(
|
|
'/state/resolve',
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
sessionId: normalizedSessionId,
|
|
clientVersion: params.clientVersion,
|
|
snapshot: params.snapshot,
|
|
} satisfies RuntimeStoryStateRequest<GameState, StoryMoment>),
|
|
},
|
|
'读取运行时故事状态失败',
|
|
options,
|
|
)
|
|
: await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
|
`/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<StoryOption, 'functionId' | 'actionText'>;
|
|
targetId?: string;
|
|
payload?: RuntimeStoryChoicePayload;
|
|
snapshot?: RuntimeStorySnapshotRequest;
|
|
},
|
|
options: RpgRuntimeStoryClientOptions = {},
|
|
) {
|
|
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
|
|
'/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,
|
|
};
|