1
This commit is contained in:
218
src/services/runtimeStoryService.ts
Normal file
218
src/services/runtimeStoryService.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
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 type {
|
||||
HydratedGameState,
|
||||
HydratedSavedGameSnapshot,
|
||||
} from '../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../types';
|
||||
import { AnimationState } from '../types';
|
||||
import { requestJson, type ApiRetryOptions } 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 RuntimeStoryServiceOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
|
||||
HydratedGameState,
|
||||
StoryMoment
|
||||
>;
|
||||
export type { RuntimeStoryChoicePayload };
|
||||
|
||||
function requestRuntimeStoryJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_STORY_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimeOptionInteraction(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption['interaction'] {
|
||||
const encounter = gameState?.currentEncounter;
|
||||
|
||||
if (encounter?.kind === 'npc') {
|
||||
const npcId = encounter.id ?? encounter.npcName;
|
||||
const npcActionMap: Record<string, StoryOption['interaction']> = {
|
||||
npc_chat: { kind: 'npc', npcId, action: 'chat' },
|
||||
npc_help: { kind: 'npc', npcId, action: 'help' },
|
||||
npc_fight: { kind: 'npc', npcId, action: 'fight' },
|
||||
npc_leave: { kind: 'npc', npcId, action: 'leave' },
|
||||
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
|
||||
npc_spar: { kind: 'npc', npcId, action: 'spar' },
|
||||
npc_trade: { kind: 'npc', npcId, action: 'trade' },
|
||||
npc_gift: { kind: 'npc', npcId, action: 'gift' },
|
||||
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
|
||||
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
|
||||
};
|
||||
|
||||
return npcActionMap[option.functionId];
|
||||
}
|
||||
|
||||
if (encounter?.kind === 'treasure') {
|
||||
const treasureActionMap: Record<string, StoryOption['interaction']> = {
|
||||
treasure_secure: { kind: 'treasure', action: 'secure' },
|
||||
treasure_inspect: { kind: 'treasure', action: 'inspect' },
|
||||
treasure_leave: { kind: 'treasure', action: 'leave' },
|
||||
};
|
||||
|
||||
return treasureActionMap[option.functionId];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createRuntimeStoryOption(
|
||||
option: RuntimeStoryOptionView,
|
||||
gameState?: Pick<GameState, 'currentEncounter'>,
|
||||
): StoryOption {
|
||||
const detailParts = [option.detailText, option.disabled ? option.reason : null]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return {
|
||||
functionId: option.functionId,
|
||||
actionText: option.actionText,
|
||||
text: option.actionText,
|
||||
detailText: detailParts || undefined,
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
interaction: buildRuntimeOptionInteraction(option, gameState),
|
||||
};
|
||||
}
|
||||
|
||||
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'>;
|
||||
}) {
|
||||
return {
|
||||
text: params.storyText,
|
||||
options: params.options
|
||||
.filter((option) => !option.disabled)
|
||||
.map((option) => createRuntimeStoryOption(option, params.gameState)),
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return requestRuntimeStoryJson<RuntimeStoryResponse>(
|
||||
`/state/${encodeURIComponent(sessionId || DEFAULT_SESSION_ID)}`,
|
||||
{ method: 'GET' },
|
||||
'读取运行时故事状态失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(
|
||||
params: {
|
||||
sessionId?: string;
|
||||
clientVersion?: number;
|
||||
option: Pick<StoryOption, 'functionId' | 'actionText'>;
|
||||
targetId?: string;
|
||||
payload?: RuntimeStoryChoicePayload;
|
||||
},
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
) {
|
||||
return 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 ?? {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
'执行运行时动作失败',
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
|
||||
return response.snapshot as HydratedSavedGameSnapshot;
|
||||
}
|
||||
Reference in New Issue
Block a user