This commit is contained in:
2026-04-21 10:30:12 +08:00
parent ae28dab032
commit 13bc79306f
49 changed files with 3691 additions and 1357 deletions

View File

@@ -17,7 +17,7 @@ const baseDraftItem: CustomWorldWorkSummary = {
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
@@ -35,7 +35,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
@@ -62,7 +62,7 @@ test('creation hub reflects updated draft title summary and counts after rerende
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -33,7 +33,7 @@ test('creation hub draft card renders compiled work summary fields', () => {
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onResumeDraft={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);

View File

@@ -15,7 +15,7 @@ type CustomWorldCreationHubProps = {
onBack: () => void;
onRetry: () => void;
onCreateNew: () => void;
onResumeDraft: (sessionId: string) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
};
@@ -36,7 +36,7 @@ export function CustomWorldCreationHub({
onBack,
onRetry,
onCreateNew,
onResumeDraft,
onOpenDraft,
onEnterPublished,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -129,7 +129,7 @@ export function CustomWorldCreationHub({
item={item}
onClick={() => {
if (item.sourceType === 'agent_session' && item.sessionId) {
onResumeDraft(item.sessionId);
onOpenDraft(item);
return;
}

View File

@@ -27,6 +27,11 @@ export function CustomWorldWorkCard({
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const actionLabel = isDraft
? hasFoundationDraft
? '继续完善'
: '继续创作'
: '进入世界';
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
return (
@@ -104,7 +109,7 @@ export function CustomWorldWorkCard({
onClick={onClick}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
>
{isDraft ? (hasFoundationDraft ? '继续精修' : '继续创作') : '进入世界'}
{actionLabel}
</button>
</div>
</div>

View File

@@ -30,13 +30,13 @@ import {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
ProfileDashboardCardKey,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import type { AuthUser } from '../../services/authService';
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformBrandLogo } from './PlatformBrandLogo';

View File

@@ -223,6 +223,97 @@ const mockAuthUser: AuthUser = {
wechatBound: false,
};
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
};
type TestAuthValue = {
user: AuthUser | null;
openLoginModal: (postLoginAction?: (() => void) | null) => void;
@@ -416,7 +507,7 @@ test('create tab opens game type modal, keeps AIRP and visual novel locked, and
).toBeTruthy();
});
test('create tab uses unified creation hub and can resume an agent draft', async () => {
test('create tab opens compiled agent draft in result refinement page', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
@@ -425,7 +516,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '精修对象',
subtitle: '待完善草稿',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
@@ -433,7 +524,7 @@ test('create tab uses unified creation hub and can resume an agent draft', async
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'object_refining',
stageLabel: '精修对象',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
roleVisualReadyCount: 1,
@@ -445,21 +536,70 @@ test('create tab uses unified creation hub and can resume an agent draft', async
canEnterWorld: false,
},
]);
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.queryByText('Agent工作区custom-world-agent-session-1')).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
},
{ timeout: 2500 },
);
});
test('create tab resumes agent workspace when draft has no compiled result yet', async () => {
const user = userEvent.setup();
vi.mocked(listCustomWorldWorks).mockResolvedValue([
{
workId: 'draft:custom-world-agent-session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: '2026-04-20T10:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: 'custom-world-agent-session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]);
render(<TestWrapper withAuth />);
await openCreationHub(user);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.queryByText('世界档案')).toBeNull();
});
test('clicking a public work while logged out routes through requireAuth', async () => {
@@ -581,7 +721,7 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
});
test('existing draft sessions enter the agent preview layout without opening legacy editor', async () => {
test('existing draft sessions open result page refinement instead of agent dialog', async () => {
const user = userEvent.setup();
vi.mocked(getCustomWorldAgentOperation).mockResolvedValue({
@@ -593,96 +733,9 @@ test('existing draft sessions enter the agent preview layout without opening leg
progress: 100,
error: null,
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue({
...mockSession,
stage: 'object_refining',
creatorIntent: {
sourceMode: 'card',
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
themeKeywords: ['海雾', '旧航路'],
toneDirectives: ['压抑', '悬疑'],
openingSituation: '首夜就有陌生船只闯入禁航区。',
coreConflicts: ['航运公会与守灯会争夺航路控制权'],
keyFactions: [],
keyCharacters: [],
keyLandmarks: [],
iconicElements: ['会移动的海雾'],
forbiddenDirectives: [],
rawSettingText: '',
},
draftProfile: {
name: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
tone: '压抑、潮湿、悬疑',
playerGoal: '查清沉船与禁航区异动的真相。',
majorFactions: ['守灯会', '航运公会'],
coreConflicts: ['守灯会与航运公会争夺旧航路控制权'],
playableNpcs: [
{
id: 'playable-1',
name: '沈砺',
title: '旧航路引路人',
role: '关键同行者',
publicIdentity: '最熟悉旧航路的人。',
publicMask: '看上去像可靠旧友。',
currentPressure: '他必须在两股势力间站队。',
hiddenHook: '暗中替沉船商盟引路。',
relationToPlayer: '旧友兼潜在背叛者',
threadIds: ['thread-1'],
summary: '他像旧友,但也像一把始终没收回鞘的刀。',
},
],
storyNpcs: [
{
id: 'story-1',
name: '顾潮音',
title: '守灯会值夜人',
role: '场景关键角色',
publicIdentity: '负责夜间巡灯与封锁。',
publicMask: '对外一直冷静克制。',
currentPressure: '她知道更多禁航区真相。',
hiddenHook: '曾亲眼见过失控海雾吞船。',
relationToPlayer: '最早愿意交换线索的人',
threadIds: ['thread-1'],
summary: '她总像比所有人更早知道海雾会往哪一侧压下来。',
},
],
landmarks: [
{
id: 'landmark-1',
name: '回潮旧灯塔',
purpose: '观察雾潮与往来船只',
mood: '潮湿、压抑、风声不止',
importance: '开局核心场景',
characterIds: ['story-1'],
threadIds: ['thread-1'],
summary: '旧灯塔是整片群岛最先看见异动的地方。',
},
],
factions: [],
threads: [],
chapters: [],
worldHook: '被海雾吞没的旧航路群岛',
playerPremise: '玩家回到群岛调查沉船真相。',
openingSituation: '首夜就有陌生船只闯入禁航区。',
iconicElements: ['会移动的海雾'],
sourceAnchorSummary: '海雾、旧灯塔、失控航路。',
},
draftCards: [
{
id: 'world-foundation',
kind: 'world',
title: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summary: '第一版世界底稿已经整理完成。',
status: 'warning',
linkedIds: ['playable-1', 'story-1', 'landmark-1'],
warningCount: 0,
},
],
});
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(
compiledAgentDraftSession,
);
render(<TestWrapper withAuth />);
@@ -704,12 +757,12 @@ test('existing draft sessions enter the agent preview layout without opening leg
await user.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByText(//u)).toBeNull();
expect(screen.queryByRole('button', { name: /AI/u })).toBeNull();
expect(screen.queryByText('技能')).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText(//u)).toBeTruthy();
expect(screen.getByRole('button', { name: /AI/u })).toBeTruthy();
});
test('agent draft result back button returns to workspace without redundant sync when session is already latest', async () => {
test('agent draft result back button returns to creation hub without redundant sync when session is already latest', async () => {
const user = userEvent.setup();
vi.mocked(executeCustomWorldAgentAction).mockResolvedValue({
@@ -843,7 +896,7 @@ test('agent draft result back button returns to workspace without redundant sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(resultSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);
@@ -858,9 +911,7 @@ test('agent draft result back button returns to workspace without redundant sync
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(
screen.getByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
expect(screen.getByText('创作中心')).toBeTruthy();
});
expect(
@@ -968,7 +1019,7 @@ test('agent draft result auto-save persists the latest profile rebuilt from sync
} satisfies CustomWorldAgentSessionSnapshot;
vi.mocked(getCustomWorldAgentSession).mockResolvedValue(syncedSession);
render(<TestWrapper />);
render(<TestWrapper withAuth />);
await openNewRpgCreation(user);

View File

@@ -20,6 +20,8 @@ import type {
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
PlatformBrowseHistoryEntry,
PlatformBrowseHistoryWriteEntry,
ProfileDashboardSummary,
ProfileSaveArchiveSummary,
} from '../../../packages/shared/src/contracts/runtime';
@@ -46,14 +48,6 @@ import {
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import {
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
type PlatformBrowseHistoryEntry,
type PlatformBrowseHistoryWriteEntry,
readPlatformBrowseHistory,
writePlatformBrowseHistory,
} from '../../services/platformBrowseHistory';
import {
deleteCustomWorldProfile,
getCustomWorldGalleryDetail,
@@ -64,7 +58,6 @@ import {
listProfileSaveArchives,
publishCustomWorldProfile,
resumeProfileSaveArchive,
syncProfileBrowseHistory,
unpublishCustomWorldProfile,
upsertCustomWorldProfile,
upsertProfileBrowseHistory,
@@ -381,18 +374,11 @@ export function PreGameSelectionFlow({
const appendBrowseHistoryEntry = useCallback(
async (entry: PlatformBrowseHistoryWriteEntry) => {
const nextEntries = writePlatformBrowseHistory(authUi?.user, entry);
setHistoryEntries(nextEntries);
setHistoryError(null);
if (!authUi?.user) {
return;
}
try {
const syncedEntries = await upsertProfileBrowseHistory(entry);
setHistoryEntries(syncedEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
} catch (error) {
setHistoryError(resolveErrorMessage(error, '写入浏览历史失败。'));
}
@@ -444,8 +430,7 @@ export function PreGameSelectionFlow({
let isActive = true;
void (async () => {
const localHistoryEntries = readPlatformBrowseHistory(authUi?.user);
setHistoryEntries(localHistoryEntries);
setHistoryEntries([]);
setHistoryError(null);
setSaveError(null);
setIsLoadingPlatform(true);
@@ -472,22 +457,7 @@ export function PreGameSelectionFlow({
isAuthenticated ? listCustomWorldWorks() : Promise.resolve([]),
listCustomWorldGallery(),
isAuthenticated ? getProfileDashboard() : Promise.resolve(null),
isAuthenticated
? (async () => {
let nextEntries = await listProfileBrowseHistory();
if (
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries =
await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
return nextEntries;
})()
: Promise.resolve(localHistoryEntries),
isAuthenticated ? listProfileBrowseHistory() : Promise.resolve([]),
isAuthenticated ? listProfileSaveArchives() : Promise.resolve([]),
]);
if (!isActive) {
@@ -881,8 +851,6 @@ export function PreGameSelectionFlow({
const isAgentDraftGenerationView =
customWorldGenerationViewSource === 'agent-draft-foundation';
const isAgentDraftResultView = customWorldResultViewSource === 'agent-draft';
const isAgentDraftResultEditingFrozen =
customWorldResultViewSource === 'agent-draft';
const activeGenerationSettingText = agentDraftSettingPreview;
const activeGenerationProgress = agentDraftGenerationProgress;
const isActiveGenerationRunning =
@@ -1096,7 +1064,8 @@ export function PreGameSelectionFlow({
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setSelectionStage('agent-workspace');
setPlatformTab('create');
setSelectionStage('platform');
};
const retryAgentDraftGeneration = () => {
@@ -1133,17 +1102,39 @@ export function PreGameSelectionFlow({
const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) {
// 阶段二要求草稿优先回到 Agent 工作区,而不是再次自动顶回结果页。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
persistAgentUiState(work.sessionId, null);
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
isAgentDraftResultAutoOpenSuppressedRef.current = true;
setGeneratedCustomWorldProfile(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
isAgentDraftResultAutoOpenSuppressedRef.current = false;
const latestSession = await syncAgentSessionSnapshot(work.sessionId);
const nextProfile = buildCustomWorldProfileFromAgentDraft(latestSession);
if (!nextProfile) {
setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。');
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
setGeneratedCustomWorldProfile(normalizeAgentBackedProfile(nextProfile));
setCustomWorldResultViewSource('agent-draft');
setPlatformTab('create');
setSelectionStage('agent-workspace');
setSelectionStage('custom-world-result');
return;
}
@@ -1179,6 +1170,7 @@ export function PreGameSelectionFlow({
openLibraryDetail,
persistAgentUiState,
savedCustomWorldEntries,
syncAgentSessionSnapshot,
setSelectionStage,
],
);
@@ -1583,32 +1575,9 @@ export function PreGameSelectionFlow({
});
}}
onCreateNew={openCreationTypePicker}
onResumeDraft={(sessionId) => {
onOpenDraft={(item) => {
runProtectedAction(() => {
void handleOpenCreationWork({
workId: `draft:${sessionId}`,
sourceType: 'agent_session',
status: 'draft',
title: '',
subtitle: '',
summary: '',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
updatedAt: new Date().toISOString(),
publishedAt: null,
stage: null,
stageLabel: '',
playableNpcCount: 0,
landmarkCount: 0,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId,
profileId: null,
canResume: true,
canEnterWorld: false,
});
void handleOpenCreationWork(item);
});
}}
onEnterPublished={(profileId) => {
@@ -1918,10 +1887,10 @@ export function PreGameSelectionFlow({
});
});
}}
readOnly={isAgentDraftResultEditingFrozen}
readOnly={false}
compactAgentResultMode={isAgentDraftResultView}
backLabel={isAgentDraftResultView ? '返回创作' : undefined}
editActionLabel="去Agent调整设定"
editActionLabel="继续调整设定"
enterWorldActionLabel="进入世界"
autoSaveState={customWorldAutoSaveState}
/>