Close DDD refactor and remove generated asset proxy

This commit is contained in:
kdletters
2026-05-02 00:27:22 +08:00
parent fd08262bf0
commit 9d9913095d
605 changed files with 11811 additions and 10106 deletions
@@ -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',
+4 -1
View File
@@ -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,
+2 -2
View File
@@ -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,
}),
File diff suppressed because it is too large Load Diff
@@ -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',
+1 -1
View File
@@ -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,
+5 -5
View File
@@ -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,
+123 -24
View File
@@ -10,6 +10,7 @@ import type {
StoryOption,
} from '../types';
import { AnimationState, WorldType } from '../types';
import type { StoryRuntimeProjectionResponse } from '../../packages/shared/src/contracts/story';
const {
connectivityError,
@@ -101,6 +102,7 @@ function createContext(
): StoryGenerationContext {
return {
runtimeSessionId: 'runtime-main',
storySessionId: 'storysess-main',
runtimeActionVersion: 3,
playerHp: 30,
playerMaxHp: 40,
@@ -115,6 +117,8 @@ function createContext(
sceneName: 'Forest Trail',
sceneDescription: 'A quiet mountain path.',
pendingSceneEncounter: false,
observeSignsRequested: false,
recentActionResult: null,
...overrides,
};
}
@@ -424,6 +428,71 @@ function createApiEnvelopeResponse(data: unknown) {
} as Response;
}
type RuntimeProjectionOverrides = Omit<
Partial<StoryRuntimeProjectionResponse>,
'storySession'
> & {
storySession?: Partial<StoryRuntimeProjectionResponse['storySession']>;
};
function createRuntimeProjection(
overrides: RuntimeProjectionOverrides = {},
): StoryRuntimeProjectionResponse {
const storySession = {
storySessionId: 'storysess-main',
runtimeSessionId: 'runtime-main',
actorUserId: 'user-main',
worldProfileId: 'profile-main',
initialPrompt: '进入山路',
openingSummary: null,
latestNarrativeText: '山路尽头传来新的动静。',
latestChoiceFunctionId: null,
status: 'active',
version: 3,
createdAt: '2026-04-08T00:00:00.000Z',
updatedAt: '2026-04-08T00:00:01.000Z',
...(overrides.storySession ?? {}),
} satisfies StoryRuntimeProjectionResponse['storySession'];
return {
storySession,
storyEvents: overrides.storyEvents ?? [],
serverVersion: overrides.serverVersion ?? storySession.version,
gameState: {
runtimeSessionId: storySession.runtimeSessionId,
storySessionId: storySession.storySessionId,
runtimeActionVersion: overrides.serverVersion ?? storySession.version,
currentScene: 'Story',
playerEquipment: { weapon: null, armor: null, relic: null },
...(overrides.gameState ?? {}),
},
actor: overrides.actor ?? {
hp: 30,
maxHp: 40,
mana: 12,
maxMana: 20,
currency: 0,
currencyText: '0 铜钱',
},
inventory: overrides.inventory ?? {
backpackItems: [],
equipmentSlots: [],
forgeRecipes: [],
},
options: overrides.options ?? [],
status: overrides.status ?? {
inBattle: false,
npcInteractionActive: false,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
currentNarrativeText:
overrides.currentNarrativeText ?? storySession.latestNarrativeText,
actionResultText: overrides.actionResultText ?? null,
toast: overrides.toast ?? null,
};
}
function createSseResponse(text: string) {
const encoder = new TextEncoder();
const chunks = [
@@ -478,14 +547,26 @@ describe('ai runtime client orchestration', () => {
streamPlainTextCompletionMock.mockReset();
});
it('requests initial story from the runtime api server', async () => {
it('requests initial story from the story session projection', async () => {
const availableOptions = [createStoryOption()];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '山路尽头传来新的动静。',
options: availableOptions,
encounter: null,
}),
createApiEnvelopeResponse(
createRuntimeProjection({
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
currentNarrativeText: '山路尽头传来新的动静。',
storySession: {
latestNarrativeText: '山路尽头传来新的动静。',
},
}),
),
);
const response = await generateInitialStory(
@@ -497,21 +578,16 @@ describe('ai runtime client orchestration', () => {
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/initial',
'/api/story/sessions/storysess-main/runtime-projection',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 3,
requestOptions: { availableOptions },
}),
method: 'GET',
}),
);
expect(response.storyText).toBe('山路尽头传来新的动静。');
expect(response.options).toEqual(availableOptions);
});
it('requests next story step from the runtime api server', async () => {
it('requests next story step from the story session action endpoint', async () => {
const availableOptions = [
createStoryOption({
functionId: 'idle_explore_forward',
@@ -521,9 +597,24 @@ describe('ai runtime client orchestration', () => {
];
fetchMock.mockResolvedValue(
createApiEnvelopeResponse({
storyText: '林间重新安静下来,你听见远处的风声。',
encounter: null,
options: availableOptions,
projection: createRuntimeProjection({
serverVersion: 4,
currentNarrativeText: '林间重新安静下来,你听见远处的风声。',
storySession: {
latestNarrativeText: '林间重新安静下来,你听见远处的风声。',
latestChoiceFunctionId: 'idle_explore_forward',
version: 4,
},
options: availableOptions.map((option) => ({
functionId: option.functionId,
actionText: option.actionText,
detailText: null,
scope: 'story',
payload: null,
enabled: true,
reason: null,
})),
}),
}),
);
@@ -533,19 +624,27 @@ describe('ai runtime client orchestration', () => {
monsters,
storyHistory,
'继续向前',
context,
{
...context,
lastFunctionId: 'idle_explore_forward',
},
{ availableOptions },
);
expect(fetchMock).toHaveBeenCalledWith(
'/api/runtime/story/continue',
'/api/story/sessions/storysess-main/actions/resolve',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
storySessionId: 'storysess-main',
clientVersion: 3,
choice: '继续向前',
requestOptions: { availableOptions },
functionId: 'idle_explore_forward',
actionText: '继续向前',
payload: {
optionText: '继续向前',
observeSignsRequested: false,
recentActionResult: null,
},
}),
}),
);
@@ -972,11 +1071,11 @@ describe('ai runtime client orchestration', () => {
});
const sceneImageCalls = fetchMock.mock.calls.filter(
([url]) => url === '/api/custom-world/scene-image',
([url]) => url === '/api/runtime/custom-world/scene-image',
);
expect(sceneImageCalls).toHaveLength(1);
expect(sceneImageCalls[0]).toEqual([
'/api/custom-world/scene-image',
'/api/runtime/custom-world/scene-image',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
+2 -1
View File
@@ -5,6 +5,7 @@ import type {
} from '../../packages/shared/src/contracts/runtime';
import { unwrapApiResponse } from '../../packages/shared/src/http';
import { createSceneHostileNpcsFromEncounters } from '../data/hostileNpcs';
import { hasMixedNarrativeLanguage } from './narrativeLanguage';
import {
buildEncounterFromSceneNpc,
getScenePresetById,
@@ -134,7 +135,7 @@ type MergeableCustomWorldRoleEntry = {
};
const CUSTOM_WORLD_SCENE_IMAGE_API_BASE_URL =
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/custom-world/scene-image';
ENV.VITE_SCENE_IMAGE_PROXY_BASE_URL || '/api/runtime/custom-world/scene-image';
const CUSTOM_WORLD_JSON_REPAIR_SYSTEM_PROMPT = `你是 JSON 修复器。
你会收到一段本应为单个 JSON 对象的文本。
你的唯一任务是把它修复成能被 JSON.parse 直接解析的单个 JSON 对象。
+62 -51
View File
@@ -9,7 +9,6 @@ import type {
NpcRecruitDialogueRequest,
PlainTextResponse,
} from '../../packages/shared/src/contracts/rpgRuntimeChat';
import type { RuntimeStoryAiRequest } from '../../packages/shared/src/contracts/rpgRuntimeStoryState';
import type {
CustomWorldGenerationProgress,
GenerateCustomWorldProfileInput,
@@ -35,6 +34,11 @@ import type {
import { fetchWithApiAuth, requestJson } from './apiClient';
import { type CharacterChatTargetStatus } from './rpgRuntimeChatTypes';
import { parseLineListContent } from './llmParsers';
import {
buildStoryMomentFromRuntimeProjection,
getStoryRuntimeProjection,
resolveRuntimeStoryAction,
} from './rpg-runtime/rpgRuntimeStoryClient';
const RUNTIME_API_BASE = '/api/runtime';
@@ -42,6 +46,20 @@ function getRuntimeSessionIdFromContext(context: StoryGenerationContext) {
return context.runtimeSessionId?.trim() || undefined;
}
function getStorySessionIdFromContext(context: StoryGenerationContext) {
return context.storySessionId?.trim() || undefined;
}
function runtimeStoryMomentToAiResponse(
story: StoryMoment | null | undefined,
fallbackText: string,
): AIResponse {
return {
storyText: story?.text?.trim() || fallbackText,
options: story?.options ?? [],
};
}
async function requestPlainText(
url: string,
payload: unknown,
@@ -162,30 +180,22 @@ export async function generateInitialStory(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
requestOptions,
}
: {
worldType: world,
character,
monsters,
context,
requestOptions,
};
void world;
void character;
void monsters;
void requestOptions;
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/initial`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'剧情开局生成失败',
);
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法生成开局剧情');
}
const projection = await getStoryRuntimeProjection({
storySessionId,
clientVersion: context.runtimeActionVersion,
});
const story = buildStoryMomentFromRuntimeProjection({ projection });
return runtimeStoryMomentToAiResponse(story, '开局剧情已同步。');
}
export async function generateNextStep(
@@ -197,35 +207,36 @@ export async function generateNextStep(
context: StoryGenerationContext,
requestOptions: StoryRequestOptions = {},
): Promise<AIResponse> {
const sessionId = getRuntimeSessionIdFromContext(context);
const payload: RuntimeStoryAiRequest | Record<string, unknown> = sessionId
? {
sessionId,
clientVersion: context.runtimeActionVersion,
choice,
lastFunctionId: context.lastFunctionId,
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
requestOptions,
}
: {
worldType: world,
character,
monsters,
history,
choice,
context,
requestOptions,
};
void world;
void character;
void monsters;
void history;
void requestOptions;
return requestJson<AIResponse>(
`${RUNTIME_API_BASE}/story/continue`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
const storySessionId = getStorySessionIdFromContext(context);
if (!storySessionId) {
throw new Error('运行时故事会话不存在,无法续写剧情');
}
const functionId = context.lastFunctionId?.trim();
if (!functionId) {
throw new Error('运行时动作缺少 functionId,无法续写剧情');
}
const response = await resolveRuntimeStoryAction({
storySessionId,
clientVersion: context.runtimeActionVersion,
option: {
functionId,
actionText: choice,
},
'剧情续写失败',
payload: {
observeSignsRequested: context.observeSignsRequested,
recentActionResult: context.recentActionResult,
},
});
return runtimeStoryMomentToAiResponse(
response.snapshot.currentStory,
choice,
);
}
+1
View File
@@ -89,6 +89,7 @@ export interface CustomWorldSceneImageResult {
export interface StoryGenerationContext {
runtimeSessionId?: string | null;
storySessionId?: string | null;
runtimeActionVersion?: number;
playerHp: number;
playerMaxHp: number;
+2 -2
View File
@@ -436,9 +436,9 @@ describe('apiClient', () => {
let capturedError: unknown;
try {
await requestJson(
'/api/runtime/story/initial',
'/api/runtime/protected-error',
{ method: 'POST' },
'剧情生成失败',
'请求失败',
);
} catch (error) {
capturedError = error;
-1
View File
@@ -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,

Some files were not shown because too many files have changed in this diff Show More