1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 21:06:48 +08:00
parent 1c72066bab
commit 75944b1f1f
102 changed files with 9648 additions and 1540 deletions

View File

@@ -27,13 +27,13 @@ function createCharacter(): Character {
} as Character;
}
test('adventure panel treats negative affinity updates as relationship change system messages', () => {
test('adventure panel renders system turns without special relationship labels', () => {
const currentStory: StoryMoment = {
text: '你们的语气忽然冷了下来。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '柳无声', text: '这件事你最好别再追问。' },
{ speaker: 'system', text: '关系转冷 好感 -2', affinityDelta: -2 },
{ speaker: 'system', text: '这轮交谈先在这里收束。' },
],
options: [],
};
@@ -102,8 +102,9 @@ test('adventure panel treats negative affinity updates as relationship change sy
/>,
);
expect(html).toContain('关系变化');
expect(html).toContain('关系转冷 好感 -2');
expect(html).toContain('系统');
expect(html).toContain('这轮交谈先在这里收束。');
expect(html).not.toContain('关系变化');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {

View File

@@ -157,9 +157,13 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
dialogue: [
{ speaker: 'player', text: '你刚才那句话是什么意思?' },
{ speaker: 'npc', speakerName: '柳无声', text: '意思是这件事还没结束。' },
{ speaker: 'system', text: '关系升温 好感 +3', affinityDelta: 3 },
],
options: [optionA, optionB, optionC],
npcAffinityEffect: {
eventId: 'effect-liu-1',
npcId: 'npc-liu',
delta: 3,
},
npcChatState: {
npcId: 'npc-liu',
npcName: '柳无声',
@@ -178,6 +182,7 @@ test('adventure panel shows npc chat custom input and exit button in chat mode',
expect(html).toContain('输入你想对 TA 说的话');
expect(html).toContain('发送');
expect(html).not.toContain('换一换');
expect(html).not.toContain('关系升温');
});
test('adventure panel hides custom input and shows quest offer actions during npc quest offer mode', () => {
@@ -243,3 +248,19 @@ test('adventure panel hides custom input and shows quest offer actions during np
expect(html).not.toContain('发送');
expect(html).not.toContain('输入你想对 TA 说的话');
});
test('adventure panel renders narrative story text without italics and hides option detail text', () => {
const option = createOption('idle_observe_signs', '观察风里残下的痕迹');
option.detailText = '这段说明不应该继续出现在 UI 里。';
const currentStory: StoryMoment = {
text: '风从桥洞里灌过来,你把注意力重新放回脚下与前路。',
options: [option],
};
const html = renderPanel(currentStory, [option]);
expect(html).toContain('font-serif');
expect(html).not.toContain('italic');
expect(html).toContain('text-[15px]');
expect(html).not.toContain('这段说明不应该继续出现在 UI 里。');
});

View File

@@ -174,9 +174,7 @@ function getDialogueTurnBubbleClass(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return turn.affinityDelta && turn.affinityDelta > 0
? 'border-rose-400/30 bg-rose-500/12 text-rose-50'
: 'border-white/12 bg-white/[0.06] text-zinc-100';
return 'border-white/12 bg-white/[0.06] text-zinc-100';
}
if (turn.speaker === 'player') {
@@ -212,7 +210,7 @@ function getDialogueTurnLabel(
turn: NonNullable<StoryMoment['dialogue']>[number],
) {
if (turn.speaker === 'system') {
return typeof turn.affinityDelta === 'number' ? '关系变化' : '系统';
return '系统';
}
if (turn.speaker === 'player') {
@@ -1107,7 +1105,7 @@ export function AdventurePanel({
)}
</div>
) : (
<p className="font-serif text-sm italic leading-relaxed text-zinc-300">
<p className="font-serif text-[15px] leading-7 text-zinc-200 sm:text-base">
{currentStory.text}
</p>
)}
@@ -1192,9 +1190,6 @@ export function AdventurePanel({
hasDeferredAdventureOptions &&
isContinueAdventureOption(option);
const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason
? option.disabledReason
: getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
@@ -1210,7 +1205,7 @@ export function AdventurePanel({
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1237,7 +1232,7 @@ export function AdventurePanel({
>
<div className="flex items-center justify-between">
<span
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
className={`text-sm sm:text-[15px] ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
@@ -1246,11 +1241,6 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div
className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}

View File

@@ -20,7 +20,13 @@ import {
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import { AnimationState, Character, CustomWorldProfile } from '../types';
import {
AnimationState,
Character,
CustomWorldProfile,
type SceneActBlueprint,
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal';
@@ -233,6 +239,104 @@ function PendingEntityCard({
);
}
function resolveSceneEntrySceneChapters(params: {
sceneChapters: CustomWorldProfile['sceneChapterBlueprints'];
sceneId: string;
sceneName: string;
}) {
const sceneChapters = params.sceneChapters ?? [];
const normalizedSceneId = params.sceneId.trim();
const normalizedSceneName = params.sceneName.trim();
const directMatches = sceneChapters.filter(
(chapter) => chapter.sceneId.trim() === normalizedSceneId,
);
if (directMatches.length > 0) {
return directMatches;
}
const linkedMatches = sceneChapters.filter((chapter) =>
chapter.linkedLandmarkIds.some(
(landmarkId) => landmarkId.trim() === normalizedSceneId,
),
);
if (linkedMatches.length > 0) {
return linkedMatches;
}
return sceneChapters.filter((chapter) => {
const chapterTitle = chapter.title.trim();
return (
chapterTitle === normalizedSceneName ||
chapter.summary.includes(normalizedSceneName) ||
chapter.acts.some(
(act) =>
act.title.includes(normalizedSceneName) ||
act.summary.includes(normalizedSceneName),
)
);
});
}
function buildSceneActParticipantText(
act: SceneActBlueprint,
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
const primaryRoleName = roleById.get(act.primaryNpcId)?.name?.trim() || '';
const supportRoleNames = act.encounterNpcIds
.filter((roleId) => roleId !== act.primaryNpcId)
.map((roleId) => roleById.get(roleId)?.name?.trim() || '')
.filter(Boolean);
return compactTextList([
primaryRoleName ? `主角色:${primaryRoleName}` : '',
supportRoleNames.length > 0
? `相遇角色:${supportRoleNames.join('、')}`
: '',
]).join('');
}
function buildSceneChapterSearchText(
sceneChapters: SceneChapterBlueprint[],
roleById: Map<
string,
| CustomWorldProfile['playableNpcs'][number]
| CustomWorldProfile['storyNpcs'][number]
>,
) {
return sceneChapters
.flatMap((chapter) => [
chapter.title,
chapter.summary,
...chapter.acts.flatMap((act) => [
act.title,
act.summary,
act.actGoal,
act.transitionHook,
buildSceneActParticipantText(act, roleById),
]),
])
.filter(Boolean)
.join(' ');
}
function resolveSceneCardImage(params: {
sceneImageSrc?: string | null;
sceneChapters: SceneChapterBlueprint[];
}) {
const firstActImageSrc =
params.sceneChapters
.flatMap((chapter) => chapter.acts)
.map((act) => act.backgroundImageSrc?.trim() || '')
.find(Boolean) || '';
return firstActImageSrc || params.sceneImageSrc?.trim() || '';
}
function CatalogCard({
title,
description,
@@ -370,6 +474,10 @@ function resolvePlayableRolePreviewImage(
role: CustomWorldProfile['playableNpcs'][number],
previewCharacter: Character | null,
) {
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
if (previewCharacter?.portrait?.trim()) {
return previewCharacter.portrait;
}
@@ -378,10 +486,6 @@ function resolvePlayableRolePreviewImage(
return previewCharacter.avatar;
}
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
@@ -796,6 +900,16 @@ export function CustomWorldEntityCatalog({
() => new Map(profile.storyNpcs.map((npc) => [npc.id, npc])),
[profile.storyNpcs],
);
const roleById = useMemo(
() =>
new Map(
[...profile.playableNpcs, ...profile.storyNpcs].map((role) => [
role.id,
role,
]),
),
[profile.playableNpcs, profile.storyNpcs],
);
const landmarkById = useMemo(
() => new Map(profile.landmarks.map((landmark) => [landmark.id, landmark])),
[profile.landmarks],
@@ -876,22 +990,53 @@ export function CustomWorldEntityCatalog({
[profile.creatorIntent],
);
const filteredSceneEntries = useMemo(() => {
const openingSceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: resolvedCampScene.id,
sceneName: resolvedCampScene.name,
});
const openingSceneEntry = {
id: 'custom-world-opening-scene',
id: resolvedCampScene.id,
kind: 'camp' as const,
name: resolvedCampScene.name,
description: resolvedCampScene.description,
imageSrc: resolvedCampImageSrc,
searchText: buildOpeningSceneSearchText(profile, resolvedCampScene),
imageSrc: resolveSceneCardImage({
sceneImageSrc: resolvedCampImageSrc,
sceneChapters: openingSceneChapters,
}),
sceneChapters: openingSceneChapters,
searchText: [
buildOpeningSceneSearchText(profile, resolvedCampScene),
buildSceneChapterSearchText(openingSceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
const landmarkEntries = filteredLandmarks.map((landmark) => ({
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
searchText: buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
}));
const landmarkEntries = profile.landmarks.map((landmark) => {
const sceneChapters = resolveSceneEntrySceneChapters({
sceneChapters: profile.sceneChapterBlueprints,
sceneId: landmark.id,
sceneName: landmark.name,
});
return {
id: landmark.id,
kind: 'landmark' as const,
name: landmark.name,
description: landmark.description,
imageSrc: resolveSceneCardImage({
sceneImageSrc: landmarkImageById.get(landmark.id) ?? landmark.imageSrc,
sceneChapters,
}),
sceneChapters,
searchText: [
buildLandmarkSearchText(landmark, storyNpcById, landmarkById),
buildSceneChapterSearchText(sceneChapters, roleById),
]
.filter(Boolean)
.join(' '),
};
});
const recentEntries = landmarkEntries.filter((entry) =>
recentLandmarkIdSet.has(entry.id),
);
@@ -909,13 +1054,13 @@ export function CustomWorldEntityCatalog({
);
}, [
deferredSearch,
filteredLandmarks,
landmarkById,
landmarkImageById,
profile,
recentLandmarkIdSet,
resolvedCampImageSrc,
resolvedCampScene,
roleById,
storyNpcById,
]);
@@ -1281,7 +1426,13 @@ export function CustomWorldEntityCatalog({
})
}
media={
previewCharacter ? (
role.imageSrc?.trim() ? (
<img
src={role.imageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
@@ -1414,51 +1565,48 @@ export function CustomWorldEntityCatalog({
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredSceneEntries.map((scene, index) => (
<div
<CatalogCard
key={buildFallbackRenderKey(
scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
>
<CatalogCard
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
selectedBulkIds.includes(scene.id)
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'camp' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({
kind: 'landmark',
mode: 'edit',
id: scene.id,
})
}
media={
<ImageFrame
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape"
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
</div>
title={scene.name}
description={
scene.kind === 'camp'
? `开局场景 · ${scene.description}`
: scene.description
}
badge={
scene.kind === 'landmark' && recentLandmarkIdSet.has(scene.id) ? (
<NewBadge />
) : null
}
isSelectionMode={scene.kind === 'landmark' && isBulkDeleteMode}
isSelected={
scene.kind === 'landmark' &&
selectedBulkIds.includes(scene.id)
}
onClick={() =>
scene.kind === 'camp'
? onEditTarget({ kind: 'camp' })
: isBulkDeleteMode
? toggleBulkSelected(scene.id)
: onEditTarget({
kind: 'landmark',
mode: 'edit',
id: scene.id,
})
}
media={
<ImageFrame
src={scene.imageSrc}
alt={scene.name}
fallbackLabel={scene.name.slice(0, 4) || '场景'}
tone="landscape"
/>
}
disabled={scene.kind === 'camp' && isBulkDeleteMode}
/>
))
)}
</div>

View File

@@ -15,6 +15,7 @@ import {
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
@@ -65,10 +66,6 @@ vi.mock('./game-shell/GameShellRuntime', () => ({
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
visualPromptText: '自动生成的形象提示词',
animationPromptText: '自动生成的动作提示词',
}),
saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined),
generateCharacterVisualCandidates: vi.fn(),
publishCharacterVisualAsset: vi.fn(),
@@ -76,6 +73,11 @@ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
publishCharacterAnimationAssets: vi.fn(),
}));
vi.mock('../services/customWorldCoverAssetService', () => ({
generateCustomWorldCoverImage: vi.fn(),
uploadCustomWorldCoverImage: vi.fn(),
}));
function createBackstoryReveal() {
return {
publicSummary: '公开背景',
@@ -261,10 +263,19 @@ function CampEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
camp: {
id: 'custom-scene-camp',
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-camp.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
@@ -273,6 +284,9 @@ function CampEditorFlowHarness() {
return (
<>
<pre data-testid="camp-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
@@ -293,6 +307,44 @@ function CampEditorFlowHarness() {
);
}
function CoverEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
cover: {
sourceType: 'default',
imageSrc: null,
characterRoleIds: ['playable-1'],
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
kind: 'cover',
});
return (
<>
<pre data-testid="cover-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function readCoverHarnessProfile() {
const content = screen.getByTestId('cover-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
function readCampHarnessProfile() {
const content = screen.getByTestId('camp-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
test('playable角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
@@ -506,6 +558,14 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
'/generated-custom-world-scenes/original-scene.png',
);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
@@ -523,22 +583,29 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () =>
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
const savedProfile = readLandmarkHarnessProfile();
expect(savedProfile.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
@@ -562,6 +629,14 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
'/generated-custom-world-scenes/original-camp.png',
);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
@@ -579,22 +654,80 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
expect(screen.getByRole('img', { name: '第1幕背景预览' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
const savedProfile = readCampHarnessProfile();
expect(savedProfile.camp?.imageSrc).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
test('开局场景在场景配置面板中与普通场景使用同级参数并可保存', async () => {
const user = userEvent.setup();
render(<CampEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
expect(screen.getByText('场景连接关系')).toBeTruthy();
expect(screen.queryByText('场景图片')).toBeNull();
expect(screen.queryByText('场景内 NPC')).toBeNull();
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
const firstActCard = getSceneActCard(0);
await user.click(within(firstActCard).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:潮灯居')).toBeNull();
});
const savedProfile = readCampHarnessProfile();
const openingSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'custom-scene-camp',
);
expect(savedProfile.camp?.sceneNpcIds).toHaveLength(3);
expect(savedProfile.camp?.sceneNpcIds).toEqual(
expect.arrayContaining(['story-1', 'story-2', 'story-3']),
);
expect(savedProfile.camp?.connections).toEqual([
{
targetLandmarkId: 'landmark-1',
relativePosition: 'north',
summary: '北侧通往沉钟栈桥。',
},
]);
expect(openingSceneChapter).toBeTruthy();
expect(openingSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(openingSceneChapter?.linkedLandmarkIds).toContain('custom-scene-camp');
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
@@ -636,7 +769,7 @@ test('场景编辑器会在场景内展示槽位化多幕配置并保存', async
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
@@ -679,7 +812,7 @@ test('场景多幕支持新增删除和调序', async () => {
await waitFor(() => {
expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
@@ -721,3 +854,83 @@ test('场景幕预览会打开当前幕运行时面板', async () => {
expect(screen.queryByText('幕预览运行时')).toBeNull();
});
});
test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async () => {
const uploadMock = vi
.mocked(customWorldCoverAssetService.uploadCustomWorldCoverImage)
.mockResolvedValue({
imageSrc: '/generated-custom-world-covers/world-1/uploaded/cover.webp',
assetId: 'custom-cover-upload-1',
sourceType: 'uploaded',
});
class MockFileReader {
result: string | null = null;
error: Error | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = 1920;
naturalHeight = 1080;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
const user = userEvent.setup();
render(<CoverEditorFlowHarness />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).toBeTruthy();
if (!input) {
throw new Error('未找到封面上传输入框');
}
const file = new File(['cover'], 'cover.png', { type: 'image/png' });
await user.upload(input, file);
await waitFor(() => {
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '确认裁剪并上传' }));
await waitFor(() => {
expect(uploadMock).toHaveBeenCalledTimes(1);
});
const uploadPayload = uploadMock.mock.calls[0]?.[0];
expect(uploadPayload?.worldName).toBe('潮雾群岛');
expect(uploadPayload?.cropRect.width).toBeGreaterThan(0);
expect(uploadPayload?.cropRect.height).toBeGreaterThan(0);
await waitFor(() => {
expect(screen.queryByText('裁剪上传封面')).toBeNull();
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑作品封面')).toBeNull();
});
const savedProfile = readCoverHarnessProfile();
expect(savedProfile.cover?.sourceType).toBe('uploaded');
expect(savedProfile.cover?.imageSrc).toBe(
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,11 +14,11 @@ interface CustomWorldGenerationViewProps {
onRetry: () => void;
onInterrupt?: () => void;
backLabel?: string;
settingActionLabel?: string;
settingActionLabel?: string | null;
retryLabel?: string;
interruptLabel?: string;
settingTitle?: string;
settingDescription?: string;
settingDescription?: string | null;
progressTitle?: string;
activeBadgeLabel?: string;
pausedBadgeLabel?: string;
@@ -80,6 +80,11 @@ export function CustomWorldGenerationView({
const progressValue = getProgressPercentage(progress);
const steps = progress?.steps ?? [];
const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
@@ -207,13 +212,15 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{settingActionLabel}
</button>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{normalizedSettingActionLabel}
</button>
) : null}
<button
type="button"
onClick={onRetry}
@@ -240,18 +247,22 @@ export function CustomWorldGenerationView({
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle}
</div>
<div className="mt-1 text-sm text-zinc-400">
{settingDescription}
</div>
{hasSettingDescription ? (
<div className="mt-1 text-sm text-zinc-400">
{normalizedSettingDescription}
</div>
) : null}
</div>
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{settingActionLabel}
</button>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{normalizedSettingActionLabel}
</button>
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">

View File

@@ -220,6 +220,33 @@ const baseProfile = {
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '沉钟栈桥章节',
summary: '围绕沉钟栈桥推进的三幕结构。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
sceneId: 'landmark-1',
title: '潮声逼近',
summary: '第一幕先把潮声与旧钟压上来。',
stageCoverage: ['opening'],
backgroundImageSrc: '/generated-custom-world-scenes/scene-act-1.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '接住首幕压力',
transitionHook: '继续逼近钟楼深处。',
},
],
},
],
creatorIntent: null,
anchorPack: null,
lockState: null,
@@ -278,7 +305,7 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar
);
await waitFor(() => {
expect(screen.getByText('云止')).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});
await waitFor(() => {
@@ -304,3 +331,70 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(/线/u)).toBeTruthy();
});
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
const user = userEvent.setup();
const profile = {
...baseProfile,
playableNpcs: [
{
...createPlayableRole('playable-portrait', '云止'),
imageSrc: '/generated-characters/playable-portrait/master.png',
generatedVisualAssetId: 'visual-playable-portrait',
},
],
} as CustomWorldProfile;
render(
<CustomWorldResultView
profile={profile}
previewCharacters={[
{
id: 'playable-portrait',
name: '云止',
title: '同行者',
description: '预览角色',
backstory: '预览背景',
personality: '预览性格',
portrait: '/template/portrait.png',
avatar: '/template/avatar.png',
assetFolder: 'test',
assetVariant: 'Hero',
combatTags: [],
skills: [],
adventureOpenings: {},
} as never,
]}
isGenerating={false}
progress={0}
progressLabel=""
error={null}
onBack={() => {}}
onProfileChange={() => {}}
/>,
);
await user.click(screen.getByRole('button', { name: //u }));
const portrait = screen.getByRole('img', { name: '云止' });
expect((portrait as HTMLImageElement).getAttribute('src')).toBe(
'/generated-characters/playable-portrait/master.png',
);
expect(screen.getByText('已生成主图')).toBeTruthy();
});
test('landmark tab uses first act image as scene card preview and keeps chapter details out of list', async () => {
const user = userEvent.setup();
render(<ResultViewHarness />);
await user.click(screen.getByRole('button', { name: /\s*2/u }));
expect(screen.queryByText('沉钟栈桥章节')).toBeNull();
expect(screen.queryByText('潮声逼近')).toBeNull();
const sceneImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect((sceneImage as HTMLImageElement).getAttribute('src')).toBe(
'/generated-custom-world-scenes/scene-act-1.png',
);
});

View File

@@ -54,6 +54,159 @@ type PendingGeneratedEntity = {
type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
type CustomWorldAssetDebugEntry = {
id: string;
label: string;
imageSrc: string;
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
};
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY =
'genarrative.debug.customWorldAssets';
function shouldEnableCustomWorldAssetDebugPanel() {
if (!import.meta.env.DEV || typeof window === 'undefined') {
return false;
}
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') {
return true;
}
return (
window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1'
);
}
function collectCustomWorldAssetDebugEntries(
profile: CustomWorldProfile,
): CustomWorldAssetDebugEntry[] {
const playableEntries = profile.playableNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `playable:${role.id}`,
label: `${role.name}主形象`,
imageSrc,
kind: 'playable' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const storyEntries = profile.storyNpcs
.map((role) => {
const imageSrc = role.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `story:${role.id}`,
label: `${role.name}场景角色主图`,
imageSrc,
kind: 'story' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const landmarkEntries = profile.landmarks
.map((landmark) => {
const imageSrc = landmark.imageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `landmark:${landmark.id}`,
label: `${landmark.name}场景主图`,
imageSrc,
kind: 'landmark' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
);
const sceneActEntries =
profile.sceneChapterBlueprints?.flatMap((chapter) =>
chapter.acts
.map((act) => {
const imageSrc = act.backgroundImageSrc?.trim() || '';
if (!imageSrc) {
return null;
}
return {
id: `scene-act:${chapter.id}:${act.id}`,
label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`,
imageSrc,
kind: 'scene-act' as const,
};
})
.filter(
(entry): entry is CustomWorldAssetDebugEntry => Boolean(entry),
),
) ?? [];
return [
...playableEntries,
...storyEntries,
...landmarkEntries,
...sceneActEntries,
];
}
function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) {
if (status === 'loaded') {
return '已加载';
}
if (status === 'error') {
return '加载失败';
}
return '检测中';
}
function resolveAssetDebugSummary(profile: CustomWorldProfile) {
return [
{
label: '可扮演角色主图',
value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`,
},
{
label: '场景角色主图',
value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`,
},
{
label: '场景主图',
value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`,
},
{
label: '分幕图',
value: `${profile.sceneChapterBlueprints?.reduce(
(sum, chapter) =>
sum +
chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim()))
.length,
0,
) ?? 0}/${
profile.sceneChapterBlueprints?.reduce(
(sum, chapter) => sum + chapter.acts.length,
0,
) ?? 0
}`,
},
];
}
function SmallButton({
onClick,
children,
@@ -236,6 +389,22 @@ export function CustomWorldResultView({
null,
);
const pendingProgressTimerRef = useRef<number | null>(null);
const assetDebugEnabled = useMemo(
() => shouldEnableCustomWorldAssetDebugPanel(),
[],
);
const assetDebugEntries = useMemo(
() =>
assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [],
[assetDebugEnabled, profile],
);
const assetDebugSummary = useMemo(
() => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []),
[assetDebugEnabled, profile],
);
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
Record<string, AssetDebugLoadStatus>
>({});
const createTarget = useMemo(
() => getCreateTargetByTab(activeTab),
@@ -254,6 +423,59 @@ export function CustomWorldResultView({
useEffect(() => () => stopPendingProgressTimer(), []);
useEffect(() => {
if (!assetDebugEnabled) {
setAssetDebugStatusMap({});
return;
}
if (assetDebugEntries.length === 0) {
setAssetDebugStatusMap({});
return;
}
let cancelled = false;
const cleanupList: Array<() => void> = [];
setAssetDebugStatusMap(
Object.fromEntries(
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
),
);
assetDebugEntries.forEach((entry) => {
const image = new Image();
const updateStatus = (status: AssetDebugLoadStatus) => {
if (cancelled) {
return;
}
setAssetDebugStatusMap((current) => {
if (current[entry.id] === status) {
return current;
}
return {
...current,
[entry.id]: status,
};
});
};
image.onload = () => updateStatus('loaded');
image.onerror = () => updateStatus('error');
image.src = entry.imageSrc;
cleanupList.push(() => {
image.onload = null;
image.onerror = null;
});
});
return () => {
cancelled = true;
cleanupList.forEach((cleanup) => cleanup());
};
}, [assetDebugEnabled, assetDebugEntries]);
const startPendingProgress = (kind: EntityGenerationKind) => {
stopPendingProgressTimer();
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
@@ -445,6 +667,77 @@ export function CustomWorldResultView({
{localGenerationError}
</div>
) : null}
{assetDebugEnabled ? (
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-bold tracking-[0.16em] text-white">
</div>
<div className="mt-1 text-xs leading-6 text-zinc-500">
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{assetDebugEntries.length}
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
{assetDebugSummary.map((entry) => (
<div
key={entry.label}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="text-[11px] text-zinc-500">{entry.label}</div>
<div className="mt-1 text-sm font-semibold text-white">
{entry.value}
</div>
</div>
))}
</div>
<div className="mt-3 space-y-2">
{assetDebugEntries.length > 0 ? (
assetDebugEntries.map((entry) => (
<div
key={entry.id}
className="platform-subpanel rounded-2xl px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-white">
{entry.label}
</div>
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
{entry.imageSrc}
</div>
</div>
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{resolveAssetDebugStatusLabel(
assetDebugStatusMap[entry.id],
)}
</div>
</div>
<div className="mt-2">
<a
href={entry.imageSrc}
target="_blank"
rel="noreferrer"
aria-label={`打开 ${entry.label}`}
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
>
</a>
</div>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
profile
</div>
)}
</div>
</div>
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (

View File

@@ -283,6 +283,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
!gameState.playerCharacter;
const collapseTopStage = gameState.currentScene === 'Selection';
const shouldHideStoryOptions = sceneTransitionPhase !== 'idle';
const visibleStoryForRender = visibleCurrentStory;
const dialogueIndicator = useMemo(() => {
if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') {
@@ -431,6 +432,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
companions={canvasCompanionRenderStates}
npcStates={visibleGameState.npcStates}
dialogueIndicator={dialogueIndicator}
npcAffinityEffect={visibleStoryForRender?.npcAffinityEffect ?? null}
onEntitySelect={setSelectedSceneEntity}
onSceneNameClick={() => setIsMapOpen(true)}
sceneTransitionPhase={sceneTransitionPhase}
@@ -485,7 +487,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
</motion.div>
)}
{visibleGameState.playerCharacter && visibleCurrentStory && (
{visibleGameState.playerCharacter && visibleStoryForRender && (
<motion.div key="story-flow" initial={{opacity: 0}} animate={{opacity: 1}} className="flex h-full min-h-0 flex-col">
<div className="story-top-tabs mb-3 grid grid-cols-3 gap-2 sm:gap-3">
<button
@@ -562,7 +564,7 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
<Suspense fallback={<PanelLoadingFallback label="正在加载冒险面板" />}>
<AdventurePanel
aiError={aiError}
currentStory={visibleCurrentStory}
currentStory={visibleStoryForRender}
isLoading={isLoading}
displayedOptions={displayedOptions}
hideOptions={shouldHideStoryOptions}

View File

@@ -6,8 +6,6 @@ import { fetchJson } from '../../editor/shared/jsonClient';
export const CHARACTER_VISUAL_GENERATE_API_PATH =
ASSET_API_PATHS.characterVisualGenerate;
export const CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH =
ASSET_API_PATHS.characterPromptBundleGenerate;
export const CHARACTER_WORKFLOW_CACHE_API_PATH =
ASSET_API_PATHS.characterWorkflowCache;
export const CHARACTER_VISUAL_PUBLISH_API_PATH =
@@ -47,29 +45,6 @@ export type CharacterVisualDraft = {
height: number;
};
export type CharacterPromptBundlePayload = {
roleKind: 'playable' | 'story';
characterName: string;
roleTitle?: string;
roleLabel?: string;
description?: string;
backstory?: string;
personality?: string;
motivation?: string;
combatStyle?: string;
tags?: string[];
characterBriefText: string;
};
export type CharacterPromptBundleResult = {
ok: true;
visualPromptText: string;
animationPromptText: string;
scenePromptText: string;
source: 'llm' | 'fallback';
model: string | null;
};
export type CharacterAssetWorkflowCache = {
characterId: string;
visualPromptText: string;
@@ -174,16 +149,6 @@ export async function generateCharacterVisualCandidates(
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
}
export async function generateCharacterPromptBundle(
payload: CharacterPromptBundlePayload,
) {
return postApiJson<CharacterPromptBundleResult>(
CHARACTER_PROMPT_BUNDLE_GENERATE_API_PATH,
payload,
'生成默认提示词失败',
);
}
export async function fetchCharacterWorkflowCache(characterId: string) {
return fetchJson<{
ok: true;

View File

@@ -44,7 +44,7 @@ export function CustomWorldAgentComposer({
return (
<div className="shrink-0">
<div className="platform-remap-surface relative">
<div className="platform-remap-surface platform-subpanel relative rounded-[1.5rem] p-1.5">
<textarea
ref={textareaRef}
value={text}
@@ -58,13 +58,13 @@ export function CustomWorldAgentComposer({
rows={2}
disabled={disabled}
placeholder="输入消息"
className="min-h-[5.5rem] w-full resize-none rounded-[1.35rem] border border-white/10 bg-[#111318]/92 px-4 pb-11 pr-18 pt-2.5 text-sm leading-5.5 text-white outline-none transition focus:border-emerald-300/35 disabled:cursor-not-allowed disabled:opacity-60"
className="platform-input min-h-[5.5rem] resize-none rounded-[1.2rem] pb-12 pr-20 pt-3 text-sm leading-5.5 disabled:cursor-not-allowed disabled:opacity-60"
/>
<button
type="button"
onClick={submit}
disabled={disabled || !text.trim()}
className="absolute bottom-2.5 right-2.5 rounded-full border border-emerald-300/20 bg-emerald-500/10 px-3 py-1.5 text-xs font-medium text-emerald-100 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--primary absolute bottom-3 right-3 h-9 min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>

View File

@@ -4,11 +4,11 @@ type CustomWorldAgentHeaderProps = {
export function CustomWorldAgentHeader({ onBack }: CustomWorldAgentHeaderProps) {
return (
<div className="platform-remap-surface flex items-center rounded-[1.5rem] border border-white/10 bg-[#111318]/95 px-4 py-3">
<div className="platform-remap-surface platform-subpanel flex items-center rounded-[1.5rem] px-4 py-3">
<button
type="button"
onClick={onBack}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:text-white"
className="platform-button platform-button--ghost h-9 min-h-0 rounded-full px-3 py-1.5 text-[11px]"
>
</button>

View File

@@ -35,37 +35,41 @@ export function CustomWorldAgentOperationBanner({
const isFailed = visibleOperation.status === 'failed';
const isRunning =
visibleOperation.status === 'running' || visibleOperation.status === 'queued';
// 操作横幅直接复用平台状态横幅,亮暗主题都从同一套 token 取色。
const bannerToneClass = isFailed
? 'platform-banner--danger'
: isRunning
? 'platform-banner--info'
: 'platform-banner--success';
const progressFillStyle = isFailed
? { background: 'linear-gradient(90deg, #fb7185 0%, #f43f5e 100%)' }
: isRunning
? { background: 'var(--platform-button-primary-fill)' }
: { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' };
return (
<div
className={`platform-remap-surface rounded-[1.4rem] border px-4 py-4 ${
isFailed
? 'border-rose-400/20 bg-[#111318]/95'
: isRunning
? 'border-emerald-300/20 bg-[#111318]/95'
: 'border-emerald-300/20 bg-[#111318]/95'
}`}
className={`platform-remap-surface platform-banner rounded-[1.4rem] px-4 py-4 ${bannerToneClass}`}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
<div className="text-sm font-semibold">
{visibleOperation.phaseLabel}
</div>
<div className="text-xs text-zinc-300">
<div className="text-xs opacity-80">
{Math.max(0, Math.min(100, Math.round(visibleOperation.progress)))}%
</div>
</div>
{visibleOperation.error ? (
<div className="mt-2 text-sm text-zinc-200">
<div className="mt-2 text-sm opacity-90">
{visibleOperation.error}
</div>
) : null}
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/10">
<div className="platform-progress-track mt-3 h-2 overflow-hidden rounded-full">
<div
className={`h-full rounded-full transition-[width] duration-300 ${
isFailed ? 'bg-rose-300' : 'bg-emerald-300'
}`}
className="h-full rounded-full transition-[width] duration-300"
style={{
width: `${Math.max(8, Math.min(100, visibleOperation.progress))}%`,
...progressFillStyle,
}}
/>
</div>

View File

@@ -37,9 +37,9 @@ export function CustomWorldAgentThread({
}, [messages, streamingReplyText, isStreamingReply]);
return (
<div className="platform-remap-surface flex h-full min-h-0 flex-1 flex-col overflow-y-auto px-1 py-2 sm:px-2">
<div className="platform-remap-surface platform-subpanel flex h-full min-h-0 flex-1 flex-col overflow-y-auto rounded-[1.75rem] px-2 py-3 sm:px-3">
{messages.length === 0 ? (
<div className="m-auto text-sm text-zinc-400">
<div className="m-auto text-sm text-[var(--platform-text-soft)]">
</div>
) : (
@@ -47,6 +47,12 @@ export function CustomWorldAgentThread({
{messages.map((message, index) => {
const isUser = message.role === 'user';
const isSystem = message.role === 'system';
// 聊天气泡统一映射到平台主题 token避免亮色主题继续透出历史深色底。
const bubbleToneClass = isUser
? 'border border-[var(--platform-cool-border)] bg-[var(--platform-cool-bg)] text-[var(--platform-text-strong)]'
: isSystem
? 'border border-[var(--platform-warm-border)] bg-[var(--platform-warm-bg)] text-[var(--platform-warm-text)]'
: 'platform-subpanel text-[var(--platform-text-strong)]';
return (
<div
@@ -56,13 +62,7 @@ export function CustomWorldAgentThread({
}`}
>
<div
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${
isUser
? 'border border-white/10 bg-white/10 text-zinc-50'
: isSystem
? 'border border-amber-300/16 bg-amber-500/10 text-amber-50'
: 'border border-white/10 bg-white/6 text-zinc-100'
}`}
className={`max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 break-words sm:max-w-[82%] ${bubbleToneClass}`}
>
<div className="whitespace-pre-wrap">{message.text}</div>
{!isUser &&
@@ -74,7 +74,7 @@ export function CustomWorldAgentThread({
key={`recommended-reply-${replyIndex}-${reply}`}
type="button"
onClick={() => onRecommendedReply?.(reply)}
className="rounded-[0.95rem] border border-white/10 bg-white/5 px-2.5 py-1.5 text-left text-[11px] leading-4.5 text-zinc-200 transition hover:border-emerald-300/25 hover:text-white"
className="platform-button platform-button--ghost min-h-0 justify-start rounded-[0.95rem] px-2.5 py-1.5 text-left text-[11px] leading-4.5 whitespace-normal"
>
{reply}
</button>
@@ -87,17 +87,17 @@ export function CustomWorldAgentThread({
})}
{isStreamingReply ? (
<div className="flex justify-start">
<div className="max-w-[90%] rounded-[1.4rem] border border-white/10 bg-white/6 px-4 py-3 text-sm leading-7 text-zinc-100 sm:max-w-[82%]">
<div className="platform-subpanel max-w-[90%] rounded-[1.4rem] px-4 py-3 text-sm leading-7 text-[var(--platform-text-strong)] sm:max-w-[82%]">
{streamingReplyText ? (
<div className="whitespace-pre-wrap">
{streamingReplyText}
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-emerald-200/80 align-[-2px]" />
<span className="ml-1 inline-block h-4 w-1 animate-pulse rounded-full bg-[var(--platform-cool-text)] align-[-2px]" />
</div>
) : (
<div className="flex items-center gap-1.5 py-1">
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70 [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-zinc-300/70" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.2s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)] [animation-delay:-0.1s]" />
<span className="h-2 w-2 animate-pulse rounded-full bg-[var(--platform-text-soft)]" />
</div>
)}
</div>

View File

@@ -42,7 +42,7 @@ export function CustomWorldAgentWorkspace({
}: CustomWorldAgentWorkspaceProps) {
if (!session) {
return (
<div className="platform-remap-surface mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] border border-white/10 bg-black/20 px-6 py-8 text-center text-sm text-zinc-400">
<div className="platform-remap-surface platform-subpanel mx-auto flex h-full w-full max-w-4xl items-center justify-center rounded-[1.75rem] px-6 py-8 text-center text-sm text-[var(--platform-text-soft)]">
</div>
);

View File

@@ -46,36 +46,42 @@ export function EightAnchorProgressBar({
const normalizedProgress = clampProgress(progressPercent);
const isCompleted = normalizedProgress >= 100;
const canQuickFill = currentTurn >= 2;
const progressFillStyle = isCompleted
? { background: 'linear-gradient(90deg, #86efac 0%, #34d399 100%)' }
: { background: 'var(--platform-button-primary-fill)' };
return (
<div className="platform-remap-surface rounded-[1.75rem] border border-white/10 bg-[#111318]/95 p-4">
<div className="platform-remap-surface platform-subpanel rounded-[1.75rem] p-4">
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-xs font-semibold tracking-[0.14em] text-zinc-300">
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-text-base)]">
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{resolveProgressHint(normalizedProgress)}
</div>
</div>
<div className="text-lg font-semibold text-white">
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{normalizedProgress}%
</div>
</div>
<div className="h-3 overflow-hidden rounded-full bg-white/8">
<div className="platform-progress-track h-3 overflow-hidden rounded-full">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,#d8ffd9_0%,#6ee7b7_45%,#34d399_100%)] transition-[width] duration-500"
style={{ width: `${Math.max(6, normalizedProgress)}%` }}
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(6, normalizedProgress)}%`,
...progressFillStyle,
}}
/>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={onSummaryClick}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>
@@ -84,7 +90,7 @@ export function EightAnchorProgressBar({
type="button"
onClick={onGenerateDraft}
disabled={disabled}
className="flex min-h-[3rem] items-center justify-center rounded-[1.1rem] border border-emerald-300/25 bg-emerald-500/12 px-4 py-3 text-sm font-semibold text-emerald-50 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--primary min-h-[3rem] rounded-[1.1rem] px-4 py-3 text-sm disabled:cursor-not-allowed disabled:opacity-45"
>
稿
</button>
@@ -93,7 +99,7 @@ export function EightAnchorProgressBar({
type="button"
onClick={onQuickFill}
disabled={disabled}
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-zinc-200 transition hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-45"
>
</button>

View File

@@ -0,0 +1,128 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import {
AnimationState,
type Character,
type Encounter,
type SceneHostileNpc,
} from '../../types';
import { GameCanvasEntityLayer } from './GameCanvasEntityLayer';
function createCharacter(): Character {
return {
id: 'hero',
name: '沈行',
title: '试剑客',
description: '测试主角',
backstory: '测试背景',
avatar: '/hero.png',
portrait: '/hero.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 10,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: 'calm',
skills: [],
adventureOpenings: {},
} as Character;
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'npc-liu',
kind: 'npc',
npcName: '柳无声',
npcDescription: '桥口旧识',
npcAvatar: '/npc-liu.png',
context: '断桥',
...overrides,
};
}
function createHostileNpc(overrides: Partial<SceneHostileNpc> = {}): SceneHostileNpc {
return {
id: 'npc-liu',
name: '柳无声',
action: '对峙',
description: '桥口旧识',
animation: 'idle',
xMeters: 3,
yOffset: 0,
facing: 'left',
attackRange: 1,
speed: 1,
hp: 10,
maxHp: 10,
encounter: createEncounter(),
...overrides,
};
}
function renderEntityLayer(effectNpcId: string | null) {
return renderToStaticMarkup(
<GameCanvasEntityLayer
companions={[]}
currentScenePreset={null}
sceneTransitionToken={0}
isSceneTransitionEntering={false}
isSceneTransitionExiting={false}
transitionSweepPx={320}
sceneTransitionExitDurationS={0.2}
sceneTransitionEntryDurationS={0.2}
companionAnchorLeft="10%"
companionAnchorBottom="20%"
playerBottomOffsetPx={0}
sceneTransitionPhase="idle"
inBattle={false}
onEntitySelect={null}
playerLeft="20%"
playerCharacter={createCharacter()}
playerHp={100}
playerMaxHp={100}
effectivePlayerFacing="right"
effectivePlayerAnimationState={AnimationState.IDLE}
shouldShowPlayerDialogueIcon={false}
dialogueIndicator={null}
npcAffinityEffect={
effectNpcId
? {
eventId: 'effect-1',
npcId: effectNpcId,
delta: 3,
}
: null
}
sceneCombatants={[createHostileNpc()]}
monsters={[]}
getHostileNpcOuterLeft={() => '70%'}
groundBottom="18%"
stageLiftPx={68}
encounter={null}
sideAnchor="15%"
cameraAnchorX={0}
monsterAnchorMeters={3.2}
playerX={0}
/>,
);
}
describe('GameCanvasEntityLayer', () => {
it('renders affinity effect on the matching hostile npc', () => {
const html = renderEntityLayer('npc-liu');
expect(html).toContain('data-testid="npc-affinity-effect-npc-liu"');
expect(html).toContain('aria-label="好感度变化 +3"');
});
it('does not render affinity effect on a different npc', () => {
const html = renderEntityLayer('npc-other');
expect(html).not.toContain('npc-affinity-effect-npc-liu');
expect(html).not.toContain('好感度变化 +3');
});
});

View File

@@ -15,6 +15,7 @@ import {
import {HostileNpcAnimator} from '../HostileNpcAnimator';
import {MedievalNpcAnimator} from '../MedievalNpcAnimator';
import {getRenderableNpcFacing} from '../npcRenderUtils';
import {NpcAffinityEffectBadge} from './NpcAffinityEffectBadge';
import {
DialogueBubbleIcon,
type GameCanvasEntitySelection,
@@ -66,6 +67,11 @@ interface GameCanvasEntityLayerProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
npcAffinityEffect?: {
eventId: string;
npcId: string;
delta: number;
} | null;
sceneCombatants: SceneHostileNpc[];
monsters: MonsterSpriteConfig[];
getHostileNpcOuterLeft: (hostileNpc: SceneHostileNpc) => string;
@@ -101,6 +107,7 @@ export function GameCanvasEntityLayer({
effectivePlayerAnimationState,
shouldShowPlayerDialogueIcon,
dialogueIndicator = null,
npcAffinityEffect = null,
sceneCombatants,
monsters,
getHostileNpcOuterLeft,
@@ -326,6 +333,10 @@ export function GameCanvasEntityLayer({
/>
</div>
)}
{/* 聊天好感变化要挂在当前角色形象上,而不是消息区里。 */}
{npcAffinityEffect?.npcId === (npcEncounter.id ?? npcEncounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);
@@ -436,6 +447,10 @@ export function GameCanvasEntityLayer({
/>
</div>
)}
{/* 和平相遇态同样沿用角色形象上的好感浮出特效。 */}
{npcAffinityEffect?.npcId === (encounter.id ?? encounter.npcName) ? (
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
) : null}
</SceneEntityButton>
</div>
);

View File

@@ -39,6 +39,7 @@ export function GameCanvasRuntime({
activeCombatEffects = [],
companions = [],
dialogueIndicator = null,
npcAffinityEffect = null,
onEntitySelect = null,
onSceneNameClick = null,
sceneTransitionPhase = 'idle',
@@ -192,6 +193,7 @@ export function GameCanvasRuntime({
effectivePlayerAnimationState={effectivePlayerAnimationState}
shouldShowPlayerDialogueIcon={shouldShowPlayerDialogueIcon}
dialogueIndicator={dialogueIndicator}
npcAffinityEffect={npcAffinityEffect}
sceneCombatants={sceneHostileNpcs}
monsters={monsters}
getHostileNpcOuterLeft={getHostileNpcOuterLeft}

View File

@@ -16,6 +16,7 @@ import {
Encounter,
SceneHostileNpc,
ScenePresetInfo,
StoryNpcAffinityEffect,
StoryEngineMemoryState,
WorldType,
} from '../../types';
@@ -54,6 +55,7 @@ export interface GameCanvasProps {
showEncounter: boolean;
activeSpeaker?: 'player' | 'npc' | null;
} | null;
npcAffinityEffect?: StoryNpcAffinityEffect | null;
onEntitySelect?: ((entity: GameCanvasEntitySelection) => void) | null;
onSceneNameClick?: (() => void) | null;
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
@@ -68,6 +70,10 @@ export const ENTITY_CONTAINER_REM = 7;
export const ROLE_CHARACTER_FRAME_CLASS = 'flex h-28 w-28 items-end justify-center overflow-visible';
export const ROLE_CHARACTER_SPRITE_CLASS = 'h-full w-full scale-[1.32] origin-bottom';
export const GENERIC_NPC_SCENE_SCALE = 1.72;
const DEFAULT_IMAGE_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
objectPosition: 'center bottom',
};
export const DEFAULT_COMBAT_HP_TOP_PX = -18;
export const CHARACTER_NPC_COMBAT_HP_TOP_PX = -2;
export const GENERIC_NPC_COMBAT_HP_TOP_PX = 94;

View File

@@ -0,0 +1,59 @@
import { Heart } from 'lucide-react';
import { motion } from 'motion/react';
import type { StoryNpcAffinityEffect } from '../../types';
interface NpcAffinityEffectBadgeProps {
effect: StoryNpcAffinityEffect;
}
/**
* 聊天结算后的好感度浮出特效。
* 仅负责表现层,不承担任何数值计算。
*/
export function NpcAffinityEffectBadge({
effect,
}: NpcAffinityEffectBadgeProps) {
const isPositive = effect.delta > 0;
const deltaText = `${effect.delta > 0 ? '+' : ''}${effect.delta}`;
return (
<motion.div
key={effect.eventId}
initial={{ opacity: 0, y: 24, scale: 0.8 }}
animate={{ opacity: [0, 1, 1, 0], y: [24, -8, -26, -44], scale: [0.8, 1.08, 1, 0.92] }}
transition={{ duration: 1.45, ease: 'easeOut' }}
className="pointer-events-none absolute -top-14 left-1/2 z-[12] flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 shadow-[0_10px_24px_rgba(0,0,0,0.35)] backdrop-blur-[2px]"
data-testid={`npc-affinity-effect-${effect.npcId}`}
aria-label={`好感度变化 ${deltaText}`}
>
{isPositive ? (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-rose-400/18 blur-md" />
<div className="relative flex items-center gap-1 text-rose-50">
<Heart className="h-3.5 w-3.5 fill-current" />
<span className="text-xs font-semibold tracking-[0.08em]">
{deltaText}
</span>
</div>
</>
) : (
<>
<div className="absolute inset-0 rounded-full bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),transparent_60%)]" />
<div className="absolute -inset-1 rounded-full bg-slate-400/15 blur-md" />
<div className="relative text-xs font-semibold tracking-[0.08em] text-slate-100">
{deltaText}
</div>
</>
)}
<div
className={`absolute inset-0 rounded-full border ${
isPositive
? 'border-rose-200/45 bg-rose-500/18'
: 'border-slate-200/35 bg-slate-700/30'
}`}
/>
</motion.div>
);
}

View File

@@ -56,7 +56,7 @@ const DESKTOP_PAGE_STAGE_CLASS =
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
<div className="text-[10px] font-semibold tracking-[0.26em] text-[var(--platform-text-soft)]">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
@@ -69,7 +69,7 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
function EmptyShelf({ text }: { text: string }) {
return (
<div
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-[var(--platform-text-base)]`}
>
{text}
</div>
@@ -101,7 +101,7 @@ function SaveArchivePreview({
)}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-[var(--platform-text-strong)]">
{label}
</span>
</div>
@@ -162,15 +162,15 @@ function WorldCard({
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white">
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-zinc-300/85">
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-zinc-200/90">
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -249,28 +249,28 @@ function CreationLibraryCard({
>
{statusLabel}
</span>
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-zinc-300">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-medium text-[var(--platform-text-base)]">
<span className="truncate">{metaLabel}</span>
</span>
</div>
<div className="mt-auto min-w-0">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-white sm:text-[1.12rem]">
<div className="line-clamp-2 break-words text-base font-black leading-[1.15] text-[var(--platform-text-strong)] sm:text-[1.12rem]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-zinc-300/84">
<div className="mt-1 line-clamp-1 break-words text-[11px] tracking-[0.08em] text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-zinc-200/88 sm:text-xs">
<div className="mt-2 line-clamp-3 break-words text-[11px] leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)] sm:text-xs">
{summaryText}
</div>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-zinc-100/90">
<span className="inline-flex min-w-0 max-w-full items-center rounded-full border border-white/12 bg-black/18 px-2 py-1 text-[10px] font-semibold tracking-[0.1em] text-[color:color-mix(in_srgb,var(--platform-text-strong)_90%,transparent)]">
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-[var(--platform-text-base)]">
<span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span>
@@ -306,21 +306,21 @@ function SaveArchiveCard({
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-zinc-300">
<span className="rounded-full border border-white/10 bg-black/18 px-2.5 py-1 text-[11px] font-medium text-[var(--platform-text-base)]">
{loading ? '恢复中' : formatSnapshotTime(entry.lastPlayedAt)}
</span>
</div>
<div className="flex min-w-0 flex-1 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-white sm:text-xl">
<div className="line-clamp-2 break-words text-[1.15rem] font-black leading-tight text-[var(--platform-text-strong)] sm:text-xl">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 break-words text-sm text-zinc-300">
<div className="mt-1 line-clamp-1 break-words text-sm text-[var(--platform-text-base)]">
{entry.subtitle}
</div>
) : null}
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-zinc-400 sm:text-sm">
<div className="mt-2 line-clamp-3 break-words text-xs leading-5 text-[var(--platform-text-soft)] sm:text-sm">
{summaryText}
</div>
<div className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-zinc-200">
@@ -356,13 +356,11 @@ function PlatformTabButton({
onClick={onClick}
className={`platform-bottom-nav__button ${active ? 'platform-bottom-nav__button--active' : ''}`}
>
<span className="flex flex-col items-center justify-center gap-1">
<span className="platform-bottom-nav__button-content">
<span className="platform-bottom-nav__icon-shell">
<Icon className="platform-bottom-nav__icon h-[1.05rem] w-[1.05rem]" />
</span>
<span className="platform-bottom-nav__label text-[11px] font-semibold tracking-[0.18em]">
{label}
<Icon className="platform-bottom-nav__icon" />
</span>
<span className="platform-bottom-nav__label">{label}</span>
</span>
</button>
);
@@ -425,14 +423,14 @@ function DesktopTrendingItem({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-zinc-500">
<div className="flex items-center gap-2 text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
<div className="mt-1 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_86%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-3 flex flex-wrap gap-2">
@@ -453,7 +451,7 @@ function DesktopTrendingItem({
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-zinc-500" />
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
@@ -584,7 +582,7 @@ function ProfileStatCard({
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-2 text-zinc-400">
<div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
@@ -598,8 +596,8 @@ function ProfileStatCard({
function ProfileStatCardSkeleton() {
return (
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
<div className="h-4 w-20 animate-pulse rounded-full bg-white/10" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-white/12" />
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
</div>
);
}
@@ -748,7 +746,7 @@ export function PlatformHomeView({
}}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,47,112,0.92),rgba(255,136,104,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm">
@@ -776,7 +774,7 @@ export function PlatformHomeView({
</button>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
@@ -833,7 +831,7 @@ export function PlatformHomeView({
onClick={onOpenCreateTypePicker}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.16),transparent_38%),radial-gradient(circle_at_right,rgba(255,201,172,0.18),transparent_30%),linear-gradient(180deg,rgba(255,90,141,0.88),rgba(255,144,105,0.88))]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<span className="platform-pill platform-pill--cool w-fit">
CREATE
@@ -887,7 +885,7 @@ export function PlatformHomeView({
{authUi?.user ? (
<>
{saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{saveError}
</div>
) : null}
@@ -1060,7 +1058,7 @@ export function PlatformHomeView({
</>
)}
</div>
<div className="mt-3 text-[11px] text-zinc-500">
<div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
{dashboardError
? dashboardError
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
@@ -1090,10 +1088,12 @@ export function PlatformHomeView({
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-zinc-400"></div>
<div className="text-xs text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</button>
</section>
</>
@@ -1121,7 +1121,7 @@ export function PlatformHomeView({
activeTab === 'home' ? (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
{platformError ? (
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="rounded-[1.5rem] border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{platformError}
</div>
) : null}
@@ -1147,8 +1147,7 @@ export function PlatformHomeView({
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(115deg,rgba(255,31,111,0.94),rgba(255,109,104,0.8)_52%,rgba(255,164,124,0.9))]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_28%),radial-gradient(circle_at_78%_24%,rgba(255,208,178,0.18),transparent_20%)]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
<div className="flex items-start justify-between gap-4">
<span className="platform-pill platform-pill--warm">
@@ -1198,8 +1197,8 @@ export function PlatformHomeView({
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(91,24,46,0.34))]" />
</div>
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-zinc-300/82">
<span className="text-zinc-500">
<div className="flex items-center gap-2 px-3 py-2 text-[11px] text-[color:color-mix(in_srgb,var(--platform-text-base)_82%,transparent)]">
<span className="text-[var(--platform-text-soft)]">
{`${index + 1}`.padStart(2, '0')}
</span>
<span className="line-clamp-1">
@@ -1286,17 +1285,17 @@ export function PlatformHomeView({
<span className="platform-pill platform-pill--cool">
{hasSavedGame ? 'SAVE POINT' : 'START HERE'}
</span>
<ArrowRight className="h-4 w-4 text-zinc-400" />
<ArrowRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</div>
<div className="mt-4 text-2xl font-semibold text-white">
<div className="mt-4 text-2xl font-semibold text-[var(--platform-text-strong)]">
{hasSavedGame ? snapshotWorldName : '从这里开启新的创作'}
</div>
<div className="mt-2 text-sm leading-7 text-zinc-300/84">
<div className="mt-2 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_84%,transparent)]">
{hasSavedGame
? `当前角色:${snapshotCharacterName}`
: '快速进入自定义世界创作,继续补齐设定、角色与核心冲突。'}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-6 text-zinc-400">
<div className="mt-3 line-clamp-3 text-sm leading-6 text-[var(--platform-text-soft)]">
{hasSavedGame
? snapshotDigest
: '先生成一版可玩的世界底稿,再继续编辑并发布。'}
@@ -1305,7 +1304,7 @@ export function PlatformHomeView({
</button>
<div className="mt-5">
<div className="text-[10px] font-semibold tracking-[0.24em] text-zinc-500">
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
@@ -1325,10 +1324,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
@@ -1366,10 +1365,10 @@ export function PlatformHomeView({
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-white">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-zinc-400">
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
@@ -1434,7 +1433,7 @@ export function PlatformHomeView({
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
<div className="platform-bottom-nav grid grid-cols-4">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
@@ -1468,7 +1467,7 @@ export function PlatformHomeView({
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<PlatformBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-[var(--platform-text-soft)]">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">
@@ -1503,7 +1502,7 @@ export function PlatformHomeView({
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'}
</span>
<span className="block truncate text-xs text-zinc-400">
<span className="block truncate text-xs text-[var(--platform-text-soft)]">
{authUi?.user ? publicUserCode : '登录后同步创作与进度'}
</span>
</span>

View File

@@ -110,7 +110,7 @@ export function PlatformWorldDetailView({
className="absolute bottom-0 right-2 h-32 w-32 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(125deg,rgba(255,31,111,0.78),rgba(255,138,115,0.52)_48%,rgba(255,255,255,0.08)_100%)]" />
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10">
<div className="flex flex-wrap items-center gap-2">
<span className="platform-pill platform-pill--warm">
@@ -151,12 +151,12 @@ export function PlatformWorldDetailView({
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-zinc-100 sm:grid-cols-4">
<div className="mt-3 grid grid-cols-2 gap-3 text-sm text-[var(--platform-text-strong)] sm:grid-cols-4">
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -164,7 +164,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -172,7 +172,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -180,7 +180,7 @@ export function PlatformWorldDetailView({
</div>
</div>
<div className="platform-subpanel rounded-xl px-3 py-3">
<div className="text-[10px] tracking-[0.18em] text-zinc-500">
<div className="text-[10px] tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-2 text-lg font-bold">
@@ -190,19 +190,19 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewCharacters.map((character, index) => (
<div
key={character.id || `preview-character-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{character.title}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{character.description}
</div>
</div>
@@ -211,19 +211,19 @@ export function PlatformWorldDetailView({
</div>
<div className="mt-5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 grid gap-3 sm:grid-cols-3">
{previewLandmarks.map((landmark, index) => (
<div
key={landmark.id || `preview-landmark-${index}`}
className="platform-subpanel rounded-2xl px-3 py-3"
className="platform-subpanel rounded-2xl px-3 py-3"
>
<div className="line-clamp-1 text-sm font-bold text-white">
<div className="line-clamp-1 text-sm font-bold text-[var(--platform-text-strong)]">
{landmark.name}
</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-zinc-300">
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[var(--platform-text-base)]">
{landmark.description}
</div>
</div>
@@ -233,7 +233,7 @@ export function PlatformWorldDetailView({
</div>
<div className="platform-surface platform-surface--soft px-4 py-3.5">
<div className="text-[10px] tracking-[0.22em] text-zinc-500">
<div className="text-[10px] tracking-[0.22em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-4 flex flex-col gap-3">
@@ -275,7 +275,7 @@ export function PlatformWorldDetailView({
) : null}
</div>
{error ? (
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
{error}
</div>
) : null}

View File

@@ -483,7 +483,8 @@ test('starting draft generation leaves the agent workspace and shows the generat
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
expect(screen.queryByText(/Agent/u)).toBeNull();
expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0);
expect(screen.getByText('当前锚点信息')).toBeTruthy();
expect(screen.getByText('当前世界信息')).toBeTruthy();
expect(screen.queryByText('回到工作区')).toBeNull();
expect(screen.getByText('世界承诺')).toBeTruthy();
expect(screen.getByText(/穿/u)).toBeTruthy();
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();

View File

@@ -43,9 +43,7 @@ import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildCustomWorldCreatorIntentFoundationText,
} from '../../services/customWorldCreatorIntent';
import { buildCustomWorldCreatorIntentFoundationText } from '../../services/customWorldCreatorIntent';
import {
hasPendingPlatformBrowseHistoryMigration,
markPlatformBrowseHistoryMigrated,
@@ -169,7 +167,7 @@ function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
function LazyPanelFallback({ label }: { label: string }) {
return (
<div className="flex h-full min-h-0 items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{label}
</div>
</div>
@@ -405,9 +403,8 @@ export function PreGameSelectionFlow({
hasPendingPlatformBrowseHistoryMigration(authUi?.user) &&
localHistoryEntries.length > 0
) {
nextEntries = await syncProfileBrowseHistory(
localHistoryEntries,
);
nextEntries =
await syncProfileBrowseHistory(localHistoryEntries);
markPlatformBrowseHistoryMigrated(authUi?.user);
}
@@ -472,12 +469,17 @@ export function PreGameSelectionFlow({
} else if (isAuthenticated) {
setSaveEntries([]);
setSaveError(
resolveErrorMessage(saveArchivesResult.reason, '读取存档列表失败。'),
resolveErrorMessage(
saveArchivesResult.reason,
'读取存档列表失败。',
),
);
}
const nextPlatformBootstrapUserId = authUi?.user?.id ?? null;
if (platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId) {
if (
platformTabBootstrapUserIdRef.current !== nextPlatformBootstrapUserId
) {
platformTabBootstrapUserIdRef.current = nextPlatformBootstrapUserId;
if (!initialAgentUiStateRef.current.activeSessionId) {
setPlatformTab(
@@ -1333,7 +1335,7 @@ export function PreGameSelectionFlow({
>
{isDetailLoading || !selectedDetailEntry ? (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{detailError || '正在读取作品详情...'}
</div>
</div>
@@ -1419,7 +1421,7 @@ export function PreGameSelectionFlow({
/>
) : (
<div className="flex h-full items-center justify-center">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-zinc-300">
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
{isLoadingAgentSession
? '正在准备 Agent 共创工作区...'
: creationTypeError || '正在恢复创作工作区...'}
@@ -1452,14 +1454,10 @@ export function PreGameSelectionFlow({
onRetry={retryAgentDraftGeneration}
onInterrupt={undefined}
backLabel="返回工作区"
settingActionLabel="回到工作区"
settingActionLabel={null}
retryLabel="重新生成草稿"
settingTitle="当前锚点信息"
settingDescription={
isAgentDraftGenerationView
? '将按当前八锚点结构编译第一版世界底稿与草稿卡。'
: undefined
}
settingTitle="当前世界信息"
settingDescription={null}
progressTitle={
isAgentDraftGenerationView ? '世界草稿生成进度' : undefined
}

View File

@@ -140,6 +140,15 @@ function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : fallback;
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(entry): entry is Record<string, unknown> =>
Boolean(entry) && typeof entry === 'object',
)
: [];
}
function toStringArray(value: unknown) {
return Array.isArray(value)
? value
@@ -858,10 +867,22 @@ function normalizeCampScene(
}
return {
id: toText(value.id, fallback.id),
name: toText(value.name, fallback.name),
description: toText(value.description, fallback.description),
visualDescription: toText(value.visualDescription) || undefined,
dangerLevel: toText(value.dangerLevel, fallback.dangerLevel),
imageSrc: toText(value.imageSrc) || undefined,
sceneNpcIds: toStringArray(value.sceneNpcIds),
connections: toRecordArray(value.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition:
toText(connection.relativePosition) || toText(connection.position) || 'forward',
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}
@@ -950,6 +971,7 @@ function normalizeSceneActBlueprint(
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
encounterNpcIds,
primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''),
linkedThreadIds: toStringArray(value.linkedThreadIds),

View File

@@ -18,7 +18,6 @@ export type EditorJsonResourceId =
(typeof EDITOR_JSON_RESOURCE_IDS)[keyof typeof EDITOR_JSON_RESOURCE_IDS];
export const ASSET_API_PATHS = {
characterPromptBundleGenerate: `${ASSETS_API_BASE_PATH}/character-prompts/generate`,
characterWorkflowCache: `${ASSETS_API_BASE_PATH}/character-workflow-cache`,
characterVisualGenerate: `${ASSETS_API_BASE_PATH}/character-visual/generate`,
characterVisualPublish: `${ASSETS_API_BASE_PATH}/character-visual/publish`,

View File

@@ -221,6 +221,7 @@ describe('createStoryChoiceActions', () => {
(inputState: GameState) => inputState.sceneHostileNpcs,
),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -296,6 +297,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -385,6 +387,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -410,8 +413,20 @@ describe('createStoryChoiceActions', () => {
expect(resolveServerRuntimeChoiceMock).not.toHaveBeenCalled();
});
it('keeps the finishing action in history before npc victory follow-up generation', async () => {
const state = createBaseState();
it('reopens npc chat instead of running generic follow-up after local npc victory', async () => {
const encounter: Encounter = {
id: 'npc-opponent',
kind: 'npc',
npcName: '山道客',
npcDescription: '拦路旧敌',
npcAvatar: '/npc.png',
context: '山道旧案',
};
const state = {
...createBaseState(),
currentEncounter: encounter,
npcInteractionActive: true,
};
const option = createBattleOption();
const afterSequence = {
...state,
@@ -422,6 +437,7 @@ describe('createStoryChoiceActions', () => {
const generateStoryForState = vi.fn().mockResolvedValue(createFallbackStory('战后续写'));
const setCurrentStory = vi.fn();
const setGameState = vi.fn();
const handleNpcBattleConversationContinuation = vi.fn(() => true);
const { handleChoice } = createStoryChoiceActions({
gameState: state,
@@ -456,6 +472,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation,
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats: vi.fn((inputState: GameState) => inputState),
getCampCompanionTravelScene: vi.fn(() => null),
@@ -484,15 +501,23 @@ describe('createStoryChoiceActions', () => {
await handleChoice(option);
expect(generateStoryForState).toHaveBeenCalledTimes(1);
const [{ history }] = generateStoryForState.mock.calls[0] as [
{ history: StoryMoment[] },
];
expect(history.map((entry) => `${entry.historyRole}:${entry.text}`)).toEqual([
'action:挥刀抢攻',
'result:山道客已经败下阵来。胜利奖励:无战利品。',
]);
expect(setCurrentStory).toHaveBeenCalledWith(createFallbackStory('战后续写'));
expect(handleNpcBattleConversationContinuation).toHaveBeenCalledWith(
expect.objectContaining({
nextState: expect.objectContaining({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
}),
encounter,
actionText: '挥刀抢攻',
resultText: '山道客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
}),
);
expect(generateStoryForState).not.toHaveBeenCalled();
expect(setCurrentStory).not.toHaveBeenCalledWith(
createFallbackStory('战后续写'),
);
});
it('injects an escape resolution into the immediate story context before ai continuation', async () => {
@@ -568,6 +593,7 @@ describe('createStoryChoiceActions', () => {
getStoryGenerationHostileNpcs: vi.fn(() => []),
getResolvedSceneHostileNpcs: vi.fn((inputState: GameState) => inputState.sceneHostileNpcs),
buildNpcStory: vi.fn(() => createFallbackStory()),
handleNpcBattleConversationContinuation: vi.fn(() => false),
updateQuestLog: vi.fn((inputState: GameState) => inputState),
incrementRuntimeStats,
getCampCompanionTravelScene: vi.fn(() => null),

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string,
) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
@@ -87,6 +96,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog,
incrementRuntimeStats,
getCampCompanionTravelScene,
@@ -127,6 +137,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
getResolvedSceneHostileNpcs: (state: GameState) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
getCampCompanionTravelScene: (state: GameState, character: Character) => GameState['currentScenePreset'] | null;
@@ -247,6 +258,7 @@ export function createStoryChoiceActions({
getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
buildNpcStory,
handleNpcBattleConversationContinuation,
updateQuestLog,
incrementRuntimeStats,
finalizeNpcBattleResult,

View File

@@ -390,6 +390,13 @@ function createAcceptedPendingQuestStory(
};
}
function createFallbackStory(text = 'fallback'): StoryMoment {
return {
text,
options: [],
};
}
type GenerateStoryForStateTestDouble = (params: {
state: GameState;
character: Character;
@@ -407,6 +414,12 @@ function createNpcEncounterActions(overrides: {
state: GameState,
character: Character,
) => StoryOption[] | null;
buildNpcStory?: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
}) {
const gameState = overrides.gameState ?? createState();
const currentStory = overrides.currentStory ?? createCurrentChatStory();
@@ -437,6 +450,33 @@ function createNpcEncounterActions(overrides: {
historyRole: 'result' as const,
},
]),
buildNpcStory:
overrides.buildNpcStory ??
vi.fn(
(
_state: GameState,
_character: Character,
encounter: Encounter,
overrideText?: string,
) => ({
text:
overrideText ??
`${encounter.npcName}还在盯着你,像是在等你继续把话说下去。`,
options: [
createOption('npc_chat', '先把刚才那一刀说清楚', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
createOption('npc_chat', '你刚才为什么会收手', {
kind: 'npc',
npcId: encounter.id ?? encounter.npcName,
action: 'chat',
}),
],
displayMode: 'dialogue',
}),
),
buildOpeningCampChatContext: vi.fn(() => ({})),
buildStoryContextFromState: vi.fn(() => ({
playerHp: gameState.playerHp,
@@ -707,16 +747,17 @@ describe('npcEncounterActions', () => {
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.npcAffinityEffect).toBeNull();
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
@@ -777,6 +818,11 @@ describe('npcEncounterActions', () => {
turn.text.includes('先和你打个招呼。前面的风不太对。'),
),
).toBe(false);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 1,
});
});
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
@@ -920,6 +966,94 @@ describe('npcEncounterActions', () => {
expect(actions.setIsLoading).toHaveBeenLastCalledWith(false);
});
it('prefers the current story non-chat options when rebuilding options after exiting npc chat', async () => {
const gameState = createState({
storyHistory: [
{
text: '你先试探了对方的态度。',
options: [],
historyRole: 'action',
},
],
});
const generateStoryForState = vi.fn().mockResolvedValue({
text: '你重新收束心神,开始判断断桥口接下来会怎么变。',
options: [createOption('idle_observe_signs', '观察周围动静')],
});
const actions = createNpcEncounterActions({
gameState,
currentStory: {
text: '断桥客把话收住,像是在等你决定下一步。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'npc', speakerName: '断桥客', text: '你还想继续聊下去吗。' },
],
options: [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '借你的人脉把线索铺开', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '现在就把这笔旧账打清', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 2,
customInputPlaceholder: '输入你想对 TA 说的话',
},
},
generateStoryForState,
getAvailableOptionsForState: vi.fn(() => [
createOption('npc_chat', '先问问你为什么堵在这里', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
createOption('npc_help', '请求援手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'help',
}),
createOption('npc_fight', '直接动手', {
kind: 'npc',
npcId: 'npc-rival',
action: 'fight',
}),
]),
});
expect(actions.exitNpcChat()).toBe(true);
await flushAsyncWork();
const [{ optionCatalog }] = generateStoryForState.mock.calls[0] as [
{ optionCatalog: StoryOption[] },
];
expect(optionCatalog).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '先问问你为什么堵在这里',
}),
expect.objectContaining({
functionId: 'npc_help',
actionText: '借你的人脉把线索铺开',
}),
expect.objectContaining({
functionId: 'npc_fight',
actionText: '现在就把这笔旧账打清',
}),
]);
});
it('opens hostile npc encounters as a declaration dialogue with only escape and fight options', () => {
const encounter = createEncounter();
const actions = createNpcEncounterActions({
@@ -1095,6 +1229,105 @@ describe('npcEncounterActions', () => {
);
});
it('reopens npc chat after battle victory with combat context and preserved negative affinity limit', () => {
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
currentStory: createFallbackStory(),
});
const reopened = actions.reopenNpcChatAfterBattle({
nextState: createState({
customWorldProfile: createSceneActProfile(),
currentEncounter: createEncounter(),
npcInteractionActive: true,
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 2,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: true,
},
},
storyHistory: [
{
historyRole: 'action',
text: '你挥刀抢攻,逼住了断桥客的退路。',
options: [],
},
{
historyRole: 'result',
text: '断桥客被逼到桥栏边,刀势已经散了。',
options: [],
},
],
}),
encounter: createEncounter(),
actionText: '挥刀抢攻',
resultText: '断桥客已经败下阵来。胜利奖励:无战利品。',
battleMode: 'fight',
});
expect(reopened).toBe(true);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
combatContext: {
battleOutcome: 'victory',
},
});
expect(lastStory.dialogue?.[0]).toEqual(
expect.objectContaining({
speaker: 'system',
text: '断桥客已经败下阵来。胜利奖励:无战利品。',
}),
);
expect(lastStory.npcChatState?.combatContext?.summary).toContain(
'你刚赢下这场交锋',
);
expect(lastStory.npcChatState?.combatContext?.logLines).toEqual(
expect.arrayContaining([
'你挥刀抢攻,逼住了断桥客的退路。',
'断桥客被逼到桥栏边,刀势已经散了。',
'挥刀抢攻',
'断桥客已经败下阵来。胜利奖励:无战利品。',
]),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({
@@ -1142,6 +1375,11 @@ describe('npcEncounterActions', () => {
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState?.pendingQuestOffer?.quest).toEqual(pendingQuest);
expect(lastStory.npcAffinityEffect).toEqual({
eventId: expect.stringContaining('npc-chat-affinity-npc-rival-'),
npcId: 'npc-rival',
delta: 2,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'查看任务',
'更换任务',

View File

@@ -85,6 +85,10 @@ type NpcChatDirective = {
forceExitAfterTurn?: boolean;
} | null;
type NpcChatCombatContext = NonNullable<
NonNullable<StoryMoment['npcChatState']>['combatContext']
>;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
@@ -108,6 +112,7 @@ export function createStoryNpcEncounterActions({
setAiError,
setIsLoading,
appendHistory,
buildNpcStory,
buildOpeningCampChatContext,
buildStoryContextFromState,
buildFallbackStoryForState,
@@ -135,6 +140,12 @@ export function createStoryNpcEncounterActions({
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildNpcStory: (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
buildOpeningCampChatContext: (
state: GameState,
character: Character,
@@ -308,6 +319,98 @@ export function createStoryNpcEncounterActions({
'先把这附近真正危险的地方说清楚',
].map((actionText) => buildNpcChatOption(encounter, actionText));
const extractRecentCombatLogLines = (history: GameState['storyHistory']) =>
history
.slice(-6)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-4);
const buildNpcBattleChatCombatContext = (params: {
battleMode: NpcBattleMode;
resultText: string;
actionText: string;
historyBase: GameState['storyHistory'];
}): NpcChatCombatContext => {
const logLines = [
...extractRecentCombatLogLines(params.historyBase),
params.actionText,
params.resultText,
].filter((line, index, lines) => lines.indexOf(line) === index);
return {
summary:
params.battleMode === 'spar'
? `你们刚结束一场切磋,${params.resultText}`
: `你刚赢下这场交锋,${params.resultText}`,
logLines,
battleOutcome:
params.battleMode === 'spar' ? 'spar_complete' : 'victory',
};
};
const reopenNpcChatAfterBattle = (params: {
nextState: GameState;
encounter: Encounter;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => {
const playerCharacter = params.nextState.playerCharacter;
if (!playerCharacter) {
return false;
}
const reopenedNpcState = getResolvedNpcState(params.nextState, params.encounter);
const chatDirective = resolveLimitedPrimaryNpcChatState({
state: params.nextState,
npcId: params.encounter.id ?? params.encounter.npcName,
affinity: reopenedNpcState.affinity,
nextTurnCount: 0,
});
const baseStory = buildNpcStory(
params.nextState,
playerCharacter,
params.encounter,
params.resultText,
);
const baseChatOptions = (baseStory.options ?? []).filter((option) =>
isNpcChatOptionForEncounter(option, params.encounter),
);
const fallbackChatOption =
baseChatOptions[0] ??
buildNpcChatOption(params.encounter, `继续和${params.encounter.npcName}对话`);
const combatContext = buildNpcBattleChatCombatContext({
battleMode: params.battleMode,
resultText: params.resultText,
actionText: params.actionText,
historyBase: params.nextState.storyHistory,
});
setCurrentStory(
buildNpcChatStoryMoment({
encounter: params.encounter,
dialogue: [
{
speaker: 'system',
text: params.resultText,
},
],
options: buildNpcChatEntryOptions(
params.encounter,
fallbackChatOption,
baseChatOptions.slice(1),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'player_reply',
combatContext,
}),
);
return true;
};
const finalizeNpcBattleResult = (
state: GameState,
character: Character,
@@ -380,6 +483,18 @@ export function createStoryNpcEncounterActions({
const defeatedHostileNpcIds = activeBattleHostiles.map(
(hostileNpc) => hostileNpc.id,
);
const restoredEncounter =
(state.currentEncounter?.kind === 'npc' ? state.currentEncounter : null) ??
activeBattleHostiles[0]?.encounter ??
({
id: battleNpcId,
kind: 'npc',
npcName: activeBattleHostiles[0]?.name ?? battleNpcId,
npcDescription: '',
npcAvatar: '',
context: '',
hostile: false,
} satisfies Encounter);
const progressedQuests = applyQuestProgressFromHostileNpcDefeat(
state.quests,
state.currentScenePreset?.id ?? null,
@@ -398,8 +513,8 @@ export function createStoryNpcEncounterActions({
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
currentEncounter: null,
npcInteractionActive: false,
currentEncounter: restoredEncounter,
npcInteractionActive: true,
sceneHostileNpcs: [],
playerInventory: addInventoryItems(state.playerInventory, lootItems),
quests: progressedQuests,
@@ -407,8 +522,8 @@ export function createStoryNpcEncounterActions({
...state.npcStates,
[battleNpcId]: {
...markNpcFirstMeaningfulContactResolved(npcState),
affinity: 0,
relationState: buildRelationState(0),
affinity: npcState.affinity,
relationState: buildRelationState(npcState.affinity),
recruited: false,
inventory: nextNpcInventory,
},
@@ -604,12 +719,15 @@ export function createStoryNpcEncounterActions({
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
combatContext?: NpcChatCombatContext | null;
latestAffinityEffect?: StoryMoment['npcAffinityEffect'];
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
displayMode: 'dialogue',
dialogue: params.dialogue,
streaming: params.streaming,
npcAffinityEffect: params.latestAffinityEffect ?? null,
npcChatState: {
npcId: params.encounter.id ?? params.encounter.npcName,
npcName: params.encounter.npcName,
@@ -622,6 +740,7 @@ export function createStoryNpcEncounterActions({
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
combatContext: params.combatContext ?? null,
},
});
@@ -642,6 +761,50 @@ export function createStoryNpcEncounterActions({
});
};
const buildPostNpcChatOptionCatalog = (
encounter: Encounter,
playerCharacter: Character,
) => {
const resolvedStateOptions =
collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
);
const currentStoryOptions = currentStory?.options ?? [];
const currentNpcKey = encounter.id ?? encounter.npcName;
const currentChatOptions = currentStoryOptions.filter((option) =>
isNpcChatOptionForEncounter(option, encounter),
);
const nonChatCurrentOptions = currentStoryOptions.filter(
(option) => !currentChatOptions.includes(option),
);
const nonChatResolvedOptions = resolvedStateOptions.filter(
(option) => !isNpcChatOptionForEncounter(option, encounter),
);
const mergedOptions: StoryOption[] = [];
const seenOptionIdentity = new Set<string>();
const pushUniqueOption = (option: StoryOption) => {
const optionIdentity = [
option.functionId,
option.interaction?.kind ?? '',
option.interaction?.kind === 'npc' ? option.interaction.npcId : '',
option.interaction?.kind === 'npc' ? option.interaction.action : '',
].join('::');
if (seenOptionIdentity.has(optionIdentity)) {
return;
}
seenOptionIdentity.add(optionIdentity);
mergedOptions.push(option);
};
currentChatOptions.slice(0, 1).forEach(pushUniqueOption);
nonChatCurrentOptions.forEach(pushUniqueOption);
nonChatResolvedOptions.forEach(pushUniqueOption);
return mergedOptions;
};
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
@@ -967,6 +1130,7 @@ export function createStoryNpcEncounterActions({
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName)
? currentStory.npcChatState
: null;
const currentCombatContext = currentNpcChatState?.combatContext ?? null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? sanitizeNpcChatDialogueHistory(
@@ -1006,6 +1170,7 @@ export function createStoryNpcEncounterActions({
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
@@ -1045,6 +1210,7 @@ export function createStoryNpcEncounterActions({
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
},
@@ -1055,6 +1221,7 @@ export function createStoryNpcEncounterActions({
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
},
);
@@ -1092,18 +1259,15 @@ export function createStoryNpcEncounterActions({
};
setGameState(finalState);
const affinityTurn =
// 好感变化只保留为一次性表现事件,不再插入聊天消息流。
const latestAffinityEffect =
chatTurn.affinityDelta !== 0
? [
{
speaker: 'system' as const,
text: `${chatTurn.affinityText} \u597d\u611f ${
chatTurn.affinityDelta > 0 ? '+' : '-'
}${Math.abs(chatTurn.affinityDelta)}`,
affinityDelta: chatTurn.affinityDelta,
},
]
: [];
? {
eventId: `npc-chat-affinity-${encounter.id ?? encounter.npcName}-${Date.now()}`,
npcId: encounter.id ?? encounter.npcName,
delta: chatTurn.affinityDelta,
}
: null;
const nextDialogue = [
...dialogueWithPlayer,
@@ -1112,7 +1276,6 @@ export function createStoryNpcEncounterActions({
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
...affinityTurn,
];
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
@@ -1153,6 +1316,7 @@ export function createStoryNpcEncounterActions({
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
npcAffinityEffect: latestAffinityEffect,
});
return true;
}
@@ -1177,6 +1341,8 @@ export function createStoryNpcEncounterActions({
pendingQuestOffer: {
quest: pendingQuest,
},
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
@@ -1195,6 +1361,8 @@ export function createStoryNpcEncounterActions({
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
combatContext: currentCombatContext,
latestAffinityEffect,
}),
);
return true;
@@ -1212,6 +1380,7 @@ export function createStoryNpcEncounterActions({
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
combatContext: currentCombatContext,
}),
);
return false;
@@ -1234,8 +1403,9 @@ export function createStoryNpcEncounterActions({
const choiceText = `结束与${encounter.npcName}的这轮交谈,重新观察当前局势`;
try {
const postChatOptionCatalog = collapseNpcChatOptions(
getAvailableOptionsForState(gameState, playerCharacter) ?? [],
const postChatOptionCatalog = buildPostNpcChatOptionCatalog(
encounter,
playerCharacter,
);
const nextStory = await generateStoryForState({
state: gameState,
@@ -1691,6 +1861,7 @@ export function createStoryNpcEncounterActions({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
replacePendingNpcQuestOffer,

View File

@@ -49,6 +49,15 @@ type BuildNpcStory = (
overrideText?: string,
) => StoryMoment;
type HandleNpcBattleConversationContinuation = (params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
}) => boolean;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
@@ -112,6 +121,7 @@ export async function runLocalStoryChoiceContinuation(params: {
state: GameState,
) => GameState['sceneHostileNpcs'];
buildNpcStory: BuildNpcStory;
handleNpcBattleConversationContinuation: HandleNpcBattleConversationContinuation;
updateQuestLog: UpdateQuestLog;
incrementRuntimeStats: IncrementRuntimeStats;
finalizeNpcBattleResult: (
@@ -289,6 +299,19 @@ export async function runLocalStoryChoiceContinuation(params: {
: null;
fallbackState = nextState;
params.setGameState(nextState);
if (
nextState.currentEncounter &&
params.handleNpcBattleConversationContinuation({
nextState,
encounter: nextState.currentEncounter,
character: params.character,
actionText: params.option.actionText,
resultText: victory.resultText,
battleMode: baseChoiceState.currentNpcBattleMode!,
})
) {
return;
}
try {
const nextStory = await params.generateStoryForState({
state: nextState,

View File

@@ -65,7 +65,10 @@ export type ChoiceRuntimeController = {
export type ChoiceRuntimeSupport = Pick<
StoryRuntimeSupport,
'buildNpcStory' | 'updateQuestLog' | 'updateRuntimeStats'
| 'buildNpcStory'
| 'handleNpcBattleConversationContinuation'
| 'updateQuestLog'
| 'updateRuntimeStats'
>;
export type StoryChoiceCoordinatorParams = {
@@ -148,6 +151,8 @@ export function createStoryChoiceCoordinatorConfig(
getStoryGenerationHostileNpcs: params.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs: params.getResolvedSceneHostileNpcs,
buildNpcStory: params.runtimeSupport.buildNpcStory,
handleNpcBattleConversationContinuation:
params.runtimeSupport.handleNpcBattleConversationContinuation,
updateQuestLog: params.runtimeSupport.updateQuestLog,
incrementRuntimeStats: params.runtimeSupport.updateRuntimeStats,
getCampCompanionTravelScene:

View File

@@ -61,6 +61,7 @@ describe('storyInteractionCoordinator', () => {
const resolveNpcInteractionDecision = vi.fn(() => ({ kind: 'chat' }));
const runtimeSupport = {
buildNpcStory: vi.fn(),
handleNpcBattleConversationContinuation: vi.fn(() => false),
cloneInventoryItemForOwner: vi.fn(),
getNpcEncounterKey: vi.fn(),
getResolvedNpcState: vi.fn(),

View File

@@ -10,6 +10,7 @@ import type {
Encounter,
GameState,
InventoryItem,
NpcBattleMode,
} from '../../types';
import { getNpcEncounterKey } from './storyGenerationState';
@@ -119,6 +120,14 @@ export const storyRuntimeSupport = {
getNpcEncounterKey,
getResolvedNpcState,
buildNpcStory,
handleNpcBattleConversationContinuation: (_params: {
nextState: GameState;
encounter: Encounter;
character: Character;
actionText: string;
resultText: string;
battleMode: NpcBattleMode;
}) => false,
updateNpcState,
updateQuestLog,
updateRuntimeStats,

View File

@@ -16,6 +16,7 @@ import { useStoryNpcInteractionFlow } from './npcInteraction';
import type { StoryInteractionCoordinatorConfig } from './storyInteractionCoordinator';
import type { StoryRuntimeSupport } from './storyRuntimeSupport';
import type {
ChoiceRuntimeSupport,
ChoiceRuntimeController,
StoryChoiceCoordinatorParams,
} from './storyChoiceCoordinator';
@@ -97,6 +98,7 @@ export function useStoryInteractionCoordinator({
enterNpcInteraction,
handleNpcInteraction,
finalizeNpcBattleResult,
reopenNpcChatAfterBattle,
handleNpcChatTurn,
exitNpcChat,
replacePendingNpcQuestOffer,
@@ -104,6 +106,7 @@ export function useStoryInteractionCoordinator({
acceptPendingNpcQuestOffer,
} = createStoryNpcEncounterActions({
...interactionConfig.npcEncounterActions,
buildNpcStory: runtimeSupport.buildNpcStory,
npcInteractionFlow,
});
@@ -173,6 +176,23 @@ export function useStoryInteractionCoordinator({
);
},
};
const choiceRuntimeSupport: ChoiceRuntimeSupport = {
...runtimeSupport,
handleNpcBattleConversationContinuation: ({
nextState,
encounter,
actionText,
resultText,
battleMode,
}) =>
reopenNpcChatAfterBattle({
nextState,
encounter,
actionText,
resultText,
battleMode,
}),
};
const { handleChoice, battleRewardUi, clearStoryChoiceUi } =
useStoryChoiceCoordinator({
gameState,
@@ -187,7 +207,7 @@ export function useStoryInteractionCoordinator({
interactionConfig.npcEncounterActions.getStoryGenerationHostileNpcs,
getResolvedSceneHostileNpcs,
runtimeController: choiceRuntimeController,
runtimeSupport,
runtimeSupport: choiceRuntimeSupport,
enterNpcInteraction,
handleNpcInteraction,
handleTreasureInteraction,

View File

@@ -20,6 +20,18 @@
:root {
--ui-scale: clamp(0.78, 0.72 + 0.45vw, 1.06);
--platform-bottom-nav-height: 3.5rem;
--platform-bottom-nav-padding: 0.25rem;
--platform-bottom-nav-gap: 0.25rem;
--platform-bottom-nav-radius: 1.2rem;
--platform-bottom-nav-button-radius: 1rem;
--platform-bottom-nav-icon-size: 1.05rem;
--platform-bottom-nav-icon-shell-size: 1.55rem;
--platform-bottom-nav-label-size: 11px;
--platform-bottom-nav-label-tracking: 0.18em;
--platform-bottom-nav-content-gap: 0.22rem;
--platform-bottom-nav-active-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 8px 18px rgba(255, 91, 132, 0.1);
}
body {
@@ -138,20 +150,20 @@ body {
color-scheme: light;
--platform-body-fill: radial-gradient(
circle at top left,
rgba(255, 108, 155, 0.16),
transparent 20%
rgba(255, 196, 214, 0.14),
transparent 24%
),
radial-gradient(
circle at 88% 4%,
rgba(255, 195, 150, 0.14),
transparent 18%
rgba(255, 222, 196, 0.12),
transparent 20%
),
radial-gradient(
circle at bottom,
rgba(255, 118, 162, 0.08),
transparent 22%
rgba(255, 214, 225, 0.08),
transparent 26%
),
linear-gradient(180deg, #fffafc 0%, #fffefe 48%, #fff5f8 100%);
linear-gradient(180deg, #fffdfd 0%, #fffefe 50%, #fff8fa 100%);
--platform-panel-shadow: 0 22px 60px rgba(215, 87, 134, 0.12),
0 8px 20px rgba(255, 255, 255, 0.82);
--platform-panel-fill: linear-gradient(
@@ -166,18 +178,28 @@ body {
);
--platform-hero-fill: linear-gradient(
135deg,
rgba(255, 31, 111, 0.96),
rgba(255, 135, 103, 0.92)
rgba(255, 139, 162, 0.9),
rgba(255, 184, 153, 0.88)
);
--platform-hero-glow-a: rgba(255, 255, 255, 0.22);
--platform-hero-glow-b: rgba(255, 228, 211, 0.2);
--platform-hero-overlay-strong: linear-gradient(
135deg,
rgba(255, 146, 170, 0.78),
rgba(255, 201, 171, 0.72)
);
--platform-hero-overlay-soft: linear-gradient(
180deg,
rgba(255, 255, 255, 0.1),
rgba(255, 246, 249, 0.26)
);
--platform-hero-glow-a: rgba(255, 255, 255, 0.18);
--platform-hero-glow-b: rgba(255, 197, 219, 0.18);
--platform-surface-border: rgba(239, 221, 228, 0.9);
--platform-surface-hover-border: rgba(255, 154, 188, 0.58);
--platform-shell-glow-1: rgba(255, 255, 255, 0.22);
--platform-shell-glow-2: rgba(255, 186, 205, 0.22);
--platform-shell-glow-3: rgba(255, 197, 158, 0.16);
--platform-surface-glow-a: rgba(255, 165, 195, 0.16);
--platform-surface-glow-b: rgba(255, 196, 160, 0.14);
--platform-shell-glow-1: rgba(255, 255, 255, 0.2);
--platform-shell-glow-2: rgba(255, 220, 229, 0.18);
--platform-shell-glow-3: rgba(255, 221, 194, 0.14);
--platform-surface-glow-a: rgba(255, 213, 225, 0.14);
--platform-surface-glow-b: rgba(255, 224, 201, 0.12);
--platform-text-strong: #28151d;
--platform-text-base: #5c4650;
--platform-text-soft: #886f79;
@@ -609,6 +631,20 @@ body {
background: var(--platform-hero-fill);
}
.platform-surface--hero::before {
background: radial-gradient(
circle at top left,
var(--platform-hero-glow-a),
transparent 34%
),
radial-gradient(
circle at bottom right,
var(--platform-hero-glow-b),
transparent 32%
),
var(--platform-hero-overlay-soft);
}
.platform-surface--light {
border-color: var(--platform-line-soft);
background: var(--platform-subpanel-fill);
@@ -800,8 +836,13 @@ body {
}
.platform-bottom-nav {
box-sizing: border-box;
min-height: var(--platform-bottom-nav-height);
gap: var(--platform-bottom-nav-gap);
border: 1px solid var(--platform-desktop-panel-border);
border-radius: var(--platform-bottom-nav-radius);
background: var(--platform-nav-fill);
padding: var(--platform-bottom-nav-padding);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 16px 40px rgba(0, 0, 0, 0.1);
@@ -812,13 +853,15 @@ body {
display: flex;
box-sizing: border-box;
width: 100%;
height: 100%;
min-height: calc(
var(--platform-bottom-nav-height) - var(--platform-bottom-nav-padding) * 2
);
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 1rem;
border-radius: var(--platform-bottom-nav-button-radius);
background: transparent;
padding: 0.35rem 0.5rem;
padding: 0;
color: var(--platform-nav-item-text);
transition:
background-color 180ms ease,
@@ -827,6 +870,16 @@ body {
transform 180ms ease;
}
.platform-bottom-nav__button-content {
display: flex;
min-height: 100%;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--platform-bottom-nav-content-gap);
}
.platform-bottom-nav__button:hover {
color: var(--platform-text-strong);
background: var(--platform-nav-item-hover-fill);
@@ -836,9 +889,7 @@ body {
border: 1px solid var(--platform-nav-active-border);
background: var(--platform-nav-active-fill);
color: var(--platform-text-strong);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
var(--platform-nav-active-shadow);
box-shadow: var(--platform-bottom-nav-active-shadow);
}
.platform-bottom-nav__icon-shell,
@@ -857,8 +908,8 @@ body {
}
.platform-bottom-nav__icon-shell {
width: 1.5rem;
height: 1.5rem;
width: var(--platform-bottom-nav-icon-shell-size);
height: var(--platform-bottom-nav-icon-shell-size);
}
.platform-desktop-rail__icon-shell {
@@ -869,12 +920,18 @@ body {
.platform-bottom-nav__icon,
.platform-desktop-rail__icon {
color: var(--platform-nav-item-icon-text);
width: var(--platform-bottom-nav-icon-size);
height: var(--platform-bottom-nav-icon-size);
transition: color 180ms ease;
}
.platform-bottom-nav__label,
.platform-desktop-rail__label {
color: var(--platform-nav-item-text);
font-size: var(--platform-bottom-nav-label-size);
font-weight: 600;
letter-spacing: var(--platform-bottom-nav-label-tracking);
line-height: 1;
transition: color 180ms ease;
}

View File

@@ -13,8 +13,7 @@
* 当前真实调用状态:
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
* 当前优先取这里的本地字段映射,而不是后端
* /api/assets/character-prompts/generate 接口
* 当前直接取这里的本地字段映射
*/
export type PromptDefaultRole = {
name: string;

View File

@@ -978,6 +978,11 @@ export async function streamNpcChatTurn(
state: GameState;
turnCount: number;
} | null;
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'spar_complete';
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;
} = {},
@@ -1002,6 +1007,7 @@ export async function streamNpcChatTurn(
turnCount: options.questOfferContext.turnCount,
}
: null,
combatContext: options.combatContext ?? null,
chatDirective: options.chatDirective ?? null,
} satisfies NpcChatTurnRequest;

View File

@@ -8,6 +8,7 @@ import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
type CustomWorldLandmarkDraft,
getCustomWorldSceneRelativePositionLabel,
normalizeCustomWorldSceneRelativePosition,
normalizeCustomWorldLandmarks,
} from '../data/customWorldSceneGraph';
import {
@@ -120,9 +121,15 @@ export interface CustomWorldGenerationLandmarkOutline {
}
export interface CustomWorldGenerationCampOutline {
id?: string;
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds?: string[];
sceneNpcNames?: string[];
connections?: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationFramework {
@@ -1061,9 +1068,33 @@ function normalizeCampOutline(
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
sceneNpcNames: [
...toStringArray(item.sceneNpcNames),
...toStringArray(item.npcs, 'name'),
...toStringArray(item.sceneNpcs, 'name'),
...toStringArray(item.npcNames),
],
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkName:
toText(connection.targetLandmarkName) ||
toText(connection.target) ||
toText(connection.sceneName),
relativePosition:
toText(connection.relativePosition) ||
toText(connection.position) ||
'forward',
summary:
toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkName),
};
}
@@ -1181,10 +1212,23 @@ function normalizeCampScene(
: {};
return {
id: toText(item.id) || fallback.id,
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
visualDescription: toText(item.visualDescription) || undefined,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
sceneNpcIds: toStringArray(item.sceneNpcIds),
connections: toRecordArray(item.connections)
.map((connection) => ({
targetLandmarkId: toText(connection.targetLandmarkId),
relativePosition: normalizeCustomWorldSceneRelativePosition(
toText(connection.relativePosition) || toText(connection.position) || 'forward',
),
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
};
}

View File

@@ -582,6 +582,72 @@ test('adapts agent draft profile into legacy custom world result profile', () =>
expect(profile?.landmarks[0]?.name).toBe('回潮旧灯塔');
});
test('agent draft result keeps generated role portraits and scene act backgrounds', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
playableNpcs: [
{
...session.draftProfile.playableNpcs[0],
imageSrc: '/generated-characters/playable-1/visual/asset-1/master.png',
generatedVisualAssetId: 'asset-1',
},
],
storyNpcs: [
{
...session.draftProfile.storyNpcs[0],
imageSrc: '/generated-characters/story-1/visual/asset-2/master.png',
generatedVisualAssetId: 'asset-2',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-1',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
},
});
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-1/visual/asset-1/master.png',
);
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe('asset-1');
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-1/visual/asset-2/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe('asset-2');
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
'scene-asset-1',
);
});
test('prefers embedded legacy result profile without dropping compiled runtime fields', () => {
const profile = buildProfileFromEmbeddedLegacyResult();
@@ -604,6 +670,87 @@ test('prefers embedded legacy result profile without dropping compiled runtime f
expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.title).toBe('潮痕');
});
test('embedded legacy result profile merges latest draft asset fields for result view', () => {
const profile = buildCustomWorldProfileFromAgentDraft({
...session,
draftProfile: {
...session.draftProfile,
legacyResultProfile: buildLegacyResultProfile(),
playableNpcs: [
{
...session.draftProfile.playableNpcs[0],
imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-playable',
},
],
storyNpcs: [
{
...session.draftProfile.storyNpcs[0],
imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png',
generatedVisualAssetId: 'asset-runtime-story',
},
],
landmarks: [
{
...session.draftProfile.landmarks[0],
imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png',
},
],
sceneChapters: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
sceneName: '回潮旧灯塔',
title: '灯塔初章',
summary: '围绕灯塔推进的首个场景章节。',
linkedThreadIds: ['thread-1'],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'scene-act-1',
title: '第一幕',
summary: '先接住回潮灯塔的入口压力。',
stageCoverage: ['opening'],
backgroundImageSrc:
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
backgroundAssetId: 'scene-asset-runtime',
encounterNpcIds: ['story-1'],
primaryNpcId: 'story-1',
linkedThreadIds: ['thread-1'],
actGoal: '接住首幕入口',
transitionHook: '向第二幕推进。',
advanceRule: 'after_primary_contact',
},
],
},
],
},
});
expect(profile?.name).toBe('旧版完整结果');
expect(profile?.playableNpcs[0]?.imageSrc).toBe(
'/generated-characters/playable-1/visual/asset-runtime/master.png',
);
expect(profile?.playableNpcs[0]?.generatedVisualAssetId).toBe(
'asset-runtime-playable',
);
expect(profile?.storyNpcs[0]?.imageSrc).toBe(
'/generated-characters/story-1/visual/asset-runtime/master.png',
);
expect(profile?.storyNpcs[0]?.generatedVisualAssetId).toBe(
'asset-runtime-story',
);
expect(profile?.landmarks[0]?.imageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc).toBe(
'/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png',
);
expect(profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundAssetId).toBe(
'scene-asset-runtime',
);
});
test('embedded legacy result profile keeps result-page settings in runtime characters and scenes', () => {
const profile = buildProfileFromEmbeddedLegacyResult();

View File

@@ -178,6 +178,110 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[];
}
function mergeDraftRoleAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftRoles: AdaptedDraftCharacter[],
roleKind: 'playable' | 'story',
) {
const draftRoleById = new Map(draftRoles.map((role) => [role.id, role]));
const currentRoles =
roleKind === 'playable' ? baseProfile.playableNpcs : baseProfile.storyNpcs;
const mergedRoles = currentRoles.map((role) => {
const draftRole = draftRoleById.get(role.id);
if (!draftRole) {
return role;
}
return {
...role,
imageSrc: draftRole.imageSrc ?? role.imageSrc,
generatedVisualAssetId:
draftRole.generatedVisualAssetId ?? role.generatedVisualAssetId,
generatedAnimationSetId:
draftRole.generatedAnimationSetId ?? role.generatedAnimationSetId,
animationMap: draftRole.animationMap ?? role.animationMap,
};
});
if (roleKind === 'playable') {
return {
...baseProfile,
playableNpcs: mergedRoles,
} satisfies CustomWorldProfile;
}
return {
...baseProfile,
storyNpcs: mergedRoles,
} satisfies CustomWorldProfile;
}
function mergeDraftSceneAssetsIntoProfile(
baseProfile: CustomWorldProfile,
draftSceneChapters: CustomWorldProfile['sceneChapterBlueprints'],
draftLandmarks: AdaptedDraftLandmark[],
) {
const normalizedDraftSceneChapters = draftSceneChapters ?? [];
const draftSceneChapterBySceneId = new Map(
normalizedDraftSceneChapters.map((chapter) => [chapter.sceneId, chapter]),
);
const draftLandmarkById = new Map(draftLandmarks.map((entry) => [entry.id, entry]));
const nextCamp = baseProfile.camp
? {
...baseProfile.camp,
imageSrc: baseProfile.camp.imageSrc,
}
: baseProfile.camp;
const nextLandmarks = baseProfile.landmarks.map((landmark) => {
const draftLandmark = draftLandmarkById.get(landmark.id);
return {
...landmark,
imageSrc: draftLandmark?.imageSrc ?? landmark.imageSrc,
};
});
const nextSceneChapterBlueprints =
normalizedDraftSceneChapters.length > 0
? baseProfile.sceneChapterBlueprints?.map((chapter) => {
const draftChapter = draftSceneChapterBySceneId.get(chapter.sceneId);
if (!draftChapter) {
return chapter;
}
const draftActById = new Map(
draftChapter.acts.map((act) => [act.id, act]),
);
return {
...chapter,
acts: chapter.acts.map((act) => {
const draftAct = draftActById.get(act.id);
if (!draftAct) {
return act;
}
return {
...act,
backgroundImageSrc:
draftAct.backgroundImageSrc ?? act.backgroundImageSrc,
backgroundAssetId:
draftAct.backgroundAssetId ?? act.backgroundAssetId,
};
}),
};
}) ?? normalizedDraftSceneChapters
: baseProfile.sceneChapterBlueprints;
return {
...baseProfile,
camp: nextCamp,
landmarks: nextLandmarks,
sceneChapterBlueprints: nextSceneChapterBlueprints,
} satisfies CustomWorldProfile;
}
function toStageCoverage(value: unknown) {
const stageCoverage = Array.isArray(value)
? value
@@ -227,6 +331,8 @@ function adaptDraftSceneChapters(
: ['climax', 'aftermath'],
backgroundImageSrc:
toText(actRecord.backgroundImageSrc) || undefined,
backgroundAssetId:
toText(actRecord.backgroundAssetId) || undefined,
encounterNpcIds,
primaryNpcId,
linkedThreadIds: toStringArray(actRecord.linkedThreadIds),
@@ -268,13 +374,6 @@ export function buildCustomWorldProfileFromAgentDraft(
}
const draftProfile = session.draftProfile;
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
return legacyResultProfile;
}
const settingText = buildAgentDraftFoundationSettingText(session);
const templateWorldType = inferTemplateWorldType(settingText);
const playableNpcs = adaptDraftCharacters(
@@ -292,6 +391,32 @@ export function buildCustomWorldProfileFromAgentDraft(
const landmarkIdSet = new Set(
adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean),
);
const draftSceneChapterBlueprints = adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
);
const legacyResultProfile = normalizeCustomWorldProfileRecord(
draftProfile.legacyResultProfile,
);
if (legacyResultProfile) {
const mergedPlayableProfile = mergeDraftRoleAssetsIntoProfile(
legacyResultProfile,
playableNpcs,
'playable',
);
const mergedStoryProfile = mergeDraftRoleAssetsIntoProfile(
mergedPlayableProfile,
storyNpcs,
'story',
);
return mergeDraftSceneAssetsIntoProfile(
mergedStoryProfile,
draftSceneChapterBlueprints,
adaptedLandmarks,
);
}
const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`,
settingText,
@@ -320,11 +445,7 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
}
: undefined,
sceneChapterBlueprints: adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
),
sceneChapterBlueprints: draftSceneChapterBlueprints,
anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack,

View File

@@ -14,8 +14,8 @@ const baseOperation: CustomWorldAgentOperationRecord = {
operationId: 'operation-1',
type: 'draft_foundation',
status: 'running',
phaseLabel: '生成世界底稿',
phaseDetail: '正在根据已确认锚点编译第一版世界结构。',
phaseLabel: '生成场景角色',
phaseDetail: '正在生成场景角色第 1 / 1 批,当前已完成 0/4。',
progress: 38,
error: null,
};
@@ -96,7 +96,7 @@ const baseSession: CustomWorldAgentSessionSnapshot = {
updatedAt: '2026-04-14T10:00:00.000Z',
};
test('maps running draft_foundation operation to legacy generation progress', () => {
test('maps running draft_foundation operation to refined generation progress steps', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
baseOperation,
1_000,
@@ -104,21 +104,51 @@ test('maps running draft_foundation operation to legacy generation progress', ()
);
expect(progress).not.toBeNull();
expect(progress?.phaseId).toBe('foundation');
expect(progress?.batchLabel).toBe('生成世界底稿');
expect(progress?.phaseId).toBe('story-outline');
expect(progress?.batchLabel).toBe('生成场景角色');
expect(progress?.overallProgress).toBe(38);
expect(progress?.elapsedMs).toBe(4_000);
expect(progress?.estimatedRemainingMs).toBeGreaterThan(0);
expect(progress?.steps).toHaveLength(13);
expect(progress?.steps.map((step) => step.status)).toEqual([
'completed',
'completed',
'completed',
'active',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
'pending',
]);
expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true);
});
test('marks all legacy progress steps complete when draft foundation finishes', () => {
test('maps auto asset phases to refined generation progress steps', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
phaseLabel: '生成幕背景图',
phaseDetail: '正在生成幕背景图 3/6潮汐码头 · 封锁加压。',
progress: 99,
},
1_000,
5_000,
);
expect(progress?.phaseId).toBe('act-backgrounds');
expect(progress?.batchLabel).toBe('生成幕背景图');
expect(progress?.steps.filter((step) => step.status === 'completed')).toHaveLength(
10,
);
expect(progress?.steps[10]?.status).toBe('active');
});
test('marks all refined progress steps complete when draft foundation finishes', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
@@ -138,6 +168,28 @@ test('marks all legacy progress steps complete when draft foundation finishes',
);
});
test('keeps failed draft foundation progress on explicit failure state instead of pretending it is still compiling cards', () => {
const progress = buildAgentDraftFoundationGenerationProgress(
{
...baseOperation,
status: 'failed',
phaseLabel: '底稿生成失败',
phaseDetail: '角色主形象补齐失败,但世界底稿尚未完成写回。',
progress: 100,
error: 'dashscope timeout',
},
1_000,
5_000,
);
expect(progress?.phaseId).toBe('failed');
expect(progress?.phaseLabel).toBe('底稿生成失败');
expect(progress?.phaseDetail).toContain('角色主形象补齐失败');
expect(progress?.steps.some((step) => step.label === '编译草稿卡')).toBe(true);
expect(progress?.steps.some((step) => step.status === 'active')).toBe(false);
expect(progress?.steps.filter((step) => step.status === 'completed').length).toBeGreaterThan(0);
});
test('builds readable draft setting text from creator intent first', () => {
const settingText = buildAgentDraftFoundationSettingText(baseSession);

View File

@@ -193,32 +193,120 @@ export function buildAgentDraftFoundationAnchorEntries(
].filter((entry) => entry.value.trim());
}
type AgentDraftFoundationStepDefinition = {
id: string;
label: string;
detail: string;
matchers: string[];
minProgress: number;
};
type AgentDraftFoundationFailedStep = {
id: string;
label: string;
detail: string;
};
// 这里按真实服务端 phaseLabel 归并步骤,避免把草稿生成硬折成 4 个失真的阶段。
const AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS = [
{
id: 'queue',
label: '接收生成请求',
detail: '正在锁定当前已确认的世界锚点与草稿范围。',
detail: '正在校验当前锚点并准备底稿编译链路。',
matchers: ['已接收请求'],
minProgress: 0,
},
{
id: 'foundation',
label: '生成世界底稿',
detail: '正在根据世界核心、关系种子与冲突线编排第一版世界结构。',
id: 'framework',
label: '整理世界骨架',
detail: '正在生成第一版世界框架、主题与核心冲突。',
matchers: ['整理世界骨架', '生成世界底稿'],
minProgress: 12,
},
{
id: 'playable-outline',
label: '生成可扮演角色',
detail: '正在补出玩家视角角色的首轮名单与定位。',
matchers: ['生成可扮演角色'],
minProgress: 16,
},
{
id: 'story-outline',
label: '生成场景角色',
detail: '正在整理关键 NPC、势力接口人与关系入口。',
matchers: ['生成场景角色'],
minProgress: 30,
},
{
id: 'landmark-seed',
label: '生成关键场景',
detail: '正在补出第一批关键场景与地点骨架。',
matchers: ['生成关键场景'],
minProgress: 44,
},
{
id: 'landmark-network',
label: '建立场景连接',
detail: '正在串联地点关系、线程挂钩与角色分布。',
matchers: ['建立场景连接'],
minProgress: 56,
},
{
id: 'playable-detail',
label: '补全可扮演角色细节',
detail: '正在补全可扮演角色的叙事基础与档案细节。',
matchers: ['补全可扮演角色'],
minProgress: 66,
},
{
id: 'story-detail',
label: '补全场景角色细节',
detail: '正在补全场景角色的叙事基础与档案细节。',
matchers: ['补全场景角色'],
minProgress: 84,
},
{
id: 'finalize',
label: '编译世界底稿',
detail: '正在把分批生成结果汇总成第一版可浏览的世界底稿。',
matchers: ['编译世界底稿'],
minProgress: 97,
},
{
id: 'role-visuals',
label: '生成角色主形象',
detail: '正在为关键角色补主形象预览资源。',
matchers: ['生成角色主形象'],
minProgress: 97,
},
{
id: 'act-backgrounds',
label: '生成幕背景图',
detail: '正在为场景章节的每一幕补背景图预览资源。',
matchers: ['生成幕背景图'],
minProgress: 98,
},
{
id: 'cards',
label: '编译草稿卡',
detail: '正在整理世界卡、角色卡地点卡的摘要和详情。',
detail: '正在整理世界卡、角色卡地点卡与详情结构。',
matchers: ['编译草稿卡'],
minProgress: 99,
},
{
id: 'workspace',
label: '准备精修工作区',
detail: '正在写回草稿数据,并切回可继续精修的工作区。',
matchers: ['世界底稿已生成'],
minProgress: 100,
},
] as const satisfies ReadonlyArray<{
id: string;
label: string;
detail: string;
}>;
] as const satisfies ReadonlyArray<AgentDraftFoundationStepDefinition>;
const AGENT_DRAFT_FOUNDATION_FAILED_STEP = {
id: 'failed',
label: '生成失败',
detail: '这一轮世界草稿没有编译完成,可以返回工作区补充设定后重试。',
} as const satisfies AgentDraftFoundationFailedStep;
function clampProgress(progress: number | null | undefined) {
if (typeof progress !== 'number' || Number.isNaN(progress)) {
@@ -228,29 +316,68 @@ function clampProgress(progress: number | null | undefined) {
return Math.max(0, Math.min(100, Math.round(progress)));
}
function resolveAgentDraftFoundationStepIndexByProgress(progress: number) {
let matchedIndex = 0;
for (
let index = 0;
index < AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
index += 1
) {
if (progress >= AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index].minProgress) {
matchedIndex = index;
}
}
return matchedIndex;
}
function resolveAgentDraftFoundationStepIndex(
operation: CustomWorldAgentOperationRecord,
) {
const progress = clampProgress(operation.progress);
const phaseLabel = operation.phaseLabel.trim();
if (
operation.status === 'completed' ||
phaseLabel.includes('世界底稿已生成') ||
progress >= 90
if (operation.status === 'completed' || phaseLabel.includes('世界底稿已生成')) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 1;
}
for (
let index = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.length - 2;
index >= 0;
index -= 1
) {
return 3;
const step = AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[index];
if (step.matchers.some((matcher) => phaseLabel.includes(matcher))) {
return index;
}
}
if (phaseLabel.includes('编译草稿卡') || progress >= 60) {
return 2;
return resolveAgentDraftFoundationStepIndexByProgress(progress);
}
function resolveAgentDraftFoundationFailedStep(
operation: CustomWorldAgentOperationRecord,
) {
if (operation.status !== 'failed') {
return null;
}
if (phaseLabel.includes('生成世界底稿') || progress >= 25) {
return 1;
}
const phaseLabel = operation.phaseLabel.trim();
const phaseDetail = operation.phaseDetail.trim();
const error = operation.error?.trim() ?? '';
return 0;
return {
id: AGENT_DRAFT_FOUNDATION_FAILED_STEP.id,
label:
phaseLabel ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.label,
detail:
phaseDetail ||
error ||
AGENT_DRAFT_FOUNDATION_FAILED_STEP.detail,
} satisfies AgentDraftFoundationFailedStep;
}
function buildAgentDraftFoundationSteps(
@@ -259,8 +386,12 @@ function buildAgentDraftFoundationSteps(
) {
return AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS.map((step, index) => {
const isCompleted =
operation.status === 'completed' || index < activeStepIndex;
const isActive = !isCompleted && index === activeStepIndex;
operation.status === 'completed' ||
(operation.status === 'failed'
? index < activeStepIndex
: index < activeStepIndex);
const isActive =
operation.status !== 'failed' && !isCompleted && index === activeStepIndex;
return {
id: step.id,
@@ -326,7 +457,9 @@ export function buildAgentDraftFoundationGenerationProgress(
nowMs,
operation.status,
);
const failedStep = resolveAgentDraftFoundationFailedStep(operation);
const activeStep =
failedStep ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[activeStepIndex] ??
AGENT_DRAFT_FOUNDATION_STEP_DEFINITIONS[0];

View File

@@ -10,7 +10,15 @@ type CampProfileSeed = Pick<
> & {
camp?: Pick<
CustomWorldCampScene,
'name' | 'description' | 'dangerLevel' | 'imageSrc'
| 'id'
| 'name'
| 'description'
| 'visualDescription'
| 'dangerLevel'
| 'imageSrc'
| 'sceneNpcIds'
| 'connections'
| 'narrativeResidues'
> | null;
};
@@ -81,9 +89,13 @@ export function buildFallbackCustomWorldCampScene(
const fallbackName = buildFallbackCampName(profile);
return {
id: 'custom-scene-camp',
name: fallbackName,
description: buildFallbackCampDescription(profile, fallbackName),
dangerLevel: 'low',
sceneNpcIds: [],
connections: [],
narrativeResidues: null,
};
}
@@ -94,9 +106,18 @@ export function resolveCustomWorldCampScene(
const camp = profile.camp;
return {
id: camp?.id?.trim() || fallback.id,
name: camp?.name?.trim() || fallback.name,
description: camp?.description?.trim() || fallback.description,
visualDescription: camp?.visualDescription?.trim() || undefined,
dangerLevel: camp?.dangerLevel?.trim() || fallback.dangerLevel,
imageSrc: camp?.imageSrc?.trim() || undefined,
sceneNpcIds: Array.isArray(camp?.sceneNpcIds)
? [...new Set(camp.sceneNpcIds.map((entry) => entry.trim()).filter(Boolean))]
: fallback.sceneNpcIds,
connections: Array.isArray(camp?.connections)
? camp.connections
: fallback.connections,
narrativeResidues: camp?.narrativeResidues ?? fallback.narrativeResidues,
};
}

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import { WorldType, type CustomWorldProfile } from '../types';
import { resolveCustomWorldCoverPresentation } from './customWorldCover';
function createBaseProfile(): CustomWorldProfile {
return {
id: 'custom-world-cover-test',
settingText: '潮雾群岛',
name: '潮雾群岛',
subtitle: '封面规则测试',
summary: '用于验证默认封面优先级。',
tone: '潮湿、压抑',
playerGoal: '查明旧航道真相。',
templateWorldType: WorldType.WUXIA,
compatibilityTemplateWorldType: WorldType.WUXIA,
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema:test',
worldId: 'CUSTOM',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '潮雾群岛',
settingSummary: '封面规则测试',
tone: '潮湿、压抑',
conflictCore: '旧航道真相',
},
slots: [],
},
playableNpcs: [
{
id: 'playable-1',
name: '林潮',
title: '守潮人',
role: '可扮演角色',
description: '负责守住第一道进港口。',
backstory: '他在港口旧案里失去过同伴。',
personality: '谨慎克制。',
motivation: '想查清货船去向。',
combatStyle: '借地形换位。',
initialAffinity: 20,
relationshipHooks: ['旧案'],
tags: ['港口'],
backstoryReveal: {
publicSummary: '他对港口格外熟悉。',
chapters: [],
},
skills: [],
initialItems: [],
imageSrc: '/images/roles/linchao.webp',
},
],
storyNpcs: [],
items: [],
camp: {
id: 'camp-1',
name: '守夜营地',
description: '潮线后的临时据点。',
dangerLevel: 'medium',
imageSrc: '/images/camp/camp.webp',
sceneNpcIds: [],
connections: [],
},
landmarks: [
{
id: 'landmark-1',
name: '潮汐码头',
description: '涨潮时会吞掉半截栈桥。',
dangerLevel: 'high',
imageSrc: '/images/landmark/docks.webp',
sceneNpcIds: [],
connections: [],
},
],
sceneChapterBlueprints: [
{
id: 'scene-chapter-1',
sceneId: 'landmark-1',
title: '潮汐码头',
summary: '第一章开局场景。',
linkedThreadIds: [],
linkedLandmarkIds: ['landmark-1'],
acts: [
{
id: 'act-1',
sceneId: 'landmark-1',
title: '雾里靠岸',
summary: '玩家第一次进入港口。',
stageCoverage: ['opening'],
backgroundImageSrc: '/images/scene/act-1.webp',
backgroundAssetId: 'asset-scene-act-1',
encounterNpcIds: [],
primaryNpcId: 'playable-1',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '拿到第一句真话。',
transitionHook: '下一幕将进入封锁区。',
},
],
},
],
};
}
describe('resolveCustomWorldCoverPresentation', () => {
it('优先使用开局场景第一幕图片作为默认封面底图', () => {
const profile = createBaseProfile();
const result = resolveCustomWorldCoverPresentation(profile);
expect(result.imageSrc).toBe('/images/scene/act-1.webp');
expect(result.renderMode).toBe('scene_with_roles');
expect(result.characterImageSrcs).toEqual(['/images/roles/linchao.webp']);
});
it('当第一幕图片缺失时按营地图与地标图顺序回退', () => {
const profile = createBaseProfile();
profile.sceneChapterBlueprints = [
{
...profile.sceneChapterBlueprints![0],
acts: [
{
...profile.sceneChapterBlueprints![0]!.acts[0]!,
backgroundImageSrc: null,
backgroundAssetId: null,
},
],
},
];
const fallbackToCamp = resolveCustomWorldCoverPresentation(profile);
expect(fallbackToCamp.imageSrc).toBe('/images/camp/camp.webp');
profile.camp = {
...profile.camp!,
imageSrc: '',
};
const fallbackToLandmark = resolveCustomWorldCoverPresentation(profile);
expect(fallbackToLandmark.imageSrc).toBe('/images/landmark/docks.webp');
});
});

View File

@@ -14,7 +14,17 @@ export type CustomWorldCoverPresentation = {
sourceType: CustomWorldCoverProfile['sourceType'];
};
function resolveOpeningSceneFirstActImageSrc(profile: CustomWorldProfile) {
return profile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundImageSrc?.trim() || null;
}
function resolveOpeningSceneImageSrc(profile: CustomWorldProfile) {
// 默认封面优先取开局场景第一幕图,避免草稿页与作品库继续沿用旧的营地兜底策略。
const firstActImageSrc = resolveOpeningSceneFirstActImageSrc(profile);
if (firstActImageSrc) {
return firstActImageSrc;
}
const campImageSrc = profile.camp?.imageSrc?.trim() || '';
if (campImageSrc) {
return campImageSrc;

View File

@@ -1,5 +1,5 @@
import { requestJson } from './apiClient';
import type { CustomWorldProfile } from '../types';
import type { CustomWorldCoverCropRect, CustomWorldProfile } from '../types';
const CUSTOM_WORLD_COVER_API_BASE = '/api/runtime/custom-world';
@@ -26,6 +26,7 @@ export interface UploadCustomWorldCoverImageRequest {
profileId: string;
worldName: string;
imageDataUrl: string;
cropRect: CustomWorldCoverCropRect;
}
export async function generateCustomWorldCoverImage(

View File

@@ -37,6 +37,13 @@ export interface CustomWorldCoverProfile {
characterRoleIds?: string[];
}
export interface CustomWorldCoverCropRect {
x: number;
y: number;
width: number;
height: number;
}
export interface CreatorFactionSeed {
id: string;
name: string;
@@ -337,6 +344,7 @@ export interface SceneActBlueprint {
summary: string;
stageCoverage: SceneActStage[];
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];
primaryNpcId: string;
linkedThreadIds: string[];
@@ -356,10 +364,15 @@ export interface SceneChapterBlueprint {
}
export interface CustomWorldCampScene {
id: string;
name: string;
description: string;
visualDescription?: string;
dangerLevel: string;
imageSrc?: string;
sceneNpcIds: string[];
connections: CustomWorldSceneConnection[];
narrativeResidues?: SceneNarrativeResidue[] | null;
}
export interface CustomWorldLandmark {

View File

@@ -111,6 +111,12 @@ export interface StoryDialogueTurn {
affinityDelta?: number;
}
export interface StoryNpcAffinityEffect {
eventId: string;
npcId: string;
delta: number;
}
export interface StoryNpcChatState {
npcId: string;
npcName: string;
@@ -125,6 +131,11 @@ export interface StoryNpcChatState {
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
combatContext?: {
summary: string;
logLines: string[];
battleOutcome: 'victory' | 'spar_complete';
} | null;
}
export interface CharacterChatTurn {
@@ -149,6 +160,7 @@ export interface StoryMoment {
deferredOptions?: StoryOption[];
historyRole?: StoryHistoryRole;
npcChatState?: StoryNpcChatState;
npcAffinityEffect?: StoryNpcAffinityEffect | null;
}
export type StoryOptionInteraction =