This commit is contained in:
2026-04-22 23:44:57 +08:00
parent 76ac9d22a5
commit 84dc92646a
484 changed files with 9598 additions and 9135 deletions

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
@@ -32,6 +32,9 @@ import {
unpublishRpgEntryWorldProfile,
upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory,
} from '../../services/rpg-entry';
import { createBigFishCreationSession } from '../../services/big-fish-creation';
import { createPuzzleAgentSession } from '../../services/puzzle-agent';
import { listPuzzleWorks } from '../../services/puzzle-works';
import type { GameState } from '../../types';
import {
AuthUiContext,
@@ -39,6 +42,7 @@ import {
} from '../auth/AuthUiContext';
import {
RpgEntryFlowShell,
type RpgEntryFlowShellProps,
type SelectionStage,
} from './RpgEntryFlowShell';
@@ -63,13 +67,20 @@ async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
}
async function openNewRpgCreation(
user: ReturnType<typeof userEvent.setup>,
) {
async function openNewRpgCreation(user: ReturnType<typeof userEvent.setup>) {
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
}
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
if (!panel) {
throw new Error(`Missing platform tab panel: ${tab}`);
}
return panel;
}
vi.mock('../../services/rpg-creation', () => ({
createRpgCreationSession: vi.fn(),
executeRpgCreationAction: vi.fn(),
@@ -96,6 +107,23 @@ vi.mock('../../services/rpg-entry', () => ({
upsertRpgProfileBrowseHistory: vi.fn(),
}));
vi.mock('../../services/puzzle-works', () => ({
listPuzzleWorks: vi.fn(),
}));
vi.mock('../../services/big-fish-creation', () => ({
createBigFishCreationSession: vi.fn(),
executeBigFishCreationAction: vi.fn(),
streamBigFishCreationMessage: vi.fn(),
}));
vi.mock('../../services/puzzle-agent', () => ({
createPuzzleAgentSession: vi.fn(),
executePuzzleAgentAction: vi.fn(),
getPuzzleAgentSession: vi.fn(),
streamPuzzleAgentMessage: vi.fn(),
}));
vi.mock('../custom-world-agent/CustomWorldAgentWorkspace', () => ({
CustomWorldAgentWorkspace: ({
session,
@@ -379,6 +407,7 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
type TestAuthValue = {
user: AuthUser | null;
canAccessProtectedData: boolean;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
requireAuth: (action: () => void) => void;
openSettingsModal: (section?: PlatformSettingsSection) => void;
@@ -393,9 +422,12 @@ type TestAuthValue = {
settingsError: string | null;
};
function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue {
function createAuthValue(
overrides: Partial<TestAuthValue> = {},
): TestAuthValue {
return {
user: mockAuthUser,
canAccessProtectedData: true,
openLoginModal: () => {},
requireAuth: (action) => action(),
openSettingsModal: () => {},
@@ -416,10 +448,12 @@ function TestWrapper({
withAuth = false,
authValue,
onContinueGame,
onSelectWorld,
}: {
withAuth?: boolean;
authValue?: TestAuthValue;
onContinueGame?: (snapshot?: HydratedSavedGameSnapshot | null) => void;
onSelectWorld?: RpgEntryFlowShellProps['handleCustomWorldSelect'];
} = {}) {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
@@ -433,7 +467,7 @@ function TestWrapper({
savedSnapshot={null}
handleContinueGame={onContinueGame ?? (() => {})}
handleStartNewGame={() => {}}
handleCustomWorldSelect={() => {}}
handleCustomWorldSelect={onSelectWorld ?? (() => {})}
/>
);
@@ -442,9 +476,7 @@ function TestWrapper({
}
return (
<AuthUiContext.Provider
value={authValue ?? createAuthValue()}
>
<AuthUiContext.Provider value={authValue ?? createAuthValue()}>
{content}
</AuthUiContext.Provider>
);
@@ -513,8 +545,106 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession,
});
vi.mocked(createBigFishCreationSession).mockResolvedValue({
session: {
sessionId: 'big-fish-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
anchorPack: {
gameplayPromise: {
key: 'gameplay_promise',
label: '核心玩法',
value: '',
status: 'missing',
},
ecologyVisualTheme: {
key: 'ecology_visual_theme',
label: '生态视觉',
value: '',
status: 'missing',
},
growthLadder: {
key: 'growth_ladder',
label: '成长阶梯',
value: '',
status: 'missing',
},
riskTempo: {
key: 'risk_tempo',
label: '风险节奏',
value: '',
status: 'missing',
},
},
draft: null,
assetSlots: [],
assetCoverage: {
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
requiredLevelCount: 0,
publishReady: false,
blockers: [],
},
messages: [],
lastAssistantReply: '先说说你想要什么样的大鱼生态。',
publishReady: false,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(createPuzzleAgentSession).mockResolvedValue({
session: {
sessionId: 'puzzle-session-1',
currentTurn: 0,
progressPercent: 0,
stage: 'clarifying',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '',
status: 'missing',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '',
status: 'missing',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '',
status: 'missing',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '',
status: 'missing',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '',
status: 'missing',
},
},
draft: null,
messages: [],
lastAssistantReply: '先说一个你最想做成拼图的画面。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-04-22T12:00:00.000Z',
},
});
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(listPuzzleWorks).mockResolvedValue({
items: [],
});
vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: {
operationId: 'operation-draft-foundation-1',
@@ -594,9 +724,7 @@ test('create tab opens compiled agent draft in result refinement page', async ()
canEnterWorld: false,
},
]);
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
render(<TestWrapper withAuth />);
@@ -606,12 +734,19 @@ test('create tab opens compiled agent draft in result refinement page', async ()
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
}, { timeout: 5000 });
await waitFor(
() => {
expect(screen.queryByText('正在加载世界编辑器...')).toBeNull();
},
{ timeout: 5000 },
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.queryByText('Agent工作区custom-world-agent-session-1')).toBeNull();
expect(
await screen.findByText('世界档案', {}, { timeout: 5000 }),
).toBeTruthy();
expect(
screen.queryByText('Agent工作区custom-world-agent-session-1'),
).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
@@ -706,7 +841,7 @@ test('opening a compiled draft with a missing agent session falls back to create
await waitFor(() => {
expect(
screen.getByText(
within(getPlatformTabPanel('create')).getByText(
'这份共创草稿已失效,已为你返回创作中心,请重新开始创作。',
),
).toBeTruthy();
@@ -807,6 +942,88 @@ test('restoring an agent workspace while logged out opens login modal before loa
expect(getRpgCreationSession).not.toHaveBeenCalled();
});
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup();
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
new ApiClientError({
message: '缺少 Authorization Bearer Token',
status: 401,
code: 'UNAUTHORIZED',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(screen.getByRole('button', { name: / RPG/u }));
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeTruthy();
});
expect(listPuzzleWorks).toHaveBeenCalled();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
test('big fish creation timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createBigFishCreationSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const button = screen.getByRole('button', { name: //u });
await user.click(button);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getAllByText(
'开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
});
test('puzzle creation timeout exits busy state and shows a readable error', async () => {
const user = userEvent.setup();
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
Object.assign(new Error('请求超时15000ms'), {
name: 'TimeoutError',
}),
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
const button = screen.getByRole('button', { name: //u });
await user.click(button);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getAllByText(
'开启拼图创作工作台超时,请确认运行时后端已启动后重试。',
).length,
).toBeGreaterThan(0);
});
expect((button as HTMLButtonElement).disabled).toBe(false);
expect(screen.queryByText(//u)).toBeNull();
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();
@@ -851,9 +1068,7 @@ test('existing draft sessions open result page refinement instead of agent dialo
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue(
compiledAgentDraftSession,
);
vi.mocked(getRpgCreationSession).mockResolvedValue(compiledAgentDraftSession);
render(<TestWrapper withAuth />);
@@ -913,9 +1128,7 @@ test('agent result view shows publish blockers and disables publish-enter action
await openNewRpgCreation(user);
expect(
await screen.findByText(/ 1 /u),
).toBeTruthy();
expect(await screen.findByText(/ 1 /u)).toBeTruthy();
const actionButton = screen.getByRole('button', {
name: //u,
});
@@ -991,7 +1204,7 @@ test('agent draft result publishes before entering world and uses published prev
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
@@ -1023,11 +1236,13 @@ test('agent draft result publishes before entering world and uses published prev
);
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
@@ -1219,11 +1434,13 @@ test('agent draft result back button returns to creation hub without redundant s
});
expect(
vi.mocked(executeRpgCreationAction).mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
),
).toBe(false);
expect(screen.queryByText('世界档案')).toBeNull();
});
@@ -1366,7 +1583,9 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
expect(upsertRpgWorldProfile).toHaveBeenCalled();
});
const latestSavedProfile = vi.mocked(upsertRpgWorldProfile).mock.calls.at(-1)?.[0];
const latestSavedProfile = vi
.mocked(upsertRpgWorldProfile)
.mock.calls.at(-1)?.[0];
expect(latestSavedProfile?.name).toBe('潮雾列岛·session最新版');
expect(latestSavedProfile?.summary).toBe(
'作品库应该保存这份同步后的最新快照。',
@@ -1391,7 +1610,8 @@ test('agent draft result can open from server result preview without embedded le
settingText: '被海雾吞没的旧航路群岛',
name: '潮雾列岛·服务端预览',
subtitle: '结果页改为优先消费 session.resultPreview',
summary: '即使 draft 中没有 legacyResultProfile也应该正常打开结果页。',
summary:
'即使 draft 中没有 legacyResultProfile也应该正常打开结果页。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
templateWorldType: 'WUXIA',
@@ -1420,9 +1640,7 @@ test('agent draft result can open from server result preview without embedded le
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(
screen.getByText('潮雾列岛·服务端预览'),
).toBeTruthy();
expect(screen.getByText('潮雾列岛·服务端预览')).toBeTruthy();
expect(
screen.getByText('结果页改为优先消费 session.resultPreview'),
).toBeTruthy();
@@ -1454,6 +1672,37 @@ test('authenticated users with save archives default into the saves tab', async
expect(screen.queryByText('SAVE ARCHIVE')).toBeNull();
});
test('manual tab switch is preserved after platform bootstrap requests finish', async () => {
const user = userEvent.setup();
let resolveGalleryRequest!: (value: []) => void;
const delayedGalleryRequest = new Promise<[]>((resolve) => {
resolveGalleryRequest = resolve;
});
vi.mocked(listRpgEntryWorldGallery).mockReturnValueOnce(
delayedGalleryRequest as Promise<[]>,
);
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'),
).toBeTruthy();
});
expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe(
'false',
);
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('true');
});
test('save tab can resume a selected archive directly into the game', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
@@ -1504,7 +1753,7 @@ test('save tab can resume a selected archive directly into the game', async () =
});
});
test('owned world detail can delete a work and return to the create tab list', async () => {
test('creation hub published work can open detail view before deleting from detail page', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
@@ -1572,7 +1821,7 @@ test('owned world detail can delete a work and return to the create tab list', a
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: '删除作品' }));
await waitFor(() => {
@@ -1650,9 +1899,168 @@ test('creation hub published work enters existing detail view', async () => {
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: //u }));
await user.click(await screen.findByRole('button', { name: //u }));
expect(await screen.findByText('世界信息')).toBeTruthy();
expect(screen.getByRole('button', { name: '开始游戏' })).toBeTruthy();
expect(screen.getByText('已发布')).toBeTruthy();
});
test('creation hub published work experience button enters world directly', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: 'published:world-experience-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: '2026-04-20T10:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-experience-1',
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([
{
ownerUserId: 'user-1',
profileId: 'world-experience-1',
profile: {
id: 'world-experience-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '已经发布的群岛世界作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清群岛旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['假航灯正在扰乱航线'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]);
render(
<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />,
);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '体验' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'world-experience-1',
name: '潮雾列岛',
}),
);
});
expect(screen.queryByText('世界信息')).toBeNull();
});
test('creation hub published work delete button removes the work directly from card list', async () => {
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
const publishedWork = {
workId: 'published:world-card-delete-1',
sourceType: 'published_profile' as const,
status: 'published' as const,
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
coverRenderMode: 'image' as const,
coverCharacterImageSrcs: [],
updatedAt: '2026-04-16T12:00:00.000Z',
publishedAt: '2026-04-16T12:00:00.000Z',
stage: null,
stageLabel: '已发布',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: 'world-card-delete-1',
canResume: false,
canEnterWorld: true,
};
const publishedLibraryEntry = {
ownerUserId: 'user-1',
profileId: 'world-card-delete-1',
profile: {
id: 'world-card-delete-1',
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '用于测试卡片删除流程的作品。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清旧案。',
majorFactions: ['守灯会'],
coreConflicts: ['雾潮正在逼近港口'],
playableNpcs: [],
storyNpcs: [],
landmarks: [],
} as never,
visibility: 'published' as const,
publishedAt: '2026-04-16T12:00:00.000Z',
updatedAt: '2026-04-16T12:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '用于测试卡片删除流程的作品。',
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
};
vi.mocked(listRpgCreationWorks)
.mockResolvedValueOnce([publishedWork])
.mockResolvedValue([]);
vi.mocked(listRpgEntryWorldLibrary)
.mockResolvedValueOnce([publishedLibraryEntry])
.mockResolvedValue([]);
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
await user.click(await screen.findByRole('button', { name: '删除' }));
await waitFor(() => {
expect(deleteRpgEntryWorldProfile).toHaveBeenCalledWith(
'world-card-delete-1',
);
});
await waitFor(() => {
expect(screen.getByText('还没有作品')).toBeTruthy();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import type {
CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { ApiClientError, isTimeoutError } from '../../services/apiClient';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import type { CustomWorldProfile } from '../../types';
@@ -11,6 +12,28 @@ export function resolveRpgEntryErrorMessage(
error: unknown,
fallback: string,
) {
if (isTimeoutError(error)) {
if (//u.test(fallback)) {
return '开启拼图创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启大鱼吃小鱼创作工作台超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启创作工作台超时,请确认运行时后端已启动后重试。';
}
return '请求超时,请稍后重试。';
}
if (
error instanceof ApiClientError &&
error.status === 401 &&
(error.code === 'UNAUTHORIZED' ||
error.message.includes('Authorization Bearer Token'))
) {
return '当前登录状态已失效,请重新登录后继续。';
}
return error instanceof Error ? error.message : fallback;
}

View File

@@ -58,6 +58,9 @@ export function useRpgCreationSessionController(
onSessionOpened,
} = params;
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
const isHydratingInitialAgentWorkspaceRef = useRef(
Boolean(initialAgentUiStateRef.current.activeSessionId),
);
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false);
const isAgentDraftResultAutoOpenSuppressedRef = useRef(false);
@@ -77,6 +80,8 @@ export function useRpgCreationSessionController(
const [isStreamingAgentReply, setIsStreamingAgentReply] = useState(false);
const [isLoadingAgentSession, setIsLoadingAgentSession] = useState(false);
const [creationTypeError, setCreationTypeError] = useState<string | null>(null);
const [agentWorkspaceRestoreError, setAgentWorkspaceRestoreError] =
useState<string | null>(null);
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
useState<CustomWorldProfile | null>(null);
const [customWorldError, setCustomWorldError] = useState<string | null>(null);
@@ -135,6 +140,8 @@ export function useRpgCreationSessionController(
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
return;
}
@@ -144,16 +151,22 @@ export function useRpgCreationSessionController(
setIsLoadingAgentSession(false);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
setAgentWorkspaceRestoreError(null);
return;
}
let cancelled = false;
const isInitialWorkspaceRestore =
isHydratingInitialAgentWorkspaceRef.current &&
activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId;
setIsLoadingAgentSession(true);
void syncAgentSessionSnapshot(activeAgentSessionId)
.then(() => {
if (!cancelled) {
setCreationTypeError(null);
setAgentWorkspaceRestoreError(null);
isHydratingInitialAgentWorkspaceRef.current = false;
}
})
.catch((error) => {
@@ -161,13 +174,20 @@ export function useRpgCreationSessionController(
return;
}
setCreationTypeError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
// 登录后自动恢复的是“上一次残留的工作区指针”,
// 这里失败时应优先静默清理,避免把旧恢复错误冒充成当前登录已失效。
if (isInitialWorkspaceRestore) {
setAgentWorkspaceRestoreError(null);
} else {
setAgentWorkspaceRestoreError(
resolveRpgCreationErrorMessage(error, '读取 Agent 共创工作区失败。'),
);
}
setAgentSession(null);
setAgentOperation(null);
setStreamingAgentReplyText('');
setIsStreamingAgentReply(false);
isHydratingInitialAgentWorkspaceRef.current = false;
persistAgentUiState(null, null);
enterCreateTab?.();
setSelectionStage('platform');
@@ -353,6 +373,7 @@ export function useRpgCreationSessionController(
const { session } = await createRpgCreationSession(
seedText ? { seedText } : {},
);
isHydratingInitialAgentWorkspaceRef.current = false;
setAgentSession(session);
setAgentOperation(null);
setGeneratedCustomWorldProfile(null);
@@ -539,6 +560,7 @@ export function useRpgCreationSessionController(
isLoadingAgentSession,
creationTypeError,
setCreationTypeError,
agentWorkspaceRestoreError,
customWorldError,
setCustomWorldError,
generatedCustomWorldProfile,

View File

@@ -28,6 +28,7 @@ import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
type UseRpgEntryBootstrapParams = {
user: AuthUser | null | undefined;
canAccessProtectedData?: boolean | undefined;
getProfileDashboard: () => Promise<ProfileDashboardSummary | null>;
handleContinueGame: (
snapshot?: HydratedSavedGameSnapshot | null,
@@ -38,12 +39,19 @@ type UseRpgEntryBootstrapParams = {
export function useRpgEntryBootstrap(
params: UseRpgEntryBootstrapParams,
) {
const { user, getProfileDashboard, handleContinueGame, hasInitialAgentSession } =
params;
const {
user,
canAccessProtectedData = Boolean(user),
getProfileDashboard,
handleContinueGame,
hasInitialAgentSession,
} = params;
const isAuthenticated = Boolean(user);
const canReadProtectedData = Boolean(user) && canAccessProtectedData;
const platformTabBootstrapUserIdRef = useRef<string | null | undefined>(
undefined,
);
const hasExplicitPlatformTabSelectionRef = useRef(false);
const [savedCustomWorldEntries, setSavedCustomWorldEntries] = useState<
CustomWorldLibraryEntry<CustomWorldProfile>[]
@@ -58,7 +66,7 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -71,8 +79,15 @@ export function useRpgEntryBootstrap(
const [profileDashboard, setProfileDashboard] =
useState<ProfileDashboardSummary | null>(null);
const setPlatformTab = useCallback((nextTab: PlatformHomeTab) => {
// 区分“平台首屏默认落点”和“用户/流程显式切换”。
// 一旦显式切过 Tab就不能再被首屏异步请求回刷成首页或存档。
hasExplicitPlatformTabSelectionRef.current = true;
setPlatformTabState(nextTab);
}, []);
const refreshProfileDashboard = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(null);
setIsLoadingDashboard(false);
@@ -91,10 +106,10 @@ export function useRpgEntryBootstrap(
} finally {
setIsLoadingDashboard(false);
}
}, [getProfileDashboard, user]);
}, [canReadProtectedData, getProfileDashboard, user]);
const refreshCustomWorldWorks = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setCustomWorldWorkEntries([]);
return [];
}
@@ -102,7 +117,7 @@ export function useRpgEntryBootstrap(
const nextItems = await listRpgCreationWorks();
setCustomWorldWorkEntries(nextItems);
return nextItems;
}, [user]);
}, [canReadProtectedData, user]);
const refreshPublishedGallery = useCallback(async () => {
const nextEntries = await listRpgEntryWorldGallery();
@@ -111,7 +126,7 @@ export function useRpgEntryBootstrap(
}, []);
const refreshSavedCustomWorldLibrary = useCallback(async () => {
if (!user) {
if (!user || !canReadProtectedData) {
setSavedCustomWorldEntries([]);
return [];
}
@@ -119,7 +134,7 @@ export function useRpgEntryBootstrap(
const nextEntries = await listRpgEntryWorldLibrary();
setSavedCustomWorldEntries(nextEntries);
return nextEntries;
}, [user]);
}, [canReadProtectedData, user]);
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
@@ -139,7 +154,7 @@ export function useRpgEntryBootstrap(
const handleResumeSaveEntry = useCallback(
async (entry: ProfileSaveArchiveSummary) => {
if (!user || isResumingSaveWorldKey) {
if (!user || !canReadProtectedData || isResumingSaveWorldKey) {
return;
}
@@ -162,11 +177,20 @@ export function useRpgEntryBootstrap(
setIsResumingSaveWorldKey(null);
}
},
[handleContinueGame, isResumingSaveWorldKey, user],
[canReadProtectedData, handleContinueGame, isResumingSaveWorldKey, user],
);
useEffect(() => {
let isActive = true;
const nextPlatformBootstrapUserId = user?.id ?? null;
const shouldApplyInitialPlatformTab =
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId;
if (shouldApplyInitialPlatformTab) {
// 在请求发出前先占位,避免首屏请求未完成时用户切了 Tab
// 返回结果又被误判成“还没初始化过”并强制跳回默认页。
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
}
void (async () => {
setHistoryEntries([]);
@@ -174,9 +198,9 @@ export function useRpgEntryBootstrap(
setSaveError(null);
setIsLoadingPlatform(true);
setPlatformError(null);
setIsLoadingDashboard(isAuthenticated);
setIsLoadingDashboard(canReadProtectedData);
setDashboardError(null);
if (!isAuthenticated) {
if (!canReadProtectedData) {
setSavedCustomWorldEntries([]);
setCustomWorldWorkEntries([]);
setSaveEntries([]);
@@ -192,16 +216,20 @@ export function useRpgEntryBootstrap(
historyResult,
saveArchivesResult,
] = await Promise.allSettled([
isAuthenticated
canReadProtectedData
? listRpgEntryWorldLibrary()
: Promise.resolve([]),
isAuthenticated
canReadProtectedData
? listRpgCreationWorks()
: Promise.resolve([]),
listRpgEntryWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated ? listRpgProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listRpgProfileSaveArchives() : Promise.resolve([]),
canReadProtectedData ? getProfileDashboard() : Promise.resolve(null),
canReadProtectedData
? listRpgProfileBrowseHistory()
: Promise.resolve([]),
canReadProtectedData
? listRpgProfileSaveArchives()
: Promise.resolve([]),
]);
if (!isActive) {
@@ -227,8 +255,10 @@ export function useRpgEntryBootstrap(
}
if (
(isAuthenticated && libraryEntriesResult.status === 'rejected') ||
(isAuthenticated && workEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
libraryEntriesResult.status === 'rejected') ||
(canReadProtectedData &&
workEntriesResult.status === 'rejected') ||
galleryEntriesResult.status === 'rejected'
) {
const platformFailure =
@@ -246,7 +276,7 @@ export function useRpgEntryBootstrap(
if (dashboardResult.status === 'fulfilled') {
setProfileDashboard(dashboardResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setProfileDashboard(null);
setDashboardError(
resolveRpgEntryErrorMessage(
@@ -258,7 +288,7 @@ export function useRpgEntryBootstrap(
if (historyResult.status === 'fulfilled') {
setHistoryEntries(historyResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setHistoryError(
resolveRpgEntryErrorMessage(historyResult.reason, '读取浏览历史失败。'),
);
@@ -266,7 +296,7 @@ export function useRpgEntryBootstrap(
if (saveArchivesResult.status === 'fulfilled') {
setSaveEntries(saveArchivesResult.value);
} else if (isAuthenticated) {
} else if (canReadProtectedData) {
setSaveEntries([]);
setSaveError(
resolveRpgEntryErrorMessage(
@@ -276,20 +306,19 @@ export function useRpgEntryBootstrap(
);
}
const nextPlatformBootstrapUserId = user?.id ?? null;
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
shouldApplyInitialPlatformTab &&
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!hasInitialAgentSession) {
setPlatformTab(
isAuthenticated &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
setPlatformTabState(
isAuthenticated &&
canReadProtectedData &&
saveArchivesResult.status === 'fulfilled' &&
saveArchivesResult.value.length > 0
? 'saves'
: 'home',
);
}
} finally {
if (isActive) {
@@ -302,10 +331,17 @@ export function useRpgEntryBootstrap(
return () => {
isActive = false;
};
}, [getProfileDashboard, hasInitialAgentSession, isAuthenticated, user]);
}, [
canReadProtectedData,
getProfileDashboard,
hasInitialAgentSession,
isAuthenticated,
user,
]);
return {
isAuthenticated,
canReadProtectedData,
platformTab,
setPlatformTab,
savedCustomWorldEntries,