fix: stabilize rpg publish and launch

This commit is contained in:
kdletters
2026-05-21 20:20:06 +08:00
parent 224a26d318
commit a9d23a8a44
14 changed files with 614 additions and 82 deletions

View File

@@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => {
};
});
function RuntimeLoadingFallback() {
return (
<div className="platform-ui-shell platform-viewport-shell platform-theme platform-theme--dark flex h-screen items-center justify-center bg-[image:var(--platform-body-fill)] p-4 font-sans text-[var(--platform-text-strong)]">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
</div>
</div>
);
}
function isRpgRuntimeRoute(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
return (
@@ -126,7 +136,7 @@ export default function App() {
if (isRuntimeActive) {
return (
<Suspense fallback={null}>
<Suspense fallback={<RuntimeLoadingFallback />}>
<RpgRuntimeApp
initialIntent={runtimeIntent}
onExitRuntime={() => {

View File

@@ -3216,6 +3216,7 @@ export function PlatformEntryFlowShellImpl({
const enterWorldCoordinator = useRpgCreationEnterWorld({
isAgentDraftResultView: sessionController.isAgentDraftResultView,
activeAgentSessionId: sessionController.activeAgentSessionId,
currentAgentSessionStage: sessionController.agentSession?.stage ?? null,
generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile,
handleCustomWorldSelect,
syncAgentDraftResultProfile:

View File

@@ -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();
});

View File

@@ -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 (

View File

@@ -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',
);
});
});

View File

@@ -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,

View File

@@ -25,6 +25,16 @@ const RpgRuntimeOverlayHost = lazy(async () => {
};
});
function RuntimeLayerLoadingFallback({ label }: { label: string }) {
return (
<div className="pointer-events-none fixed inset-x-0 top-4 z-[24] flex justify-center px-4">
<div className="rounded-full border border-white/10 bg-black/55 px-4 py-2 text-xs text-zinc-200 shadow-[0_12px_30px_rgba(0,0,0,0.32)] backdrop-blur-sm">
{label}
</div>
</div>
);
}
/**
* RPG 运行态总外壳。
* 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host
@@ -167,7 +177,7 @@ export function RpgRuntimeShell({
}}
>
{gameState.worldType ? (
<Suspense fallback={null}>
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载场景" />}>
<RpgRuntimeCanvasStage
gameState={gameState}
visibleGameState={visibleGameState}
@@ -275,7 +285,7 @@ export function RpgRuntimeShell({
/>
{gameState.worldType ? (
<Suspense fallback={null}>
<Suspense fallback={<RuntimeLayerLoadingFallback label="正在加载界面" />}>
<RpgRuntimeOverlayHost
gameState={gameState}
isLoading={isLoading}

View File

@@ -79,6 +79,72 @@ describe('rpgEntryLibraryClient world library routes', () => {
);
});
it('normalizes detail profiles before runtime launch consumes them', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
publicWorkCode: 'CW-1',
authorPublicUserCode: 'U-1',
profile: {
id: 'profile-1',
name: '旧数据世界',
summary: '只有摘要字段的旧 profile。',
},
visibility: 'published',
publishedAt: '2026-05-21T00:00:00.000Z',
updatedAt: '2026-05-21T00:00:00.000Z',
authorDisplayName: '作者',
worldName: '旧数据世界',
subtitle: '旧数据',
summaryText: '只有摘要字段的旧 profile。',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
},
});
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
expect(Array.isArray(entry.profile.storyNpcs)).toBe(true);
expect(Array.isArray(entry.profile.landmarks)).toBe(true);
expect(entry.profile.attributeSchema.schemaVersion).toBe(1);
});
it('falls back to entry summary when old detail profile cannot be normalized', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {
ownerUserId: 'owner-1',
profileId: 'profile-1',
publicWorkCode: 'CW-1',
authorPublicUserCode: 'U-1',
profile: {
id: 'profile-1',
summary: '缺少 name 的旧 profile。',
},
visibility: 'published',
publishedAt: '2026-05-21T00:00:00.000Z',
updatedAt: '2026-05-21T00:00:00.000Z',
authorDisplayName: '作者',
worldName: '摘要兜底世界',
subtitle: '旧数据',
summaryText: '缺少 name 的旧 profile。',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
},
});
const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1');
expect(entry.profile.id).toBe('profile-1');
expect(entry.profile.name).toBe('摘要兜底世界');
expect(Array.isArray(entry.profile.playableNpcs)).toBe(true);
});
it('reads owned library detail from the runtime entry route', async () => {
requestJsonMock.mockResolvedValueOnce({
entry: {

View File

@@ -7,13 +7,62 @@ import {
import type {
CustomWorldGalleryDetailResponse,
CustomWorldGalleryResponse,
CustomWorldLibraryEntry,
CustomWorldLibraryMutationResponse,
CustomWorldLibraryResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { CustomWorldProfile } from '../../types';
export type { RuntimeRequestOptions };
type RpgEntryWorldEntry = CustomWorldLibraryEntry<CustomWorldProfile>;
type RpgEntryWorldMutationResponse =
CustomWorldLibraryMutationResponse<CustomWorldProfile>;
function normalizeRpgEntryWorldProfile(entry: RpgEntryWorldEntry) {
const rawProfile =
entry.profile && typeof entry.profile === 'object' ? entry.profile : {};
const fallbackProfile = {
id: entry.profileId,
name: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
settingText: entry.summaryText || entry.worldName,
playableNpcs: [],
storyNpcs: [],
items: [],
landmarks: [],
};
const normalizedProfile =
normalizeCustomWorldProfileRecord({
...fallbackProfile,
...rawProfile,
}) ?? normalizeCustomWorldProfileRecord(fallbackProfile);
return {
...entry,
profile: normalizedProfile ?? entry.profile,
} as RpgEntryWorldEntry;
}
function normalizeRpgEntryWorldEntries(
entries: RpgEntryWorldEntry[] | null | undefined,
) {
return Array.isArray(entries)
? entries.map((entry) => normalizeRpgEntryWorldProfile(entry))
: [];
}
function normalizeRpgEntryWorldMutationResponse(
response: RpgEntryWorldMutationResponse,
) {
return {
entry: normalizeRpgEntryWorldProfile(response.entry),
entries: normalizeRpgEntryWorldEntries(response.entries),
};
}
/**
* RPG 入口世界库 client 的真实实现。
* 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。
@@ -33,7 +82,7 @@ export async function listRpgEntryWorldLibrary(
},
);
return Array.isArray(response?.entries) ? response.entries : [];
return normalizeRpgEntryWorldEntries(response?.entries);
}
export async function listRpgEntryWorldGallery(
@@ -63,7 +112,7 @@ export async function getRpgEntryWorldGalleryDetail(
options,
);
return response.entry;
return normalizeRpgEntryWorldProfile(response.entry);
}
export async function getRpgEntryWorldGalleryDetailByCode(
@@ -79,7 +128,7 @@ export async function getRpgEntryWorldGalleryDetailByCode(
options,
);
return response.entry;
return normalizeRpgEntryWorldProfile(response.entry);
}
export async function remixRpgEntryWorldGallery(
@@ -96,10 +145,7 @@ export async function remixRpgEntryWorldGallery(
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
return normalizeRpgEntryWorldMutationResponse(response);
}
export async function recordRpgEntryWorldGalleryPlay(
@@ -116,7 +162,7 @@ export async function recordRpgEntryWorldGalleryPlay(
options,
);
return response.entry;
return normalizeRpgEntryWorldProfile(response.entry);
}
export async function likeRpgEntryWorldGallery(
@@ -133,7 +179,7 @@ export async function likeRpgEntryWorldGallery(
options,
);
return response.entry;
return normalizeRpgEntryWorldProfile(response.entry);
}
export async function getRpgEntryWorldLibraryDetail(
@@ -149,7 +195,7 @@ export async function getRpgEntryWorldLibraryDetail(
options,
);
return response.entry;
return normalizeRpgEntryWorldProfile(response.entry);
}
export async function upsertRpgEntryWorldProfile(
@@ -171,10 +217,7 @@ export async function upsertRpgEntryWorldProfile(
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
return normalizeRpgEntryWorldMutationResponse(response);
}
export async function deleteRpgEntryWorldProfile(
@@ -190,7 +233,7 @@ export async function deleteRpgEntryWorldProfile(
options,
);
return Array.isArray(response?.entries) ? response.entries : [];
return normalizeRpgEntryWorldEntries(response?.entries);
}
export async function publishRpgEntryWorldProfile(
@@ -206,10 +249,7 @@ export async function publishRpgEntryWorldProfile(
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
return normalizeRpgEntryWorldMutationResponse(response);
}
export async function unpublishRpgEntryWorldProfile(
@@ -225,10 +265,7 @@ export async function unpublishRpgEntryWorldProfile(
options,
);
return {
entry: response.entry,
entries: Array.isArray(response?.entries) ? response.entries : [],
};
return normalizeRpgEntryWorldMutationResponse(response);
}
export const rpgEntryLibraryClient = {