This commit is contained in:
2026-04-24 22:25:13 +08:00
parent 75681751c2
commit 67062a8af3
43 changed files with 1857 additions and 268 deletions

View File

@@ -73,4 +73,19 @@ describe('CharacterAnimator portrait fallbacks', () => {
expect(image.style.transform).toContain('rotate(-90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
it('uses generated portrait for movement when generated animation is missing', () => {
render(
<CharacterAnimator
state={AnimationState.RUN}
character={buildCharacter({generatedVisualAssetId: 'assetobj-role-main'})}
/>,
);
const image = screen.getByRole('img', {
name: /沈砺 run animation/i,
}) as HTMLImageElement;
expect(image.getAttribute('src')).toBe('/generated/portrait.png');
});
});

View File

@@ -107,6 +107,9 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
playbackRate = 1,
}) => {
const explicitConfig = character.animationMap?.[state];
const hasGeneratedPortraitOnly =
Boolean(character.generatedVisualAssetId && character.portrait?.trim())
&& !explicitConfig;
const usePortraitIdleFallback =
!explicitConfig && state === AnimationState.IDLE;
const usePortraitDeathFallback =
@@ -118,7 +121,7 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
hasGeneratedPortraitOnly || usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)

View File

@@ -341,6 +341,46 @@ function resolveSceneCardImage(params: {
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function collectSceneActImagePreviews(sceneChapters: SceneChapterBlueprint[]) {
return sceneChapters.flatMap((chapter) =>
chapter.acts
.map((act, index) => ({
id: act.id.trim() || `${chapter.id}-act-${index}`,
title: act.title.trim() || `${index + 1}`,
imageSrc: act.backgroundImageSrc?.trim() || '',
}))
.filter((act) => act.imageSrc),
);
}
function SceneActPreviewStrip({
acts,
sceneName,
}: {
acts: Array<{ id: string; title: string; imageSrc: string }>;
sceneName: string;
}) {
if (acts.length <= 0) return null;
return (
<div className="flex w-full gap-1.5 overflow-x-auto pb-0.5">
{acts.map((act) => (
<div
key={act.id}
className="platform-subpanel h-12 w-[5.25rem] shrink-0 overflow-hidden rounded-xl"
title={act.title}
>
<ResolvedAssetImage
src={act.imageSrc}
alt={`${sceneName}-${act.title}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
);
}
function CatalogCard({
title,
description,
@@ -1015,6 +1055,7 @@ export function CustomWorldEntityCatalog({
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
actPreviews: collectSceneActImagePreviews(openingSceneChapters),
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
@@ -1039,6 +1080,7 @@ export function CustomWorldEntityCatalog({
sceneChapters,
}),
sceneChapters,
actPreviews: collectSceneActImagePreviews(sceneChapters),
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
@@ -1576,6 +1618,12 @@ export function CustomWorldEntityCatalog({
tone="landscape"
/>
}
actions={
<SceneActPreviewStrip
acts={scene.actPreviews}
sceneName={scene.name}
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))

View File

@@ -262,6 +262,21 @@ const baseProfile = {
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
{
id: 'scene-act-2',
sceneId: 'landmark-1',
title: '钟楼回响',
summary: '第二幕把旧钟与暗线证据推到台前。',
stageCoverage: ['investigation'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-2.png',
backgroundAssetId: 'scene-asset-2',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_clue_found',
actGoal: '找到旧钟证据',
transitionHook: '钟楼深处传来第二次回响。',
},
],
},
],
@@ -400,7 +415,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder',
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
test('landmark tab previews every generated act image while keeping chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
@@ -414,6 +429,17 @@ test('landmark tab uses first act image as scene card preview and keeps chapter
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-潮声逼近',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-1.png');
expect(
(screen.getByRole('img', {
name: '沉钟栈桥-钟楼回响',
}) as HTMLImageElement).getAttribute('src'),
).toBe('/generated-custom-world-scenes/scene-act-2.png');
});
test('readOnly result view hides edit and create actions for agent preview mode', async () => {
@@ -535,10 +561,9 @@ test('agent result view opens publish blocker dialog only when user clicks publi
await user.click(screen.getByRole('button', { name: '发布并进入世界' }));
expect(
screen.getByRole('dialog', { name: '发布前检查' }),
).toBeTruthy();
expect(screen.getByText(/ 2 /u)).toBeTruthy();
expect(screen.getByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(
screen.getByText(//u),
).toBeTruthy();

View File

@@ -10,11 +10,14 @@ import { AuthGate } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
authEntry: vi.fn(),
changePassword: vi.fn(),
ensureStoredAccessToken: vi.fn(),
ensureAutoAuthUser: vi.fn(),
getAuthLoginOptions: vi.fn(),
getCurrentAuthUser: vi.fn(),
loginWithPhoneCode: vi.fn(),
resetPassword: vi.fn(),
sendPhoneLoginCode: vi.fn(),
startWechatLogin: vi.fn(),
consumeAuthCallbackResult: vi.fn(),
@@ -26,10 +29,13 @@ vi.mock('../../services/apiClient', () => ({
}));
vi.mock('../../services/authService', () => ({
authEntry: authMocks.authEntry,
bindWechatPhone: vi.fn(),
changePassword: authMocks.changePassword,
changePhoneNumber: vi.fn(),
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
getStoredLastLoginPhone: vi.fn(() => ''),
getAuthAuditLogs: vi.fn(),
getAuthLoginOptions: authMocks.getAuthLoginOptions,
getAuthRiskBlocks: vi.fn(),
@@ -40,8 +46,10 @@ vi.mock('../../services/authService', () => ({
loginWithPhoneCode: authMocks.loginWithPhoneCode,
logoutAllAuthSessions: vi.fn(),
logoutAuthUser: vi.fn(),
resetPassword: authMocks.resetPassword,
revokeAuthSession: vi.fn(),
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
setStoredLastLoginPhone: vi.fn(),
startWechatLogin: authMocks.startWechatLogin,
}));
@@ -86,6 +94,9 @@ beforeEach(() => {
availableLoginMethods: ['phone'],
});
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser);
authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.resetPassword.mockResolvedValue(mockUser);
authMocks.sendPhoneLoginCode.mockResolvedValue({
cooldownSeconds: 60,
expiresInSeconds: 300,
@@ -203,13 +214,13 @@ test('auth gate opens a login modal for protected actions and resumes after logi
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(dialog).toBeTruthy();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '登录' }));
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
@@ -220,7 +231,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '登录账号' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
});
test('auth state refresh keeps mounted platform content and local tab state', async () => {
@@ -280,7 +291,7 @@ test('auth gate shows sms send feedback in the login modal', async () => {
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '登录账号' });
const dialog = screen.getByRole('dialog', { name: '账号入口' });
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.click(within(dialog).getByRole('button', { name: '获取验证码' }));
@@ -296,7 +307,48 @@ test('auth gate shows sms send feedback in the login modal', async () => {
});
expect(
within(dialog).getByText('短信请求已提交,请留意手机短信。验证码有效期约 5 分钟。'),
within(dialog).getByText('短信请求已提交,验证码有效期约 5 分钟。'),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '60s' })).toBeTruthy();
});
test('auth gate separates sms and password login by tabs', async () => {
const user = userEvent.setup();
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone', 'password'],
});
render(
<AuthGate>
<ProtectedActionButton onAuthenticated={vi.fn()} />
</AuthGate>,
);
await user.click(await screen.findByRole('button', { name: '进入作品' }));
const dialog = screen.getByRole('dialog', { name: '账号入口' });
expect(
within(dialog)
.getByRole('tab', { name: '短信登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('密码')).toBeNull();
await user.click(within(dialog).getByRole('tab', { name: '密码登录' }));
expect(
within(dialog)
.getByRole('tab', { name: '密码登录' })
.getAttribute('aria-selected'),
).toBe('true');
expect(within(dialog).queryByLabelText('验证码')).toBeNull();
await user.type(within(dialog).getByLabelText('手机号/邮箱'), '13800000000');
await user.type(within(dialog).getByLabelText('密码'), 'passw0rd');
await user.click(within(dialog).getByRole('button', { name: '注册/登录' }));
await waitFor(() => {
expect(authMocks.authEntry).toHaveBeenCalledWith('13800000000', 'passw0rd');
});
});

View File

@@ -10,6 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password';
type LoginScreenProps = {
isOpen: boolean;
@@ -72,6 +73,18 @@ export function LoginScreen({
const passwordLoginEnabled = availableLoginMethods.includes('password');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
useEffect(() => {
if (activeLoginTab === 'phone' && !phoneLoginEnabled && passwordLoginEnabled) {
setActiveLoginTab('password');
return;
}
if (activeLoginTab === 'password' && !passwordLoginEnabled && phoneLoginEnabled) {
setActiveLoginTab('phone');
}
}, [activeLoginTab, passwordLoginEnabled, phoneLoginEnabled]);
useEffect(() => {
if (cooldownSeconds <= 0) {
@@ -152,8 +165,29 @@ export function LoginScreen({
onSubmit={() => onResetPassword(resetPhone, resetCode, resetPasswordValue)}
/>
) : (
<div className="flex flex-col gap-4 px-5 py-5">
{passwordLoginEnabled ? (
<div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? (
<div
className="grid grid-cols-2 gap-2"
role="tablist"
aria-label="登录方式"
>
<LoginTabButton
active={activeLoginTab === 'phone'}
onClick={() => setActiveLoginTab('phone')}
>
</LoginTabButton>
<LoginTabButton
active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')}
>
</LoginTabButton>
</div>
) : null}
{passwordLoginEnabled && activeLoginTab === 'password' ? (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
@@ -162,14 +196,13 @@ export function LoginScreen({
}}
>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<span>/</span>
<input
className="platform-input"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
placeholder="手机号或邮箱"
/>
</label>
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
@@ -213,7 +246,7 @@ export function LoginScreen({
</form>
) : null}
{phoneLoginEnabled ? (
{phoneLoginEnabled && activeLoginTab === 'phone' ? (
<PhoneCodeForm
phone={phone}
code={code}
@@ -226,7 +259,7 @@ export function LoginScreen({
hint={hint}
submitLabel="注册/登录"
enabled={phoneLoginEnabled}
showPhoneField={!passwordLoginEnabled}
showPhoneField
onPhoneChange={setPhone}
onCodeChange={setCode}
onCaptchaAnswerChange={setCaptchaAnswer}
@@ -258,6 +291,35 @@ export function LoginScreen({
);
}
function LoginTabButton({
active,
children,
onClick,
}: {
active: boolean;
children: string;
onClick: () => void;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
className={`relative h-12 text-base font-semibold transition-colors sm:text-lg ${
active
? 'text-[var(--platform-text-strong)]'
: 'text-[var(--platform-text-muted)]'
}`}
onClick={onClick}
>
<span>{children}</span>
{active ? (
<span className="absolute bottom-1 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-[var(--platform-accent)]" />
) : null}
</button>
);
}
function PhoneCodeForm({
phone,
code,

View File

@@ -1,6 +1,7 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
@@ -127,5 +128,36 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];
const persistedDraft = {
...baseDraftItem,
workId: 'draft:profile-1',
sourceType: 'published_profile' as const,
sessionId: null,
profileId: 'profile-1',
title: '可继续整理的草稿',
};
render(
<CustomWorldCreationHub
items={[persistedDraft]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={(item) => {
openedItems.push(item);
}}
onEnterPublished={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: /稿/u }));
expect(openedItems).toEqual([persistedDraft]);
});

View File

@@ -171,10 +171,7 @@ export function CustomWorldCreationHub({
return;
}
if (
item.item.sourceType === 'agent_session' &&
item.item.sessionId
) {
if (item.item.status === 'draft') {
onOpenDraft(item.item);
return;
}

View File

@@ -76,7 +76,21 @@ export function CustomWorldWorkCard({
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem] xl:min-h-[12.25rem] xl:px-4 xl:py-3.5">
<div
role="button"
tabIndex={0}
aria-label={`${openActionLabel}${title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
>
<CustomWorldCoverArtwork
imageSrc={coverImageSrc}
title={title}
@@ -86,7 +100,7 @@ export function CustomWorldWorkCard({
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col xl:min-h-[10.75rem]">
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
@@ -124,11 +138,14 @@ export function CustomWorldWorkCard({
{onDelete ? (
<button
type="button"
onClick={onDelete}
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : `删除作品《${title}`}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
@@ -155,19 +172,19 @@ export function CustomWorldWorkCard({
</div>
</div>
<div className="mt-4 xl:mt-3">
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2 xl:line-clamp-2 xl:leading-6">
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between xl:gap-2 xl:pt-3">
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{isPuzzle ? (
<>
@@ -222,18 +239,14 @@ export function CustomWorldWorkCard({
)}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
<button
type="button"
onClick={onOpen}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
{openActionLabel}
</button>
{onExperience ? (
<button
type="button"
onClick={onExperience}
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>

View File

@@ -1272,16 +1272,17 @@ export function PlatformEntryFlowShellImpl({
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
const deleteTask = work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
const deleteTask =
work.sourceType === 'published_profile' && work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
: work.sourceType === 'agent_session' && work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
void deleteTask
.then(async () => {
@@ -2087,23 +2088,43 @@ export function PlatformEntryFlowShellImpl({
});
});
}}
onTestWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'进入作品测试失败。',
),
);
});
});
}}
onPublishWorld={async () => {
await enterWorldCoordinator.publishCurrentResult();
}}
onTestWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? () => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'进入作品测试失败。',
),
);
});
});
}
: undefined
}
onPublishWorld={
sessionController.isAgentDraftResultView &&
sessionController.agentSession?.stage !== 'published'
? async () => {
try {
await enterWorldCoordinator.publishCurrentResult();
} catch (error) {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'发布到广场失败。',
),
);
throw error;
}
}
: undefined
}
onGenerateEntity={
sessionController.isAgentDraftResultView
? async (kind) => {

View File

@@ -345,11 +345,8 @@ function buildDefaultSceneActBlueprint(params: {
title: actTitle,
summary: actSummary,
stageCoverage,
backgroundPromptText: compactTextList([
`${sceneLabel}${actTitle}背景`,
sceneSummary,
actSummary,
]).join(''),
// 幕背景画面描述应来自草稿生成阶段的大模型输出,前端缺失时只留空,避免展示规则拼接文本。
backgroundPromptText: '',
backgroundImageSrc: params.backgroundImageSrc || undefined,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
@@ -472,9 +469,7 @@ function sanitizeSceneChapterBlueprint(params: {
title: currentAct?.title?.trim() || fallbackAct.title,
summary: currentAct?.summary?.trim() || fallbackAct.summary,
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
backgroundPromptText:
currentAct?.backgroundPromptText?.trim() ||
fallbackAct.backgroundPromptText,
backgroundPromptText: currentAct?.backgroundPromptText?.trim() || '',
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||

View File

@@ -291,6 +291,7 @@ export function RpgCreationResultActionBar({
isPublishing={isPublishing}
onClose={() => setShowPublishBlockersDialog(false)}
onEditCover={() => {
setShowPublishBlockersDialog(false);
onOpenCoverEditor?.();
}}
onPublish={() => {

View File

@@ -1544,9 +1544,8 @@ test('existing draft sessions open result page refinement instead of agent dialo
async () => {
expect(await screen.findByText('世界档案')).toBeTruthy();
expect(screen.getByText('已自动保存')).toBeTruthy();
expect(
screen.getByRole('button', { name: //u }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
},
{ timeout: 2500 },
);
@@ -1594,9 +1593,11 @@ test('agent result view shows publish blocker dialog before publish action when
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
});
const actionButton = await screen.findByRole(
'button',
{ name: '发布' },
{ timeout: 5000 },
);
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
const publishWorldCallCountBeforeClick = vi
@@ -1609,8 +1610,9 @@ test('agent result view shows publish blocker dialog before publish action when
await user.click(actionButton);
expect(await screen.findByRole('dialog', { name: '发布前检查' })).toBeTruthy();
expect(screen.getByText(/ 1 /u)).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '发布作品' })).toBeTruthy();
expect(screen.getByText('发布检查')).toBeTruthy();
expect(screen.getByText('封面设置')).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
const publishWorldCallCountAfterClick = vi
@@ -1623,7 +1625,7 @@ test('agent result view shows publish blocker dialog before publish action when
expect(publishWorldCallCountAfterClick).toBe(publishWorldCallCountBeforeClick);
});
test('agent draft result publishes before entering world and uses published preview profile', async () => {
test('agent draft result publishes to gallery from publish panel', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
@@ -1711,9 +1713,10 @@ test('agent draft result publishes before entering world and uses published prev
await openNewRpgCreation(user);
const actionButton = await screen.findByRole('button', {
name: //u,
name: '发布',
});
await user.click(actionButton);
await user.click(await screen.findByRole('button', { name: '发布到广场' }));
await waitFor(() => {
expect(executeRpgCreationAction).toHaveBeenCalledWith(
@@ -1723,23 +1726,78 @@ test('agent draft result publishes before entering world and uses published prev
}),
);
});
expect(handleCustomWorldSelect).not.toHaveBeenCalled();
});
test('agent draft result test button enters current draft without publish gate', async () => {
const user = userEvent.setup();
const handleCustomWorldSelect = vi.fn();
vi.mocked(getRpgCreationOperation).mockResolvedValue({
operationId: 'operation-draft-foundation-1',
type: 'draft_foundation',
status: 'completed',
phaseLabel: '世界底稿已生成',
phaseDetail: '第一版世界底稿和 4 张草稿卡已经整理完成。',
progress: 100,
error: null,
});
vi.mocked(getRpgCreationSession).mockResolvedValue({
...compiledAgentDraftSession,
stage: 'ready_to_publish',
resultPreview: {
...compiledAgentDraftSession.resultPreview!,
publishReady: false,
canEnterWorld: false,
blockers: [
{
id: 'missing-cover-image',
code: 'MISSING_COVER_IMAGE',
message: '发布前需要补齐作品封面。',
},
],
},
});
function TestDraftWrapper() {
const [selectionStage, setSelectionStage] =
useState<SelectionStage>('platform');
return (
<AuthUiContext.Provider value={createAuthValue()}>
<RpgEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
gameState={{} as GameState}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={() => {}}
handleStartNewGame={() => {}}
handleCustomWorldSelect={handleCustomWorldSelect}
/>
</AuthUiContext.Provider>
);
}
render(<TestDraftWrapper />);
await openNewRpgCreation(user);
await user.click(await screen.findByRole('button', { name: '作品测试' }));
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({ name: '潮雾列岛' }),
);
});
expect(
vi
.mocked(executeRpgCreationAction)
.mock.calls.some(
([sessionId, payload]) =>
sessionId === 'custom-world-agent-session-1' &&
payload?.action === 'sync_result_profile',
payload?.action === 'publish_world',
),
).toBe(false);
await waitFor(() => {
expect(handleCustomWorldSelect).toHaveBeenCalledWith(
expect.objectContaining({
name: '潮雾列岛·已发布',
summary: '发布完成后应直接使用已发布预览进入世界。',
}),
);
});
});
test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => {
@@ -1879,12 +1937,13 @@ test('agent result view does not keep legacy publish blockers when preview uses
await user.click(await screen.findByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: '作品测试' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
});
expect(screen.queryByText(/ 4 /u)).toBeNull();
const actionButton = screen.getByRole('button', {
name: //u,
name: '发布',
});
expect((actionButton as HTMLButtonElement).disabled).toBe(false);
});

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { normalizeCustomWorldProfileRecord } from './customWorldLibrary';
describe('normalizeCustomWorldProfileRecord role asset descriptions', () => {
it('保留草稿生成阶段产出的角色形象描述字段', () => {
const profile = normalizeCustomWorldProfileRecord({
name: '雾港归航',
settingText: '海雾旧案',
playableNpcs: [
{
name: '岑灯',
title: '返乡守灯人',
role: '主角代理',
description: '追查旧案的人',
visualDescription: '瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
actionDescription: '抬灯照出雾中航线,侧身抽出卷边海图迅速标记。',
sceneVisualDescription: '旧灯塔石阶被潮水打湿,青白灯火照着雾中海图。',
},
],
storyNpcs: [
{
name: '议长甲',
title: '群岛议长',
role: '遮掩者',
description: '压住旧档的人',
visualDescription: '银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
actionDescription: '用印信压住卷宗,抬手示意巡海队封锁出口。',
sceneVisualDescription: '议会厅高窗外翻涌海雾,长桌尽头堆着封存卷宗。',
},
],
});
expect(profile?.playableNpcs[0]?.visualDescription).toBe(
'瘦高守灯人披深蓝旧雨衣,腰挂铜灯与卷边海图,眼下有长期失眠的青影。',
);
expect(profile?.playableNpcs[0]?.actionDescription).toContain('抬灯');
expect(profile?.playableNpcs[0]?.sceneVisualDescription).toContain('旧灯塔');
expect(profile?.storyNpcs[0]?.visualDescription).toBe(
'银发议长穿硬挺黑色长礼服,胸前别着海鸟徽章,手套边缘沾着档案灰。',
);
expect(profile?.storyNpcs[0]?.actionDescription).toContain('印信');
expect(profile?.storyNpcs[0]?.sceneVisualDescription).toContain('议会厅');
});
});

View File

@@ -683,6 +683,9 @@ function normalizePlayableNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,
@@ -755,6 +758,9 @@ function normalizeStoryNpc(
title,
role,
description: fallbackSource.description,
visualDescription: toText(value.visualDescription) || undefined,
actionDescription: toText(value.actionDescription) || undefined,
sceneVisualDescription: toText(value.sceneVisualDescription) || undefined,
backstory: fallbackSource.backstory,
personality: fallbackSource.personality,
motivation: fallbackSource.motivation,

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { buildCustomWorldRoleOutlineBatchPrompt } from './customWorldPrompts';
const framework = {
settingText: '潮雾封锁的边境港城,旧灯塔下藏着失踪船队的线索。',
name: '潮雾港',
subtitle: '旧灯塔仍在雾里亮着',
summary: '玩家需要在港城各方势力间找到失踪船队真相。',
tone: '潮湿、悬疑、克制',
playerGoal: '找回失踪船队并决定港城秩序的走向。',
templateWorldType: 'custom',
compatibilityTemplateWorldType: 'custom',
majorFactions: ['守灯人', '走私船帮'],
coreConflicts: ['旧航道真相', '港城权力交接'],
camp: {
name: '旧灯塔营地',
description: '潮雾里的临时归处。',
dangerLevel: 'medium',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
};
describe('buildCustomWorldRoleOutlineBatchPrompt', () => {
it('requires model-generated visual descriptions for role drafts', () => {
const prompt = buildCustomWorldRoleOutlineBatchPrompt({
framework,
roleType: 'playable',
batchCount: 2,
});
expect(prompt).toContain('"visualDescription"');
expect(prompt).toContain('"actionDescription"');
expect(prompt).toContain('"sceneVisualDescription"');
expect(prompt).toContain('visualDescription 必须跟随本步骤直接生成');
expect(prompt).toContain('不能复制 description');
});
});

View File

@@ -494,6 +494,9 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
' "title": "称号",',
' "role": "身份",',
' "description": "极简定位描述",',
' "visualDescription": "专门用于主形象生成的外观描述",',
' "actionDescription": "专门用于动作生成的动作气质描述",',
' "sceneVisualDescription": "角色常出现的场景氛围描述",',
' "initialAffinity": 18,',
' "relationshipHooks": ["一个关系切入口"],',
' "tags": ["标签1", "标签2"]',
@@ -505,9 +508,12 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- 只保留name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'- description 控制在 8 到 18 个汉字内,只写角色定位,不写外观。',
'- visualDescription 必须跟随本步骤直接生成,专门描述角色外观,包含轮廓、服饰 / 身体特征、携带物或材质气质,不能复制 description。',
'- actionDescription 专门描述动作气质sceneVisualDescription 专门描述角色常出现的场景氛围。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
'- description 控制在 8 到 18 个汉字内,title 和 role 尽量短。',
'- title 和 role 尽量短。',
'- initialAffinity 必须是 -40 到 90 的整数。',
roleType === 'playable'
? '- 可扮演角色的定位必须明显不同,通常使用 18 到 40 的初始好感。'
@@ -536,8 +542,9 @@ export function buildCustomWorldRoleOutlineBatchJsonRepairPrompt(params: {
forbiddenNames.length > 0
? `禁止使用这些重复名:${forbiddenNames.join('、')}`
: '',
'每个角色只包含name、title、role、description、initialAffinity、relationshipHooks、tags。',
'每个角色只包含name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。',
'如果缺少字段字符串补空字符串relationshipHooks 和 tags 补空数组initialAffinity 补默认整数。',
'visualDescription 必须是独立外观描述,不能用 description 原文替代。',
'不要输出 backstory、skills、landmarks 或任何其他字段。',
'原始文本:',
responseText.trim(),