fix: stabilize rpg publish and launch
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 (
|
||||
|
||||
@@ -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,148 @@ 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -8,6 +9,7 @@ import type { CustomWorldProfile } from '../../types';
|
||||
type UseRpgCreationEnterWorldParams = {
|
||||
isAgentDraftResultView: boolean;
|
||||
activeAgentSessionId: string | null;
|
||||
currentAgentSessionStage?: CustomWorldAgentSessionSnapshot['stage'] | null;
|
||||
generatedCustomWorldProfile: CustomWorldProfile | null;
|
||||
handleCustomWorldSelect: (
|
||||
customWorldProfile: CustomWorldProfile,
|
||||
@@ -33,6 +35,7 @@ export function useRpgCreationEnterWorld(
|
||||
const {
|
||||
isAgentDraftResultView,
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
generatedCustomWorldProfile,
|
||||
handleCustomWorldSelect,
|
||||
syncAgentDraftResultProfile,
|
||||
@@ -77,6 +80,17 @@ export function useRpgCreationEnterWorld(
|
||||
return generatedCustomWorldProfile;
|
||||
}
|
||||
|
||||
if (currentAgentSessionStage === 'published') {
|
||||
const latestView = await syncAgentCreationResultView(activeAgentSessionId);
|
||||
const publishedProfile =
|
||||
rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ??
|
||||
generatedCustomWorldProfile;
|
||||
// 中文注释:已发布会话的“进入世界”只读取后端结果页真相,
|
||||
// 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。
|
||||
setGeneratedCustomWorldProfile(publishedProfile);
|
||||
return publishedProfile;
|
||||
}
|
||||
|
||||
const syncedResult = await syncAgentDraftResultProfile(
|
||||
generatedCustomWorldProfile,
|
||||
);
|
||||
@@ -112,6 +126,7 @@ export function useRpgCreationEnterWorld(
|
||||
return publishedProfile;
|
||||
}, [
|
||||
activeAgentSessionId,
|
||||
currentAgentSessionStage,
|
||||
executePublishWorld,
|
||||
generatedCustomWorldProfile,
|
||||
isAgentDraftResultView,
|
||||
|
||||
Reference in New Issue
Block a user