Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions

View File

@@ -10,6 +10,7 @@ import type {
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story';
const {
connectivityError,
@@ -101,6 +102,7 @@ function createContext(
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 3,
playerHp: 30,
playerMaxHp: 40,
@@ -115,6 +117,8 @@ function createContext(
sceneName: 'Forest Trail',
sceneDescription: 'A quiet mountain path.',
pendingSceneEncounter: false,
observeSignsRequested: false,
recentActionResult: null,
...overrides,
};
}
@@ -424,6 +428,71 @@ function createApiEnvelopeResponse(data: unknown) {
} as Response;
}
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-main',
worldProfileId: 'profile-main',
initialPrompt: '进入山路',
openingSummary: null,
latestNarrativeText: '山路尽头传来新的动静。',
latestChoiceFunctionId: null,
status: 'active',
version: 3,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:01.000Z',
...(overrides.storySession ?? {}),
} satisfies StoryRuntimeProjectionResponse['storySession'];
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion: overrides.serverVersion ?? storySession.version,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: overrides.serverVersion ?? storySession.version,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 30,
maxHp: 40,
mana: 12,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createSseResponse(text: string) {
const encoder = new TextEncoder();
const chunks = [
@@ -478,14 +547,26 @@ describe('ai runtime client orchestration', () => {
streamPlainTextCompletionMock.mockReset();
});
it('requests initial story from the runtime api server', async () => {
it('requests initial story from the story session projection', async () => {
const availableOptions = [createStoryOption()];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '山路尽头传来新的动静。',
options: availableOptions,
encounter: null,
}),
createApiEnvelopeResponse(
createRuntimeProjection({
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
currentNarrativeText: '山路尽头传来新的动静。',
storySession: {
latestNarrativeText: '山路尽头传来新的动静。',
},
}),
),
);
const response = await generateInitialStory(
@@ -497,21 +578,16 @@ describe('ai runtime client orchestration', () => {
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/initial',
'/api/story/sessions/storysess-main/runtime-projection',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
requestOptions: { availableOptions },
}),
method: 'GET',
}),
);
expect(response.storyText).toBe('山路尽头传来新的动静。');
expect(response.options).toEqual(availableOptions);
});
it('requests next story step from the runtime api server', async () => {
it('requests next story step from the story session action endpoint', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
@@ -521,9 +597,24 @@ describe('ai runtime client orchestration', () => {
];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: availableOptions,
projection: createRuntimeProjection({
serverVersion: 4,
currentNarrativeText: '林间重新安静下来,你听见远处的风声。',
storySession: {
latestNarrativeText: '林间重新安静下来,你听见远处的风声。',
latestChoiceFunctionId: 'idle_explore_forward',
version: 4,
},
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
}),
}),
);
@@ -533,19 +624,27 @@ describe('ai runtime client orchestration', () => {
monsters,
storyHistory,
'继续向前',
context,
{
...context,
lastFunctionId: 'idle_explore_forward',
},
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/continue',
'/api/story/sessions/storysess-main/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
storySessionId: 'storysess-main',
clientVersion: 3,
choice: '继续向前',
requestOptions: { availableOptions },
functionId: 'idle_explore_forward',
actionText: '继续向前',
payload: {
optionText: '继续向前',
observeSignsRequested: false,
recentActionResult: null,
},
}),
}),
);
@@ -972,11 +1071,11 @@ describe('ai runtime client orchestration', () => {
});
const sceneImageCalls = fetchMock.mock.calls.filter(
([url]) => url === '/api/custom-world/scene-image',
([url]) => url === '/api/runtime/custom-world/scene-image',
);
expect(sceneImageCalls).toHaveLength(1);
expect(sceneImageCalls[0]).toEqual([
'/api/custom-world/scene-image',
'/api/runtime/custom-world/scene-image',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({

View File

@@ -5,6 +5,7 @@ import type {
} from '../../packages/shared/src/contracts/runtime';
import { unwrapApiResponse } from '../../packages/shared/src/http';
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildEncounterFromSceneNpc,
getScenePresetById,
@@ -134,7 +135,7 @@ type MergeableCustomWorldRoleEntry = {
};
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/custom-world/scene-image';
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/runtime/custom-world/scene-image';
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。

View File

@@ -9,7 +9,6 @@ import type {
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
import type { RuntimeStoryAiRequest } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
@@ -35,6 +34,11 @@ import type {
import { fetchWithApiAuth, requestJson } from './apiClient';
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import { parseLineListContent } from './llmParsers';
import {
buildStoryMomentFromRuntimeProjection,
getStoryRuntimeProjection,
resolveRuntimeStoryAction,
} from './rpg-runtime/rpgRuntimeStoryClient';
const RUNTIME_API_BASE = '/api/runtime';
@@ -42,6 +46,20 @@ function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
function getStorySessionIdFromContext(context: StoryGenerationContext) {
return context.storySessionId?.trim() || undefined;
}
function runtimeStoryMomentToAiResponse(
story: StoryMoment | null | undefined,
fallbackText: string,
): AIResponse {
return {
storyText: story?.text?.trim() || fallbackText,
options: story?.options ?? [],
};
}
async function requestPlainText(
url: string,
payload: unknown,
@@ -162,30 +180,22 @@ export async function generateInitialStory(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
requestOptions,
}
: {
worldType: world,
character,
monsters,
context,
requestOptions,
};
void world;
void character;
void monsters;
void requestOptions;
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/initial`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'剧情开局生成失败',
);
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法生成开局剧情');
}
const projection = await getStoryRuntimeProjection({
storySessionId,
clientVersion: context.runtimeActionVersion,
});
const story = buildStoryMomentFromRuntimeProjection({ projection });
return runtimeStoryMomentToAiResponse(story, '开局剧情已同步。');
}
export async function generateNextStep(
@@ -197,35 +207,36 @@ export async function generateNextStep(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
choice,
lastFunctionId: context.lastFunctionId,
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
requestOptions,
}
: {
worldType: world,
character,
monsters,
history,
choice,
context,
requestOptions,
};
void world;
void character;
void monsters;
void history;
void requestOptions;
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法续写剧情');
}
const functionId = context.lastFunctionId?.trim();
if (!functionId) {
throw new Error('运行时动作缺少 functionId无法续写剧情');
}
const response = await resolveRuntimeStoryAction({
storySessionId,
clientVersion: context.runtimeActionVersion,
option: {
functionId,
actionText: choice,
},
'剧情续写失败',
payload: {
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
},
});
return runtimeStoryMomentToAiResponse(
response.snapshot.currentStory,
choice,
);
}

View File

@@ -89,6 +89,7 @@ export interface CustomWorldSceneImageResult {
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
storySessionId?: string | null;
runtimeActionVersion?: number;
playerHp: number;
playerMaxHp: number;

View File

@@ -436,9 +436,9 @@ describe('apiClient', () => {
let capturedError: unknown;
try {
await requestJson(
'/api/runtime/story/initial',
'/api/runtime/protected-error',
{ method: 'POST' },
'剧情生成失败',
'请求失败',
);
} catch (error) {
capturedError = error;

View File

@@ -1,5 +1,4 @@
export {
advanceLocalPuzzleNextLevel,
advancePuzzleNextLevel,
dragPuzzlePieceOrGroup,
getPuzzleRun,

View File

@@ -1,5 +1,4 @@
import type {
AdvanceLocalPuzzleNextLevelRequest,
DragPuzzlePieceRequest,
PuzzleRunResponse,
StartPuzzleRunRequest,
@@ -134,28 +133,7 @@ export async function submitPuzzleLeaderboard(
);
}
/**
* 单机运行态进入下一关,图片来源选择全部由后端裁决。
*/
export async function advanceLocalPuzzleNextLevel(
payload: AdvanceLocalPuzzleNextLevelRequest,
) {
return requestJson<PuzzleRunResponse>(
`${PUZZLE_RUNTIME_API_BASE}/local-next-level`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'进入下一关失败',
{
retry: PUZZLE_RUNTIME_WRITE_RETRY,
},
);
}
export const puzzleRuntimeClient = {
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
advanceNextLevel: advancePuzzleNextLevel,
drag: dragPuzzlePieceOrGroup,
getRun: getPuzzleRun,

View File

@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
}));
import {
generateRpgWorldLandmark,
generateRpgWorldSceneImage,
generateRpgWorldSceneNpc,
} from './rpgCreationAssetClient';
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('rpgCreationAssetClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
requestJsonMock.mockResolvedValue({
entity: { id: 'landmark-1', name: '雾港' },
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
npc: { id: 'npc-1', name: '守灯人' },
});
});
it('posts scene images to the runtime custom world asset route', async () => {
await generateRpgWorldSceneImage({
profile: {
id: 'profile-1',
name: '雾海群岛',
subtitle: '潮雾旧账',
summary: '雾海群岛上有一座旧港。',
tone: '潮湿、悬疑',
playerGoal: '查明旧港的灯火',
settingText: '海雾与旧账缠在一起。',
},
landmark: {
id: 'scene-1',
name: '旧港',
description: '潮湿旧港的夜景',
},
userPrompt: '潮湿旧港的夜景',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/scene-image',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
'生成自定义世界场景图失败',
);
});
it('posts generated entities to the runtime custom world asset route', async () => {
await generateRpgWorldLandmark({
profile: {
id: 'profile-1',
name: '雾海群岛',
} as never,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/entity',
expect.objectContaining({
method: 'POST',
}),
'生成场景失败',
);
});
it('posts scene npcs to the runtime custom world asset route', async () => {
await generateRpgWorldSceneNpc({
profile: {
id: 'profile-1',
name: '雾海群岛',
} as never,
landmarkId: 'scene-1',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/custom-world/scene-npc',
expect.objectContaining({
method: 'POST',
}),
'生成场景 NPC 失败',
);
});
});

View File

@@ -16,7 +16,7 @@ import {
} from '../customWorldCoverAssetService';
import { requestRpgCreationPostJson } from './rpgCreationRequestHelpers';
const RPG_CREATION_ASSET_API_BASE = '/api/custom-world';
const RPG_CREATION_ASSET_API_BASE = '/api/runtime/custom-world';
export type RpgCreationHistoryAssetKind = 'character_visual' | 'scene_image';

View File

@@ -8,21 +8,23 @@ export {
streamRpgNpcRecruitDialogue,
} from './rpgRuntimeChatClient';
export {
beginRpgStorySession,
getRpgRuntimeActionSnapshot,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStorySessionId,
getRpgRuntimeStoryState,
continueRpgStorySession,
getRpgStoryRuntimeProjection,
getRpgStorySessionState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
rpgRuntimeStoryClient,
beginRuntimeStorySession,
beginStorySession,
continueStorySession,
getRuntimeActionSnapshot,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStorySessionId,
getRuntimeStoryState,
getStoryRuntimeProjection,
getStorySessionState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
buildRuntimeSnapshotFromProjection,
loadRuntimeInventoryView,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
buildStoryMomentFromRuntimeProjection,
type RpgRuntimeStoryClientOptions,
type RuntimeStoryChoicePayload,
type RuntimeStoryInventoryView,
@@ -30,7 +32,7 @@ export {
type RuntimeStoryResponse,
type StorySessionMutationResult,
type StorySessionStateResult,
shouldUseRpgRuntimeServerOptions,
shouldUseServerRuntimeOptions,
} from './rpgRuntimeStoryClient';
export {
deleteRpgSaveSnapshot,

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { requestJsonMock } = vi.hoisted(() => ({
requestJsonMock: vi.fn(),
@@ -14,25 +14,106 @@ vi.mock('../apiClient', async () => {
});
import { AnimationState } from '../../types';
import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story';
import {
beginRpgStorySession,
beginRpgRuntimeStorySession,
beginStorySession,
beginRuntimeStorySession,
buildStoryMomentFromRuntimeOptions,
continueRpgStorySession,
getRpgStorySessionState,
getRpgRuntimeClientVersion,
getRpgRuntimeSessionId,
getRpgRuntimeStorySessionId,
getRpgRuntimeStoryState,
isRpgRuntimeServerFunctionId,
isRpgRuntimeTaskFunctionId,
loadRpgRuntimeInventoryView,
resolveRpgRuntimeStoryProjectionMoment,
resolveRpgRuntimeStoryAction,
resolveRpgRuntimeStoryMoment,
shouldUseRpgRuntimeServerOptions,
continueStorySession,
getStorySessionState,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStorySessionId,
getRuntimeStoryState,
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
loadRuntimeInventoryView,
buildStoryMomentFromRuntimeProjection,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
shouldUseServerRuntimeOptions,
} from './rpgRuntimeStoryClient';
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createStorySession(
overrides: Partial<StoryRuntimeProjectionResponse['storySession']> = {},
): StoryRuntimeProjectionResponse['storySession'] {
return {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端故事',
latestChoiceFunctionId: null,
status: 'active',
version: 1,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:01.000Z',
...overrides,
};
}
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = createStorySession(overrides.storySession);
const serverVersion = overrides.serverVersion ?? storySession.version;
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: serverVersion,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createRuntimeMutationResponse(
overrides: RuntimeProjectionOverrides = {},
) {
return {
projection: createRuntimeProjection(overrides),
};
}
describe('rpgRuntimeStoryClient', () => {
beforeEach(() => {
requestJsonMock.mockReset();
@@ -64,7 +145,7 @@ describe('rpgRuntimeStoryClient', () => {
},
});
const result = await beginRpgStorySession({
const result = await beginStorySession({
runtimeSessionId: 'runtime-main',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
@@ -114,7 +195,7 @@ describe('rpgRuntimeStoryClient', () => {
},
});
await continueRpgStorySession({
await continueStorySession({
storySessionId: ' storysess-main ',
narrativeText: '你继续向前。',
choiceFunctionId: 'story_continue',
@@ -154,7 +235,7 @@ describe('rpgRuntimeStoryClient', () => {
storyEvents: [],
});
await getRpgStorySessionState({ storySessionId: ' storysess-main ' });
await getStorySessionState({ storySessionId: ' storysess-main ' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/story/sessions/storysess-main/state',
@@ -166,25 +247,31 @@ describe('rpgRuntimeStoryClient', () => {
);
});
it('starts runtime sessions through the backend bootstrap endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-server-1',
serverVersion: 1,
snapshot: {
version: 2,
savedAt: '2026-04-28T00:00:00.000Z',
bottomTab: 'adventure',
it('starts runtime sessions through the backend runtime endpoint', async () => {
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
storySessionId: 'storysess-server-1',
runtimeSessionId: 'runtime-server-1',
worldProfileId: 'profile-1',
latestNarrativeText: '营地开场',
openingSummary: '营地开场',
version: 2,
updatedAt: '2026-04-28T00:00:00.000Z',
},
serverVersion: 2,
gameState: {
runtimeSessionId: 'runtime-server-1',
storySessionId: 'storysess-server-1',
currentScene: 'Story',
playerCharacter: { id: 'role-1', name: '沈砺' },
playerEquipment: { weapon: null, armor: null, relic: null },
},
currentStory: null,
},
});
currentNarrativeText: '营地开场',
}),
);
const result = await beginRpgRuntimeStorySession({
const result = await beginRuntimeStorySession({
worldType: 'CUSTOM',
customWorldProfile: { id: 'profile-1' } as never,
character: { id: 'role-1', name: '沈砺' } as never,
@@ -195,8 +282,11 @@ describe('rpgRuntimeStoryClient', () => {
expect(result.snapshot.gameState.runtimeSessionId).toBe(
'runtime-server-1',
);
expect(result.snapshot.gameState.storySessionId).toBe(
'storysess-server-1',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/sessions',
'/api/story/sessions/runtime',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
@@ -213,28 +303,35 @@ describe('rpgRuntimeStoryClient', () => {
});
it('builds runtime action requests against the dedicated story endpoint', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 2,
viewModel: {},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '后端已结算',
options: [],
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
storySessionId: 'storysess-custom',
latestNarrativeText: '后端已结算',
latestChoiceFunctionId: 'npc_chat',
version: 2,
},
serverVersion: 2,
gameState: {
storySessionId: 'storysess-custom',
runtimeSessionId: 'runtime-main',
},
storyEvents: [
{
eventId: 'storyevt-2',
storySessionId: 'storysess-custom',
eventKind: 'story_continued',
narrativeText: '后端已结算',
choiceFunctionId: 'npc_chat',
createdAt: '2026-04-08T00:00:01.000Z',
},
],
currentNarrativeText: '后端已结算',
}),
);
await resolveRpgRuntimeStoryAction({
sessionId: 'runtime-custom',
await resolveRuntimeStoryAction({
storySessionId: 'storysess-custom',
clientVersion: 9,
option: {
functionId: 'npc_chat',
@@ -243,19 +340,17 @@ describe('rpgRuntimeStoryClient', () => {
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/actions/resolve',
'/api/story/sessions/storysess-custom/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-custom',
storySessionId: 'storysess-custom',
clientVersion: 9,
action: {
type: 'story_choice',
functionId: 'npc_chat',
targetId: undefined,
payload: {
optionText: '继续交谈',
},
functionId: 'npc_chat',
actionText: '继续交谈',
targetId: undefined,
payload: {
optionText: '继续交谈',
},
}),
}),
@@ -265,27 +360,20 @@ describe('rpgRuntimeStoryClient', () => {
});
it('merges custom runtime payload fields into the action request body', async () => {
requestJsonMock.mockResolvedValue({
sessionId: 'runtime-main',
serverVersion: 3,
viewModel: {},
presentation: {
actionText: '使用凝神灵液',
resultText: '后端已结算物品使用',
storyText: '后端已结算物品使用',
options: [],
},
patches: [],
snapshot: {
version: 3,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {},
currentStory: null,
},
});
requestJsonMock.mockResolvedValue(
createRuntimeMutationResponse({
storySession: {
latestNarrativeText: '后端已结算物品使用',
latestChoiceFunctionId: 'inventory_use',
version: 3,
},
serverVersion: 3,
currentNarrativeText: '后端已结算物品使用',
}),
);
await resolveRpgRuntimeStoryAction({
await resolveRuntimeStoryAction({
storySessionId: 'storysess-main',
option: {
functionId: 'inventory_use',
actionText: '使用凝神灵液',
@@ -296,20 +384,18 @@ describe('rpgRuntimeStoryClient', () => {
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/story/actions/resolve',
'/api/story/sessions/storysess-main/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
storySessionId: 'storysess-main',
clientVersion: undefined,
action: {
type: 'story_choice',
functionId: 'inventory_use',
targetId: undefined,
payload: {
optionText: '使用凝神灵液',
itemId: 'focus-tonic',
},
functionId: 'inventory_use',
actionText: '使用凝神灵液',
targetId: undefined,
payload: {
optionText: '使用凝神灵液',
itemId: 'focus-tonic',
},
}),
}),
@@ -319,49 +405,18 @@ describe('rpgRuntimeStoryClient', () => {
});
it('reads runtime story state by story session id', async () => {
requestJsonMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '服务端故事',
latestChoiceFunctionId: null,
status: 'active',
version: 4,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 4,
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端故事',
actionResultText: null,
toast: null,
});
requestJsonMock.mockResolvedValue(
createRuntimeProjection({
storySession: {
latestNarrativeText: '服务端故事',
version: 4,
},
serverVersion: 4,
currentNarrativeText: '服务端故事',
}),
);
await getRpgRuntimeStoryState({
await getRuntimeStoryState({
storySessionId: 'storysess-main',
clientVersion: 7,
});
@@ -378,13 +433,13 @@ describe('rpgRuntimeStoryClient', () => {
it('rejects missing story session id instead of falling back to runtime id', async () => {
expect(() =>
getRpgRuntimeStorySessionId({
getRuntimeStorySessionId({
storySessionId: '',
}),
).toThrow('运行时故事会话不存在,无法读取服务端投影');
await expect(
loadRpgRuntimeInventoryView({
loadRuntimeInventoryView({
gameState: {
runtimeSessionId: 'runtime-inventory',
storySessionId: null,
@@ -394,14 +449,14 @@ describe('rpgRuntimeStoryClient', () => {
).rejects.toThrow('运行时故事会话不存在,无法读取服务端投影');
await expect(
continueRpgStorySession({
continueStorySession({
storySessionId: '',
narrativeText: '继续',
}),
).rejects.toThrow('故事会话不存在,无法继续故事');
await expect(
getRpgStorySessionState({
getStorySessionState({
storySessionId: '',
}),
).rejects.toThrow('故事会话不存在,无法读取故事会话状态');
@@ -410,74 +465,62 @@ describe('rpgRuntimeStoryClient', () => {
});
it('loads backend inventory view from story runtime projection', async () => {
requestJsonMock.mockResolvedValue({
storySession: {
storySessionId: 'storysess-inventory',
runtimeSessionId: 'runtime-inventory',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '背包状态',
latestChoiceFunctionId: null,
status: 'active',
version: 5,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 5,
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 90,
currencyText: '90 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
currencyText: '18 铜钱',
requirements: [
{
id: 'material:any',
label: '任意材料',
quantity: 3,
owned: 3,
requestJsonMock.mockResolvedValue(
createRuntimeProjection({
storySession: {
storySessionId: 'storysess-inventory',
runtimeSessionId: 'runtime-inventory',
latestNarrativeText: '背包状态',
version: 5,
},
serverVersion: 5,
gameState: {
storySessionId: 'storysess-inventory',
runtimeSessionId: 'runtime-inventory',
},
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 90,
currencyText: '90 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [
{
id: 'synthesis-refined-ingot',
name: '压炼锭材',
kind: 'synthesis',
description: '把零散残片和基础材料压成稳定可用的金属锭材。',
resultLabel: '精炼锭材',
currencyCost: 18,
currencyText: '18 铜钱',
requirements: [
{
id: 'material:any',
label: '任意材料',
quantity: 3,
owned: 3,
},
],
canCraft: true,
action: {
functionId: 'forge_craft',
actionText: '制作精炼锭材',
payload: { recipeId: 'synthesis-refined-ingot' },
enabled: true,
},
],
canCraft: true,
action: {
functionId: 'forge_craft',
actionText: '制作精炼锭材',
payload: { recipeId: 'synthesis-refined-ingot' },
enabled: true,
},
},
],
},
options: [],
status: {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText: '',
actionResultText: null,
toast: null,
});
],
},
currentNarrativeText: '',
}),
);
const view = await loadRpgRuntimeInventoryView({
const view = await loadRuntimeInventoryView({
gameState: {
storySessionId: 'storysess-inventory',
runtimeActionVersion: 5,
@@ -524,13 +567,13 @@ describe('rpgRuntimeStoryClient', () => {
});
it('recognizes server-runtime option pools for server-side legality checks', () => {
expect(isRpgRuntimeTaskFunctionId('npc_chat')).toBe(true);
expect(isRpgRuntimeTaskFunctionId('battle_attack_basic')).toBe(true);
expect(isRpgRuntimeTaskFunctionId('npc_trade')).toBe(false);
expect(isRpgRuntimeServerFunctionId('npc_trade')).toBe(true);
expect(isRpgRuntimeServerFunctionId('unknown_action')).toBe(false);
expect(isTask5RuntimeFunctionId('npc_chat')).toBe(true);
expect(isTask5RuntimeFunctionId('battle_attack_basic')).toBe(true);
expect(isTask5RuntimeFunctionId('npc_trade')).toBe(false);
expect(isServerRuntimeFunctionId('npc_trade')).toBe(true);
expect(isServerRuntimeFunctionId('unknown_action')).toBe(false);
expect(
shouldUseRpgRuntimeServerOptions([
shouldUseServerRuntimeOptions([
{
functionId: 'npc_chat',
actionText: '继续交谈',
@@ -547,7 +590,7 @@ describe('rpgRuntimeStoryClient', () => {
]),
).toBe(true);
expect(
shouldUseRpgRuntimeServerOptions([
shouldUseServerRuntimeOptions([
{
functionId: 'npc_trade',
actionText: '交易',
@@ -564,7 +607,7 @@ describe('rpgRuntimeStoryClient', () => {
]),
).toBe(true);
expect(
shouldUseRpgRuntimeServerOptions([
shouldUseServerRuntimeOptions([
{
functionId: 'unknown_action',
actionText: '未知动作',
@@ -580,47 +623,25 @@ describe('rpgRuntimeStoryClient', () => {
},
]),
).toBe(false);
expect(getRpgRuntimeSessionId({ runtimeSessionId: '' })).toBe(
expect(getRuntimeSessionId({ runtimeSessionId: '' })).toBe(
'runtime-main',
);
expect(getRpgRuntimeStorySessionId({ storySessionId: ' storysess-1 ' })).toBe(
expect(getRuntimeStorySessionId({ storySessionId: ' storysess-1 ' })).toBe(
'storysess-1',
);
expect(getRpgRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
expect(getRuntimeClientVersion({ runtimeActionVersion: 3 })).toBe(3);
});
it('builds story moments from story runtime projection options', () => {
const story = resolveRpgRuntimeStoryProjectionMoment({
projection: {
const story = buildStoryMomentFromRuntimeProjection({
projection: createRuntimeProjection({
storySession: {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-1',
worldProfileId: 'profile-1',
initialPrompt: '进入营地',
openingSummary: null,
latestNarrativeText: '兜底故事',
latestChoiceFunctionId: null,
status: 'active',
version: 5,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:00.000Z',
},
storyEvents: [],
serverVersion: 5,
actor: {
hp: 100,
maxHp: 100,
mana: 20,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: [
{
functionId: 'npc_chat',
@@ -639,9 +660,7 @@ describe('rpgRuntimeStoryClient', () => {
currentNpcBattleOutcome: null,
},
currentNarrativeText: '服务端投影故事',
actionResultText: null,
toast: null,
},
}),
});
expect(story.text).toBe('服务端投影故事');
@@ -680,100 +699,65 @@ describe('rpgRuntimeStoryClient', () => {
});
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
const story = resolveRpgRuntimeStoryMoment({
const hydratedSnapshot = {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never;
const story = resolveRuntimeStoryMoment({
response: {
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
inventory: {
playerCurrency: 0,
currencyText: '0 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
projection: createRuntimeProjection({
storySession: {
latestNarrativeText: '普通文本',
latestChoiceFunctionId: 'npc_chat',
version: 4,
},
serverVersion: 4,
currentNarrativeText: '普通文本',
}),
snapshot: hydratedSnapshot,
inventoryView: {
playerCurrency: 0,
currencyText: '0 铜钱',
inBattle: false,
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '普通文本',
options: [],
battle: null,
toast: null,
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
},
hydratedSnapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
hydratedSnapshot,
fallbackStoryText: '普通文本',
});

View File

@@ -8,15 +8,16 @@ import {
TASK5_RUNTIME_FUNCTION_IDS,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryAction';
import type {
RuntimeStoryActionRequest,
RuntimeStoryActionResponse,
RuntimeStoryBootstrapRequest,
RuntimeStoryBootstrapResponse,
RuntimeStoryOptionView,
RuntimeBattlePresentation,
RuntimeStoryInventoryViewModel,
} from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
BeginStoryRuntimeSessionRequest,
BeginStorySessionRequest,
ContinueStoryRequest,
ResolveStoryRuntimeActionRequest,
StoryRuntimeMutationResponse,
StorySessionMutationResponse,
StorySessionStateResponse,
StoryRuntimeOptionProjection,
@@ -28,7 +29,6 @@ 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 STORY_SESSIONS_API_BASE = '/api/story/sessions';
const DEFAULT_SESSION_ID = 'runtime-main';
const RUNTIME_STORY_RETRY: ApiRetryOptions = {
@@ -50,40 +50,25 @@ export type RpgRuntimeStoryClientOptions = {
retry?: ApiRetryOptions;
};
export type RuntimeStoryResponse = RuntimeStoryActionResponse<
GameState,
StoryMoment
>;
export type RuntimeStoryBootstrapResult = RuntimeStoryBootstrapResponse<
GameState,
StoryMoment
>;
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 RuntimeStoryInventoryView =
RuntimeStoryResponse['viewModel']['inventory'];
export type { RuntimeStoryChoicePayload };
function requestRuntimeStoryJson<T>(
path: string,
init: RequestInit,
fallbackMessage: string,
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释runtime story 请求默认带一层轻量重试,
// 因为这里既有 state 拉取,也有动作结算,请求失败会直接影响当前回合体验。
return requestJson<T>(
`${RUNTIME_STORY_API_BASE}${path}`,
{
...init,
signal: options.signal,
},
fallbackMessage,
{ retry: options.retry ?? RUNTIME_STORY_RETRY },
);
}
function requestStorySessionJson<T>(
path: string,
init: RequestInit,
@@ -105,7 +90,7 @@ function createRuntimeStoryOption(
option: RuntimeStoryOptionView,
_gameState?: Pick<GameState, 'currentEncounter'>,
): StoryOption {
// 中文注释:服务端 viewModel 当前只返回动作层字段,
// 中文注释:服务端投影当前只返回动作层字段,
// 前端在这里补齐 StoryOption 所需的基础表现字段,保持冒险面板消费接口稳定。
return {
functionId: option.functionId,
@@ -178,6 +163,47 @@ function getRuntimeProjectionStoryText(
);
}
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<GameState, 'runtimeSessionId'>,
) {
@@ -279,18 +305,13 @@ export function resolveRuntimeStoryMoment(params: {
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.response.presentation?.storyText ||
params.hydratedSnapshot.currentStory?.text ||
params.fallbackStoryText ||
'',
options,
options: params.response.projection.options.map(mapRuntimeProjectionOption),
gameState: params.hydratedSnapshot.gameState.currentEncounter
? params.hydratedSnapshot.gameState
: params.fallbackGameState,
@@ -408,14 +429,14 @@ export async function loadRuntimeInventoryView(
}
export async function beginRuntimeStorySession(
params: RuntimeStoryBootstrapRequest<
params: BeginStoryRuntimeSessionRequest<
GameState['customWorldProfile'],
NonNullable<GameState['playerCharacter']>
>,
options: RpgRuntimeStoryClientOptions = {},
) {
const response = await requestRuntimeStoryJson<RuntimeStoryBootstrapResult>(
'/sessions',
const response = await requestStorySessionJson<StoryRuntimeMutationResponse>(
'/runtime',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -425,17 +446,12 @@ export async function beginRuntimeStorySession(
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
),
} satisfies RuntimeStoryBootstrapResult;
return normalizeRuntimeMutationResponse(response);
}
export async function resolveRuntimeStoryAction(
params: {
sessionId?: string;
storySessionId: string;
clientVersion?: number;
option: Pick<StoryOption, 'functionId' | 'actionText'>;
targetId?: string;
@@ -443,76 +459,36 @@ export async function resolveRuntimeStoryAction(
},
options: RpgRuntimeStoryClientOptions = {},
) {
// 中文注释story_choice 是当前前端统一提交给服务端的动作包裹格式,
// optionText 会一起带上,方便服务端日志、提示词和调试链查看用户当轮选择。
const response = await requestRuntimeStoryJson<RuntimeStoryResponse>(
'/actions/resolve',
const storySessionId = normalizeStorySessionId(
params.storySessionId,
'故事会话不存在,无法执行运行时动作',
);
// 中文注释:写入 DTO 采用 story session scoped 扁平字段;
// optionText 仍放进 payload方便服务端日志、提示词和调试链查看用户当轮选择。
const response = await requestStorySessionJson<StoryRuntimeMutationResponse>(
`/${encodeURIComponent(storySessionId)}/actions/resolve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: params.sessionId || DEFAULT_SESSION_ID,
storySessionId,
clientVersion: params.clientVersion,
action: {
type: 'story_choice',
functionId: params.option.functionId,
targetId: params.targetId,
payload: {
optionText: params.option.actionText,
...(params.payload ?? {}),
},
functionId: params.option.functionId,
actionText: params.option.actionText,
targetId: params.targetId,
payload: {
optionText: params.option.actionText,
...(params.payload ?? {}),
},
} satisfies RuntimeStoryActionRequest),
} satisfies ResolveStoryRuntimeActionRequest),
},
'执行运行时动作失败',
options,
);
return {
...response,
snapshot: rehydrateSavedSnapshot(
response.snapshot as HydratedSavedGameSnapshot,
),
} satisfies RuntimeStoryResponse;
return normalizeRuntimeMutationResponse(response);
}
export function getRuntimeActionSnapshot(response: RuntimeStoryResponse) {
return rehydrateSavedSnapshot(response.snapshot as HydratedSavedGameSnapshot);
return response.snapshot;
}
export const beginRpgRuntimeStorySession = beginRuntimeStorySession;
export const beginRpgStorySession = beginStorySession;
export const continueRpgStorySession = continueStorySession;
export const getRpgStoryRuntimeProjection = getStoryRuntimeProjection;
export const getRpgStorySessionState = getStorySessionState;
export const getRpgRuntimeActionSnapshot = getRuntimeActionSnapshot;
export const getRpgRuntimeClientVersion = getRuntimeClientVersion;
export const getRpgRuntimeSessionId = getRuntimeSessionId;
export const getRpgRuntimeStorySessionId = getRuntimeStorySessionId;
export const getRpgRuntimeStoryState = getRuntimeStoryState;
export const isRpgRuntimeServerFunctionId = isServerRuntimeFunctionId;
export const isRpgRuntimeTaskFunctionId = isTask5RuntimeFunctionId;
export const loadRpgRuntimeInventoryView = loadRuntimeInventoryView;
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
export const resolveRpgRuntimeStoryProjectionMoment =
buildStoryMomentFromRuntimeProjection;
export const resolveRpgRuntimeStoryMoment = resolveRuntimeStoryMoment;
export const shouldUseRpgRuntimeServerOptions = shouldUseServerRuntimeOptions;
export const rpgRuntimeStoryClient = {
beginSession: beginRpgRuntimeStorySession,
beginStorySession: beginRpgStorySession,
continueStorySession: continueRpgStorySession,
getActionSnapshot: getRpgRuntimeActionSnapshot,
getClientVersion: getRpgRuntimeClientVersion,
getInventoryView: loadRpgRuntimeInventoryView,
getSessionId: getRpgRuntimeSessionId,
getStoryRuntimeProjection: getRpgStoryRuntimeProjection,
getStorySessionId: getRpgRuntimeStorySessionId,
getStorySessionState: getRpgStorySessionState,
getState: getRpgRuntimeStoryState,
resolveAction: resolveRpgRuntimeStoryAction,
resolveProjectionMoment: resolveRpgRuntimeStoryProjectionMoment,
resolveMoment: resolveRpgRuntimeStoryMoment,
shouldUseServerOptions: shouldUseRpgRuntimeServerOptions,
};