1
This commit is contained in:
@@ -18,12 +18,14 @@ import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
type CustomWorldProfile,
|
||||
type CustomWorldOpeningCgProfile,
|
||||
type SceneActBlueprint,
|
||||
type SceneChapterBlueprint,
|
||||
} from '../types';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
|
||||
import { ResolvedAssetImage } from './ResolvedAssetImage';
|
||||
import { ResolvedAssetVideo } from './ResolvedAssetVideo';
|
||||
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
|
||||
export type ResultTab = 'world' | 'playable' | 'story' | 'landmarks';
|
||||
@@ -50,6 +52,10 @@ interface CustomWorldEntityCatalogProps {
|
||||
createActionLabel?: string;
|
||||
onCreateAction?: () => void;
|
||||
createActionDisabled?: boolean;
|
||||
openingCgGenerating?: boolean;
|
||||
openingCgPhaseLabel?: string | null;
|
||||
openingCgGenerateDisabled?: boolean;
|
||||
onGenerateOpeningCg?: () => void;
|
||||
pendingGeneratedEntity?: PendingGeneratedEntity | null;
|
||||
recentGeneratedIds?: RecentGeneratedIds;
|
||||
readOnly?: boolean;
|
||||
@@ -240,6 +246,85 @@ function PendingEntityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function OpeningCgPreview({
|
||||
openingCg,
|
||||
isGenerating,
|
||||
phaseLabel,
|
||||
generateDisabled,
|
||||
readOnly,
|
||||
onGenerate,
|
||||
}: {
|
||||
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||
isGenerating: boolean;
|
||||
phaseLabel?: string | null;
|
||||
generateDisabled?: boolean;
|
||||
readOnly: boolean;
|
||||
onGenerate?: () => void;
|
||||
}) {
|
||||
const hasVideo = Boolean(openingCg?.videoSrc?.trim());
|
||||
const buttonLabel = hasVideo ? '重新生成' : '生成';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--platform-subpanel-border)] bg-black/35 aspect-video">
|
||||
{hasVideo ? (
|
||||
<ResolvedAssetVideo
|
||||
src={openingCg?.videoSrc}
|
||||
className="h-full w-full object-cover"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : openingCg?.storyboardImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={openingCg.storyboardImageSrc}
|
||||
alt="开局 CG 故事板"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm font-semibold tracking-[0.18em] text-zinc-500">
|
||||
开局 CG
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
80 积分
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
预计 10 分钟
|
||||
</span>
|
||||
{hasVideo ? (
|
||||
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
|
||||
已生成
|
||||
</span>
|
||||
) : null}
|
||||
{!readOnly && onGenerate ? (
|
||||
<div className="ml-auto">
|
||||
<SmallButton
|
||||
onClick={onGenerate}
|
||||
tone="sky"
|
||||
disabled={isGenerating || generateDisabled}
|
||||
>
|
||||
{isGenerating ? (phaseLabel ?? '生成中') : buttonLabel}
|
||||
</SmallButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div className="platform-progress-track h-2 overflow-hidden rounded-full">
|
||||
<div className="h-full w-2/3 animate-pulse bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]" />
|
||||
</div>
|
||||
) : null}
|
||||
{openingCg?.status === 'failed' && openingCg.errorMessage ? (
|
||||
<div className="platform-banner platform-banner--danger rounded-2xl px-3 py-2 text-xs leading-5">
|
||||
{openingCg.errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildSceneActParticipantText(
|
||||
act: SceneActBlueprint,
|
||||
roleById: Map<
|
||||
@@ -557,6 +642,10 @@ export function CustomWorldEntityCatalog({
|
||||
createActionLabel,
|
||||
onCreateAction,
|
||||
createActionDisabled = false,
|
||||
openingCgGenerating = false,
|
||||
openingCgPhaseLabel = null,
|
||||
openingCgGenerateDisabled = false,
|
||||
onGenerateOpeningCg,
|
||||
pendingGeneratedEntity = null,
|
||||
recentGeneratedIds = {
|
||||
playable: [],
|
||||
@@ -916,6 +1005,17 @@ export function CustomWorldEntityCatalog({
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="开局 CG">
|
||||
<OpeningCgPreview
|
||||
openingCg={profile.openingCg}
|
||||
isGenerating={openingCgGenerating}
|
||||
phaseLabel={openingCgPhaseLabel}
|
||||
generateDisabled={openingCgGenerateDisabled}
|
||||
readOnly={readOnly}
|
||||
onGenerate={onGenerateOpeningCg}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="世界概述"
|
||||
actions={
|
||||
|
||||
@@ -15,6 +15,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
||||
const generateLandmark = vi.fn();
|
||||
const generateSceneImage = vi.fn();
|
||||
const generateSceneNpc = vi.fn();
|
||||
const generateOpeningCg = vi.fn();
|
||||
|
||||
return {
|
||||
rpgCreationAssetClient: {
|
||||
@@ -23,6 +24,7 @@ vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => {
|
||||
generateLandmark,
|
||||
generateSceneImage,
|
||||
generateSceneNpc,
|
||||
generateOpeningCg,
|
||||
},
|
||||
generateCustomWorldPlayableNpc: generatePlayableNpc,
|
||||
generateCustomWorldStoryNpc: generateStoryNpc,
|
||||
@@ -343,6 +345,46 @@ test('world basic setting renders eight anchor fields and hides legacy parsed/so
|
||||
expect(screen.getByText(/沉钟异动和旧案灭口是同一条线/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('world tab generates opening cg only after manual click and writes it back to profile', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png',
|
||||
storyboardAssetId: 'storyboard-1',
|
||||
videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4',
|
||||
videoAssetId: 'video-1',
|
||||
imageModel: 'gpt-image-2',
|
||||
videoModel: 'doubao-seedance-2-0-fast-260128',
|
||||
aspectRatio: '16:9',
|
||||
imageSize: '2k',
|
||||
videoResolution: '480p',
|
||||
durationSeconds: 15,
|
||||
pointCost: 80,
|
||||
estimatedWaitMinutes: 10,
|
||||
updatedAt: '2026-05-03T00:00:00Z',
|
||||
});
|
||||
|
||||
render(<ResultViewHarness />);
|
||||
|
||||
expect(mockedRpgCreationAssetClient.generateOpeningCg).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedRpgCreationAssetClient.generateOpeningCg).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector(
|
||||
'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test('playable tab prefers generated portrait over runtime preview placeholder', async () => {
|
||||
const user = userEvent.setup();
|
||||
const profile = {
|
||||
|
||||
30
src/components/ResolvedAssetVideo.tsx
Normal file
30
src/components/ResolvedAssetVideo.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { VideoHTMLAttributes } from 'react';
|
||||
|
||||
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
|
||||
|
||||
type ResolvedAssetVideoProps = Omit<
|
||||
VideoHTMLAttributes<HTMLVideoElement>,
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetVideo({
|
||||
src,
|
||||
fallbackSrc,
|
||||
refreshKey,
|
||||
...rest
|
||||
}: ResolvedAssetVideoProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
if (!finalSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <video {...rest} src={finalSrc} />;
|
||||
}
|
||||
@@ -57,6 +57,13 @@ describe('PublishShareModal', () => {
|
||||
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
|
||||
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByTestId('share-channel-logo-wechat'),
|
||||
).toBeTruthy();
|
||||
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByTestId('share-channel-logo-douyin'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Copy, MessageCircle, Music2 } from 'lucide-react';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
@@ -15,26 +15,74 @@ type PublishShareModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
|
||||
|
||||
type ShareChannel = {
|
||||
id: ShareChannelId;
|
||||
label: string;
|
||||
iconClassName: string;
|
||||
};
|
||||
|
||||
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
|
||||
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
|
||||
wechat:
|
||||
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
|
||||
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
|
||||
douyin:
|
||||
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
|
||||
};
|
||||
|
||||
const SHARE_CHANNELS = [
|
||||
{
|
||||
id: 'wechat',
|
||||
label: '微信',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-emerald-500 text-white',
|
||||
iconClassName: 'bg-[#07c160] text-white',
|
||||
},
|
||||
{
|
||||
id: 'qq',
|
||||
label: 'QQ',
|
||||
icon: MessageCircle,
|
||||
className: 'bg-sky-500 text-white',
|
||||
iconClassName: 'bg-[#12b7f5] text-white',
|
||||
},
|
||||
{
|
||||
id: 'douyin',
|
||||
label: '抖音',
|
||||
icon: Music2,
|
||||
className: 'bg-slate-950 text-white',
|
||||
iconClassName: 'bg-black text-white',
|
||||
},
|
||||
] as const;
|
||||
] as const satisfies readonly ShareChannel[];
|
||||
|
||||
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
|
||||
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
|
||||
|
||||
if (channel.id === 'douyin') {
|
||||
return (
|
||||
<svg
|
||||
viewBox="-1 -1 26 26"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className="h-6 w-6 overflow-visible"
|
||||
data-share-channel-logo={channel.id}
|
||||
data-testid={`share-channel-logo-${channel.id}`}
|
||||
>
|
||||
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
|
||||
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
|
||||
<path d={iconPath} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
className="h-6 w-6"
|
||||
data-share-channel-logo={channel.id}
|
||||
data-testid={`share-channel-logo-${channel.id}`}
|
||||
>
|
||||
<path d={iconPath} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布完成后的分享弹窗。
|
||||
@@ -98,8 +146,6 @@ export function PublishShareModal({
|
||||
footer={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
@@ -110,9 +156,9 @@ export function PublishShareModal({
|
||||
title={channel.label}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<ShareChannelLogo channel={channel} />
|
||||
</span>
|
||||
<span>{channel.label}</span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -104,20 +104,12 @@ test('creation hub reflects updated draft title summary and counts after rerende
|
||||
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
|
||||
expect(screen.queryByText('角色 3')).toBeNull();
|
||||
expect(screen.queryByText('地点 4')).toBeNull();
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
const match3dButton = screen.getByRole('button', { name: /抓大鹅/u });
|
||||
expect(
|
||||
rpgButton.compareDocumentPosition(puzzleButton) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(
|
||||
within(match3dButton).getAllByText('经典消除玩法').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(puzzleButton).toBeTruthy();
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
|
||||
rerender(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -42,10 +42,11 @@ test('creation hub draft card renders compiled work summary fields', () => {
|
||||
expect(html).toContain('一个被潮雾切开的列岛世界');
|
||||
expect(html).toContain('玩家是失职返乡的守灯人');
|
||||
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
|
||||
expect(html).toContain('角色扮演');
|
||||
expect(html).toContain('拼图');
|
||||
expect(html).toContain('创意礼物,生活分享');
|
||||
expect(html).not.toContain('角色扮演');
|
||||
expect(html).not.toContain('大鱼吃小鱼');
|
||||
expect(html).not.toContain('抓大鹅');
|
||||
});
|
||||
|
||||
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
getEncounterCharacterBottomOffsetPx,
|
||||
getEncounterCharacterOpponentBottom,
|
||||
getHostileNpcSceneBottomOffsetPx,
|
||||
getMonsterWorldLeft,
|
||||
getMirroredStageEntityLeft,
|
||||
getMonsterWorldLeft,
|
||||
getNpcCombatHpTop,
|
||||
getSceneNpcVisualBottomOffsetPx,
|
||||
MONSTER_COMBAT_HP_TOP_PX,
|
||||
@@ -387,6 +387,53 @@ describe('GameCanvasEntityLayer', () => {
|
||||
expect(html).toContain('查看后排乙详情');
|
||||
});
|
||||
|
||||
it('hides opposite scene actors while the player exits for a scene transition', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<GameCanvasEntityLayer
|
||||
companions={[]}
|
||||
sceneActAmbientEncounters={[
|
||||
createEncounter({ id: 'npc-back-1', npcName: '后排甲' }),
|
||||
]}
|
||||
currentScenePreset={null}
|
||||
sceneTransitionToken={1}
|
||||
isSceneTransitionEntering={false}
|
||||
isSceneTransitionExiting={true}
|
||||
transitionSweepPx={320}
|
||||
sceneTransitionExitDurationS={0.2}
|
||||
sceneTransitionEntryDurationS={0.2}
|
||||
companionAnchorLeft="10%"
|
||||
companionAnchorBottom="20%"
|
||||
playerBottomOffsetPx={0}
|
||||
sceneTransitionPhase="exiting"
|
||||
inBattle={false}
|
||||
onEntitySelect={null}
|
||||
playerLeft="20%"
|
||||
playerCharacter={createCharacter()}
|
||||
playerHp={100}
|
||||
playerMaxHp={100}
|
||||
effectivePlayerFacing="right"
|
||||
effectivePlayerAnimationState={AnimationState.RUN}
|
||||
shouldShowPlayerDialogueIcon={false}
|
||||
dialogueIndicator={null}
|
||||
npcAffinityEffect={null}
|
||||
sceneCombatants={[createHostileNpc({ name: '旧场景敌人' })]}
|
||||
monsters={[]}
|
||||
getHostileNpcOuterLeft={() => '70%'}
|
||||
groundBottom="18%"
|
||||
stageLiftPx={68}
|
||||
encounter={createEncounter({ id: 'npc-primary', npcName: '主角色' })}
|
||||
sideAnchor="15%"
|
||||
cameraAnchorX={0}
|
||||
monsterAnchorMeters={3.2}
|
||||
playerX={0}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('查看旧场景敌人详情');
|
||||
expect(html).not.toContain('查看主角色详情');
|
||||
expect(html).not.toContain('查看后排甲详情');
|
||||
});
|
||||
|
||||
it('keeps hostile combatant identity stable while attack position changes', () => {
|
||||
const sideAnchor = '15%';
|
||||
const cameraAnchorX = 0;
|
||||
|
||||
@@ -114,6 +114,22 @@ function addCssPxOffset(value: string, offsetPx: number) {
|
||||
return offsetPx === 0 ? value : `calc(${value} + ${offsetPx}px)`;
|
||||
}
|
||||
|
||||
function getSceneTransitionMotionConfig(
|
||||
isEntering: boolean,
|
||||
isExiting: boolean,
|
||||
transitionSweepPx: number,
|
||||
durationS: number,
|
||||
) {
|
||||
return {
|
||||
initial: isEntering ? {x: -transitionSweepPx} : false,
|
||||
animate: {x: isExiting ? transitionSweepPx : 0},
|
||||
transition: {
|
||||
duration: isExiting ? durationS : isEntering ? durationS : 0.18,
|
||||
ease: 'linear' as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function CombatFloatingNumber({
|
||||
event,
|
||||
onDone,
|
||||
@@ -451,7 +467,9 @@ export function GameCanvasEntityLayer({
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{sceneCombatants.map((hostileNpc, index) => {
|
||||
{sceneTransitionPhase === 'exiting'
|
||||
? null
|
||||
: sceneCombatants.map((hostileNpc, index) => {
|
||||
const npcEncounter = hostileNpc.encounter ?? buildFallbackCombatEncounter(hostileNpc);
|
||||
const hostileRenderKey = [
|
||||
hostileNpc.id,
|
||||
@@ -465,9 +483,15 @@ export function GameCanvasEntityLayer({
|
||||
? monsters.find(item => item.id === npcEncounter.monsterPresetId) ?? config ?? null
|
||||
: null;
|
||||
const npcSceneSpriteFacing =
|
||||
npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
isSceneTransitionEntering
|
||||
? 'right'
|
||||
: npcCharacter
|
||||
? hostileNpc.facing
|
||||
: getRenderableNpcFacing(npcEncounter, hostileNpc.facing, {medievalVisual: true});
|
||||
const hostileNpcAnimation =
|
||||
isSceneTransitionEntering
|
||||
? ('move' as const)
|
||||
: hostileNpc.animation;
|
||||
const npcCombatHpTop = getNpcCombatHpTop(
|
||||
npcCharacter ? npcEncounter?.characterId : null,
|
||||
npcCharacter ? null : npcEncounter?.monsterPresetId,
|
||||
@@ -498,10 +522,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: stageLiftPx + (hostileNpc.yOffset ?? 0) + battleEntityVisualOffsetPx;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={hostileRenderKey}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getHostileNpcOuterLeft(hostileNpc),
|
||||
bottom: entityBottom,
|
||||
@@ -526,7 +560,11 @@ export function GameCanvasEntityLayer({
|
||||
<CombatReactiveSpriteFrame events={feedbackEvents} facing={npcSceneSpriteFacing}>
|
||||
{npcCharacter ? (
|
||||
<RoleCharacterSprite
|
||||
state={hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)}
|
||||
state={
|
||||
isSceneTransitionEntering
|
||||
? AnimationState.RUN
|
||||
: hostileNpc.characterAnimation ?? mapHostileNpcAnimationToCharacterState(hostileNpc.animation)
|
||||
}
|
||||
character={npcCharacter}
|
||||
facing={npcSceneSpriteFacing}
|
||||
/>
|
||||
@@ -534,8 +572,8 @@ export function GameCanvasEntityLayer({
|
||||
<div style={{transform: `translate(${renderOffset.x}px, ${renderOffset.y}px)`}}>
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={npcMonsterConfig}
|
||||
animation={hostileNpc.animation}
|
||||
flip={hostileNpc.facing === 'right'}
|
||||
animation={hostileNpcAnimation}
|
||||
flip={npcSceneSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
</div>
|
||||
@@ -561,11 +599,11 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
|
||||
{shouldRenderPeacefulEncounter &&
|
||||
{sceneTransitionPhase !== 'exiting' && shouldRenderPeacefulEncounter &&
|
||||
(() => {
|
||||
if (!encounter) {
|
||||
return null;
|
||||
@@ -594,11 +632,23 @@ export function GameCanvasEntityLayer({
|
||||
const peacefulBottomOffsetPx = peacefulResolvedCharacter
|
||||
? getEncounterCharacterBottomOffsetPx(stageLiftPx, encounter, peacefulResolvedCharacter)
|
||||
: stageLiftPx + peacefulHostileBottomOffsetPx;
|
||||
const peacefulNpcSpriteFacing = towardPeacefulPlayer;
|
||||
const peacefulNpcSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: towardPeacefulPlayer;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -639,7 +689,7 @@ export function GameCanvasEntityLayer({
|
||||
!encounter.visual &&
|
||||
!encounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={peacefulResolvedCharacter}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
/>
|
||||
@@ -647,13 +697,13 @@ export function GameCanvasEntityLayer({
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={peacefulMonsterConfig}
|
||||
animation={isPeacefulEncounterMoving ? 'move' : 'idle'}
|
||||
flip={towardPeacefulPlayer === 'right'}
|
||||
flip={peacefulNpcSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={encounter}
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={peacefulNpcSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
|
||||
/>
|
||||
@@ -672,11 +722,12 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{!inBattle &&
|
||||
sceneTransitionPhase !== 'exiting' &&
|
||||
sceneActAmbientEncounters.map((ambientEncounter, index) => {
|
||||
const ambientOffsetPx = SCENE_ACT_BACK_ROW_OFFSET_PX[index];
|
||||
if (ambientOffsetPx === undefined) {
|
||||
@@ -708,6 +759,9 @@ export function GameCanvasEntityLayer({
|
||||
SCENE_ACT_BACK_ROW_ANCHOR_X_METERS,
|
||||
playerX,
|
||||
);
|
||||
const ambientSpriteFacing = isSceneTransitionEntering
|
||||
? 'right'
|
||||
: ambientFacing;
|
||||
const ambientBottom = ambientEncounter.characterId
|
||||
? getEncounterCharacterOpponentBottom(
|
||||
groundBottom,
|
||||
@@ -717,10 +771,20 @@ export function GameCanvasEntityLayer({
|
||||
)
|
||||
: `calc(${groundBottom} + ${stageLiftPx + ambientHostileBottomOffsetPx}px)`;
|
||||
|
||||
const motionConfig = getSceneTransitionMotionConfig(
|
||||
isSceneTransitionEntering,
|
||||
isSceneTransitionExiting,
|
||||
transitionSweepPx,
|
||||
sceneTransitionEntryDurationS,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<motion.div
|
||||
key={`scene-act-ambient-${ambientEncounter.id ?? ambientEncounter.npcName}-${index}`}
|
||||
className="absolute"
|
||||
initial={motionConfig.initial}
|
||||
animate={motionConfig.animate}
|
||||
transition={motionConfig.transition}
|
||||
style={{
|
||||
left: getMonsterWorldLeft(
|
||||
sideAnchor,
|
||||
@@ -751,22 +815,22 @@ export function GameCanvasEntityLayer({
|
||||
!ambientEncounter.visual &&
|
||||
!ambientEncounter.imageSrc?.trim() ? (
|
||||
<RoleCharacterSprite
|
||||
state={AnimationState.IDLE}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
character={ambientResolvedCharacter}
|
||||
facing={ambientFacing}
|
||||
facing={ambientSpriteFacing}
|
||||
/>
|
||||
) : ambientMonsterConfig ? (
|
||||
<HostileNpcAnimator
|
||||
hostileNpc={ambientMonsterConfig}
|
||||
animation="idle"
|
||||
flip={ambientFacing === 'right'}
|
||||
flip={ambientSpriteFacing === 'right'}
|
||||
className="scale-[1.82] origin-bottom"
|
||||
/>
|
||||
) : (
|
||||
<SceneEncounterNpcSprite
|
||||
encounter={ambientEncounter}
|
||||
state={AnimationState.IDLE}
|
||||
facing={ambientFacing}
|
||||
state={isSceneTransitionEntering ? AnimationState.RUN : AnimationState.IDLE}
|
||||
facing={ambientSpriteFacing}
|
||||
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.32)]"
|
||||
/>
|
||||
)}
|
||||
@@ -777,7 +841,7 @@ export function GameCanvasEntityLayer({
|
||||
<NpcAffinityEffectBadge effect={npcAffinityEffect} />
|
||||
) : null}
|
||||
</SceneEntityButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -659,15 +659,12 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) {
|
||||
function buildPuzzleCompileActionFromFormPayload(
|
||||
payload: CreatePuzzleAgentSessionRequest | null,
|
||||
): PuzzleAgentActionRequest {
|
||||
const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim();
|
||||
const workDescription = payload?.workDescription?.trim();
|
||||
const pictureDescription = payload?.pictureDescription?.trim();
|
||||
const pictureDescription =
|
||||
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
|
||||
|
||||
return {
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription || workTitle,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
promptText: pictureDescription,
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
@@ -679,28 +676,15 @@ function buildPuzzleFormPayloadFromSession(
|
||||
session: PuzzleAgentSessionSnapshot,
|
||||
): CreatePuzzleAgentSessionRequest {
|
||||
const formDraft = session.draft?.formDraft;
|
||||
const workTitle =
|
||||
formDraft?.workTitle?.trim() ||
|
||||
session.draft?.workTitle?.trim() ||
|
||||
session.draft?.levelName?.trim() ||
|
||||
session.anchorPack.themePromise.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
const workDescription =
|
||||
formDraft?.workDescription?.trim() ||
|
||||
session.draft?.workDescription?.trim() ||
|
||||
session.draft?.summary?.trim() ||
|
||||
'';
|
||||
const pictureDescription =
|
||||
formDraft?.pictureDescription?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
|
||||
session.anchorPack.visualSubject.value.trim() ||
|
||||
session.seedText?.trim() ||
|
||||
'';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
imageModel: null,
|
||||
@@ -723,9 +707,9 @@ function buildPuzzleFormPayloadFromAction(
|
||||
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
|
||||
|
||||
return {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
...(workTitle ? { workTitle } : {}),
|
||||
...(workDescription ? { workDescription } : {}),
|
||||
pictureDescription,
|
||||
referenceImageSrc:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
@@ -920,6 +904,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
initialPublicWorkCode,
|
||||
}: PlatformEntryFlowShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||
@@ -1662,7 +1650,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleFormDraftPayload(formPayload);
|
||||
}
|
||||
|
||||
if (payload.action === 'publish_puzzle_work') {
|
||||
if (
|
||||
payload.action === 'publish_puzzle_work' ||
|
||||
payload.action === 'generate_puzzle_tags'
|
||||
) {
|
||||
await Promise.allSettled([
|
||||
refreshPuzzleShelf(),
|
||||
refreshPuzzleGallery(),
|
||||
@@ -1836,8 +1827,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
const response = await executePuzzleAgentAction(session.sessionId, {
|
||||
action: 'save_puzzle_form_draft',
|
||||
promptText: payload.pictureDescription ?? null,
|
||||
workTitle: payload.workTitle ?? payload.seedText ?? '',
|
||||
workDescription: payload.workDescription ?? '',
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
imageModel: payload.imageModel ?? null,
|
||||
});
|
||||
@@ -5057,9 +5046,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onBack={leavePuzzleFlow}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
@@ -5463,6 +5450,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||
closeOnBackdrop={!deletingCreationWorkId}
|
||||
size="sm"
|
||||
overlayClassName={`platform-theme ${platformThemeClass} !items-center`}
|
||||
panelClassName="platform-remap-surface rounded-[1.75rem]"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -28,13 +28,15 @@ test('platform creation types are derived from new work entry config', () => {
|
||||
test('new work entry config controls visibility and open order', () => {
|
||||
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
|
||||
|
||||
expect(isPlatformCreationTypeVisible('rpg')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
|
||||
expect(isPlatformCreationTypeVisible('match3d')).toBe(false);
|
||||
expect(visibleIds).not.toContain('rpg');
|
||||
expect(visibleIds).not.toContain('big-fish');
|
||||
expect(visibleIds[0]).toBe('rpg');
|
||||
expect(visibleIds).not.toContain('match3d');
|
||||
expect(visibleIds[0]).toBe('puzzle');
|
||||
expect(visibleIds).toEqual([
|
||||
'rpg',
|
||||
'puzzle',
|
||||
'match3d',
|
||||
'airp',
|
||||
'visual-novel',
|
||||
]);
|
||||
|
||||
@@ -83,21 +83,18 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||
target: { value: '一套雨夜猫街主题拼图。' },
|
||||
});
|
||||
expect(screen.queryByLabelText('作品名称')).toBeNull();
|
||||
expect(screen.queryByLabelText('作品描述')).toBeNull();
|
||||
expect(screen.getByText('创建拼图')).toBeTruthy();
|
||||
expect(screen.queryByText('try')).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '暖灯猫街',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
@@ -107,6 +104,35 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
||||
});
|
||||
|
||||
test('puzzle workspace applies a creation template prompt', () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '宠物可爱拼图模板' }));
|
||||
|
||||
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
|
||||
);
|
||||
expect(screen.getAllByText('宠物可爱拼图').length).toBeGreaterThan(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pictureDescription:
|
||||
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净,适合萌宠拼图分享。',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle workspace falls back to compile action for restored sessions', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const onCreateFromForm = vi.fn();
|
||||
@@ -126,10 +152,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
expect(onCreateFromForm).not.toHaveBeenCalled();
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
workTitle: '雾港遗迹拼图',
|
||||
workDescription: '雾港遗迹拼图',
|
||||
pictureDescription: '潮雾中的灯塔与断桥',
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
candidateCount: 1,
|
||||
@@ -149,12 +173,6 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品名称'), {
|
||||
target: { value: '暖灯猫街' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||
target: { value: '一套雨夜猫街主题拼图。' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
@@ -175,8 +193,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
const onAutoSaveForm = vi.fn();
|
||||
const formDraftSession: PuzzleAgentSessionSnapshot = {
|
||||
...baseSession,
|
||||
seedText:
|
||||
'作品名称:旧街拼图\n作品描述:旧街雨夜的拼图草稿。\n画面描述:旧街灯牌下的猫。',
|
||||
seedText: '画面描述:旧街灯牌下的猫。',
|
||||
draft: {
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
@@ -204,8 +221,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
},
|
||||
],
|
||||
formDraft: {
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
pictureDescription: '旧街灯牌下的猫。',
|
||||
},
|
||||
},
|
||||
@@ -221,12 +236,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect((screen.getByLabelText('作品名称') as HTMLInputElement).value).toBe(
|
||||
'旧街拼图',
|
||||
);
|
||||
expect((screen.getByLabelText('作品描述') as HTMLTextAreaElement).value).toBe(
|
||||
'旧街雨夜的拼图草稿。',
|
||||
);
|
||||
expect((screen.getByLabelText('画面描述') as HTMLTextAreaElement).value).toBe(
|
||||
'旧街灯牌下的猫。',
|
||||
);
|
||||
@@ -240,9 +249,7 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
});
|
||||
|
||||
expect(onAutoSaveForm).toHaveBeenCalledWith({
|
||||
seedText: '旧街拼图',
|
||||
workTitle: '旧街拼图',
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
seedText: '旧街灯牌下的猫和发光雨伞。',
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'gpt-image-2',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
SendPuzzleAgentMessageRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { PUZZLE_CREATION_TEMPLATES } from './puzzleCreationTemplates';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -28,8 +29,6 @@ type PuzzleAgentWorkspaceProps = {
|
||||
};
|
||||
|
||||
type PuzzleFormState = {
|
||||
workTitle: string;
|
||||
workDescription: string;
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageLabel: string;
|
||||
@@ -37,8 +36,6 @@ type PuzzleFormState = {
|
||||
};
|
||||
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
workTitle: '',
|
||||
workDescription: '',
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
@@ -52,8 +49,6 @@ function resolveInitialFormState(
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
if (formDraft) {
|
||||
return {
|
||||
workTitle: formDraft.workTitle ?? '',
|
||||
workDescription: formDraft.workDescription ?? '',
|
||||
pictureDescription: formDraft.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
@@ -65,10 +60,10 @@ function resolveInitialFormState(
|
||||
|
||||
if (initialFormPayload) {
|
||||
return {
|
||||
workTitle:
|
||||
initialFormPayload.workTitle ?? initialFormPayload.seedText ?? '',
|
||||
workDescription: initialFormPayload.workDescription ?? '',
|
||||
pictureDescription: initialFormPayload.pictureDescription ?? '',
|
||||
pictureDescription:
|
||||
initialFormPayload.pictureDescription ??
|
||||
initialFormPayload.seedText ??
|
||||
'',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择参考图'
|
||||
@@ -82,19 +77,12 @@ function resolveInitialFormState(
|
||||
}
|
||||
|
||||
return {
|
||||
workTitle:
|
||||
session.draft?.workTitle ||
|
||||
session.draft?.levelName ||
|
||||
session.seedText ||
|
||||
session.anchorPack.themePromise.value ||
|
||||
session.messages.find((message) => message.role === 'user')?.text ||
|
||||
'',
|
||||
workDescription:
|
||||
session.draft?.workDescription ||
|
||||
session.anchorPack.themePromise.value ||
|
||||
'',
|
||||
pictureDescription:
|
||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
||||
session.draft?.formDraft?.pictureDescription ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value ||
|
||||
session.seedText ||
|
||||
'',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -121,6 +109,9 @@ export function PuzzleAgentWorkspace({
|
||||
const [referenceImageError, setReferenceImageError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(
|
||||
PUZZLE_CREATION_TEMPLATES[0]?.id ?? '',
|
||||
);
|
||||
const previousSessionIdRef = useRef<string | null>(
|
||||
session?.sessionId ?? null,
|
||||
);
|
||||
@@ -148,18 +139,13 @@ export function PuzzleAgentWorkspace({
|
||||
appliedInitialFormKeyRef.current = nextInitialFormKey;
|
||||
setFormState(resolveInitialFormState(session, initialFormPayload));
|
||||
setReferenceImageError(null);
|
||||
}, [initialFormPayload, session?.sessionId]);
|
||||
}, [initialFormPayload, session]);
|
||||
|
||||
const workTitle = formState.workTitle.trim();
|
||||
const workDescription = formState.workDescription.trim();
|
||||
const pictureDescription = formState.pictureDescription.trim();
|
||||
const canSubmit =
|
||||
Boolean(workTitle && workDescription && pictureDescription) && !isBusy;
|
||||
const canSubmit = Boolean(pictureDescription) && !isBusy;
|
||||
const autosavePayload = useMemo(
|
||||
() => ({
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -168,13 +154,9 @@ export function PuzzleAgentWorkspace({
|
||||
formState.referenceImageSrc,
|
||||
formState.imageModel,
|
||||
pictureDescription,
|
||||
workDescription,
|
||||
workTitle,
|
||||
],
|
||||
);
|
||||
const autosaveSignature = JSON.stringify([
|
||||
autosavePayload.workTitle,
|
||||
autosavePayload.workDescription,
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
@@ -189,7 +171,7 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
autosaveSessionIdRef.current = currentSessionId;
|
||||
lastAutosaveSignatureRef.current = autosaveSignature;
|
||||
}, [autosaveSignature, session?.sessionId]);
|
||||
}, [autosaveSignature, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -214,7 +196,7 @@ export function PuzzleAgentWorkspace({
|
||||
onAutoSaveForm,
|
||||
session?.draft?.formDraft,
|
||||
session?.stage,
|
||||
session?.sessionId,
|
||||
session,
|
||||
]);
|
||||
|
||||
const handleReferenceImageChange = async (
|
||||
@@ -243,15 +225,28 @@ export function PuzzleAgentWorkspace({
|
||||
}
|
||||
};
|
||||
|
||||
const applyTemplatePrompt = (templateId: string) => {
|
||||
const template = PUZZLE_CREATION_TEMPLATES.find(
|
||||
(item) => item.id === templateId,
|
||||
);
|
||||
if (!template) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTemplateId(template.id);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: template.prompt,
|
||||
}));
|
||||
};
|
||||
|
||||
const submitForm = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
seedText: workTitle,
|
||||
workTitle,
|
||||
workDescription,
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -265,8 +260,6 @@ export function PuzzleAgentWorkspace({
|
||||
onExecuteAction({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: pictureDescription,
|
||||
workTitle,
|
||||
workDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
imageModel: formState.imageModel,
|
||||
@@ -275,7 +268,7 @@ export function PuzzleAgentWorkspace({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@@ -291,61 +284,107 @@ export function PuzzleAgentWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<section className="platform-subpanel rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="space-y-5">
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品名称
|
||||
<div className="mb-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="m-0 text-5xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-7xl">
|
||||
创建拼图
|
||||
</h1>
|
||||
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="platform-subpanel overflow-hidden rounded-[1.5rem] p-4 sm:p-5">
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3 sm:p-4">
|
||||
<div className="mb-3 flex min-h-6 items-center justify-between gap-3">
|
||||
<span className="text-xs font-black text-[var(--platform-text-soft)]">
|
||||
Template
|
||||
</span>
|
||||
<span className="max-w-[11rem] truncate text-xs font-black text-[var(--platform-text-strong)]">
|
||||
{PUZZLE_CREATION_TEMPLATES.find(
|
||||
(item) => item.id === selectedTemplateId,
|
||||
)?.title ?? PUZZLE_CREATION_TEMPLATES[0]?.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 overflow-x-auto pb-2"
|
||||
aria-label="拼图创作模板"
|
||||
>
|
||||
{PUZZLE_CREATION_TEMPLATES.map((template) => {
|
||||
const selected = template.id === selectedTemplateId;
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => applyTemplatePrompt(template.id)}
|
||||
className={`min-h-[10.2rem] w-[7.45rem] shrink-0 rounded-[1rem] border p-2 text-left transition ${
|
||||
selected
|
||||
? 'border-emerald-300 bg-emerald-50/86 shadow-[0_0_0_1px_rgba(16,185,129,0.18)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/82 hover:bg-white'
|
||||
} ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${template.title}模板`}
|
||||
>
|
||||
<span className="block aspect-square overflow-hidden rounded-[0.8rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<img
|
||||
src={template.imageSrc}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
<span className="mt-2 block min-h-8 overflow-hidden text-ellipsis text-xs font-black leading-4 text-[var(--platform-text-strong)]">
|
||||
{template.title}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="mt-2 inline-flex max-w-full rounded-full bg-emerald-100 px-2 py-1 text-[10px] font-black text-emerald-700">
|
||||
已选择
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<label
|
||||
className={`inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/92 px-4 text-sm font-black text-[var(--platform-text-strong)] shadow-sm transition hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
title={formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span>
|
||||
{formState.referenceImageSrc ? '更换参考图' : '上传参考图'}
|
||||
</span>
|
||||
<input
|
||||
value={formState.workTitle}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workTitle: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="作品名称"
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品描述
|
||||
</span>
|
||||
<textarea
|
||||
value={formState.workDescription}
|
||||
disabled={isBusy}
|
||||
rows={4}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
workDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
aria-label="作品描述"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面描述
|
||||
</span>
|
||||
<div className="relative mt-2">
|
||||
<span className="sr-only">画面描述</span>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={formState.pictureDescription}
|
||||
disabled={isBusy}
|
||||
rows={10}
|
||||
placeholder="一只猫在雨夜灯牌下回头,霓虹反光清晰,街角有花店和小伞,适合切成拼图。"
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
pictureDescription: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
className="min-h-[18rem] w-full resize-none rounded-[1.35rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-4 pb-16 text-base leading-7 text-[var(--platform-text-strong)] outline-none placeholder:text-zinc-400 sm:min-h-[20rem]"
|
||||
aria-label="画面描述"
|
||||
/>
|
||||
<PuzzleImageModelPicker
|
||||
@@ -358,26 +397,6 @@ export function PuzzleAgentWorkspace({
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<label
|
||||
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
title={
|
||||
formState.referenceImageSrc ? '更换参考图' : '添加参考图'
|
||||
}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{formState.referenceImageSrc ? '更换参考图' : '添加参考图'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
void handleReferenceImageChange(event);
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
|
||||
94
src/components/puzzle-agent/puzzleCreationTemplates.ts
Normal file
94
src/components/puzzle-agent/puzzleCreationTemplates.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
export type PuzzleCreationTemplate = {
|
||||
id: string;
|
||||
title: string;
|
||||
imageSrc: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
// 中文注释:模板只服务入口快速填词,正式作品信息仍在结果页补全。
|
||||
export const PUZZLE_CREATION_TEMPLATES: PuzzleCreationTemplate[] = [
|
||||
{
|
||||
id: 'couple-memory',
|
||||
title: '情侣合照拼图',
|
||||
imageSrc: '/puzzle-creation-templates/couple-memory.webp',
|
||||
prompt:
|
||||
'温暖自然光下的一对情侣纪念合照,城市咖啡馆窗边,桌面有花束和两杯热饮,人物神情自然,画面主体清晰,前中后景层次明确。',
|
||||
},
|
||||
{
|
||||
id: 'family-keepsake',
|
||||
title: '家庭纪念拼图',
|
||||
imageSrc: '/puzzle-creation-templates/family-keepsake.webp',
|
||||
prompt:
|
||||
'三代家人在客厅沙发前的家庭纪念合照,柔和午后阳光,孩子抱着生日蛋糕,长辈微笑,画面温暖完整,细节丰富但不杂乱。',
|
||||
},
|
||||
{
|
||||
id: 'friends-party',
|
||||
title: '朋友聚会拼图',
|
||||
imageSrc: '/puzzle-creation-templates/friends-party.webp',
|
||||
prompt:
|
||||
'朋友们在露台夜晚聚会,彩灯、桌上零食和举杯瞬间,人物分布有层次,中央焦点清楚,氛围轻松热闹。',
|
||||
},
|
||||
{
|
||||
id: 'festival-card',
|
||||
title: '节日贺卡拼图',
|
||||
imageSrc: '/puzzle-creation-templates/festival-card.webp',
|
||||
prompt:
|
||||
'节日餐桌与礼物布置,暖色灯光、彩带、蜡烛和窗外烟花,画面像无字贺卡,主体集中,边角细节可辨。',
|
||||
},
|
||||
{
|
||||
id: 'knowledge-summary',
|
||||
title: '知识总结拼图',
|
||||
imageSrc: '/puzzle-creation-templates/knowledge-summary.webp',
|
||||
prompt:
|
||||
'一张无文字的知识学习主题插画,书桌上有打开的笔记本、便签、咖啡、台灯和思维导图式图形元素,构图整洁,重点明确。',
|
||||
},
|
||||
{
|
||||
id: 'product-detail',
|
||||
title: '商品细节拼图',
|
||||
imageSrc: '/puzzle-creation-templates/product-detail.webp',
|
||||
prompt:
|
||||
'精致商品静物展示,一只高质感香水瓶放在丝绸与花瓣之间,玻璃反光清晰,包装和材质细节丰富,背景干净。',
|
||||
},
|
||||
{
|
||||
id: 'healing-landscape',
|
||||
title: '治愈风景拼图',
|
||||
imageSrc: '/puzzle-creation-templates/healing-landscape.webp',
|
||||
prompt:
|
||||
'治愈风景插画,清晨湖边、薄雾、远山、木栈道和一盏小灯,色彩柔和。',
|
||||
},
|
||||
{
|
||||
id: 'cute-pet',
|
||||
title: '宠物可爱拼图',
|
||||
imageSrc: '/puzzle-creation-templates/cute-pet.webp',
|
||||
prompt:
|
||||
'一只可爱的橘猫趴在阳光窗台上,旁边有绿植、毛线球和小毯子,猫的表情清楚,画面温柔干净。',
|
||||
},
|
||||
{
|
||||
id: 'hot-topic-poster',
|
||||
title: '热点海报拼图',
|
||||
imageSrc: '/puzzle-creation-templates/hot-topic-poster.webp',
|
||||
prompt:
|
||||
'电影感热点海报风插画,雨夜街头、霓虹反光、奔跑的人影和远处光束,强烈视觉焦点,画面无文字。',
|
||||
},
|
||||
{
|
||||
id: 'event-invitation',
|
||||
title: '活动邀请拼图',
|
||||
imageSrc: '/puzzle-creation-templates/event-invitation.webp',
|
||||
prompt:
|
||||
'活动邀请主题插画,展厅入口、花艺装置、签到台和柔和灯带,人群剪影自然分布,画面高级干净,无文字。',
|
||||
},
|
||||
{
|
||||
id: 'daily-challenge',
|
||||
title: '每日挑战拼图',
|
||||
imageSrc: '/puzzle-creation-templates/daily-challenge.webp',
|
||||
prompt:
|
||||
'每日挑战主题插画,清爽桌面上摆放相机、明信片、计时器和小奖章,色彩明亮,构图有趣,细节可拆解。',
|
||||
},
|
||||
{
|
||||
id: 'children-learning',
|
||||
title: '儿童认知拼图',
|
||||
imageSrc: '/puzzle-creation-templates/children-learning.webp',
|
||||
prompt:
|
||||
'儿童认知学习插画,木质桌面上有积木、彩色形状、动物玩偶和小书本,色彩明快,元素边界清晰,无文字。',
|
||||
},
|
||||
];
|
||||
@@ -104,11 +104,12 @@ function createSession(
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack,
|
||||
draft: {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
workTitle: overrides.draft?.workTitle ?? '暖灯猫街作品',
|
||||
workDescription:
|
||||
overrides.draft?.workDescription ?? '一套雨夜猫街主题拼图。',
|
||||
levelName: level.levelName,
|
||||
summary: level.pictureDescription,
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
themeTags: overrides.draft?.themeTags ?? ['猫咪', '雨夜', '暖灯'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
@@ -119,6 +120,7 @@ function createSession(
|
||||
generationStatus: 'ready',
|
||||
levels: [level],
|
||||
metadata: null,
|
||||
...overrides.draft,
|
||||
},
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
@@ -199,7 +201,7 @@ describe('PuzzleResultView', () => {
|
||||
workTitle: '暖灯猫街合集',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levels: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -250,7 +252,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -280,7 +282,7 @@ describe('PuzzleResultView', () => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
levelName: '暖灯猫街',
|
||||
summary: '一只猫在雨夜灯牌下回头。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
levels: [
|
||||
expect.objectContaining({
|
||||
levelId: 'puzzle-level-1',
|
||||
@@ -386,7 +388,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '新关卡里有一座发光钟楼。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
@@ -427,7 +429,7 @@ describe('PuzzleResultView', () => {
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
}),
|
||||
);
|
||||
@@ -440,6 +442,57 @@ describe('PuzzleResultView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('generates six tags after work title and description are filled', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...createSession().draft!,
|
||||
workTitle: '雨夜猫街',
|
||||
workDescription: '',
|
||||
themeTags: [],
|
||||
},
|
||||
resultPreview: {
|
||||
draft: createSession().draft!,
|
||||
publishReady: false,
|
||||
blockers: [
|
||||
{
|
||||
id: 'invalid-tag-count',
|
||||
code: 'INVALID_TAG_COUNT',
|
||||
message: '正式标签数量必须在 3 到 6 之间',
|
||||
},
|
||||
],
|
||||
qualityFindings: [],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '作品信息' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
|
||||
expect(screen.getByText('请先填写作品名称和作品描述。')).toBeTruthy();
|
||||
expect(onExecuteAction).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('作品描述'), {
|
||||
target: { value: '一套雨夜猫街主题拼图。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI生成作品标签' }));
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith({
|
||||
action: 'generate_puzzle_tags',
|
||||
workTitle: '雨夜猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
levelName: '雨夜猫街',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: [],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('selects a history puzzle asset as reference image for the selected level', async () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
@@ -496,7 +549,7 @@ describe('PuzzleResultView', () => {
|
||||
candidateCount: 1,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '屋檐下的猫与暖灯街角。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
themeTags: ['猫咪', '雨夜', '暖灯'],
|
||||
levelsJson: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ function syncDraftFromEditState(
|
||||
const primaryLevel = levels[0] ?? buildFallbackLevelFromDraft(draft);
|
||||
return {
|
||||
...draft,
|
||||
workTitle: editState.workTitle.trim() || draft.workTitle,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
levelName: primaryLevel.levelName,
|
||||
summary: editState.workDescription.trim(),
|
||||
@@ -145,8 +145,8 @@ function syncDraftFromEditState(
|
||||
|
||||
function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
|
||||
return {
|
||||
workTitle: draft.workTitle || draft.levelName,
|
||||
workDescription: draft.workDescription || '',
|
||||
workTitle: draft.workTitle ?? '',
|
||||
workDescription: draft.workDescription ?? '',
|
||||
themeTags: normalizeThemeTagInput(draft.themeTags.join(',')),
|
||||
levels: normalizeDraftLevels(draft),
|
||||
};
|
||||
@@ -219,16 +219,7 @@ function buildPublishReady(
|
||||
|
||||
return {
|
||||
blockers: [...new Set(blockers.filter(Boolean))],
|
||||
publishReady:
|
||||
Boolean(session.resultPreview?.publishReady) &&
|
||||
Boolean(editState.workTitle.trim()) &&
|
||||
Boolean(editState.workDescription.trim()) &&
|
||||
editState.themeTags.length >= PUZZLE_MIN_THEME_TAG_COUNT &&
|
||||
editState.themeTags.length <= PUZZLE_MAX_THEME_TAG_COUNT &&
|
||||
levels.length > 0 &&
|
||||
levels.every(
|
||||
(level) => level.levelName.trim() && resolveLevelFormalImageSrc(level),
|
||||
),
|
||||
publishReady: blockers.filter(Boolean).length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,11 +299,15 @@ function PuzzleResultTabs({
|
||||
function PuzzleThemeTagEditor({
|
||||
editState,
|
||||
isBusy,
|
||||
error,
|
||||
onChange,
|
||||
onGenerateTags,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
isBusy: boolean;
|
||||
error: string | null;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateTags: () => void;
|
||||
}) {
|
||||
const [newTagText, setNewTagText] = useState('');
|
||||
const [isAddingTag, setIsAddingTag] = useState(false);
|
||||
@@ -339,18 +334,34 @@ function PuzzleThemeTagEditor({
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
作品标签
|
||||
</div>
|
||||
{!isAddingTag ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsAddingTag(true)}
|
||||
onClick={onGenerateTags}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="新增作品标签"
|
||||
title="新增作品标签"
|
||||
aria-label="AI生成作品标签"
|
||||
title="AI生成作品标签"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{isBusy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{!isAddingTag ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsAddingTag(true)}
|
||||
className="platform-icon-button h-9 w-9"
|
||||
aria-label="新增作品标签"
|
||||
title="新增作品标签"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -430,6 +441,11 @@ function PuzzleThemeTagEditor({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1191,12 +1207,16 @@ function PuzzleLevelListTab({
|
||||
|
||||
function PuzzleWorkInfoTab({
|
||||
editState,
|
||||
tagGenerationError,
|
||||
isBusy,
|
||||
onChange,
|
||||
onGenerateTags,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
tagGenerationError: string | null;
|
||||
isBusy: boolean;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateTags: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -1233,8 +1253,10 @@ function PuzzleWorkInfoTab({
|
||||
|
||||
<PuzzleThemeTagEditor
|
||||
editState={editState}
|
||||
error={tagGenerationError}
|
||||
isBusy={isBusy}
|
||||
onChange={onChange}
|
||||
onGenerateTags={onGenerateTags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1304,6 +1326,9 @@ export function PuzzleResultView({
|
||||
const [autoSaveState, setAutoSaveState] =
|
||||
useState<PuzzleAutoSaveState>('idle');
|
||||
const [autoSaveError, setAutoSaveError] = useState<string | null>(null);
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
@@ -1314,6 +1339,7 @@ export function PuzzleResultView({
|
||||
setActiveLevelId(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
@@ -1327,6 +1353,7 @@ export function PuzzleResultView({
|
||||
);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -1445,7 +1472,7 @@ export function PuzzleResultView({
|
||||
const buildLevelDraft = (level: PuzzleDraftLevel): PuzzleResultDraft => ({
|
||||
...syncedDraft,
|
||||
levelName: level.levelName,
|
||||
summary: level.pictureDescription,
|
||||
summary: editState.workDescription.trim(),
|
||||
candidates: level.candidates,
|
||||
selectedCandidateId: level.selectedCandidateId,
|
||||
coverImageSrc: resolveLevelFormalImageSrc(level) || level.coverImageSrc,
|
||||
@@ -1498,8 +1525,28 @@ export function PuzzleResultView({
|
||||
) : (
|
||||
<PuzzleWorkInfoTab
|
||||
editState={editState}
|
||||
tagGenerationError={tagGenerationError}
|
||||
isBusy={isBusy}
|
||||
onChange={setEditState}
|
||||
onGenerateTags={() => {
|
||||
const workTitle = editState.workTitle.trim();
|
||||
const workDescription = editState.workDescription.trim();
|
||||
if (!workTitle || !workDescription) {
|
||||
setTagGenerationError('请先填写作品名称和作品描述。');
|
||||
return;
|
||||
}
|
||||
setTagGenerationError(null);
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_tags',
|
||||
workTitle,
|
||||
workDescription,
|
||||
levelName: firstLevel?.levelName.trim(),
|
||||
summary: workDescription,
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { Character, CustomWorldProfile } from '../../types';
|
||||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
@@ -91,10 +92,19 @@ export function RpgCreationResultView({
|
||||
qualityFindings = [],
|
||||
}: RpgCreationResultViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
const [openingCgGenerating, setOpeningCgGenerating] = useState(false);
|
||||
const [openingCgGenerationError, setOpeningCgGenerationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const latestProfileRef = useRef(profile);
|
||||
const assetDebugEnabled = useMemo(
|
||||
() => shouldEnableRpgCreationAssetDebugPanel(),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestProfileRef.current = profile;
|
||||
}, [profile]);
|
||||
const {
|
||||
closeEditorTarget,
|
||||
createLabel,
|
||||
@@ -133,6 +143,32 @@ export function RpgCreationResultView({
|
||||
}
|
||||
: handleDeleteLandmarks;
|
||||
|
||||
const handleGenerateOpeningCg = async () => {
|
||||
if (readOnly || isGenerating || openingCgGenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpeningCgGenerating(true);
|
||||
setOpeningCgGenerationError(null);
|
||||
try {
|
||||
const openingCg = await rpgCreationAssetClient.generateOpeningCg({
|
||||
profile: latestProfileRef.current,
|
||||
});
|
||||
onProfileChange({
|
||||
...latestProfileRef.current,
|
||||
openingCg,
|
||||
});
|
||||
} catch (generationError) {
|
||||
setOpeningCgGenerationError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '生成开局 CG 失败',
|
||||
);
|
||||
} finally {
|
||||
setOpeningCgGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,98rem)] xl:px-1 2xl:max-w-[min(100%,112rem)]">
|
||||
<RpgCreationResultHeader
|
||||
@@ -152,6 +188,14 @@ export function RpgCreationResultView({
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||
onDeleteLandmarks={deleteLandmarks}
|
||||
openingCgGenerating={openingCgGenerating}
|
||||
openingCgPhaseLabel={
|
||||
openingCgGenerating ? '正在生成开局 CG' : null
|
||||
}
|
||||
openingCgGenerateDisabled={isGenerating}
|
||||
onGenerateOpeningCg={
|
||||
readOnly ? undefined : () => void handleGenerateOpeningCg()
|
||||
}
|
||||
createActionLabel={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||
? undefined
|
||||
@@ -227,6 +271,11 @@ export function RpgCreationResultView({
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{!error && openingCgGenerationError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{openingCgGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{assetDebugEnabled ? (
|
||||
<RpgCreationAssetDebugPanel profile={profile} />
|
||||
) : null}
|
||||
|
||||
@@ -138,7 +138,9 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openExistingRpgDraft(
|
||||
@@ -1867,7 +1869,7 @@ beforeEach(() => {
|
||||
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
|
||||
});
|
||||
|
||||
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
|
||||
test('create hub hides RPG and Match3D while keeping AIRP and visual novel locked', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
@@ -1881,15 +1883,10 @@ test('create hub opens RPG while keeping AIRP and visual novel locked', async ()
|
||||
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await screen.findByText('Agent工作区:custom-world-agent-session-1'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /抓大鹅/u })).toBeNull();
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('platform create hub does not prefetch hidden big fish platform data', async () => {
|
||||
@@ -1900,7 +1897,7 @@ test('platform create hub does not prefetch hidden big fish platform data', asyn
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /角色扮演/u }),
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(listBigFishWorks).not.toHaveBeenCalled();
|
||||
@@ -2643,7 +2640,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('selecting RPG creation while logged out routes through requireAuth', async () => {
|
||||
test('selecting puzzle creation while logged out routes through requireAuth', async () => {
|
||||
const user = userEvent.setup();
|
||||
const requireAuth = vi.fn();
|
||||
|
||||
@@ -2658,12 +2655,15 @@ test('selecting RPG creation while logged out routes through requireAuth', async
|
||||
);
|
||||
|
||||
await openCreationHub(user);
|
||||
const rpgButton = await screen.findByRole('button', { name: /角色扮演/u });
|
||||
const puzzleButton = await screen.findByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
});
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
expect(requireAuth).toHaveBeenCalledTimes(1);
|
||||
expect(createRpgCreationSession).not.toHaveBeenCalled();
|
||||
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('restoring an agent workspace while logged out opens login modal before loading the protected session', async () => {
|
||||
@@ -2772,10 +2772,10 @@ test('refreshing RPG agent path restores stored agent workspace pointer', async
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
test('new puzzle creation entry maps raw bearer token errors to user-facing auth copy', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(createRpgCreationSession).mockRejectedValueOnce(
|
||||
vi.mocked(createPuzzleAgentSession).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '缺少 Authorization Bearer Token',
|
||||
status: 401,
|
||||
@@ -2786,13 +2786,15 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
const rpgButton = screen.getByRole('button', { name: /角色扮演/u });
|
||||
const puzzleButton = screen.getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
});
|
||||
|
||||
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(rpgButton);
|
||||
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
|
||||
await user.click(puzzleButton);
|
||||
|
||||
expect(listPuzzleWorks).toHaveBeenCalled();
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
await within(getPlatformTabPanel('create')).findByText(
|
||||
'当前登录状态已失效,请重新登录后继续。',
|
||||
@@ -2839,7 +2841,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
expect(screen.queryByText(/正在准备拼图共创工作区/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('match3d creation card opens workspace even when public galleries fail', async () => {
|
||||
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
|
||||
const user = userEvent.setup();
|
||||
const match3dSession = buildMockMatch3DAgentSession();
|
||||
|
||||
@@ -2858,20 +2860,13 @@ test('match3d creation card opens workspace even when public galleries fail', as
|
||||
await openCreationHub(user);
|
||||
expect(screen.queryByText('读取作品广场失败')).toBeNull();
|
||||
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
|
||||
|
||||
const button = screen.getByRole('button', {
|
||||
name: /抓大鹅.*经典消除玩法/u,
|
||||
});
|
||||
expect(button as HTMLButtonElement).toHaveProperty('disabled', false);
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(match3dCreationClient.createSession).toHaveBeenCalledWith({});
|
||||
});
|
||||
expect(await screen.findByText('抓大鹅工作区:match3d-agent-session-1')).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /抓大鹅.*经典消除玩法/u }),
|
||||
).toBeNull();
|
||||
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('puzzle draft card restores the bound agent session and opens the result view', async () => {
|
||||
test('puzzle draft result back button returns to creation hub', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(listPuzzleWorks).mockResolvedValue({
|
||||
@@ -2913,9 +2908,12 @@ test('puzzle draft card restores the bound agent session and opens the result vi
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('拼图玩法共创')).toBeNull();
|
||||
expect(
|
||||
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText('拼图结果页')).toBeNull();
|
||||
});
|
||||
|
||||
test('published puzzle work card restores its source session for editing', async () => {
|
||||
@@ -4361,7 +4359,9 @@ test('agent draft result back button returns to creation hub without syncing res
|
||||
await user.click(screen.getByRole('button', { name: /返回创作/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -4677,13 +4677,17 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /拼图.*创意礼物/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText('角色扮演'),
|
||||
within(getPlatformTabPanel('create')).getByRole('button', {
|
||||
name: /拼图.*创意礼物/u,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -5045,9 +5049,22 @@ test('creation hub published work card keeps delete action guarded by detail flo
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
|
||||
expect(await screen.findByRole('button', { name: /查看详情/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
await user.click(screen.getByRole('button', { name: '删除' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.parentElement?.className).toContain('!items-center');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('platform-remap-surface');
|
||||
expect(dialog.className).toContain('rounded-[1.75rem]');
|
||||
expect(
|
||||
within(dialog).getByText('确认删除《潮雾列岛》吗?'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: '确认删除' }),
|
||||
).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -151,6 +151,7 @@ export function useRpgRuntimeShellViewModel(
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
onDeferredAutoChoice: (option) => handleChoice(option),
|
||||
});
|
||||
const {
|
||||
visibleGameState,
|
||||
@@ -222,12 +223,24 @@ export function useRpgRuntimeShellViewModel(
|
||||
const handleSceneTransitionChoice = useCallback(
|
||||
(option: StoryOption) => {
|
||||
const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId];
|
||||
if (transitionMode) {
|
||||
const shouldBeginTransition =
|
||||
transitionMode &&
|
||||
(option.functionId !== 'story_continue_adventure' ||
|
||||
Boolean(
|
||||
currentStory?.deferredAutoChoice ||
|
||||
currentStory?.deferredRuntimeState,
|
||||
));
|
||||
if (shouldBeginTransition) {
|
||||
beginSceneTransition(transitionMode);
|
||||
}
|
||||
handleChoice(option);
|
||||
},
|
||||
[beginSceneTransition, handleChoice],
|
||||
[
|
||||
beginSceneTransition,
|
||||
currentStory?.deferredAutoChoice,
|
||||
currentStory?.deferredRuntimeState,
|
||||
handleChoice,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
AnimationState,
|
||||
type GameState,
|
||||
type StoryMoment,
|
||||
type StoryOption,
|
||||
WorldType,
|
||||
} from '../../types';
|
||||
import { useRpgSceneTransitionModel } from './useRpgSceneTransitionModel';
|
||||
|
||||
function createGameState(actId: string): GameState {
|
||||
return {
|
||||
worldType: WorldType.WUXIA,
|
||||
customWorldProfile: null,
|
||||
playerCharacter: {
|
||||
id: 'hero',
|
||||
name: '测试主角',
|
||||
title: '游侠',
|
||||
description: '测试角色',
|
||||
backstory: '测试背景',
|
||||
avatar: '',
|
||||
portrait: '',
|
||||
assetFolder: '',
|
||||
assetVariant: '',
|
||||
attributes: {
|
||||
strength: 10,
|
||||
agility: 10,
|
||||
intelligence: 10,
|
||||
spirit: 10,
|
||||
},
|
||||
personality: '沉稳',
|
||||
skills: [],
|
||||
adventureOpenings: {},
|
||||
},
|
||||
runtimeStats: {
|
||||
playTimeMs: 0,
|
||||
lastPlayTickAt: null,
|
||||
hostileNpcsDefeated: 0,
|
||||
questsAccepted: 0,
|
||||
itemsUsed: 0,
|
||||
scenesTraveled: 0,
|
||||
},
|
||||
currentScene: 'Story',
|
||||
storyHistory: [{ text: '旧幕', options: [] }],
|
||||
storyEngineMemory: {
|
||||
discoveredFactIds: [],
|
||||
activeThreadIds: [],
|
||||
resolvedScarIds: [],
|
||||
recentCarrierIds: [],
|
||||
currentSceneActState: {
|
||||
sceneId: 'scene-1',
|
||||
chapterId: 'chapter-1',
|
||||
currentActId: actId,
|
||||
currentActIndex: actId === 'act-1' ? 0 : 1,
|
||||
completedActIds: actId === 'act-1' ? [] : ['act-1'],
|
||||
visitedActIds: [actId],
|
||||
},
|
||||
},
|
||||
characterChats: {},
|
||||
animationState: AnimationState.IDLE,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
currentScenePreset: {
|
||||
id: 'scene-1',
|
||||
name: '断桥旧哨',
|
||||
description: '测试场景',
|
||||
imageSrc: '/scene.png',
|
||||
treasureHints: [],
|
||||
npcs: [],
|
||||
},
|
||||
sceneHostileNpcs: [],
|
||||
playerX: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
playerActionMode: 'idle',
|
||||
scrollWorld: false,
|
||||
inBattle: false,
|
||||
playerHp: 100,
|
||||
playerMaxHp: 100,
|
||||
playerMana: 20,
|
||||
playerMaxMana: 20,
|
||||
playerSkillCooldowns: {},
|
||||
activeCombatEffects: [],
|
||||
playerCurrency: 0,
|
||||
playerInventory: [],
|
||||
playerEquipment: {
|
||||
weapon: null,
|
||||
armor: null,
|
||||
relic: null,
|
||||
},
|
||||
npcStates: {},
|
||||
quests: [],
|
||||
roster: [],
|
||||
companions: [],
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
currentNpcBattleOutcome: null,
|
||||
sparReturnEncounter: null,
|
||||
sparPlayerHpBefore: null,
|
||||
sparPlayerMaxHpBefore: null,
|
||||
sparStoryHistoryBefore: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
options: StoryOption[] = [],
|
||||
deferredAutoChoice?: StoryOption,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options,
|
||||
deferredAutoChoice,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRpgSceneTransitionModel', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('fires deferred auto choice only after entry and through the latest callback', () => {
|
||||
vi.useFakeTimers();
|
||||
const autoChoice: StoryOption = {
|
||||
functionId: 'npc_preview_talk',
|
||||
actionText: '与新角色交谈',
|
||||
text: '与新角色交谈',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.IDLE,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right',
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const firstCallback = vi.fn();
|
||||
const latestCallback = vi.fn();
|
||||
const initialState = createGameState('act-1');
|
||||
const initialStory = createStory('旧幕收束', [
|
||||
{
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
text: '继续冒险',
|
||||
visuals: autoChoice.visuals,
|
||||
},
|
||||
]);
|
||||
const nextStory = createStory('新幕入口', [autoChoice], autoChoice);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
(props: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment;
|
||||
onDeferredAutoChoice: (option: StoryOption) => void;
|
||||
}) =>
|
||||
useRpgSceneTransitionModel({
|
||||
gameState: props.gameState,
|
||||
currentStory: props.currentStory,
|
||||
openingCampSceneId: null,
|
||||
onDeferredAutoChoice: props.onDeferredAutoChoice,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
gameState: initialState,
|
||||
currentStory: initialStory,
|
||||
onDeferredAutoChoice: firstCallback,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setSceneTransitionDurations({ exitMs: 20, entryMs: 30 });
|
||||
});
|
||||
act(() => {
|
||||
result.current.beginSceneTransition('content-change');
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('exiting');
|
||||
|
||||
rerender({
|
||||
gameState: createGameState('act-2'),
|
||||
currentStory: nextStory,
|
||||
onDeferredAutoChoice: latestCallback,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(20);
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('entering');
|
||||
expect(latestCallback).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30);
|
||||
});
|
||||
|
||||
expect(result.current.sceneTransitionPhase).toBe('idle');
|
||||
expect(firstCallback).not.toHaveBeenCalled();
|
||||
expect(latestCallback).toHaveBeenCalledWith(autoChoice);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameState, StoryMoment } from '../../types';
|
||||
import type { GameState, StoryMoment, StoryOption } from '../../types';
|
||||
|
||||
export type SceneTransitionPhase = 'idle' | 'exiting' | 'entering';
|
||||
export type SceneTransitionTriggerMode = 'scene-change' | 'content-change';
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_SCENE_SWITCH_ENTRY_MS = 5930;
|
||||
export const SCENE_TRANSITION_FUNCTION_MODES: Partial<
|
||||
Record<string, SceneTransitionTriggerMode>
|
||||
> = {
|
||||
story_continue_adventure: 'content-change',
|
||||
idle_travel_next_scene: 'scene-change',
|
||||
camp_travel_home_scene: 'scene-change',
|
||||
idle_explore_forward: 'content-change',
|
||||
@@ -29,6 +30,9 @@ function buildSceneTransitionContentKey(
|
||||
currentStory: StoryMoment | null,
|
||||
) {
|
||||
const sceneId = gameState.currentScenePreset?.id ?? 'scene:none';
|
||||
const sceneActId =
|
||||
gameState.storyEngineMemory?.currentSceneActState?.currentActId ??
|
||||
'act:none';
|
||||
const encounterKey = gameState.currentEncounter
|
||||
? `${gameState.currentEncounter.kind}:${gameState.currentEncounter.id ?? gameState.currentEncounter.npcName ?? 'unknown'}`
|
||||
: 'encounter:none';
|
||||
@@ -39,9 +43,9 @@ function buildSceneTransitionContentKey(
|
||||
)
|
||||
.join('|');
|
||||
const storyKey = currentStory
|
||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}`
|
||||
? `${currentStory.displayMode ?? 'story'}:${currentStory.text ?? ''}:${currentStory.dialogue?.length ?? 0}:${currentStory.options.map((option) => option.functionId).join('|')}:${currentStory.deferredAutoChoice?.functionId ?? 'auto:none'}`
|
||||
: 'story:none';
|
||||
return [sceneId, encounterKey, monsterKey, storyKey].join('::');
|
||||
return [sceneId, sceneActId, encounterKey, monsterKey, storyKey].join('::');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,8 +56,14 @@ export function useRpgSceneTransitionModel(params: {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
openingCampSceneId: string | null;
|
||||
onDeferredAutoChoice?: ((option: StoryOption) => void) | null;
|
||||
}) {
|
||||
const { gameState, currentStory, openingCampSceneId } = params;
|
||||
const {
|
||||
gameState,
|
||||
currentStory,
|
||||
openingCampSceneId,
|
||||
onDeferredAutoChoice = null,
|
||||
} = params;
|
||||
const [renderGameState, setRenderGameState] = useState(gameState);
|
||||
const [renderCurrentStory, setRenderCurrentStory] = useState(currentStory);
|
||||
const [sceneTransitionPhase, setSceneTransitionPhase] =
|
||||
@@ -73,6 +83,13 @@ export function useRpgSceneTransitionModel(params: {
|
||||
});
|
||||
const sceneTransitionTimerIdsRef = useRef<number[]>([]);
|
||||
const sceneTransitionRequestRef = useRef<SceneTransitionRequest | null>(null);
|
||||
const pendingDeferredAutoChoiceRef =
|
||||
useRef<StoryOption | null>(null);
|
||||
const onDeferredAutoChoiceRef = useRef(onDeferredAutoChoice);
|
||||
|
||||
useEffect(() => {
|
||||
onDeferredAutoChoiceRef.current = onDeferredAutoChoice;
|
||||
}, [onDeferredAutoChoice]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -81,6 +98,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
);
|
||||
sceneTransitionTimerIdsRef.current = [];
|
||||
sceneTransitionRequestRef.current = null;
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -98,6 +116,15 @@ export function useRpgSceneTransitionModel(params: {
|
||||
|
||||
const entryTimerId = window.setTimeout(() => {
|
||||
setSceneTransitionPhase('idle');
|
||||
const autoChoice =
|
||||
payload.currentStory?.deferredAutoChoice ??
|
||||
pendingDeferredAutoChoiceRef.current;
|
||||
if (autoChoice) {
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
// 中文注释:入场计时器可能跨过一次 currentStory/gameState 更新,
|
||||
// 必须读取最新回调,避免用点击“继续冒险”前的旧状态自动开聊。
|
||||
onDeferredAutoChoiceRef.current?.(autoChoice);
|
||||
}
|
||||
}, sceneTransitionDurations.entryMs);
|
||||
sceneTransitionTimerIdsRef.current.push(entryTimerId);
|
||||
},
|
||||
@@ -109,6 +136,7 @@ export function useRpgSceneTransitionModel(params: {
|
||||
if (sceneTransitionPhase !== 'idle') return;
|
||||
|
||||
pendingScenePayloadRef.current = { gameState, currentStory };
|
||||
pendingDeferredAutoChoiceRef.current = null;
|
||||
sceneTransitionTimerIdsRef.current.forEach((timerId) =>
|
||||
window.clearTimeout(timerId),
|
||||
);
|
||||
@@ -170,6 +198,8 @@ export function useRpgSceneTransitionModel(params: {
|
||||
: buildSceneTransitionContentKey(gameState, currentStory) !==
|
||||
request.baselineContentKey;
|
||||
if (isReady) {
|
||||
pendingDeferredAutoChoiceRef.current =
|
||||
currentStory?.deferredAutoChoice ?? null;
|
||||
startSceneEntering({ gameState, currentStory });
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
title: '角色扮演',
|
||||
subtitle: '敬请期待',
|
||||
badge: '敬请期待',
|
||||
visible: true,
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
@@ -43,7 +43,7 @@ export const NEW_WORK_ENTRY_CONFIG = {
|
||||
title: '抓大鹅',
|
||||
subtitle: '经典消除玩法',
|
||||
badge: '可创建',
|
||||
visible: true,
|
||||
visible: false,
|
||||
open: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -398,6 +398,88 @@ describe('createStoryChoiceActions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the deferred auto choice for the scene transition model to trigger after entry', async () => {
|
||||
const state = {
|
||||
...createBaseState(),
|
||||
inBattle: false,
|
||||
currentBattleNpcId: null,
|
||||
currentNpcBattleMode: null,
|
||||
};
|
||||
const autoChoice = createBattleOption('npc_preview_talk');
|
||||
const continueOption: StoryOption = {
|
||||
functionId: 'story_continue_adventure',
|
||||
actionText: '继续冒险',
|
||||
text: '继续冒险',
|
||||
visuals: {
|
||||
playerAnimation: AnimationState.RUN,
|
||||
playerMoveMeters: 0,
|
||||
playerOffsetY: 0,
|
||||
playerFacing: 'right' as const,
|
||||
scrollWorld: false,
|
||||
monsterChanges: [],
|
||||
},
|
||||
};
|
||||
const currentStory: StoryMoment = {
|
||||
text: '对话已经完成',
|
||||
options: [continueOption],
|
||||
deferredOptions: [autoChoice],
|
||||
deferredAutoChoice: autoChoice,
|
||||
};
|
||||
const setCurrentStory = vi.fn();
|
||||
|
||||
const { handleChoice } = createStoryChoiceActions({
|
||||
gameState: state,
|
||||
currentStory,
|
||||
isLoading: false,
|
||||
setGameState: vi.fn(),
|
||||
setCurrentStory,
|
||||
setAiError: vi.fn(),
|
||||
setIsLoading: vi.fn(),
|
||||
setBattleReward: vi.fn(),
|
||||
buildResolvedChoiceState: vi.fn(),
|
||||
playResolvedChoice: vi.fn(),
|
||||
buildStoryContextFromState: vi.fn(),
|
||||
buildStoryFromResponse: vi.fn((_, __, response) => response),
|
||||
buildFallbackStoryForState: vi.fn(() => createFallbackStory()),
|
||||
generateStoryForState: vi.fn(),
|
||||
getAvailableOptionsForState: vi.fn(() => null),
|
||||
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),
|
||||
enterNpcInteraction: vi.fn(() => false),
|
||||
handleNpcInteraction: vi.fn(),
|
||||
handleTreasureInteraction: vi.fn(() => false),
|
||||
commitGeneratedStateWithEncounterEntry: vi.fn(),
|
||||
finalizeNpcBattleResult: vi.fn(() => null),
|
||||
isContinueAdventureOption: vi.fn(
|
||||
(option: StoryOption) =>
|
||||
option.functionId === 'story_continue_adventure',
|
||||
),
|
||||
isCampTravelHomeOption: vi.fn(() => false),
|
||||
isRegularNpcEncounter: neverNpcEncounter,
|
||||
isNpcEncounter: neverNpcEncounter,
|
||||
npcPreviewTalkFunctionId: 'npc_preview_talk',
|
||||
fallbackCompanionName: '同伴',
|
||||
turnVisualMs: 820,
|
||||
});
|
||||
|
||||
await handleChoice(continueOption);
|
||||
|
||||
expect(setCurrentStory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: [autoChoice],
|
||||
deferredOptions: undefined,
|
||||
deferredAutoChoice: autoChoice,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps npc chat choices on the local UI path so chat mode can continue streaming locally', async () => {
|
||||
const state = createBaseState();
|
||||
const option = createBattleOption('npc_chat');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
import { ensureSceneEncounterPreview } from '../../data/sceneEncounterPreviews';
|
||||
import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import { isServerRuntimeFunctionId } from '../../services/rpg-runtime';
|
||||
import {
|
||||
@@ -185,8 +186,13 @@ export function createStoryChoiceActions({
|
||||
currentStory?.deferredOptions?.length &&
|
||||
isContinueAdventureOption(option)
|
||||
) {
|
||||
const deferredAutoChoice =
|
||||
currentStory.deferredAutoChoice &&
|
||||
currentStory.deferredOptions.includes(currentStory.deferredAutoChoice)
|
||||
? currentStory.deferredAutoChoice
|
||||
: undefined;
|
||||
if (currentStory.deferredRuntimeState) {
|
||||
setGameState({
|
||||
const restoredState = ensureSceneEncounterPreview({
|
||||
...gameState,
|
||||
currentEncounter: null,
|
||||
npcInteractionActive: false,
|
||||
@@ -202,12 +208,15 @@ export function createStoryChoiceActions({
|
||||
currentStory.deferredRuntimeState.storyEngineMemory ??
|
||||
gameState.storyEngineMemory,
|
||||
});
|
||||
|
||||
setGameState(restoredState);
|
||||
}
|
||||
setCurrentStory({
|
||||
...currentStory,
|
||||
options: currentStory.deferredOptions,
|
||||
deferredOptions: undefined,
|
||||
deferredRuntimeState: undefined,
|
||||
deferredAutoChoice,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useStoryChoiceCoordinator } from './useStoryChoiceCoordinator';
|
||||
type RpgRuntimeInteractionFlowParams = {
|
||||
gameState: GameState;
|
||||
isLoading: boolean;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
interactionConfig: StoryInteractionCoordinatorConfig;
|
||||
runtimeSupport: StoryRuntimeSupport;
|
||||
buildResolvedChoiceState: (
|
||||
@@ -76,6 +77,7 @@ export function createClearStoryInteractionUi(params: {
|
||||
export function useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
sceneTransitionPhase = 'idle',
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
@@ -117,7 +119,15 @@ export function useRpgRuntimeInteractionFlow({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || gameState.inBattle || gameState.npcInteractionActive) {
|
||||
const pendingAutoChoice =
|
||||
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice;
|
||||
if (
|
||||
isLoading ||
|
||||
sceneTransitionPhase !== 'idle' ||
|
||||
pendingAutoChoice ||
|
||||
gameState.inBattle ||
|
||||
gameState.npcInteractionActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -134,8 +144,10 @@ export function useRpgRuntimeInteractionFlow({
|
||||
gameState.currentEncounter,
|
||||
gameState.inBattle,
|
||||
gameState.npcInteractionActive,
|
||||
interactionConfig.npcEncounterActions.currentStory?.deferredAutoChoice,
|
||||
isLoading,
|
||||
isNpcEncounter,
|
||||
sceneTransitionPhase,
|
||||
]);
|
||||
|
||||
const choiceRuntimeController: Parameters<
|
||||
|
||||
@@ -25,8 +25,8 @@ import type { StoryGenerationContext } from '../../services/aiTypes';
|
||||
import {
|
||||
advanceSceneActRuntimeState,
|
||||
getSceneConnectionDirectionText,
|
||||
resolveSceneActProgression,
|
||||
resolveLimitedPrimaryNpcChatState,
|
||||
resolveSceneActProgression,
|
||||
} from '../../services/customWorldSceneActRuntime';
|
||||
import { normalizeStoryEngineMemoryState } from '../../services/storyEngine/visibilityEngine';
|
||||
import type {
|
||||
@@ -1730,6 +1730,14 @@ export function createStoryNpcEncounterActions({
|
||||
deferredOptions: progressionResult?.options,
|
||||
deferredRuntimeState:
|
||||
progressionResult?.deferredRuntimeState ?? undefined,
|
||||
deferredAutoChoice:
|
||||
progressionResult?.options.find(
|
||||
(option) => option.functionId === 'npc_preview_talk',
|
||||
) ??
|
||||
progressionResult?.options.find(
|
||||
(option) => option.functionId === 'npc_chat',
|
||||
) ??
|
||||
undefined,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -56,11 +56,13 @@ export type {
|
||||
export function useRpgRuntimeStory({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase = 'idle',
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
}: {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -108,6 +110,7 @@ export function useRpgRuntimeStory({
|
||||
} = useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase,
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useStoryGoalOptionCoordinator } from './useStoryGoalOptionCoordinator';
|
||||
type RpgRuntimeStoryFlowParams = {
|
||||
gameState: GameState;
|
||||
setGameState: Dispatch<SetStateAction<GameState>>;
|
||||
sceneTransitionPhase?: 'idle' | 'exiting' | 'entering';
|
||||
buildResolvedChoiceState: (
|
||||
state: GameState,
|
||||
option: StoryOption,
|
||||
@@ -61,6 +62,7 @@ type RpgRuntimeStoryFlowParams = {
|
||||
export function useRpgRuntimeStoryFlow({
|
||||
gameState,
|
||||
setGameState,
|
||||
sceneTransitionPhase = 'idle',
|
||||
buildResolvedChoiceState,
|
||||
playResolvedChoice,
|
||||
getStoryGenerationHostileNpcs,
|
||||
@@ -148,6 +150,7 @@ export function useRpgRuntimeStoryFlow({
|
||||
} = useRpgRuntimeInteractionFlow({
|
||||
gameState,
|
||||
isLoading,
|
||||
sceneTransitionPhase,
|
||||
interactionConfig,
|
||||
runtimeSupport,
|
||||
buildResolvedChoiceState,
|
||||
|
||||
@@ -7,6 +7,46 @@ import {
|
||||
} from './miniGameDraftGenerationProgress';
|
||||
|
||||
describe('miniGameDraftGenerationProgress', () => {
|
||||
test('puzzle draft generation follows picture-only creation steps', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'compile',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 1500);
|
||||
|
||||
expect(progress?.steps.map((step) => step.label)).toEqual([
|
||||
'编译首关草稿',
|
||||
'生成首关画面',
|
||||
'写入正式草稿',
|
||||
]);
|
||||
expect(progress?.phaseLabel).toBe('编译首关草稿');
|
||||
expect(progress?.steps[0]?.detail).toBe(
|
||||
'根据画面描述生成首关名称和结果页草稿。',
|
||||
);
|
||||
});
|
||||
|
||||
test('puzzle ready copy points to result page work info completion', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'puzzle',
|
||||
phase: 'ready',
|
||||
startedAtMs: 1000,
|
||||
completedAssetCount: 1,
|
||||
totalAssetCount: 1,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const progress = buildMiniGameDraftGenerationProgress(state, 2000);
|
||||
|
||||
expect(progress?.phaseDetail).toBe(
|
||||
'首关草稿与正式图已准备完成,可进入结果页补作品信息。',
|
||||
);
|
||||
});
|
||||
|
||||
test('big fish draft generation exposes multiple draft steps', () => {
|
||||
const state: MiniGameDraftGenerationState = {
|
||||
kind: 'big-fish',
|
||||
@@ -111,24 +151,12 @@ describe('miniGameDraftGenerationProgress', () => {
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-29T00:00:00.000Z',
|
||||
}, {
|
||||
seedText: '表单作品名',
|
||||
workTitle: '暖灯猫街',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
id: 'puzzle-title',
|
||||
label: '作品名称',
|
||||
value: '暖灯猫街',
|
||||
},
|
||||
{
|
||||
id: 'work-description',
|
||||
label: '作品描述',
|
||||
value: '一套雨夜猫街主题拼图。',
|
||||
},
|
||||
{
|
||||
id: 'picture-description',
|
||||
label: '画面描述',
|
||||
|
||||
@@ -47,20 +47,20 @@ type MiniGameAnchorSource = {
|
||||
const PUZZLE_STEPS = [
|
||||
{
|
||||
id: 'compile',
|
||||
label: '编译拼图草稿',
|
||||
detail: '整理主题、主体、构图与标签。',
|
||||
label: '编译首关草稿',
|
||||
detail: '根据画面描述生成首关名称和结果页草稿。',
|
||||
weight: 34,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-images',
|
||||
label: '生成拼图图片',
|
||||
detail: '根据草稿生成候选图。',
|
||||
label: '生成首关画面',
|
||||
detail: '按画面描述和参考图生成第一张拼图图。',
|
||||
weight: 33,
|
||||
},
|
||||
{
|
||||
id: 'puzzle-select-image',
|
||||
label: '确认正式图片',
|
||||
detail: '选择候选图写入结果页。',
|
||||
label: '写入正式草稿',
|
||||
detail: '把首图设为正式图并同步到结果页。',
|
||||
weight: 33,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<MiniGameStepDefinition>;
|
||||
@@ -211,7 +211,7 @@ export function buildMiniGameDraftGenerationProgress(
|
||||
(normalizedState.phase === 'ready'
|
||||
? normalizedState.kind === 'big-fish'
|
||||
? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。'
|
||||
: '完整草稿与资产已准备完成。'
|
||||
: '首关草稿与正式图已准备完成,可进入结果页补作品信息。'
|
||||
: activeStep.detail),
|
||||
batchLabel: activeStep.label,
|
||||
overallProgress: clampProgress(overallProgress),
|
||||
@@ -238,28 +238,12 @@ export function buildPuzzleGenerationAnchorEntries(
|
||||
}
|
||||
|
||||
const entries: Array<MiniGameAnchorSource | null> = [
|
||||
{
|
||||
key: 'puzzle-title',
|
||||
label: '作品名称',
|
||||
value:
|
||||
formPayload?.workTitle?.trim() ||
|
||||
formPayload?.seedText?.trim() ||
|
||||
session.draft?.workTitle ||
|
||||
session.anchorPack.themePromise.value,
|
||||
},
|
||||
{
|
||||
key: 'work-description',
|
||||
label: '作品描述',
|
||||
value:
|
||||
formPayload?.workDescription?.trim() ||
|
||||
session.draft?.workDescription ||
|
||||
'',
|
||||
},
|
||||
{
|
||||
key: 'picture-description',
|
||||
label: '画面描述',
|
||||
value:
|
||||
formPayload?.pictureDescription?.trim() ||
|
||||
formPayload?.seedText?.trim() ||
|
||||
session.draft?.levels?.[0]?.pictureDescription ||
|
||||
session.anchorPack.visualSubject.value,
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ const { requestJsonMock } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
generateRpgWorldOpeningCg,
|
||||
generateRpgWorldLandmark,
|
||||
generateRpgWorldSceneImage,
|
||||
generateRpgWorldSceneNpc,
|
||||
@@ -23,6 +24,11 @@ describe('rpgCreationAssetClient', () => {
|
||||
entity: { id: 'landmark-1', name: '雾港' },
|
||||
imageSrc: '/generated-custom-world-scenes/profile/scene/image.webp',
|
||||
npc: { id: 'npc-1', name: '守灯人' },
|
||||
openingCg: {
|
||||
id: 'opening-cg-1',
|
||||
status: 'ready',
|
||||
videoSrc: '/generated-custom-world-scenes/profile/opening.mp4',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,4 +95,24 @@ describe('rpgCreationAssetClient', () => {
|
||||
'生成场景 NPC 失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('posts opening cg generation to the runtime custom world asset route', async () => {
|
||||
const openingCg = await generateRpgWorldOpeningCg({
|
||||
profile: {
|
||||
id: 'profile-1',
|
||||
name: '雾海群岛',
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(openingCg.videoSrc).toBe(
|
||||
'/generated-custom-world-scenes/profile/opening.mp4',
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/custom-world/opening-cg',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'生成开局 CG 失败',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ASSET_API_PATHS } from '../../editor/shared/editorApiClient';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldOpeningCgProfile,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
@@ -132,6 +133,20 @@ export async function generateRpgWorldLandmark(payload: {
|
||||
return response.entity;
|
||||
}
|
||||
|
||||
export async function generateRpgWorldOpeningCg(payload: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const response = await requestRpgCreationPostJson<{
|
||||
openingCg: CustomWorldOpeningCgProfile;
|
||||
}>(
|
||||
`${RPG_CREATION_ASSET_API_BASE}/opening-cg`,
|
||||
payload,
|
||||
'生成开局 CG 失败',
|
||||
);
|
||||
|
||||
return response.openingCg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作包 D 把结果页与编辑器依赖的资产请求迁入 RPG 创作域 client,
|
||||
* 保留封面资产服务的既有边界,不把逻辑重新塞回 `aiService.ts`。
|
||||
@@ -143,6 +158,7 @@ export const rpgCreationAssetClient = {
|
||||
generatePlayableNpc: generateRpgWorldPlayableNpc,
|
||||
generateStoryNpc: generateRpgWorldStoryNpc,
|
||||
generateLandmark: generateRpgWorldLandmark,
|
||||
generateOpeningCg: generateRpgWorldOpeningCg,
|
||||
generateCoverImage: generateCustomWorldCoverImage,
|
||||
uploadCoverImage: uploadCustomWorldCoverImage,
|
||||
};
|
||||
|
||||
@@ -65,7 +65,8 @@ function createRuntimeProjection(
|
||||
overrides: RuntimeProjectionOverrides = {},
|
||||
): StoryRuntimeProjectionResponse {
|
||||
const storySession = createStorySession(overrides.storySession);
|
||||
const serverVersion = overrides.serverVersion ?? storySession.version;
|
||||
const serverVersion =
|
||||
overrides.serverVersion ?? storySession.version ?? 1;
|
||||
|
||||
return {
|
||||
storySession,
|
||||
|
||||
@@ -46,6 +46,37 @@ export interface CustomWorldCoverCropRect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type CustomWorldOpeningCgStatus =
|
||||
| 'not_started'
|
||||
| 'storyboard_generating'
|
||||
| 'video_generating'
|
||||
| 'ready'
|
||||
| 'failed';
|
||||
|
||||
export interface CustomWorldOpeningCgProfile {
|
||||
id: string;
|
||||
status: CustomWorldOpeningCgStatus;
|
||||
storyboardImageSrc?: string | null;
|
||||
storyboardAssetId?: string | null;
|
||||
videoSrc?: string | null;
|
||||
videoAssetId?: string | null;
|
||||
posterImageSrc?: string | null;
|
||||
posterAssetId?: string | null;
|
||||
storyboardPrompt?: string | null;
|
||||
videoPrompt?: string | null;
|
||||
imageModel: 'gpt-image-2';
|
||||
videoModel: string;
|
||||
aspectRatio: '16:9';
|
||||
imageSize: '2k';
|
||||
videoResolution: '480p';
|
||||
durationSeconds: 15;
|
||||
pointCost: 80;
|
||||
estimatedWaitMinutes: 10;
|
||||
generatedAt?: string | null;
|
||||
updatedAt: string;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface CreatorFactionSeed {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -411,6 +442,7 @@ export interface CustomWorldProfile {
|
||||
*/
|
||||
playerPremise?: string | null;
|
||||
cover?: CustomWorldCoverProfile | null;
|
||||
openingCg?: CustomWorldOpeningCgProfile | null;
|
||||
templateWorldType: WorldTemplateType;
|
||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||
majorFactions: string[];
|
||||
|
||||
@@ -166,6 +166,8 @@ export interface StoryMoment {
|
||||
currentScenePreset?: ScenePresetInfo | null;
|
||||
storyEngineMemory?: StoryEngineMemoryState;
|
||||
};
|
||||
// 中文注释:用于“继续冒险”过场完成后自动执行下一幕入口,避免角色尚未走到位就开聊。
|
||||
deferredAutoChoice?: StoryOption;
|
||||
historyRole?: StoryHistoryRole;
|
||||
npcChatState?: StoryNpcChatState;
|
||||
npcAffinityEffect?: StoryNpcAffinityEffect | null;
|
||||
|
||||
Reference in New Issue
Block a user