fix: preserve rpg custom world detail profiles

This commit is contained in:
kdletters
2026-05-22 03:14:11 +08:00
parent a9d23a8a44
commit d74457faa2
19 changed files with 2726 additions and 109 deletions

View File

@@ -35,6 +35,7 @@ import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import {
readPublicWorkCodeFromLocationSearch,
resolveSelectionStageFromPath,
@@ -1921,6 +1922,14 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
},
};
const compiledAgentResultPreview = normalizeCustomWorldProfileRecord(
compiledAgentDraftSession.resultPreview?.preview,
);
if (!compiledAgentResultPreview) {
throw new Error('failed to normalize compiled agent result preview');
}
function buildResultViewForSession(
session: CustomWorldAgentSessionSnapshot,
): RpgCreationResultView {
@@ -8011,6 +8020,288 @@ test('agent draft result test button enters current draft without publish gate',
).toBe(false);
}, 10_000);
test('agent draft result test button enters the opened draft profile instead of a previous session', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const previousDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-1',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
summary: '上一份草稿内容,不能被本次启动复用。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-previous-1',
name: '沈砺',
},
],
sessionId: 'custom-world-agent-session-1',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const openedDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-2',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '本次从草稿架打开的目标草稿内容。',
playerGoal: '找到废都钟楼下被星砂掩埋的旧约。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-opened-1',
name: '砂眠',
title: '废都引路人',
},
],
storyNpcs: [],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-opened-1',
name: '坠星钟楼',
},
],
sessionId: 'custom-world-agent-session-2',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const sessionsById = new Map([
[previousDraftSession.sessionId, previousDraftSession],
[openedDraftSession.sessionId, openedDraftSession],
]);
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test session: ${sessionId}`);
}
return session;
});
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test result view: ${sessionId}`);
}
return buildResultViewForSession(session);
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-1',
title: '潮雾列岛',
summary: '上一份草稿内容,不能被本次启动复用。',
sessionId: 'custom-world-agent-session-1',
playableNpcCount: 1,
landmarkCount: 1,
}),
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-2',
title: '星砂废都',
subtitle: '待完善草稿',
summary: '本次从草稿架打开的目标草稿内容。',
sessionId: 'custom-world-agent-session-2',
playableNpcCount: 1,
landmarkCount: 1,
}),
]);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续完善《星砂废都》/u,
}),
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('星砂废都')).toBeTruthy();
await user.click(
await screen.findByRole('button', { name: '作品测试' }, { timeout: 5000 }),
);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
summary: '本次从草稿架打开的目标草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-opened-1',
name: '砂眠',
}),
],
}),
expect.objectContaining({
mode: 'play',
disablePersistence: true,
returnStage: 'custom-world-result',
}),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(([, payload]) => payload?.action === 'publish_world'),
).toBe(false);
}, 10_000);
test('agent draft result start button enters the opened published draft profile instead of a previous session', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const previousDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-1',
stage: 'published',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-1',
name: '潮雾列岛',
summary: '上一份已发布草稿内容,不能被本次启动复用。',
sessionId: 'custom-world-agent-session-1',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const openedPublishedDraftSession = {
...compiledAgentDraftSession,
sessionId: 'custom-world-agent-session-2',
stage: 'published',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: true,
canEnterWorld: true,
preview: {
...compiledAgentResultPreview,
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-opened-1',
name: '砂眠',
title: '废都引路人',
},
],
sessionId: 'custom-world-agent-session-2',
},
},
} satisfies CustomWorldAgentSessionSnapshot;
const sessionsById = new Map([
[previousDraftSession.sessionId, previousDraftSession],
[openedPublishedDraftSession.sessionId, openedPublishedDraftSession],
]);
vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test session: ${sessionId}`);
}
return session;
});
vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => {
const session = sessionsById.get(sessionId);
if (!session) {
throw new Error(`Missing test result view: ${sessionId}`);
}
return buildResultViewForSession(session);
});
vi.mocked(listRpgCreationWorks).mockResolvedValue([
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-1',
title: '潮雾列岛',
summary: '上一份已发布草稿内容,不能被本次启动复用。',
stage: 'published',
stageLabel: '已发布',
sessionId: 'custom-world-agent-session-1',
playableNpcCount: 1,
landmarkCount: 1,
canEnterWorld: true,
}),
buildExistingRpgDraftWork({
workId: 'draft:custom-world-agent-session-2',
title: '星砂废都',
subtitle: '已发布草稿',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
stage: 'published',
stageLabel: '已发布',
sessionId: 'custom-world-agent-session-2',
playableNpcCount: 1,
landmarkCount: 1,
canEnterWorld: true,
}),
]);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续完善《星砂废都》/u,
}),
);
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(screen.getByText('星砂废都')).toBeTruthy();
await user.click(
await screen.findByRole('button', { name: '进入世界' }, { timeout: 5000 }),
);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: 'agent-draft-custom-world-agent-session-2',
name: '星砂废都',
summary: '本次从草稿架打开且已发布的目标草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-opened-1',
name: '砂眠',
}),
],
}),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(([, payload]) => payload?.action === 'publish_world'),
).toBe(false);
}, 10_000);
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
const user = userEvent.setup();
@@ -8057,7 +8348,7 @@ test('agent result view does not keep legacy publish blockers when preview uses
publishReady: true,
blockers: [],
preview: {
...compiledAgentDraftSession.resultPreview!.preview,
...compiledAgentResultPreview,
settingText: '被海雾吞没的旧航路群岛',
anchorContent: {
worldPromise:
@@ -8695,6 +8986,65 @@ test('save tab can resume a selected archive directly into the game', async () =
});
});
test('profile page exposes save archive picker as a direct entry', async () => {
const user = userEvent.setup();
const handleContinueGame = vi.fn();
vi.mocked(listProfileSaveArchives).mockResolvedValue([
{
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
]);
vi.mocked(resumeProfileSaveArchive).mockResolvedValue({
entry: {
worldKey: 'custom:world-1',
ownerUserId: null,
profileId: 'world-1',
worldType: 'CUSTOM',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '回到旧灯塔继续推进调查。',
coverImageSrc: null,
lastPlayedAt: '2026-04-19T12:00:00.000Z',
},
snapshot: {
version: 2,
savedAt: '2026-04-19T12:00:00.000Z',
bottomTab: 'adventure',
currentStory: null,
gameState: {
worldType: 'CUSTOM',
},
} as HydratedSavedGameSnapshot,
});
render(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
await clickFirstButtonByName(user, '我的');
const shortcutRegion = await screen.findByRole('region', { name: '常用功能' });
await user.click(within(shortcutRegion).getByRole('button', { name: /存档/u }));
const closeButton = await screen.findByLabelText('关闭存档');
const modal = closeButton.closest('.fixed') as HTMLElement;
expect(modal).toBeTruthy();
expect(within(modal).getByText('SAVES')).toBeTruthy();
await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u }));
await waitFor(() => {
expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1');
expect(handleContinueGame).toHaveBeenCalledTimes(1);
});
});
test('creation hub published work can open detail view before deleting from detail page', async () => {
const user = userEvent.setup();
@@ -8928,6 +9278,342 @@ test('creation hub published work experience button enters world directly', asyn
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work start uses loaded detail profile instead of library summary', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
const workProfileId = 'world-detail-launch-1';
const summaryEntry = buildMockRpgGalleryDetail({
ownerUserId: mockAuthUser.id,
profileId: workProfileId,
publicWorkCode: 'work-detail-launch-1',
authorPublicUserCode: mockAuthUser.publicUserCode,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: mockAuthUser.displayName,
worldName: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summaryText: '列表摘要只提供卡片信息,不能作为运行态 profile。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
});
summaryEntry.profile = {
...summaryEntry.profile,
name: '默认档案',
summary: '列表摘要不含运行态角色。',
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
const detailEntry = buildMockRpgGalleryDetail({
...summaryEntry,
summaryText: '详情接口返回完整草稿内容。',
});
detailEntry.profile = {
...detailEntry.profile,
name: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summary: '详情接口返回完整草稿内容。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-stardust-1',
name: '砂眠',
title: '废都引路人',
},
],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-stardust-1',
name: '坠星钟楼',
},
],
};
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: `published:${workProfileId}`,
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: 1,
landmarkCount: 1,
roleVisualReadyCount: 1,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: workProfileId,
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
vi.mocked(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).mockResolvedValue(detailEntry);
render(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
await waitFor(() => {
expect(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).toHaveBeenCalledWith(workProfileId);
});
await user.click(await screen.findByRole('button', { name: '启动' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
id: workProfileId,
name: '星砂废都',
summary: '详情接口返回完整草稿内容。',
playableNpcs: [
expect.objectContaining({
id: 'playable-stardust-1',
name: '砂眠',
}),
],
landmarks: [
expect.objectContaining({
id: 'landmark-stardust-1',
name: '坠星钟楼',
}),
],
}),
);
});
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
});
test('creation hub published work edit keeps loaded detail profile assets instead of library summary', async () => {
const user = userEvent.setup();
const workProfileId = 'world-detail-edit-assets-1';
const summaryEntry = buildMockRpgGalleryDetail({
ownerUserId: mockAuthUser.id,
profileId: workProfileId,
publicWorkCode: 'work-detail-edit-assets-1',
authorPublicUserCode: mockAuthUser.publicUserCode,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: mockAuthUser.displayName,
worldName: '星砂废都',
subtitle: '坠星沙海与废都钟楼',
summaryText: '列表摘要字段齐全但不含详情资产。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
});
summaryEntry.profile = {
...summaryEntry.profile,
name: '星砂废都',
summary: '列表摘要字段齐全但不含详情资产。',
playableNpcs: [
{
...compiledAgentResultPreview.playableNpcs[0]!,
id: 'playable-stardust-1',
name: '砂眠',
imageSrc: undefined,
},
],
storyNpcs: [
{
...compiledAgentResultPreview.storyNpcs[0]!,
id: 'story-clock-keeper-1',
name: '钟守',
imageSrc: undefined,
},
],
landmarks: [
{
...compiledAgentResultPreview.landmarks[0]!,
id: 'landmark-stardust-1',
name: '坠星钟楼',
imageSrc: undefined,
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-stardust-1',
sceneId: 'landmark-stardust-1',
title: '坠星钟楼',
summary: '星砂覆盖钟楼入口,钟守等待第一位访客。',
sceneTaskDescription: '调查钟楼旧铃自鸣的原因。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-stardust-1'],
acts: [
{
id: 'act-stardust-opening-1',
sceneId: 'landmark-stardust-1',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening'],
backgroundImageSrc: undefined,
encounterNpcIds: ['playable-stardust-1'],
primaryNpcId: 'playable-stardust-1',
oppositeNpcId: 'story-clock-keeper-1',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
cover: null,
openingCg: null,
};
const detailEntry = buildMockRpgGalleryDetail({
...summaryEntry,
summaryText: '详情接口返回完整草稿内容。',
});
detailEntry.profile = {
...summaryEntry.profile,
summary: '详情接口返回完整草稿内容。',
cover: {
sourceType: 'generated',
imageSrc: '/assets/custom-world/star-waste-cover.png',
characterRoleIds: ['playable-stardust-1'],
},
openingCg: {
id: 'opening-cg-stardust-1',
status: 'ready',
storyboardImageSrc: '/assets/custom-world/opening-storyboard.png',
videoSrc: '/assets/custom-world/opening.mp4',
imageModel: 'gpt-image-2',
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9',
imageSize: '2k',
videoResolution: '480p',
durationSeconds: 15,
pointCost: 80,
estimatedWaitMinutes: 10,
updatedAt: '2026-05-21T00:00:00.000Z',
},
camp: {
id: 'camp-stardust-1',
name: '废都营地',
description: '钟楼阴影下的临时营地。',
imageSrc: '/assets/custom-world/star-waste-camp.png',
sceneNpcIds: ['playable-stardust-1'],
connections: [],
},
playableNpcs: [
{
...summaryEntry.profile.playableNpcs[0]!,
imageSrc: '/assets/custom-world/playable-stardust-1.png',
},
],
storyNpcs: [
{
...summaryEntry.profile.storyNpcs[0]!,
imageSrc: '/assets/custom-world/story-clock-keeper-1.png',
},
],
landmarks: [
{
...summaryEntry.profile.landmarks[0]!,
imageSrc: '/assets/custom-world/landmark-stardust-1.png',
},
],
sceneChapterBlueprints: [
{
...summaryEntry.profile.sceneChapterBlueprints![0]!,
acts: [
{
...summaryEntry.profile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc:
'/assets/custom-world/act-stardust-opening-1.png',
},
],
},
],
};
vi.mocked(listRpgCreationWorks).mockResolvedValue([
{
workId: `published:${workProfileId}`,
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: 1,
landmarkCount: 1,
roleVisualReadyCount: 0,
roleAnimationReadyCount: 0,
roleAssetSummaryLabel: null,
sessionId: null,
profileId: workProfileId,
canResume: false,
canEnterWorld: true,
},
]);
vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]);
vi.mocked(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).mockResolvedValue(detailEntry);
render(<TestWrapper withAuth />);
await openDraftHub(user);
await user.click(await screen.findByRole('button', { name: /查看详情/u }));
await waitFor(() => {
expect(
rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail,
).toHaveBeenCalledWith(workProfileId);
});
await user.click(await screen.findByRole('button', { name: '作品编辑' }));
expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy();
expect(
document.querySelector('video[src="/assets/custom-world/opening.mp4"]'),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: /场景\s+2/u }));
expect((await screen.findByAltText('废都营地')).getAttribute('src')).toBe(
'/assets/custom-world/star-waste-camp.png',
);
expect(screen.getByAltText('坠星钟楼-第一幕').getAttribute('src')).toBe(
'/assets/custom-world/act-stardust-opening-1.png',
);
await user.click(screen.getByRole('button', { name: /可扮演角色\s+1/u }));
expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe(
'/assets/custom-world/playable-stardust-1.png',
);
await user.click(screen.getByRole('button', { name: /场景角色\s+1/u }));
expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe(
'/assets/custom-world/story-clock-keeper-1.png',
);
});
test('creation hub published work card reveals delete action after card action reveal', async () => {
const user = userEvent.setup();

View File

@@ -1,5 +1,6 @@
import {
AlertCircle,
Archive,
ArrowRight,
BookOpen,
Camera,
@@ -233,7 +234,8 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type RechargeTab = 'points' | 'membership';
type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel';
type WechatPayResult = {
@@ -3306,7 +3308,7 @@ function ProfileReferralModal({
onRedeemCodeChange,
onSubmitRedeemCode,
}: {
panel: ProfilePopupPanel;
panel: ProfileReferralPanel;
center: ProfileReferralInviteCenterResponse | null;
isLoading: boolean;
isSubmittingRedeem: boolean;
@@ -3477,6 +3479,66 @@ function ProfileReferralModal({
);
}
function ProfileSaveArchivesModal({
saveEntries,
saveError,
isResumingSaveWorldKey,
onClose,
onResumeSave,
}: {
saveEntries: ProfileSaveArchiveSummary[];
saveError: string | null;
isResumingSaveWorldKey: string | null;
onClose: () => void;
onResumeSave: (entry: ProfileSaveArchiveSummary) => void;
}) {
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭存档"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
SAVES
</div>
<div className="mt-1 text-2xl font-black"></div>
</div>
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : null}
{saveEntries.length > 0 ? (
<div className="mt-5 grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:profile-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
) : (
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-500">
</div>
)}
</div>
</div>
</div>
);
}
function ProfilePlayedWorksModal({
stats,
isLoading,
@@ -4504,7 +4566,7 @@ export function RpgEntryHomeView({
loadReferralCenter();
}, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]);
const openProfilePopupPanel = (panel: ProfilePopupPanel) => {
const openProfilePopupPanel = (panel: ProfileReferralPanel) => {
setProfilePopupPanel(panel);
setReferralError(null);
setReferralSuccess(null);
@@ -5842,6 +5904,16 @@ export function RpgEntryHomeView({
icon={showRechargeEntry ? Coins : Ticket}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
@@ -6430,7 +6502,15 @@ export function RpgEntryHomeView({
))}
</div>
</div>
{profilePopupPanel ? (
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
@@ -6595,7 +6675,15 @@ export function RpgEntryHomeView({
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel ? (
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}

View File

@@ -0,0 +1,157 @@
import type { CustomWorldProfile } from '../../types';
export function countCustomWorldProfileDetailSlots(
profile: Partial<CustomWorldProfile> | null | undefined,
) {
if (!profile) {
return 0;
}
return (
(profile.playableNpcs?.length ?? 0) +
(profile.storyNpcs?.length ?? 0) +
(profile.items?.length ?? 0) +
(profile.landmarks?.length ?? 0) +
(profile.sceneChapterBlueprints?.length ?? 0)
);
}
export function countCustomWorldProfileAssetSlots(
profile: Partial<CustomWorldProfile> | null | undefined,
) {
if (!profile) {
return 0;
}
return [
profile.cover?.imageSrc,
profile.openingCg?.storyboardImageSrc,
profile.openingCg?.videoSrc,
profile.openingCg?.posterImageSrc,
profile.camp?.imageSrc,
...(profile.playableNpcs ?? []).flatMap((role) => [
role.imageSrc,
role.generatedVisualAssetId,
role.generatedAnimationSetId,
...(role.skills ?? []).flatMap((skill) => [
skill.actionPreviewConfig?.basePath,
skill.actionPreviewConfig?.previewVideoPath,
skill.actionPreviewConfig?.file,
]),
...role.initialItems.flatMap((item) => [item.iconSrc]),
...Object.values(role.animationMap ?? {}).flatMap((config) => [
config?.basePath,
config?.previewVideoPath,
config?.file,
]),
]),
...(profile.storyNpcs ?? []).flatMap((npc) => [
npc.imageSrc,
npc.generatedVisualAssetId,
npc.generatedAnimationSetId,
...(npc.skills ?? []).flatMap((skill) => [
skill.actionPreviewConfig?.basePath,
skill.actionPreviewConfig?.previewVideoPath,
skill.actionPreviewConfig?.file,
]),
...npc.initialItems.flatMap((item) => [item.iconSrc]),
...Object.values(npc.animationMap ?? {}).flatMap((config) => [
config?.basePath,
config?.previewVideoPath,
config?.file,
]),
]),
...(profile.items ?? []).flatMap((item) => [item.iconSrc, item.sourcePath]),
...(profile.landmarks ?? []).map((landmark) => landmark.imageSrc),
...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) =>
chapter.acts.flatMap((act) => [
act.backgroundImageSrc,
act.backgroundAssetId,
]),
),
].filter((value) => value?.trim()).length;
}
export function countCustomWorldProfileStructuredSlots(
profile: Partial<CustomWorldProfile> | null | undefined,
) {
if (!profile) {
return 0;
}
return [
profile.cover,
profile.attributeSchema,
profile.themePack,
profile.storyGraph,
profile.knowledgeFacts?.length ? profile.knowledgeFacts : null,
profile.threadContracts?.length ? profile.threadContracts : null,
profile.anchorContent,
profile.creatorIntent,
profile.anchorPack,
profile.lockState,
profile.ownedSettingLayers,
profile.generationMode,
profile.generationStatus,
profile.scenarioPackId,
profile.campaignPackId,
...(profile.playableNpcs ?? []).flatMap((role) => [
role.attributeProfile,
...(role.skills ?? []).map((skill) => skill.actionPreviewConfig),
...role.initialItems,
]),
...(profile.storyNpcs ?? []).flatMap((npc) => [
npc.attributeProfile,
...(npc.skills ?? []).map((skill) => skill.actionPreviewConfig),
...npc.initialItems,
]),
...(profile.landmarks ?? []).flatMap((landmark) => [
landmark.visualDescription,
landmark.narrativeResidues?.length ? landmark.narrativeResidues : null,
]),
...((profile.camp?.narrativeResidues ?? []).length
? [profile.camp?.narrativeResidues]
: []),
...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => [
chapter.sceneTaskDescription,
chapter.linkedThreadIds.length ? chapter.linkedThreadIds : null,
chapter.linkedLandmarkIds.length ? chapter.linkedLandmarkIds : null,
...chapter.acts.flatMap((act) => [
act.eventDescription,
act.linkedThreadIds.length ? act.linkedThreadIds : null,
act.actGoal,
act.transitionHook,
]),
]),
].filter(Boolean).length;
}
export function getCustomWorldProfileCompletenessScore(
profile: Partial<CustomWorldProfile> | null | undefined,
) {
return (
countCustomWorldProfileDetailSlots(profile) +
countCustomWorldProfileAssetSlots(profile) +
countCustomWorldProfileStructuredSlots(profile)
);
}
export function chooseMoreCompleteCustomWorldProfile(
fallbackProfile: CustomWorldProfile,
candidateProfile: CustomWorldProfile | null | undefined,
) {
if (!candidateProfile) {
return fallbackProfile;
}
if (candidateProfile.id !== fallbackProfile.id) {
return candidateProfile;
}
// 中文注释:发布 / 回读可能只返回列表摘要或旧快照。
// 同一个 profileId 下进入世界不能把当前结果页的封面、CG、角色资产降级掉。
return getCustomWorldProfileCompletenessScore(candidateProfile) >=
getCustomWorldProfileCompletenessScore(fallbackProfile)
? candidateProfile
: fallbackProfile;
}

View File

@@ -340,4 +340,136 @@ describe('useRpgCreationEnterWorld', () => {
'published-profile',
);
});
it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => {
const resultProfile = {
...buildProfile({
id: 'draft-profile-rich-assets',
name: '星砂废都',
imageSrc: '/generated-characters/draft-role/portrait.png',
}),
cover: {
sourceType: 'generated' as const,
imageSrc: '/generated-custom-world-covers/star-waste/cover.webp',
characterRoleIds: ['draft-profile-rich-assets-role'],
},
openingCg: {
id: 'opening-cg-stardust',
status: 'ready' as const,
storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png',
videoSrc: '/generated-custom-world-scenes/opening/opening.mp4',
imageModel: 'gpt-image-2' as const,
videoModel: 'doubao-seedance-2-0-fast-260128',
aspectRatio: '16:9' as const,
imageSize: '2k' as const,
videoResolution: '480p' as const,
durationSeconds: 15 as const,
pointCost: 80 as const,
estimatedWaitMinutes: 10 as const,
updatedAt: '2026-05-21T00:00:00.000Z',
},
sceneChapterBlueprints: [
{
id: 'scene-chapter-stardust',
sceneId: 'landmark-stardust',
title: '钟楼第一夜',
summary: '钟楼第一夜。',
sceneTaskDescription: '进入钟楼。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-stardust'],
acts: [
{
id: 'act-stardust-opening',
sceneId: 'landmark-stardust',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening' as const],
backgroundImageSrc:
'/assets/custom-world/act-stardust-opening.png',
backgroundAssetId: 'asset-act-stardust-opening',
encounterNpcIds: ['draft-profile-rich-assets-role'],
primaryNpcId: 'draft-profile-rich-assets-role',
oppositeNpcId: 'draft-profile-rich-assets-role',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: [],
advanceRule: 'after_primary_contact' as const,
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
} satisfies CustomWorldProfile;
const stalePublishedProfile = {
...resultProfile,
name: '星砂废都',
cover: null,
openingCg: null,
playableNpcs: [],
sceneChapterBlueprints: null,
} satisfies CustomWorldProfile;
const handleCustomWorldSelect = vi.fn();
const setGeneratedCustomWorldProfile = vi.fn();
const syncAgentDraftResultProfile = vi.fn(async () => ({
profile: resultProfile,
view: buildResultView({
stage: 'ready_to_publish',
profile: resultProfile,
canEnterWorld: false,
}),
}));
const executePublishWorld = vi.fn(async () => buildSession('published'));
const syncAgentCreationResultView = vi.fn(async () =>
buildResultView({
stage: 'published',
profile: stalePublishedProfile,
canEnterWorld: true,
}),
);
function Harness() {
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
isAgentDraftResultView: true,
activeAgentSessionId: 'session-1',
currentAgentSessionStage: 'ready_to_publish',
generatedCustomWorldProfile: resultProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile,
executePublishWorld,
syncAgentCreationResultView,
setGeneratedCustomWorldProfile,
});
return (
<button
type="button"
onClick={() => void enterWorldFromCurrentResult()}
>
</button>
);
}
const { getByText } = render(<Harness />);
await act(async () => {
getByText('进入世界').click();
});
const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0];
expect(launchedProfile?.id).toBe('draft-profile-rich-assets');
expect(launchedProfile?.cover?.imageSrc).toBe(
'/generated-custom-world-covers/star-waste/cover.webp',
);
expect(launchedProfile?.openingCg?.videoSrc).toBe(
'/generated-custom-world-scenes/opening/opening.mp4',
);
expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/draft-role/portrait.png',
);
expect(
launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0]
?.backgroundImageSrc,
).toBe('/assets/custom-world/act-stardust-opening.png');
});
});

View File

@@ -5,6 +5,7 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s
import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { chooseMoreCompleteCustomWorldProfile } from './rpgProfileCompleteness';
type UseRpgCreationEnterWorldParams = {
isAgentDraftResultView: boolean;
@@ -82,9 +83,10 @@ export function useRpgCreationEnterWorld(
if (currentAgentSessionStage === 'published') {
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
generatedCustomWorldProfile;
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
generatedCustomWorldProfile,
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
);
// 中文注释:已发布会话的“进入世界”只读取后端结果页真相,
// 不能再同步草稿或重复发送 publish_world否则会被发布阶段门槛拒绝。
setGeneratedCustomWorldProfile(publishedProfile);
@@ -110,17 +112,18 @@ export function useRpgCreationEnterWorld(
if (canEnterPublishedWorld) {
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
return (
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile
return chooseMoreCompleteCustomWorldProfile(
latestProfile,
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
);
}
await executePublishWorld();
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
const publishedProfile =
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
latestProfile;
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
latestProfile,
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
);
setGeneratedCustomWorldProfile(publishedProfile);
return publishedProfile;

View File

@@ -1,17 +1,19 @@
/** @vitest-environment jsdom */
import { act, render } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import {
executeRpgCreationAction,
getRpgCreationOperation,
upsertRpgWorldProfile,
} from '../../services/rpg-creation';
import { type CustomWorldProfile,WorldType } from '../../types';
import { type CustomWorldProfile, WorldType } from '../../types';
import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave';
import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail';
@@ -64,6 +66,30 @@ function buildProfile(name: string): CustomWorldProfile {
};
}
function buildLibraryEntry(
profile: CustomWorldProfile,
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
ownerUserId: 'user-1',
profileId: profile.id,
publicWorkCode: null,
authorPublicUserCode: null,
profile,
visibility: 'published' as const,
publishedAt: '2026-04-25T00:00:00.000Z',
updatedAt: '2026-04-25T00:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: profile.name,
subtitle: profile.subtitle,
summaryText: profile.summary,
coverImageSrc: null,
themeMode: 'tide' as const,
playableNpcCount: profile.playableNpcs.length,
landmarkCount: profile.landmarks.length,
likeCount: 0,
};
}
function buildSession(
overrides: Partial<CustomWorldAgentSessionSnapshot> = {},
): CustomWorldAgentSessionSnapshot {
@@ -221,6 +247,361 @@ describe('RPG Agent 草稿恢复', () => {
expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result');
});
it('作品详情已加载完整编辑资产时列表摘要不能覆盖 selectedDetailEntry', async () => {
const fullProfile: CustomWorldProfile = {
...buildProfile('星砂废都'),
id: 'profile-stardust',
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-shamian'],
},
playableNpcs: [
{
id: 'playable-shamian',
name: '砂眠',
title: '废都引路人',
role: '主角代理',
description: '追查旧约的人。',
backstory: '从星砂潮汐里醒来。',
personality: '冷静。',
motivation: '找到旧约。',
combatStyle: '踏砂突进。',
initialAffinity: 45,
relationshipHooks: [],
tags: [],
relations: [],
backstoryReveal: {
publicSummary: '废都引路人。',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
actionPreviewConfig: {
folder: 'characters/shamian',
prefix: 'skill_',
frames: 8,
basePath: '/assets/custom-world/shamian/skill',
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
},
},
],
initialItems: [
{
id: 'item-sand-compass',
name: '星砂罗盘',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '能指出旧约埋藏方向。',
tags: [],
iconSrc: '/assets/custom-world/items/sand-compass.png',
},
],
imageSrc: '/assets/custom-world/playable-shamian.png',
attributeProfile: {
schemaId: 'schema-星砂废都',
values: { axis_a: 8 },
topTraits: ['星砂共鸣'],
evidence: [
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
],
},
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-clocktower',
sceneId: 'landmark-clocktower',
title: '钟楼第一夜',
summary: '钟楼第一夜。',
sceneTaskDescription: '进入钟楼。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-clocktower'],
acts: [
{
id: 'act-clocktower-opening',
sceneId: 'landmark-clocktower',
title: '第一幕',
summary: '砂眠带玩家进入坠星钟楼。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/assets/custom-world/act-clocktower-opening.png',
backgroundAssetId: 'asset-act-clocktower-opening',
encounterNpcIds: ['playable-shamian'],
primaryNpcId: 'playable-shamian',
oppositeNpcId: 'playable-shamian',
eventDescription: '钟楼旧铃忽然自鸣。',
linkedThreadIds: ['thread-old-vow'],
advanceRule: 'after_primary_contact',
actGoal: '进入钟楼。',
transitionHook: '星砂开始倒流。',
},
],
},
],
};
const summaryProfile: CustomWorldProfile = {
...fullProfile,
cover: null,
playableNpcs: [
{
...fullProfile.playableNpcs[0]!,
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
},
],
initialItems: [
{
...fullProfile.playableNpcs[0]!.initialItems[0]!,
iconSrc: undefined,
},
],
imageSrc: undefined,
attributeProfile: undefined,
},
],
sceneChapterBlueprints: [
{
...fullProfile.sceneChapterBlueprints![0]!,
acts: [
{
...fullProfile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc: undefined,
backgroundAssetId: undefined,
linkedThreadIds: [],
actGoal: '',
transitionHook: '',
},
],
},
],
};
const detailEntry = buildLibraryEntry(fullProfile);
const summaryEntry = buildLibraryEntry(summaryProfile);
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(detailEntry);
useEffect(() => {
if (selectedDetailEntry) {
selectedEntries.push(selectedDetailEntry);
}
}, [selectedDetailEntry]);
useRpgEntryLibraryDetail({
userId: 'user-1',
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: [summaryEntry],
setSavedCustomWorldEntries: vi.fn(),
setGeneratedCustomWorldProfile: vi.fn(),
setCustomWorldError: vi.fn(),
setCustomWorldAutoSaveError: vi.fn(),
setCustomWorldAutoSaveState: vi.fn(),
setCustomWorldGenerationViewSource: vi.fn(),
setCustomWorldResultViewSource: vi.fn(),
setSelectionStage: vi.fn(),
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentCreationResultView: vi.fn(),
buildDraftResultProfile: () => null,
suppressAgentDraftResultAutoOpen: vi.fn(),
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
markAutoSavedProfile: vi.fn(),
});
return null;
}
render(<Harness />);
await act(async () => {});
const lastSelected = selectedEntries.at(-1);
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
'playable-shamian',
]);
expect(
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
?.previewVideoPath,
).toBe('/assets/custom-world/shamian/skill.mp4');
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
'/assets/custom-world/items/sand-compass.png',
);
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy();
expect(
lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0]
?.backgroundImageSrc,
).toBe('/assets/custom-world/act-clocktower-opening.png');
});
it('默认封面和角色编辑结构差异也不能被列表摘要覆盖', async () => {
const fullRole = {
id: 'playable-shamian',
name: '砂眠',
title: '废都引路人',
role: '主角代理',
description: '追查旧约的人。',
backstory: '从星砂潮汐里醒来。',
personality: '冷静。',
motivation: '找到旧约。',
combatStyle: '踏砂突进。',
initialAffinity: 45,
relationshipHooks: [],
tags: [],
relations: [],
backstoryReveal: {
publicSummary: '废都引路人。',
privateChatUnlockAffinity: 60,
chapters: [],
},
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
actionPreviewConfig: {
folder: 'characters/shamian',
prefix: 'skill_',
frames: 8,
basePath: '/assets/custom-world/shamian/skill',
previewVideoPath: '/assets/custom-world/shamian/skill.mp4',
},
},
],
initialItems: [
{
id: 'item-sand-compass',
name: '星砂罗盘',
category: '专属物品',
quantity: 1,
rarity: 'rare',
description: '能指出旧约埋藏方向。',
tags: [],
iconSrc: '/assets/custom-world/items/sand-compass.png',
},
],
attributeProfile: {
schemaId: 'schema-星砂废都',
values: { axis_a: 8 },
topTraits: ['星砂共鸣'],
evidence: [
{ slotId: 'axis_a', reason: '能听见星砂潮汐。' },
],
},
} satisfies CustomWorldProfile['playableNpcs'][number];
const fullProfile: CustomWorldProfile = {
...buildProfile('星砂废都'),
id: 'profile-stardust-structure',
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-shamian'],
},
playableNpcs: [fullRole],
};
const summaryProfile: CustomWorldProfile = {
...fullProfile,
cover: null,
playableNpcs: [
{
...fullRole,
skills: [
{
id: 'skill-star-step',
name: '星砂步',
summary: '踏砂突进。',
style: 'mobility',
},
],
initialItems: [
{
...fullRole.initialItems[0]!,
iconSrc: undefined,
},
],
attributeProfile: undefined,
},
],
};
const detailEntry = buildLibraryEntry(fullProfile);
const summaryEntry = buildLibraryEntry(summaryProfile);
const selectedEntries: CustomWorldLibraryEntry<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | null
>(detailEntry);
useEffect(() => {
if (selectedDetailEntry) {
selectedEntries.push(selectedDetailEntry);
}
}, [selectedDetailEntry]);
useRpgEntryLibraryDetail({
userId: 'user-1',
selectedDetailEntry,
setSelectedDetailEntry,
savedCustomWorldEntries: [summaryEntry],
setSavedCustomWorldEntries: vi.fn(),
setGeneratedCustomWorldProfile: vi.fn(),
setCustomWorldError: vi.fn(),
setCustomWorldAutoSaveError: vi.fn(),
setCustomWorldAutoSaveState: vi.fn(),
setCustomWorldGenerationViewSource: vi.fn(),
setCustomWorldResultViewSource: vi.fn(),
setSelectionStage: vi.fn(),
setPlatformTabToCreate: vi.fn(),
setPlatformTabToDraft: vi.fn(),
setPlatformError: vi.fn(),
appendBrowseHistoryEntry: vi.fn(async () => {}),
refreshCustomWorldWorks: vi.fn(async () => []),
refreshPublishedGallery: vi.fn(async () => []),
persistAgentUiState: vi.fn(),
syncAgentCreationResultView: vi.fn(),
buildDraftResultProfile: () => null,
suppressAgentDraftResultAutoOpen: vi.fn(),
releaseAgentDraftResultAutoOpenSuppression: vi.fn(),
resetAutoSaveTrackingToIdle: vi.fn(),
markAutoSavedProfile: vi.fn(),
});
return null;
}
render(<Harness />);
await act(async () => {});
const lastSelected = selectedEntries.at(-1);
expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([
'playable-shamian',
]);
expect(
lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig
?.previewVideoPath,
).toBe('/assets/custom-world/shamian/skill.mp4');
expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe(
'/assets/custom-world/items/sand-compass.png',
);
expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual(
{ axis_a: 8 },
);
});
it('Agent 结果页自动保存先回写 session再保存后端 result-view profile', async () => {
const oldProfile = buildProfile('旧前端快照');
const latestProfile = {

View File

@@ -21,6 +21,11 @@ import {
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import type { CustomWorldProfile } from '../../types';
import {
countCustomWorldProfileAssetSlots,
countCustomWorldProfileDetailSlots,
countCustomWorldProfileStructuredSlots,
} from './rpgProfileCompleteness';
import { resolveRpgEntryErrorMessage } from './rpgEntryShared';
import type {
CustomWorldAutoSaveState,
@@ -86,6 +91,46 @@ function isMissingRpgEntryAgentSessionError(error: unknown) {
);
}
function shouldKeepSelectedDetailProfile(
selectedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
nextOwnedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
) {
if (
selectedEntry.ownerUserId !== nextOwnedEntry.ownerUserId ||
selectedEntry.profileId !== nextOwnedEntry.profileId
) {
return false;
}
const selectedDetailCount = countCustomWorldProfileDetailSlots(
selectedEntry.profile,
);
const nextDetailCount = countCustomWorldProfileDetailSlots(
nextOwnedEntry.profile,
);
const selectedAssetSlotCount = countCustomWorldProfileAssetSlots(
selectedEntry.profile,
);
const nextAssetSlotCount = countCustomWorldProfileAssetSlots(
nextOwnedEntry.profile,
);
const selectedStructuredSlotCount =
countCustomWorldProfileStructuredSlots(selectedEntry.profile);
const nextStructuredSlotCount = countCustomWorldProfileStructuredSlots(
nextOwnedEntry.profile,
);
const expectedRuntimeCount =
nextOwnedEntry.playableNpcCount + nextOwnedEntry.landmarkCount;
// 作品架列表只保证卡片摘要,不能在详情接口已经拿到完整运行态字段后覆盖详情。
return (
(selectedDetailCount > nextDetailCount &&
expectedRuntimeCount > nextDetailCount) ||
selectedAssetSlotCount > nextAssetSlotCount ||
selectedStructuredSlotCount > nextStructuredSlotCount
);
}
/**
* 负责平台详情、创作作品入口和结果页打开路径。
* 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。
@@ -136,6 +181,10 @@ export function useRpgEntryLibraryDetail(
entry.profileId === selectedDetailEntry.profileId,
);
if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) {
if (shouldKeepSelectedDetailProfile(selectedDetailEntry, nextOwnedEntry)) {
return;
}
setSelectedDetailEntry(nextOwnedEntry);
}
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);