Merge remote-tracking branch 'origin/master' into codex/bark-battle
This commit is contained in:
@@ -15,7 +15,51 @@ import {
|
||||
import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView';
|
||||
|
||||
vi.mock('../../data/characterPresets', () => ({
|
||||
ROLE_TEMPLATE_CHARACTERS: [],
|
||||
ROLE_TEMPLATE_CHARACTERS: [
|
||||
{
|
||||
id: 'fallback-hero',
|
||||
name: '兜底侠',
|
||||
title: '默认角色',
|
||||
description: '兜底角色',
|
||||
backstory: '兜底背景',
|
||||
personality: '冷静 果断',
|
||||
gender: 'unknown',
|
||||
portrait: '/portraits/fallback.png',
|
||||
attributes: {
|
||||
strength: 8,
|
||||
agility: 8,
|
||||
intelligence: 8,
|
||||
spirit: 8,
|
||||
},
|
||||
attributeProfile: {
|
||||
schemaId: 'schema:custom:fallback',
|
||||
values: {
|
||||
axis_a: 8,
|
||||
axis_b: 8,
|
||||
axis_c: 8,
|
||||
axis_d: 8,
|
||||
axis_e: 8,
|
||||
axis_f: 8,
|
||||
},
|
||||
evidence: [],
|
||||
},
|
||||
attributeProfiles: {
|
||||
CUSTOM: {
|
||||
schemaId: 'schema:custom:fallback',
|
||||
values: {
|
||||
axis_a: 8,
|
||||
axis_b: 8,
|
||||
axis_c: 8,
|
||||
axis_d: 8,
|
||||
axis_e: 8,
|
||||
axis_f: 8,
|
||||
},
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
skills: [],
|
||||
},
|
||||
],
|
||||
buildCustomWorldPlayableCharacters: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -190,3 +234,46 @@ test('custom world character selection stays stable when character ids are empty
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('custom world character selection falls back instead of rendering a blank screen when profile characters are malformed', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
vi.mocked(buildCustomWorldPlayableCharacters).mockImplementation(() => {
|
||||
throw new TypeError('profile.playableNpcs is not iterable');
|
||||
});
|
||||
|
||||
render(
|
||||
<RpgEntryCharacterSelectView
|
||||
worldType={WorldType.CUSTOM}
|
||||
customWorldProfile={{
|
||||
id: 'broken-profile',
|
||||
name: '坏数据',
|
||||
attributeSchema: {
|
||||
id: 'schema:custom:fallback',
|
||||
worldId: 'broken-profile',
|
||||
schemaVersion: 1,
|
||||
generatedFrom: {
|
||||
worldType: WorldType.CUSTOM,
|
||||
worldName: '坏数据',
|
||||
settingSummary: '坏数据',
|
||||
tone: '测试',
|
||||
conflictCore: '测试',
|
||||
},
|
||||
slots: [
|
||||
{ slotId: 'axis_a', name: '骨势' },
|
||||
{ slotId: 'axis_b', name: '身法' },
|
||||
{ slotId: 'axis_c', name: '眼脉' },
|
||||
{ slotId: 'axis_d', name: '心焰' },
|
||||
{ slotId: 'axis_e', name: '尘缘' },
|
||||
{ slotId: 'axis_f', name: '玄息' },
|
||||
],
|
||||
},
|
||||
} as unknown as CustomWorldProfile}
|
||||
onBack={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('选择你的角色')).toBeTruthy();
|
||||
expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /进入营地/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -112,6 +112,19 @@ function buildSelectionCharacterKey(character: Character, index: number) {
|
||||
return `selection-character-${index}-${fallbackSeed}`;
|
||||
}
|
||||
|
||||
function resolveSelectionCharacters(profile: CustomWorldProfile | null) {
|
||||
try {
|
||||
const characters = profile
|
||||
? buildCustomWorldPlayableCharacters(profile)
|
||||
: ROLE_TEMPLATE_CHARACTERS;
|
||||
|
||||
return characters.length > 0 ? characters : ROLE_TEMPLATE_CHARACTERS;
|
||||
} catch (error) {
|
||||
console.warn('自定义世界角色数据异常,已回退默认角色。', error);
|
||||
return ROLE_TEMPLATE_CHARACTERS;
|
||||
}
|
||||
}
|
||||
|
||||
function applyCharacterSelectionDraft(
|
||||
character: Character | null,
|
||||
draft?: CharacterSelectionDraft | null,
|
||||
@@ -209,7 +222,7 @@ export function RpgEntryCharacterSelectView({
|
||||
onConfirm,
|
||||
}: RpgEntryCharacterSelectViewProps) {
|
||||
const selectionCharacters = useMemo(
|
||||
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
|
||||
() => resolveSelectionCharacters(customWorldProfile),
|
||||
[customWorldProfile],
|
||||
);
|
||||
const selectionEntries = useMemo(
|
||||
@@ -329,7 +342,18 @@ export function RpgEntryCharacterSelectView({
|
||||
};
|
||||
|
||||
if (!selectedCharacter || !selectedCharacterMeta) {
|
||||
return null;
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center gap-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="text-sm text-zinc-300">角色数据暂不可用</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import {
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveSelectionStageFromPath,
|
||||
@@ -276,6 +277,17 @@ const testCreationEntryConfig = {
|
||||
description: '先选玩法类型,再进入对应创作工作台。',
|
||||
},
|
||||
creationTypes: [
|
||||
{
|
||||
id: 'rpg',
|
||||
title: '文字冒险',
|
||||
subtitle: '经典 RPG 体验',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/rpg.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 10,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'puzzle',
|
||||
title: '拼图',
|
||||
@@ -1993,6 +2005,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 {
|
||||
@@ -3336,8 +3356,8 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
|
||||
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/puzzle.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/airp.webp');
|
||||
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/rpg.webp');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
|
||||
).toContain('/creation-type-references/match3d.webp');
|
||||
@@ -8299,6 +8319,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();
|
||||
|
||||
@@ -8345,7 +8647,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:
|
||||
@@ -8983,6 +9285,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();
|
||||
|
||||
@@ -9216,6 +9577,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();
|
||||
|
||||
|
||||
@@ -2100,6 +2100,7 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
|
||||
const nav = container.querySelector('.platform-bottom-nav');
|
||||
expect(nav).toBeTruthy();
|
||||
expect(nav?.classList.contains('platform-bottom-nav')).toBe(true);
|
||||
const buttons = within(nav as HTMLElement).getAllByRole('button');
|
||||
|
||||
expect(buttons.map((button) => button.textContent)).toEqual([
|
||||
@@ -2110,6 +2111,12 @@ test('logged out bottom nav turns active recommend tab into next action', () =>
|
||||
expect(buttons[0]?.querySelector('.lucide-chevron-down')).toBeTruthy();
|
||||
expect(buttons[1]?.querySelector('.lucide-sparkles')).toBeTruthy();
|
||||
expect(buttons[2]?.querySelector('.lucide-compass')).toBeTruthy();
|
||||
expect(
|
||||
buttons[1]?.querySelector('.platform-bottom-nav__primary-action'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
buttons[0]?.querySelector('.platform-bottom-nav__active-mark'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged in draft bottom tab shows unread marker', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
@@ -130,6 +131,7 @@ import {
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
@@ -234,7 +236,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 = {
|
||||
@@ -1209,13 +1212,21 @@ function PlatformTabButton({
|
||||
className={`platform-bottom-nav__button ${emphasized ? 'platform-bottom-nav__button--primary' : ''} ${active ? 'platform-bottom-nav__button--active' : ''}`}
|
||||
>
|
||||
<span className="platform-bottom-nav__button-content">
|
||||
<span className="platform-bottom-nav__icon-shell">
|
||||
<span
|
||||
className={`platform-bottom-nav__icon-shell ${emphasized ? 'platform-bottom-nav__primary-action' : ''}`}
|
||||
>
|
||||
<Icon className="platform-bottom-nav__icon" />
|
||||
{showDot ? (
|
||||
<span aria-hidden="true" className="platform-nav-unread-dot" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="platform-bottom-nav__label">{label}</span>
|
||||
{active ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="platform-bottom-nav__active-mark"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -3358,7 +3369,7 @@ function ProfileReferralModal({
|
||||
onRedeemCodeChange,
|
||||
onSubmitRedeemCode,
|
||||
}: {
|
||||
panel: ProfilePopupPanel;
|
||||
panel: ProfileReferralPanel;
|
||||
center: ProfileReferralInviteCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
isSubmittingRedeem: boolean;
|
||||
@@ -3529,6 +3540,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,
|
||||
@@ -4556,7 +4627,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);
|
||||
@@ -5894,6 +5965,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="兑换码"
|
||||
@@ -6491,7 +6572,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}
|
||||
@@ -6656,7 +6745,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}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
|
||||
import type {
|
||||
JumpHopGalleryCardResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
buildBigFishPublicWorkCode,
|
||||
buildJumpHopPublicWorkCode,
|
||||
buildMatch3DPublicWorkCode,
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
@@ -44,6 +49,7 @@ export type PlatformWorldCardLike =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
@@ -175,6 +181,30 @@ export type PlatformVisualNovelGalleryCard = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PlatformJumpHopGalleryCard = {
|
||||
sourceType: 'jump-hop';
|
||||
workId: string;
|
||||
profileId: string;
|
||||
sourceSessionId?: string | null;
|
||||
publicWorkCode: string;
|
||||
ownerUserId: string;
|
||||
authorDisplayName: string;
|
||||
worldName: string;
|
||||
subtitle: string;
|
||||
summaryText: string;
|
||||
coverImageSrc: string | null;
|
||||
themeTags: string[];
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
difficulty?: string;
|
||||
stylePreset?: string;
|
||||
};
|
||||
|
||||
export type PlatformEdutainmentGalleryCard = {
|
||||
sourceType: 'edutainment';
|
||||
templateId: typeof EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID;
|
||||
@@ -233,6 +263,7 @@ export type PlatformPublicGalleryCard =
|
||||
| PlatformMatch3DGalleryCard
|
||||
| PlatformSquareHoleGalleryCard
|
||||
| PlatformPuzzleGalleryCard
|
||||
| PlatformJumpHopGalleryCard
|
||||
| PlatformVisualNovelGalleryCard
|
||||
| PlatformBarkBattleGalleryCard
|
||||
| PlatformEdutainmentGalleryCard;
|
||||
@@ -273,6 +304,12 @@ export function isVisualNovelGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'visual-novel';
|
||||
}
|
||||
|
||||
export function isJumpHopGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformJumpHopGalleryCard {
|
||||
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
|
||||
}
|
||||
|
||||
export function isEdutainmentGalleryEntry(
|
||||
entry: PlatformWorldCardLike,
|
||||
): entry is PlatformEdutainmentGalleryCard {
|
||||
@@ -426,6 +463,53 @@ export function mapVisualNovelWorkToPlatformGalleryCard(
|
||||
};
|
||||
}
|
||||
|
||||
export function mapJumpHopWorkToPlatformGalleryCard(
|
||||
work: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse,
|
||||
): PlatformJumpHopGalleryCard {
|
||||
const summary = 'summary' in work ? work.summary : work;
|
||||
const difficulty = summary.difficulty;
|
||||
const difficultyLabel =
|
||||
difficulty === 'easy'
|
||||
? '轻松节奏'
|
||||
: difficulty === 'advanced'
|
||||
? '进阶跳台'
|
||||
: difficulty === 'challenge'
|
||||
? '极限路线'
|
||||
: '标准路线';
|
||||
|
||||
return {
|
||||
sourceType: 'jump-hop',
|
||||
workId: summary.workId,
|
||||
profileId: summary.profileId,
|
||||
sourceSessionId:
|
||||
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
|
||||
publicWorkCode:
|
||||
'publicWorkCode' in summary && summary.publicWorkCode.trim()
|
||||
? summary.publicWorkCode
|
||||
: buildJumpHopPublicWorkCode(summary.profileId),
|
||||
ownerUserId: summary.ownerUserId,
|
||||
authorDisplayName:
|
||||
'authorDisplayName' in summary ? summary.authorDisplayName : '玩家',
|
||||
worldName: summary.workTitle,
|
||||
subtitle: difficultyLabel,
|
||||
summaryText: summary.workDescription,
|
||||
coverImageSrc: summary.coverImageSrc ?? null,
|
||||
themeTags:
|
||||
summary.themeTags.length > 0
|
||||
? summary.themeTags
|
||||
: ['跳一跳', difficultyLabel],
|
||||
playCount: summary.playCount ?? 0,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
visibility: 'published',
|
||||
publishedAt: summary.publishedAt ?? null,
|
||||
updatedAt: summary.updatedAt,
|
||||
difficulty,
|
||||
stylePreset: summary.stylePreset,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBabyObjectMatchDraftToPlatformGalleryCard(
|
||||
draft: BabyObjectMatchDraft,
|
||||
): PlatformEdutainmentGalleryCard {
|
||||
@@ -561,6 +645,10 @@ export function resolvePlatformWorldFallbackCoverImage(
|
||||
return '/creation-type-references/visual-novel.webp';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return '/creation-type-references/jump-hop.webp';
|
||||
}
|
||||
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return '/creation-type-references/big-fish.webp';
|
||||
}
|
||||
@@ -728,6 +816,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
: ['视觉小说'];
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
: ['跳一跳'];
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0
|
||||
? entry.themeTags.slice(0, 3)
|
||||
@@ -826,6 +920,10 @@ export function resolvePlatformPublicWorkCode(
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return entry.publicWorkCode;
|
||||
}
|
||||
|
||||
157
src/components/rpg-entry/rpgProfileCompleteness.ts
Normal file
157
src/components/rpg-entry/rpgProfileCompleteness.ts
Normal 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;
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { act, render } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { type CustomWorldProfile, WorldType } from '../../types';
|
||||
import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld';
|
||||
|
||||
@@ -69,7 +71,9 @@ function buildProfile(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
function buildSession(
|
||||
stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish',
|
||||
): CustomWorldAgentSessionSnapshot {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
currentTurn: 1,
|
||||
@@ -85,7 +89,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
},
|
||||
progressPercent: 100,
|
||||
lastAssistantReply: '',
|
||||
stage: 'ready_to_publish',
|
||||
stage,
|
||||
focusCardId: null,
|
||||
creatorIntent: null,
|
||||
creatorIntentReadiness: {
|
||||
@@ -113,6 +117,31 @@ function buildSession(): CustomWorldAgentSessionSnapshot {
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultView(params: {
|
||||
stage?: CustomWorldAgentSessionSnapshot['stage'];
|
||||
profile: CustomWorldProfile | null;
|
||||
canEnterWorld?: boolean;
|
||||
}): RpgCreationResultView {
|
||||
const stage = params.stage ?? 'ready_to_publish';
|
||||
const profileRecord = params.profile
|
||||
? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord)
|
||||
: null;
|
||||
return {
|
||||
session: buildSession(stage),
|
||||
profile: profileRecord,
|
||||
profileSource: profileRecord ? 'result_preview' : 'none',
|
||||
targetStage: 'custom-world-result',
|
||||
generationViewSource: null,
|
||||
resultViewSource: profileRecord ? 'agent-draft' : null,
|
||||
canAutosaveLibrary: true,
|
||||
canSyncResultProfile: stage !== 'published',
|
||||
publishReady: true,
|
||||
canEnterWorld: params.canEnterWorld ?? stage === 'published',
|
||||
blockerCount: 0,
|
||||
recoveryAction: 'open_result',
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgCreationEnterWorld', () => {
|
||||
it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
@@ -167,4 +196,280 @@ describe('useRpgCreationEnterWorld', () => {
|
||||
handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/portrait.png');
|
||||
});
|
||||
|
||||
it('Agent 草稿发布时先保存当前结果页 profile,再发送 publish_world 并回读结果页', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '发布前填写内容',
|
||||
imageSrc: '/generated-characters/draft-role/portrait.png',
|
||||
});
|
||||
const syncedProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '已保存的填写内容',
|
||||
imageSrc: '/generated-characters/draft-role/synced.png',
|
||||
});
|
||||
const publishedProfile = buildProfile({
|
||||
id: 'draft-profile',
|
||||
name: '已发布世界',
|
||||
imageSrc: '/generated-characters/draft-role/published.png',
|
||||
});
|
||||
const callOrder: string[] = [];
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const syncAgentDraftResultProfile = vi.fn(async () => {
|
||||
callOrder.push('save');
|
||||
return {
|
||||
profile: syncedProfile,
|
||||
view: buildResultView({
|
||||
stage: 'ready_to_publish',
|
||||
profile: syncedProfile,
|
||||
canEnterWorld: false,
|
||||
}),
|
||||
};
|
||||
});
|
||||
const executePublishWorld = vi.fn(async () => {
|
||||
callOrder.push('publish');
|
||||
return buildSession('published');
|
||||
});
|
||||
const syncAgentCreationResultView = vi.fn(async () => {
|
||||
callOrder.push('reload');
|
||||
return buildResultView({
|
||||
stage: 'published',
|
||||
profile: publishedProfile,
|
||||
canEnterWorld: true,
|
||||
});
|
||||
});
|
||||
|
||||
function Harness() {
|
||||
const { publishCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
currentAgentSessionStage: 'ready_to_publish',
|
||||
generatedCustomWorldProfile: resultProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
executePublishWorld,
|
||||
syncAgentCreationResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => void publishCurrentResult()}>
|
||||
发布
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('发布').click();
|
||||
});
|
||||
|
||||
expect(callOrder).toEqual(['save', 'publish', 'reload']);
|
||||
expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile);
|
||||
expect(executePublishWorld).toHaveBeenCalledTimes(1);
|
||||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile);
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id,
|
||||
).toBe('draft-profile');
|
||||
expect(
|
||||
setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0]
|
||||
?.imageSrc,
|
||||
).toBe('/generated-characters/draft-role/published.png');
|
||||
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => {
|
||||
const resultProfile = buildProfile({
|
||||
id: 'published-profile',
|
||||
name: '已发布世界',
|
||||
imageSrc: '/generated-characters/published-role/portrait.png',
|
||||
});
|
||||
const publishedView = buildResultView({
|
||||
stage: 'published',
|
||||
profile: resultProfile,
|
||||
canEnterWorld: true,
|
||||
});
|
||||
const handleCustomWorldSelect = vi.fn();
|
||||
const setGeneratedCustomWorldProfile = vi.fn();
|
||||
const executePublishWorld = vi.fn(async () => buildSession('published'));
|
||||
const syncAgentCreationResultView = vi.fn(async () => publishedView);
|
||||
const syncAgentDraftResultProfile = vi.fn(async () => ({
|
||||
profile: resultProfile,
|
||||
view: null,
|
||||
}));
|
||||
|
||||
function Harness() {
|
||||
const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({
|
||||
isAgentDraftResultView: true,
|
||||
activeAgentSessionId: 'session-1',
|
||||
currentAgentSessionStage: 'published',
|
||||
generatedCustomWorldProfile: resultProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
executePublishWorld,
|
||||
syncAgentCreationResultView,
|
||||
setGeneratedCustomWorldProfile,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void enterWorldFromCurrentResult()}
|
||||
>
|
||||
进入世界
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const { getByText } = render(<Harness />);
|
||||
await act(async () => {
|
||||
getByText('进入世界').click();
|
||||
});
|
||||
|
||||
expect(syncAgentDraftResultProfile).not.toHaveBeenCalled();
|
||||
expect(executePublishWorld).not.toHaveBeenCalled();
|
||||
expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1');
|
||||
expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1);
|
||||
expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe(
|
||||
'published-profile',
|
||||
);
|
||||
expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1);
|
||||
expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe(
|
||||
'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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
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;
|
||||
activeAgentSessionId: string | null;
|
||||
currentAgentSessionStage?: CustomWorldAgentSessionSnapshot['stage'] | null;
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
@@ -33,6 +36,7 @@ export function useRpgCreationEnterWorld(
|
||||
const {
|
||||
isAgentDraftResultView,
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
@@ -77,6 +81,18 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
if (currentAgentSessionStage === 'published') {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const publishedProfile = chooseMoreCompleteCustomWorldProfile(
|
||||
generatedCustomWorldProfile,
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView),
|
||||
);
|
||||
// 中文注释:已发布会话的“进入世界”只读取后端结果页真相,
|
||||
// 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
}
|
||||
|
||||
const syncedResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
@@ -96,22 +112,24 @@ 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;
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
isAgentDraftResultView,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user