Persist custom world asset configs in runtime snapshots

This commit is contained in:
2026-04-18 17:00:46 +08:00
parent 7ce61e9879
commit ac801fe05f
29 changed files with 3397 additions and 400 deletions

View File

@@ -45,14 +45,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
imageClassName,
playbackRate = 1,
}) => {
const [frameIndex, setFrameIndex] = useState(1);
const config =
character.animationMap?.[state] ??
DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE];
const startFrame = config.startFrame ?? 1;
const frameCount = config.frames;
const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
? Math.max(1, Math.floor(config.startFrame))
: 1;
const [frameIndex, setFrameIndex] = useState(startFrame);
const frameCount =
typeof config.frames === 'number' && Number.isFinite(config.frames)
? Math.max(1, Math.floor(config.frames))
: 1;
const fps =
typeof config.fps === 'number' && Number.isFinite(config.fps)
? Math.max(1, config.fps)
@@ -72,26 +78,33 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
fps,
effectivePlaybackRate,
].join('::');
const endFrame = startFrame + frameCount - 1;
const intervalDelay = Math.max(
40,
Math.round(1000 / (fps * effectivePlaybackRate)),
);
useEffect(() => {
setFrameIndex(startFrame);
setFrameIndex((current) => (current === startFrame ? current : startFrame));
}, [animationSignature, startFrame]);
useEffect(() => {
if (frameCount <= 1) return;
const endFrame = startFrame + frameCount - 1;
const interval = window.setInterval(() => {
setFrameIndex(prev => {
return prev >= endFrame ? startFrame : prev + 1;
setFrameIndex((current) => {
if (current < startFrame || current > endFrame) {
return startFrame;
}
return current >= endFrame ? startFrame : current + 1;
});
}, Math.max(40, Math.round(1000 / (fps * effectivePlaybackRate))));
}, intervalDelay);
return () => window.clearInterval(interval);
}, [
animationSignature,
effectivePlaybackRate,
fps,
endFrame,
frameCount,
intervalDelay,
startFrame,
]);

View File

