Close DDD refactor and remove generated asset proxy
This commit is contained in:
@@ -93,18 +93,17 @@ import {
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime';
|
||||
import {
|
||||
dragLocalPuzzlePiece,
|
||||
isLocalPuzzleRun,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
swapLocalPuzzlePieces,
|
||||
} from '../../services/puzzle-runtime/puzzleLocalRuntime';
|
||||
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
|
||||
deletePuzzleWork,
|
||||
listPuzzleWorks,
|
||||
updatePuzzleWork,
|
||||
} from '../../services/puzzle-works';
|
||||
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
|
||||
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
|
||||
import {
|
||||
@@ -1288,55 +1287,53 @@ export function PlatformEntryFlowShellImpl({
|
||||
[isPuzzleBusy, resolvePuzzleErrorMessage, setSelectionStage],
|
||||
);
|
||||
|
||||
const buildPuzzleTestWork = useCallback(
|
||||
(draft: PuzzleResultDraft) => {
|
||||
const profileId =
|
||||
puzzleSession?.publishedProfileId ??
|
||||
`draft-${puzzleSession?.sessionId ?? 'puzzle'}-test`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
workId: `test-${profileId}`,
|
||||
profileId,
|
||||
ownerUserId: authUi?.user?.id ?? 'current-user',
|
||||
sourceSessionId: puzzleSession?.sessionId ?? null,
|
||||
authorDisplayName: authUi?.user?.displayName ?? '玩家',
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: now,
|
||||
publishedAt: null,
|
||||
playCount: 0,
|
||||
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
|
||||
} satisfies PuzzleWorkSummary;
|
||||
},
|
||||
[
|
||||
authUi?.user?.displayName,
|
||||
authUi?.user?.id,
|
||||
puzzleSession?.publishedProfileId,
|
||||
puzzleSession?.resultPreview?.publishReady,
|
||||
puzzleSession?.sessionId,
|
||||
],
|
||||
);
|
||||
|
||||
const startPuzzleTestRunFromDraft = useCallback(
|
||||
(draft: PuzzleResultDraft) => {
|
||||
async (draft: PuzzleResultDraft) => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
if (!draft.coverImageSrc) {
|
||||
setPuzzleError('请先选择一张正式拼图图片。');
|
||||
return;
|
||||
}
|
||||
const profileId =
|
||||
puzzleSession?.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession?.sessionId);
|
||||
if (!profileId) {
|
||||
setPuzzleError('这份拼图草稿缺少会话信息,请重新开始创作。');
|
||||
return;
|
||||
}
|
||||
|
||||
const testWork = buildPuzzleTestWork(draft);
|
||||
setSelectedPuzzleDetail(testWork);
|
||||
setPuzzleRun(startLocalPuzzleRun(testWork));
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
setSelectionStage('puzzle-runtime');
|
||||
try {
|
||||
const { item } = await updatePuzzleWork(profileId, {
|
||||
levelName: draft.levelName,
|
||||
summary: draft.summary,
|
||||
themeTags: draft.themeTags,
|
||||
coverImageSrc: draft.coverImageSrc,
|
||||
coverAssetId: draft.coverAssetId,
|
||||
});
|
||||
const { run } = await startPuzzleRun({ profileId: item.profileId });
|
||||
setSelectedPuzzleDetail(item);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
setSelectionStage('puzzle-runtime');
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '启动拼图试玩失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[buildPuzzleTestWork, setSelectionStage],
|
||||
[
|
||||
isPuzzleBusy,
|
||||
puzzleSession?.publishedProfileId,
|
||||
puzzleSession?.sessionId,
|
||||
resolvePuzzleErrorMessage,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const submitBigFishInput = useCallback(
|
||||
@@ -1387,27 +1384,43 @@ export function PlatformEntryFlowShellImpl({
|
||||
]);
|
||||
|
||||
const swapPuzzlePiecesInRun = useCallback(
|
||||
(payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
async (payload: { firstPieceId: string; secondPieceId: string }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
try {
|
||||
const { run } = await swapPuzzlePieces(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
(payload: { pieceId: string; targetRow: number; targetCol: number }) => {
|
||||
async (payload: { pieceId: string; targetRow: number; targetCol: number }) => {
|
||||
if (!puzzleRun || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
try {
|
||||
const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun],
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1436,12 +1449,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
nickname: authUi?.user?.displayName?.trim() || '玩家',
|
||||
};
|
||||
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname));
|
||||
setIsPuzzleLeaderboardBusy(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
@@ -1477,13 +1484,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const { run } = await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
sourceSessionId:
|
||||
selectedPuzzleDetail?.sourceSessionId ??
|
||||
puzzleSession?.sessionId ??
|
||||
null,
|
||||
});
|
||||
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
} catch (error) {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
|
||||
@@ -1494,9 +1495,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
puzzleRun,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
]);
|
||||
|
||||
const leaveAgentWorkspace = useCallback(() => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
'它负责把开局同伴营地流程平稳切到角色真正的起始场景;正式目标场景、encounter 清理、战斗态清理和镜头残留状态由后端 resolver 写入。',
|
||||
trigger: '常见于开局同伴营地对话后的跟进选项。',
|
||||
execution:
|
||||
'点击后作为服务端 runtime function id 提交到 /api/runtime/story/actions/resolve,由后端执行定制场景迁移和历史写入。',
|
||||
'点击后作为服务端 runtime function id 提交到 /api/story/sessions/{storySessionId}/actions/resolve,由后端执行定制场景迁移和历史写入。',
|
||||
result: '玩家会离开营地进入角色主场景,正式开始该角色的冒险线。',
|
||||
active: true,
|
||||
runtime: {
|
||||
@@ -53,7 +53,7 @@ export const CAMP_TRAVEL_HOME_FUNCTION: FunctionDocumentationEntry = {
|
||||
uiMode: 'none',
|
||||
visuals: CAMP_TRAVEL_HOME_OPTION_VISUALS,
|
||||
executor:
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_camp_travel_home_scene_action',
|
||||
'server-rs/crates/module-runtime-story/src/session_action.rs -> resolve_story_runtime_action',
|
||||
animationNote:
|
||||
'前端保留 run 视觉元信息;正式状态以服务端 hydrated snapshot 为准。',
|
||||
storyNote:
|
||||
|
||||
@@ -30,7 +30,7 @@ export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
|
||||
storyMode: 'special_sequence',
|
||||
uiMode: 'none',
|
||||
executor:
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action',
|
||||
'server-rs/crates/module-runtime-story/src/session_action.rs -> resolve_story_runtime_action',
|
||||
animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
|
||||
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
|
||||
uiNote: '不弹 modal,直接进入对白流。',
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('functionCatalog', () => {
|
||||
expect(campTravelOption.actionText).toBe('前往 竹林古道');
|
||||
expect(campTravelOption.detailText).toBe('离开营地,前往 竹林古道。');
|
||||
expect(CAMP_TRAVEL_HOME_FUNCTION.runtime?.executor).toContain(
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs',
|
||||
'server-rs/crates/module-runtime-story/src/session_action.rs',
|
||||
);
|
||||
expect(CAMP_TRAVEL_HOME_FUNCTION.detailedDescription).toContain(
|
||||
'后端 resolver',
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { FunctionDocumentationEntry } from '../types';
|
||||
*/
|
||||
const QUEST_OFFER_SOURCE = 'src/data/functionCatalog/npc/npcChatQuestOffer.ts';
|
||||
const QUEST_OFFER_EXECUTOR =
|
||||
'server-rs/crates/api-server/src/runtime_story/compat.rs -> resolve_runtime_story_choice_action';
|
||||
'server-rs/crates/module-runtime-story/src/session_action.rs -> resolve_story_runtime_action';
|
||||
|
||||
export const NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION: FunctionDocumentationEntry = {
|
||||
id: 'npc_chat_quest_offer_view',
|
||||
|
||||
@@ -182,7 +182,10 @@ export function buildNpcBattleFormationFromEncounter(params: {
|
||||
const slots = getSceneActBattleSlots(primaryX);
|
||||
|
||||
const resolvedFormation = formationSourceEncounters.map((memberEncounter, index) => {
|
||||
const slot = slots[index] ?? slots[slots.length - 1];
|
||||
const slot = slots[index] ?? slots[slots.length - 1] ?? {
|
||||
xMeters: primaryX,
|
||||
yOffset: 0,
|
||||
};
|
||||
const npcState = getResolvedNpcState(state, memberEncounter);
|
||||
const battleMonster = createNpcBattleMonster(
|
||||
memberEncounter,
|
||||
|
||||
@@ -5,15 +5,15 @@ vi.mock('../../services/aiService', () => ({
|
||||
}));
|
||||
|
||||
const {
|
||||
isRpgRuntimeServerFunctionIdMock,
|
||||
isServerRuntimeFunctionIdMock,
|
||||
runServerRuntimeChoiceActionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
isRpgRuntimeServerFunctionIdMock: vi.fn(() => false),
|
||||
isServerRuntimeFunctionIdMock: vi.fn(() => false),
|
||||
runServerRuntimeChoiceActionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-runtime', () => ({
|
||||
isRpgRuntimeServerFunctionId: isRpgRuntimeServerFunctionIdMock,
|
||||
isServerRuntimeFunctionId: isServerRuntimeFunctionIdMock,
|
||||
}));
|
||||
|
||||
import { AnimationState, type Character, type Encounter, type GameState, type StoryMoment, type StoryOption, WorldType } from '../../types';
|
||||
@@ -164,8 +164,8 @@ const neverNpcEncounter = (
|
||||
|
||||
describe('createStoryChoiceActions', () => {
|
||||
beforeEach(() => {
|
||||
isRpgRuntimeServerFunctionIdMock.mockReset();
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(false);
|
||||
isServerRuntimeFunctionIdMock.mockReset();
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(false);
|
||||
runServerRuntimeChoiceActionMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -389,7 +389,7 @@ describe('createStoryChoiceActions', () => {
|
||||
const setCurrentStory = vi.fn();
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: {
|
||||
@@ -493,7 +493,7 @@ describe('createStoryChoiceActions', () => {
|
||||
};
|
||||
const handleNpcInteraction = vi.fn(() => true);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -575,7 +575,7 @@ describe('createStoryChoiceActions', () => {
|
||||
}));
|
||||
const playResolvedChoice = vi.fn().mockResolvedValue(state);
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -681,7 +681,7 @@ describe('createStoryChoiceActions', () => {
|
||||
inBattle: true,
|
||||
});
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -757,7 +757,7 @@ describe('createStoryChoiceActions', () => {
|
||||
const buildResolvedChoiceState = vi.fn();
|
||||
const playResolvedChoice = vi.fn();
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
@@ -849,7 +849,7 @@ describe('createStoryChoiceActions', () => {
|
||||
const playResolvedChoice = vi.fn();
|
||||
const commitGeneratedStateWithEncounterEntry = vi.fn();
|
||||
|
||||
isRpgRuntimeServerFunctionIdMock.mockReturnValue(true);
|
||||
isServerRuntimeFunctionIdMock.mockReturnValue(true);
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
} from 'react';
|
||||
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isRpgRuntimeServerFunctionId } from '../../services/rpg-runtime';
|
||||
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
type Character,
|
||||
type Encounter,
|
||||
@@ -191,7 +191,7 @@ export function createStoryChoiceActions({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeServerFunctionId(option.functionId)) {
|
||||
if (isServerRuntimeFunctionId(option.functionId)) {
|
||||
await runServerRuntimeChoiceAction({
|
||||
gameState,
|
||||
currentStory,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { RuntimeStoryInventoryActionView } from '../../../packages/shared/src/contracts/rpgRuntimeStoryState';
|
||||
import {
|
||||
loadRpgRuntimeInventoryView,
|
||||
loadRuntimeInventoryView,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryInventoryView,
|
||||
} from '../../services/rpg-runtime';
|
||||
@@ -53,7 +53,7 @@ export function useStoryInventoryActions({
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
void loadRpgRuntimeInventoryView(
|
||||
void loadRuntimeInventoryView(
|
||||
{
|
||||
gameState: {
|
||||
storySessionId,
|
||||
@@ -129,7 +129,7 @@ export function useStoryInventoryActions({
|
||||
|
||||
setGameState(hydratedSnapshot.gameState);
|
||||
setCurrentStory(nextStory);
|
||||
setServerInventoryView(response.viewModel.inventory);
|
||||
setServerInventoryView(response.inventoryView);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve inventory runtime action on the server:', error);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
getRpgRuntimeClientVersion,
|
||||
getRpgRuntimeSessionId,
|
||||
getRpgRuntimeStorySessionId,
|
||||
getRpgStoryRuntimeProjection,
|
||||
resolveRpgRuntimeStoryAction,
|
||||
resolveRpgRuntimeStoryProjectionMoment,
|
||||
resolveRpgRuntimeStoryMoment,
|
||||
buildRuntimeSnapshotFromProjection,
|
||||
buildStoryMomentFromRuntimeProjection,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeStorySessionId,
|
||||
getStoryRuntimeProjection,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
} from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
@@ -22,11 +22,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
||||
}) {
|
||||
// 中文注释:状态目录只从服务端持久化 session 读取,
|
||||
// 前端不再上传本地 GameState 快照参与动作合法性解析。
|
||||
const response = await getRpgStoryRuntimeProjection({
|
||||
storySessionId: getRpgRuntimeStorySessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
const response = await getStoryRuntimeProjection({
|
||||
storySessionId: getRuntimeStorySessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
});
|
||||
const options = resolveRpgRuntimeStoryProjectionMoment({
|
||||
const options = buildStoryMomentFromRuntimeProjection({
|
||||
projection: response,
|
||||
gameState: params.gameState,
|
||||
}).options;
|
||||
@@ -52,25 +52,25 @@ export async function resumeServerRuntimeStory(
|
||||
|
||||
// 中文注释:继续游戏后向服务端刷新一次状态,
|
||||
// 让长期离线的本地快照重新对齐服务端当前 runtime view model。
|
||||
const response = await getRpgStoryRuntimeProjection({
|
||||
storySessionId: getRpgRuntimeStorySessionId(hydratedSnapshot.gameState),
|
||||
const response = await getStoryRuntimeProjection({
|
||||
storySessionId: getRuntimeStorySessionId(hydratedSnapshot.gameState),
|
||||
});
|
||||
const runtimeOptions = response.options;
|
||||
const nextStory =
|
||||
response.currentNarrativeText || runtimeOptions.length > 0
|
||||
? resolveRpgRuntimeStoryProjectionMoment({
|
||||
? buildStoryMomentFromRuntimeProjection({
|
||||
projection: response,
|
||||
gameState: hydratedSnapshot.gameState,
|
||||
})
|
||||
: hydratedSnapshot.currentStory;
|
||||
const projectedSnapshot = buildRuntimeSnapshotFromProjection(
|
||||
response,
|
||||
hydratedSnapshot.bottomTab,
|
||||
);
|
||||
const resumedSnapshot = {
|
||||
...hydratedSnapshot,
|
||||
gameState: {
|
||||
...hydratedSnapshot.gameState,
|
||||
runtimeSessionId: response.storySession.runtimeSessionId,
|
||||
storySessionId: response.storySession.storySessionId,
|
||||
runtimeActionVersion: response.serverVersion,
|
||||
},
|
||||
gameState: projectedSnapshot.gameState,
|
||||
currentStory: nextStory,
|
||||
} satisfies HydratedSavedGameSnapshot;
|
||||
|
||||
return {
|
||||
@@ -88,9 +88,9 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
}) {
|
||||
// 中文注释:正式动作结算统一先走服务端;
|
||||
// 前端这里只提交 action/payload,并消费后端已经补齐的快照与表现数据。
|
||||
const response = await resolveRpgRuntimeStoryAction({
|
||||
sessionId: getRpgRuntimeSessionId(params.gameState),
|
||||
clientVersion: getRpgRuntimeClientVersion(params.gameState),
|
||||
const response = await resolveRuntimeStoryAction({
|
||||
storySessionId: getRuntimeStorySessionId(params.gameState),
|
||||
clientVersion: getRuntimeClientVersion(params.gameState),
|
||||
option: params.option,
|
||||
targetId:
|
||||
params.option.interaction?.kind === 'npc'
|
||||
@@ -98,17 +98,17 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
: undefined,
|
||||
payload: params.payload,
|
||||
});
|
||||
const hydratedSnapshot = rehydrateSavedSnapshot(response.snapshot);
|
||||
const hydratedSnapshot = response.snapshot;
|
||||
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: resolveRpgRuntimeStoryMoment({
|
||||
nextStory: resolveRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
response.presentation?.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
}),
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
const {
|
||||
getStoryRuntimeProjectionMock,
|
||||
resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionIdMock,
|
||||
getRuntimeStorySessionIdMock,
|
||||
getRuntimeClientVersionMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getStoryRuntimeProjectionMock: vi.fn(),
|
||||
resolveRuntimeStoryActionMock: vi.fn(),
|
||||
getRuntimeSessionIdMock: vi.fn(() => 'runtime-main'),
|
||||
getRuntimeStorySessionIdMock: vi.fn(() => 'storysess-main'),
|
||||
getRuntimeClientVersionMock: vi.fn(() => 0),
|
||||
}));
|
||||
@@ -24,14 +22,8 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getRpgStoryRuntimeProjection: getStoryRuntimeProjectionMock,
|
||||
resolveRpgRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRpgRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRpgRuntimeStorySessionId: getRuntimeStorySessionIdMock,
|
||||
getRpgRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
getStoryRuntimeProjection: getStoryRuntimeProjectionMock,
|
||||
resolveRuntimeStoryAction: resolveRuntimeStoryActionMock,
|
||||
getRuntimeSessionId: getRuntimeSessionIdMock,
|
||||
getRuntimeStorySessionId: getRuntimeStorySessionIdMock,
|
||||
getRuntimeClientVersion: getRuntimeClientVersionMock,
|
||||
};
|
||||
@@ -40,12 +32,20 @@ vi.mock('../../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
import { WorldType } from '../../types';
|
||||
import type { StoryRuntimeProjectionResponse } from '../../../packages/shared/src/contracts/story';
|
||||
import {
|
||||
loadServerRuntimeOptionCatalog,
|
||||
resolveServerRuntimeChoice,
|
||||
resumeServerRuntimeStory,
|
||||
} from './runtimeStoryCoordinator';
|
||||
|
||||
type RuntimeProjectionOverrides = Omit<
|
||||
Partial<StoryRuntimeProjectionResponse>,
|
||||
'storySession'
|
||||
> & {
|
||||
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
|
||||
};
|
||||
|
||||
function createStory(text: string): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
@@ -61,6 +61,115 @@ function createGameState(): GameState {
|
||||
} as GameState;
|
||||
}
|
||||
|
||||
function createStorySession(
|
||||
overrides: Partial<StoryRuntimeProjectionResponse['storySession']> = {},
|
||||
) {
|
||||
return {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 8,
|
||||
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 createRuntimeResponse(params: {
|
||||
snapshot: HydratedSavedGameSnapshot;
|
||||
functionId?: string;
|
||||
options?: StoryRuntimeProjectionResponse['options'];
|
||||
presentation?: {
|
||||
storyText?: string;
|
||||
resultText?: string;
|
||||
battle?: {
|
||||
targetId?: string;
|
||||
damageDealt?: number;
|
||||
damageTaken?: number;
|
||||
outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'defeat' | 'escaped';
|
||||
} | null;
|
||||
};
|
||||
}) {
|
||||
const projection = createRuntimeProjection({
|
||||
storySession: {
|
||||
latestNarrativeText: params.snapshot.currentStory?.text ?? '服务端故事',
|
||||
latestChoiceFunctionId: params.functionId ?? null,
|
||||
version: params.snapshot.version,
|
||||
},
|
||||
serverVersion: params.snapshot.version,
|
||||
gameState: params.snapshot.gameState as never,
|
||||
options: params.options ?? [],
|
||||
currentNarrativeText: params.snapshot.currentStory?.text ?? '服务端故事',
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: projection.storySession.runtimeSessionId,
|
||||
serverVersion: projection.serverVersion,
|
||||
projection,
|
||||
snapshot: params.snapshot,
|
||||
inventoryView: {
|
||||
playerCurrency: projection.actor.currency,
|
||||
currencyText: projection.actor.currencyText,
|
||||
inBattle: projection.status.inBattle,
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
presentation: params.presentation,
|
||||
};
|
||||
}
|
||||
|
||||
function createTravelGameState(): GameState {
|
||||
return {
|
||||
runtimeSessionId: 'runtime-main',
|
||||
@@ -258,8 +367,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
beforeEach(() => {
|
||||
getStoryRuntimeProjectionMock.mockReset();
|
||||
resolveRuntimeStoryActionMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReset();
|
||||
getRuntimeSessionIdMock.mockReturnValue('runtime-main');
|
||||
getRuntimeStorySessionIdMock.mockReset();
|
||||
getRuntimeStorySessionIdMock.mockReturnValue('storysess-main');
|
||||
getRuntimeClientVersionMock.mockReset();
|
||||
@@ -270,36 +377,12 @@ describe('runtimeStoryCoordinator', () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 3,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 3,
|
||||
actor: {
|
||||
hp: 100,
|
||||
maxHp: 100,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
currency: 0,
|
||||
currencyText: '0 铜钱',
|
||||
},
|
||||
inventory: {
|
||||
backpackItems: [],
|
||||
equipmentSlots: [],
|
||||
forgeRecipes: [],
|
||||
},
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
@@ -315,9 +398,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
}));
|
||||
|
||||
const options = await loadServerRuntimeOptionCatalog({
|
||||
gameState,
|
||||
@@ -374,41 +455,22 @@ describe('runtimeStoryCoordinator', () => {
|
||||
bottomTab: 'adventure',
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 96,
|
||||
maxHp: 100,
|
||||
mana: 18,
|
||||
maxMana: 20,
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
|
||||
snapshot: hydratedSnapshot,
|
||||
functionId: 'npc_chat',
|
||||
options: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
enabled: true,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'npc_help',
|
||||
actionText: '请求援手',
|
||||
scope: 'npc',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '关系已有变化',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: hydratedSnapshot,
|
||||
});
|
||||
}));
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
@@ -420,7 +482,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
});
|
||||
|
||||
expect(resolveRuntimeStoryActionMock).toHaveBeenCalledWith({
|
||||
sessionId: 'runtime-main',
|
||||
storySessionId: 'storysess-main',
|
||||
clientVersion: 7,
|
||||
option,
|
||||
targetId: 'npc-opponent',
|
||||
@@ -464,22 +526,11 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentStory: createStory('本地快照故事'),
|
||||
bottomTab: 'inventory' as const,
|
||||
} as HydratedSavedGameSnapshot;
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '服务端恢复后的故事',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 8,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 8,
|
||||
actor: {
|
||||
hp: 90,
|
||||
@@ -509,9 +560,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '服务端恢复后的故事',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
}));
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
@@ -565,7 +614,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
expect(result.nextStory).toBe(localHydratedSnapshot.currentStory);
|
||||
});
|
||||
|
||||
it('rehydrates npc_fight server snapshots before returning runtime choices', async () => {
|
||||
it('uses npc_fight server snapshots before returning runtime choices', async () => {
|
||||
const gameState = createGameState();
|
||||
const currentStory = createStory('当前故事');
|
||||
const option = {
|
||||
@@ -588,50 +637,22 @@ describe('runtimeStoryCoordinator', () => {
|
||||
} as StoryOption;
|
||||
const rawBattleSnapshot = createRuntimeNpcBattleSnapshot();
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
|
||||
snapshot: rawBattleSnapshot,
|
||||
functionId: 'npc_fight',
|
||||
options: [
|
||||
{
|
||||
functionId: 'battle_probe_pressure',
|
||||
actionText: '稳步试探',
|
||||
scope: 'combat',
|
||||
enabled: true,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_probe_pressure',
|
||||
actionText: '稳步试探',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: rawBattleSnapshot,
|
||||
});
|
||||
}));
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
@@ -644,11 +665,6 @@ describe('runtimeStoryCoordinator', () => {
|
||||
id: 'npc-bandit',
|
||||
hp: 21,
|
||||
maxHp: 32,
|
||||
encounter: expect.objectContaining({
|
||||
kind: 'npc',
|
||||
id: 'npc-bandit',
|
||||
npcName: '断桥匪首',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.nextStory.options[0]).toEqual(
|
||||
@@ -693,48 +709,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
},
|
||||
} as StoryOption;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 42,
|
||||
maxHp: 50,
|
||||
mana: 20,
|
||||
maxMana: 20,
|
||||
},
|
||||
encounter: {
|
||||
id: 'npc-bandit',
|
||||
kind: 'npc',
|
||||
npcName: '断桥匪首',
|
||||
hostile: true,
|
||||
affinity: -12,
|
||||
recruited: false,
|
||||
interactionActive: false,
|
||||
battleMode: 'fight',
|
||||
},
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: true,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: 'fight',
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '直接开战',
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
|
||||
snapshot: createRuntimeNpcBattleSnapshot({
|
||||
currentEncounter: {
|
||||
kind: 'npc',
|
||||
@@ -750,7 +725,20 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentBattleNpcId: 'npc-bandit',
|
||||
currentNpcBattleMode: 'fight',
|
||||
}),
|
||||
});
|
||||
functionId: 'npc_fight',
|
||||
options: [
|
||||
{
|
||||
functionId: 'battle_attack_basic',
|
||||
actionText: '普通攻击',
|
||||
scope: 'combat',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
presentation: {
|
||||
resultText: '当前冲突正式转入战斗结算。',
|
||||
storyText: '断桥匪首已经摆开架势。',
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
@@ -803,41 +791,22 @@ describe('runtimeStoryCoordinator', () => {
|
||||
},
|
||||
} as HydratedSavedGameSnapshot;
|
||||
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue({
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 8,
|
||||
viewModel: {
|
||||
player: {
|
||||
hp: 38,
|
||||
maxHp: 40,
|
||||
mana: 12,
|
||||
maxMana: 16,
|
||||
resolveRuntimeStoryActionMock.mockResolvedValue(createRuntimeResponse({
|
||||
snapshot: serverSnapshot,
|
||||
functionId: 'idle_travel_next_scene',
|
||||
options: [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察周围迹象',
|
||||
scope: 'story',
|
||||
enabled: true,
|
||||
},
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [
|
||||
{
|
||||
functionId: 'idle_observe_signs',
|
||||
actionText: '观察周围迹象',
|
||||
scope: 'story',
|
||||
},
|
||||
],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: false,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
presentation: {
|
||||
actionText: '前往相邻场景',
|
||||
resultText: '你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。',
|
||||
storyText: '',
|
||||
options: [],
|
||||
},
|
||||
patches: [],
|
||||
snapshot: serverSnapshot,
|
||||
});
|
||||
}));
|
||||
|
||||
const result = await resolveServerRuntimeChoice({
|
||||
gameState,
|
||||
@@ -865,23 +834,16 @@ describe('runtimeStoryCoordinator', () => {
|
||||
storySessionId: 'storysess-main',
|
||||
});
|
||||
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue({
|
||||
getStoryRuntimeProjectionMock.mockResolvedValue(createRuntimeProjection({
|
||||
storySession: {
|
||||
storySessionId: 'storysess-main',
|
||||
runtimeSessionId: 'runtime-main',
|
||||
actorUserId: 'user-1',
|
||||
worldProfileId: 'profile-1',
|
||||
initialPrompt: '进入营地',
|
||||
openingSummary: null,
|
||||
latestNarrativeText: '断桥匪首还在步步逼近。',
|
||||
latestChoiceFunctionId: null,
|
||||
status: 'active',
|
||||
version: 8,
|
||||
createdAt: '2026-04-08T00:00:00.000Z',
|
||||
updatedAt: '2026-04-08T00:00:00.000Z',
|
||||
},
|
||||
storyEvents: [],
|
||||
serverVersion: 8,
|
||||
gameState: {
|
||||
...localHydratedSnapshot.gameState,
|
||||
runtimeActionVersion: 8,
|
||||
},
|
||||
actor: {
|
||||
hp: 39,
|
||||
maxHp: 50,
|
||||
@@ -910,9 +872,7 @@ describe('runtimeStoryCoordinator', () => {
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
currentNarrativeText: '断桥匪首还在步步逼近。',
|
||||
actionResultText: null,
|
||||
toast: null,
|
||||
});
|
||||
}));
|
||||
|
||||
const result = await resumeServerRuntimeStory(localHydratedSnapshot);
|
||||
|
||||
|
||||
@@ -103,7 +103,11 @@ async function playServerBattlePresentation(params: {
|
||||
setGameState: (state: GameState) => void;
|
||||
turnVisualMs: number;
|
||||
}) {
|
||||
const battle = params.response.presentation.battle;
|
||||
const presentation =
|
||||
params.response && 'presentation' in params.response
|
||||
? params.response.presentation
|
||||
: undefined;
|
||||
const battle = presentation?.battle;
|
||||
if (!battle || !params.baseState.inBattle) {
|
||||
return;
|
||||
}
|
||||
@@ -138,7 +142,7 @@ async function playServerBattlePresentation(params: {
|
||||
? {
|
||||
...hostileNpc,
|
||||
animation: isRecoveryOrItem ? ('move' as const) : ('attack' as const),
|
||||
action: params.response.presentation.resultText || hostileNpc.action,
|
||||
action: presentation?.resultText || hostileNpc.action,
|
||||
}
|
||||
: hostileNpc,
|
||||
),
|
||||
|
||||
@@ -21,6 +21,7 @@ export function buildStoryContextFromState(
|
||||
): StoryGenerationContext {
|
||||
return {
|
||||
runtimeSessionId: state.runtimeSessionId ?? null,
|
||||
storySessionId: state.storySessionId ?? null,
|
||||
runtimeActionVersion: state.runtimeActionVersion,
|
||||
playerHp: state.playerHp,
|
||||
playerMaxHp: state.playerMaxHp,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
StoryGenerationContext,
|
||||
StoryRequestOptions,
|
||||
} from '../../services/aiService';
|
||||
import { shouldUseRpgRuntimeServerOptions } from '../../services/rpg-runtime';
|
||||
import { shouldUseServerRuntimeOptions } from '../../services/rpg-runtime';
|
||||
import type {
|
||||
AIResponse,
|
||||
Character,
|
||||
@@ -78,7 +78,7 @@ export async function resolveStoryRequestOptions(params: {
|
||||
? null
|
||||
: params.getAvailableOptionsForState(params.state, params.character);
|
||||
|
||||
if (optionCatalog || !shouldUseRpgRuntimeServerOptions(availableOptions)) {
|
||||
if (optionCatalog || !shouldUseServerRuntimeOptions(availableOptions)) {
|
||||
return {
|
||||
availableOptions,
|
||||
optionCatalog,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createEmptyEquipmentLoadout } from '../../data/equipmentEffects';
|
||||
import { createInitialPlayerProgressionState } from '../../data/playerProgression';
|
||||
import { createInitialGameRuntimeStats } from '../../data/runtimeStats';
|
||||
import { getScenePreset } from '../../data/scenePresets';
|
||||
import { beginRpgRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { beginRuntimeStorySession } from '../../services/rpg-runtime/rpgRuntimeStoryClient';
|
||||
import { createEmptyStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import {
|
||||
AnimationState,
|
||||
@@ -169,7 +169,7 @@ export function useRpgSessionBootstrap() {
|
||||
return;
|
||||
}
|
||||
|
||||
void beginRpgRuntimeStorySession({
|
||||
void beginRuntimeStorySession({
|
||||
worldType: resolvedWorldType,
|
||||
customWorldProfile: launchState.customWorldProfile,
|
||||
character,
|
||||
@@ -199,4 +199,3 @@ export function useRpgSessionBootstrap() {
|
||||
export type RpgSessionBootstrapResult = ReturnType<
|
||||
typeof useRpgSessionBootstrap
|
||||
>;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { isAbortError } from '../../services/apiClient';
|
||||
import {
|
||||
getRpgRuntimeSessionId,
|
||||
getRuntimeSessionId,
|
||||
rpgSnapshotClient,
|
||||
} from '../../services/rpg-runtime';
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
@@ -223,7 +223,7 @@ export function useRpgSessionPersistence({
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void persistSnapshot({
|
||||
payload: {
|
||||
sessionId: getRpgRuntimeSessionId(gameState),
|
||||
sessionId: getRuntimeSessionId(gameState),
|
||||
bottomTab,
|
||||
},
|
||||
logLabel: 'failed to autosave remote snapshot',
|
||||
@@ -251,7 +251,7 @@ export function useRpgSessionPersistence({
|
||||
// 差别只在于调用方可显式覆盖本次 checkpoint 的 session 与 UI tab。
|
||||
const snapshot = await persistSnapshot({
|
||||
payload: {
|
||||
sessionId: getRpgRuntimeSessionId(nextGameState),
|
||||
sessionId: getRuntimeSessionId(nextGameState),
|
||||
bottomTab: nextBottomTab,
|
||||
},
|
||||
logLabel: 'failed to save remote snapshot',
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock('../services/rpg-entry', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../services/rpg-runtime', () => ({
|
||||
getRpgRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
|
||||
getRuntimeSessionId: (gameState: Pick<GameState, 'runtimeSessionId'>) =>
|
||||
gameState.runtimeSessionId?.trim() || 'runtime-main',
|
||||
rpgSnapshotClient: {
|
||||
getSnapshot: storageMocks.getSaveSnapshot,
|
||||
|
||||
@@ -18,8 +18,8 @@ import { useRpgSessionBootstrap } from './rpg-session';
|
||||
const aiServiceMocks = vi.hoisted(() => ({
|
||||
streamNpcChatTurn: vi.fn(),
|
||||
}));
|
||||
const rpgRuntimeStoryClientMocks = vi.hoisted(() => ({
|
||||
beginRpgRuntimeStorySession: vi.fn(),
|
||||
const runtimeStoryClientMocks = vi.hoisted(() => ({
|
||||
beginRuntimeStorySession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/aiService', async () => {
|
||||
@@ -41,8 +41,8 @@ vi.mock('../services/rpg-runtime/rpgRuntimeStoryClient', async () => {
|
||||
|
||||
return {
|
||||
...actual,
|
||||
beginRpgRuntimeStorySession:
|
||||
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession,
|
||||
beginRuntimeStorySession:
|
||||
runtimeStoryClientMocks.beginRuntimeStorySession,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -588,7 +588,7 @@ function GameFlowHarness({
|
||||
);
|
||||
const selectedCharacter = playableCharacters[0] ?? null;
|
||||
if (selectedCharacter) {
|
||||
rpgRuntimeStoryClientMocks.beginRpgRuntimeStorySession.mockResolvedValue(
|
||||
runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue(
|
||||
buildRuntimeStoryBootstrapSnapshot({
|
||||
profile,
|
||||
character: selectedCharacter,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 对象。
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ export interface CustomWorldSceneImageResult {
|
||||
|
||||
export interface StoryGenerationContext {
|
||||
runtimeSessionId?: string | null;
|
||||
storySessionId?: string | null;
|
||||
runtimeActionVersion?: number;
|
||||
playerHp: number;
|
||||
playerMaxHp: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
|
||||
@@ -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,
|
||||
|
||||
92
src/services/rpg-creation/rpgCreationAssetClient.test.ts
Normal file
92
src/services/rpg-creation/rpgCreationAssetClient.test.ts
Normal 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 失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '普通文本',
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user