1
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -291,6 +291,7 @@ export function RpgCreationResultActionBar({
|
||||
isPublishing={isPublishing}
|
||||
onClose={() => setShowPublishBlockersDialog(false)}
|
||||
onEditCover={() => {
|
||||
setShowPublishBlockersDialog(false);
|
||||
onOpenCoverEditor?.();
|
||||
}}
|
||||
onPublish={() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
45
src/data/customWorldLibrary.test.ts
Normal file
45
src/data/customWorldLibrary.test.ts
Normal 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('议会厅');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
40
src/prompts/customWorldPrompts.test.ts
Normal file
40
src/prompts/customWorldPrompts.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user