@@ -286,8 +286,11 @@ export function CompanionCampModal({
<div className="border-t border-white/10 px-5 py-4">
<div className="mb-3 text-xs font-bold text-white"></div>
<div className="grid gap-3 md:grid-cols-3">
{campMoments.map(moment => (
<div key={moment} className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300">
{campMoments.map((moment, index) => (
<div
key={`camp-moment-${index}-${moment}`}
className="rounded-xl border border-white/8 bg-black/18 px-4 py-3 text-sm leading-relaxed text-zinc-300"
>
{moment}
</div>
))}

View File

@@ -11,6 +11,7 @@ import {
useState,
} from 'react';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { getCustomWorldSceneRelativePositionLabel } from '../data/customWorldSceneGraph';
import {
resolveCustomWorldCampSceneImage,
@@ -184,6 +185,14 @@ function EmptyState({ title }: { title: string }) {
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
function NewBadge() {
return (
<span className="rounded-full border border-amber-300/24 bg-amber-500/12 px-2.5 py-1 text-[10px] font-semibold text-amber-100">
@@ -349,6 +358,43 @@ function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function buildPlayableRoleCardDescription(
role: CustomWorldProfile['playableNpcs'][number],
) {
const summary =
role.description.trim() ||
role.backstoryReveal.publicSummary.trim() ||
role.backstory.trim() ||
role.motivation.trim();
return compactTextList([role.title || role.role, summary]).join(' / ');
}
function resolvePlayableRolePreviewImage(
role: CustomWorldProfile['playableNpcs'][number],
previewCharacter: Character | null,
) {
if (previewCharacter?.portrait?.trim()) {
return previewCharacter.portrait;
}
if (previewCharacter?.avatar?.trim()) {
return previewCharacter.avatar;
}
if (role.imageSrc?.trim()) {
return role.imageSrc;
}
const template = role.templateCharacterId
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
return template?.portrait ?? '';
}
function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
@@ -1165,166 +1211,95 @@ export function CustomWorldEntityCatalog({
progress={pendingGeneratedEntity.progress}
/>
) : null}
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{readOnly
? '当前是草稿结果预览,可先浏览角色结构,再回到工作区继续精修。'
: '可扮演角色支持新增、删除与更换外观模板。'}
</div>
{filteredPlayable.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的可扮演角色。" />
) : (
filteredPlayable.map((role) => {
filteredPlayable.map((role, index) => {
const previewCharacter =
previewCharacterById.get(role.id) ?? null;
const previewImageSrc = resolvePlayableRolePreviewImage(
role,
previewCharacter,
);
const description = buildPlayableRoleCardDescription(role);
return (
<div key={role.id}>
<Section
<div
key={buildFallbackRenderKey(
role.id,
`playable-role-${index}-${role.name.trim() || 'unnamed'}`,
)}
className="space-y-2"
>
<CatalogCard
title={role.name}
subtitle={role.title}
description={description || '暂无描述'}
badge={recentPlayableIdSet.has(role.id) ? <NewBadge /> : null}
actions={
readOnly ? (
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
isSelectionMode={false}
isSelected={false}
layout="compact"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
media={
previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : previewImageSrc ? (
<img
src={previewImageSrc}
alt={role.name}
className="h-full w-full object-cover object-top"
/>
) : (
<div className="flex items-center gap-2">
<SmallButton
onClick={() =>
onEditTarget({
kind: 'playable',
mode: 'edit',
id: role.id,
})
}
tone="sky"
>
</SmallButton>
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
<div className="flex h-full w-full items-center justify-center bg-black/30 px-3 text-center text-xs font-semibold tracking-[0.16em] text-zinc-400">
{role.name.slice(0, 4) || '角色'}
</div>
)
}
>
<div className="flex flex-col gap-3 sm:flex-row">
<div className="flex h-28 w-28 shrink-0 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/35">
{previewCharacter ? (
<CharacterAnimator
state={AnimationState.RUN}
character={previewCharacter}
className="h-full w-full"
imageClassName="object-bottom"
/>
) : null}
/>
<div className="flex flex-wrap items-center gap-2 px-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<span className="rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</span>
) : null}
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300">
{role.initialAffinity}
</span>
{role.generatedVisualAssetId ? (
<span className="rounded-full border border-emerald-400/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] text-emerald-100">
</span>
) : null}
{role.tags.slice(0, 2).map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
{!readOnly ? (
<div className="ml-auto">
<SmallButton
onClick={() => removePlayable(role.id, role.name)}
tone="rose"
>
</SmallButton>
</div>
<div className="min-w-0 flex-1">
{lockedCharacterNames.has(role.name.trim()) ? (
<div className="mb-2 inline-flex rounded-full border border-amber-300/18 bg-amber-500/10 px-2.5 py-1 text-[10px] text-amber-100">
</div>
) : null}
<div className="text-sm leading-6 text-zinc-300">
{role.description}
</div>
<div className="mt-2 text-xs leading-6 text-zinc-400">
{role.backstory}
</div>
<div className="mt-3 rounded-xl border border-sky-300/12 bg-sky-500/8 px-3 py-2 text-xs leading-6 text-sky-50/95">
{role.backstoryReveal.publicSummary || '未填写'}
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2">
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.role}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.initialAffinity}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.personality}
</div>
<div className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.combatStyle}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300">
{role.motivation}
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.backstoryReveal.chapters.map((chapter) => (
<div
key={`${role.id}-${chapter.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{chapter.affinityRequired} ·{' '}
{chapter.title}{chapter.teaser}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.skills.map((skill) => (
<div
key={`${role.id}-${skill.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{skill.name} · {skill.style}{skill.summary}
</div>
))}
</div>
</div>
<div className="mt-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-400">
</div>
<div className="mt-2 space-y-2">
{role.initialItems.map((item) => (
<div
key={`${role.id}-${item.id}`}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs leading-6 text-zinc-300"
>
{item.name} x{item.quantity} · {item.category} ·{' '}
{item.rarity}{item.description}
</div>
))}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{role.tags.map((tag) => (
<span
key={`${role.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
))}
</div>
</div>
</div>
</Section>
) : null}
</div>
</div>
);
})
@@ -1344,8 +1319,13 @@ export function CustomWorldEntityCatalog({
{filteredStory.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景角色。" />
) : (
filteredStory.map((npc) => (
<div key={npc.id}>
filteredStory.map((npc, index) => (
<div
key={buildFallbackRenderKey(
npc.id,
`story-npc-${index}-${npc.name.trim() || 'unnamed'}`,
)}
>
<CatalogCard
title={npc.name}
description={npc.description}
@@ -1399,8 +1379,13 @@ export function CustomWorldEntityCatalog({
{filteredSceneEntries.length === 0 ? (
<EmptyState title="当前没有符合搜索条件的场景。" />
) : (
filteredSceneEntries.map((scene) => (
<div key={scene.id}>
filteredSceneEntries.map((scene, index) => (
<div
key={buildFallbackRenderKey(
scene.id,
`scene-entry-${index}-${scene.name.trim() || scene.kind}`,
)}
>
<CatalogCard
title={scene.name}
description={

View File

@@ -2,6 +2,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
import type {
@@ -9,12 +10,32 @@ import type {
CustomWorldPlayableNpc,
CustomWorldProfile,
} from '../types';
import { CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
CustomWorldEntityEditorModal,
type CustomWorldEditorTarget,
} from './CustomWorldEntityEditorModal';
vi.mock('../data/characterPresets', async () => {
const actual = await vi.importActual<typeof import('../data/characterPresets')>(
'../data/characterPresets',
);
return {
...actual,
buildCustomWorldPlayableCharacters: vi.fn(() => []),
};
});
vi.mock('./CharacterAnimator', () => ({
CharacterAnimator: () => <div></div>,
}));
vi.mock('../services/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => (
<div>{npc.name}</div>
@@ -136,6 +157,94 @@ function createProfile(): CustomWorldProfile {
} as unknown as CustomWorldProfile;
}
function createProfileWithLandmark(): CustomWorldProfile {
return {
...createProfile(),
storyNpcs: [
createStoryRole('story-1', '顾潮音'),
createStoryRole('story-2', '闻雪汀'),
createStoryRole('story-3', '谢孤灯'),
],
landmarks: [
{
id: 'landmark-1',
name: '沉钟栈桥',
description: '旧钟与潮声常年相撞的码头栈桥。',
dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-scene.png',
sceneNpcIds: ['story-1', 'story-2', 'story-3'],
connections: [],
},
],
} as unknown as CustomWorldProfile;
}
function LandmarkEditorFlowHarness() {
const [profile, setProfile] = useState(createProfileWithLandmark());
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
kind: 'landmark',
mode: 'edit',
id: 'landmark-1',
});
return (
<>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={setTarget}
onProfileChange={setProfile}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<CustomWorldEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
function CampEditorFlowHarness() {
const [profile, setProfile] = useState({
...createProfileWithLandmark(),
camp: {
name: '潮灯居',
description: '玩家最初落脚的旧灯塔内院。',
dangerLevel: 'medium',
imageSrc: '/generated-custom-world-scenes/original-camp.png',
},
});
const [target, setTarget] = useState<CustomWorldEditorTarget | null>({
kind: 'camp',
});
return (
<>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
activeTab="landmarks"
onActiveTabChange={() => {}}
onEditTarget={setTarget}
onProfileChange={setProfile}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>
<CustomWorldEntityEditorModal
profile={profile}
target={target}
onClose={() => setTarget(null)}
onProfileChange={setProfile}
/>
</>
);
}
test('playable角色打开AI工坊后不会自动关闭', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
@@ -179,3 +288,263 @@ test('场景角色打开AI工坊后不会自动关闭', async () => {
expect(handleClose).not.toHaveBeenCalled();
});
test('可扮演角色未修改时右上角关闭不会弹确认', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(handleClose).toHaveBeenCalledTimes(1);
expect(screen.queryByText('确认关闭')).toBeNull();
});
test('可扮演角色修改后右上角关闭才弹确认', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'playable', mode: 'edit', id: 'playable-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
const nameInput = screen.getByDisplayValue('沈砺');
await user.clear(nameInput);
await user.type(nameInput, '沈砺·改');
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(handleClose).not.toHaveBeenCalled();
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
});
test('场景角色未修改时右上角关闭不会弹确认', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(handleClose).toHaveBeenCalledTimes(1);
expect(screen.queryByText('确认关闭')).toBeNull();
});
test('场景角色修改后右上角关闭才弹确认', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<CustomWorldEntityEditorModal
profile={createProfile()}
target={{ kind: 'story', mode: 'edit', id: 'story-1' }}
onClose={handleClose}
onProfileChange={vi.fn()}
/>,
);
const nameInput = screen.getByDisplayValue('顾潮音');
await user.clear(nameInput);
await user.type(nameInput, '顾潮音·改');
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(handleClose).not.toHaveBeenCalled();
expect(screen.getAllByText('确认关闭').length).toBeGreaterThan(0);
});
test('可扮演角色列表使用缩略卡片并点击进入编辑', async () => {
const user = userEvent.setup();
const handleEditTarget = vi.fn();
render(
<CustomWorldEntityCatalog
profile={createProfile()}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
onEditTarget={handleEditTarget}
onProfileChange={vi.fn()}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
expect(screen.queryByText(//u)).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
expect(handleEditTarget).toHaveBeenCalledWith({
kind: 'playable',
mode: 'edit',
id: 'playable-1',
});
});
test('实体目录在空 id 列表项下不会触发重复 key 警告', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
render(
<CustomWorldEntityCatalog
profile={{
...createProfile(),
playableNpcs: [
createPlayableRole('', '沈砺'),
createPlayableRole('', '闻潮'),
],
}}
previewCharacters={[]}
activeTab="playable"
onActiveTabChange={() => {}}
onEditTarget={() => {}}
onProfileChange={vi.fn()}
onDeleteStoryNpcs={() => {}}
onDeleteLandmarks={() => {}}
/>,
);
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});
test('场景图片保存后会同步更新编辑页和场景列表', async () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/updated-scene.png',
assetId: 'asset-1',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-1',
prompt: '更新后的场景图',
});
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
const initialListImage = screen.getByRole('img', { name: '沉钟栈桥' });
expect(initialListImage.getAttribute('src')).toBe(
'/generated-custom-world-scenes/original-scene.png',
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
});
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(screen.queryByText('智能生成:沉钟栈桥')).toBeNull();
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '沉钟栈桥' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-scene.png',
);
});
});
test('开局场景图片保存后会同步更新编辑页和场景列表', async () => {
const aiService = await import('../services/aiService');
vi.mocked(aiService.generateCustomWorldSceneImage).mockClear();
vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({
imageSrc: '/generated-custom-world-scenes/updated-camp.png',
assetId: 'asset-camp-1',
model: 'wan2.2-t2i-flash',
size: '1280*720',
taskId: 'task-camp-1',
prompt: '更新后的开局场景图',
});
const user = userEvent.setup();
render(<CampEditorFlowHarness />);
const initialListImage = screen.getByRole('img', { name: '潮灯居' });
expect(initialListImage.getAttribute('src')).toBe(
'/generated-custom-world-scenes/original-camp.png',
);
await user.click(screen.getByRole('button', { name: 'AI生成' }));
await waitFor(() => {
expect(screen.getByText('智能生成:潮灯居')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: '开始生成' }));
await waitFor(() => {
expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1);
});
await user.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(screen.queryByText('智能生成:潮灯居')).toBeNull();
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '场景图片' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByRole('img', { name: '场景图片' })).toBeNull();
});
await waitFor(() => {
expect(screen.getByRole('img', { name: '潮灯居' }).getAttribute('src')).toBe(
'/generated-custom-world-scenes/updated-camp.png',
);
});
});

View File

@@ -1,6 +1,6 @@
import type { ChangeEvent } from 'react';
import type { CSSProperties } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useState } from 'react';
import { Children, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
@@ -352,9 +352,29 @@ function syncLandmarksWithStoryNpcs(
});
}
function buildDraftSyncToken(value: unknown) {
try {
const serialized = JSON.stringify(value);
return serialized ?? 'undefined';
} catch {
return String(value);
}
}
function useDraft<T>(value: T) {
const syncToken = useMemo(() => buildDraftSyncToken(value), [value]);
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
const lastSyncedTokenRef = useRef(syncToken);
useEffect(() => {
if (lastSyncedTokenRef.current === syncToken) {
return;
}
lastSyncedTokenRef.current = syncToken;
setDraft(value);
}, [syncToken, value]);
return [draft, setDraft] as const;
}
@@ -421,6 +441,7 @@ function ModalShell({
type="button"
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
@@ -495,6 +516,7 @@ function CompactDialogShell({
type="button"
onClick={onClose}
disabled={disableClose}
aria-label="关闭"
className={`rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white ${disableClose ? 'cursor-not-allowed opacity-45' : ''}`}
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
@@ -843,7 +865,7 @@ function ScenePresetPickerModal({
const isSelected = src === selectedSrc;
return (
<button
key={src}
key={`preset-image-${index}-${src || 'empty'}`}
type="button"
onClick={() => {
onSelect(src);
@@ -1666,7 +1688,7 @@ function SaveBar({
showClose?: boolean;
}) {
return (
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0)_0%,rgba(8,10,17,0.84)_42%,rgba(8,10,17,0.96)_100%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.2rem)] pt-2 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div
className={`flex flex-col gap-3 ${
extraAction
@@ -2813,7 +2835,11 @@ function CampSceneEditor({
onSaveProfile: (profile: CustomWorldProfile) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useDraft(resolveCustomWorldCampScene(profile));
const initialCampScene = useMemo(
() => resolveCustomWorldCampScene(profile),
[profile],
);
const [draft, setDraft] = useDraft(initialCampScene);
const [isPresetPickerOpen, setIsPresetPickerOpen] = useState(false);
const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
@@ -2833,8 +2859,8 @@ function CampSceneEditor({
[draft],
);
const initialSnapshot = useMemo(
() => JSON.stringify(resolveCustomWorldCampScene(profile)),
[profile],
() => JSON.stringify(initialCampScene),
[initialCampScene],
);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const campSceneDraft = useMemo<CustomWorldLandmark>(
@@ -3002,6 +3028,9 @@ function PlayableNpcEditor({
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const selectedTemplate =
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
@@ -3027,6 +3056,10 @@ function PlayableNpcEditor({
}));
const handleRequestClose = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};
@@ -3300,6 +3333,9 @@ function StoryNpcEditor({
const [isVisualEditorOpen, setIsVisualEditorOpen] = useState(false);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const [isCloseConfirmOpen, setIsCloseConfirmOpen] = useState(false);
const initialSnapshot = useMemo(() => JSON.stringify(npc), [npc]);
const draftSnapshot = useMemo(() => JSON.stringify(draft), [draft]);
const hasUnsavedChanges = draftSnapshot !== initialSnapshot;
const roleOptions = useMemo(
() =>
[...profile.playableNpcs, ...profile.storyNpcs]
@@ -3319,6 +3355,10 @@ function StoryNpcEditor({
}));
const handleRequestClose = () => {
if (!hasUnsavedChanges) {
onClose();
return;
}
setIsCloseConfirmOpen(true);
};

View File

@@ -48,6 +48,14 @@ function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
@@ -171,9 +179,9 @@ export function CustomWorldGenerationView({
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
{steps.map((step) => (
{steps.map((step, index) => (
<div
key={step.id}
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
className={`rounded-2xl border px-4 py-3 transition-colors ${
step.status === 'completed'
? 'border-emerald-400/16 bg-emerald-500/8'
@@ -269,9 +277,12 @@ export function CustomWorldGenerationView({
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry) => (
{anchorEntries.map((entry, index) => (
<div
key={entry.id}
key={buildFallbackRenderKey(
entry.id,
`anchor-entry-${index}`,
)}
className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">

View File

@@ -3,8 +3,8 @@ import {
RefreshCcw,
} from 'lucide-react';
import {
type CSSProperties,
type ChangeEvent,
type CSSProperties,
type ReactNode,
useEffect,
useMemo,
@@ -15,8 +15,8 @@ import { createPortal } from 'react-dom';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CharacterAnimationConfig,
type Character,
type CharacterAnimationConfig,
} from '../types';
import {
buildAnimationClipFromVideoSource,
@@ -358,6 +358,37 @@ function buildRoleCharacterBrief(
.join('\n');
}
function isLegacyGeneratedVisualDescription(value: string) {
const normalized = value.trim();
if (!normalized) {
return false;
}
return [
'2D 横版 RPG',
'纯绿色绿幕',
'2 到 2.5 头身',
'深色粗轮廓',
'身体整体朝右',
'脚底完整可见',
].some((marker) => normalized.includes(marker));
}
function isLegacyGeneratedActionDescription(value: string) {
const normalized = value.trim();
if (!normalized) {
return false;
}
return [
'动作气质参考:',
'发力起手明确',
'收招利落',
'动作表现偏向',
'起手克制',
].some((marker) => normalized.includes(marker));
}
function mergeRole<T extends EditableCustomWorldRole>(
role: T,
patch: Partial<T>,
@@ -712,10 +743,16 @@ export function CustomWorldRoleAssetStudioModal({
});
setWorkingRole(nextRole);
setVisualPromptText(
cache.visualPromptText || initialPromptBundle.visualPromptText,
cache.visualPromptText &&
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
? cache.visualPromptText
: initialPromptBundle.visualPromptText,
);
setAnimationPromptText(
cache.animationPromptText || initialPromptBundle.animationPromptText,
cache.animationPromptText &&
!isLegacyGeneratedActionDescription(cache.animationPromptText)
? cache.animationPromptText
: initialPromptBundle.animationPromptText,
);
setVisualDrafts(cache.visualDrafts ?? []);
setSelectedVisualDraftId(
@@ -904,6 +941,8 @@ export function CustomWorldRoleAssetStudioModal({
}
const isLoopAction = config.loop;
const shouldUseLastFrameReference =
!isLoopAction && config.animation !== AnimationState.DIE;
const result = await generateCharacterAnimationDraft({
characterId: workingRole.id,
@@ -915,7 +954,9 @@ export function CustomWorldRoleAssetStudioModal({
visualSource: workingRole.imageSrc,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
lastFrameImageDataUrl: isLoopAction ? undefined : workingRole.imageSrc,
lastFrameImageDataUrl: shouldUseLastFrameReference
? workingRole.imageSrc
: undefined,
frameCount: config.frameCount,
fps: config.fps,
durationSeconds: config.durationSeconds,
@@ -1108,12 +1149,12 @@ export function CustomWorldRoleAssetStudioModal({
</div>
</div>
<Field label="形象提示词">
<Field label="形象描述">
<TextArea
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="角色形象提示词会先按设定自动生成,也可以继续手动细化。"
placeholder="这里默认展示角色形象描述,也可以继续手动细化。"
/>
</Field>
@@ -1296,12 +1337,12 @@ export function CustomWorldRoleAssetStudioModal({
})}
</div>
<Field label="动作提示词">
<Field label="动作描述">
<TextArea
value={animationPromptText}
onChange={setAnimationPromptText}
rows={5}
placeholder="角色动作提示词会先按设定自动生成,也可以继续手动细化。"
placeholder="这里默认展示角色动作描述,也可以继续手动细化。"
/>
</Field>

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
import { buildDefaultRolePromptBundle } from './customWorldRolePromptDefaults';
describe('buildDefaultRolePromptBundle', () => {
it('prefers model-generated role descriptions instead of rule-based assembly', () => {
it('uses model-generated role descriptions directly', () => {
const result = buildDefaultRolePromptBundle({
name: '沈砺',
title: '灰炬向导',
@@ -28,7 +28,7 @@ describe('buildDefaultRolePromptBundle', () => {
);
});
it('falls back to compact role descriptions without reintroducing built-in prompt rules', () => {
it('falls back to existing entity descriptions without assembling new rules', () => {
const result = buildDefaultRolePromptBundle({
name: '顾潮音',
title: '港口守望者',
@@ -41,11 +41,11 @@ describe('buildDefaultRolePromptBundle', () => {
tags: ['潮雾港', '守望', '旧案'],
});
expect(result.visualPromptText).toContain('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toContain('长枪封线后借高差压制。');
expect(result.scenePromptText).toContain('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('2D 横版 RPG');
expect(result.visualPromptText).not.toContain('纯绿色绿幕');
expect(result.visualPromptText).toBe('总在潮雾港高处盯着来往船影的守望者。');
expect(result.animationPromptText).toBe('长枪封线后借高差压制。');
expect(result.scenePromptText).toBe('他把许多没说出口的旧案痕迹留在港口高处。');
expect(result.visualPromptText).not.toContain('经典横版像素动作角色');
expect(result.visualPromptText).not.toContain('深色粗轮廓配合清晰大色块');
expect(result.visualPromptText).not.toContain('提示词');
});
});

View File

@@ -23,52 +23,35 @@ function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
function compactDescription(parts: Array<string | undefined>, maxLength: number) {
return parts
.map((item) => cleanSeedText(item, maxLength))
.filter(Boolean)
.join(' ')
.slice(0, maxLength);
function pickFirstDescription(
values: Array<string | undefined>,
maxLength: number,
) {
for (const value of values) {
const normalized = cleanSeedText(value, maxLength);
if (normalized) {
return normalized;
}
}
return '';
}
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {
const roleLabel = [cleanSeedText(role.name, 40), cleanSeedText(role.title, 40)]
.filter(Boolean)
.join('');
const fallbackVisualDescription = compactDescription(
[
roleLabel || cleanSeedText(role.role, 40),
role.description,
role.personality,
role.tags && role.tags.length > 0 ? role.tags.slice(0, 8).join('、') : '',
],
220,
);
const fallbackActionDescription = compactDescription(
[
role.actionDescription,
role.combatStyle,
role.motivation,
role.personality,
],
180,
);
const generatedSceneDescription = cleanSeedText(role.sceneVisualDescription, 220);
const fallbackSceneDescription = compactDescription(
[
role.backstory,
role.description,
role.motivation,
],
220,
);
return {
visualPromptText:
cleanSeedText(role.visualDescription, 220) || fallbackVisualDescription,
animationPromptText: fallbackActionDescription,
scenePromptText: generatedSceneDescription || fallbackSceneDescription,
visualPromptText: pickFirstDescription(
[role.visualDescription, role.description],
220,
),
animationPromptText: pickFirstDescription(
[role.actionDescription, role.combatStyle],
180,
),
scenePromptText: pickFirstDescription(
[role.sceneVisualDescription, role.backstory],
220,
),
};
}

View File

@@ -1,8 +1,15 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { afterEach, expect, test, vi } from 'vitest';
import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel';
afterEach(() => {
vi.restoreAllMocks();
});
test('clarification panel shows pending questions and ready state', () => {
const pendingHtml = renderToStaticMarkup(
<CustomWorldAgentClarificationPanel
@@ -44,3 +51,48 @@ test('clarification panel shows pending questions and ready state', () => {
expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里');
expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段');
});
test('falls back to stable keys when clarification ids are empty', () => {
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
render(
<CustomWorldAgentClarificationPanel
readiness={{
isReady: false,
completedKeys: [],
missingKeys: ['player_premise', 'core_conflict'],
}}
pendingClarifications={[
{
id: '',
label: '玩家身份与开局',
question: '玩家是谁,故事开场时卡在什么处境里?',
targetKey: 'player_premise',
priority: 2,
},
{
id: '',
label: '核心冲突',
question: '第一阶段最直接撞上的冲突是什么?',
targetKey: 'core_conflict',
priority: 1,
},
]}
/>,
);
expect(screen.getByText(//u)).toBeTruthy();
expect(screen.getByText(//u)).toBeTruthy();
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});

View File

@@ -44,7 +44,7 @@ export function CustomWorldAgentClarificationPanel({
<div className="mt-4 space-y-2">
{pendingClarifications.slice(0, 3).map((item, index) => (
<div
key={item.id}
key={item.id.trim() || `clarification-${item.targetKey}-${index}`}
className="rounded-[1.15rem] border border-white/8 bg-white/5 px-3 py-3"
>
<div className="flex items-start justify-between gap-3">

View File

@@ -44,7 +44,6 @@ import {
} from '../../services/customWorldAgentUiState';
import {
buildCustomWorldCreatorIntentFoundationText,
buildCustomWorldCreatorIntentGenerationText,
} from '../../services/customWorldCreatorIntent';
import {
clearPlatformBrowseHistory,
@@ -151,14 +150,6 @@ function buildOptimisticAgentMessage(
};
}
function buildAgentSeedTextFromProfile(profile: CustomWorldProfile) {
return (
buildCustomWorldCreatorIntentGenerationText(profile.creatorIntent).trim() ||
buildCustomWorldCreatorIntentFoundationText(profile.creatorIntent).trim() ||
profile.settingText.trim()
);
}
function normalizeAgentBackedProfile(profile: CustomWorldProfile) {
const foundationText = buildCustomWorldCreatorIntentFoundationText(
profile.creatorIntent,
@@ -784,29 +775,6 @@ export function PreGameSelectionFlow({
}
};
const continueWorldInAgent = async (
profile = generatedCustomWorldProfile,
) => {
if (!profile || isCreatingAgentSession) {
return;
}
if (isAgentDraftResultView && activeAgentSessionId) {
setGeneratedCustomWorldProfile(null);
setCustomWorldError(null);
setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle');
setAgentDraftGenerationStartedAt(null);
setCustomWorldGenerationViewSource(null);
setCustomWorldResultViewSource(null);
setPlatformTab('create');
setSelectionStage('agent-workspace');
return;
}
await openRpgAgentWorkspace(buildAgentSeedTextFromProfile(profile));
};
const submitAgentMessage = async (
payload: SendCustomWorldAgentMessageRequest,
) => {
@@ -961,10 +929,6 @@ export function PreGameSelectionFlow({
openCreationTypePicker();
};
const editCustomWorldSetting = () => {
void continueWorldInAgent();
};
const openLibraryDetail = (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => {
@@ -1441,9 +1405,7 @@ export function PreGameSelectionFlow({
? leaveAgentDraftResult
: leaveCustomWorldResult
}
onEditSetting={
isAgentDraftResultView ? undefined : editCustomWorldSetting
}
onEditSetting={undefined}
onRegenerate={undefined}
onContinueExpand={undefined}
onEnterWorld={() => {

View File

@@ -1,11 +1,11 @@
import { rehydrateSavedSnapshot } from '../../persistence/runtimeSnapshot';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
getRuntimeSessionId,
getRuntimeStoryState,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
type RuntimeStoryChoicePayload,
type RuntimeStoryResponse,
} from '../../services/runtimeStoryService';
@@ -38,10 +38,11 @@ export async function loadServerRuntimeOptionCatalog(params: {
const response = await getRuntimeStoryState(
getRuntimeSessionId(params.gameState),
);
const options = buildStoryMomentFromRuntimeOptions({
storyText: response.presentation.storyText,
options: getRuntimeResponseOptions(response),
gameState: params.gameState,
const options = resolveRuntimeStoryMoment({
response,
hydratedSnapshot: response.snapshot,
fallbackGameState: params.gameState,
fallbackStoryText: response.presentation.storyText,
}).options;
return options.length > 0 ? options : null;
@@ -70,14 +71,15 @@ export async function resumeServerRuntimeStory(
const runtimeOptions = getRuntimeResponseOptions(response);
const nextStory =
response.presentation.storyText || runtimeOptions.length > 0
? buildStoryMomentFromRuntimeOptions({
storyText:
? resolveRuntimeStoryMoment({
response,
hydratedSnapshot: resumedSnapshot,
fallbackGameState: hydratedSnapshot.gameState,
fallbackStoryText:
response.presentation.storyText ||
resumedSnapshot.currentStory?.text ||
hydratedSnapshot.currentStory?.text ||
'',
options: runtimeOptions,
gameState: resumedSnapshot.gameState,
})
: resumedSnapshot.currentStory;
@@ -111,13 +113,14 @@ export async function resolveServerRuntimeChoice(params: {
return {
response,
hydratedSnapshot,
nextStory: buildStoryMomentFromRuntimeOptions({
storyText:
nextStory: resolveRuntimeStoryMoment({
response,
hydratedSnapshot,
fallbackGameState: params.gameState,
fallbackStoryText:
response.presentation.storyText ||
hydratedSnapshot.currentStory?.text ||
params.option.actionText,
options: getRuntimeResponseOptions(response),
gameState: hydratedSnapshot.gameState,
}),
};
}

View File

@@ -12,6 +12,7 @@ vi.mock('./apiClient', async () => {
};
});
import { AnimationState } from '../types';
import {
buildStoryMomentFromRuntimeOptions,
getRuntimeClientVersion,
@@ -19,9 +20,9 @@ import {
isServerRuntimeFunctionId,
isTask5RuntimeFunctionId,
resolveRuntimeStoryAction,
resolveRuntimeStoryMoment,
shouldUseServerRuntimeOptions,
} from './runtimeStoryService';
import { AnimationState } from '../types';
describe('runtimeStoryService', () => {
beforeEach(() => {
@@ -246,4 +247,99 @@ describe('runtimeStoryService', () => {
action: 'trade',
});
});
it('prefers the richer snapshot story when the server persisted dialogue mode', () => {
const story = resolveRuntimeStoryMoment({
response: {
sessionId: 'runtime-main',
serverVersion: 4,
viewModel: {
player: { hp: 10, maxHp: 10, mana: 5, maxMana: 5 },
encounter: null,
companions: [],
availableOptions: [],
status: {
inBattle: false,
npcInteractionActive: true,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
},
},
presentation: {
actionText: '继续交谈',
resultText: '后端已结算',
storyText: '普通文本',
options: [],
battle: null,
toast: null,
},
patches: [],
snapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
},
hydratedSnapshot: {
version: 2,
savedAt: '2026-04-08T00:00:00.000Z',
bottomTab: 'adventure',
gameState: {} as never,
currentStory: {
text: '你:先把话说开。\n梁伯那我就直说了。',
options: [],
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '先把话说开。' },
{ speaker: 'npc', speakerName: '梁伯', text: '那我就直说了。' },
],
deferredOptions: [
{
functionId: 'npc_chat',
actionText: '继续交谈',
text: '继续交谈',
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
},
],
},
} as never,
fallbackStoryText: '普通文本',
});
expect(story.displayMode).toBe('dialogue');
expect(story.deferredOptions).toHaveLength(1);
expect(story.text).toContain('梁伯');
});
});

View File

@@ -168,6 +168,45 @@ export function buildStoryMomentFromRuntimeOptions(params: {
} satisfies StoryMoment;
}
function shouldPreferSnapshotStory(story: StoryMoment | null) {
return Boolean(
story &&
(
story.displayMode === 'dialogue' ||
story.deferredOptions?.length ||
story.dialogue?.length
),
);
}
export function resolveRuntimeStoryMoment(params: {
response: RuntimeStoryResponse;
hydratedSnapshot: HydratedSavedGameSnapshot;
fallbackGameState?: Pick<GameState, 'currentEncounter'>;
fallbackStoryText?: string;
}) {
if (shouldPreferSnapshotStory(params.hydratedSnapshot.currentStory)) {
return params.hydratedSnapshot.currentStory!;
}
const options =
params.response.viewModel.availableOptions.length > 0
? params.response.viewModel.availableOptions
: params.response.presentation.options;
return buildStoryMomentFromRuntimeOptions({
storyText:
params.response.presentation.storyText ||
params.hydratedSnapshot.currentStory?.text ||
params.fallbackStoryText ||
'',
options,
gameState: params.hydratedSnapshot.gameState.currentEncounter
? params.hydratedSnapshot.gameState
: params.fallbackGameState,
});
}
export async function getRuntimeStoryState(
sessionId: string,
options: RuntimeStoryServiceOptions = {},