fix: preserve rpg custom world detail profiles
This commit is contained in:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user