Persist custom world asset configs in runtime snapshots
This commit is contained in:
@@ -45,14 +45,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
imageClassName,
|
||||
playbackRate = 1,
|
||||
}) => {
|
||||
const [frameIndex, setFrameIndex] = useState(1);
|
||||
const config =
|
||||
character.animationMap?.[state] ??
|
||||
DEFAULT_ANIMATIONS[state] ??
|
||||
character.animationMap?.[AnimationState.IDLE] ??
|
||||
DEFAULT_ANIMATIONS[AnimationState.IDLE];
|
||||
const startFrame = config.startFrame ?? 1;
|
||||
const frameCount = config.frames;
|
||||
const startFrame =
|
||||
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
|
||||
? Math.max(1, Math.floor(config.startFrame))
|
||||
: 1;
|
||||
const [frameIndex, setFrameIndex] = useState(startFrame);
|
||||
const frameCount =
|
||||
typeof config.frames === 'number' && Number.isFinite(config.frames)
|
||||
? Math.max(1, Math.floor(config.frames))
|
||||
: 1;
|
||||
const fps =
|
||||
typeof config.fps === 'number' && Number.isFinite(config.fps)
|
||||
? Math.max(1, config.fps)
|
||||
@@ -72,26 +78,33 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
|
||||
fps,
|
||||
effectivePlaybackRate,
|
||||
].join('::');
|
||||
const endFrame = startFrame + frameCount - 1;
|
||||
const intervalDelay = Math.max(
|
||||
40,
|
||||
Math.round(1000 / (fps * effectivePlaybackRate)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFrameIndex(startFrame);
|
||||
setFrameIndex((current) => (current === startFrame ? current : startFrame));
|
||||
}, [animationSignature, startFrame]);
|
||||
|
||||
useEffect(() => {
|
||||
if (frameCount <= 1) return;
|
||||
|
||||
const endFrame = startFrame + frameCount - 1;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
setFrameIndex(prev => {
|
||||
return prev >= endFrame ? startFrame : prev + 1;
|
||||
setFrameIndex((current) => {
|
||||
if (current < startFrame || current > endFrame) {
|
||||
return startFrame;
|
||||
}
|
||||
return current >= endFrame ? startFrame : current + 1;
|
||||
});
|
||||
}, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate))));
|
||||
}, intervalDelay);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [
|
||||
animationSignature,
|
||||
effectivePlaybackRate,
|
||||
fps,
|
||||
endFrame,
|
||||
frameCount,
|
||||
intervalDelay,
|
||||
startFrame,
|
||||
]);
|
||||
|
||||
|
||||
@@ -286,8 +286,11 @@ export function CompanionCampModal({
|
||||
<div className="border-t border-white/10 px-5 py-4">
|
||||
<div className="mb-3 text-xs font-bold text-white">营地气氛</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{campMoments.map(moment => (
|
||||
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
|
||||
{campMoments.map((moment, index) => (
|
||||
<div
|
||||
key={`camp-moment-${index}-${moment}`}
|
||||
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"
|
||||
>
|
||||
{moment}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
@@ -184,6 +185,14 @@ function EmptyState({ title }: { title: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildFallbackRenderKey(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const normalizedValue = value?.trim();
|
||||
return normalizedValue ? normalizedValue : fallback;
|
||||
}
|
||||
|
||||
function NewBadge() {
|
||||
return (
|
||||
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
|
||||
@@ -349,6 +358,43 @@ function compactTextList(values: Array<string | null | undefined>) {
|
||||
return values.map((value) => value?.trim() ?? '').filter(Boolean);
|
||||
}
|
||||
|
||||
function buildPlayableRoleCardDescription(
|
||||
role: CustomWorldProfile['playableNpcs'][number],
|
||||
) {
|
||||
const summary =
|
||||
role.description.trim() ||
|
||||
role.backstoryReveal.publicSummary.trim() ||
|
||||
role.backstory.trim() ||
|
||||
role.motivation.trim();
|
||||
|
||||
return compactTextList([role.title || role.role, summary]).join(' / ');
|
||||
}
|
||||
|
||||
function resolvePlayableRolePreviewImage(
|
||||
role: CustomWorldProfile['playableNpcs'][number],
|
||||
previewCharacter: Character | null,
|
||||
) {
|
||||
if (previewCharacter?.portrait?.trim()) {
|
||||
return previewCharacter.portrait;
|
||||
}
|
||||
|
||||
if (previewCharacter?.avatar?.trim()) {
|
||||
return previewCharacter.avatar;
|
||||
}
|
||||
|
||||
if (role.imageSrc?.trim()) {
|
||||
return role.imageSrc;
|
||||
}
|
||||
|
||||
const template = role.templateCharacterId
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === role.templateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
|
||||
return template?.portrait ?? '';
|
||||
}
|
||||
|
||||
function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
@@ -1165,166 +1211,95 @@ export function CustomWorldEntityCatalog({
|
||||
progress={pendingGeneratedEntity.progress}
|
||||
/>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||||
{readOnly
|
||||
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
|
||||
: '可扮演角色支持新增、删除与更换外观模板。'}
|
||||
</div>
|
||||
{filteredPlayable.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
|
||||
) : (
|
||||
filteredPlayable.map((role) => {
|
||||
filteredPlayable.map((role, index) => {
|
||||
const previewCharacter =
|
||||
previewCharacterById.get(role.id) ?? null;
|
||||
const previewImageSrc = resolvePlayableRolePreviewImage(
|
||||
role,
|
||||
previewCharacter,
|
||||
);
|
||||
const description = buildPlayableRoleCardDescription(role);
|
||||
|
||||
return (
|
||||
<div key={role.id}>
|
||||
<Section
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
role.id,
|
||||
`playable-role-${index}-${role.name.trim() || 'unnamed'}`,
|
||||
)}
|
||||
className="space-y-2"
|
||||
>
|
||||
<CatalogCard
|
||||
title={role.name}
|
||||
subtitle={role.title}
|
||||
description={description || '暂无描述'}
|
||||
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
|
||||
actions={
|
||||
readOnly ? (
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
查看详情
|
||||
</SmallButton>
|
||||
isSelectionMode={false}
|
||||
isSelected={false}
|
||||
layout="compact"
|
||||
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
media={
|
||||
previewCharacter ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : previewImageSrc ? (
|
||||
<img
|
||||
src={previewImageSrc}
|
||||
alt={role.name}
|
||||
className="h-full w-full object-cover object-top"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<SmallButton
|
||||
onClick={() =>
|
||||
onEditTarget({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: role.id,
|
||||
})
|
||||
}
|
||||
tone="sky"
|
||||
>
|
||||
编辑
|
||||
</SmallButton>
|
||||
<SmallButton
|
||||
onClick={() => removePlayable(role.id, role.name)}
|
||||
tone="rose"
|
||||
>
|
||||
删除
|
||||
</SmallButton>
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
|
||||
{role.name.slice(0, 4) || '角色'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
|
||||
{previewCharacter ? (
|
||||
<CharacterAnimator
|
||||
state={AnimationState.RUN}
|
||||
character={previewCharacter}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 px-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
创作者锁定
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
|
||||
初始好感 {role.initialAffinity}
|
||||
</span>
|
||||
{role.generatedVisualAssetId ? (
|
||||
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
|
||||
已生成主图
|
||||
</span>
|
||||
) : null}
|
||||
{role.tags.slice(0, 2).map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{!readOnly ? (
|
||||
<div className="ml-auto">
|
||||
<SmallButton
|
||||
onClick={() => removePlayable(role.id, role.name)}
|
||||
tone="rose"
|
||||
>
|
||||
删除
|
||||
</SmallButton>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{lockedCharacterNames.has(role.name.trim()) ? (
|
||||
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
|
||||
创作者锁定角色
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm leading-6 text-zinc-300">
|
||||
{role.description}
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-6 text-zinc-400">
|
||||
{role.backstory}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
|
||||
公开背景:
|
||||
{role.backstoryReveal.publicSummary || '未填写'}
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
身份:{role.role}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
初始好感:{role.initialAffinity}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
性格:{role.personality}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
战斗:{role.combatStyle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
|
||||
动机:{role.motivation}
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
好感背景章节
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.backstoryReveal.chapters.map((chapter) => (
|
||||
<div
|
||||
key={`${role.id}-${chapter.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{chapter.affinityRequired} 好感 ·{' '}
|
||||
{chapter.title}:{chapter.teaser}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
技能
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.skills.map((skill) => (
|
||||
<div
|
||||
key={`${role.id}-${skill.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{skill.name} · {skill.style}:{skill.summary}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
|
||||
初始物品
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{role.initialItems.map((item) => (
|
||||
<div
|
||||
key={`${role.id}-${item.id}`}
|
||||
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
|
||||
>
|
||||
{item.name} x{item.quantity} · {item.category} ·{' '}
|
||||
{item.rarity}:{item.description}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{role.tags.map((tag) => (
|
||||
<span
|
||||
key={`${role.id}-${tag}`}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -1344,8 +1319,13 @@ export function CustomWorldEntityCatalog({
|
||||
{filteredStory.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景角色。" />
|
||||
) : (
|
||||
filteredStory.map((npc) => (
|
||||
<div key={npc.id}>
|
||||
filteredStory.map((npc, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
npc.id,
|
||||
`story-npc-${index}-${npc.name.trim() || 'unnamed'}`,
|
||||
)}
|
||||
>
|
||||
<CatalogCard
|
||||
title={npc.name}
|
||||
description={npc.description}
|
||||
@@ -1399,8 +1379,13 @@ export function CustomWorldEntityCatalog({
|
||||
{filteredSceneEntries.length === 0 ? (
|
||||
<EmptyState title="当前没有符合搜索条件的场景。" />
|
||||
) : (
|
||||
filteredSceneEntries.map((scene) => (
|
||||
<div key={scene.id}>
|
||||
filteredSceneEntries.map((scene, index) => (
|
||||
<div
|
||||
key={buildFallbackRenderKey(
|
||||
scene.id,
|
||||
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
|
||||
)}
|
||||
>
|
||||
<CatalogCard
|
||||
title={scene.name}
|
||||
description={
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -9,12 +10,32 @@ import type {
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../types';
|
||||
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
|
||||
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
|
||||
import {
|
||||
CustomWorldEntityEditorModal,
|
||||
type CustomWorldEditorTarget,
|
||||
} from './CustomWorldEntityEditorModal';
|
||||
|
||||
vi.mock('../data/characterPresets', async () => {
|
||||
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
|
||||
'../data/characterPresets',
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
buildCustomWorldPlayableCharacters: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./CharacterAnimator', () => ({
|
||||
CharacterAnimator: () => <div>角色预览</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../services/aiService', () => ({
|
||||
generateCustomWorldSceneImage: vi.fn(),
|
||||
generateCustomWorldSceneNpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./CustomWorldNpcVisualEditor', () => ({
|
||||
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
|
||||
<div>{npc.name}</div>
|
||||
@@ -136,6 +157,94 @@ function createProfile(): CustomWorldProfile {
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function createProfileWithLandmark(): CustomWorldProfile {
|
||||
return {
|
||||
...createProfile(),
|
||||
storyNpcs: [
|
||||
createStoryRole('story-1', '顾潮音'),
|
||||
createStoryRole('story-2', '闻雪汀'),
|
||||
createStoryRole('story-3', '谢孤灯'),
|
||||
],
|
||||
landmarks: [
|
||||
{
|
||||
id: 'landmark-1',
|
||||
name: '沉钟栈桥',
|
||||
description: '旧钟与潮声常年相撞的码头栈桥。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-scene.png',
|
||||
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
|
||||
connections: [],
|
||||
},
|
||||
],
|
||||
} as unknown as CustomWorldProfile;
|
||||
}
|
||||
|
||||
function LandmarkEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState(createProfileWithLandmark());
|
||||
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
|
||||
kind: 'landmark',
|
||||
mode: 'edit',
|
||||
id: 'landmark-1',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="landmarks"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={setTarget}
|
||||
onProfileChange={setProfile}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={profile}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CampEditorFlowHarness() {
|
||||
const [profile, setProfile] = useState({
|
||||
...createProfileWithLandmark(),
|
||||
camp: {
|
||||
name: '潮灯居',
|
||||
description: '玩家最初落脚的旧灯塔内院。',
|
||||
dangerLevel: 'medium',
|
||||
imageSrc: '/generated-custom-world-scenes/original-camp.png',
|
||||
},
|
||||
});
|
||||
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
|
||||
kind: 'camp',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={[]}
|
||||
activeTab="landmarks"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={setTarget}
|
||||
onProfileChange={setProfile}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={profile}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
onProfileChange={setProfile}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
test('playable角色打开AI工坊后不会自动关闭', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
@@ -179,3 +288,263 @@ test('场景角色打开AI工坊后不会自动关闭', async () => {
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('可扮演角色未修改时右上角关闭不会弹确认', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(handleClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('确认关闭')).toBeNull();
|
||||
});
|
||||
|
||||
test('可扮演角色修改后右上角关闭才弹确认', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('沈砺');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, '沈砺·改');
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('场景角色未修改时右上角关闭不会弹确认', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(handleClose).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('确认关闭')).toBeNull();
|
||||
});
|
||||
|
||||
test('场景角色修改后右上角关闭才弹确认', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleClose = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityEditorModal
|
||||
profile={createProfile()}
|
||||
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
|
||||
onClose={handleClose}
|
||||
onProfileChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByDisplayValue('顾潮音');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, '顾潮音·改');
|
||||
await user.click(screen.getByRole('button', { name: '关闭' }));
|
||||
|
||||
expect(handleClose).not.toHaveBeenCalled();
|
||||
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleEditTarget = vi.fn();
|
||||
|
||||
render(
|
||||
<CustomWorldEntityCatalog
|
||||
profile={createProfile()}
|
||||
previewCharacters={[]}
|
||||
activeTab="playable"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={handleEditTarget}
|
||||
onProfileChange={vi.fn()}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/公开背景/u)).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /沈砺/u }));
|
||||
|
||||
expect(handleEditTarget).toHaveBeenCalledWith({
|
||||
kind: 'playable',
|
||||
mode: 'edit',
|
||||
id: 'playable-1',
|
||||
});
|
||||
});
|
||||
|
||||
test('实体目录在空 id 列表项下不会触发重复 key 警告', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CustomWorldEntityCatalog
|
||||
profile={{
|
||||
...createProfile(),
|
||||
playableNpcs: [
|
||||
createPlayableRole('', '沈砺'),
|
||||
createPlayableRole('', '闻潮'),
|
||||
],
|
||||
}}
|
||||
previewCharacters={[]}
|
||||
activeTab="playable"
|
||||
onActiveTabChange={() => {}}
|
||||
onEditTarget={() => {}}
|
||||
onProfileChange={vi.fn()}
|
||||
onDeleteStoryNpcs={() => {}}
|
||||
onDeleteLandmarks={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /沈砺/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /闻潮/u })).toBeTruthy();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('场景图片保存后会同步更新编辑页和场景列表', async () => {
|
||||
const aiService = await import('../services/aiService');
|
||||
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
|
||||
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-scenes/updated-scene.png',
|
||||
assetId: 'asset-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-1',
|
||||
prompt: '更新后的场景图',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
const initialListImage = screen.getByRole('img', { name: '沉钟栈桥' });
|
||||
expect(initialListImage.getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/original-scene.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成' }));
|
||||
await waitFor(() => {
|
||||
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-scene.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
|
||||
const aiService = await import('../services/aiService');
|
||||
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
|
||||
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-scenes/updated-camp.png',
|
||||
assetId: 'asset-camp-1',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-camp-1',
|
||||
prompt: '更新后的开局场景图',
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CampEditorFlowHarness />);
|
||||
|
||||
const initialListImage = screen.getByRole('img', { name: '潮灯居' });
|
||||
expect(initialListImage.getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/original-camp.png',
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成' }));
|
||||
await waitFor(() => {
|
||||
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('智能生成:潮灯居')).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /保存修改/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
|
||||
'/generated-custom-world-scenes/updated-camp.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
|
||||
@@ -352,9 +352,29 @@ function syncLandmarksWithStoryNpcs(
|
||||
});
|
||||
}
|
||||
|
||||
function buildDraftSyncToken(value: unknown) {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized ?? 'undefined';
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function useDraft<T>(value: T) {
|
||||
const syncToken = useMemo(() => buildDraftSyncToken(value), [value]);
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => setDraft(value), [value]);
|
||||
const lastSyncedTokenRef = useRef(syncToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSyncedTokenRef.current === syncToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSyncedTokenRef.current = syncToken;
|
||||
setDraft(value);
|
||||
}, [syncToken, value]);
|
||||
|
||||
return [draft, setDraft] as const;
|
||||
}
|
||||
|
||||
@@ -421,6 +441,7 @@ function ModalShell({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
aria-label="关闭"
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
@@ -495,6 +516,7 @@ function CompactDialogShell({
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
aria-label="关闭"
|
||||
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
@@ -843,7 +865,7 @@ function ScenePresetPickerModal({
|
||||
const isSelected = src === selectedSrc;
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
key={`preset-image-${index}-${src || 'empty'}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(src);
|
||||
@@ -1666,7 +1688,7 @@ function SaveBar({
|
||||
showClose?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
||||
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.84)_42%,rgba(8,10,17,0.96)_100%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
|
||||
<div
|
||||
className={`flex flex-col gap-3 ${
|
||||
extraAction
|
||||
@@ -2813,7 +2835,11 @@ function CampSceneEditor({
|
||||
onSaveProfile: (profile: CustomWorldProfile) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useDraft(resolveCustomWorldCampScene(profile));
|
||||
const initialCampScene = useMemo(
|
||||
() => resolveCustomWorldCampScene(profile),
|
||||
[profile],
|
||||
);
|
||||
const [draft, setDraft] = useDraft(initialCampScene);
|
||||
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
|
||||
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
@@ -2833,8 +2859,8 @@ function CampSceneEditor({
|
||||
[draft],
|
||||
);
|
||||
const initialSnapshot = useMemo(
|
||||
() => JSON.stringify(resolveCustomWorldCampScene(profile)),
|
||||
[profile],
|
||||
() => JSON.stringify(initialCampScene),
|
||||
[initialCampScene],
|
||||
);
|
||||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||
const campSceneDraft = useMemo<CustomWorldLandmark>(
|
||||
@@ -3002,6 +3028,9 @@ function PlayableNpcEditor({
|
||||
const [draft, setDraft] = useDraft(npc);
|
||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||
const selectedTemplate =
|
||||
ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === draft.templateCharacterId,
|
||||
@@ -3027,6 +3056,10 @@ function PlayableNpcEditor({
|
||||
}));
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setIsCloseConfirmOpen(true);
|
||||
};
|
||||
|
||||
@@ -3300,6 +3333,9 @@ function StoryNpcEditor({
|
||||
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
|
||||
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
|
||||
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
|
||||
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
|
||||
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
|
||||
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
[...profile.playableNpcs, ...profile.storyNpcs]
|
||||
@@ -3319,6 +3355,10 @@ function StoryNpcEditor({
|
||||
}));
|
||||
|
||||
const handleRequestClose = () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setIsCloseConfirmOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||
}
|
||||
|
||||
function buildFallbackRenderKey(
|
||||
value: string | null | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const normalizedValue = value?.trim();
|
||||
return normalizedValue ? normalizedValue : fallback;
|
||||
}
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
anchorEntries = [],
|
||||
@@ -171,9 +179,9 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step) => (
|
||||
{steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
@@ -269,9 +277,12 @@ export function CustomWorldGenerationView({
|
||||
</div>
|
||||
{hasStructuredAnchors ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{anchorEntries.map((entry) => (
|
||||
{anchorEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
key={buildFallbackRenderKey(
|
||||
entry.id,
|
||||
`anchor-entry-${index}`,
|
||||
)}
|
||||
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
|
||||
>
|
||||
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -15,8 +15,8 @@ import { createPortal } from 'react-dom';
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
|
||||
import {
|
||||
AnimationState,
|
||||
type CharacterAnimationConfig,
|
||||
type Character,
|
||||
type CharacterAnimationConfig,
|
||||
} from '../types';
|
||||
import {
|
||||
buildAnimationClipFromVideoSource,
|
||||
@@ -358,6 +358,37 @@ function buildRoleCharacterBrief(
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function isLegacyGeneratedVisualDescription(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'2D 横版 RPG',
|
||||
'纯绿色绿幕',
|
||||
'2 到 2.5 头身',
|
||||
'深色粗轮廓',
|
||||
'身体整体朝右',
|
||||
'脚底完整可见',
|
||||
].some((marker) => normalized.includes(marker));
|
||||
}
|
||||
|
||||
function isLegacyGeneratedActionDescription(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [
|
||||
'动作气质参考:',
|
||||
'发力起手明确',
|
||||
'收招利落',
|
||||
'动作表现偏向',
|
||||
'起手克制',
|
||||
].some((marker) => normalized.includes(marker));
|
||||
}
|
||||
|
||||
function mergeRole<T extends EditableCustomWorldRole>(
|
||||
role: T,
|
||||
patch: Partial<T>,
|
||||
@@ -712,10 +743,16 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
});
|
||||
setWorkingRole(nextRole);
|
||||
setVisualPromptText(
|
||||
cache.visualPromptText || initialPromptBundle.visualPromptText,
|
||||
cache.visualPromptText &&
|
||||
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
|
||||
? cache.visualPromptText
|
||||
: initialPromptBundle.visualPromptText,
|
||||
);
|
||||
setAnimationPromptText(
|
||||
cache.animationPromptText || initialPromptBundle.animationPromptText,
|
||||
cache.animationPromptText &&
|
||||
!isLegacyGeneratedActionDescription(cache.animationPromptText)
|
||||
? cache.animationPromptText
|
||||
: initialPromptBundle.animationPromptText,
|
||||
);
|
||||
setVisualDrafts(cache.visualDrafts ?? []);
|
||||
setSelectedVisualDraftId(
|
||||
@@ -904,6 +941,8 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
}
|
||||
|
||||
const isLoopAction = config.loop;
|
||||
const shouldUseLastFrameReference =
|
||||
!isLoopAction && config.animation !== AnimationState.DIE;
|
||||
|
||||
const result = await generateCharacterAnimationDraft({
|
||||
characterId: workingRole.id,
|
||||
@@ -915,7 +954,9 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
visualSource: workingRole.imageSrc,
|
||||
referenceImageDataUrls: [],
|
||||
referenceVideoDataUrls: [],
|
||||
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
|
||||
lastFrameImageDataUrl: shouldUseLastFrameReference
|
||||
? workingRole.imageSrc
|
||||
: undefined,
|
||||
frameCount: config.frameCount,
|
||||
fps: config.fps,
|
||||
durationSeconds: config.durationSeconds,
|
||||
@@ -1108,12 +1149,12 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="形象提示词">
|
||||
<Field label="形象描述">
|
||||
<TextArea
|
||||
value={visualPromptText}
|
||||
onChange={setVisualPromptText}
|
||||
rows={6}
|
||||
placeholder="角色形象提示词会先按设定自动生成,也可以继续手动细化。"
|
||||
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -1296,12 +1337,12 @@ export function CustomWorldRoleAssetStudioModal({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Field label="动作提示词">
|
||||
<Field label="动作描述">
|
||||
<TextArea
|
||||
value={animationPromptText}
|
||||
onChange={setAnimationPromptText}
|
||||
rows={5}
|
||||
placeholder="角色动作提示词会先按设定自动生成,也可以继续手动细化。"
|
||||
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
|
||||
|
||||
describe('buildDefaultRolePromptBundle', () => {
|
||||
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
|
||||
it('uses model-generated role descriptions directly', () => {
|
||||
const result = buildDefaultRolePromptBundle({
|
||||
name: '沈砺',
|
||||
title: '灰炬向导',
|
||||
@@ -28,7 +28,7 @@ describe('buildDefaultRolePromptBundle', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
|
||||
it('falls back to existing entity descriptions without assembling new rules', () => {
|
||||
const result = buildDefaultRolePromptBundle({
|
||||
name: '顾潮音',
|
||||
title: '港口守望者',
|
||||
@@ -41,11 +41,11 @@ describe('buildDefaultRolePromptBundle', () => {
|
||||
tags: ['潮雾港', '守望', '旧案'],
|
||||
});
|
||||
|
||||
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
|
||||
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
|
||||
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
|
||||
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
|
||||
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
|
||||
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
|
||||
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
|
||||
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
|
||||
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
|
||||
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
|
||||
expect(result.visualPromptText).not.toContain('提示词');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,52 +23,35 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
|
||||
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
|
||||
return parts
|
||||
.map((item) => cleanSeedText(item, maxLength))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.slice(0, maxLength);
|
||||
function pickFirstDescription(
|
||||
values: Array<string | undefined>,
|
||||
maxLength: number,
|
||||
) {
|
||||
for (const value of values) {
|
||||
const normalized = cleanSeedText(value, maxLength);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildDefaultRolePromptBundle(
|
||||
role: PromptDefaultRole,
|
||||
): CustomWorldRolePromptBundle {
|
||||
const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
const fallbackVisualDescription = compactDescription(
|
||||
[
|
||||
roleLabel || cleanSeedText(role.role, 40),
|
||||
role.description,
|
||||
role.personality,
|
||||
role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
|
||||
],
|
||||
220,
|
||||
);
|
||||
const fallbackActionDescription = compactDescription(
|
||||
[
|
||||
role.actionDescription,
|
||||
role.combatStyle,
|
||||
role.motivation,
|
||||
role.personality,
|
||||
],
|
||||
180,
|
||||
);
|
||||
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
|
||||
const fallbackSceneDescription = compactDescription(
|
||||
[
|
||||
role.backstory,
|
||||
role.description,
|
||||
role.motivation,
|
||||
],
|
||||
220,
|
||||
);
|
||||
|
||||
return {
|
||||
visualPromptText:
|
||||
cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
|
||||
animationPromptText: fallbackActionDescription,
|
||||
scenePromptText: generatedSceneDescription || fallbackSceneDescription,
|
||||
visualPromptText: pickFirstDescription(
|
||||
[role.visualDescription, role.description],
|
||||
220,
|
||||
),
|
||||
animationPromptText: pickFirstDescription(
|
||||
[role.actionDescription, role.combatStyle],
|
||||
180,
|
||||
),
|
||||
scenePromptText: pickFirstDescription(
|
||||
[role.sceneVisualDescription, role.backstory],
|
||||
220,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { expect, test } from 'vitest';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('clarification panel shows pending questions and ready state', () => {
|
||||
const pendingHtml = renderToStaticMarkup(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
@@ -44,3 +51,48 @@ test('clarification panel shows pending questions and ready state', () => {
|
||||
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
|
||||
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
|
||||
});
|
||||
|
||||
test('falls back to stable keys when clarification ids are empty', () => {
|
||||
const consoleErrorSpy = vi
|
||||
.spyOn(console, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<CustomWorldAgentClarificationPanel
|
||||
readiness={{
|
||||
isReady: false,
|
||||
completedKeys: [],
|
||||
missingKeys: ['player_premise', 'core_conflict'],
|
||||
}}
|
||||
pendingClarifications={[
|
||||
{
|
||||
id: '',
|
||||
label: '玩家身份与开局',
|
||||
question: '玩家是谁,故事开场时卡在什么处境里?',
|
||||
targetKey: 'player_premise',
|
||||
priority: 2,
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
label: '核心冲突',
|
||||
question: '第一阶段最直接撞上的冲突是什么?',
|
||||
targetKey: 'core_conflict',
|
||||
priority: 1,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy();
|
||||
expect(screen.getByText(/核心冲突/u)).toBeTruthy();
|
||||
|
||||
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
|
||||
call.some(
|
||||
(arg) =>
|
||||
typeof arg === 'string' &&
|
||||
arg.includes('Encountered two children with the same key'),
|
||||
),
|
||||
);
|
||||
|
||||
expect(duplicateKeyCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ export function CustomWorldAgentClarificationPanel({
|
||||
<div className="mt-4 space-y-2">
|
||||
{pendingClarifications.slice(0, 3).map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
|
||||
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
} from '../../services/customWorldAgentUiState';
|
||||
import {
|
||||
buildCustomWorldCreatorIntentFoundationText,
|
||||
buildCustomWorldCreatorIntentGenerationText,
|
||||
} from '../../services/customWorldCreatorIntent';
|
||||
import {
|
||||
clearPlatformBrowseHistory,
|
||||
@@ -151,14 +150,6 @@ function buildOptimisticAgentMessage(
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
|
||||
return (
|
||||
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
|
||||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
|
||||
profile.settingText.trim()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
|
||||
const foundationText = buildCustomWorldCreatorIntentFoundationText(
|
||||
profile.creatorIntent,
|
||||
@@ -784,29 +775,6 @@ export function PreGameSelectionFlow({
|
||||
}
|
||||
};
|
||||
|
||||
const continueWorldInAgent = async (
|
||||
profile = generatedCustomWorldProfile,
|
||||
) => {
|
||||
if (!profile || isCreatingAgentSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAgentDraftResultView && activeAgentSessionId) {
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldAutoSaveState('idle');
|
||||
setAgentDraftGenerationStartedAt(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setPlatformTab('create');
|
||||
setSelectionStage('agent-workspace');
|
||||
return;
|
||||
}
|
||||
|
||||
await openRpgAgentWorkspace(buildAgentSeedTextFromProfile(profile));
|
||||
};
|
||||
|
||||
const submitAgentMessage = async (
|
||||
payload: SendCustomWorldAgentMessageRequest,
|
||||
) => {
|
||||
@@ -961,10 +929,6 @@ export function PreGameSelectionFlow({
|
||||
openCreationTypePicker();
|
||||
};
|
||||
|
||||
const editCustomWorldSetting = () => {
|
||||
void continueWorldInAgent();
|
||||
};
|
||||
|
||||
const openLibraryDetail = (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
) => {
|
||||
@@ -1441,9 +1405,7 @@ export function PreGameSelectionFlow({
|
||||
? leaveAgentDraftResult
|
||||
: leaveCustomWorldResult
|
||||
}
|
||||
onEditSetting={
|
||||
isAgentDraftResultView ? undefined : editCustomWorldSetting
|
||||
}
|
||||
onEditSetting={undefined}
|
||||
onRegenerate={undefined}
|
||||
onContinueExpand={undefined}
|
||||
onEnterWorld={() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
getRuntimeSessionId,
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
type RuntimeStoryChoicePayload,
|
||||
type RuntimeStoryResponse,
|
||||
} from '../../services/runtimeStoryService';
|
||||
@@ -38,10 +38,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
|
||||
const response = await getRuntimeStoryState(
|
||||
getRuntimeSessionId(params.gameState),
|
||||
);
|
||||
const options = buildStoryMomentFromRuntimeOptions({
|
||||
storyText: response.presentation.storyText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: params.gameState,
|
||||
const options = resolveRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: response.snapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText: response.presentation.storyText,
|
||||
}).options;
|
||||
|
||||
return options.length > 0 ? options : null;
|
||||
@@ -70,14 +71,15 @@ export async function resumeServerRuntimeStory(
|
||||
const runtimeOptions = getRuntimeResponseOptions(response);
|
||||
const nextStory =
|
||||
response.presentation.storyText || runtimeOptions.length > 0
|
||||
? buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
? resolveRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot: resumedSnapshot,
|
||||
fallbackGameState: hydratedSnapshot.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
resumedSnapshot.currentStory?.text ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
'',
|
||||
options: runtimeOptions,
|
||||
gameState: resumedSnapshot.gameState,
|
||||
})
|
||||
: resumedSnapshot.currentStory;
|
||||
|
||||
@@ -111,13 +113,14 @@ export async function resolveServerRuntimeChoice(params: {
|
||||
return {
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
nextStory: buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
nextStory: resolveRuntimeStoryMoment({
|
||||
response,
|
||||
hydratedSnapshot,
|
||||
fallbackGameState: params.gameState,
|
||||
fallbackStoryText:
|
||||
response.presentation.storyText ||
|
||||
hydratedSnapshot.currentStory?.text ||
|
||||
params.option.actionText,
|
||||
options: getRuntimeResponseOptions(response),
|
||||
gameState: hydratedSnapshot.gameState,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('./apiClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { AnimationState } from '../types';
|
||||
import {
|
||||
buildStoryMomentFromRuntimeOptions,
|
||||
getRuntimeClientVersion,
|
||||
@@ -19,9 +20,9 @@ import {
|
||||
isServerRuntimeFunctionId,
|
||||
isTask5RuntimeFunctionId,
|
||||
resolveRuntimeStoryAction,
|
||||
resolveRuntimeStoryMoment,
|
||||
shouldUseServerRuntimeOptions,
|
||||
} from './runtimeStoryService';
|
||||
import { AnimationState } from '../types';
|
||||
|
||||
describe('runtimeStoryService', () => {
|
||||
beforeEach(() => {
|
||||
@@ -246,4 +247,99 @@ describe('runtimeStoryService', () => {
|
||||
action: 'trade',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
|
||||
const story = resolveRuntimeStoryMoment({
|
||||
response: {
|
||||
sessionId: 'runtime-main',
|
||||
serverVersion: 4,
|
||||
viewModel: {
|
||||
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
|
||||
encounter: null,
|
||||
companions: [],
|
||||
availableOptions: [],
|
||||
status: {
|
||||
inBattle: false,
|
||||
npcInteractionActive: true,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
},
|
||||
},
|
||||
presentation: {
|
||||
actionText: '继续交谈',
|
||||
resultText: '后端已结算',
|
||||
storyText: '普通文本',
|
||||
options: [],
|
||||
battle: null,
|
||||
toast: null,
|
||||
},
|
||||
patches: [],
|
||||
snapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
hydratedSnapshot: {
|
||||
version: 2,
|
||||
savedAt: '2026-04-08T00:00:00.000Z',
|
||||
bottomTab: 'adventure',
|
||||
gameState: {} as never,
|
||||
currentStory: {
|
||||
text: '你:先把话说开。\n梁伯:那我就直说了。',
|
||||
options: [],
|
||||
displayMode: 'dialogue',
|
||||
dialogue: [
|
||||
{ speaker: 'player', text: '先把话说开。' },
|
||||
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
|
||||
],
|
||||
deferredOptions: [
|
||||
{
|
||||
functionId: 'npc_chat',
|
||||
actionText: '继续交谈',
|
||||
text: '继续交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
fallbackStoryText: '普通文本',
|
||||
});
|
||||
|
||||
expect(story.displayMode).toBe('dialogue');
|
||||
expect(story.deferredOptions).toHaveLength(1);
|
||||
expect(story.text).toContain('梁伯');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,6 +168,45 @@ export function buildStoryMomentFromRuntimeOptions(params: {
|
||||
} satisfies StoryMoment;
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotStory(story: StoryMoment | null) {
|
||||
return Boolean(
|
||||
story &&
|
||||
(
|
||||
story.displayMode === 'dialogue' ||
|
||||
story.deferredOptions?.length ||
|
||||
story.dialogue?.length
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRuntimeStoryMoment(params: {
|
||||
response: RuntimeStoryResponse;
|
||||
hydratedSnapshot: HydratedSavedGameSnapshot;
|
||||
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
|
||||
fallbackStoryText?: string;
|
||||
}) {
|
||||
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
|
||||
return params.hydratedSnapshot.currentStory!;
|
||||
}
|
||||
|
||||
const options =
|
||||
params.response.viewModel.availableOptions.length > 0
|
||||
? params.response.viewModel.availableOptions
|
||||
: params.response.presentation.options;
|
||||
|
||||
return buildStoryMomentFromRuntimeOptions({
|
||||
storyText:
|
||||
params.response.presentation.storyText ||
|
||||
params.hydratedSnapshot.currentStory?.text ||
|
||||
params.fallbackStoryText ||
|
||||
'',
|
||||
options,
|
||||
gameState: params.hydratedSnapshot.gameState.currentEncounter
|
||||
? params.hydratedSnapshot.gameState
|
||||
: params.fallbackGameState,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(
|
||||
sessionId: string,
|
||||
options: RuntimeStoryServiceOptions = {},
|
||||
|
||||
Reference in New Issue
Block a user