合并 master 并保留外部生成 worker 模式

合入 master 的拼消消、微信能力、OpenSSL 3.2 和 SpacetimeDB 2.4.1 更新
保留外部内容生成 queue/inline、worker lease 与动态扩缩容口径
补齐拼图后台图片生成队列轮询和运行态返回恢复
同步容器、生产运维和 Hermes 共享记忆中的 worker 文档
This commit is contained in:
2026-06-09 16:55:32 +08:00
497 changed files with 66318 additions and 13329 deletions

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import type { CustomWorldCoverRenderMode } from '../services/customWorldCover';
import { ResolvedAssetImage } from './ResolvedAssetImage';

View File

@@ -74,6 +74,12 @@ describe('CustomWorldGenerationView', () => {
expect((container.firstChild as HTMLElement).className).toContain(
'z-[1]',
);
expect((container.firstChild as HTMLElement).className).toContain(
'overflow-hidden',
);
expect((container.firstChild as HTMLElement).className).not.toContain(
'overflow-y-auto',
);
const pageVideo = screen.getByTestId(
'generation-page-background-video',
@@ -114,6 +120,14 @@ describe('CustomWorldGenerationView', () => {
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
expect(screen.getByText('预计等待').parentElement?.className).toContain(
@@ -231,6 +245,10 @@ describe('CustomWorldGenerationView', () => {
expect(
screen.getByTestId('generation-current-step-card').className,
).toContain('bg-white/58');
expect(
screen.getByTestId('generation-current-step-card').parentElement
?.className,
).toContain('mt-5');
expect(
screen.getByRole('progressbar', { name: '编译草稿 进度' }),
).toBeTruthy();
@@ -238,10 +256,11 @@ describe('CustomWorldGenerationView', () => {
expect(screen.queryByText('写回结果')).toBeNull();
expect(screen.queryByText('当前批次')).toBeNull();
expect(screen.queryByText('正在整理当前设定步骤')).toBeNull();
expect(screen.queryByText('竖屏生成题材')).toBeNull();
},
);
test('keeps the setting information panel as compact information cards', () => {
test('does not render setting information cards on generation pages', () => {
render(
<CustomWorldGenerationView
settingText="大鱼吃小鱼题材"
@@ -258,19 +277,15 @@ describe('CustomWorldGenerationView', () => {
backLabel="返回创作中心"
settingDescription={null}
settingActionLabel={null}
settingTitle="当前大鱼吃小鱼信息"
progressTitle="大鱼吃小鱼草稿生成进度"
/>,
);
expect(screen.getByText('当前大鱼吃小鱼信息')).toBeTruthy();
expect(screen.getByText('当前大鱼吃小鱼信息').className).toContain('text-[13px]');
expect(screen.getByText('题材')).toBeTruthy();
expect(screen.getByText('题材').className).toContain('text-[9px]');
expect(screen.getByText('火锅')).toBeTruthy();
expect(screen.getByText('火锅').className).toContain('text-[13px]');
expect(screen.getByText('素材数量')).toBeTruthy();
expect(screen.getByText('20 种素材')).toBeTruthy();
expect(screen.queryByText('当前大鱼吃小鱼信息')).toBeNull();
expect(screen.queryByText('题材')).toBeNull();
expect(screen.queryByText('火锅')).toBeNull();
expect(screen.queryByText('素材数量')).toBeNull();
expect(screen.queryByText('20 种素材')).toBeNull();
expect(screen.queryByText('大鱼吃小鱼题材')).toBeNull();
expect(screen.getByTestId('generation-page-background-video')).toBeTruthy();
});

View File

@@ -97,33 +97,18 @@ function resolveCurrentGenerationStep(
);
}
function buildFallbackRenderKey(
value: string | null | undefined,
fallback: string,
) {
const normalizedValue = value?.trim();
return normalizedValue ? normalizedValue : fallback;
}
export function CustomWorldGenerationView({
settingText,
anchorEntries = [],
progress,
isGenerating,
onBack,
onEditSetting,
onRetry,
onInterrupt,
backLabel = '返回',
settingActionLabel = '修改设定',
retryLabel = '重新开始生成',
interruptLabel = '中断世界生成',
settingTitle = '玩家设定',
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
progressTitle = '生成进度',
activeBadgeLabel = '世界建设中',
idleBadgeLabel = '等待操作',
structuredEmptyText = '正在整理当前设定结构,请稍后。',
hideBatchModule = false,
}: CustomWorldGenerationViewProps) {
void hideBatchModule;
@@ -138,12 +123,6 @@ export function CustomWorldGenerationView({
: isGenerating
? '进行中'
: '待处理';
const hasStructuredAnchors = anchorEntries.length > 0;
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
const normalizedSettingDescription = settingDescription?.trim() ?? '';
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
const hasSettingDescription = normalizedSettingDescription.length > 0;
const estimatedWaitText =
progress?.estimatedRemainingMs != null
? formatDuration(progress.estimatedRemainingMs)
@@ -153,11 +132,10 @@ export function CustomWorldGenerationView({
return (
<div
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto overscroll-y-contain bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
style={{ WebkitOverflowScrolling: 'touch' }}
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
>
<GenerationPageBackdrop />
<div className="relative z-10 mb-6 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-6">
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
<button
type="button"
onClick={onBack}
@@ -171,7 +149,10 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="relative z-10 flex flex-none flex-col gap-4">
<div
className="relative z-10 flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<section className="overflow-hidden px-0 pb-2 pt-0 sm:px-0">
<GenerationProgressHero
title={progressTitle}
@@ -181,7 +162,7 @@ export function CustomWorldGenerationView({
elapsedText={elapsedText}
/>
<div className="mt-[-0.15rem] px-0 sm:px-0">
<div className="mt-5 px-0 sm:mt-[-0.15rem] sm:px-0">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
@@ -191,24 +172,13 @@ export function CustomWorldGenerationView({
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? (
<>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
>
{normalizedSettingActionLabel}
</button>
) : null}
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto"
>
{retryLabel}
</button>
</>
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--primary w-full sm:w-auto"
>
{retryLabel}
</button>
) : onInterrupt ? (
<button
type="button"
@@ -220,55 +190,6 @@ export function CustomWorldGenerationView({
) : null}
</div>
</section>
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[13px] font-black tracking-[0.08em] text-[#111111]">
{settingTitle}
</div>
{hasSettingDescription ? (
<div className="mt-2 text-[13px] leading-6 text-[#7e6656]">
{normalizedSettingDescription}
</div>
) : null}
</div>
{hasSettingActionLabel ? (
<button
type="button"
onClick={onEditSetting}
disabled={isGenerating}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
>
{normalizedSettingActionLabel}
</button>
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{anchorEntries.map((entry, index) => (
<div
key={buildFallbackRenderKey(
entry.id,
`anchor-entry-${index}`,
)}
className="rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4"
>
<div className="text-[9px] font-bold tracking-[0.12em] text-[#8e6f5d] sm:text-[10px]">
{entry.label}
</div>
<div className="mt-2 whitespace-pre-line text-[13px] leading-7 text-[#111111]">
{entry.value}
</div>
</div>
))}
</div>
) : (
<div className="whitespace-pre-line rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4 text-[13px] leading-7 text-[#111111] md:max-h-[16rem] md:overflow-y-auto">
{settingText || structuredEmptyText}
</div>
)}
</section>
</div>
</div>
);

View File

@@ -133,7 +133,7 @@ export function GenerationProgressHero({
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-0 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
@@ -215,7 +215,7 @@ export function GenerationProgressHero({
</div>
</div>
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div className="relative z-20 mt-3 grid w-full grid-cols-2 gap-2 px-0 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"

View File

@@ -1,6 +1,7 @@
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';
import { RuntimeResourcePendingMarker } from './common/RuntimeResourcePendingMarker';
type ResolvedAssetImageProps = Omit<
ImgHTMLAttributes<HTMLImageElement>,
@@ -19,39 +20,50 @@ export function ResolvedAssetImage({
onError,
...rest
}: ResolvedAssetImageProps) {
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
refreshKey,
});
const normalizedSource = src?.trim() ?? '';
const normalizedFallbackSrc = fallbackSrc?.trim() ?? '';
const [useFallbackSrc, setUseFallbackSrc] = useState(false);
const finalSrc =
useFallbackSrc && normalizedFallbackSrc
? normalizedFallbackSrc
: resolvedUrl || normalizedFallbackSrc;
const pendingMarker = (
<RuntimeResourcePendingMarker
source={normalizedSource}
kind="image"
isPending={shouldResolve && isResolving}
/>
);
useEffect(() => {
setUseFallbackSrc(false);
}, [normalizedFallbackSrc, resolvedUrl]);
if (!finalSrc) {
return null;
return pendingMarker;
}
return (
<img
{...rest}
src={finalSrc}
alt={alt}
onError={(event) => {
if (
normalizedFallbackSrc &&
!useFallbackSrc &&
finalSrc !== normalizedFallbackSrc
) {
setUseFallbackSrc(true);
}
onError?.(event);
}}
/>
<>
{pendingMarker}
<img
{...rest}
src={finalSrc}
alt={alt}
onError={(event) => {
if (
normalizedFallbackSrc &&
!useFallbackSrc &&
finalSrc !== normalizedFallbackSrc
) {
setUseFallbackSrc(true);
}
onError?.(event);
}}
/>
</>
);
}

View File

@@ -17,10 +17,13 @@ const baseUser: AuthUser = {
displayName: '138****8000',
avatarUrl: null,
publicUserCode: 'user-tester',
phoneNumber: '13800138000',
phoneNumberMasked: '138****8000',
loginMethod: 'phone',
bindingStatus: 'active',
wechatBound: true,
wechatDisplayName: '微信旅人甲',
wechatAccount: 'wx-openid-bind-001',
};
function renderAccountModal(overrides?: {
@@ -112,6 +115,10 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('direct account entry does not render the settings shell as another dialog', () => {
@@ -129,12 +136,70 @@ test('direct account entry does not render the settings shell as another dialog'
).toBeNull();
});
test('account panel uses compact binding cards and keeps logout actions at the bottom', () => {
renderAccountModal({ entryMode: 'account' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('账号信息')).toBeTruthy();
expect(within(accountDialog).queryByText('身份信息')).toBeNull();
expect(
within(accountDialog).queryByText(
'统一查看身份、安全状态、登录设备与最近操作。',
),
).toBeNull();
expect(within(accountDialog).queryByText('登录方式')).toBeNull();
expect(within(accountDialog).getByText('绑定手机号')).toBeTruthy();
expect(within(accountDialog).getByText('13800138000')).toBeTruthy();
expect(within(accountDialog).queryByText('138****8000')).toBeNull();
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
expect(within(accountDialog).getByText('微信旅人甲')).toBeTruthy();
expect(within(accountDialog).queryByText('wx-openid-bind-001')).toBeNull();
const compactCards = accountDialog.querySelectorAll(
'[data-account-binding-card]',
);
expect(compactCards).toHaveLength(2);
expect(
within(compactCards[0] as HTMLElement).getByRole('button', {
name: '更换手机号',
}),
).toBeTruthy();
expect(
within(compactCards[1] as HTMLElement).getByRole('button', {
name: '更换微信号',
}),
).toBeTruthy();
const accountContent =
accountDialog.querySelector('[data-account-content]') ?? accountDialog;
expect(
accountContent.lastElementChild?.getAttribute('data-account-actions'),
).toBe('true');
});
test('account panel avoids bare bound label when wechat display name is missing', () => {
renderAccountModal({
entryMode: 'account',
user: {
...baseUser,
wechatDisplayName: null,
wechatAccount: 'openid_abcdef123456',
},
});
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
expect(within(accountDialog).getByText('微信账号尾号 123456')).toBeTruthy();
expect(within(accountDialog).queryByText('openid_abcdef123456')).toBeNull();
expect(within(accountDialog).queryByText('已绑定')).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
@@ -162,7 +227,7 @@ test('nested settings panels keep back navigation without an extra close action'
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
@@ -201,7 +266,7 @@ test('settings overlays move focus away from inert triggers and restore it on ba
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
const accountTrigger = screen.getByRole('button', { name: /账号与安全/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
@@ -283,7 +348,7 @@ test('account panel includes merged security devices and audit sections', async
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
@@ -324,7 +389,7 @@ test('current merged session group hides kick action and shows count', async ()
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('2 个会话')).toBeTruthy();
@@ -348,7 +413,7 @@ test('remote merged session group can be revoked with loading state', async () =
revokingSessionIds: ['usess_remote'],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const revokeButton = within(accountDialog).getByRole('button', {
@@ -373,7 +438,7 @@ test('remote session revoke passes the grouped session payload', async () => {
onRevokeSession,
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
await user.click(screen.getByRole('button', { name: /账号与安全/ }));
await user.click(
within(screen.getByRole('dialog', { name: '账号信息' })).getByRole(
'button',

View File

@@ -1,3 +1,4 @@
import { ArrowLeft } from 'lucide-react';
import {
type ReactNode,
useCallback,
@@ -65,8 +66,8 @@ const SETTINGS_SECTIONS: Array<{
label: string;
detail: string;
}> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与安全' },
{ id: 'appearance', label: '主题设置', detail: '亮暗主题' },
{ id: 'account', label: '账号与安全', detail: '身份与设备' },
];
const ACCOUNT_MODAL_MAX_HEIGHT =
@@ -93,17 +94,6 @@ function normalizeSettingsSection(
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -119,6 +109,15 @@ function formatSessionTime(value: string) {
});
}
function formatBoundWechatAccount(value: string | null | undefined) {
const normalized = value?.trim();
if (!normalized) {
return null;
}
return `微信账号尾号 ${normalized.slice(-6)}`;
}
function SettingsEntryCard({
label,
detail,
@@ -166,7 +165,7 @@ function OverlayPanel({
onClose,
children,
}: {
eyebrow: string;
eyebrow?: string;
title: string;
description?: string;
action?: ReactNode;
@@ -184,12 +183,16 @@ function OverlayPanel({
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{eyebrow ? (
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
) : null}
<div
className={`${eyebrow ? 'mt-2 text-2xl' : 'text-xl sm:text-2xl'} font-semibold text-[var(--platform-text-strong)]`}
>
{title}
</div>
{description ? (
@@ -204,9 +207,10 @@ function OverlayPanel({
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
className="platform-button platform-button--ghost min-h-0 gap-1.5 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
) : (
@@ -446,17 +450,18 @@ export function AccountModal({
? '正在同步平台设置...'
: '平台设置已同步';
const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
] as const;
const boundPhoneNumber =
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
const boundWechatDisplayName =
user.wechatDisplayName?.trim() ||
formatBoundWechatAccount(user.wechatAccount) ||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance:
platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
account:
user.phoneNumberMasked || user.wechatBound
user.phoneNumber || user.phoneNumberMasked || user.wechatBound
? '查看身份、安全状态、登录设备与操作记录。'
: '查看账号绑定状态与安全记录。',
};
@@ -524,7 +529,7 @@ export function AccountModal({
{activeSection === 'appearance' ? (
<OverlayPanel
eyebrow="平台偏好"
title="主题外观"
title="主题设置"
description="切换平台亮色或暗色主题。"
onBack={closeSectionPanel}
onClose={onClose}
@@ -568,70 +573,79 @@ export function AccountModal({
{activeSection === 'account' ? (
<OverlayPanel
eyebrow="身份信息"
title="账号信息"
description="统一查看身份、安全状态、登录设备与最近操作。"
standalone={isDirectAccountMode}
onBack={isDirectAccountMode ? undefined : closeSectionPanel}
onClose={onClose}
>
<div className="flex min-h-0 flex-col gap-4">
<div data-account-content className="flex min-h-0 flex-col gap-3">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
{accountSummaryCards.map(([label, value]) => (
<div
key={label}
className="platform-subpanel rounded-2xl px-4 py-3"
>
<div className="text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{label}
</div>
<div className="mt-2 text-sm font-semibold text-[var(--platform-text-strong)]">
{value}
<div className="grid gap-2.5 sm:grid-cols-2">
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
))}
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundPhoneNumber}
</div>
</div>
<div
data-account-binding-card
className="platform-subpanel rounded-2xl px-3.5 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={() => {
setAccountNotice('更换微信号功能暂未接入。');
}}
>
</button>
</div>
<div className="mt-1.5 break-all text-sm font-semibold text-[var(--platform-text-strong)]">
{boundWechatDisplayName}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
className="min-h-0 shrink-0 rounded-full px-0 text-[11px] font-semibold text-[var(--platform-cool-text)]"
onClick={(event) => {
passwordTriggerRef.current = event.currentTarget;
setAccountNotice('');
@@ -644,40 +658,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice('');
resetChangePhoneDraft();
setIsChangePhonePanelOpen(true);
}}
>
</button>
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -690,7 +676,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -734,15 +720,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -755,7 +738,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -818,15 +801,12 @@ export function AccountModal({
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="platform-subpanel rounded-2xl px-3.5 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
@@ -839,7 +819,7 @@ export function AccountModal({
</button>
</div>
<div className="mt-4 grid gap-3">
<div className="mt-3 grid gap-2.5">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
@@ -873,6 +853,30 @@ export function AccountModal({
)}
</div>
</div>
<div
data-account-actions
className="grid gap-2.5 pt-1 sm:grid-cols-2"
>
<button
type="button"
className="platform-button platform-button--ghost h-10 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-10 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
{isChangePhonePanelOpen ? (

View File

@@ -3,11 +3,11 @@
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
import { AuthGate } from './AuthGate';
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
import { useAuthUi } from './AuthUiContext';
const authMocks = vi.hoisted(() => ({
@@ -107,6 +107,7 @@ beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.history.replaceState(null, '', '/');
setAuthGateReloadForTest(vi.fn());
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getStoredAccessToken.mockReturnValue('');
@@ -158,6 +159,10 @@ beforeEach(() => {
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
});
afterEach(() => {
setAuthGateReloadForTest(null);
});
async function acceptLegalConsent(
user: ReturnType<typeof userEvent.setup>,
dialog: HTMLElement,
@@ -382,6 +387,8 @@ test('auth gate keeps sms and password entries available when login options requ
test('auth gate opens a login modal for protected actions and resumes after login', async () => {
const user = userEvent.setup();
const onAuthenticated = vi.fn();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
@@ -411,6 +418,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
);
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
@@ -636,6 +644,8 @@ test('registration invite modal can skip when invite code is empty', async () =>
test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -674,10 +684,13 @@ test('auth state refresh keeps mounted platform content and local tab state', as
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(2);
});
expect(screen.getByText('当前Tab创作')).toBeTruthy();
expect(reload).not.toHaveBeenCalled();
});
test('logout withdraws user context before backend request finishes', async () => {
const user = userEvent.setup();
const reload = vi.fn();
setAuthGateReloadForTest(reload);
authMocks.getCurrentAuthUser.mockResolvedValue({
user: mockUser,
availableLoginMethods: ['phone'],
@@ -703,11 +716,14 @@ test('logout withdraws user context before backend request finishes', async () =
expect(await screen.findByText('当前用户:未登录')).toBeTruthy();
expect(screen.getByText('私有数据:不可读取')).toBeTruthy();
expect(authMocks.logoutAuthUser).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled();
await act(async () => {
resolveLogout();
await logoutPromise;
});
expect(reload).toHaveBeenCalledTimes(1);
});
test('auth gate shows sms send feedback in the login modal', async () => {

View File

@@ -65,6 +65,18 @@ type AuthStatus =
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
let reloadCurrentPageForAuthStateChange = () => {
window.location.reload();
};
export function setAuthGateReloadForTest(handler: (() => void) | null) {
reloadCurrentPageForAuthStateChange =
handler ??
(() => {
window.location.reload();
});
}
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
@@ -140,6 +152,8 @@ export function AuthGate({ children }: AuthGateProps) {
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false);
const authHydrateVersionRef = useRef(0);
const lastStableAuthPresenceRef = useRef<boolean | null>(null);
const pendingAuthStateReloadRef = useRef(false);
const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current &&
(status === 'checking' || status === 'recovering');
@@ -152,36 +166,64 @@ export function AuthGate({ children }: AuthGateProps) {
hasRenderedPlatformContentRef.current = true;
}
const markAuthStateReloadIfChanged = useCallback(
(
nextUser: AuthUser | null,
options: { reloadOnChange?: boolean } = {},
) => {
const nextHasUser = Boolean(nextUser);
const previousHasUser = lastStableAuthPresenceRef.current;
if (previousHasUser === null) {
lastStableAuthPresenceRef.current = nextHasUser;
return;
}
lastStableAuthPresenceRef.current = nextHasUser;
if (
previousHasUser !== nextHasUser &&
options.reloadOnChange !== false
) {
pendingAuthStateReloadRef.current = true;
}
},
[],
);
const activateReadyUser = useCallback((nextUser: AuthUser) => {
// 受保护业务 hook 只在 readyUser 暴露后启动,必须先保证请求层能带 Bearer token。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(nextUser);
setUser(nextUser);
setStatus('ready');
}, []);
}, [markAuthStateReloadIfChanged]);
const clearLocalAuthenticatedState = useCallback(() => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成。
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存
authHydrateVersionRef.current += 1;
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
}, []);
const clearLocalAuthenticatedState = useCallback(
(options: { reloadOnChange?: boolean } = {}) => {
// 退出动作必须先收回前端鉴权上下文,再等待后端吊销完成
// 否则平台壳层会在无刷新状态下继续暴露旧用户的私有作品缓存。
authHydrateVersionRef.current += 1;
markAuthStateReloadIfChanged(null, options);
pendingProtectedActionRef.current = null;
setUser(null);
setStatus('unauthenticated');
setShowLoginModal(false);
setShowRegistrationInviteModal(false);
setShowSettingsModal(false);
setSettingsEntryMode('settings');
setInitialSettingsSection(null);
setSessions([]);
setRevokingSessionIds([]);
setAuditLogs([]);
setRiskBlocks([]);
setLoginCaptchaChallenge(null);
setBindCaptchaChallenge(null);
setChangePhoneCaptchaChallenge(null);
setPendingInviteCode('');
setRegistrationInviteError('');
setError('');
},
[markAuthStateReloadIfChanged],
);
const restoreAuthSession = useCallback(async () => {
const hadLocalAccessToken = Boolean(getStoredAccessToken());
@@ -234,7 +276,7 @@ export function AuthGate({ children }: AuthGateProps) {
}, []);
const logoutCurrentSession = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAuthUser();
} catch (logoutError) {
@@ -243,11 +285,13 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出登录失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
const logoutAllSessions = useCallback(async () => {
clearLocalAuthenticatedState();
clearLocalAuthenticatedState({ reloadOnChange: false });
try {
await logoutAllAuthSessions();
} catch (logoutError) {
@@ -256,6 +300,8 @@ export function AuthGate({ children }: AuthGateProps) {
? logoutError.message
: '退出全部设备失败,请刷新页面确认状态。',
);
} finally {
reloadCurrentPageForAuthStateChange();
}
}, [clearLocalAuthenticatedState]);
@@ -386,6 +432,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
markAuthStateReloadIfChanged(null);
setUser(null);
setStatus('unauthenticated');
} catch (optionsError) {
@@ -394,6 +441,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
setAvailableLoginMethods(REQUIRED_LOGIN_METHODS);
markAuthStateReloadIfChanged(null);
setUser(null);
// 中文注释:登录方式接口失败时按产品约定保留验证码和密码登录入口;
// 这里不展示接口读取错误,避免用户误以为登录本身不可用。
@@ -413,6 +461,7 @@ export function AuthGate({ children }: AuthGateProps) {
return;
}
if (restoredSession.kind === 'guest') {
markAuthStateReloadIfChanged(null);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(
restoredSession.session?.availableLoginMethods,
@@ -423,6 +472,7 @@ export function AuthGate({ children }: AuthGateProps) {
}
const nextSession = restoredSession.session;
markAuthStateReloadIfChanged(nextSession.user);
setUser(nextSession.user);
setAvailableLoginMethods(
normalizeAvailableLoginMethods(nextSession.availableLoginMethods),
@@ -470,19 +520,23 @@ export function AuthGate({ children }: AuthGateProps) {
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
window.removeEventListener('hashchange', handleAuthHashChange);
};
}, [restoreAuthSession]);
}, [markAuthStateReloadIfChanged, restoreAuthSession]);
useEffect(() => {
if (!readyUser) {
setShowSettingsModal(false);
return;
} else {
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
}
setShowLoginModal(false);
const pendingAction = pendingProtectedActionRef.current;
pendingProtectedActionRef.current = null;
pendingAction?.();
if (pendingAuthStateReloadRef.current) {
pendingAuthStateReloadRef.current = false;
reloadCurrentPageForAuthStateChange();
}
}, [readyUser]);
useEffect(() => {

View File

@@ -15,10 +15,6 @@ vi.mock('../../services/bark-battle-creation', () => ({
updateBarkBattleDraftConfig: vi.fn(),
}));
vi.mock('./BarkBattlePreviewCard', () => ({
BarkBattlePreviewCard: () => <div></div>,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
@@ -61,6 +57,12 @@ describe('BarkBattleGeneratingView', () => {
expect(container.firstChild).toBeTruthy();
expect((container.firstChild as HTMLElement).className).toContain('z-[1]');
expect((container.firstChild as HTMLElement).className).toContain(
'overflow-hidden',
);
expect((container.firstChild as HTMLElement).className).not.toContain(
'overflow-y-auto',
);
expect(screen.getByText('总进度')).toBeTruthy();
expect(screen.getByText('总进度').className).toContain('text-[9px]');
const pageVideo = screen.getByTestId(
@@ -100,6 +102,14 @@ describe('BarkBattleGeneratingView', () => {
expect(screen.getByTestId('generation-hero-elapsed-card').className).toContain(
'bg-white/58',
);
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('mt-3');
expect(
screen.getByTestId('generation-hero-wait-card').parentElement
?.className,
).toContain('px-0');
expect(screen.getByText('预计等待').className).toContain('text-[9px]');
expect(screen.getByText('已耗时').className).toContain('text-[9px]');
expect(screen.getByText('预计等待').parentElement?.className).toContain(
@@ -218,7 +228,13 @@ describe('BarkBattleGeneratingView', () => {
expect(
screen.getByTestId('generation-current-step-card').className,
).toContain('bg-white/58');
expect(screen.getByText('预览信息').className).toContain('text-[13px]');
expect(
screen.getByTestId('generation-current-step-card').parentElement
?.className,
).toContain('mt-5');
expect(screen.queryByText('预览信息')).toBeNull();
expect(screen.queryByText('汪汪声浪预览')).toBeNull();
expect(screen.queryByText('霓虹公园擂台')).toBeNull();
expect(screen.queryByText('对手形象')).toBeNull();
expect(screen.queryByText('竞技背景')).toBeNull();
expect(onComplete).not.toHaveBeenCalled();

View File

@@ -17,7 +17,6 @@ import {
GenerationPageBackdrop,
GenerationProgressHero,
} from '../GenerationProgressHero';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleGeneratingViewProps = {
draft: BarkBattleDraftConfig;
@@ -355,54 +354,49 @@ export function BarkBattleGeneratingView({
}, [draft, onComplete, onError]);
return (
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5 xl:px-6 xl:pb-5 xl:pt-5">
<GenerationPageBackdrop />
<div className="relative z-10 mx-auto flex w-full max-w-[48rem] flex-col">
<div className="mb-6 flex shrink-0 items-center justify-between gap-3 sm:mb-6">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span>
</div>
<div className="relative z-30 mx-auto mb-4 flex w-full max-w-[48rem] shrink-0 items-center justify-between gap-3 sm:mb-5">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-5 w-5" strokeWidth={2.6} />
<span className="break-keep"></span>
</button>
<span className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
</span>
</div>
<section className="flex flex-col gap-4">
<div className="grid content-start gap-3 overflow-hidden px-0 pb-0 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
<div
className="relative z-10 mx-auto flex min-h-0 w-full max-w-[48rem] flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<section className="grid content-start gap-3 overflow-hidden px-0 pb-2 pt-0">
<GenerationProgressHero
title="汪汪声浪素材生成进度"
phaseLabel={draft.title || '未命名声浪竞技场'}
progressValue={progressValue}
estimatedWaitText="3 分钟"
elapsedText={formatGenerationDuration(elapsedMs)}
/>
<div className="mt-5 sm:mt-[-0.15rem]">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
<div className="mt-[-0.15rem]">
<GenerationCurrentStepCard
label={currentStepLabel}
statusLabel={currentStepStatusLabel}
progressValue={currentStepProgress}
/>
</div>
{error || primaryFailureMessage ? (
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error ?? primaryFailureMessage}
</div>
) : null}
</div>
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
<div className="mb-4 text-[13px] font-black tracking-[0.08em] text-[#111111]">
{error || primaryFailureMessage ? (
<div className="rounded-[1.4rem] border border-[#d88969]/35 bg-white/76 px-4 py-3 text-sm leading-6 text-[#a6402f] backdrop-blur-md">
{error ?? primaryFailureMessage}
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
) : null}
</section>
</div>
</div>

View File

@@ -0,0 +1,300 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
type TestAudioAsset = CreativeAudioAsset;
const originalMediaRecorder = globalThis.MediaRecorder;
const originalMediaDevices = navigator.mediaDevices;
afterEach(() => {
globalThis.MediaRecorder = originalMediaRecorder;
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: originalMediaDevices,
});
vi.restoreAllMocks();
});
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
return {
assetId: 'asset-test',
audioSrc: 'blob:audio-preview',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'hit.wav',
durationMs: 800,
...overrides,
};
}
function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
) {
const onAssetChange = vi.fn();
const onError = vi.fn();
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
buildAsset({
audioSrc: `blob:${source}`,
source,
prompt: file.name,
}),
);
const rendered = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={null}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={onAssetChange}
onError={onError}
readFileAsAsset={readFileAsAsset}
{...overrides}
/>,
);
return { ...rendered, onAssetChange, onError, readFileAsAsset };
}
function getUploadInput() {
const input = screen
.getByText('上传')
.closest('label')
?.querySelector('input[type="file"]') as HTMLInputElement | null;
expect(input).not.toBeNull();
return input!;
}
test('音频面板按需显示最长限制标签', () => {
renderPanel({ limitLabel: '最长 1 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy();
});
test('音频面板未传限制标签时不渲染限制提示', () => {
renderPanel();
expect(screen.queryByText('最长 1 秒')).toBeNull();
});
test('音频面板无资产时显示默认音效文案', () => {
renderPanel();
expect(screen.getByText('默认木鱼音')).toBeTruthy();
});
test('音频面板有预览地址时渲染 audio 控件', () => {
const { container } = render(
<CreativeAudioInputPanel<TestAudioAsset>
title="敲击音效"
defaultLabel="默认木鱼音"
asset={buildAsset({ audioSrc: 'blob:preview' })}
buildRecordedFileName={() => 'recorded-hit.webm'}
onAssetChange={() => {}}
onError={() => {}}
/>,
);
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
'blob:preview',
);
});
test('音频面板有资产但无预览地址时显示已选择状态', () => {
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
expect(screen.getByText('音效已选择')).toBeTruthy();
});
test('点击重置清空当前音频资产', () => {
const onAssetChange = vi.fn();
renderPanel({
asset: buildAsset(),
onAssetChange,
});
fireEvent.click(screen.getByRole('button', { name: '重置' }));
expect(onAssetChange).toHaveBeenCalledWith(null);
});
test('取消上传选择时不读取音频', () => {
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [] } });
expect(readFileAsAsset).not.toHaveBeenCalled();
expect(onAssetChange).not.toHaveBeenCalled();
});
test('上传音频成功后清空错误并写入资产', async () => {
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
await waitFor(() =>
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(onError).toHaveBeenCalledWith(null);
});
test('上传音频失败时提示错误且不写入资产', async () => {
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频最长 1 秒。');
});
const onAssetChange = vi.fn();
const onError = vi.fn();
renderPanel({ readFileAsAsset, onAssetChange, onError });
fireEvent.change(getUploadInput(), {
target: {
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
},
});
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
expect(onAssetChange).not.toHaveBeenCalled();
});
test('浏览器不支持录音时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: undefined,
});
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
);
});
test('录音启动失败时透传启动错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => {
throw new Error('麦克风拒绝授权。');
}),
},
});
globalThis.MediaRecorder = class {
start = vi.fn();
stop = vi.fn();
} as unknown as typeof MediaRecorder;
const { onError } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
});
test('录音停止后按 recorded 来源读取音频', async () => {
const stopTrack = vi.fn();
const recorderInstances: Array<{
ondataavailable: ((event: BlobEvent) => void) | null;
onstop: (() => void) | null;
}> = [];
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [{ stop: stopTrack }],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
constructor() {
recorderInstances.push(this);
}
start = vi.fn();
stop = vi.fn(() => {
this.ondataavailable?.({
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
} as BlobEvent);
this.onstop?.();
});
} as unknown as typeof MediaRecorder;
const { readFileAsAsset, onAssetChange } = renderPanel();
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
expect(recordedFile).toBeInstanceOf(File);
expect((recordedFile as File).name).toBe('recorded-hit.webm');
expect(source).toBe('recorded');
expect(stopTrack).toHaveBeenCalledTimes(1);
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
expect(recorderInstances).toHaveLength(1);
});
test('录音保存失败时提示错误', async () => {
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: {
getUserMedia: vi.fn(async () => ({
getTracks: () => [],
})),
},
});
globalThis.MediaRecorder = class {
mimeType = 'audio/webm';
ondataavailable: ((event: BlobEvent) => void) | null = null;
onstop: (() => void) | null = null;
start = vi.fn();
stop = vi.fn(() => this.onstop?.());
} as unknown as typeof MediaRecorder;
const readFileAsAsset = vi.fn(async () => {
throw new Error('音频声音过小,请重新录制或上传。');
});
const onError = vi.fn();
renderPanel({ readFileAsAsset, onError });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
fireEvent.click(screen.getByRole('button', { name: '停止' }));
await waitFor(() =>
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
);
});
test('禁用状态不启动录音也不允许上传', () => {
const getUserMedia = vi.fn();
Object.defineProperty(navigator, 'mediaDevices', {
configurable: true,
value: { getUserMedia },
});
const { container } = renderPanel({ disabled: true });
fireEvent.click(screen.getByRole('button', { name: '录音' }));
expect(getUserMedia).not.toHaveBeenCalled();
const input = container.querySelector('input[type="file"]');
expect(input).not.toBeNull();
expect((input as HTMLInputElement).disabled).toBe(true);
});

View File

@@ -1,20 +1,16 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
import {
type CreativeAudioAsset,
readCreativeAudioFileAsAsset,
} from './creativeAudioFileAsset';
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
title: string;
defaultLabel: string;
limitLabel?: string;
asset: TAsset | null;
buildRecordedFileName: () => string;
onAssetChange: (asset: TAsset | null) => void;
@@ -25,36 +21,11 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
) => Promise<TAsset>;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
limitLabel,
asset,
buildRecordedFileName,
onAssetChange,
@@ -124,8 +95,15 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
<div className="flex min-w-0 items-center gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
{limitLabel ? (
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
{limitLabel}
</div>
) : null}
</div>
{asset ? (
<button

View File

@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
const onMainImageFileSelect = vi.fn();
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
try {
render(
<CreativeImageInputPanel
mainImageClickMode="preview"
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
}}
onMainImageFileSelect={onMainImageFileSelect}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
fireEvent.click(
screen.getByRole('button', { name: '关闭关卡图片预览' }),
);
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
expect(inputClickSpy).toHaveBeenCalledTimes(1);
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
target: {
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
},
});
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
} finally {
inputClickSpy.mockRestore();
}
});
test('creative image input panel can hide upload and history controls independently', () => {
render(
<CreativeImageInputPanel
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
uploadedImageAlt="拼图关卡图"
canUploadMainImage={false}
canUseImageHistory={false}
mainImageInputId="level-image-upload-input"
promptTextareaId="level-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="重新生成画面"
submitDisabled={false}
labels={{
imageField: '画面图',
uploadImage: '上传参考图',
replaceImage: '更换参考图',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除参考图',
removeImageConfirmTitle: '移除参考图?',
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onHistoryClick={() => {}}
onSubmit={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
expect(
screen.queryByLabelText('上传参考图', { selector: 'input' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
});
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
render(
<CreativeImageInputPanel

View File

@@ -6,7 +6,7 @@ import {
Trash2,
X,
} from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
promptReferenceUpload: string;
promptReferencePreviewAlt: string;
closePromptReferencePreview: string;
previewMainImage?: string;
closeMainImagePreview?: string;
history?: string;
};
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
disabled?: boolean;
isSubmitting?: boolean;
mainImageMode?: 'edit' | 'preview';
mainImageClickMode?: 'upload' | 'preview';
canUploadMainImage?: boolean;
canUseImageHistory?: boolean;
canRemoveMainImage?: boolean;
canToggleAiRedraw?: boolean;
canUploadPromptReferences?: boolean;
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
disabled = false,
isSubmitting = false,
mainImageMode = 'edit',
mainImageClickMode = 'preview',
canUploadMainImage = true,
canUseImageHistory = true,
canRemoveMainImage = true,
canToggleAiRedraw = true,
canUploadPromptReferences,
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
onHistoryClick,
onSubmit,
}: CreativeImageInputPanelProps) {
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
const [previewReferenceImage, setPreviewReferenceImage] =
useState<CreativeImageInputReferenceImage | null>(null);
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
useState(false);
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
const promptReferenceUploadDisabled =
disabled || promptReferenceImages.length >= promptReferenceLimit;
const canEditMainImage = mainImageMode === 'edit';
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
const shouldShowHistoryButton =
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
const shouldPreviewMainImage =
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
const shouldShowMainImageUploadButton =
isMainImageUploadEnabled && shouldPreviewMainImage;
useEffect(() => {
if (uploadedImageSrc) {
setPreviewReferenceImage(null);
} else {
setIsMainImagePreviewOpen(false);
}
}, [uploadedImageSrc]);
@@ -187,35 +206,48 @@ export function CreativeImageInputPanel({
</div>
<div className={imageFrameClassName}>
<div className={imageCardClassName}>
{canEditMainImage ? (
<>
<input
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
}}
className="sr-only"
/>
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
{isMainImageUploadEnabled ? (
<input
ref={mainImageInputRef}
id={mainImageInputId}
type="file"
accept={mainImageAccept}
disabled={disabled}
aria-label={labels.uploadImage}
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (file) {
onMainImageFileSelect(file);
}
>
<span className="sr-only">
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
</span>
</label>
</>
}}
className="sr-only"
/>
) : null}
{shouldPreviewMainImage ? (
<button
type="button"
className="absolute inset-0 z-[2] cursor-zoom-in"
aria-label={labels.previewMainImage ?? uploadedImageAlt}
title={labels.previewMainImage ?? uploadedImageAlt}
onClick={() => setIsMainImagePreviewOpen(true)}
/>
) : isMainImageUploadEnabled ? (
<label
htmlFor={mainImageInputId}
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
title={
uploadedImageSrc
? labels.replaceImage
: labels.uploadImage
}
>
<span className="sr-only">
{uploadedImageSrc
? labels.replaceImage
: labels.uploadImage}
</span>
</label>
) : null}
{uploadedImageSrc ? (
<ResolvedAssetImage
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
</span>
)}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
{canEditMainImage && onHistoryClick ? (
{shouldShowMainImageUploadButton ? (
<button
type="button"
disabled={disabled}
onClick={() => mainImageInputRef.current?.click()}
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
aria-label={labels.replaceImage}
title={labels.replaceImage}
>
<ImagePlus className="h-4 w-4" />
</button>
) : null}
{shouldShowHistoryButton ? (
<button
type="button"
disabled={disabled}
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
>
<Trash2 className="h-4 w-4" />
</button>
) : canEditMainImage && !uploadedImageSrc ? (
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
<label
htmlFor={mainImageInputId}
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
</div>
) : null}
{isMainImagePreviewOpen && uploadedImageSrc ? (
<div
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
onClick={() => setIsMainImagePreviewOpen(false)}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="creative-image-main-preview-title"
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
onClick={(event) => event.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div
id="creative-image-main-preview-title"
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
>
{labels.previewMainImage ?? uploadedImageAlt}
</div>
<button
type="button"
aria-label={
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
}
onClick={() => setIsMainImagePreviewOpen(false)}
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
<ResolvedAssetImage
src={uploadedImageSrc}
refreshKey={uploadedImageRefreshKey}
alt={uploadedImageAlt}
className="h-full max-h-[82vh] w-full object-contain"
/>
</div>
</div>
</div>
) : null}
{isRemoveImageConfirmOpen ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div

View File

@@ -0,0 +1,29 @@
type RuntimeResourcePendingMarkerProps = {
source?: string | null;
isPending?: boolean;
kind?: string;
};
export const RUNTIME_RESOURCE_PENDING_SELECTOR =
'[data-runtime-resource-pending="true"]';
export function RuntimeResourcePendingMarker({
source,
isPending = true,
kind,
}: RuntimeResourcePendingMarkerProps) {
const normalizedSource = source?.trim() ?? '';
if (!isPending || !normalizedSource) {
return null;
}
return (
<span
hidden
aria-hidden="true"
data-runtime-resource-pending="true"
data-runtime-resource-src={normalizedSource}
data-runtime-resource-kind={kind}
/>
);
}

View File

@@ -0,0 +1,35 @@
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}

View File

@@ -0,0 +1,241 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
encodePcmChannelsToWavBlob,
findAudibleFrameRange,
normalizeAudioBufferSection,
prepareCreativeAudioFileForLocalUse,
} from './creativeAudioProcessing';
const originalAudioContext = globalThis.AudioContext;
const originalCreateObjectUrl = URL.createObjectURL;
afterEach(() => {
globalThis.AudioContext = originalAudioContext;
URL.createObjectURL = originalCreateObjectUrl;
vi.restoreAllMocks();
});
function createAudioBufferStub(
channels: number[][],
sampleRate = 1000,
): AudioBuffer {
return {
length: channels[0]?.length ?? 0,
numberOfChannels: channels.length,
sampleRate,
duration: (channels[0]?.length ?? 0) / sampleRate,
getChannelData: (channel: number) =>
new Float32Array(channels[channel] ?? []),
} as AudioBuffer;
}
function installAudioContextMock(
decodeAudioData: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
) {
globalThis.AudioContext = class {
decodeAudioData = decodeAudioData;
close = vi.fn();
} as unknown as typeof AudioContext;
}
test('prepareCreativeAudioFileForLocalUse rejects empty audio files', async () => {
await expect(
prepareCreativeAudioFileForLocalUse(
new File([], 'empty.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频文件为空,请重新选择。');
});
test('prepareCreativeAudioFileForLocalUse rejects non-audio files', async () => {
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['not-audio'], 'note.txt', { type: 'text/plain' }),
'uploaded',
),
).rejects.toThrow('请选择音频文件。');
});
test('prepareCreativeAudioFileForLocalUse reports decode failures', async () => {
installAudioContextMock(async () => {
throw new Error('decode failed');
});
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'broken.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频解码失败,请重新选择。');
});
test('prepareCreativeAudioFileForLocalUse rejects when AudioContext is unavailable', async () => {
globalThis.AudioContext = undefined as unknown as typeof AudioContext;
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('当前浏览器不支持音频处理。');
});
test('prepareCreativeAudioFileForLocalUse rejects all-silent audio', async () => {
installAudioContextMock(async () => createAudioBufferStub([[0, 0.001, 0]], 1000));
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'silent.webm', { type: 'audio/webm' }),
'recorded',
),
).rejects.toThrow('音频声音过小,请重新录制或上传。');
});
test('prepareCreativeAudioFileForLocalUse allows audio exactly at the visible limit', async () => {
URL.createObjectURL = vi.fn(() => 'blob:one-second-audio');
installAudioContextMock(async () =>
createAudioBufferStub([Array.from({ length: 1000 }, () => 0.2)], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'one-second.webm', { type: 'audio/webm' }),
'uploaded',
);
expect(asset.durationMs).toBe(1000);
expect(asset.audioSrc).toBe('blob:one-second-audio');
});
test('prepareCreativeAudioFileForLocalUse rejects audio longer than the visible limit after trimming', async () => {
installAudioContextMock(async () =>
createAudioBufferStub([[0, ...Array.from({ length: 1001 }, () => 0.2), 0]], 1000),
);
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'long.webm', { type: 'audio/webm' }),
'uploaded',
),
).rejects.toThrow('音频最长 1 秒。');
});
test('findAudibleFrameRange trims quiet leading and trailing frames', () => {
const buffer = createAudioBufferStub([
[0, 0.003, 0.02, 0.2, -0.03, 0.004],
[0, 0, 0, 0.05, 0, 0],
]);
expect(findAudibleFrameRange(buffer, 0.01)).toEqual({
startFrame: 2,
frameCount: 3,
});
});
test('normalizeAudioBufferSection pulls samples toward -15 LKFS approximation', () => {
const buffer = createAudioBufferStub([[0.02, -0.02, 0.02, -0.02]], 1000);
const normalized = normalizeAudioBufferSection(
buffer,
{ startFrame: 0, frameCount: 4 },
{ targetLkfs: -15, peakCeiling: 0.98 },
);
const channel = normalized[0];
expect(channel).toBeDefined();
const rms = Math.sqrt(
channel!.reduce((sum, sample) => sum + sample * sample, 0) /
channel!.length,
);
expect(rms).toBeCloseTo(Math.pow(10, -15 / 20), 3);
});
test('normalizeAudioBufferSection avoids clipping when target gain is too high', () => {
const buffer = createAudioBufferStub([[0.8, -0.8, 0.4, -0.4]], 1000);
const normalized = normalizeAudioBufferSection(
buffer,
{ startFrame: 0, frameCount: 4 },
{ targetLkfs: 0, peakCeiling: 0.5 },
);
const channel = normalized[0];
expect(channel).toBeDefined();
const peak = Math.max(...channel!.map((sample) => Math.abs(sample)));
expect(peak).toBeLessThanOrEqual(0.5);
});
test('normalizeAudioBufferSection rejects zero-energy sections', () => {
const buffer = createAudioBufferStub([[0, 0, 0]], 1000);
expect(() =>
normalizeAudioBufferSection(buffer, { startFrame: 0, frameCount: 3 }),
).toThrow('音频声音过小,请重新录制或上传。');
});
test('prepareCreativeAudioFileForLocalUse writes trimmed normalized wav blob', async () => {
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
installAudioContextMock(async () =>
createAudioBufferStub([[0, 0, 0.12, -0.12, 0]], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'uploaded',
);
const bytes = await asset.blob.arrayBuffer();
expect(asset.fileName).toBe('hit.wav');
expect(asset.mimeType).toBe('audio/wav');
expect(asset.audioSrc).toBe('blob:processed-audio');
expect(asset.durationMs).toBe(2);
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
});
test('prepareCreativeAudioFileForLocalUse still succeeds without object URL support', async () => {
URL.createObjectURL = undefined as unknown as typeof URL.createObjectURL;
installAudioContextMock(async () =>
createAudioBufferStub([[0.12, -0.12]], 1000),
);
const asset = await prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
'recorded',
);
expect(asset.audioSrc).toBe('');
expect(asset.previewUrl).toBe('');
});
test('prepareCreativeAudioFileForLocalUse normalizes processed wav file names', async () => {
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
installAudioContextMock(async () =>
createAudioBufferStub([[0.12, -0.12]], 1000),
);
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], 'hit-sound', { type: 'audio/webm' }),
'uploaded',
),
).resolves.toMatchObject({ fileName: 'hit-sound.wav' });
await expect(
prepareCreativeAudioFileForLocalUse(
new File(['audio-bytes'], ' ', { type: 'audio/webm' }),
'uploaded',
),
).resolves.toMatchObject({ fileName: 'creative-audio.wav' });
});
test('encodePcmChannelsToWavBlob writes pcm16 wav bytes', async () => {
const blob = encodePcmChannelsToWavBlob(
[new Float32Array([0.25, -0.5])],
1000,
);
const bytes = await blob.arrayBuffer();
const view = new DataView(bytes);
expect(blob.type).toBe('audio/wav');
expect(view.getUint32(40, true)).toBe(4);
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
expect(view.getInt16(46, true)).toBeCloseTo(-16384, -1);
});

View File

@@ -0,0 +1,308 @@
import {
type CreativeAudioAsset,
} from './creativeAudioFileAsset';
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type CreativeAudioSource = 'uploaded' | 'recorded';
export type PendingCreativeAudioAsset = CreativeAudioAsset & {
fileName: string;
mimeType: string;
blob: Blob;
source: CreativeAudioSource;
previewUrl: string;
durationMs: number;
};
export type CreativeAudioProcessingOptions = {
maxDurationMs?: number;
silenceThreshold?: number;
targetLkfs?: number;
peakCeiling?: number;
};
export type AudibleFrameRange = {
startFrame: number;
frameCount: number;
};
const DEFAULT_MAX_DURATION_MS = 1000;
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_TARGET_LKFS = -15;
const DEFAULT_PEAK_CEILING = 0.98;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export async function prepareCreativeAudioFileForLocalUse(
file: File,
source: CreativeAudioSource,
options: CreativeAudioProcessingOptions = {},
): Promise<PendingCreativeAudioAsset> {
validateCreativeAudioFile(file);
const decodedBuffer = await decodeCreativeAudioFile(file);
const range = findAudibleFrameRange(
decodedBuffer,
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD,
);
if (!range) {
throw new Error('音频声音过小,请重新录制或上传。');
}
const durationMs = Math.round(
(range.frameCount / decodedBuffer.sampleRate) * 1000,
);
const maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS;
if (durationMs > maxDurationMs) {
throw new Error(`音频最长 ${formatDurationSeconds(maxDurationMs)} 秒。`);
}
const normalized = normalizeAudioBufferSection(decodedBuffer, range, {
targetLkfs: options.targetLkfs ?? DEFAULT_TARGET_LKFS,
peakCeiling: options.peakCeiling ?? DEFAULT_PEAK_CEILING,
});
const blob = encodePcmChannelsToWavBlob(normalized, decodedBuffer.sampleRate);
const fileName = buildProcessedAudioFileName(file.name);
const previewUrl =
typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
? URL.createObjectURL(blob)
: '';
return {
assetId: `local-${source}-${Date.now()}`,
audioSrc: previewUrl,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs,
fileName,
mimeType: blob.type,
blob,
previewUrl,
};
}
export function findAudibleFrameRange(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
): AudibleFrameRange | null {
const threshold = Math.max(0, silenceThreshold);
let startFrame: number | null = null;
let endFrame: number | null = null;
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
startFrame = frameIndex;
break;
}
}
if (startFrame === null) {
return null;
}
for (let frameIndex = buffer.length - 1; frameIndex >= startFrame; frameIndex -= 1) {
if (isFrameAudible(buffer, frameIndex, threshold)) {
endFrame = frameIndex;
break;
}
}
if (endFrame === null) {
return null;
}
return {
startFrame,
frameCount: endFrame - startFrame + 1,
};
}
export function normalizeAudioBufferSection(
buffer: AudioBuffer,
range: AudibleFrameRange,
options: Pick<CreativeAudioProcessingOptions, 'targetLkfs' | 'peakCeiling'> = {},
) {
const channelCount = Math.max(1, buffer.numberOfChannels);
const targetLkfs = options.targetLkfs ?? DEFAULT_TARGET_LKFS;
const peakCeiling = Math.max(0.01, options.peakCeiling ?? DEFAULT_PEAK_CEILING);
const channels = Array.from({ length: channelCount }, (_value, channelIndex) =>
copyChannelSection(buffer, channelIndex, range),
);
const stats = measurePcmStats(channels);
if (stats.rms <= 0 || stats.peak <= 0) {
throw new Error('音频声音过小,请重新录制或上传。');
}
// 浏览器端近似:用全通道 RMS 估算 LKFS再按 GY/T 377-2023 目标值拉到 -15 LKFS。
const targetLinear = Math.pow(10, targetLkfs / 20);
const loudnessGain = targetLinear / stats.rms;
const protectedGain = Math.min(loudnessGain, peakCeiling / stats.peak);
return channels.map((channel) =>
Float32Array.from(channel, (sample) => clampSample(sample * protectedGain)),
);
}
export function encodePcmChannelsToWavBlob(
channels: Float32Array[],
sampleRate: number,
) {
const channelCount = Math.max(1, channels.length);
const frameCount = channels[0]?.length ?? 0;
const dataByteLength = frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channels[channelIndex]?.[frameIndex] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function validateCreativeAudioFile(file: File) {
if (file.size <= 0) {
throw new Error('音频文件为空,请重新选择。');
}
if (!resolveFileMimeType(file).startsWith('audio/')) {
throw new Error('请选择音频文件。');
}
}
async function decodeCreativeAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
throw new Error('当前浏览器不支持音频处理。');
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} catch {
throw new Error('音频解码失败,请重新选择。');
} finally {
void context.close();
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
function resolveFileMimeType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
return '';
}
function isFrameAudible(
buffer: AudioBuffer,
frameIndex: number,
threshold: number,
) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return true;
}
}
return false;
}
function copyChannelSection(
buffer: AudioBuffer,
channelIndex: number,
range: AudibleFrameRange,
) {
const source =
channelIndex < buffer.numberOfChannels
? buffer.getChannelData(channelIndex)
: new Float32Array(buffer.length);
const output = new Float32Array(range.frameCount);
for (let frameOffset = 0; frameOffset < range.frameCount; frameOffset += 1) {
output[frameOffset] = source[range.startFrame + frameOffset] ?? 0;
}
return output;
}
function measurePcmStats(channels: Float32Array[]) {
let sumSquares = 0;
let peak = 0;
let sampleCount = 0;
for (const channel of channels) {
for (const sample of channel) {
sumSquares += sample * sample;
peak = Math.max(peak, Math.abs(sample));
sampleCount += 1;
}
}
return {
rms: sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0,
peak,
};
}
function clampSample(sample: number) {
return Math.max(-1, Math.min(1, sample));
}
function toSignedPcm16(sample: number) {
const clamped = clampSample(sample);
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildProcessedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'creative-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}
function formatDurationSeconds(durationMs: number) {
return Number.isInteger(durationMs / 1000)
? String(durationMs / 1000)
: (durationMs / 1000).toFixed(1);
}

View File

@@ -0,0 +1,77 @@
import { expect, test } from 'vitest';
import {
buildLeadingSilenceTrimmedWavBlob,
findFirstAudibleFrame,
} from './creativeAudioSilenceTrim';
function createAudioBufferStub(
channels: number[][],
sampleRate = 1000,
): AudioBuffer {
return {
length: channels[0]?.length ?? 0,
numberOfChannels: channels.length,
sampleRate,
duration: (channels[0]?.length ?? 0) / sampleRate,
getChannelData: (channel: number) =>
new Float32Array(channels[channel] ?? []),
} as AudioBuffer;
}
test('findFirstAudibleFrame skips leading frames that are silent across all channels', () => {
const buffer = createAudioBufferStub([
[0, 0.003, -0.006, 0.012, 0.02],
[0, -0.004, 0.009, 0, 0],
]);
expect(findFirstAudibleFrame(buffer, 0.01)).toBe(3);
});
test('buildLeadingSilenceTrimmedWavBlob writes a wav that starts at the first audible frame', async () => {
const buffer = createAudioBufferStub(
[
[0, 0, 0, 0.25, -0.5],
[0, 0, 0, -0.25, 0.5],
],
1000,
);
const blob = buildLeadingSilenceTrimmedWavBlob(buffer, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
});
expect(blob).not.toBeNull();
expect(blob?.type).toBe('audio/wav');
const bytes = await blob!.arrayBuffer();
const view = new DataView(bytes);
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
expect(String.fromCharCode(...new Uint8Array(bytes, 36, 4))).toBe('data');
expect(view.getUint32(40, true)).toBe(8);
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
expect(view.getInt16(46, true)).toBeCloseTo(-8192, -1);
expect(view.getInt16(48, true)).toBeCloseTo(-16384, -1);
expect(view.getInt16(50, true)).toBeCloseTo(16383, -1);
});
test('buildLeadingSilenceTrimmedWavBlob keeps the original recording when no leading silence is removable', () => {
const startsImmediately = createAudioBufferStub([[0.2, 0.1, 0.05]], 1000);
const allSilent = createAudioBufferStub([[0, 0.001, -0.001]], 1000);
expect(
buildLeadingSilenceTrimmedWavBlob(startsImmediately, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
expect(
buildLeadingSilenceTrimmedWavBlob(allSilent, {
silenceThreshold: 0.01,
minimumTrimDurationMs: 1,
}),
).toBeNull();
});

View File

@@ -0,0 +1,190 @@
type BrowserAudioGlobal = typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
};
export type LeadingSilenceTrimOptions = {
silenceThreshold?: number;
minimumTrimDurationMs?: number;
};
const DEFAULT_SILENCE_THRESHOLD = 0.01;
const DEFAULT_MINIMUM_TRIM_DURATION_MS = 20;
const WAV_HEADER_BYTE_LENGTH = 44;
const WAV_BITS_PER_SAMPLE = 16;
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
export function findFirstAudibleFrame(
buffer: AudioBuffer,
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
) {
const threshold = Math.max(0, silenceThreshold);
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
for (
let channelIndex = 0;
channelIndex < buffer.numberOfChannels;
channelIndex += 1
) {
const channelData = buffer.getChannelData(channelIndex);
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
return frameIndex;
}
}
}
return null;
}
export function buildLeadingSilenceTrimmedWavBlob(
buffer: AudioBuffer,
options: LeadingSilenceTrimOptions = {},
) {
const silenceThreshold =
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD;
const minimumTrimDurationMs =
options.minimumTrimDurationMs ?? DEFAULT_MINIMUM_TRIM_DURATION_MS;
const firstAudibleFrame = findFirstAudibleFrame(buffer, silenceThreshold);
if (firstAudibleFrame === null) {
return null;
}
const minimumTrimFrames = Math.max(
1,
Math.round((buffer.sampleRate * minimumTrimDurationMs) / 1000),
);
if (firstAudibleFrame < minimumTrimFrames) {
return null;
}
const frameCount = buffer.length - firstAudibleFrame;
if (frameCount <= 0) {
return null;
}
return encodeAudioBufferSectionToWavBlob(
buffer,
firstAudibleFrame,
frameCount,
);
}
export async function trimLeadingSilenceFromRecordedAudioFile(
file: File,
options: LeadingSilenceTrimOptions = {},
) {
try {
const decodedBuffer = await decodeRecordedAudioFile(file);
if (!decodedBuffer) {
return file;
}
const trimmedBlob = buildLeadingSilenceTrimmedWavBlob(
decodedBuffer,
options,
);
if (!trimmedBlob) {
return file;
}
return new File([trimmedBlob], buildTrimmedAudioFileName(file.name), {
type: trimmedBlob.type,
lastModified: Date.now(),
});
} catch {
// 录音裁剪只是体验优化,浏览器解码失败时必须保留用户刚录好的原始文件。
return file;
}
}
function getAudioContextConstructor() {
const audioGlobal = globalThis as BrowserAudioGlobal;
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
}
async function decodeRecordedAudioFile(file: File) {
const AudioContextConstructor = getAudioContextConstructor();
if (!AudioContextConstructor) {
return null;
}
const context = new AudioContextConstructor();
try {
const bytes = await file.arrayBuffer();
return await context.decodeAudioData(bytes.slice(0));
} finally {
void context.close();
}
}
function encodeAudioBufferSectionToWavBlob(
buffer: AudioBuffer,
startFrame: number,
frameCount: number,
) {
// MediaRecorder 输出格式不稳定;解码后统一写成 WAV避免再依赖浏览器重新编码。
const channelCount = Math.max(1, buffer.numberOfChannels);
const dataByteLength =
frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
const view = new DataView(output);
const channelData = Array.from({ length: channelCount }, (_value, index) =>
index < buffer.numberOfChannels
? buffer.getChannelData(index)
: new Float32Array(buffer.length),
);
writeAscii(view, 0, 'RIFF');
view.setUint32(4, 36 + dataByteLength, true);
writeAscii(view, 8, 'WAVE');
writeAscii(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channelCount, true);
view.setUint32(24, buffer.sampleRate, true);
view.setUint32(
28,
buffer.sampleRate * channelCount * WAV_BYTES_PER_SAMPLE,
true,
);
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
writeAscii(view, 36, 'data');
view.setUint32(40, dataByteLength, true);
let outputOffset = WAV_HEADER_BYTE_LENGTH;
for (let frameOffset = 0; frameOffset < frameCount; frameOffset += 1) {
const sourceFrame = startFrame + frameOffset;
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
const sample = channelData[channelIndex]?.[sourceFrame] ?? 0;
view.setInt16(outputOffset, toSignedPcm16(sample), true);
outputOffset += WAV_BYTES_PER_SAMPLE;
}
}
return new Blob([output], { type: 'audio/wav' });
}
function toSignedPcm16(sample: number) {
const clamped = Math.max(-1, Math.min(1, sample));
return clamped < 0
? Math.round(clamped * 0x8000)
: Math.round(clamped * 0x7fff);
}
function writeAscii(view: DataView, offset: number, value: string) {
for (let index = 0; index < value.length; index += 1) {
view.setUint8(offset + index, value.charCodeAt(index));
}
}
function buildTrimmedAudioFileName(fileName: string) {
const normalizedName = fileName.trim();
if (!normalizedName) {
return 'recorded-audio.wav';
}
return /\.[^.]+$/u.test(normalizedName)
? normalizedName.replace(/\.[^.]+$/u, '.wav')
: `${normalizedName}.wav`;
}

View File

@@ -10,7 +10,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};
@@ -1043,14 +1043,10 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub published share icon copies share text without opening the card', async () => {
test('creation hub published share icon opens unified share payload without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
const onShareWork = vi.fn();
render(
<CustomWorldCreationHub
@@ -1081,6 +1077,7 @@ test('creation hub published share icon copies share text without opening the ca
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onShareWork={onShareWork}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
@@ -1092,19 +1089,12 @@ test('creation hub published share icon copies share text without opening the ca
await user.click(shareButton);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('邀请你来玩《沉钟拼图》'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-PROFILE1'),
);
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('/gallery/puzzle/detail?work=PZ-PROFILE1'),
);
expect(onShareWork).toHaveBeenCalledWith({
title: '沉钟拼图',
publicWorkCode: 'PZ-PROFILE1',
stage: 'puzzle-gallery-detail',
});
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(
await screen.findByRole('button', { name: '分享内容已复制' }),
).toBeTruthy();
});
test('creation hub published share icon is shown directly on the card header', () => {

View File

@@ -4,7 +4,7 @@ import { expect, test } from 'vitest';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -241,7 +241,7 @@ test('creation start card renders reference-aligned banner and template metadata
expect(html).toContain('creation-template-card__body');
expect(html).toContain('creation-template-card__cost-badge');
expect(html).toContain('拼图关卡创作');
expect(html).toContain('10-20泥点数');
expect(html).toContain('10泥点数');
expect(html).toContain('即将开放');
expect(html).toContain('data-locked="true"');
expect(html).toContain('暂未开放');
@@ -292,7 +292,62 @@ test('locked creation template card replaces mud point cost with unavailable sta
expect(html).toContain('data-locked="true"');
expect(html).toContain('即将开放');
expect(html).toContain('暂未开放');
expect(html).not.toContain('10-20泥点数');
expect(html).not.toContain('10泥点数');
});
test('creation template card renders mud point cost from unified creation spec', () => {
const config = {
...testEntryConfig,
creationTypes: [
{
id: 'puzzle',
title: '拼图',
subtitle: '拼图关卡创作',
badge: '可创建',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
unifiedCreationSpec: {
playId: 'puzzle',
title: '拼图',
mudPointCost: 12,
workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating',
resultStage: 'puzzle-result',
fields: [
{
id: 'pictureDescription',
kind: 'text',
label: '画面描述',
required: true,
},
],
},
},
],
} satisfies CreationEntryConfig;
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={config}
creationTypes={derivePlatformCreationTypes(config.creationTypes)}
mode="start-only"
/>,
);
expect(html).toContain('12泥点数');
expect(html).not.toContain('10泥点数');
});
test('creation start card falls back to legacy single banner when eventBanners is empty', () => {
@@ -525,6 +580,7 @@ test('creation start card maps backend jump-hop draft to template card', () => {
profileId: 'jump-hop-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-1',
themeText: '跳一跳生成草稿',
workTitle: '跳一跳生成草稿',
workDescription: '后端仍在生成跳一跳玩法。',
themeTags: ['跳一跳'],

View File

@@ -0,0 +1,128 @@
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import {
CustomWorldCreationHub as CustomWorldCreationHubView,
} from './CustomWorldCreationHub';
type ShelfBuilderParams = Parameters<typeof buildCreationWorkShelfItems>[0];
type HubViewProps = Parameters<typeof CustomWorldCreationHubView>[0];
type LegacyCustomWorldCreationHubProps = Omit<HubViewProps, 'shelfItems'> &
Partial<
Omit<ShelfBuilderParams, 'rpgItems' | 'bigFishItems' | 'puzzleItems'>
> & {
shelfItems?: CreationWorkShelfItem[];
items?: ShelfBuilderParams['rpgItems'];
bigFishItems?: ShelfBuilderParams['bigFishItems'];
puzzleItems?: ShelfBuilderParams['puzzleItems'];
onOpenDraft?: ShelfBuilderParams['onOpenRpgDraft'];
onEnterPublished?: ShelfBuilderParams['onEnterRpgPublished'];
onDeletePublished?: ShelfBuilderParams['onDeleteRpg'] | null;
getWorkState?: ShelfBuilderParams['getItemState'];
};
/** 测试用 Adapter旧 fixture 先转成 shelfItems生产 Hub Interface 保持窄面。 */
export function CustomWorldCreationHub({
shelfItems,
items = [],
rpgLibraryEntries = [],
bigFishItems = [],
match3dItems = [],
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleItems = [],
babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [],
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
getWorkState,
creationTypes,
...props
}: LegacyCustomWorldCreationHubProps) {
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const resolvedShelfItems =
shelfItems ??
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState: getItemState ?? getWorkState,
});
return (
<CustomWorldCreationHubView
{...props}
creationTypes={creationTypes}
shelfItems={resolvedShelfItems}
/>
);
}

View File

@@ -1,29 +1,16 @@
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types';
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
@@ -47,7 +34,7 @@ type WorkMetricSnapshot = Record<
>;
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
shelfItems: CreationWorkShelfItem[];
loading: boolean;
error: string | null;
onRetry: () => void;
@@ -55,46 +42,10 @@ type CustomWorldCreationHubProps = {
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
jumpHopItems?: JumpHopWorkSummaryResponse[];
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
barkBattleItems?: BarkBattleWorkSummary[];
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
onShareWork?: ((payload: PublishShareModalPayload) => void) | null;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
mode?: 'full' | 'start-only' | 'works-only';
@@ -163,9 +114,44 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
}
}
function resolveShelfShareStage(
sharePath: string,
): PublishShareModalPayload['stage'] | null {
let pathname = '';
try {
pathname = new URL(sharePath, 'https://genarrative.local').pathname;
} catch {
pathname = sharePath.split(/[?#]/u)[0] ?? '';
}
const stage = resolveSelectionStageFromPath(pathname);
return stage === 'platform' ? null : stage;
}
function buildCreationWorkShelfSharePayload(
item: CreationWorkShelfItem,
): PublishShareModalPayload | null {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return null;
}
const stage = resolveShelfShareStage(sharePath);
if (!stage) {
return null;
}
return {
title: item.title,
publicWorkCode,
stage,
};
}
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
items,
shelfItems,
loading,
error,
onRetry,
@@ -173,137 +159,15 @@ export function CustomWorldCreationHub({
entryConfig,
creationTypes,
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
jumpHopItems = [],
onOpenJumpHopDetail,
onDeleteJumpHop = null,
woodenFishItems = [],
onOpenWoodenFishDetail = null,
onDeleteWoodenFish = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
onShareWork = null,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish: onDeleteBigFish ?? undefined,
onOpenMatch3DDetail,
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
}),
[
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
barkBattleItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onDeleteJumpHop,
onDeleteWoodenFish,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onOpenWoodenFishDetail,
onEnterPublished,
getWorkState,
puzzleItems,
rpgLibraryEntries,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,
visualNovelItems,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
@@ -341,44 +205,8 @@ export function CustomWorldCreationHub({
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'jump-hop':
onOpenJumpHopDetail?.(item.source.item);
return;
case 'wooden-fish':
onOpenWoodenFishDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
// 中文注释:玩法差异由 Work Shelf Adapter 承载Hub 只负责响应卡片点击。
item.actions.open();
}
function buildDeleteAction(item: CreationWorkShelfItem) {
@@ -389,6 +217,17 @@ export function CustomWorldCreationHub({
return item.actions.delete ?? null;
}
function buildShareAction(item: CreationWorkShelfItem) {
const payload = buildCreationWorkShelfSharePayload(item);
if (!payload) {
return null;
}
return () => {
onShareWork?.(payload);
};
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
return item.actions.claimPointIncentive ?? null;
}
@@ -464,6 +303,7 @@ export function CustomWorldCreationHub({
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onShare={buildShareAction(item)}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&

View File

@@ -33,7 +33,7 @@ function shouldShowCreationBadge(badge: string) {
}
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
export function resolveCreationEntryEventBanners(
function resolveCreationEntryEventBanners(
entryConfig: CreationEntryConfig,
): CreationEventBannerCard[] {
const configuredBanners = Array.isArray(entryConfig.eventBanners)
@@ -379,7 +379,7 @@ export function CustomWorldCreationStartCard({
<Coins className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{item.locked ? '暂未开放' : '10-20泥点数'}
{item.locked ? '暂未开放' : item.mudPointCostLabel}
</span>
</span>
</div>

View File

@@ -7,6 +7,7 @@ import {
Trash2,
} from 'lucide-react';
import {
default as React,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -17,7 +18,6 @@ import {
useState,
} from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import {
formatPlatformWorkDisplayName,
@@ -39,6 +39,7 @@ type CustomWorldWorkCardProps = {
onOpen: () => void;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
onShare?: (() => void) | null;
onClaimPointIncentive?: (() => void) | null;
pointIncentiveBusy?: boolean;
};
@@ -62,6 +63,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
'wooden-fish': '/wooden-fish/default-hit-object.png',
'puzzle-clear': '/creation-type-references/puzzle.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
@@ -229,13 +231,10 @@ export function CustomWorldWorkCard({
onOpen,
onDelete = null,
deleteBusy = false,
onShare = null,
onClaimPointIncentive = null,
pointIncentiveBusy = false,
}: CustomWorldWorkCardProps) {
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const shareResetTimerRef = useRef<number | null>(null);
const suppressOpenResetTimerRef = useRef<number | null>(null);
const suppressOpenRef = useRef(false);
const swipeGestureRef = useRef<{
@@ -251,7 +250,7 @@ export function CustomWorldWorkCard({
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath);
isPublished && item.canShare && Boolean(item.sharePath) && Boolean(onShare);
const swipeActionCount = onDelete ? 1 : 0;
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
@@ -287,34 +286,8 @@ export function CustomWorldWorkCard({
}`,
} as CSSProperties;
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
const sharePath = item.sharePath?.trim();
if (!publicWorkCode || !sharePath) {
return;
}
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
shareResetTimerRef.current = window.setTimeout(() => {
shareResetTimerRef.current = null;
setShareState('idle');
}, 1400);
});
};
useEffect(() => {
return () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
@@ -675,7 +648,7 @@ export function CustomWorldWorkCard({
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
onShare?.();
}}
onKeyDown={(event) => {
event.stopPropagation();
@@ -686,20 +659,8 @@ export function CustomWorldWorkCard({
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享作品"
aria-label="分享"
className="creation-work-card__quick-action-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />

View File

@@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
buildCreationWorkShelfItemsFromSources,
type CreationWorkShelfItem,
getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
@@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => {
const [staleRpgItem] = buildCreationWorkShelfItems({
rpgItems: [
{
workId: 'draft:rpg-source-adapter',
sourceType: 'agent_session',
status: 'draft',
title: '旧 RPG 草稿',
subtitle: '待完善',
summary: '通过 source adapter 输入。',
coverImageSrc: null,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '待完善',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'rpg-source-adapter',
profileId: null,
canResume: true,
canEnterWorld: false,
},
],
bigFishItems: [],
puzzleItems: [],
});
const [freshPuzzleItem] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:source-adapter',
profileId: 'puzzle-source-adapter',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '新拼图',
summary: '新近拼图。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-03T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
const items = buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () => (staleRpgItem ? [staleRpgItem] : []),
},
{
kind: 'puzzle',
buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []),
},
],
getItemState: (item) =>
item.id === staleRpgItem?.id
? {
isGenerating: true,
hasUnreadUpdate: true,
titleOverride: '生成中 RPG 草稿',
}
: null,
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:source-adapter',
'draft:rpg-source-adapter',
]);
expect(items[1]?.title).toBe('生成中 RPG 草稿');
expect(items[1]?.isGenerating).toBe(true);
expect(items[1]?.hasUnreadUpdate).toBe(true);
});
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
const onOpenWoodenFishDetail = vi.fn();
const woodenFishWork = {
@@ -99,6 +180,47 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
});
test('buildCreationWorkShelfItems maps puzzle clear items with PC public code', () => {
const onOpenPuzzleClearDetail = vi.fn();
const puzzleClearWork = {
runtimeKind: 'puzzle-clear' as const,
workId: 'puzzle-clear-work-1',
profileId: 'puzzle-clear-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-clear-session-1',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题。',
themePrompt: '霓虹星港',
coverImageSrc: '/generated-puzzle-clear-assets/profile/atlas.png',
publicationStatus: 'published',
playCount: 6,
updatedAt: '2026-05-30T00:00:00.000Z',
publishedAt: '2026-05-30T00:00:00.000Z',
publishReady: true,
generationStatus: 'ready' as const,
};
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
puzzleClearItems: [puzzleClearWork],
onOpenPuzzleClearDetail,
});
items[0]?.actions.open();
expect(items).toHaveLength(1);
expect(items[0]?.kind).toBe('puzzle-clear');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('PC-12345678');
expect(items[0]?.sharePath).toContain('/works/detail?work=PC-12345678');
expect(items[0]?.openActionLabel).toBe('查看详情');
expect(items[0]?.badges.some((badge) => badge.label === '拼消消')).toBe(true);
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(6);
expect(onOpenPuzzleClearDetail).toHaveBeenCalledWith(puzzleClearWork);
});
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],

View File

@@ -2,21 +2,23 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
@@ -37,6 +39,7 @@ export type CreationWorkShelfKind =
| 'square-hole'
| 'jump-hop'
| 'wooden-fish'
| 'puzzle-clear'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
@@ -97,6 +100,10 @@ export type CreationWorkShelfSource =
kind: 'wooden-fish';
item: WoodenFishWorkSummaryResponse;
}
| {
kind: 'puzzle-clear';
item: PuzzleClearWorkSummaryResponse;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
@@ -157,6 +164,11 @@ export type CreationWorkShelfRuntimeState = {
summaryOverride?: string;
};
export type CreationWorkShelfSourceAdapter = {
kind: CreationWorkShelfKind;
buildItems: () => readonly CreationWorkShelfItem[];
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -165,6 +177,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[];
jumpHopItems?: JumpHopWorkSummaryResponse[];
woodenFishItems?: WoodenFishWorkSummaryResponse[];
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
@@ -175,6 +188,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeleteJumpHop?: boolean;
canDeleteWoodenFish?: boolean;
canDeletePuzzleClear?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
@@ -192,6 +206,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
onOpenPuzzleClearDetail?: (item: PuzzleClearWorkSummaryResponse) => void;
onDeletePuzzleClear?: (item: PuzzleClearWorkSummaryResponse) => void;
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
@@ -213,6 +229,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleClearItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
@@ -223,6 +240,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeleteJumpHop = false,
canDeleteWoodenFish = false,
canDeletePuzzleClear = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
@@ -240,6 +258,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleClearDetail,
onDeletePuzzleClear,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
@@ -252,70 +272,145 @@ export function buildCreationWorkShelfItems(params: {
getItemState,
} = params;
return [
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
...match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
...squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
...jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
...woodenFishItems.map((item) =>
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
onOpen: onOpenWoodenFishDetail,
onDelete: onDeleteWoodenFish,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
]
return buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () =>
rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
},
{
kind: 'big-fish',
buildItems: () =>
bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
},
{
kind: 'match3d',
buildItems: () =>
match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
},
{
kind: 'square-hole',
buildItems: () =>
squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
},
{
kind: 'jump-hop',
buildItems: () =>
jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
},
{
kind: 'wooden-fish',
buildItems: () =>
woodenFishItems.map((item) =>
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
onOpen: onOpenWoodenFishDetail,
onDelete: onDeleteWoodenFish,
}),
),
},
{
kind: 'puzzle-clear',
buildItems: () =>
puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
},
{
kind: 'puzzle',
buildItems: () =>
puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
},
{
kind: 'baby-object-match',
buildItems: () =>
babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(
item,
canDeleteBabyObjectMatch,
{
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
},
),
),
},
{
kind: 'bark-battle',
buildItems: () =>
mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
},
{
kind: 'visual-novel',
buildItems: () =>
visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
},
],
getItemState,
});
}
export function buildCreationWorkShelfItemsFromSources(params: {
sources: readonly CreationWorkShelfSourceAdapter[];
getItemState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
}) {
const { sources, getItemState } = params;
const sourceItems = sources.reduce<CreationWorkShelfItem[]>(
(items, source) => {
items.push(...source.buildItems());
return items;
},
[],
);
return sourceItems
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
@@ -903,6 +998,56 @@ function mapWoodenFishWorkToShelfItem(
};
}
function mapPuzzleClearWorkToShelfItem(
item: PuzzleClearWorkSummaryResponse,
canDelete: boolean,
adapter: WorkShelfAdapter<PuzzleClearWorkSummaryResponse>,
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published'
? buildPuzzleClearPublicWorkCode(item.profileId)
: null;
const title = item.workTitle.trim() || '拼消消';
const summary =
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
return {
id: item.workId,
kind: 'puzzle-clear',
status,
title,
summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc),
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼消消', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'puzzle-clear', item },
};
}
function resolveAuthorDisplayName(...sources: Array<unknown>) {
for (const source of sources) {
const authorDisplayName =
@@ -1119,6 +1264,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
return isPersistedPuzzleDraftGenerating(item.source.item);
case 'wooden-fish':
return item.source.item.generationStatus === 'generating';
case 'puzzle-clear':
return item.source.item.generationStatus === 'generating';
case 'bark-battle':
return isPersistedBarkBattleDraftGenerating(item.source.item);
default:

View File

@@ -1,144 +1,227 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { JumpHopWorkProfileResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { JumpHopResultView } from './JumpHopResultView';
const draft: JumpHopDraftResponse = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish',
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'finish',
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
};
vi.mock('../../services/jump-hop/useJumpHopLeaderboard', () => ({
useJumpHopLeaderboard: vi.fn(),
}));
test('jump hop result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onEdit = vi.fn();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onRegenerateCharacter = vi.fn();
const onRegenerateTiles = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: null,
isLoading: false,
error: null,
refresh: vi.fn(),
});
});
test('跳一跳结果页展示排行榜列表', () => {
vi.mocked(useJumpHopLeaderboard).mockReturnValue({
leaderboard: {
profileId: 'jump-hop-profile-test',
items: [
{
rank: 1,
playerId: 'user-secret-1',
displayName: '陶泥儿玩家',
successfulJumpCount: 12,
durationMs: 40123,
updatedAt: '2026-05-27T00:00:00Z',
},
{
rank: 2,
playerId: 'user-secret-2',
displayName: '森林玩家',
successfulJumpCount: 10,
durationMs: 38210,
updatedAt: '2026-05-26T00:00:00Z',
},
],
viewerBest: null,
},
isLoading: false,
error: null,
refresh: vi.fn(),
});
render(
<JumpHopResultView
profile={draft}
onBack={onBack}
onEdit={onEdit}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateCharacter={onRegenerateCharacter}
onRegenerateTiles={onRegenerateTiles}
profile={buildProfile({ publicationStatus: 'published' })}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(screen.getByText('云端跳台')).toBeTruthy();
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: '地块' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
expect(screen.getByText('排行榜')).toBeTruthy();
expect(screen.getByText('陶泥儿玩家')).toBeTruthy();
expect(screen.queryByText('user-secret-1')).toBeNull();
expect(screen.getByText('12 跳')).toBeTruthy();
expect(screen.getByText('00:40')).toBeTruthy();
expect(screen.getByText('森林玩家')).toBeTruthy();
expect(screen.queryByText('user-secret-2')).toBeNull();
});
test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(screen.getByTestId('jump-hop-result-character-logo').getAttribute('src')).toBe(
'/branding/jump-hop-taonier-character.png',
);
});
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
const { container } = render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('overflow-y-auto');
expect(root.className).toContain('overscroll-contain');
expect(root.className).toContain('safe-area-inset-bottom');
});
test('跳一跳草稿结果页不请求公开排行榜', () => {
render(
<JumpHopResultView
profile={buildProfile({ publicationStatus: 'draft' })}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
expect(useJumpHopLeaderboard).not.toHaveBeenCalled();
expect(screen.queryByText('排行榜')).toBeNull();
});
function buildProfile(
options: {
publicationStatus?: JumpHopWorkProfileResponse['summary']['publicationStatus'];
} = {},
): JumpHopWorkProfileResponse {
return {
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-profile-test',
profileId: 'jump-hop-profile-test',
ownerUserId: 'user-test',
sourceSessionId: 'jump-hop-session-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: options.publicationStatus ?? 'draft',
playCount: 0,
updatedAt: '2026-05-27T00:00:00Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-test',
themeText: '测试',
workTitle: '测试',
workDescription: '测试',
themeTags: ['测试'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterPrompt: '默认角色',
tilePrompt: '地块',
endMoodPrompt: null,
characterAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
path: null,
coverComposite: null,
backButtonAsset: null,
generationStatus: 'ready',
},
path: null as never,
defaultCharacter: {
characterId: 'jump-hop-default-runner',
displayName: '默认角色',
modelKind: 'builtin-three',
bodyColor: '#f59e0b',
accentColor: '#2563eb',
},
characterAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAtlasAsset: {
assetId: 'builtin',
imageSrc: 'builtin://jump-hop/default-character',
imageObjectKey: '',
assetObjectId: 'builtin',
generationProvider: 'builtin-three',
prompt: '默认角色',
width: 0,
height: 0,
},
tileAssets: [],
backButtonAsset: null,
};
}

View File

@@ -2,18 +2,22 @@ import {
ArrowLeft,
Loader2,
Play,
RefreshCcw,
Send,
Shuffle,
} from 'lucide-react';
import { type CSSProperties, useMemo, useState } from 'react';
import { type CSSProperties, useState } from 'react';
import type {
JumpHopDraftResponse,
JumpHopPath,
JumpHopPlatform,
JumpHopTileAsset,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import {
formatJumpHopDurationLabel,
selectJumpHopTileAsset,
} from '../../services/jump-hop/jumpHopRuntimeModel';
import { useJumpHopLeaderboard } from '../../services/jump-hop/useJumpHopLeaderboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type JumpHopResultViewProps = {
@@ -34,7 +38,6 @@ type JumpHopResultViewProps = {
onEdit: () => void;
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateCharacter: () => void;
onRegenerateTiles: () => void;
};
@@ -44,43 +47,6 @@ function isJumpHopWorkProfile(
return 'summary' in profile;
}
type MiniMapPlatform = {
platform: JumpHopPlatform;
index: number;
x: number;
y: number;
width: number;
height: number;
isStart: boolean;
isFinish: boolean;
};
const difficultyToneByValue: Record<
JumpHopPath['difficulty'],
{ accent: string; soft: string; label: string }
> = {
advanced: {
accent: '#df7f40',
soft: 'rgba(249, 115, 22, 0.16)',
label: '进阶',
},
challenge: {
accent: '#b64a35',
soft: 'rgba(182, 98, 63, 0.16)',
label: '挑战',
},
easy: {
accent: '#14b8a6',
soft: 'rgba(20, 184, 166, 0.16)',
label: '轻松',
},
standard: {
accent: '#2563eb',
soft: 'rgba(37, 99, 235, 0.16)',
label: '标准',
},
};
const tileToneByType: Record<string, string> = {
accent: '#c4b5fd',
bonus: '#fde68a',
@@ -90,155 +56,193 @@ const tileToneByType: Record<string, string> = {
target: '#fecdd3',
};
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
const JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC =
'/branding/jump-hop-taonier-character.png';
function JumpHopDefaultCharacterPreview() {
return (
<div className="relative grid aspect-[1/1] place-items-center overflow-hidden bg-[linear-gradient(180deg,#eff6ff_0%,#fff7ed_100%)]">
<div className="absolute inset-x-[18%] bottom-[14%] h-[14%] rounded-full bg-slate-900/12 blur-[2px]" />
<img
src={JUMP_HOP_TAONIER_CHARACTER_IMAGE_SRC}
alt=""
draggable={false}
className="relative z-10 h-[78%] w-[78%] object-contain drop-shadow-[0_12px_18px_rgba(146,64,14,0.2)]"
data-testid="jump-hop-result-character-logo"
/>
</div>
);
}
function normalizePathPlatforms(path: JumpHopPath | null | undefined) {
const platforms = path?.platforms ?? [];
if (platforms.length === 0) {
return [];
function JumpHopTilePoolPreview({
tileAssets,
tileAtlasAsset,
tileAtlasFallbackSrc,
}: {
tileAssets: JumpHopTileAsset[];
tileAtlasAsset?: JumpHopDraftResponse['tileAtlasAsset'] | null;
tileAtlasFallbackSrc?: string | null;
}) {
const visibleTiles = tileAssets.slice(0, 25);
const atlasSrc =
tileAtlasAsset?.imageSrc?.trim() || tileAtlasFallbackSrc?.trim() || '';
const atlasRefreshKey = tileAtlasAsset?.assetObjectId || atlasSrc;
if (visibleTiles.length > 0) {
return (
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{visibleTiles.map((tile, index) => (
<div
key={tile.tileId ?? `${tile.sourceAtlasCell}-${index}`}
className="grid min-h-0 place-items-center overflow-hidden rounded-[0.45rem] border border-white/80 bg-slate-50"
>
{tile.imageSrc ? (
<ResolvedAssetImage
src={tile.imageSrc}
refreshKey={tile.assetObjectId}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span
className="h-4 w-4 rounded-full"
style={{
background:
tileToneByType[tile.tileType] ?? tileToneByType.normal,
}}
/>
)}
</div>
))}
</div>
);
}
const coordinatePlatforms = platforms.filter(
(platform) => isFiniteNumber(platform.x) && isFiniteNumber(platform.y),
);
const shouldUseCoordinates = coordinatePlatforms.length >= 2;
const xValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.x)
: [];
const yValues = shouldUseCoordinates
? coordinatePlatforms.map((platform) => platform.y)
: [];
const minX = Math.min(...xValues);
const maxX = Math.max(...xValues);
const minY = Math.min(...yValues);
const maxY = Math.max(...yValues);
const xRange = Math.max(maxX - minX, 1);
const yRange = Math.max(maxY - minY, 1);
const denominator = Math.max(platforms.length - 1, 1);
return platforms.map((platform, index): MiniMapPlatform => {
const sequenceRatio = index / denominator;
const hasCoordinates =
shouldUseCoordinates &&
isFiniteNumber(platform.x) &&
isFiniteNumber(platform.y);
const x = hasCoordinates
? 12 + ((platform.x - minX) / xRange) * 76
: 12 + sequenceRatio * 76;
const y = hasCoordinates
? 14 + ((platform.y - minY) / yRange) * 72
: 50 + Math.sin(sequenceRatio * Math.PI * 2.3) * 18;
return {
platform,
index,
x,
y,
width: Math.min(Math.max(platform.width || 54, 42), 82),
height: Math.min(Math.max(platform.height || 42, 34), 68),
isStart: index === 0 || platform.tileType === 'start',
isFinish:
index === path?.finishIndex ||
platform.tileType === 'finish' ||
platform.tileType === 'target',
};
});
}
function JumpHopPathMiniMap({ path }: { path: JumpHopPath }) {
const platforms = useMemo(() => normalizePathPlatforms(path), [path]);
const tone =
difficultyToneByValue[path.difficulty] ?? difficultyToneByValue.standard;
const pathPoints = platforms
.map((platform) => `${platform.x},${platform.y}`)
.join(' ');
if (platforms.length === 0) {
return null;
if (atlasSrc) {
return (
<ResolvedAssetImage
src={atlasSrc}
refreshKey={atlasRefreshKey}
alt=""
className="aspect-[1/1] w-full object-cover"
/>
);
}
return (
<div
className="relative aspect-[1/1] w-full overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]"
style={
{
'--jump-hop-path-accent': tone.accent,
'--jump-hop-path-soft': tone.soft,
} as CSSProperties
}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_24%_18%,rgba(255,255,255,0.92),transparent_28%),radial-gradient(circle_at_75%_78%,rgba(125,211,252,0.24),transparent_32%)]" />
<svg
viewBox="0 0 100 100"
className="absolute inset-0 h-full w-full"
aria-hidden="true"
>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-soft)"
strokeWidth="11"
strokeLinecap="round"
strokeLinejoin="round"
<div className="grid aspect-[1/1] grid-cols-5 gap-1 bg-white/78 p-2">
{Array.from({ length: 25 }).map((_, index) => (
<span
key={index}
className="rounded-[0.45rem] border border-white/80"
style={{
background:
Object.values(tileToneByType)[index % Object.values(tileToneByType).length],
}}
/>
<polyline
points={pathPoints}
fill="none"
stroke="var(--jump-hop-path-accent)"
strokeWidth="2.6"
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray="4 4"
/>
</svg>
{platforms.map((item) => {
const tileTone =
tileToneByType[item.platform.tileType] ?? tileToneByType.normal;
const scoreBoost =
isFiniteNumber(item.platform.scoreValue) &&
item.platform.scoreValue > 1;
const style = {
left: `${item.x}%`,
top: `${item.y}%`,
width: `${item.width}%`,
height: `${item.height}%`,
background: tileTone,
borderColor: item.isFinish ? tone.accent : 'rgba(255,255,255,0.92)',
zIndex: 10 + item.index,
} as CSSProperties;
))}
</div>
);
}
function JumpHopFirstPlatformsPreview({
path,
tileAssets,
}: {
path: JumpHopPath | null | undefined;
tileAssets: JumpHopTileAsset[];
}) {
const platforms = (path?.platforms ?? []).slice(0, 3);
return (
<div className="relative aspect-[1/1] overflow-hidden bg-[linear-gradient(180deg,#f8fbff_0%,#eef8ff_100%)]">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(255,255,255,0.92),transparent_34%)]" />
{platforms.map((platform, index) => {
const asset = selectJumpHopTileAsset(
tileAssets,
path?.seed,
index,
platform.platformId,
);
const style = {
left: `${50 + (index - 1) * 24}%`,
top: `${68 - index * 22}%`,
width: `${34 - index * 3}%`,
zIndex: 10 + index,
} as CSSProperties;
return (
<div
key={
item.platform.platformId ||
`${item.index}-${item.platform.tileType}`
}
className="absolute grid max-h-9 max-w-11 min-h-6 min-w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-[0.72rem] border-2 shadow-[0_8px_18px_rgba(15,23,42,0.13)]"
key={platform.platformId || index}
className="absolute aspect-[1.16/1] -translate-x-1/2 -translate-y-1/2"
style={style}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{
background:
item.isStart || item.isFinish ? tone.accent : '#ffffff',
boxShadow: scoreBoost ? `0 0 0 4px ${tone.soft}` : undefined,
}}
/>
{item.isStart || item.isFinish ? (
<span className="absolute -top-2.5 rounded-full bg-slate-950/78 px-1.5 py-0.5 text-[0.58rem] font-black leading-none text-white">
{item.isStart ? '起' : '终'}
</span>
) : null}
<div className="absolute inset-x-[12%] bottom-[-6%] h-[22%] rounded-full bg-slate-900/14 blur-[3px]" />
{asset?.imageSrc ? (
<ResolvedAssetImage
src={asset.imageSrc}
refreshKey={asset.assetObjectId}
alt=""
className="relative h-full w-full object-contain"
/>
) : (
<div
className="relative h-full w-full rounded-[18%] border-2 border-white/90 shadow-[0_10px_22px_rgba(15,23,42,0.14)]"
style={{
background:
tileToneByType[platform.tileType] ?? tileToneByType.normal,
}}
/>
)}
</div>
);
})}
<div className="absolute left-2 top-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-strong)] shadow-sm">
{tone.label}
{platforms.length === 0 ? (
<div className="absolute inset-0 grid place-items-center text-sm font-bold text-[var(--platform-text-soft)]">
</div>
) : null}
</div>
);
}
function JumpHopResultLeaderboard({
profileId,
}: {
profileId?: string | null;
}) {
const { leaderboard, isLoading, error } = useJumpHopLeaderboard(profileId);
const items = leaderboard?.items ?? [];
return (
<div className="mt-4 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 p-3">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
</div>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
) : null}
</div>
<div className="absolute bottom-2 right-2 rounded-full border border-white/80 bg-white/82 px-2 py-1 text-[0.62rem] font-black text-[var(--platform-text-base)] shadow-sm">
{platforms.length}
<div className="mt-3 grid gap-2">
{items.slice(0, 5).map((entry) => (
<div
key={`${entry.rank}-${entry.playerId}`}
className="grid grid-cols-[1.8rem_minmax(0,1fr)_auto_auto] items-center gap-2 rounded-[0.75rem] bg-white/70 px-2 py-2 text-xs font-bold text-[var(--platform-text-base)]"
>
<span className="text-[var(--platform-text-soft)]">
{entry.rank}
</span>
<span className="truncate">
{entry.displayName?.trim() || '玩家'}
</span>
<span>{entry.successfulJumpCount} </span>
<span>{formatJumpHopDurationLabel(entry.durationMs)}</span>
</div>
))}
{items.length === 0 ? (
<div className="rounded-[0.75rem] bg-white/60 px-2 py-2 text-xs font-bold text-[var(--platform-text-soft)]">
{error ?? '暂无成绩'}
</div>
) : null}
</div>
</div>
);
@@ -252,7 +256,6 @@ export function JumpHopResultView({
onEdit,
onStartTestRun,
onPublish,
onRegenerateCharacter,
onRegenerateTiles,
}: JumpHopResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
@@ -264,12 +267,15 @@ export function JumpHopResultView({
path: NonNullable<JumpHopDraftResponse['path']>;
};
const path = isWorkProfile ? profile.path : safeDraft.path;
const characterAsset = isWorkProfile
? profile.characterAsset
: safeDraft.characterAsset;
const tileAtlasAsset = isWorkProfile
? profile.tileAtlasAsset
: safeDraft.tileAtlasAsset;
const tileAssets = isWorkProfile ? profile.tileAssets : safeDraft.tileAssets;
const profileId = isWorkProfile
? profile.summary.profileId
: safeDraft.profileId;
const canShowLeaderboard =
isWorkProfile && profile.summary.publicationStatus === 'published';
const titleSource = isWorkProfile
? profile.summary.workTitle
: profile.workTitle;
@@ -278,15 +284,12 @@ export function JumpHopResultView({
: profile.workDescription;
const title = titleSource?.trim() || safeDraft.workTitle.trim() || '跳一跳';
const summary = summarySource?.trim() || safeDraft.workDescription.trim();
const pathPlatforms = normalizePathPlatforms(path);
const canRenderPathMiniMap = pathPlatforms.length > 0;
const hasAssets = Boolean(
profile.characterImageSrc?.trim() ||
profile.tileAtlasImageSrc?.trim() ||
profile.tileAtlasImageSrc?.trim() ||
profile.pathPreviewImageSrc?.trim() ||
characterAsset?.imageSrc?.trim() ||
tileAtlasAsset?.imageSrc?.trim() ||
canRenderPathMiniMap,
tileAssets.length > 0 ||
path?.platforms.length,
);
const handlePublish = async () => {
@@ -299,7 +302,7 @@ export function JumpHopResultView({
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
@@ -310,15 +313,6 @@ export function JumpHopResultView({
</button>
<div className="flex gap-2">
<button
type="button"
onClick={onRegenerateCharacter}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRegenerateTiles}
@@ -343,69 +337,25 @@ export function JumpHopResultView({
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.characterImageSrc || characterAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('characterImageSrc' in profile
? profile.characterImageSrc
: null) ??
characterAsset?.imageSrc ??
''
}
alt="角色图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopDefaultCharacterPreview />
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{profile.tileAtlasImageSrc || tileAtlasAsset?.imageSrc ? (
<ResolvedAssetImage
src={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
tileAtlasAsset?.imageSrc ??
''
}
alt="地块图"
className="aspect-[1/1] w-full object-cover"
/>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopTilePoolPreview
tileAssets={tileAssets}
tileAtlasAsset={tileAtlasAsset}
tileAtlasFallbackSrc={
('tileAtlasImageSrc' in profile
? profile.tileAtlasImageSrc
: null) ??
null
}
/>
</div>
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{path && canRenderPathMiniMap ? (
<JumpHopPathMiniMap path={path} />
) : 'pathPreviewImageSrc' in profile &&
profile.pathPreviewImageSrc ? (
<ResolvedAssetImage
src={profile.pathPreviewImageSrc}
alt="路径预览"
className="aspect-[1/1] w-full object-cover"
/>
) : path ? (
<div className="grid aspect-[1/1] place-items-center px-3 text-center">
<div>
<div className="text-3xl font-black text-[var(--platform-text-strong)]">
{path.platforms.length}
</div>
<div className="mt-1 text-xs font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{path.difficulty}
</div>
</div>
</div>
) : (
<div className="grid aspect-[1/1] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<JumpHopFirstPlatformsPreview
path={path}
tileAssets={tileAssets}
/>
</div>
</div>
{!hasAssets ? (
@@ -419,6 +369,9 @@ export function JumpHopResultView({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{canShowLeaderboard ? (
<JumpHopResultLeaderboard profileId={profileId} />
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{error}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,11 @@ import {
readAssetBytes,
resolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
buildMatch3DTrayInsertionPlan,
resolveMatch3DTrayItemIdToSlotIndexMap,
syncMatch3DItemTraySlotIndexes,
} from '../../services/match3d-runtime/match3dTrayLayout';
import {
getMatch3DGeneratedImageViewSources,
normalizeMatch3DGeneratedItemAssetsForRuntime,
@@ -41,11 +46,6 @@ import {
loadMatch3DSpritesheetAssetRegions,
type Match3DDecodedSpritesheetRegion,
} from '../../services/match3dSpritesheetParser';
import {
buildMatch3DTrayInsertionPlan,
resolveMatch3DTrayItemIdToSlotIndexMap,
syncMatch3DItemTraySlotIndexes,
} from '../../services/match3d-runtime/match3dTrayLayout';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
@@ -55,6 +55,7 @@ import {
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
import {
findMatch3DHitItem,
type Match3DAlphaHitMask,
@@ -398,12 +399,6 @@ function indexMatch3DUiSpritesheetRegions(
return new Map(regions.map((region) => [region.label, region]));
}
function resolveMatch3DImageReadUrlCacheKey(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
return resolveMatch3DImageReadUrlSources(imageSourcesByType).join('|');
}
function resolveMatch3DImageReadUrlSources(
imageSourcesByType: ReadonlyMap<string, readonly string[]>,
) {
@@ -1047,6 +1042,10 @@ export function Match3DRuntimeShell({
const [itemSpritesheetViewGroups, setItemSpritesheetViewGroups] = useState<
Match3DItemSpritesheetViewGroup[]
>([]);
const [isUiSpritesheetResolving, setIsUiSpritesheetResolving] =
useState(false);
const [isItemSpritesheetResolving, setIsItemSpritesheetResolving] =
useState(false);
const uiSpritesheetRegionByLabel = useMemo(
() => indexMatch3DUiSpritesheetRegions(uiSpritesheetRegions),
[uiSpritesheetRegions],
@@ -1077,11 +1076,13 @@ export function Match3DRuntimeShell({
setUiSpritesheetRegions((current) =>
current.length > 0 ? [] : current,
);
setIsUiSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setIsUiSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: uiSpritesheetSource,
labels: MATCH3D_UI_SPRITESHEET_LABELS,
@@ -1093,11 +1094,13 @@ export function Match3DRuntimeShell({
.then((regions) => {
if (!cancelled) {
setUiSpritesheetRegions(regions);
setIsUiSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setUiSpritesheetRegions([]);
setIsUiSpritesheetResolving(false);
}
});
@@ -1112,11 +1115,13 @@ export function Match3DRuntimeShell({
setItemSpritesheetViewGroups((current) =>
current.length > 0 ? [] : current,
);
setIsItemSpritesheetResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setIsItemSpritesheetResolving(true);
void loadMatch3DSpritesheetAssetRegions({
source: itemSpritesheetSource,
maxRegions: 100,
@@ -1132,11 +1137,13 @@ export function Match3DRuntimeShell({
runtimeGeneratedItemAssets.map((asset) => asset.itemName),
),
);
setIsItemSpritesheetResolving(false);
}
})
.catch(() => {
if (!cancelled) {
setItemSpritesheetViewGroups([]);
setIsItemSpritesheetResolving(false);
}
});
@@ -1315,13 +1322,17 @@ export function Match3DRuntimeShell({
),
[itemSpritesheetViewGroups, runtimeGeneratedItemAssets, run],
);
const imageReadUrlSources = useMemo(
() => resolveMatch3DImageReadUrlSources(imageSourcesByType),
[imageSourcesByType],
);
const itemSizeByType = useMemo(
() => buildMatch3DItemSizeByType(run, runtimeGeneratedItemAssets),
[runtimeGeneratedItemAssets, run],
);
const imageReadUrlCacheKey = useMemo(
() => resolveMatch3DImageReadUrlCacheKey(imageSourcesByType),
[imageSourcesByType],
() => imageReadUrlSources.join('|'),
[imageReadUrlSources],
);
const [resolvedImageSources, setResolvedImageSources] = useState<
Map<string, string>
@@ -1329,6 +1340,8 @@ export function Match3DRuntimeShell({
const [failedImageSources, setFailedImageSources] = useState<Set<string>>(
() => new Set(),
);
const [isImageSourcesResolving, setIsImageSourcesResolving] =
useState(false);
const resolvedImageSourceEntriesByType = useMemo(
() =>
buildResolvedMatch3DImageSourceEntriesByType(
@@ -1354,6 +1367,12 @@ export function Match3DRuntimeShell({
useState('');
const [resolvedContainerImageSrc, setResolvedContainerImageSrc] =
useState('');
const [isBackgroundMusicResolving, setIsBackgroundMusicResolving] =
useState(false);
const [isBackgroundImageResolving, setIsBackgroundImageResolving] =
useState(false);
const [isContainerImageResolving, setIsContainerImageResolving] =
useState(false);
const clickSoundByTypeId = useMemo(() => {
if (!run) {
return new Map<string, string>();
@@ -1397,16 +1416,19 @@ export function Match3DRuntimeShell({
const source = backgroundMusicSrc?.trim() ?? '';
if (!source) {
setResolvedBackgroundMusicSrc('');
setIsBackgroundMusicResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(source)) {
setResolvedBackgroundMusicSrc(source);
setIsBackgroundMusicResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundMusicSrc('');
setIsBackgroundMusicResolving(true);
void resolveAssetReadUrl(source, {
signal: controller.signal,
expireSeconds: 300,
@@ -1420,6 +1442,11 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setResolvedBackgroundMusicSrc('');
}
})
.finally(() => {
if (!cancelled) {
setIsBackgroundMusicResolving(false);
}
});
return () => {
@@ -1439,15 +1466,19 @@ export function Match3DRuntimeShell({
useEffect(() => {
if (!backgroundAssetSrc) {
setResolvedBackgroundImageSrc('');
setIsBackgroundImageResolving(false);
return undefined;
}
if (!isGeneratedLegacyPath(backgroundAssetSrc)) {
setResolvedBackgroundImageSrc(backgroundAssetSrc);
setIsBackgroundImageResolving(false);
return undefined;
}
let cancelled = false;
const controller = new AbortController();
setResolvedBackgroundImageSrc('');
setIsBackgroundImageResolving(true);
void resolveAssetReadUrl(backgroundAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -1461,6 +1492,11 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setResolvedBackgroundImageSrc('');
}
})
.finally(() => {
if (!cancelled) {
setIsBackgroundImageResolving(false);
}
});
return () => {
@@ -1475,8 +1511,10 @@ export function Match3DRuntimeShell({
setResolvedContainerImageSrc('');
if (!isGeneratedLegacyPath(containerAssetSrc)) {
setResolvedContainerImageSrc(containerAssetSrc);
setIsContainerImageResolving(false);
return undefined;
}
setIsContainerImageResolving(true);
void resolveAssetReadUrl(containerAssetSrc, {
signal: controller.signal,
expireSeconds: 300,
@@ -1494,6 +1532,11 @@ export function Match3DRuntimeShell({
: MATCH3D_CONTAINER_REFERENCE_SRC,
);
}
})
.finally(() => {
if (!cancelled) {
setIsContainerImageResolving(false);
}
});
return () => {
@@ -1503,7 +1546,7 @@ export function Match3DRuntimeShell({
}, [containerAssetSrc]);
useEffect(() => {
const rawSources = resolveMatch3DImageReadUrlSources(imageSourcesByType);
const rawSources = imageReadUrlSources;
if (rawSources.length <= 0) {
setResolvedImageSources((current) =>
current.size > 0 ? new Map() : current,
@@ -1511,6 +1554,7 @@ export function Match3DRuntimeShell({
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
setIsImageSourcesResolving(false);
return undefined;
}
@@ -1519,6 +1563,7 @@ export function Match3DRuntimeShell({
setFailedImageSources((current) =>
current.size > 0 ? new Set() : current,
);
setIsImageSourcesResolving(false);
return undefined;
}
@@ -1535,6 +1580,7 @@ export function Match3DRuntimeShell({
return retained;
});
setFailedImageSources(new Set());
setIsImageSourcesResolving(true);
void Promise.all(
rawSources.map(async (source) => {
if (nextSources.has(source)) {
@@ -1565,13 +1611,18 @@ export function Match3DRuntimeShell({
if (!cancelled) {
setFailedImageSources(new Set(rawSources));
}
})
.finally(() => {
if (!cancelled) {
setIsImageSourcesResolving(false);
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [imageReadUrlCacheKey, imageSourcesByType]);
}, [imageReadUrlCacheKey, imageReadUrlSources]);
useEffect(() => {
const rawSources = alphaHitMaskCacheKey
@@ -1925,6 +1976,44 @@ export function Match3DRuntimeShell({
<main
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#16221f] text-white`}
>
<RuntimeResourcePendingMarker
source={backgroundMusicSrc}
kind="audio"
isPending={isBackgroundMusicResolving}
/>
<RuntimeResourcePendingMarker
source={backgroundAssetSrc}
kind="image"
isPending={isBackgroundImageResolving}
/>
<RuntimeResourcePendingMarker
source={containerAssetSrc}
kind="image"
isPending={isContainerImageResolving}
/>
<RuntimeResourcePendingMarker
source={uiSpritesheetSource}
kind="image"
isPending={isUiSpritesheetResolving}
/>
<RuntimeResourcePendingMarker
source={itemSpritesheetSource}
kind="image"
isPending={isItemSpritesheetResolving}
/>
{imageReadUrlSources.map((source) => (
<RuntimeResourcePendingMarker
key={`match3d-runtime-resource:${source}`}
source={source}
kind="image"
isPending={
isImageSourcesResolving &&
isGeneratedLegacyPath(source) &&
!resolvedImageSources.has(source) &&
!failedImageSources.has(source)
}
/>
))}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
{resolvedBackgroundImageSrc ? (
<img

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from 'vitest';
import {
resolveMiniGameGenerationViewBusy,
resolveMiniGameGenerationProgressTickState,
} from './PlatformEntryFlowShellImpl';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => {
test('returns jump hop and wooden fish generation states for progress ticking', () => {
const jumpHopState = createMiniGameDraftGenerationState('jump-hop');
const woodenFishState = createMiniGameDraftGenerationState('wooden-fish');
expect(
resolveMiniGameGenerationProgressTickState('jump-hop-generating', {
'jump-hop': jumpHopState,
}),
).toBe(jumpHopState);
expect(
resolveMiniGameGenerationProgressTickState('wooden-fish-generating', {
'wooden-fish': woodenFishState,
}),
).toBe(woodenFishState);
});
test('returns null when the stage does not need generation ticking', () => {
expect(
resolveMiniGameGenerationProgressTickState('platform', {
'jump-hop': createMiniGameDraftGenerationState('jump-hop'),
}),
).toBeNull();
});
});
describe('resolveMiniGameGenerationViewBusy', () => {
test('敲木鱼恢复生成中草稿时继续隐藏重新生成按钮', () => {
const woodenFishGeneratingState =
createMiniGameDraftGenerationState('wooden-fish');
expect(
resolveMiniGameGenerationViewBusy(false, woodenFishGeneratingState),
).toBe(true);
});
test('生成态结束后只保留真实 busy', () => {
const woodenFishReadyState = resolveFinishedMiniGameDraftGenerationState(
createMiniGameDraftGenerationState('wooden-fish'),
'ready',
);
expect(resolveMiniGameGenerationViewBusy(false, woodenFishReadyState)).toBe(
false,
);
expect(resolveMiniGameGenerationViewBusy(true, woodenFishReadyState)).toBe(
true,
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,11 @@ import {
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isCustomWorldGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isPuzzleClearGalleryEntry,
isWoodenFishGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformWorkAuthorDisplayName,
resolvePlatformPublicWorkCode,
@@ -57,9 +61,18 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if (isPuzzleClearGalleryEntry(entry)) {
return '拼消消';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
if (isJumpHopGalleryEntry(entry)) {
return '跳一跳';
}
if (isWoodenFishGalleryEntry(entry)) {
return '敲木鱼';
}
if ('sourceType' in entry && entry.sourceType === 'match3d') {
return '抓大鹅';
}
@@ -75,7 +88,11 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}
return 'RPG';
if (isCustomWorldGalleryEntry(entry)) {
return 'RPG';
}
throw new Error('未知公开作品类型。');
}
function getAuthorAvatarLabel(authorDisplayName: string) {

View File

@@ -1,9 +1,19 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type {
BarkBattleDraftConfig,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
buildBarkBattleDraftConfigFromWorkSummary,
buildBarkBattlePublishedConfigFromDraft,
buildBarkBattlePublishedConfigFromWork,
buildBarkBattlePublishSnapshot,
mergeBarkBattlePublishedConfigAssets,
mergeBarkBattleWorksByWorkId,
mergeBarkBattleWorkSummary,
resolveBarkBattleDraftGenerationStatus,
shouldPreserveLocalBarkBattleWorkOnRefresh,
} from './barkBattleWorkCache';
@@ -20,6 +30,7 @@ function buildBarkBattleWork(
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
@@ -34,6 +45,29 @@ function buildBarkBattleWork(
};
}
function buildBarkBattleDraft(
overrides: Partial<BarkBattleDraftConfig> = {},
): BarkBattleDraftConfig {
return {
draftId: 'bark-battle-draft-1',
workId: 'BB-cache-race-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:00:00.000Z',
...overrides,
};
}
test('preserves local published bark battle when refresh only returns same work draft', () => {
const published = buildBarkBattleWork({
status: 'published',
@@ -106,3 +140,124 @@ test('preserves local ready bark battle draft when refresh has not returned it y
expect(merged[0]?.generationStatus).toBe('ready');
});
test('resolves bark battle draft generation status from required images', () => {
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }),
false,
),
).toBe('pending_assets');
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ opponentCharacterImageSrc: '' }),
true,
),
).toBe('partial_failed');
expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe(
'ready',
);
});
test('builds draft runtime config with stable defaults', () => {
const config = buildBarkBattlePublishedConfigFromDraft(
buildBarkBattleDraft({
workId: undefined,
configVersion: undefined,
rulesetVersion: undefined,
}),
);
expect(config.workId).toBe('bark-battle-draft-1');
expect(config.draftId).toBe('bark-battle-draft-1');
expect(config.configVersion).toBe(1);
expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1');
expect(config.playTypeId).toBe('bark-battle');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
});
test('builds work runtime config with publishedAt fallback', () => {
const config = buildBarkBattlePublishedConfigFromWork(
buildBarkBattleWork({ publishedAt: null }),
);
expect(config.workId).toBe('BB-cache-race-12345678');
expect(config.description).toBe('测试声浪赛');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png');
});
test('builds draft config from work summary with stable defaults', () => {
const config = buildBarkBattleDraftConfigFromWorkSummary(
buildBarkBattleWork({
draftId: null,
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
}),
);
expect(config).toMatchObject({
draftId: 'BB-cache-race-12345678',
workId: 'BB-cache-race-12345678',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-21T10:00:00.000Z',
});
expect(config.playerCharacterImageSrc).toBeUndefined();
expect(config.opponentCharacterImageSrc).toBeUndefined();
expect(config.uiBackgroundImageSrc).toBeUndefined();
});
test('builds publish snapshot without empty asset fields', () => {
const snapshot = buildBarkBattlePublishSnapshot(
buildBarkBattleDraft({
playerCharacterImageSrc: '',
opponentCharacterImageSrc: undefined,
}),
);
expect(snapshot).not.toHaveProperty('playerCharacterImageSrc');
expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc');
expect(snapshot.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});
test('merges draft assets into published config when publish response omits them', () => {
const draft = buildBarkBattleDraft();
const published: BarkBattlePublishedConfig = {
workId: 'BB-cache-race-12345678',
draftId: 'bark-battle-draft-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: '2026-05-21T10:01:00.000Z',
};
const merged = mergeBarkBattlePublishedConfigAssets(published, draft);
expect(merged.playerCharacterImageSrc).toBe(
'/generated-bark-battle/player.png',
);
expect(merged.opponentCharacterImageSrc).toBe(
'/generated-bark-battle/opponent.png',
);
expect(merged.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});

View File

@@ -1,6 +1,8 @@
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
@@ -36,6 +38,132 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary)
);
}
export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) {
return Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
);
}
export function resolveBarkBattleDraftGenerationStatus(
draft: BarkBattleDraftConfig,
partialFailed: boolean,
): BarkBattleGenerationStatus {
if (hasBarkBattleDraftRequiredImages(draft)) {
return 'ready';
}
return partialFailed ? 'partial_failed' : 'pending_assets';
}
export function buildBarkBattlePublishedConfigFromDraft(
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
workId: draft.workId ?? draft.draftId,
draftId: draft.draftId,
configVersion: draft.configVersion ?? 1,
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
playerCharacterImageSrc: draft.playerCharacterImageSrc,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
difficultyPreset: draft.difficultyPreset,
updatedAt: draft.updatedAt,
publishedAt: draft.updatedAt,
};
}
export function buildBarkBattlePublishSnapshot(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
difficultyPreset: draft.difficultyPreset,
};
}
export function mergeBarkBattlePublishedConfigAssets(
published: BarkBattlePublishedConfig,
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
...published,
playerCharacterImageSrc:
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
opponentCharacterImageSrc:
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
uiBackgroundImageSrc:
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
};
}
export function buildBarkBattlePublishedConfigFromWork(
work: BarkBattleWorkSummary,
): BarkBattlePublishedConfig {
return {
workId: work.workId,
draftId: work.draftId ?? null,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
updatedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
};
}
export function buildBarkBattleDraftConfigFromWorkSummary(
work: BarkBattleWorkSummary,
): BarkBattleDraftConfig {
return {
draftId: work.draftId ?? work.workId,
workId: work.workId,
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: work.updatedAt,
};
}
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
item: BarkBattleWorkSummary,
refreshed: readonly BarkBattleWorkSummary[],
@@ -85,11 +213,7 @@ export function buildBarkBattleWorkSummaryFromDraft(
difficultyPreset: draft.difficultyPreset,
status: 'draft',
generationStatus,
publishReady: Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
),
publishReady: hasBarkBattleDraftRequiredImages(draft),
playCount: 0,
updatedAt: draft.updatedAt,
publishedAt: null,

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';
import {
type PlatformCreationLaunchTarget,
resolvePlatformCreationLaunchIntent,
} from './platformCreationLaunchModel';
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
describe('platformCreationLaunchModel', () => {
test('keeps airp as a placeholder noop before prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'airp',
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
});
});
test('blocks hidden baby object match after prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'baby-object-match',
isBabyObjectMatchVisible: false,
}),
).toEqual({
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
});
});
test('resolves known creation launch targets', () => {
const targets: PlatformCreationLaunchTarget[] = [
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
];
targets.forEach((target) => {
expect(
resolvePlatformCreationLaunchIntent({
type: target,
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'launch',
shouldPrepare: true,
target,
});
});
});
test('keeps unknown creation type as a prepared noop', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'unknown-template' as never,
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
});
});
});

View File

@@ -0,0 +1,87 @@
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
export type PlatformCreationLaunchTarget =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'jump-hop'
| 'wooden-fish'
| 'puzzle'
| 'bark-battle'
| 'visual-novel'
| 'baby-object-match';
export type PlatformCreationLaunchIntent =
| {
type: 'noop';
shouldPrepare: false;
reason: 'placeholder';
}
| {
type: 'noop';
shouldPrepare: true;
reason: 'unknown';
}
| {
type: 'blocked';
shouldPrepare: true;
message: string;
}
| {
type: 'launch';
shouldPrepare: true;
target: PlatformCreationLaunchTarget;
};
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
]);
export function resolvePlatformCreationLaunchIntent(params: {
type: PlatformCreationTypeId;
isBabyObjectMatchVisible: boolean;
}): PlatformCreationLaunchIntent {
if (params.type === 'airp') {
return {
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
};
}
if (
params.type === 'baby-object-match' &&
!params.isBabyObjectMatchVisible
) {
return {
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
};
}
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
return {
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
};
}
return {
type: 'launch',
shouldPrepare: true,
target: params.type as PlatformCreationLaunchTarget,
};
}

View File

@@ -0,0 +1,498 @@
import { describe, expect, test } from 'vitest';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import {
buildBabyObjectMatchCreationUrlState,
buildBarkBattleCreationUrlState,
buildBigFishCreationUrlState,
buildJumpHopCreationUrlState,
buildMatch3DCreationUrlState,
buildPuzzleCreationUrlState,
buildPuzzleDraftRuntimeUrlState,
buildPuzzlePublishedRuntimeUrlState,
buildPuzzleRuntimeUrlStateKey,
buildSquareHoleCreationUrlState,
buildVisualNovelCreationUrlState,
buildWoodenFishCreationUrlState,
hasCreationUrlStateValue,
hasPuzzleRuntimeUrlStateValue,
matchesBabyObjectMatchCreationUrlRestoreTarget,
matchesBarkBattleCreationUrlRestoreTarget,
matchesBigFishCreationUrlRestoreTarget,
matchesSessionProfileWorkCreationUrlRestoreTarget,
matchesVisualNovelCreationUrlRestoreTarget,
normalizeCreationUrlValue,
resolveCreationUrlRestoreTarget,
resolveInitialCreationUrlRestoreDecision,
resolveJumpHopCreationUrlRestoreStage,
resolveWoodenFishCreationUrlRestoreStage,
} from './platformCreationUrlStateModel';
describe('platformCreationUrlStateModel', () => {
test('normalizes private creation url state values', () => {
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
expect(normalizeCreationUrlValue(' ')).toBeNull();
expect(
hasCreationUrlStateValue({
sessionId: ' ',
profileId: null,
draftId: undefined,
workId: 'work-1',
}),
).toBe(true);
expect(hasCreationUrlStateValue({})).toBe(false);
});
test('resolves initial creation url restore readiness', () => {
const readyParams = {
handled: false,
pathname: '/creation/puzzle/result',
state: { sessionId: 'puzzle-session-1' },
isLoadingPlatform: false,
canReadProtectedData: true,
};
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
handled: true,
}),
).toEqual({ type: 'skip' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
pathname: '/works/detail',
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
state: {},
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
isLoadingPlatform: true,
}),
).toEqual({ type: 'wait' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
canReadProtectedData: false,
}),
).toEqual({ type: 'wait' });
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
type: 'restore',
});
});
test('resolves supported creation url restore targets from paths', () => {
const state = {
sessionId: ' session-1 ',
profileId: ' profile-1 ',
draftId: ' draft-1 ',
workId: ' work-1 ',
};
const cases = [
['/creation/big-fish/result', 'big-fish'],
['/creation/match3d/result', 'match3d'],
['/creation/square-hole/result', 'square-hole'],
['/creation/puzzle/result', 'puzzle'],
['/creation/visual-novel/result', 'visual-novel'],
['/creation/bark-battle/result', 'bark-battle'],
['/creation/baby-object-match/result', 'baby-object-match'],
['/creation/jump-hop/result', 'jump-hop'],
['/creation/wooden-fish/result', 'wooden-fish'],
] as const;
cases.forEach(([pathname, kind]) => {
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
kind,
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
isGeneratingPath: false,
});
});
});
test('normalizes creation url restore target values and generating paths', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
sessionId: ' ',
profileId: ' jump-profile-1 ',
draftId: undefined,
workId: null,
}),
).toEqual({
kind: 'jump-hop',
sessionId: null,
profileId: 'jump-profile-1',
draftId: null,
workId: null,
isGeneratingPath: true,
});
});
test('derives big fish restore session from work id when needed', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
workId: 'big-fish-work-river',
}),
).toEqual({
kind: 'big-fish',
sessionId: null,
profileId: null,
draftId: null,
workId: 'big-fish-work-river',
isGeneratingPath: false,
bigFishSessionId: 'river',
});
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
sessionId: 'big-fish-session-carp',
workId: 'big-fish-work-river',
}),
).toMatchObject({
kind: 'big-fish',
bigFishSessionId: 'big-fish-session-carp',
});
});
test('keeps unsupported creation paths without a concrete restore target', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
sessionId: 'rpg-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
sessionId: 'unknown-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
sessionId: 'big-fish-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/works/detail', {
workId: 'work-1',
}),
).toBeNull();
});
test('matches restore targets against work and draft identities', () => {
const bigFishTarget = resolveCreationUrlRestoreTarget(
'/creation/big-fish/result',
{
workId: 'big-fish-work-river',
},
);
expect(bigFishTarget?.kind).toBe('big-fish');
if (bigFishTarget?.kind !== 'big-fish') {
throw new Error('big fish target expected');
}
expect(
matchesBigFishCreationUrlRestoreTarget(
{ sourceSessionId: 'river' },
bigFishTarget,
),
).toBe(true);
expect(
matchesBigFishCreationUrlRestoreTarget(
{ workId: 'big-fish-work-river' },
bigFishTarget,
),
).toBe(true);
const target = {
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
};
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: 'session-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ workId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesVisualNovelCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ draftId: 'draft-1' },
target,
),
).toBe(true);
expect(
matchesBabyObjectMatchCreationUrlRestoreTarget(
{ profileId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: null, profileId: null, workId: null },
{ sessionId: null, profileId: null, workId: null },
),
).toBe(false);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ workId: null, draftId: null },
{ workId: null, draftId: null },
),
).toBe(false);
});
test('resolves work backed restore stages', () => {
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-generating');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-result');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: false,
}),
).toBe('jump-hop-workspace');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-generating');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-result');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
}),
).toBe('wooden-fish-workspace');
});
test('builds creation restore state for core session based plays', () => {
expect(
buildBigFishCreationUrlState({
sessionId: ' big-fish-session-1 ',
} as BigFishSessionSnapshotResponse),
).toEqual({
sessionId: 'big-fish-session-1',
workId: 'big-fish-work-big-fish-session-1',
});
expect(
buildMatch3DCreationUrlState({
sessionId: 'match3d-session-1',
draft: { profileId: 'match3d-profile-draft' },
} as Match3DAgentSessionSnapshot),
).toEqual({
sessionId: 'match3d-session-1',
profileId: 'match3d-profile-draft',
workId: 'match3d-profile-draft',
});
expect(
buildSquareHoleCreationUrlState({
sessionId: 'square-session-1',
publishedProfileId: 'square-profile-published',
} as SquareHoleSessionSnapshot),
).toEqual({
sessionId: 'square-session-1',
profileId: 'square-profile-published',
workId: 'square-profile-published',
});
expect(
buildVisualNovelCreationUrlState({
sessionId: 'visual-session-1',
draft: { profileId: 'visual-profile-1' },
} as VisualNovelAgentSessionSnapshot),
).toEqual({
sessionId: 'visual-session-1',
profileId: 'visual-profile-1',
workId: 'visual-profile-1',
});
});
test('builds puzzle creation and runtime query state', () => {
expect(
buildPuzzleCreationUrlState({
sessionId: 'puzzle-session-ocean',
} as PuzzleAgentSessionSnapshot),
).toEqual({
sessionId: 'puzzle-session-ocean',
profileId: 'puzzle-profile-ocean',
workId: 'puzzle-work-ocean',
});
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
buildPuzzleWork({
profileId: 'puzzle-profile-ocean',
sourceSessionId: null,
}),
'level-2',
);
expect(draftRuntime).toEqual({
mode: 'draft',
runtimeSessionId: 'puzzle-session-ocean',
runtimeProfileId: 'puzzle-profile-ocean',
runtimeLevelId: 'level-2',
});
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
);
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
);
expect(publishedRuntime.mode).toBe('published');
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
});
test('builds creation state for work backed plays with work id priority', () => {
expect(
buildJumpHopCreationUrlState({
session: {
sessionId: 'jump-session-1',
draft: { profileId: 'jump-profile-draft' },
} as JumpHopSessionSnapshotResponse,
work: {
summary: {
profileId: 'jump-profile-work',
workId: 'jump-work-1',
},
} as JumpHopWorkProfileResponse,
}),
).toEqual({
sessionId: 'jump-session-1',
profileId: 'jump-profile-work',
workId: 'jump-work-1',
});
expect(
buildWoodenFishCreationUrlState({
session: {
sessionId: 'wood-session-1',
draft: { profileId: 'wood-profile-draft' },
} as WoodenFishSessionSnapshotResponse,
work: {
summary: {
profileId: 'wood-profile-work',
workId: 'wood-work-1',
},
} as WoodenFishWorkProfileResponse,
}),
).toEqual({
sessionId: 'wood-session-1',
profileId: 'wood-profile-work',
draftId: 'wood-profile-work',
workId: 'wood-work-1',
});
});
test('builds creation state for draft backed local plays', () => {
expect(
buildBarkBattleCreationUrlState({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
} as BarkBattleDraftConfig),
).toEqual({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
});
expect(
buildBabyObjectMatchCreationUrlState({
draftId: 'baby-draft-1',
profileId: 'baby-profile-1',
} as BabyObjectMatchDraft),
).toEqual({
profileId: 'baby-profile-1',
draftId: 'baby-draft-1',
workId: 'baby-profile-1',
});
});
});
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-base',
profileId: 'puzzle-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-base',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾港口拼图。',
levelName: '潮雾拼图',
summary: '潮雾港口拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}

View File

@@ -0,0 +1,450 @@
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type {
PuzzleClearSessionSnapshotResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import {
type CreationUrlState,
isCreationRestorePath,
} from '../../services/creationUrlState';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import type { SelectionStage } from './platformEntryTypes';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
buildPuzzleSessionIdFromProfileId,
} from './platformPuzzleIdentityModel';
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
export function normalizeCreationUrlValue(value: string | null | undefined) {
return value?.trim() || null;
}
export function hasCreationUrlStateValue(state: CreationUrlState) {
return Boolean(
normalizeCreationUrlValue(state.sessionId) ||
normalizeCreationUrlValue(state.profileId) ||
normalizeCreationUrlValue(state.draftId) ||
normalizeCreationUrlValue(state.workId),
);
}
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
return Boolean(
normalizeCreationUrlValue(state.runtimeSessionId) ||
normalizeCreationUrlValue(state.runtimeProfileId) ||
normalizeCreationUrlValue(state.runtimeLevelId) ||
normalizeCreationUrlValue(state.publicWorkCode) ||
normalizeCreationUrlValue(state.mode),
);
}
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
return [
normalizeCreationUrlValue(state.mode),
normalizeCreationUrlValue(state.runtimeSessionId),
normalizeCreationUrlValue(state.runtimeProfileId),
normalizeCreationUrlValue(state.runtimeLevelId),
normalizeCreationUrlValue(state.publicWorkCode),
].join('|');
}
export type CreationUrlRestoreTargetKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'puzzle'
| 'visual-novel'
| 'bark-battle'
| 'baby-object-match'
| 'jump-hop'
| 'wooden-fish';
type CreationUrlRestoreTargetBase = {
kind: CreationUrlRestoreTargetKind;
sessionId: string | null;
profileId: string | null;
draftId: string | null;
workId: string | null;
isGeneratingPath: boolean;
};
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: 'big-fish';
bigFishSessionId: string | null;
};
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
};
export type CreationUrlRestoreTarget =
| BigFishCreationUrlRestoreTarget
| NonBigFishCreationUrlRestoreTarget;
export type BigFishRestoreWorkIdentity = {
sourceSessionId?: string | null;
workId?: string | null;
};
export type SessionProfileWorkRestoreIdentity = {
sourceSessionId?: string | null;
profileId?: string | null;
workId?: string | null;
};
export type ProfileRestoreWorkIdentity = {
profileId?: string | null;
};
export type BarkBattleRestoreWorkIdentity = {
workId?: string | null;
draftId?: string | null;
};
export type BabyObjectMatchRestoreDraftIdentity = {
profileId?: string | null;
draftId?: string | null;
};
const CREATION_URL_RESTORE_TARGET_ROUTES = [
['/creation/big-fish', 'big-fish'],
['/creation/match3d', 'match3d'],
['/creation/square-hole', 'square-hole'],
['/creation/puzzle', 'puzzle'],
['/creation/visual-novel', 'visual-novel'],
['/creation/bark-battle', 'bark-battle'],
['/creation/baby-object-match', 'baby-object-match'],
['/creation/jump-hop', 'jump-hop'],
['/creation/wooden-fish', 'wooden-fish'],
] as const satisfies readonly (readonly [
string,
CreationUrlRestoreTargetKind,
])[];
export function resolveCreationUrlRestoreTarget(
pathname: string | undefined,
state: CreationUrlState,
): CreationUrlRestoreTarget | null {
const path = pathname?.trim() ?? '';
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
path === prefix || path.startsWith(`${prefix}/`),
);
if (!route) {
return null;
}
const kind = route[1];
const sessionId = normalizeCreationUrlValue(state.sessionId);
const profileId = normalizeCreationUrlValue(state.profileId);
const draftId = normalizeCreationUrlValue(state.draftId);
const workId = normalizeCreationUrlValue(state.workId);
const base = {
kind,
sessionId,
profileId,
draftId,
workId,
isGeneratingPath: path.includes('/generating'),
};
if (kind === 'big-fish') {
return {
...base,
kind,
bigFishSessionId:
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
};
}
return base as NonBigFishCreationUrlRestoreTarget;
}
function matchesRestoreValue(
itemValue: string | null | undefined,
targetValue: string | null,
) {
return Boolean(targetValue && itemValue === targetValue);
}
export function matchesBigFishCreationUrlRestoreTarget(
item: BigFishRestoreWorkIdentity,
target: BigFishCreationUrlRestoreTarget,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
item: SessionProfileWorkRestoreIdentity,
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesVisualNovelCreationUrlRestoreTarget(
item: ProfileRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
) {
return matchesRestoreValue(item.profileId, target.profileId);
}
export function matchesBarkBattleCreationUrlRestoreTarget(
item: BarkBattleRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
) {
return (
matchesRestoreValue(item.workId, target.workId) ||
matchesRestoreValue(item.draftId, target.draftId)
);
}
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
item: BabyObjectMatchRestoreDraftIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
) {
return (
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.draftId, target.draftId) ||
matchesRestoreValue(item.profileId, target.workId)
);
}
export function resolveJumpHopCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
hasRestoredWork: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'jump-hop-generating';
}
return params.hasRestoredDraft || params.hasRestoredWork
? 'jump-hop-result'
: 'jump-hop-workspace';
}
export function resolveWoodenFishCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'wooden-fish-generating';
}
return params.hasRestoredDraft
? 'wooden-fish-result'
: 'wooden-fish-workspace';
}
export type InitialCreationUrlRestoreDecision =
| { type: 'skip' }
| { type: 'mark-handled' }
| { type: 'wait' }
| { type: 'restore' };
export function resolveInitialCreationUrlRestoreDecision(params: {
handled: boolean;
pathname: string | undefined;
state: CreationUrlState;
isLoadingPlatform: boolean;
canReadProtectedData: boolean;
}): InitialCreationUrlRestoreDecision {
if (params.handled) {
return { type: 'skip' };
}
if (
!isCreationRestorePath(params.pathname) ||
!hasCreationUrlStateValue(params.state)
) {
return { type: 'mark-handled' };
}
if (params.isLoadingPlatform || !params.canReadProtectedData) {
return { type: 'wait' };
}
return { type: 'restore' };
}
export function buildBigFishCreationUrlState(
session: BigFishSessionSnapshotResponse | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
return {
sessionId,
workId: sessionId ? `big-fish-work-${sessionId}` : null,
};
}
export function buildMatch3DCreationUrlState(
session: Match3DAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildSquareHoleCreationUrlState(
session: SquareHoleSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildPuzzleCreationUrlState(
session: PuzzleAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
);
return {
sessionId,
profileId,
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
};
}
export function buildPuzzleDraftRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
const runtimeSessionId =
normalizeCreationUrlValue(item.sourceSessionId) ??
buildPuzzleSessionIdFromProfileId(item.profileId);
return {
mode: 'draft',
runtimeSessionId,
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
};
}
export function buildPuzzlePublishedRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
return {
mode: 'published',
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
};
}
export function buildVisualNovelCreationUrlState(
session: VisualNovelAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
return {
sessionId,
profileId,
workId: profileId ?? sessionId,
};
}
export function buildJumpHopCreationUrlState(params: {
session?: JumpHopSessionSnapshotResponse | null;
work?: JumpHopWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
return {
sessionId,
profileId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildPuzzleClearCreationUrlState(params: {
session?: PuzzleClearSessionSnapshotResponse | null;
work?: PuzzleClearWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
return {
sessionId,
profileId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildWoodenFishCreationUrlState(params: {
session?: WoodenFishSessionSnapshotResponse | null;
work?: WoodenFishWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
const draftId = profileId ?? sessionId;
return {
sessionId,
profileId,
draftId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildBarkBattleCreationUrlState(
draft: BarkBattleDraftConfig | null,
): CreationUrlState {
return {
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
};
}
export function buildBabyObjectMatchCreationUrlState(
draft: BabyObjectMatchDraft | null,
): CreationUrlState {
const profileId = normalizeCreationUrlValue(draft?.profileId);
return {
profileId,
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: profileId,
};
}

View File

@@ -0,0 +1,334 @@
import { describe, expect, test } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
describe('platformCreationWorkDeleteFlow', () => {
test('resolves RPG library delete confirmation without draft notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg-library',
entry: {
profileId: 'rpg-profile',
worldName: '潮雾列岛',
},
}),
).toEqual({
id: 'rpg-profile',
title: '潮雾列岛',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: [],
});
});
test('resolves RPG work delete detail and notice keys by work status', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork(),
}),
).toEqual({
id: 'rpg-work',
title: 'RPG 草稿',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork({ status: 'published' }),
}).detail,
).toBe('删除后会从你的作品列表和公开广场中移除。');
});
test('resolves mini game delete models with shared public and private detail copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'big-fish',
work: buildBigFishWork({ status: 'published' }),
}),
).toMatchObject({
id: 'big-fish-work',
title: '大鱼作品',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'match3d',
work: buildMatch3DWork(),
}).detail,
).toBe('删除后会从你的作品列表中移除。');
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'square-hole',
work: buildSquareHoleWork({ publicationStatus: 'published' }),
}).noticeKeys,
).toEqual([
'square-hole:square-hole-work',
'square-hole:square-hole-profile',
'square-hole:square-hole-session',
]);
});
test('resolves puzzle title fallback and stable result notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({
workTitle: ' ',
levelName: ' 雾港第一关 ',
sourceSessionId: 'puzzle-session-ocean',
}),
}),
).toEqual({
id: 'puzzle-work',
title: '雾港第一关',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: [
'puzzle:puzzle-work',
'puzzle:puzzle-profile',
'puzzle:puzzle-session-ocean',
'puzzle:puzzle-work-ocean',
'puzzle:puzzle-profile-ocean',
],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
}).title,
).toBe('未命名拼图');
});
test('resolves visual novel and baby object match special delete copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'visual-novel',
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
}),
).toEqual({
id: 'visual-novel-profile',
title: '未命名视觉小说',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['visual-novel:visual-novel-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'baby-object-match',
work: buildBabyObjectMatchDraft({
workTitle: ' ',
publicationStatus: 'published',
}),
}),
).toEqual({
id: 'baby-profile',
title: '宝贝识物',
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
noticeKeys: [
'baby-object-match:baby-profile',
'baby-object-match:baby-draft',
],
});
});
});
function buildRpgWork(
overrides: Partial<CustomWorldWorkSummary> = {},
): CustomWorldWorkSummary {
return {
workId: 'rpg-work',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 草稿',
subtitle: '待完善',
summary: 'RPG 摘要。',
coverImageSrc: null,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
stage: 'draft',
stageLabel: '草稿',
playableNpcCount: 1,
landmarkCount: 1,
sessionId: 'rpg-session',
profileId: 'rpg-profile',
canResume: true,
canEnterWorld: false,
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work',
sourceSessionId: 'big-fish-session',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '大鱼作品',
subtitle: '大鱼吃小鱼',
summary: '大鱼摘要。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levelCount: 1,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: true,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
workTitle: '拼图作品',
workDescription: '拼图摘要。',
levelName: '拼图第一关',
summary: '拼图摘要。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '糖果厨房',
summary: '抓大鹅摘要。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work',
profileId: 'square-hole-profile',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session',
gameName: '方洞作品',
themeText: '图形',
twistRule: '反直觉',
summary: '方洞摘要。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '背景',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 8,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile',
ownerUserId: 'user-1',
title: '视觉小说作品',
description: '视觉小说摘要。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
return {
draftId: 'baby-draft',
profileId: 'baby-profile',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物作品',
workDescription: '宝贝识物摘要。',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'apple',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'banana',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [],
publicationStatus: 'draft',
createdAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}

View File

@@ -0,0 +1,288 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
collectDraftNoticeKeys,
} from './platformDraftGenerationShelfModel';
const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。';
const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。';
const EDUTAINMENT_PUBLIC_DELETE_DETAIL =
'删除后会从你的作品列表和寓教于乐板块中移除。';
export type PlatformCreationWorkDeleteConfirmationModel = {
id: string;
title: string;
detail: string;
noticeKeys: string[];
};
export type PlatformCreationWorkDeleteInput =
| {
kind: 'rpg-library';
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>;
}
| {
kind: 'rpg';
work: Pick<
CustomWorldWorkSummary,
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
>;
}
| {
kind: 'big-fish';
work: Pick<
BigFishWorkSummary,
'workId' | 'title' | 'status' | 'sourceSessionId'
>;
}
| {
kind: 'puzzle';
work: Pick<
PuzzleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'workTitle'
| 'levelName'
| 'publicationStatus'
>;
}
| {
kind: 'match3d';
work: Pick<
Match3DWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>;
}
| {
kind: 'square-hole';
work: Pick<
SquareHoleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>;
}
| {
kind: 'visual-novel';
work: Pick<
VisualNovelWorkSummary,
'profileId' | 'title' | 'publishStatus'
>;
}
| {
kind: 'baby-object-match';
work: Pick<
BabyObjectMatchDraft,
| 'profileId'
| 'draftId'
| 'workTitle'
| 'templateName'
| 'publicationStatus'
>;
};
export function resolvePlatformCreationWorkDeleteConfirmationModel(
input: PlatformCreationWorkDeleteInput,
): PlatformCreationWorkDeleteConfirmationModel {
switch (input.kind) {
case 'rpg-library':
return resolveRpgLibraryDeleteConfirmationModel(input.entry);
case 'rpg':
return resolveRpgWorkDeleteConfirmationModel(input.work);
case 'big-fish':
return resolveBigFishWorkDeleteConfirmationModel(input.work);
case 'puzzle':
return resolvePuzzleWorkDeleteConfirmationModel(input.work);
case 'match3d':
return resolveMatch3DWorkDeleteConfirmationModel(input.work);
case 'square-hole':
return resolveSquareHoleWorkDeleteConfirmationModel(input.work);
case 'visual-novel':
return resolveVisualNovelWorkDeleteConfirmationModel(input.work);
case 'baby-object-match':
return resolveBabyObjectMatchDeleteConfirmationModel(input.work);
default: {
const exhaustive: never = input;
return exhaustive;
}
}
}
function resolveStatusDeleteDetail(
status: string,
publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL,
) {
return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL;
}
function resolveTrimmedTitle(
value: string | null | undefined,
fallback: string,
) {
const trimmedValue = value?.trim();
return trimmedValue || fallback;
}
function resolveRpgLibraryDeleteConfirmationModel(
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: entry.profileId,
title: entry.worldName,
detail: PUBLIC_GALLERY_DELETE_DETAIL,
noticeKeys: [],
};
}
function resolveRpgWorkDeleteConfirmationModel(
work: Pick<
CustomWorldWorkSummary,
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.title,
detail: resolveStatusDeleteDetail(work.status),
noticeKeys: collectDraftNoticeKeys('rpg', [
work.workId,
work.sessionId,
work.profileId,
]),
};
}
function resolveBigFishWorkDeleteConfirmationModel(
work: Pick<
BigFishWorkSummary,
'workId' | 'title' | 'status' | 'sourceSessionId'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.title,
detail: resolveStatusDeleteDetail(work.status),
noticeKeys: collectDraftNoticeKeys('big-fish', [
work.workId,
work.sourceSessionId,
]),
};
}
function resolvePuzzleWorkDeleteConfirmationModel(
work: Pick<
PuzzleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'workTitle'
| 'levelName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: resolveTrimmedTitle(
work.workTitle,
resolveTrimmedTitle(work.levelName, '未命名拼图'),
),
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('puzzle', [
work.workId,
work.profileId,
work.sourceSessionId,
buildPuzzleResultWorkId(work.sourceSessionId),
buildPuzzleResultProfileId(work.sourceSessionId),
]),
};
}
function resolveMatch3DWorkDeleteConfirmationModel(
work: Pick<
Match3DWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.gameName,
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('match3d', [
work.workId,
work.profileId,
work.sourceSessionId,
]),
};
}
function resolveSquareHoleWorkDeleteConfirmationModel(
work: Pick<
SquareHoleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.gameName,
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('square-hole', [
work.workId,
work.profileId,
work.sourceSessionId,
]),
};
}
function resolveVisualNovelWorkDeleteConfirmationModel(
work: Pick<VisualNovelWorkSummary, 'profileId' | 'title' | 'publishStatus'>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.profileId,
title: work.title || '未命名视觉小说',
detail: resolveStatusDeleteDetail(work.publishStatus),
noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]),
};
}
function resolveBabyObjectMatchDeleteConfirmationModel(
work: Pick<
BabyObjectMatchDraft,
'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.profileId,
title: resolveTrimmedTitle(work.workTitle, work.templateName),
detail: resolveStatusDeleteDetail(
work.publicationStatus,
EDUTAINMENT_PUBLIC_DELETE_DETAIL,
),
noticeKeys: collectDraftNoticeKeys('baby-object-match', [
work.profileId,
work.draftId,
]),
};
}

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from 'vitest';
import {
buildPlatformErrorDialogDismissKey,
buildPlatformTaskCompletionDialogDismissKey,
formatPlatformDialogSource,
isBackgroundGenerationStillRunningMessage,
normalizePlatformDialogMessage,
PLATFORM_TASK_COMPLETION_MESSAGE,
resolveActivePlatformDialog,
resolvePlatformErrorDialog,
} from './platformDialogStateModel';
describe('platformDialogStateModel', () => {
test('normalizes platform dialog messages', () => {
expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败');
expect(normalizePlatformDialogMessage(' ')).toBeNull();
expect(normalizePlatformDialogMessage(null)).toBeNull();
});
test('formats dialog source with optional identity', () => {
expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe(
'拼图草稿 puzzle-session-1',
);
expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿');
});
test('detects background generation still running messages', () => {
expect(
isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'),
).toBe(true);
expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe(
false,
);
});
test('resolves the first non-empty error candidate', () => {
expect(
resolvePlatformErrorDialog([
{
key: 'empty',
source: '空来源',
message: ' ',
},
{
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: ' 素材生成失败。 ',
},
]),
).toEqual({
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
});
expect(
resolvePlatformErrorDialog([
{
key: 'empty',
source: '空来源',
message: null,
},
]),
).toBeNull();
});
test('builds stable dismiss keys for error and completion dialogs', () => {
expect(
buildPlatformErrorDialogDismissKey({
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
}),
).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。');
expect(buildPlatformErrorDialogDismissKey(null)).toBeNull();
expect(
buildPlatformTaskCompletionDialogDismissKey({
key: 'match3d',
source: '抓大鹅草稿 match3d-session-1',
message: PLATFORM_TASK_COMPLETION_MESSAGE,
completedAtMs: null,
}),
).toBe(
`match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`,
);
});
test('hides active dialog when the dismiss key has already been recorded', () => {
const dialog = {
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
};
const dismissKey = buildPlatformErrorDialogDismissKey(dialog);
expect(
resolveActivePlatformDialog(
dialog,
dismissKey,
buildPlatformErrorDialogDismissKey,
),
).toBeNull();
expect(
resolveActivePlatformDialog(
dialog,
'other-dismiss-key',
buildPlatformErrorDialogDismissKey,
),
).toBe(dialog);
});
});

View File

@@ -0,0 +1,85 @@
import type { PlatformErrorDialogPayload } from './PlatformErrorDialog';
import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog';
export type PlatformErrorDialogState = PlatformErrorDialogPayload & {
key: string;
};
export type PlatformTaskFailureDialogState = PlatformErrorDialogState & {
failedAtMs: number;
};
export type PlatformTaskCompletionDialogState =
PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
};
export type PlatformDialogCandidate = {
key: string;
source: string;
message: string | null | undefined;
};
export const PLATFORM_TASK_COMPLETION_MESSAGE =
'生成任务已完成,可以继续查看草稿。';
/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */
export function normalizePlatformDialogMessage(
message: string | null | undefined,
) {
const normalized = message?.trim();
return normalized ? normalized : null;
}
export function formatPlatformDialogSource(label: string, id?: string | null) {
const normalizedId = id?.trim();
return normalizedId ? `${label} ${normalizedId}` : label;
}
export function isBackgroundGenerationStillRunningMessage(message: string) {
return /|||/u.test(message);
}
export function resolvePlatformErrorDialog(
candidates: readonly PlatformDialogCandidate[],
): PlatformErrorDialogState | null {
for (const candidate of candidates) {
const message = normalizePlatformDialogMessage(candidate.message);
if (message) {
return {
key: candidate.key,
source: candidate.source,
message,
};
}
}
return null;
}
export function buildPlatformErrorDialogDismissKey(
error: PlatformErrorDialogState | null,
) {
return error ? `${error.key}:${error.source}:${error.message}` : null;
}
export function buildPlatformTaskCompletionDialogDismissKey(
completion: PlatformTaskCompletionDialogState | null,
) {
return completion
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
: null;
}
export function resolveActivePlatformDialog<TDialog>(
currentDialog: TDialog | null,
dismissedDialogKey: string | null,
buildDismissKey: (dialog: TDialog | null) => string | null,
): TDialog | null {
const currentDialogDismissKey = buildDismissKey(currentDialog);
return currentDialogDismissKey &&
currentDialogDismissKey === dismissedDialogKey
? null
: currentDialog;
}

View File

@@ -0,0 +1,792 @@
import { describe, expect, test } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
import {
buildCreationWorkShelfRuntimeState,
buildPendingPuzzleWorks,
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
collectVisibleDraftNoticeKeys,
createPendingDraftShelfState,
type DraftGenerationNoticeMap,
getGenerationNoticeShelfKeys,
hasUnreadDraftGenerationUpdates,
mergeBigFishWorkSummary,
mergePuzzleWorkSummary,
resolveBigFishDraftOpenIntent,
resolveJumpHopDraftOpenIntent,
resolveMatch3DDraftOpenIntent,
resolvePuzzleDraftOpenIntent,
resolveSquareHoleDraftOpenIntent,
resolveVisualNovelDraftOpenIntent,
resolveWoodenFishDraftOpenIntent,
} from './platformDraftGenerationShelfModel';
describe('platformDraftGenerationShelfModel', () => {
test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
sourceSessionId: null,
publicationStatus: 'published',
}),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
});
test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork(),
notices: {
'puzzle:puzzle-session-base': {
status: 'failed',
seen: false,
message: '首图生成失败。',
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'failed-generation',
source: 'restored',
errorMessage: '首图生成失败。',
});
});
test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork(),
notices: {},
generation: emptyGenerationFacts({
activeSessionId: 'puzzle-session-base',
hasActiveGenerationRunning: true,
}),
}),
).toMatchObject({
type: 'active-generation',
});
});
test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
coverImageSrc: '/media/puzzle-cover.png',
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
const item = buildMatch3DWork({
publicationStatus: 'published',
});
expect(
resolveMatch3DDraftOpenIntent({
item,
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveMatch3DDraftOpenIntent({
item,
notices: {},
forceDraft: true,
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => {
expect(
resolveMatch3DDraftOpenIntent({
item: buildMatch3DWork(),
notices: {
'match3d:match3d-session-base': {
status: 'ready',
seen: false,
},
},
generation: emptyGenerationFacts({
hasBackgroundGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'ready-unread',
});
});
test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => {
expect(
resolveMatch3DDraftOpenIntent({
item: buildMatch3DWork({
generationStatus: 'generating',
}),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
});
test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => {
expect(
resolveBigFishDraftOpenIntent({
item: buildBigFishWork(),
activeSessionId: 'big-fish-session-base',
hasActiveGenerationRunning: true,
}),
).toMatchObject({
type: 'active-generation',
sourceSessionId: 'big-fish-session-base',
});
expect(
resolveBigFishDraftOpenIntent({
item: buildBigFishWork(),
activeSessionId: 'other-session',
hasActiveGenerationRunning: true,
}),
).toMatchObject({
type: 'restore-draft',
sourceSessionId: 'big-fish-session-base',
});
});
test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => {
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork({ publicationStatus: 'published' }),
activeSessionId: null,
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork({ sourceSessionId: null }),
forceDraft: true,
activeSessionId: null,
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'missing-session',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork(),
activeSessionId: 'square-hole-session-base',
hasActiveGenerationRunning: true,
isGenerationReady: false,
}),
).toMatchObject({
type: 'active-generation',
sourceSessionId: 'square-hole-session-base',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork(),
activeSessionId: 'other-session',
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'restore-draft',
shouldClearGenerationState: true,
});
});
test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => {
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork({ publishStatus: 'published' }),
activeSessionId: null,
hasActiveGenerationRunning: false,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'visual-novel-profile-base',
hasActiveGenerationRunning: true,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'active-generation',
profileId: 'visual-novel-profile-base',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'visual-novel-profile-base',
hasActiveGenerationRunning: false,
hasActiveSessionDraft: true,
}),
).toMatchObject({
type: 'current-result',
profileId: 'visual-novel-profile-base',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'other-profile',
hasActiveGenerationRunning: false,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'load-detail',
profileId: 'visual-novel-profile-base',
});
});
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ publicationStatus: 'published' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork(),
notices: {
'jump-hop:jump-hop-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: 'jump-hop-session-base',
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'active-failed-generation',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ generationStatus: 'generating' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ generationStatus: 'generating' }),
notices: {
'jump-hop:jump-hop-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ sourceSessionId: null }),
notices: {
'jump-hop:jump-hop-work-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: null,
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'load-detail',
});
});
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork({
sourceSessionId: null,
generationStatus: 'generating',
}),
notices: {
'wooden-fish:wooden-fish-profile-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: 'wooden-fish-profile-base',
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'active-failed-generation',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork({ generationStatus: 'generating' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork(),
notices: {
'wooden-fish:wooden-fish-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
failureFallbackStage: 'wooden-fish-workspace',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork(),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
failureFallbackStage: 'wooden-fish-generating',
});
});
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
const pending = buildPendingPuzzleWorks(
{
'puzzle-session-ocean': createPendingDraftShelfState(
'failed',
false,
'2026-06-03T08:00:00.000Z',
),
},
[],
);
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject({
workId: 'puzzle-work-ocean',
profileId: 'puzzle-profile-ocean',
sourceSessionId: 'puzzle-session-ocean',
workTitle: '拼图草稿',
summary: '拼图草稿生成失败,可重新打开处理。',
generationStatus: 'failed',
});
});
test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => {
const pending = buildPendingPuzzleWorks(
{
'puzzle-session-ocean': createPendingDraftShelfState(
'generating',
false,
'2026-06-03T08:00:00.000Z',
),
},
[buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })],
);
expect(pending).toEqual([]);
});
test('mergePuzzleWorkSummary only replaces the matching profile', () => {
const current = buildPuzzleWork({
profileId: 'puzzle-profile-1',
workTitle: '旧拼图',
});
const updated = buildPuzzleWork({
profileId: 'puzzle-profile-1',
workTitle: '新拼图',
});
const other = buildPuzzleWork({
profileId: 'puzzle-profile-2',
workTitle: '别的拼图',
});
expect(mergePuzzleWorkSummary(current, updated)).toBe(updated);
expect(mergePuzzleWorkSummary(current, other)).toBe(current);
});
test('mergeBigFishWorkSummary only replaces the matching source session', () => {
const current = buildBigFishWork({
sourceSessionId: 'big-fish-session-1',
title: '旧大鱼',
});
const updated = buildBigFishWork({
sourceSessionId: 'big-fish-session-1',
title: '新大鱼',
});
const other = buildBigFishWork({
sourceSessionId: 'big-fish-session-2',
title: '别的大鱼',
});
expect(mergeBigFishWorkSummary(current, updated)).toBe(updated);
expect(mergeBigFishWorkSummary(current, other)).toBe(current);
});
test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => {
const [item] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
buildPuzzleWork({
workId: 'puzzle-work-empty',
profileId: 'puzzle-profile-empty',
sourceSessionId: 'puzzle-session-empty',
workTitle: '',
workDescription: '',
levelName: '',
summary: '正在生成拼图草稿。',
generationStatus: 'generating',
}),
],
});
expect(item).toBeTruthy();
const noticeKeys = getGenerationNoticeShelfKeys(item!);
const notices = Object.fromEntries(
noticeKeys.map((key) => [
key,
{ status: 'failed', seen: false },
]),
) as DraftGenerationNoticeMap;
const state = buildCreationWorkShelfRuntimeState({
item: item!,
notices,
pendingShelfItems: {
puzzle: {
'puzzle-session-empty': createPendingDraftShelfState(
'failed',
false,
'2026-06-03T08:00:00.000Z',
{ summary: '图片生成超时,可重新打开处理。' },
),
},
},
});
expect(state).toMatchObject({
isGenerating: false,
hasGenerationFailure: true,
generationFailureSummary: '拼图草稿生成失败,可重新打开处理。',
hasUnreadUpdate: false,
suppressPersistedGenerating: true,
titleOverride: '拼图草稿',
summaryOverride: '图片生成超时,可重新打开处理。',
});
});
test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => {
const puzzle = buildPuzzleWork({
workId: 'puzzle-work-ocean',
profileId: 'puzzle-profile-ocean',
sourceSessionId: 'puzzle-session-ocean',
});
const visibleKeys = collectVisibleDraftNoticeKeys({
rpgItems: [],
bigFishItems: [],
jumpHopItems: [],
woodenFishItems: [],
match3dItems: [],
squareHoleItems: [],
puzzleItems: [puzzle],
visualNovelItems: [],
barkBattleItems: [],
babyObjectMatchItems: [],
});
expect(visibleKeys).toContain('puzzle:puzzle-work-ocean');
expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean');
expect(visibleKeys).toContain('puzzle:puzzle-session-ocean');
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
'puzzle-work-ocean',
);
expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe(
'puzzle-profile-ocean',
);
expect(
hasUnreadDraftGenerationUpdates(
{
'puzzle:puzzle-profile-ocean': {
status: 'ready',
seen: false,
},
},
visibleKeys,
),
).toBe(true);
expect(
hasUnreadDraftGenerationUpdates(
{
'puzzle:puzzle-profile-ocean': {
status: 'ready',
seen: true,
},
},
visibleKeys,
),
).toBe(false);
});
});
function emptyGenerationFacts(
overrides: Partial<Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation']> = {},
): Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation'] {
return {
activeSessionId: null,
hasActiveGenerationFailure: false,
hasActiveGenerationRunning: false,
hasBackgroundGenerationFailure: false,
hasBackgroundGenerationRunning: false,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-base',
profileId: 'puzzle-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-base',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾港口拼图。',
levelName: '潮雾拼图',
summary: '潮雾港口拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-base',
profileId: 'match3d-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-base',
gameName: '潮雾抓大鹅',
themeText: '潮雾港口',
summary: '潮雾港口抓大鹅。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
generatedItemAssets: [],
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work-base',
sourceSessionId: 'big-fish-session-base',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾大鱼',
subtitle: '潮雾港口',
summary: '潮雾港口大鱼吃小鱼。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levelCount: 1,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work-base',
profileId: 'square-hole-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session-base',
gameName: '潮雾方洞',
themeText: '潮雾港口',
twistRule: '避开雾门',
summary: '潮雾港口方洞挑战。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '潮雾港口',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 1,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-base',
ownerUserId: 'user-1',
title: '潮雾视觉小说',
description: '潮雾港口视觉小说。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
...overrides,
};
}
function buildJumpHopWork(
overrides: Partial<JumpHopWorkSummaryResponse> = {},
): JumpHopWorkSummaryResponse {
return {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-base',
profileId: 'jump-hop-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-base',
themeText: '潮雾港口',
workTitle: '潮雾跳一跳',
workDescription: '潮雾港口跳一跳。',
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
...overrides,
};
}
function buildWoodenFishWork(
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
): WoodenFishWorkSummaryResponse {
return {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-base',
profileId: 'wooden-fish-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'wooden-fish-session-base',
workTitle: '潮雾敲木鱼',
workDescription: '潮雾港口敲木鱼。',
themeTags: ['敲木鱼'],
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
...overrides,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { afterEach, expect, test, vi } from 'vitest';
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import {
derivePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
getVisiblePlatformCreationTypes,
groupVisiblePlatformCreationTypes,
isPlatformCreationTypeOpen,
isPlatformCreationTypeVisible,
} from './platformEntryCreationTypes';
@@ -41,6 +42,22 @@ test('database entry config controls visibility open state and display order', (
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
unifiedCreationSpec: {
playId: 'match3d',
title: '抓大鹅',
mudPointCost: 8,
workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating',
resultStage: 'match3d-result',
fields: [
{
id: 'themeText',
kind: 'text',
label: '题材',
required: true,
},
],
},
},
{
id: 'square-hole',
@@ -63,6 +80,7 @@ test('database entry config controls visibility open state and display order', (
id: 'match3d',
locked: false,
hidden: false,
mudPointCostLabel: '8泥点数',
}),
expect.objectContaining({
id: 'square-hole',
@@ -74,6 +92,7 @@ test('database entry config controls visibility open state and display order', (
title: '数据库拼图',
locked: true,
hidden: false,
mudPointCostLabel: '10泥点数',
}),
]);
});
@@ -81,7 +100,7 @@ test('database entry config controls visibility open state and display order', (
test('visible platform creation types hide invisible cards and put locked cards last', () => {
const cards = derivePlatformCreationTypes([
{
id: 'hidden',
id: 'airp',
title: '隐藏',
subtitle: '隐藏',
badge: '隐藏',
@@ -95,7 +114,7 @@ test('visible platform creation types hide invisible cards and put locked cards
updatedAtMicros: 1,
},
{
id: 'locked',
id: 'visual-novel',
title: '锁定',
subtitle: '锁定',
badge: '即将开放',
@@ -109,7 +128,7 @@ test('visible platform creation types hide invisible cards and put locked cards
updatedAtMicros: 1,
},
{
id: 'open',
id: 'rpg',
title: '开放',
subtitle: '开放',
badge: '可创建',
@@ -125,13 +144,13 @@ test('visible platform creation types hide invisible cards and put locked cards
]);
expect(getVisiblePlatformCreationTypes(cards).map((item) => item.id)).toEqual(
['open', 'locked'],
['rpg', 'visual-novel'],
);
expect(isPlatformCreationTypeVisible(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'hidden')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'locked')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'open')).toBe(true);
expect(isPlatformCreationTypeVisible(cards, 'airp')).toBe(false);
expect(isPlatformCreationTypeVisible(cards, 'rpg')).toBe(true);
expect(isPlatformCreationTypeOpen(cards, 'airp')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'visual-novel')).toBe(false);
expect(isPlatformCreationTypeOpen(cards, 'rpg')).toBe(true);
expect(
cards.every((item) =>
item.imageSrc.startsWith('/creation-type-references/'),
@@ -288,7 +307,7 @@ test('groups visible platform creation types by backend category metadata', () =
updatedAtMicros: 1,
},
{
id: 'hidden',
id: 'airp',
title: '隐藏入口',
subtitle: '隐藏',
badge: '隐藏',
@@ -319,7 +338,7 @@ test('groups visible platform creation types by backend category metadata', () =
test('falls back when backend creation type category metadata is missing', () => {
const cards = derivePlatformCreationTypes([
{
id: 'legacy-entry',
id: 'creative-agent',
title: '历史入口',
subtitle: '旧数据缺少分类字段',
badge: '可创建',
@@ -336,7 +355,7 @@ test('falls back when backend creation type category metadata is missing', () =>
expect(cards[0]).toEqual(
expect.objectContaining({
id: 'legacy-entry',
id: 'creative-agent',
categoryId: 'recommended',
categoryLabel: '热门推荐',
}),
@@ -348,3 +367,24 @@ test('falls back when backend creation type category metadata is missing', () =>
}),
]);
});
test('throws when backend sends an unknown creation type id', () => {
const unknownEntry = {
id: 'unknown-play',
title: '未知玩法',
subtitle: '未知',
badge: '未知',
imageSrc: '/creation-type-references/puzzle.webp',
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
} as unknown as CreationEntryTypeConfig;
expect(() => derivePlatformCreationTypes([unknownEntry])).toThrow(
'未知创作类型unknown-play',
);
});

View File

@@ -1,7 +1,14 @@
import type { CreationEntryTypeConfig } from '../../services/creationEntryConfigService';
import {
assertPlatformCreationTypeId,
type PlatformCreationTypeId,
} from '../../../packages/shared/src/contracts/playTypes';
import {
type CreationEntryTypeConfig,
DEFAULT_UNIFIED_CREATION_MUD_POINT_COST,
} from '../../services/creationEntryConfigService';
import { isEdutainmentEntryEnabled } from './platformEdutainmentVisibility';
export type PlatformCreationTypeId = string;
export type { PlatformCreationTypeId };
export type PlatformCreationTypeCard = {
id: PlatformCreationTypeId;
@@ -9,6 +16,8 @@ export type PlatformCreationTypeCard = {
subtitle: string;
badge: string;
imageSrc: string;
mudPointCost: number;
mudPointCostLabel: string;
locked: boolean;
categoryId: string;
categoryLabel: string;
@@ -28,6 +37,16 @@ const RECENT_CREATION_CATEGORY_ID = 'recent';
const FALLBACK_CREATION_CATEGORY_ID = 'recommended';
const FALLBACK_CREATION_CATEGORY_LABEL = '热门推荐';
function normalizeMudPointCost(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.trunc(value)
: DEFAULT_UNIFIED_CREATION_MUD_POINT_COST;
}
function formatMudPointCostText(value: number | null | undefined) {
return `${normalizeMudPointCost(value)}泥点数`;
}
export function getVisiblePlatformCreationTypes(
creationTypes: readonly PlatformCreationTypeCard[],
) {
@@ -117,21 +136,31 @@ export function derivePlatformCreationTypes(
): PlatformCreationTypeCard[] {
const orderedCards = [...creationTypes]
.sort((left, right) => left.sortOrder - right.sortOrder)
.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(item.id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
}));
.map((item) => {
const id = assertPlatformCreationTypeId(item.id);
return {
id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
imageSrc: item.imageSrc,
mudPointCost: normalizeMudPointCost(
item.unifiedCreationSpec?.mudPointCost,
),
mudPointCostLabel: formatMudPointCostText(
item.unifiedCreationSpec?.mudPointCost,
),
locked: !item.open,
categoryId: normalizeCategoryId(item.categoryId),
categoryLabel: normalizeCategoryLabel(item.categoryLabel),
categorySortOrder: item.categorySortOrder,
sortOrder: item.sortOrder,
hidden:
!item.visible ||
(id === 'baby-object-match' && !isEdutainmentEntryEnabled()),
};
});
return [
...orderedCards.filter((item) => !item.hidden && !item.locked),

View File

@@ -36,6 +36,10 @@ export type SelectionStage =
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'puzzle-clear-workspace'
| 'puzzle-clear-generating'
| 'puzzle-clear-result'
| 'puzzle-clear-runtime'
| 'bark-battle-workspace'
| 'bark-battle-generating'
| 'bark-battle-result'

View File

@@ -0,0 +1,73 @@
import { describe, expect, test } from 'vitest';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import { shouldTickPlatformGenerationProgressClock } from './platformGenerationProgressClock';
describe('platformGenerationProgressClock', () => {
test('ticks while puzzle clear generation is still running', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
}),
).toBe(true);
});
test('stops ticking after puzzle clear generation is ready or failed', () => {
const runningState = createMiniGameDraftGenerationState('puzzle-clear');
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: { ...runningState, phase: 'ready' },
}),
).toBe(false);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'puzzle-clear-generating',
generationState: { ...runningState, phase: 'failed' },
}),
).toBe(false);
});
test('ticks for other shared mini game generation stages', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'jump-hop-generating',
generationState: createMiniGameDraftGenerationState('jump-hop'),
}),
).toBe(true);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'wooden-fish-generating',
generationState: createMiniGameDraftGenerationState('wooden-fish'),
}),
).toBe(true);
});
test('ticks visual novel generation from its phase source', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'visual-novel-generating',
visualNovelGenerationStartedAtMs: 1000,
visualNovelGenerationPhase: 'generating',
}),
).toBe(true);
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'visual-novel-generating',
visualNovelGenerationStartedAtMs: 1000,
visualNovelGenerationPhase: 'ready',
}),
).toBe(false);
});
test('does not tick when no generating stage is active', () => {
expect(
shouldTickPlatformGenerationProgressClock({
selectionStage: 'platform',
generationState: createMiniGameDraftGenerationState('puzzle-clear'),
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,36 @@
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
type PlatformGenerationProgressClockInput = {
selectionStage: SelectionStage;
generationState?: MiniGameDraftGenerationState | null;
visualNovelGenerationStartedAtMs?: number | null;
visualNovelGenerationPhase?: VisualNovelEntryGenerationPhase;
};
export function shouldTickPlatformGenerationProgressClock({
selectionStage,
generationState,
visualNovelGenerationStartedAtMs,
visualNovelGenerationPhase,
}: PlatformGenerationProgressClockInput) {
if (selectionStage === 'visual-novel-generating') {
return (
visualNovelGenerationStartedAtMs != null &&
visualNovelGenerationPhase !== 'ready' &&
visualNovelGenerationPhase !== 'failed'
);
}
if (!selectionStage.endsWith('-generating')) {
return false;
}
return Boolean(
generationState &&
generationState.phase !== 'ready' &&
generationState.phase !== 'failed',
);
}

View File

@@ -0,0 +1,173 @@
import { describe, expect, test } from 'vitest';
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationPhase,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel';
function buildGenerationState(
kind: MiniGameDraftGenerationKind,
phase: MiniGameDraftGenerationPhase = 'compile',
): MiniGameDraftGenerationState {
return {
kind,
phase,
startedAtMs: 1000,
completedAssetCount: 0,
totalAssetCount: 1,
error: null,
};
}
describe('platformGenerationProgressTickModel', () => {
test('ticks while a mini-game generation stage has a running state', () => {
const cases: Array<
[stage: SelectionStage, kind: MiniGameDraftGenerationKind]
> = [
['puzzle-generating', 'puzzle'],
['match3d-generating', 'match3d'],
['big-fish-generating', 'big-fish'],
['square-hole-generating', 'square-hole'],
['jump-hop-generating', 'jump-hop'],
['wooden-fish-generating', 'wooden-fish'],
['baby-object-match-generating', 'baby-object-match'],
];
for (const [selectionStage, kind] of cases) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage,
miniGameStates: {
[kind]: buildGenerationState(kind),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: kind,
shouldTick: true,
});
}
});
test('does not tick mini-game generation when state is missing or terminal', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
for (const phase of ['ready', 'failed'] as const) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {
puzzle: buildGenerationState('puzzle', phase),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
}
});
test('does not tick when stage and mini-game state do not match', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'puzzle-generating',
miniGameStates: {
match3d: buildGenerationState('match3d'),
},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'puzzle',
shouldTick: false,
});
});
test('ticks visual novel generation only after it has started and before terminal phases', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: 1000,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: true,
});
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: null,
phase: 'generating',
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: false,
});
for (const phase of ['ready', 'failed'] as const) {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'visual-novel-generating',
miniGameStates: {},
visualNovel: {
startedAtMs: 1000,
phase,
},
}),
).toEqual({
activeKind: 'visual-novel',
shouldTick: false,
});
}
});
test('does not tick non-generation stages even when states are present', () => {
expect(
resolvePlatformGenerationProgressTickDecision({
selectionStage: 'platform',
miniGameStates: {
puzzle: buildGenerationState('puzzle'),
},
visualNovel: {
startedAtMs: 1000,
phase: 'generating',
},
}),
).toEqual({
activeKind: null,
shouldTick: false,
});
});
});

View File

@@ -0,0 +1,79 @@
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
export type PlatformVisualNovelGenerationPhase =
| 'generating'
| 'ready'
| 'failed';
export type PlatformGenerationProgressTickKind =
| MiniGameDraftGenerationKind
| 'visual-novel';
export type PlatformGenerationProgressTickInput = {
selectionStage: SelectionStage;
miniGameStates: Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
visualNovel: {
startedAtMs: number | null;
phase: PlatformVisualNovelGenerationPhase;
};
};
export type PlatformGenerationProgressTickDecision = {
activeKind: PlatformGenerationProgressTickKind | null;
shouldTick: boolean;
};
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'match3d-generating': 'match3d',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'jump-hop-generating': 'jump-hop',
'wooden-fish-generating': 'wooden-fish',
'baby-object-match-generating': 'baby-object-match',
};
function shouldTickMiniGameGenerationState(
state: MiniGameDraftGenerationState | null | undefined,
) {
return state != null && state.phase !== 'ready' && state.phase !== 'failed';
}
/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */
export function resolvePlatformGenerationProgressTickDecision(
input: PlatformGenerationProgressTickInput,
): PlatformGenerationProgressTickDecision {
if (input.selectionStage === 'visual-novel-generating') {
return {
activeKind: 'visual-novel',
shouldTick:
input.visualNovel.startedAtMs != null &&
input.visualNovel.phase !== 'ready' &&
input.visualNovel.phase !== 'failed',
};
}
const activeKind =
MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null;
if (!activeKind) {
return {
activeKind: null,
shouldTick: false,
};
}
return {
activeKind,
shouldTick: shouldTickMiniGameGenerationState(
input.miniGameStates[activeKind],
),
};
}

View File

@@ -0,0 +1,294 @@
import { expect, test } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
buildMatch3DProfileFromSession,
mapMatch3DWorkToPublicWorkDetail,
mapPublicWorkDetailToMatch3DWork,
resolveActiveMatch3DRuntimeProfile,
resolveMatch3DRuntimeBackgroundImageSrc,
resolveMatch3DRuntimeGeneratedBackgroundAsset,
resolveMatch3DRuntimeGeneratedItemAssets,
} from './platformMatch3DRuntimeProfile';
function buildBackgroundAsset(
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
): Match3DGeneratedBackgroundAsset {
return {
prompt: '森林棋盘',
imageSrc: '/generated/match3d/background.png',
imageObjectKey: null,
status: 'ready',
...overrides,
};
}
function buildItemAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '蘑菇',
imageSrc: '/generated/match3d/item.png',
imageObjectKey: null,
status: 'image_ready',
...overrides,
};
}
function buildProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '森林抓鹅',
themeText: '森林',
summary: '找出蘑菇。',
tags: ['森林', '蘑菇'],
coverImageSrc: '/cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
...overrides,
};
}
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
return {
runId: 'match3d-run-1',
profileId: 'match3d-profile-1',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1000,
durationLimitMs: 60000,
remainingMs: 55000,
clearCount: 12,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: [],
...overrides,
};
}
function buildPublicWork(
overrides: Partial<PlatformMatch3DGalleryCard> = {},
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
sourceSessionId: 'match3d-session-1',
publicWorkCode: 'M3D-00000001',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '森林抓鹅',
subtitle: '抓大鹅',
summaryText: '找出蘑菇。',
coverImageSrc: '/cover.png',
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
themeTags: ['森林', '蘑菇'],
visibility: 'published',
publishedAt: '2026-05-20T00:00:00.000Z',
updatedAt: '2026-05-20T00:00:00.000Z',
...overrides,
};
}
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/background-from-item.png',
imageObjectKey: 'oss/background-from-item.png',
});
const work = mapPublicWorkDetailToMatch3DWork(
buildPublicWork({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(work?.backgroundImageSrc).toBe(
'/generated/match3d/background-from-item.png',
);
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
});
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/detail-background.png',
});
const detail = mapMatch3DWorkToPublicWorkDetail(
buildProfile({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(detail).toMatchObject({
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
backgroundImageSrc: '/generated/match3d/detail-background.png',
generatedBackgroundAsset: backgroundAsset,
});
});
test('Match3D runtime profile builds draft profile from session snapshot', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/draft-background.png',
});
const session: Match3DAgentSessionSnapshot = {
sessionId: 'match3d-session-draft',
currentTurn: 2,
progressPercent: 100,
stage: 'draft_compiled',
anchorPack: {
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
clearCount: {
key: 'clearCount',
label: '消除数',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '4',
status: 'confirmed',
},
},
messages: [],
lastAssistantReply: null,
updatedAt: '2026-05-21T00:00:00.000Z',
draft: {
profileId: 'match3d-draft-profile',
gameName: '草稿抓鹅',
themeText: '森林',
summaryText: '草稿摘要',
tags: ['森林'],
coverImageSrc: null,
referenceImageSrc: '/reference.png',
clearCount: 12,
difficulty: 4,
publishReady: true,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
},
};
const profile = buildMatch3DProfileFromSession(session);
expect(profile?.profileId).toBe('match3d-draft-profile');
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
expect(profile?.publicationStatus).toBe('draft');
expect(profile?.coverImageSrc).toBe('/reference.png');
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(profile?.backgroundImageSrc).toBe(
'/generated/match3d/draft-background.png',
);
});
test('Match3D runtime profile selects active profile by run profile id', () => {
const runtimeProfile = buildProfile({
profileId: 'runtime-profile',
gameName: '运行态抓鹅',
});
const draftProfile = buildProfile({
profileId: 'draft-profile',
gameName: '旧草稿抓鹅',
});
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'runtime-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(runtimeProfile);
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'draft-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(draftProfile);
});
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
const staleProfile = buildProfile({
profileId: 'stale-profile',
generatedBackgroundAsset: buildBackgroundAsset({
imageSrc: '/generated/match3d/stale-background.png',
}),
generatedItemAssets: [
buildItemAsset({
itemId: 'stale-item',
imageSrc: '/generated/match3d/stale-item.png',
}),
],
});
const publicBackground = buildBackgroundAsset({
imageSrc: '/generated/match3d/public-background.png',
});
const publicWork = buildPublicWork({
profileId: 'public-profile',
generatedBackgroundAsset: publicBackground,
generatedItemAssets: [
buildItemAsset({
itemId: 'public-item',
imageSrc: '/generated/match3d/public-item.png',
}),
],
});
const run = buildRun({ profileId: 'public-profile' });
expect(
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
),
).toBe(true);
expect(
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
).toEqual(publicBackground);
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
'/generated/match3d/public-background.png',
);
});

View File

@@ -0,0 +1,335 @@
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
isMatch3DGalleryEntry,
mapMatch3DWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export function mapMatch3DWorkToPublicWorkDetail(
item: Match3DWorkSummary,
): PlatformPublicGalleryCard {
return mapMatch3DWorkToPlatformGalleryCard(
normalizeMatch3DWorkForRuntimeUi(item),
);
}
export function mapPublicWorkDetailToMatch3DWork(
entry: PlatformPublicGalleryCard,
): Match3DWorkSummary | null {
if (!isMatch3DGalleryEntry(entry)) {
return null;
}
return promoteMatch3DGeneratedBackgroundAsset({
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId:
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
? entry.sourceSessionId
: null,
gameName: entry.worldName,
themeText: entry.themeTags[0] ?? '经典消除',
summary: entry.summaryText,
tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
backgroundPrompt: entry.backgroundPrompt ?? null,
backgroundImageSrc: entry.backgroundImageSrc ?? null,
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
generatedBackgroundAsset:
entry.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
null,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
});
}
export function findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null
);
}
export function promoteMatch3DGeneratedBackgroundAsset<
T extends Pick<
Match3DWorkSummary,
| 'backgroundPrompt'
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
>(profile: T): T {
const backgroundAsset =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
if (!backgroundAsset) {
return profile;
}
return {
...profile,
backgroundPrompt:
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
backgroundAsset.imageSrc ??
backgroundAsset.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
backgroundAsset.imageObjectKey ??
backgroundAsset.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? backgroundAsset,
};
}
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
profile: T,
): T {
return promoteMatch3DGeneratedBackgroundAsset({
...profile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
profile.generatedItemAssets,
),
});
}
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
profiles: readonly T[],
): T[] {
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
}
export function buildMatch3DProfileFromSession(
session: Match3DAgentSessionSnapshot | null,
): Match3DWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
);
return promoteMatch3DGeneratedBackgroundAsset({
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
summary: draft.summary ?? draft.summaryText ?? '',
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
referenceImageSrc: draft.referenceImageSrc ?? null,
clearCount: draft.clearCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
backgroundPrompt: draft.backgroundPrompt ?? null,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets,
});
}
export function hasMatch3DRuntimeAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return hasMatch3DGeneratedImageAsset(assets);
}
export function hasMatch3DRuntimeBackgroundAsset(
profile: Pick<
Match3DWorkSummary,
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
) {
return Boolean(
profile.backgroundImageSrc?.trim() ||
profile.backgroundImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedItemAssets?.some(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
),
);
}
export function resolveMatch3DRuntimeGeneratedItemAssets(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileAssets = profile?.generatedItemAssets ?? [];
const publicDetailAssets =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (publicWorkDetail.generatedItemAssets ?? [])
: [];
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DRuntimeAsset(publicDetailAssets)
? mergeMatch3DGeneratedItemAssetsForRuntime(
publicDetailAssets,
profileAssets,
)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
}
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return publicDetailAssets.length > 0
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileBackground = profile
? (promoteMatch3DGeneratedBackgroundAsset(profile)
.generatedBackgroundAsset ?? null)
: null;
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
.generatedBackgroundAsset ?? null)
: null;
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground ?? publicBackground;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground ?? profileBackground;
}
return profileBackground ?? publicBackground;
}
export function resolveActiveMatch3DRuntimeProfile(
run: Match3DRunSnapshot | null,
runtimeProfile: Match3DWorkProfile | null,
profile: Match3DWorkProfile | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
return runtimeProfile;
}
if (runProfileId && profile?.profileId === runProfileId) {
return profile;
}
return runtimeProfile ?? profile;
}
export function resolveMatch3DRuntimeBackgroundImageSrc(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const resolvedProfile = profile
? promoteMatch3DGeneratedBackgroundAsset(profile)
: null;
const resolvedPublicWork =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
: null;
const profileBackground =
resolvedProfile?.backgroundImageSrc?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
const publicBackground =
resolvedPublicWork?.backgroundImageSrc?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground || publicBackground || null;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground || profileBackground || null;
}
return profileBackground || publicBackground || null;
}

View File

@@ -0,0 +1,384 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
createFailedMiniGameDraftGenerationStateForRestoredDraft,
createMiniGameDraftGenerationStateForRestoredDraft,
createPuzzleDraftGenerationStateFromPayload,
isMiniGameDraftGenerating,
isMiniGameDraftReady,
mergeMatch3DGeneratedAssetsIntoGenerationState,
mergePuzzleSessionProgressIntoGenerationState,
rebaseMiniGameDraftBackgroundCompileTaskForDisplay,
rebaseMiniGameDraftGenerationStateForDisplay,
resolveFinishedMiniGameDraftGenerationState,
resolvePuzzlePhaseFromSessionProgress,
} from './platformMiniGameDraftGenerationStateModel';
const NOW = Date.parse('2026-06-04T03:00:00.000Z');
const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z';
const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT);
function buildAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = buildAnchorPack();
return {
sessionId: 'puzzle-session-1',
seedText: '星桥',
currentTurn: 1,
progressPercent: 90,
stage: 'draft_ready',
anchorPack,
draft: {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把星桥碎片拼回原位。',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [],
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: SESSION_UPDATED_AT,
...overrides,
};
}
function buildState(
overrides: Partial<MiniGameDraftGenerationState> = {},
): MiniGameDraftGenerationState {
return {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 100,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
},
...overrides,
};
}
function buildMatch3DAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '红宝石',
status: 'pending',
...overrides,
};
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe('platformMiniGameDraftGenerationStateModel', () => {
test('creates restored generation state with metadata and explicit start time', () => {
expect(
createMiniGameDraftGenerationStateForRestoredDraft(
'match3d',
{ puzzleAiRedraw: false },
123,
),
).toMatchObject({
kind: 'match3d',
phase: 'match3d-work-title',
startedAtMs: 123,
metadata: {
puzzleAiRedraw: false,
},
});
});
test('creates failed restored state from backend updated time', () => {
expect(
createFailedMiniGameDraftGenerationStateForRestoredDraft(
'puzzle',
SESSION_UPDATED_AT,
'生成失败',
{ puzzleAiRedraw: true },
),
).toMatchObject({
kind: 'puzzle',
phase: 'failed',
startedAtMs: SESSION_UPDATED_AT_MS,
finishedAtMs: NOW,
error: '生成失败',
metadata: {
puzzleAiRedraw: true,
},
});
});
test('rebases finished state for display without changing other fields', () => {
const state = buildState({
phase: 'ready',
finishedAtMs: 300,
completedAssetCount: 2,
totalAssetCount: 3,
});
expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({
...state,
finishedAtMs: undefined,
});
expect(
rebaseMiniGameDraftBackgroundCompileTaskForDisplay({
sessionId: 'task-1',
generationState: state,
}),
).toEqual({
sessionId: 'task-1',
generationState: {
...state,
finishedAtMs: undefined,
},
});
});
test('creates puzzle generation state from payload and compiled session', () => {
const payload: CreatePuzzleAgentSessionRequest = {
seedText: '星桥',
aiRedraw: false,
};
expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({
kind: 'puzzle',
phase: 'compile',
startedAtMs: NOW,
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: undefined,
puzzleActiveStepStartedAtMs: undefined,
puzzleProgressPercent: undefined,
},
});
expect(
createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()),
).toMatchObject({
kind: 'puzzle',
phase: 'compile',
startedAtMs: SESSION_UPDATED_AT_MS,
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: NOW,
puzzleProgressPercent: 90,
},
});
});
test('resolves puzzle phase from backend progress thresholds', () => {
const state = buildState();
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 96 }),
),
).toBe('puzzle-select-image');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 94 }),
),
).toBe('puzzle-ui-assets');
expect(
resolvePuzzlePhaseFromSessionProgress(
buildState({ metadata: { puzzleAiRedraw: false } }),
buildPuzzleSession({ progressPercent: 88 }),
),
).toBe('puzzle-level-scene');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 88 }),
),
).toBe('puzzle-cover-image');
expect(
resolvePuzzlePhaseFromSessionProgress(
state,
buildPuzzleSession({ progressPercent: 20 }),
),
).toBe('compile');
});
test('merges compiled puzzle session progress into generation state', () => {
expect(
mergePuzzleSessionProgressIntoGenerationState(
buildState({
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
},
}),
buildPuzzleSession({ progressPercent: 90 }),
),
).toMatchObject({
metadata: {
puzzleAiRedraw: false,
puzzleActivePhaseId: 'puzzle-level-scene',
puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS,
puzzleProgressPercent: 90,
},
});
expect(
mergePuzzleSessionProgressIntoGenerationState(
buildState(),
buildPuzzleSession({
draft: {
...buildPuzzleSession().draft!,
formDraft: {
pictureDescription: '星桥',
},
},
}),
).metadata,
).toMatchObject({
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
});
});
test('merges match3d generated assets into active generation state', () => {
const state = buildState({
kind: 'match3d',
phase: 'match3d-material-sheet',
completedAssetCount: 0,
totalAssetCount: 0,
error: '旧错误',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
buildMatch3DAsset({
itemId: 'item-with-view',
imageViews: [
{
viewId: 'front',
viewIndex: 0,
imageObjectKey: 'objects/front.png',
},
],
}),
buildMatch3DAsset({
itemId: 'item-with-src',
imageSrc: '/generated/item.png',
}),
buildMatch3DAsset({
itemId: 'item-with-error',
error: '切图失败',
}),
]),
).toMatchObject({
phase: 'match3d-generate-views',
completedAssetCount: 2,
totalAssetCount: 5,
error: '切图失败',
});
});
test('keeps match3d generated asset merge away from finished states', () => {
const readyState = buildState({
kind: 'match3d',
phase: 'ready',
completedAssetCount: 5,
totalAssetCount: 5,
});
const failedState = buildState({
kind: 'match3d',
phase: 'failed',
error: '已失败',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(readyState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(failedState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBeNull();
});
test('finishes generation state and resolves ready/generating flags', () => {
const failedState = resolveFinishedMiniGameDraftGenerationState(
buildState({ error: '旧错误' }),
'failed',
{
completedAssetCount: 1,
totalAssetCount: 2,
},
);
expect(failedState).toMatchObject({
phase: 'failed',
finishedAtMs: NOW,
error: '旧错误',
completedAssetCount: 1,
totalAssetCount: 2,
});
expect(isMiniGameDraftReady(failedState)).toBe(false);
expect(isMiniGameDraftGenerating(failedState)).toBe(false);
expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true);
expect(isMiniGameDraftGenerating(buildState())).toBe(true);
expect(isMiniGameDraftGenerating(null)).toBe(false);
});
});

View File

@@ -0,0 +1,197 @@
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
createMiniGameDraftGenerationState,
type MiniGameDraftGenerationKind,
type MiniGameDraftGenerationPhase,
type MiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
} from '../../services/miniGameDraftGenerationProgress';
export function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
metadata?: MiniGameDraftGenerationState['metadata'],
startedAtMs = Date.now(),
): MiniGameDraftGenerationState {
return {
...createMiniGameDraftGenerationState(kind, startedAtMs),
...(metadata ? { metadata } : {}),
};
}
export function createFailedMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,
updatedAt: string | null | undefined,
error: string,
metadata?: MiniGameDraftGenerationState['metadata'],
): MiniGameDraftGenerationState {
return resolveFinishedMiniGameDraftGenerationState(
createMiniGameDraftGenerationStateForRestoredDraft(
kind,
metadata,
resolveMiniGameDraftGenerationStartedAtMs(updatedAt),
),
'failed',
{ error },
);
}
/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */
export function rebaseMiniGameDraftGenerationStateForDisplay(
state: MiniGameDraftGenerationState,
): MiniGameDraftGenerationState {
return {
...state,
finishedAtMs: undefined,
};
}
export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay<
T extends { generationState: MiniGameDraftGenerationState },
>(task: T): T {
return {
...task,
generationState: rebaseMiniGameDraftGenerationStateForDisplay(
task.generationState,
),
};
}
export function createPuzzleDraftGenerationStateFromPayload(
payload: CreatePuzzleAgentSessionRequest | null | undefined,
session: PuzzleAgentSessionSnapshot | null | undefined = null,
): MiniGameDraftGenerationState {
const puzzleProgressPercent =
session?.draft && !session.draft.formDraft
? session.progressPercent
: undefined;
return {
...createMiniGameDraftGenerationState(
'puzzle',
resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt),
),
metadata: {
puzzleAiRedraw: payload?.aiRedraw ?? true,
puzzleActivePhaseId:
typeof puzzleProgressPercent === 'number' ? 'compile' : undefined,
puzzleActiveStepStartedAtMs:
typeof puzzleProgressPercent === 'number' ? Date.now() : undefined,
puzzleProgressPercent,
},
};
}
export function resolvePuzzlePhaseFromSessionProgress(
state: MiniGameDraftGenerationState,
session: PuzzleAgentSessionSnapshot,
): MiniGameDraftGenerationPhase {
if (session.progressPercent >= 96) {
return 'puzzle-select-image';
}
if (session.progressPercent >= 94) {
return 'puzzle-ui-assets';
}
if (session.progressPercent >= 88) {
return state.metadata?.puzzleAiRedraw === false
? 'puzzle-level-scene'
: 'puzzle-cover-image';
}
return 'compile';
}
export function mergePuzzleSessionProgressIntoGenerationState(
state: MiniGameDraftGenerationState,
session: PuzzleAgentSessionSnapshot,
): MiniGameDraftGenerationState {
const isCompiledGenerationSession = Boolean(
session.draft && !session.draft.formDraft,
);
const nextPhaseId = isCompiledGenerationSession
? resolvePuzzlePhaseFromSessionProgress(state, session)
: state.metadata?.puzzleActivePhaseId;
const shouldResetActiveStepStart =
isCompiledGenerationSession &&
nextPhaseId != null &&
nextPhaseId !== state.metadata?.puzzleActivePhaseId;
return {
...state,
metadata: {
...state.metadata,
puzzleActivePhaseId: nextPhaseId,
puzzleActiveStepStartedAtMs: shouldResetActiveStepStart
? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt)
: state.metadata?.puzzleActiveStepStartedAtMs,
puzzleProgressPercent: isCompiledGenerationSession
? session.progressPercent
: state.metadata?.puzzleProgressPercent,
},
};
}
export function mergeMatch3DGeneratedAssetsIntoGenerationState(
current: MiniGameDraftGenerationState | null,
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): MiniGameDraftGenerationState | null {
if (!current || current.phase === 'ready' || current.phase === 'failed') {
return current;
}
const assetList = assets ?? [];
const imageReadyCount = assetList.filter(
(asset) =>
asset.imageViews?.some(
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
) ||
asset.imageObjectKey?.trim() ||
asset.imageSrc?.trim(),
).length;
const totalAssetCount = Math.max(5, assetList.length);
const failedAsset = assetList.find((asset) => asset.error?.trim());
return {
...current,
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
completedAssetCount: imageReadyCount,
totalAssetCount,
error: failedAsset?.error?.trim() || current.error,
};
}
export function resolveFinishedMiniGameDraftGenerationState(
state: MiniGameDraftGenerationState,
phase: 'ready' | 'failed',
options: {
error?: string | null;
completedAssetCount?: number;
totalAssetCount?: number;
} = {},
): MiniGameDraftGenerationState {
return {
...state,
phase,
finishedAtMs: Date.now(),
error: options.error ?? state.error,
completedAssetCount:
options.completedAssetCount ?? state.completedAssetCount,
totalAssetCount: options.totalAssetCount ?? state.totalAssetCount,
};
}
export function isMiniGameDraftReady(
state: MiniGameDraftGenerationState | null,
) {
return state?.phase === 'ready';
}
export function isMiniGameDraftGenerating(
state: MiniGameDraftGenerationState | null,
) {
return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed');
}

View File

@@ -0,0 +1,595 @@
import { describe, expect, test } from 'vitest';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
Match3DAgentSessionSnapshot,
Match3DAnchorPackResponse,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAnchorPack,
PuzzleDraftLevel,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/woodenFish';
import {
buildJumpHopDraftActionPayload,
buildMatch3DFormPayloadFromSession,
buildMatch3DFormPayloadFromWork,
buildPendingMatch3DDraftMetadata,
buildPendingPuzzleDraftMetadata,
buildPuzzleCompileActionFromFormPayload,
buildPuzzleFormPayloadFromAction,
buildPuzzleFormPayloadFromSession,
buildPuzzleFormPayloadFromWork,
buildPuzzleWorkUpdatePayloadFromDraft,
buildWoodenFishDraftActionPayload,
isEmptyPuzzleFormOnlyDraft,
isPuzzleFormOnlyDraft,
} from './platformMiniGameDraftPayloadModel';
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleLevel(
overrides: Partial<PuzzleDraftLevel> = {},
): PuzzleDraftLevel {
return {
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '关卡画面描述',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
workTitle: ' 星桥拼图 ',
workDescription: ' 修复星桥机关。 ',
levelName: '星桥机关',
summary: '把碎片拼回原位。',
themeTags: ['星桥'],
coverImageSrc: '/cover.png',
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-01T10:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [buildPuzzleLevel()],
...overrides,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = buildPuzzleAnchorPack();
return {
sessionId: 'puzzle-session-1',
seedText: '种子描述',
currentTurn: 1,
progressPercent: 20,
stage: 'collecting_anchors',
anchorPack,
draft: {
workTitle: '会话标题',
workDescription: '会话描述',
levelName: '星桥机关',
summary: '会话摘要',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [buildPuzzleLevel()],
formDraft: {
workTitle: '表单标题',
workDescription: '表单描述',
pictureDescription: '表单画面',
},
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function buildMatch3DAnchorPack(
overrides: Partial<Match3DAnchorPackResponse> = {},
): Match3DAnchorPackResponse {
return {
theme: {
key: 'theme',
label: '主题',
value: '海岛玩具',
status: 'confirmed',
},
clearCount: {
key: 'clearCount',
label: '消除次数',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
...overrides,
};
}
function buildMatch3DSession(
overrides: Partial<Match3DAgentSessionSnapshot> = {},
): Match3DAgentSessionSnapshot {
return {
sessionId: 'match3d-session-1',
currentTurn: 1,
progressPercent: 20,
stage: 'collecting',
anchorPack: buildMatch3DAnchorPack(),
config: null,
draft: null,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
updatedAt: '2026-06-01T11:00:00.000Z',
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
gameName: '海岛抓大鹅',
themeText: ' 海岛玩具 ',
summary: '收集海岛玩具。',
tags: ['海岛'],
coverImageSrc: '/match3d-cover.png',
referenceImageSrc: '/match3d-reference.png',
clearCount: 12,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T11:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildJumpHopDraft(
overrides: Partial<NonNullable<JumpHopSessionSnapshotResponse['draft']>> = {},
): NonNullable<JumpHopSessionSnapshotResponse['draft']> {
return {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-1',
themeText: '草稿主题',
workTitle: '草稿跳一跳',
workDescription: '从草稿恢复。',
themeTags: ['草稿'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '草稿角色',
tilePrompt: '草稿平台',
endMoodPrompt: '草稿终点',
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'draft',
...overrides,
};
}
function buildJumpHopPayload(
overrides: Partial<JumpHopWorkspaceCreateRequest> = {},
): JumpHopWorkspaceCreateRequest {
return {
templateId: 'jump-hop',
themeText: '表单主题',
workTitle: '表单跳一跳',
workDescription: '从表单提交。',
themeTags: ['表单'],
difficulty: 'advanced',
stylePreset: 'neon-glass',
characterPrompt: '表单角色',
tilePrompt: '表单平台',
endMoodPrompt: '表单终点',
...overrides,
};
}
function buildWoodenFishDraft(
overrides: Partial<
NonNullable<WoodenFishSessionSnapshotResponse['draft']>
> = {},
): NonNullable<WoodenFishSessionSnapshotResponse['draft']> {
return {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '草稿木鱼',
workDescription: '从草稿恢复。',
themeTags: ['草稿'],
hitObjectPrompt: '草稿敲击物',
hitObjectReferenceImageSrc: '/draft-hit-ref.png',
hitSoundPrompt: null,
floatingWords: ['草稿 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: null,
generationStatus: 'draft',
...overrides,
};
}
function buildWoodenFishPayload(
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
): WoodenFishWorkspaceCreateRequest {
return {
templateId: 'wooden-fish',
workTitle: '表单木鱼',
workDescription: '从表单提交。',
themeTags: ['表单'],
hitObjectPrompt: '表单敲击物',
hitObjectReferenceImageSrc: '/form-hit-ref.png',
hitSoundPrompt: null,
hitSoundAsset: null,
floatingWords: ['表单 +1'],
...overrides,
};
}
describe('platformMiniGameDraftPayloadModel', () => {
test('builds puzzle form payload from work with fallback description priority', () => {
expect(
buildPuzzleFormPayloadFromWork(
buildPuzzleWork({
workDescription: ' ',
summary: ' 摘要描述 ',
levelName: ' 关卡标题 ',
}),
),
).toEqual({
seedText: '摘要描述',
workTitle: '星桥拼图',
workDescription: '摘要描述',
pictureDescription: '摘要描述',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
});
});
test('builds puzzle work update payload from result draft', () => {
const draft = buildPuzzleSession().draft!;
expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({
workTitle: '会话标题',
workDescription: '会话描述',
levelName: '星桥机关',
summary: '会话摘要',
themeTags: ['星桥'],
coverImageSrc: null,
coverAssetId: null,
levels: [buildPuzzleLevel()],
});
expect(
buildPuzzleWorkUpdatePayloadFromDraft({
...draft,
levels: undefined,
}).levels,
).toEqual([]);
});
test('builds jump hop draft action payload from payload or draft', () => {
expect(
buildJumpHopDraftActionPayload('compile-draft', {
payload: buildJumpHopPayload(),
draft: buildJumpHopDraft(),
}),
).toEqual({
actionType: 'compile-draft',
workTitle: '表单跳一跳',
workDescription: '从表单提交。',
themeTags: ['表单'],
difficulty: 'advanced',
stylePreset: 'neon-glass',
characterPrompt: '表单角色',
tilePrompt: '表单平台',
endMoodPrompt: '表单终点',
});
expect(
buildJumpHopDraftActionPayload('regenerate-tiles', {
draft: buildJumpHopDraft(),
}),
).toMatchObject({
actionType: 'regenerate-tiles',
workTitle: '草稿跳一跳',
tilePrompt: '草稿平台',
});
});
test('builds wooden fish draft action payload from payload or draft', () => {
expect(
buildWoodenFishDraftActionPayload('compile-draft', {
payload: buildWoodenFishPayload(),
draft: buildWoodenFishDraft(),
}),
).toEqual({
actionType: 'compile-draft',
workTitle: '表单木鱼',
workDescription: '从表单提交。',
themeTags: ['表单'],
hitObjectPrompt: '表单敲击物',
hitObjectReferenceImageSrc: '/form-hit-ref.png',
hitSoundAsset: null,
floatingWords: ['表单 +1'],
});
expect(
buildWoodenFishDraftActionPayload('regenerate-hit-object', {
draft: buildWoodenFishDraft(),
}),
).toMatchObject({
actionType: 'regenerate-hit-object',
workTitle: '草稿木鱼',
hitObjectPrompt: '草稿敲击物',
floatingWords: ['草稿 +1'],
});
});
test('builds puzzle form payload from session form draft and fallbacks', () => {
expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({
seedText: '表单画面',
workTitle: '表单标题',
workDescription: '表单描述',
pictureDescription: '表单画面',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
});
expect(
buildPuzzleFormPayloadFromSession(
buildPuzzleSession({
draft: {
...buildPuzzleSession().draft!,
formDraft: null,
levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })],
},
}),
).pictureDescription,
).toBe('关卡优先');
});
test('resolves puzzle form-only draft state for empty and filled forms', () => {
const baseDraft = buildPuzzleSession().draft!;
const emptySession = buildPuzzleSession({
seedText: ' ',
draft: {
...baseDraft,
formDraft: {
workTitle: ' ',
workDescription: ' ',
pictureDescription: ' ',
},
},
});
expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true);
expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true);
expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true);
expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false);
expect(
isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })),
).toBe(false);
});
test('builds puzzle compile action and restores form payload from action', () => {
const payload: CreatePuzzleAgentSessionRequest = {
seedText: '种子',
workTitle: ' 标题 ',
workDescription: '',
pictureDescription: ' 画面 ',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
};
const action = buildPuzzleCompileActionFromFormPayload(payload);
expect(action).toEqual({
action: 'compile_puzzle_draft',
promptText: '画面',
workTitle: '标题',
workDescription: '画面',
pictureDescription: '画面',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
candidateCount: 1,
});
expect(buildPuzzleFormPayloadFromAction(action)).toEqual({
seedText: '画面',
workTitle: '标题',
workDescription: '画面',
pictureDescription: '画面',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
});
expect(
buildPuzzleFormPayloadFromAction({
action: 'publish_puzzle_work',
} as PuzzleAgentActionRequest),
).toBeNull();
});
test('builds pending puzzle metadata from non-empty payload fields', () => {
expect(
buildPendingPuzzleDraftMetadata({
workTitle: ' 标题 ',
workDescription: ' ',
pictureDescription: ' 画面 ',
seedText: '种子',
}),
).toEqual({
title: '标题',
summary: '画面',
});
expect(buildPendingPuzzleDraftMetadata(null)).toEqual({});
});
test('builds match3d form payload from session config, draft and anchors', () => {
expect(
buildMatch3DFormPayloadFromSession(
buildMatch3DSession({
config: {
themeText: ' 配置主题 ',
referenceImageSrc: '/config-ref.png',
clearCount: 9,
difficulty: 4,
assetStyleId: 'style-1',
assetStyleLabel: '手办',
assetStylePrompt: '软陶手办',
generateClickSound: true,
},
draft: {
profileId: 'profile-1',
gameName: '草稿标题',
themeText: '草稿主题',
tags: [],
referenceImageSrc: '/draft-ref.png',
clearCount: 6,
difficulty: 2,
},
}),
),
).toEqual({
seedText: '配置主题',
themeText: '配置主题',
referenceImageSrc: '/config-ref.png',
clearCount: 9,
difficulty: 4,
assetStyleId: 'style-1',
assetStyleLabel: '手办',
assetStylePrompt: '软陶手办',
generateClickSound: true,
});
expect(
buildMatch3DFormPayloadFromSession(
buildMatch3DSession({
anchorPack: buildMatch3DAnchorPack({
clearCount: {
key: 'clearCount',
label: '消除次数',
value: 'not-number',
status: 'confirmed',
},
}),
}),
),
).toMatchObject({
seedText: '海岛玩具',
clearCount: undefined,
difficulty: 3,
});
});
test('builds match3d form payload from work and pending metadata', () => {
expect(
buildMatch3DFormPayloadFromWork(
buildMatch3DWork({
themeText: ' ',
}),
),
).toEqual({
seedText: '海岛抓大鹅',
themeText: '海岛抓大鹅',
referenceImageSrc: '/match3d-reference.png',
clearCount: 12,
difficulty: 3,
});
expect(
buildPendingMatch3DDraftMetadata({
themeText: ' ',
seedText: ' 海岛抓大鹅 ',
}),
).toEqual({
title: '海岛抓大鹅',
summary: '海岛抓大鹅',
});
});
});

View File

@@ -0,0 +1,320 @@
import type {
JumpHopActionRequest,
JumpHopSessionSnapshotResponse,
JumpHopWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
WoodenFishActionRequest,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/woodenFish';
export type PuzzleWorkUpdatePayload = {
workTitle?: string;
workDescription?: string;
levelName: string;
summary: string;
themeTags: string[];
coverImageSrc?: string | null;
coverAssetId?: string | null;
levels: PuzzleDraftLevel[];
};
export function buildPuzzleFormPayloadFromWork(
item: PuzzleWorkSummary,
): CreatePuzzleAgentSessionRequest {
const pictureDescription =
item.workDescription?.trim() ||
item.summary?.trim() ||
item.levels?.[0]?.pictureDescription?.trim() ||
item.levelName?.trim() ||
item.workTitle?.trim() ||
'';
return {
seedText: pictureDescription,
workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined,
workDescription: item.workDescription?.trim() || item.summary?.trim(),
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
};
}
export function buildPuzzleWorkUpdatePayloadFromDraft(
draft: PuzzleResultDraft,
): PuzzleWorkUpdatePayload {
return {
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
};
}
export function buildJumpHopDraftActionPayload(
actionType: 'compile-draft' | 'regenerate-tiles',
input: {
payload?: JumpHopWorkspaceCreateRequest | null;
draft?: JumpHopSessionSnapshotResponse['draft'] | null;
},
): JumpHopActionRequest {
const { payload, draft } = input;
return {
actionType,
workTitle: payload?.workTitle ?? draft?.workTitle,
workDescription: payload?.workDescription ?? draft?.workDescription,
themeTags: payload?.themeTags ?? draft?.themeTags,
difficulty: payload?.difficulty ?? draft?.difficulty,
stylePreset: payload?.stylePreset ?? draft?.stylePreset,
characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt,
tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt,
endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt,
};
}
export function buildWoodenFishDraftActionPayload(
actionType: 'compile-draft' | 'regenerate-hit-object',
input: {
payload?: WoodenFishWorkspaceCreateRequest | null;
draft?: WoodenFishSessionSnapshotResponse['draft'] | null;
},
): WoodenFishActionRequest {
const { payload, draft } = input;
return {
actionType,
workTitle: payload?.workTitle ?? draft?.workTitle,
workDescription: payload?.workDescription ?? draft?.workDescription,
themeTags: payload?.themeTags ?? draft?.themeTags,
hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt,
hitObjectReferenceImageSrc:
payload?.hitObjectReferenceImageSrc ??
draft?.hitObjectReferenceImageSrc,
hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset,
floatingWords: payload?.floatingWords ?? draft?.floatingWords,
};
}
function parseOptionalFiniteNumber(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
const normalizedValue = value?.trim();
if (!normalizedValue) {
return undefined;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
export function buildMatch3DFormPayloadFromSession(
session: Match3DAgentSessionSnapshot,
): CreateMatch3DSessionRequest {
const themeText =
session.config?.themeText?.trim() ||
session.draft?.themeText?.trim() ||
session.anchorPack.theme.value.trim() ||
'';
return {
seedText: themeText,
themeText,
referenceImageSrc:
session.config?.referenceImageSrc ??
session.draft?.referenceImageSrc ??
null,
clearCount:
session.config?.clearCount ??
session.draft?.clearCount ??
parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ??
undefined,
difficulty:
session.config?.difficulty ??
session.draft?.difficulty ??
parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ??
undefined,
assetStyleId: session.config?.assetStyleId ?? null,
assetStyleLabel: session.config?.assetStyleLabel ?? null,
assetStylePrompt: session.config?.assetStylePrompt ?? null,
generateClickSound: session.config?.generateClickSound,
};
}
export function buildMatch3DFormPayloadFromWork(
item: Match3DWorkSummary,
): CreateMatch3DSessionRequest {
const themeText = item.themeText?.trim() || item.gameName?.trim() || '';
return {
seedText: themeText,
themeText,
referenceImageSrc: item.referenceImageSrc ?? null,
clearCount: item.clearCount,
difficulty: item.difficulty,
};
}
export function buildPuzzleCompileActionFromFormPayload(
payload: CreatePuzzleAgentSessionRequest | null,
): PuzzleAgentActionRequest {
const pictureDescription =
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
const workTitle = payload?.workTitle?.trim();
const workDescription = payload?.workDescription?.trim() || pictureDescription;
return {
action: 'compile_puzzle_draft',
promptText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
...(pictureDescription ? { pictureDescription } : {}),
referenceImageSrc: payload?.referenceImageSrc || null,
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
imageModel: payload?.imageModel ?? null,
aiRedraw: payload?.aiRedraw ?? true,
candidateCount: 1,
};
}
export function buildPuzzleFormPayloadFromSession(
session: PuzzleAgentSessionSnapshot,
): CreatePuzzleAgentSessionRequest {
const formDraft = session.draft?.formDraft;
const pictureDescription =
formDraft?.pictureDescription?.trim() ||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
session.anchorPack.visualSubject.value.trim() ||
session.seedText?.trim() ||
'';
const workTitle =
formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim();
const workDescription =
formDraft?.workDescription?.trim() ||
session.draft?.workDescription?.trim() ||
session.draft?.summary?.trim() ||
pictureDescription;
return {
seedText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
};
}
export function isPuzzleFormOnlyDraft(
session: PuzzleAgentSessionSnapshot | null,
) {
return Boolean(
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
);
}
export function isEmptyPuzzleFormOnlyDraft(
session: PuzzleAgentSessionSnapshot | null,
) {
if (!isPuzzleFormOnlyDraft(session)) {
return false;
}
const formDraft = session?.draft?.formDraft;
return !(
session?.seedText?.trim() ||
formDraft?.workTitle?.trim() ||
formDraft?.workDescription?.trim() ||
formDraft?.pictureDescription?.trim()
);
}
export function buildPendingPuzzleDraftMetadata(
payload: CreatePuzzleAgentSessionRequest | null | undefined,
) {
const title = payload?.workTitle?.trim();
const summary =
payload?.workDescription?.trim() ||
payload?.pictureDescription?.trim() ||
payload?.seedText?.trim();
return {
...(title ? { title } : {}),
...(summary ? { summary } : {}),
};
}
export function buildPendingMatch3DDraftMetadata(
payload: CreateMatch3DSessionRequest | null | undefined,
) {
const themeText = payload?.themeText?.trim() || payload?.seedText?.trim();
return {
...(themeText ? { title: themeText, summary: themeText } : {}),
};
}
export function buildPuzzleFormPayloadFromAction(
payload: PuzzleAgentActionRequest,
): CreatePuzzleAgentSessionRequest | null {
if (
payload.action !== 'compile_puzzle_draft' &&
payload.action !== 'save_puzzle_form_draft'
) {
return null;
}
const workTitle = payload.workTitle?.trim() ?? '';
const workDescription = payload.workDescription?.trim() ?? '';
const pictureDescription =
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
return {
seedText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
pictureDescription,
referenceImageSrc:
payload.action === 'compile_puzzle_draft'
? (payload.referenceImageSrc ?? null)
: (payload.referenceImageSrc ?? null),
referenceImageSrcs: payload.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
imageModel:
payload.action === 'compile_puzzle_draft'
? (payload.imageModel ?? null)
: (payload.imageModel ?? null),
aiRedraw:
payload.action === 'compile_puzzle_draft'
? (payload.aiRedraw ?? true)
: (payload.aiRedraw ?? true),
};
}

View File

@@ -0,0 +1,716 @@
import { describe, expect, test } from 'vitest';
import type {
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type {
SquareHoleResultDraft,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
VisualNovelResultDraft,
VisualNovelWorkDetail,
} from '../../../packages/shared/src/contracts/visualNovel';
import type {
WoodenFishAudioAsset,
WoodenFishImageAsset,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import {
buildJumpHopPendingSession,
buildPuzzleRuntimeWorkFromSession,
buildSquareHoleProfileFromSession,
buildVisualNovelSessionFromWorkDetail,
buildWoodenFishGeneratingWorkSummary,
buildWoodenFishPendingSession,
buildWoodenFishSessionFromWorkDetail,
} from './platformMiniGameSessionMappingModel';
function buildAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleDraft(
overrides: Partial<PuzzleResultDraft> = {},
): PuzzleResultDraft {
const anchorPack = buildAnchorPack();
return {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把星桥碎片拼回原位。',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-cover.png',
coverAssetId: 'asset-cover',
generationStatus: 'ready',
levels: [
{
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '星桥',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-level-cover.png',
coverAssetId: 'asset-level-cover',
generationStatus: 'ready',
},
],
...overrides,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const draft = buildPuzzleDraft();
return {
sessionId: 'puzzle-session-12345678',
seedText: '星桥',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: draft.anchorPack,
draft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft,
blockers: [],
qualityFindings: [],
publishReady: true,
},
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function buildJumpHopSummary(
overrides: Partial<JumpHopWorkSummaryResponse> = {},
): JumpHopWorkSummaryResponse {
return {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-1',
profileId: 'jump-hop-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' jump-hop-session-1 ',
themeText: '云阶机关',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: '/jump-hop-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T11:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
function buildSquareHoleDraft(
overrides: Partial<SquareHoleResultDraft> = {},
): SquareHoleResultDraft {
return {
profileId: 'square-hole-profile-1',
gameName: '星桥方洞',
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
summary: '把星桥机关里的形状送入正确孔洞。',
tags: ['星桥', '机关'],
coverImageSrc: '/square-hole-cover.png',
backgroundPrompt: '星桥机关背景',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: [
{
optionId: 'shape-1',
shapeKind: 'star',
label: '星形',
targetHoleId: 'hole-1',
imagePrompt: '星形积木',
imageSrc: '/shape-star.png',
},
],
holeOptions: [
{
holeId: 'hole-1',
holeKind: 'star-hole',
label: '星形洞',
imagePrompt: '星形洞口',
imageSrc: '/hole-star.png',
},
],
shapeCount: 6,
difficulty: 3,
publishReady: true,
blockers: [],
...overrides,
};
}
function buildSquareHoleSession(
overrides: Partial<SquareHoleSessionSnapshot> = {},
): SquareHoleSessionSnapshot {
return {
sessionId: 'square-hole-session-1',
currentTurn: 2,
progressPercent: 100,
stage: 'draft_ready',
anchorPack: {
theme: {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed',
},
twistRule: {
key: 'twistRule',
label: '扭转规则',
value: '只允许相同颜色形状入洞',
status: 'confirmed',
},
shapeCount: {
key: 'shapeCount',
label: '形状数量',
value: '6',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
},
config: {
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
shapeCount: 6,
difficulty: 3,
shapeOptions: [],
holeOptions: [],
backgroundPrompt: '星桥机关背景',
coverImageSrc: null,
backgroundImageSrc: null,
},
draft: buildSquareHoleDraft(),
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
updatedAt: '2026-06-01T12:30:00.000Z',
...overrides,
};
}
function buildVisualNovelDraft(
overrides: Partial<VisualNovelResultDraft> = {},
): VisualNovelResultDraft {
return {
profileId: 'visual-novel-profile-1',
workTitle: '雪线电台',
workDescription: '旧电台牵出雪夜列车谜案。',
workTags: ['雪夜', '电台'],
coverImageSrc: '/visual-novel-cover.png',
sourceMode: 'idea',
sourceAssetIds: ['asset-source-1'],
world: {
title: '北境终点线',
summary: '边境小城与旧电台。',
background: '十二年前的雪崩留下夜间广播。',
premise: '玩家需要在日出前找出列车停摆的原因。',
literaryStyle: '克制冷光感。',
playerRole: '临时广播员',
defaultTone: '安静紧张',
},
characters: [
{
characterId: 'vn-char-1',
name: '林遥',
gender: '女',
role: 'main',
appearance: '灰色长外套。',
personality: '谨慎敏锐。',
tone: '短句多。',
background: '旧电台夜班实习生。',
relationshipToPlayer: '临时搭档',
imageAssets: [],
defaultExpression: 'calm',
isPlayerVisible: false,
},
],
scenes: [
{
sceneId: 'vn-scene-1',
name: '风雪站台',
description: '站灯忽明忽暗。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: ['vn-phase-1'],
},
],
storyPhases: [
{
phaseId: 'vn-phase-1',
title: '重启站台',
goal: '确认列车为何停在废弃站台。',
summary: '玩家抵达风雪站台。',
entryCondition: '开场进入',
exitCondition: '找到车长日志',
sceneIds: ['vn-scene-1'],
characterIds: ['vn-char-1'],
suggestedChoices: ['检查广播柜'],
},
],
opening: {
sceneId: 'vn-scene-1',
narration: '雪落得很慢。',
speakerCharacterId: 'vn-char-1',
firstDialogue: '你听见了吗?',
initialChoices: [
{
choiceId: 'vn-choice-1',
text: '靠近广播柜。',
actionHint: 'inspect_radio',
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'template_config',
saveArchiveEnabled: true,
},
publishReady: true,
validationIssues: [],
updatedAt: '2026-06-01T13:00:00.000Z',
...overrides,
};
}
function buildVisualNovelWorkDetail(
overrides: Partial<VisualNovelWorkDetail> = {},
): VisualNovelWorkDetail {
const draft = buildVisualNovelDraft();
return {
workId: 'visual-novel-work-1',
summary: {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-1',
ownerUserId: 'user-visual-novel-1',
title: draft.workTitle,
description: draft.workDescription,
coverImageSrc: draft.coverImageSrc,
tags: draft.workTags,
publishStatus: 'draft',
publishReady: draft.publishReady,
playCount: 0,
updatedAt: '2026-06-01T13:30:00.000Z',
publishedAt: null,
},
sourceSessionId: ' visual-novel-session-1 ',
authorDisplayName: '视觉小说作者',
sourceAssetIds: draft.sourceAssetIds,
draft,
createdAt: '2026-06-01T12:50:00.000Z',
...overrides,
};
}
const woodenFishImageAsset: WoodenFishImageAsset = {
assetId: 'asset-hit',
imageSrc: '/hit.png',
imageObjectKey: 'hit.png',
assetObjectId: 'asset-object-hit',
generationProvider: 'test',
prompt: '木鱼',
width: 512,
height: 512,
};
const woodenFishAudioAsset: WoodenFishAudioAsset = {
assetId: 'asset-sound',
audioSrc: '/hit.mp3',
audioObjectKey: 'hit.mp3',
assetObjectId: 'asset-object-sound',
source: 'test',
};
function buildWoodenFishSummary(
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
): WoodenFishWorkSummaryResponse {
return {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-1',
profileId: 'wooden-fish-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' wooden-fish-session-1 ',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
coverImageSrc: '/wooden-fish-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
function buildWoodenFishWorkProfile(
overrides: Partial<WoodenFishWorkProfileResponse> = {},
): WoodenFishWorkProfileResponse {
const summary = buildWoodenFishSummary();
const draft = {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: summary.profileId,
workTitle: summary.workTitle,
workDescription: summary.workDescription,
themeTags: summary.themeTags,
hitObjectPrompt: '星灯',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
coverImageSrc: summary.coverImageSrc,
generationStatus: summary.generationStatus,
};
return {
summary,
draft,
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
floatingWords: ['功德 +1'],
...overrides,
};
}
function buildWoodenFishSession(
overrides: Partial<WoodenFishSessionSnapshotResponse> = {},
): WoodenFishSessionSnapshotResponse {
const summary = buildWoodenFishSummary();
return {
sessionId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: buildWoodenFishWorkProfile({ summary }).draft,
createdAt: '2026-06-01T11:59:00.000Z',
updatedAt: '2026-06-01T12:00:00.000Z',
...overrides,
};
}
function buildWoodenFishCreatePayload(
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
): WoodenFishWorkspaceCreateRequest {
return {
templateId: 'wooden-fish',
workTitle: '表单星灯木鱼',
workDescription: '表单里敲亮星灯。',
themeTags: ['表单星灯'],
hitObjectPrompt: '星灯',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
...overrides,
};
}
describe('platformMiniGameSessionMappingModel', () => {
test('builds a draft puzzle runtime work from a session', () => {
expect(
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
userId: 'user-1',
displayName: '玩家一号',
}),
).toMatchObject({
workId: 'puzzle-work-12345678',
profileId: 'puzzle-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-12345678',
authorDisplayName: '玩家一号',
workTitle: '星桥拼图',
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'draft',
publishedAt: null,
publishReady: true,
});
});
test('prefers published puzzle profile id when present', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
publishedProfileId: 'published-puzzle-profile',
}),
{},
),
).toMatchObject({
profileId: 'published-puzzle-profile',
workId: 'puzzle-work-12345678',
ownerUserId: 'current-user',
authorDisplayName: '玩家',
});
});
test('returns null for puzzle runtime work without draft or cover', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: null,
}),
{},
),
).toBeNull();
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
}),
{},
),
).toBeNull();
});
test('builds jump hop pending session from work summary', () => {
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
sessionId: 'jump-hop-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-1',
themeText: '云阶机关',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: '/jump-hop-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T11:00:00.000Z',
updatedAt: '2026-06-01T11:00:00.000Z',
});
});
test('builds square hole draft profile from session', () => {
expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({
workId: 'square-hole-profile-1',
profileId: 'square-hole-profile-1',
ownerUserId: 'current-user',
sourceSessionId: 'square-hole-session-1',
gameName: '星桥方洞',
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
summary: '把星桥机关里的形状送入正确孔洞。',
tags: ['星桥', '机关'],
coverImageSrc: '/square-hole-cover.png',
backgroundPrompt: '星桥机关背景',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: buildSquareHoleDraft().shapeOptions,
holeOptions: buildSquareHoleDraft().holeOptions,
shapeCount: 6,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:30:00.000Z',
publishedAt: null,
publishReady: true,
});
});
test('returns null for square hole profile without session draft or profile id', () => {
expect(buildSquareHoleProfileFromSession(null)).toBeNull();
expect(
buildSquareHoleProfileFromSession(
buildSquareHoleSession({
draft: null,
}),
),
).toBeNull();
expect(
buildSquareHoleProfileFromSession(
buildSquareHoleSession({
draft: buildSquareHoleDraft({ profileId: '' }),
}),
),
).toBeNull();
});
test('builds visual novel recovered session from work detail', () => {
const work = buildVisualNovelWorkDetail();
expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({
sessionId: 'visual-novel-session-1',
ownerUserId: 'user-visual-novel-1',
sourceMode: 'idea',
status: 'ready',
messages: [],
draft: work.draft,
pendingAction: null,
createdAt: '2026-06-01T12:50:00.000Z',
updatedAt: '2026-06-01T13:30:00.000Z',
});
});
test('falls back visual novel recovered session id to work id', () => {
expect(
buildVisualNovelSessionFromWorkDetail(
buildVisualNovelWorkDetail({
sourceSessionId: ' ',
workId: 'visual-novel-work-fallback',
}),
).sessionId,
).toBe('visual-novel-work-fallback');
});
test('builds wooden fish pending session from work summary', () => {
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
sessionId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: '/wooden-fish-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T12:00:00.000Z',
updatedAt: '2026-06-01T12:00:00.000Z',
});
});
test('builds wooden fish generating work summary from session and payload', () => {
expect(
buildWoodenFishGeneratingWorkSummary(
buildWoodenFishSession(),
buildWoodenFishCreatePayload(),
),
).toEqual({
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-session-1',
profileId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'wooden-fish-session-1',
workTitle: '表单星灯木鱼',
workDescription: '表单里敲亮星灯。',
themeTags: ['表单星灯'],
coverImageSrc: '/wooden-fish-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
});
expect(
buildWoodenFishGeneratingWorkSummary(
buildWoodenFishSession({
draft: null,
createdAt: '2026-06-01T11:59:00.000Z',
}),
null,
),
).toMatchObject({
workTitle: '敲木鱼',
workDescription: '',
themeTags: ['敲木鱼'],
coverImageSrc: null,
updatedAt: '2026-06-01T12:00:00.000Z',
});
});
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
buildWoodenFishSummary({
sourceSessionId: ' fallback-session ',
}),
),
).toMatchObject({
sessionId: 'fallback-session',
ownerUserId: 'user-1',
status: 'generating',
});
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
null,
).sessionId,
).toBe('wooden-fish-profile-1');
});
});

View File

@@ -0,0 +1,275 @@
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleClearSessionSnapshotResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type {
VisualNovelAgentSessionSnapshot,
VisualNovelWorkDetail,
} from '../../../packages/shared/src/contracts/visualNovel';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
export type PlatformMiniGameSessionOwner = {
userId?: string | null;
displayName?: string | null;
};
export function buildPuzzleRuntimeWorkFromSession(
session: PuzzleAgentSessionSnapshot,
owner: PlatformMiniGameSessionOwner,
): PuzzleWorkSummary | null {
const draft = session.draft;
const profileId =
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
return null;
}
return {
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
profileId,
ownerUserId: owner.userId ?? 'current-user',
sourceSessionId: session.sessionId,
authorDisplayName: owner.displayName ?? '玩家',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
publicationStatus: 'draft',
updatedAt: session.updatedAt,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(session.resultPreview?.publishReady),
levels: draft.levels,
};
}
export function buildPuzzleClearSessionFromWorkDetail(
work: PuzzleClearWorkProfileResponse,
fallbackItem?: PuzzleClearWorkSummaryResponse | null,
): PuzzleClearSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
work.summary.profileId;
return {
sessionId,
ownerUserId: work.summary.ownerUserId,
status: work.summary.generationStatus,
draft: work.draft,
createdAt: work.summary.updatedAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildPuzzleClearPendingSession(
item: PuzzleClearWorkSummaryResponse,
): PuzzleClearSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themePrompt: item.themePrompt,
boardBackgroundPrompt: item.themePrompt,
generateBoardBackground: true,
boardBackgroundAsset: null,
cardBackImageSrc: null,
atlasAsset: null,
patternGroups: [],
cardAssets: [],
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
export function buildSquareHoleProfileFromSession(
session: SquareHoleSessionSnapshot | null,
): SquareHoleWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
return {
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
twistRule: draft.twistRule,
summary: draft.summary,
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? null,
backgroundPrompt: draft.backgroundPrompt,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
shapeOptions: draft.shapeOptions,
holeOptions: draft.holeOptions,
shapeCount: draft.shapeCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
};
}
export function buildVisualNovelSessionFromWorkDetail(
work: VisualNovelWorkDetail,
): VisualNovelAgentSessionSnapshot {
return {
sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId,
ownerUserId: work.summary.ownerUserId,
sourceMode: work.draft.sourceMode,
status: 'ready',
messages: [],
draft: work.draft,
pendingAction: null,
createdAt: work.createdAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildJumpHopPendingSession(
item: JumpHopWorkSummaryResponse,
): JumpHopSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: item.profileId,
themeText: item.themeText,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
difficulty: item.difficulty,
stylePreset: item.stylePreset,
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
export function buildWoodenFishSessionFromWorkDetail(
work: WoodenFishWorkProfileResponse,
fallbackItem?: WoodenFishWorkSummaryResponse | null,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
work.summary.profileId;
return {
sessionId,
ownerUserId: work.summary.ownerUserId,
status: work.summary.generationStatus,
draft: work.draft,
createdAt: work.summary.updatedAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildWoodenFishGeneratingWorkSummary(
session: WoodenFishSessionSnapshotResponse,
payload?: WoodenFishWorkspaceCreateRequest | null,
): WoodenFishWorkSummaryResponse {
const updatedAt = session.updatedAt ?? session.createdAt;
return {
runtimeKind: 'wooden-fish',
workId: session.sessionId,
profileId: session.sessionId,
ownerUserId: session.ownerUserId,
sourceSessionId: session.sessionId,
workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼',
workDescription:
payload?.workDescription ?? session.draft?.workDescription ?? '',
themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'],
coverImageSrc: session.draft?.coverImageSrc ?? null,
publicationStatus: 'draft',
playCount: 0,
updatedAt,
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
};
}
export function buildWoodenFishPendingSession(
item: WoodenFishWorkSummaryResponse,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, test } from 'vitest';
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
function buildPlayedWork(
overrides: Partial<ProfilePlayedWorkSummary> = {},
): ProfilePlayedWorkSummary {
return {
worldKey: 'custom:world-1',
ownerUserId: 'user-1',
profileId: 'world-1',
worldType: 'CUSTOM',
worldTitle: '潮雾列岛',
worldSubtitle: '旧灯塔与失控航路',
firstPlayedAt: '2026-04-18T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
lastObservedPlayTimeMs: 12_000,
...overrides,
};
}
describe('platformPlayedWorkOpenModel', () => {
test('opens puzzle played works with profile tab context', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType: 'PUZZLE',
profileId: 'puzzle-profile-1',
}),
),
).toEqual({
type: 'open-puzzle',
profileId: 'puzzle-profile-1',
tab: 'profile',
});
});
test('falls back to worldKey prefixes when profile id is absent', () => {
const cases = [
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
['match3d:profile-2', 'open-match3d', 'profile-2'],
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
] as const;
for (const [worldKey, type, profileId] of cases) {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey,
profileId: null,
worldType: null,
}),
),
).toMatchObject({ type, profileId });
}
});
test('keeps explicit profile id ahead of worldKey fallback', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'jump-hop:key-profile',
profileId: 'explicit-profile',
worldType: null,
}),
),
).toMatchObject({
type: 'open-jump-hop',
profileId: 'explicit-profile',
});
});
test('supports played work type aliases for mini-games', () => {
const cases = [
['match_3d', 'open-match3d'],
['square_hole', 'open-square-hole'],
['jump_hop', 'open-jump-hop'],
['wooden_fish', 'open-wooden-fish'],
] as const;
for (const [worldType, type] of cases) {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType,
profileId: `${worldType}-profile`,
}),
),
).toMatchObject({
type,
profileId: `${worldType}-profile`,
});
}
});
test('returns noop when a mini-game target is empty', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'puzzle:key-profile',
profileId: '',
worldType: 'puzzle',
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
});
test('builds big fish intent and fallback work for gallery misses', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'big-fish:big-fish-session-1',
ownerUserId: null,
profileId: null,
worldType: 'big_fish',
worldTitle: '机械深海',
worldSubtitle: '',
}),
),
).toEqual({
type: 'open-big-fish',
sessionId: 'big-fish-session-1',
fallbackWork: {
workId: 'big-fish:big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: '',
authorDisplayName: '玩家',
title: '机械深海',
subtitle: '',
summary: '',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-19T12:00:00.000Z',
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
});
});
test('opens unknown played work types as RPG detail when identity is complete', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType: 'CUSTOM',
profileId: null,
}),
),
).toEqual({
type: 'open-rpg',
detail: {
ownerUserId: 'user-1',
profileId: 'custom:world-1',
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-04-18T12:00:00.000Z',
updatedAt: '2026-04-19T12:00:00.000Z',
authorDisplayName: '旧灯塔与失控航路',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
},
});
});
test('returns noop for RPG fallback when owner or profile is missing', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
ownerUserId: null,
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: '',
profileId: null,
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
});
});

View File

@@ -0,0 +1,212 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type {
CustomWorldGalleryCard,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
export type PlatformPlayedWorkOpenIntent =
| {
type: 'noop';
reason: 'missing-target';
}
| {
type: 'open-puzzle';
profileId: string;
tab: 'profile';
}
| {
type: 'open-match3d';
profileId: string;
}
| {
type: 'open-square-hole';
profileId: string;
}
| {
type: 'open-jump-hop';
profileId: string;
}
| {
type: 'open-wooden-fish';
profileId: string;
}
| {
type: 'open-big-fish';
sessionId: string;
fallbackWork: BigFishWorkSummary;
}
| {
type: 'open-rpg';
detail: CustomWorldGalleryCard;
};
function normalizePlayedWorkWorldType(worldType: string | null) {
return (worldType ?? '').toLowerCase();
}
function resolvePlayedWorkTargetId(
work: ProfilePlayedWorkSummary,
worldKeyPrefix: string,
) {
const prefixedWorldKey = `${worldKeyPrefix}:`;
return (
work.profileId ??
(work.worldKey.startsWith(prefixedWorldKey)
? work.worldKey.slice(prefixedWorldKey.length)
: work.worldKey)
);
}
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
profileId: string,
intent: (profileId: string) => TIntent,
) {
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
}
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
return {
type: 'noop',
reason: 'missing-target',
};
}
function buildPlayedBigFishFallbackWork(
work: ProfilePlayedWorkSummary,
sessionId: string,
): BigFishWorkSummary {
return {
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
authorDisplayName: work.worldSubtitle || '玩家',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
};
}
function buildPlayedRpgDetail(
work: ProfilePlayedWorkSummary,
profileId: string,
ownerUserId: string,
): CustomWorldGalleryCard {
return {
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
};
}
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
export function resolvePlatformPlayedWorkOpenIntent(
work: ProfilePlayedWorkSummary,
): PlatformPlayedWorkOpenIntent {
const worldType = normalizePlayedWorkWorldType(work.worldType);
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-puzzle',
profileId: resolvedProfileId,
tab: 'profile',
}));
}
if (
worldType === 'match3d' ||
worldType === 'match_3d' ||
work.worldKey.startsWith('match3d:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-match3d',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'square-hole' ||
worldType === 'square_hole' ||
work.worldKey.startsWith('square-hole:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-square-hole',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'jump-hop' ||
worldType === 'jump_hop' ||
work.worldKey.startsWith('jump-hop:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-jump-hop',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'wooden-fish' ||
worldType === 'wooden_fish' ||
work.worldKey.startsWith('wooden-fish:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-wooden-fish',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'big_fish' ||
worldType === 'big-fish' ||
work.worldKey.startsWith('big-fish:')
) {
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
return sessionId
? {
type: 'open-big-fish',
sessionId,
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
}
: buildMissingPlayedWorkTargetIntent();
}
const profileId = work.profileId ?? work.worldKey;
const ownerUserId = work.ownerUserId;
if (!ownerUserId || !profileId) {
return buildMissingPlayedWorkTargetIntent();
}
return {
type: 'open-rpg',
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
};
}

View File

@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
adjustProfileDashboardWalletBalance,
reconcileProfileWalletLocalDeltaWithServerDashboard,
resolveProfileWalletBalance,
} from './platformProfileWalletDeltaModel';
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
function buildDashboard(
overrides: Partial<ProfileDashboardSummary> = {},
): ProfileDashboardSummary {
return {
walletBalance: 100,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-06-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe('platformProfileWalletDeltaModel', () => {
test('normalizes wallet balance to a non-negative integer', () => {
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
12,
);
expect(
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
).toBe(0);
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
expect(resolveProfileWalletBalance(null)).toBe(0);
});
test('applies local delta and refreshes dashboard timestamp', () => {
expect(
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
).toMatchObject({
walletBalance: 97,
updatedAt: '2026-06-04T04:30:00.000Z',
});
expect(
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
).toMatchObject({
walletBalance: 0,
});
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
const dashboard = buildDashboard();
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
dashboard,
);
});
test('reconciles debit delta already reflected by latest server dashboard', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 98 }),
-5,
),
).toBe(-3);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 92 }),
-5,
),
).toBe(0);
});
test('reconciles credit delta already reflected by latest server dashboard', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 103 }),
8,
),
).toBe(5);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 120 }),
8,
),
).toBe(0);
});
test('does not reconcile when server balance moves against local delta', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 104 }),
-5,
),
).toBe(-5);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 96 }),
8,
),
).toBe(8);
});
});

View File

@@ -0,0 +1,61 @@
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
type ProfileWalletBalanceSource =
| Pick<ProfileDashboardSummary, 'walletBalance'>
| { walletBalance?: number | null }
| null
| undefined;
export function resolveProfileWalletBalance(
dashboard: ProfileWalletBalanceSource,
) {
const walletBalance = dashboard?.walletBalance;
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
? Math.max(0, Math.floor(walletBalance))
: 0;
}
export function adjustProfileDashboardWalletBalance(
dashboard: ProfileDashboardSummary | null,
delta: number,
): ProfileDashboardSummary | null {
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
return dashboard;
}
return {
...dashboard,
walletBalance: Math.max(
0,
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
),
updatedAt: new Date().toISOString(),
};
}
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
previousDashboard: ProfileDashboardSummary | null,
latestDashboard: ProfileDashboardSummary | null,
localDelta: number,
) {
if (
!previousDashboard ||
!latestDashboard ||
!Number.isFinite(localDelta) ||
localDelta === 0
) {
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
}
const previousBalance = resolveProfileWalletBalance(previousDashboard);
const latestBalance = resolveProfileWalletBalance(latestDashboard);
const normalizedDelta = Math.trunc(localDelta);
if (normalizedDelta < 0) {
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
return Math.min(0, normalizedDelta + reflectedDebit);
}
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
return Math.max(0, normalizedDelta - reflectedCredit);
}

View File

@@ -0,0 +1,483 @@
import { describe, expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import {
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
buildWoodenFishPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
import {
mapRpgPublicCodeSearchDetailToGalleryCard,
type PlatformPublicCodeSearchStep,
resolveBabyObjectMatchPublicCodeSearchMatch,
resolveBarkBattlePublicCodeSearchMatch,
resolveBigFishPublicCodeSearchMatch,
resolveJumpHopPublicCodeSearchMatch,
resolveMatch3DPublicCodeSearchMatch,
resolvePlatformPublicCodeSearchPlan,
resolvePuzzlePublicCodeSearchMatch,
resolveSquareHolePublicCodeSearchMatch,
resolveVisualNovelPublicCodeSearchMatch,
resolveWoodenFishPublicCodeSearchMatch,
} from './platformPublicCodeSearchModel';
function expectSearchSteps(
keyword: string,
steps: readonly PlatformPublicCodeSearchStep[],
) {
expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps);
}
describe('platformPublicCodeSearchModel', () => {
test('ignores empty public code search input', () => {
expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull();
});
test('normalizes public code search keyword before planning', () => {
expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({
normalizedKeyword: 'PZ-00000001',
steps: ['puzzle-work'],
});
});
test('searches internal user ids directly without work fallback', () => {
expectSearchSteps('user_00000001', ['user-id']);
expectSearchSteps('USER-profile-1', ['user-id']);
});
test('routes known public work prefixes to their play-specific lookup', () => {
const cases: Array<
[keyword: string, step: PlatformPublicCodeSearchStep]
> = [
['PZ-EPUBLIC1', 'puzzle-work'],
['BF-NPUBLIC1', 'big-fish-work'],
['JH-EPUBLIC1', 'jump-hop-work'],
['WF-EPUBLIC1', 'wooden-fish-work'],
['BO-EPUBLIC1', 'baby-object-match-work'],
['M3-EPUBLIC1', 'match3d-work'],
['M3D-LEGACY1', 'match3d-work'],
['SH-EPUBLIC1', 'square-hole-work'],
['VN-EPUBLIC1', 'visual-novel-work'],
['BB-EPUBLIC1', 'bark-battle-work'],
];
for (const [keyword, step] of cases) {
expectSearchSteps(keyword, [step]);
}
});
test('searches RPG public works before public user codes for CW and numeric codes', () => {
expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']);
expectSearchSteps('12345678', ['rpg-work', 'public-user-code']);
});
test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => {
const legacyFallbackSteps = [
'public-user-code',
'rpg-work',
'bark-battle-work',
'public-user-code',
] as const;
expectSearchSteps('SY-00000001', legacyFallbackSteps);
expectSearchSteps('月井守望', legacyFallbackSteps);
});
test('maps RPG detail responses to gallery cards with count defaults', () => {
expect(
mapRpgPublicCodeSearchDetailToGalleryCard(
buildRpgDetailEntry({
playCount: undefined,
remixCount: undefined,
likeCount: undefined,
}),
),
).toMatchObject({
profileId: 'rpg-profile-1',
visibility: 'published',
worldName: '潮雾世界',
playCount: 0,
remixCount: 0,
likeCount: 0,
});
});
test('resolves public code matches for every play-specific gallery type', () => {
const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' });
const bigFish = buildBigFishWork({
sourceSessionId: 'big-fish-session-12345678',
});
const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' });
const woodenFish = buildWoodenFishCard({
profileId: 'wooden-fish-profile-12345678',
});
const babyObjectMatch = buildBabyObjectMatchDraft({
profileId: 'baby-object-profile-12345678',
});
const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' });
const squareHole = buildSquareHoleWork({
profileId: 'square-hole-profile-12345678',
});
const visualNovel = buildVisualNovelWork({
profileId: 'visual-novel-profile-12345678',
});
const barkBattle = buildBarkBattleWork({
workId: 'bark-battle-work-12345678',
});
expect(
resolvePuzzlePublicCodeSearchMatch(
[puzzle],
buildPuzzlePublicWorkCode(puzzle.profileId),
)?.detail,
).toMatchObject({ sourceType: 'puzzle' });
expect(
resolveBigFishPublicCodeSearchMatch(
[bigFish],
buildBigFishPublicWorkCode(bigFish.sourceSessionId),
)?.detail,
).toMatchObject({ sourceType: 'big-fish' });
expect(
resolveJumpHopPublicCodeSearchMatch(
[jumpHop],
buildJumpHopPublicWorkCode(jumpHop.profileId),
)?.detail,
).toMatchObject({ sourceType: 'jump-hop' });
expect(
resolveWoodenFishPublicCodeSearchMatch(
[woodenFish],
buildWoodenFishPublicWorkCode(woodenFish.profileId),
)?.detail,
).toMatchObject({ sourceType: 'wooden-fish' });
expect(
resolveBabyObjectMatchPublicCodeSearchMatch(
[babyObjectMatch],
`BO-${babyObjectMatch.profileId.slice(-8)}`,
)?.detail,
).toMatchObject({ sourceType: 'edutainment' });
expect(
resolveMatch3DPublicCodeSearchMatch(
[match3d],
buildMatch3DPublicWorkCode(match3d.profileId),
)?.detail,
).toMatchObject({ sourceType: 'match3d' });
expect(
resolveSquareHolePublicCodeSearchMatch(
[squareHole],
buildSquareHolePublicWorkCode(squareHole.profileId),
)?.detail,
).toMatchObject({ sourceType: 'square-hole' });
expect(
resolveVisualNovelPublicCodeSearchMatch(
[visualNovel],
buildVisualNovelPublicWorkCode(visualNovel.profileId),
)?.detail,
).toMatchObject({ sourceType: 'visual-novel' });
expect(
resolveBarkBattlePublicCodeSearchMatch(
[barkBattle],
buildBarkBattlePublicWorkCode(barkBattle.workId),
)?.detail,
).toMatchObject({ sourceType: 'bark-battle' });
});
test('public code search matchers skip entries hidden by visibility policy', () => {
const hiddenPuzzle = buildPuzzleWork({
profileId: 'hidden-profile-12345678',
});
expect(
resolvePuzzlePublicCodeSearchMatch(
[hiddenPuzzle],
buildPuzzlePublicWorkCode(hiddenPuzzle.profileId),
() => false,
),
).toBeNull();
});
});
function buildRpgDetailEntry(
overrides: Partial<CustomWorldLibraryEntry<CustomWorldProfile>> = {},
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
ownerUserId: 'rpg-owner-1',
profileId: 'rpg-profile-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
profile: {} as CustomWorldProfile,
visibility: 'published',
publishedAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
authorDisplayName: '测试作者',
worldName: '潮雾世界',
subtitle: '潮雾港',
summaryText: '潮雾世界说明。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
playCount: 1,
remixCount: 1,
likeCount: 1,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾拼图说明。',
levelName: '潮雾拼图',
summary: '潮雾拼图说明。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levels: [],
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾大鱼',
subtitle: '潮雾港',
summary: '潮雾大鱼说明。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levelCount: 1,
levelMainImageReadyCount: 1,
levelMotionReadyCount: 1,
backgroundReady: true,
...overrides,
};
}
function buildJumpHopCard(
overrides: Partial<JumpHopGalleryCardResponse> = {},
): JumpHopGalleryCardResponse {
const profileId = overrides.profileId ?? 'jump-hop-profile-1';
return {
publicWorkCode: buildJumpHopPublicWorkCode(profileId),
workId: 'jump-hop-work-1',
profileId,
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
themeText: '潮雾港',
workTitle: '潮雾跳一跳',
workDescription: '潮雾跳一跳说明。',
coverImageSrc: null,
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildWoodenFishCard(
overrides: Partial<WoodenFishGalleryCardResponse> = {},
): WoodenFishGalleryCardResponse {
const profileId = overrides.profileId ?? 'wooden-fish-profile-1';
return {
publicWorkCode: buildWoodenFishPublicWorkCode(profileId),
workId: 'wooden-fish-work-1',
profileId,
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾木鱼',
workDescription: '潮雾木鱼说明。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
return {
draftId: 'baby-draft-1',
profileId: 'baby-object-profile-1',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '潮雾识物',
workDescription: '潮雾识物说明。',
itemNames: ['苹果', '香蕉'],
itemAssets: [
buildBabyObjectMatchItemAsset('item-a', '苹果'),
buildBabyObjectMatchItemAsset('item-b', '香蕉'),
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'published',
createdAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}
function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) {
return {
itemId,
itemName,
imageSrc: `/media/${itemId}.png`,
assetObjectId: null,
generationProvider: 'placeholder' as const,
prompt: itemName,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '潮雾抓大鹅',
themeText: '潮雾港',
summary: '潮雾抓大鹅说明。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
generatedItemAssets: [],
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work-1',
profileId: 'square-hole-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session-1',
gameName: '潮雾方洞',
themeText: '潮雾港',
twistRule: '避开雾门',
summary: '潮雾方洞说明。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '潮雾港',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 1,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
publishReady: true,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-1',
ownerUserId: 'user-1',
title: '潮雾视觉小说',
description: '潮雾视觉小说说明。',
coverImageSrc: null,
tags: [],
publishStatus: 'published',
publishReady: true,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾声浪',
summary: '潮雾声浪说明。',
themeDescription: '潮雾港',
playerImageDescription: '小狗',
opponentImageDescription: '对手',
onomatopoeia: ['汪'],
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}

View File

@@ -0,0 +1,313 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import {
isSameBabyObjectMatchPublicWorkCode,
isSameBarkBattlePublicWorkCode,
isSameBigFishPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameMatch3DPublicWorkCode,
isSamePuzzlePublicWorkCode,
isSameSquareHolePublicWorkCode,
isSameVisualNovelPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { canExposePublicWork } from './platformEdutainmentVisibility';
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
import {
mapBarkBattleWorkToPublicWorkDetail,
mapBigFishWorkToPublicWorkDetail,
mapJumpHopWorkToPublicWorkDetail,
mapPuzzleWorkToPublicWorkDetail,
mapSquareHoleWorkToPublicWorkDetail,
mapVisualNovelWorkToPublicWorkDetail,
mapWoodenFishWorkToPublicWorkDetail,
} from './platformPublicWorkDetailFlow';
export type PlatformPublicCodeSearchStep =
| 'user-id'
| 'public-user-code'
| 'rpg-work'
| 'puzzle-work'
| 'big-fish-work'
| 'jump-hop-work'
| 'wooden-fish-work'
| 'baby-object-match-work'
| 'match3d-work'
| 'square-hole-work'
| 'visual-novel-work'
| 'bark-battle-work';
export type PlatformPublicCodeSearchPlan = {
normalizedKeyword: string;
steps: readonly PlatformPublicCodeSearchStep[];
};
export type PlatformPublicCodeSearchMatch<TItem> = {
item: TItem;
detail: PlatformPublicGalleryCard;
};
type PlatformPublicCodeSearchMatcherInput<TItem> = {
keyword: string;
entries: readonly TItem[];
mapEntry: (item: TItem) => PlatformPublicGalleryCard;
matchesEntry: (keyword: string, item: TItem) => boolean;
canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean;
};
const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu;
const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u;
const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray<
readonly [prefix: string, step: PlatformPublicCodeSearchStep]
> = [
['PZ', 'puzzle-work'],
['BF', 'big-fish-work'],
['JH', 'jump-hop-work'],
['WF', 'wooden-fish-work'],
['BO', 'baby-object-match-work'],
['M3', 'match3d-work'],
['SH', 'square-hole-work'],
['VN', 'visual-novel-work'],
['BB', 'bark-battle-work'],
];
/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */
export function resolvePlatformPublicCodeSearchPlan(
keyword: string,
): PlatformPublicCodeSearchPlan | null {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return null;
}
if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) {
return {
normalizedKeyword,
steps: ['user-id'],
};
}
const upperKeyword = normalizedKeyword.toUpperCase();
const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) =>
upperKeyword.startsWith(prefix),
)?.[1];
if (directWorkStep) {
return {
normalizedKeyword,
steps: [directWorkStep],
};
}
if (
upperKeyword.startsWith('CW') ||
PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword)
) {
return {
normalizedKeyword,
steps: ['rpg-work', 'public-user-code'],
};
}
return {
normalizedKeyword,
steps: [
'public-user-code',
'rpg-work',
'bark-battle-work',
'public-user-code',
],
};
}
export function mapRpgPublicCodeSearchDetailToGalleryCard(
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
): CustomWorldGalleryCard {
return {
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
};
}
export function resolvePuzzlePublicCodeSearchMatch(
entries: readonly PuzzleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapPuzzleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSamePuzzlePublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBigFishPublicCodeSearchMatch(
entries: readonly BigFishWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBigFishWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId),
canExposeEntry,
});
}
export function resolveJumpHopPublicCodeSearchMatch(
entries: readonly JumpHopGalleryCardResponse[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapJumpHopWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameJumpHopPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveWoodenFishPublicCodeSearchMatch(
entries: readonly WoodenFishGalleryCardResponse[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapWoodenFishWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBabyObjectMatchPublicCodeSearchMatch(
entries: readonly BabyObjectMatchDraft[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard,
matchesEntry: (searchKeyword, item) =>
isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveMatch3DPublicCodeSearchMatch(
entries: readonly Match3DWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapMatch3DWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameMatch3DPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveSquareHolePublicCodeSearchMatch(
entries: readonly SquareHoleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapSquareHoleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameSquareHolePublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveVisualNovelPublicCodeSearchMatch(
entries: readonly VisualNovelWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapVisualNovelWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBarkBattlePublicCodeSearchMatch(
entries: readonly BarkBattleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBarkBattleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameBarkBattlePublicWorkCode(searchKeyword, item.workId),
canExposeEntry,
});
}
function resolveMappedPublicCodeSearchMatch<TItem>({
keyword,
entries,
mapEntry,
matchesEntry,
canExposeEntry = canExposePublicWork,
}: PlatformPublicCodeSearchMatcherInput<TItem>):
| PlatformPublicCodeSearchMatch<TItem>
| null {
for (const item of entries) {
const detail = mapEntry(item);
if (canExposeEntry(detail) && matchesEntry(keyword, item)) {
return { item, detail };
}
}
return null;
}

View File

@@ -0,0 +1,911 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
buildPlatformPublicGalleryFeeds,
getPlatformPublicGalleryEntryKey,
getPlatformPublicGalleryEntryTime,
getPlatformRecommendRuntimeKind,
isPlatformRecommendRuntimeReadyForEntry,
isSamePlatformPublicGalleryEntry,
mergePlatformPublicGalleryEntries,
type PlatformRecommendRuntimeStartIntentDeps,
type RecommendRuntimeKind,
resolvePlatformRecommendRuntimeAutoStartDecision,
resolvePlatformRecommendRuntimeStartIntent,
} from './platformPublicGalleryFlow';
import {
mapBarkBattlePublicDetailToWorkSummary,
mapPublicWorkDetailToBigFishWork,
mapPublicWorkDetailToPuzzleWork,
mapPublicWorkDetailToSquareHoleWork,
} from './platformPublicWorkDetailFlow';
type TypedPlatformPublicGalleryCard = Extract<
PlatformPublicGalleryCard,
{ sourceType: string }
>;
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
type TypedPlatformPublicGalleryCardOverrides = Partial<
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
>;
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'rpg-profile',
publicWorkCode: 'CW-RPG',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
authorDisplayName: '玩家',
worldName: 'RPG 世界',
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work',
sourceSessionId: 'big-fish-session',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '大鱼吃小鱼',
subtitle: '海湾',
summary: '一路长大。',
coverImageSrc: '/big-fish-cover.png',
status: 'published',
updatedAt: '2026-06-01T02:00:00.000Z',
publishedAt: '2026-06-01T02:00:00.000Z',
publishReady: true,
levelCount: 1,
levelMainImageReadyCount: 1,
levelMotionReadyCount: 1,
backgroundReady: true,
playCount: 1,
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
const itemAsset = {
itemId: 'item-a',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder' as const,
prompt: '苹果',
};
return {
draftId: 'baby-draft',
profileId: 'baby-profile',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '认识水果。',
itemNames: ['苹果', '香蕉'],
itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'published',
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T03:00:00.000Z',
publishedAt: '2026-06-01T03:00:00.000Z',
...overrides,
};
}
function buildJumpHopEntry(
overrides: Partial<JumpHopGalleryCardResponse> = {},
): JumpHopGalleryCardResponse {
return {
publicWorkCode: 'JH-JUMP',
workId: 'jump-hop-work',
profileId: 'jump-hop-profile',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
themeText: '一路向前',
workTitle: '跳一跳',
workDescription: '一路向前。',
coverImageSrc: '/jump-hop-cover.png',
themeTags: ['跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
publicationStatus: 'published',
playCount: 1,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildTypedEntry(
sourceType: PlatformGallerySourceType,
overrides: TypedPlatformPublicGalleryCardOverrides = {},
): PlatformPublicGalleryCard {
const common = {
workId: `${sourceType}-work`,
profileId: `${sourceType}-profile`,
publicWorkCode: `${sourceType}-code`,
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: `${sourceType} 作品`,
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeTags: [sourceType],
visibility: 'published' as const,
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
};
switch (sourceType) {
case 'puzzle':
return { ...common, ...overrides, sourceType };
case 'puzzle-clear':
return {
...common,
...overrides,
sourceType,
themePrompt: '拼消消主题',
};
case 'big-fish':
return { ...common, ...overrides, sourceType };
case 'match3d':
return { ...common, ...overrides, sourceType };
case 'square-hole':
return { ...common, ...overrides, sourceType };
case 'visual-novel':
return { ...common, ...overrides, sourceType };
case 'jump-hop':
return { ...common, ...overrides, sourceType };
case 'wooden-fish':
return { ...common, ...overrides, sourceType };
case 'edutainment':
return {
...common,
...overrides,
sourceType,
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
};
case 'bark-battle':
return {
...common,
...overrides,
sourceType,
authorPublicUserCode: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
};
default: {
const exhaustive: never = sourceType;
return exhaustive;
}
}
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
levelName: '拼图作品',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'published',
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
playCount: 3,
remixCount: 2,
likeCount: 1,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: true,
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '经典消除',
summary: '抓大鹅摘要',
tags: ['抓大鹅'],
coverImageSrc: '/match3d-cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 10,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
publishReady: true,
generatedItemAssets: [],
...overrides,
};
}
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'bark-battle-work',
draftId: 'bark-battle-draft',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '汪汪声浪作品',
summary: '汪汪摘要',
themeDescription: '森林擂台',
playerImageDescription: '小狗',
opponentImageDescription: '对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/bark-bg.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 9,
recentPlayCount7d: 2,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
...overrides,
};
}
function buildRecommendRuntimeStartDeps(
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
): PlatformRecommendRuntimeStartIntentDeps {
return {
selectedPuzzleDetail: null,
barkBattleGalleryEntries: [],
mapMatch3DWork: () => buildMatch3DWork(),
...overrides,
};
}
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
const cases: Array<
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
> = [
['big-fish', 'big-fish', 'big-fish'],
['puzzle', 'puzzle', 'puzzle'],
['jump-hop', 'jump-hop', 'jump-hop'],
['wooden-fish', 'wooden-fish', 'wooden-fish'],
['match3d', 'match3d', 'match3d'],
['square-hole', 'square-hole', 'square-hole'],
['visual-novel', 'visual-novel', 'visual-novel'],
['bark-battle', 'bark-battle', 'bark-battle'],
[
'edutainment',
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
'edutainment',
],
];
cases.forEach(([sourceType, keyKind, kind]) => {
const entry = buildTypedEntry(sourceType);
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
`${keyKind}:user-1:${sourceType}-profile`,
);
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
});
const rpgEntry = buildRpgEntry();
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
'rpg:user-1:rpg-profile',
);
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
});
test('platform public gallery flow compares entries by resolved identity', () => {
const left = buildTypedEntry('puzzle');
const sameIdentity = buildTypedEntry('puzzle', {
workId: 'other-work',
worldName: '新标题',
});
const otherKind = buildTypedEntry('match3d', {
ownerUserId: left.ownerUserId,
profileId: left.profileId,
});
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
});
test('platform public gallery flow resolves recommend runtime start intent', () => {
const bigFishEntry = buildTypedEntry('big-fish');
expect(
resolvePlatformRecommendRuntimeStartIntent(
bigFishEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
returnStage: 'platform',
embedded: true,
});
const selectedPuzzleDetail = buildPuzzleWork({
profileId: 'puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('puzzle'),
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
),
).toEqual({
type: 'start-puzzle',
work: selectedPuzzleDetail,
returnStage: 'platform',
embedded: true,
});
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'fallback-puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
puzzleEntry,
buildRecommendRuntimeStartDeps({
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
}),
),
).toEqual({
type: 'start-puzzle',
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('jump-hop'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-jump-hop',
profileId: 'jump-hop-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('wooden-fish'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-wooden-fish',
profileId: 'wooden-fish-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('visual-novel'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-visual-novel',
profileId: 'visual-novel-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('edutainment'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-edutainment',
entry: buildTypedEntry('edutainment'),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildRpgEntry(),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'mark-ready',
});
});
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
const match3DEntry = buildTypedEntry('match3d');
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({
mapMatch3DWork: (entry) =>
entry === match3DEntry ? match3DWork : null,
}),
),
).toEqual({
type: 'start-match3d',
work: match3DWork,
returnStage: 'work-detail',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
),
).toEqual({
type: 'blocked',
errorTarget: 'match3d',
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
});
const squareHoleEntry = buildTypedEntry('square-hole');
expect(
resolvePlatformRecommendRuntimeStartIntent(
squareHoleEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-square-hole',
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
const entry = buildTypedEntry('bark-battle');
const galleryWork = buildBarkBattleWork({
workId: 'bark-battle-work',
title: '推荐缓存',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps({
barkBattleGalleryEntries: [galleryWork],
}),
),
).toEqual({
type: 'start-bark-battle',
work: galleryWork,
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-bark-battle',
work: mapBarkBattlePublicDetailToWorkSummary(entry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime readiness', () => {
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'puzzle',
hasBigFishRun: true,
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'big-fish',
hasBigFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
activeKind: 'jump-hop',
hasJumpHopRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
activeKind: 'wooden-fish',
hasWoodenFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
activeKind: 'match3d',
hasMatch3DRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
activeKind: 'square-hole',
hasSquareHoleRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
activeKind: 'visual-novel',
hasVisualNovelRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
activeKind: 'bark-battle',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
activeKind: 'rpg',
}),
).toBe(true);
});
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'puzzle-profile',
});
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'another-profile',
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: false,
}),
).toBe(false);
});
test('platform public gallery flow resolves recommend runtime auto-start gates', () => {
const entry = buildTypedEntry('big-fish');
const baseInput: Parameters<
typeof resolvePlatformRecommendRuntimeAutoStartDecision
>[0] = {
isDesktopLayout: false,
selectionStage: 'platform',
platformTab: 'home',
isLoadingPlatform: false,
entries: [entry],
activeEntryKey: null,
isStarting: false,
readyState: { activeKind: null },
};
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
isDesktopLayout: true,
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
platformTab: 'discover',
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
entries: [],
}),
).toEqual({ type: 'clear' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
isStarting: true,
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
activeEntryKey: getPlatformPublicGalleryEntryKey(entry),
hasStartError: true,
}),
).toEqual({ type: 'noop' });
});
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
const firstEntry = buildTypedEntry('big-fish', {
profileId: 'big-fish-first',
});
const activeEntry = buildTypedEntry('puzzle', {
profileId: 'puzzle-active',
});
const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry);
const baseInput: Parameters<
typeof resolvePlatformRecommendRuntimeAutoStartDecision
>[0] = {
isDesktopLayout: false,
selectionStage: 'platform',
platformTab: 'home',
isLoadingPlatform: false,
entries: [firstEntry, activeEntry],
activeEntryKey,
isStarting: false,
readyState: { activeKind: 'puzzle' },
};
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
readyState: {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'puzzle-active',
},
}),
).toEqual({ type: 'noop' });
expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({
type: 'start',
entry: activeEntry,
});
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
readyState: { activeKind: null },
}),
).toEqual({
type: 'start',
entry: activeEntry,
});
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
activeEntryKey: 'missing-entry',
}),
).toEqual({
type: 'start',
entry: firstEntry,
});
});
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
const staleRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '旧版 RPG',
publishedAt: '2026-06-01T00:00:00.000Z',
});
const freshRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '新版 RPG',
publishedAt: '2026-06-04T00:00:00.000Z',
});
const middleRpgEntry = buildRpgEntry({
profileId: 'middle-rpg',
worldName: '中间 RPG',
publishedAt: '2026-06-02T00:00:00.000Z',
});
const updatedOnlyEntry = buildTypedEntry('big-fish', {
profileId: 'updated-only',
publishedAt: null,
updatedAt: '2026-06-03T00:00:00.000Z',
});
const invalidTimeEntry = buildTypedEntry('puzzle', {
profileId: 'invalid-time',
publishedAt: 'not-a-date',
updatedAt: 'still-not-a-date',
});
const merged = mergePlatformPublicGalleryEntries(
[staleRpgEntry, middleRpgEntry],
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
);
expect(merged).toHaveLength(4);
expect(merged.map((entry) => entry.profileId)).toEqual([
'shared-rpg',
'updated-only',
'middle-rpg',
'invalid-time',
]);
expect(merged[0]?.worldName).toBe('新版 RPG');
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
});
test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => {
const hiddenBigFish = buildBigFishWork({
workId: 'hidden-big-fish',
sourceSessionId: 'hidden-big-fish-session',
});
const hiddenBabyDraft = buildBabyObjectMatchDraft({
profileId: 'hidden-baby',
});
const publishedBarkFallback = buildBarkBattleWork({
workId: 'fallback-bark',
publishedAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
});
const draftBarkFallback = buildBarkBattleWork({
workId: 'draft-bark',
status: 'draft',
});
const hiddenFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [
buildRpgEntry({
profileId: 'rpg-visible',
publishedAt: '2026-06-01T00:00:00.000Z',
}),
],
bigFishEntries: [hiddenBigFish],
match3dEntries: [],
puzzleEntries: [],
puzzleClearEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [draftBarkFallback, publishedBarkFallback],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [hiddenBabyDraft],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(
hiddenFeeds.latestEntries.map((entry) =>
'sourceType' in entry ? entry.sourceType : 'rpg',
),
).toEqual(['bark-battle', 'rpg']);
expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark');
const visibleFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [],
bigFishEntries: [hiddenBigFish],
match3dEntries: [],
puzzleEntries: [],
puzzleClearEntries: [],
barkBattleGalleryEntries: [
buildBarkBattleWork({
workId: 'gallery-bark',
publishedAt: '2026-06-05T00:00:00.000Z',
updatedAt: '2026-06-05T00:00:00.000Z',
}),
],
barkBattleWorks: [publishedBarkFallback],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [hiddenBabyDraft],
isBigFishCreationVisible: true,
isBabyObjectMatchVisible: true,
isVisualNovelCreationOpen: false,
});
expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
'gallery-bark',
'hidden-baby',
'hidden-big-fish-session',
]);
expect(visibleFeeds.featuredEntries).toEqual(
visibleFeeds.latestEntries.slice(0, 6),
);
});
test('platform public gallery flow preserves feed tie order and featured slice', () => {
const sameTime = '2026-06-04T00:00:00.000Z';
const tieFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [],
bigFishEntries: [],
match3dEntries: [],
puzzleEntries: [],
puzzleClearEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [
buildBarkBattleWork({
workId: 'fallback-bark',
publishedAt: sameTime,
updatedAt: sameTime,
}),
],
jumpHopEntries: [
buildJumpHopEntry({
profileId: 'jump-hop',
publishedAt: sameTime,
updatedAt: sameTime,
}),
],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
'jump-hop',
'fallback-bark',
]);
const sliceFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: Array.from({ length: 7 }, (_, index) =>
buildRpgEntry({
profileId: `rpg-${index}`,
publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
}),
),
bigFishEntries: [],
match3dEntries: [],
puzzleEntries: [],
puzzleClearEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(sliceFeeds.featuredEntries).toHaveLength(6);
});

View File

@@ -0,0 +1,594 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { PuzzleClearGalleryCardResponse } from '../../services/puzzle-clear/puzzleClearClient';
import {
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleClearGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapBigFishWorkToPlatformGalleryCard,
mapJumpHopWorkToPlatformGalleryCard,
mapPuzzleClearWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard,
mapSquareHoleWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
import {
mapBarkBattlePublicDetailToWorkSummary,
mapPublicWorkDetailToBigFishWork,
mapPublicWorkDetailToPuzzleWork,
mapPublicWorkDetailToSquareHoleWork,
} from './platformPublicWorkDetailFlow';
export type RecommendRuntimeKind =
| 'bark-battle'
| 'big-fish'
| 'edutainment'
| 'jump-hop'
| 'match3d'
| 'puzzle'
| 'puzzle-clear'
| 'square-hole'
| 'wooden-fish'
| 'visual-novel'
| 'rpg';
export type PlatformRecommendRuntimeStartErrorTarget =
| 'bark-battle'
| 'big-fish'
| 'match3d'
| 'puzzle'
| 'puzzle-clear'
| 'square-hole';
export type PlatformRecommendRuntimeStartIntent =
| {
type: 'blocked';
errorTarget: PlatformRecommendRuntimeStartErrorTarget;
errorMessage: string;
}
| {
type: 'start-big-fish';
work: BigFishWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-puzzle';
work: PuzzleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-puzzle-clear';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-jump-hop';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-wooden-fish';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-match3d';
work: Match3DWorkSummary;
returnStage: 'work-detail';
embedded: true;
}
| {
type: 'start-square-hole';
work: SquareHoleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-visual-novel';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-bark-battle';
work: BarkBattleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-edutainment';
entry: PlatformPublicGalleryCard;
returnStage: 'platform';
embedded: true;
}
| {
type: 'mark-ready';
};
export type PlatformRecommendRuntimeStartIntentDeps = {
selectedPuzzleDetail?: PuzzleWorkSummary | null;
barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[];
mapMatch3DWork: (
entry: PlatformPublicGalleryCard,
) => Match3DWorkSummary | null;
};
export type PlatformRecommendRuntimeReadyState = {
activeKind: RecommendRuntimeKind | null;
hasBabyObjectMatchDraft?: boolean;
hasBigFishRun?: boolean;
hasJumpHopRun?: boolean;
hasMatch3DRun?: boolean;
hasPuzzleClearRun?: boolean;
hasSquareHoleRun?: boolean;
hasVisualNovelRun?: boolean;
hasWoodenFishRun?: boolean;
puzzleRunEntryProfileId?: string | null;
puzzleRunCurrentLevelProfileId?: string | null;
};
export type PlatformRecommendRuntimeAutoStartDecision =
| { type: 'noop' }
| { type: 'clear' }
| { type: 'start'; entry: PlatformPublicGalleryCard };
export type PlatformRecommendRuntimeAutoStartInput = {
isDesktopLayout: boolean;
selectionStage: string;
platformTab: string;
isLoadingPlatform: boolean;
entries: readonly PlatformPublicGalleryCard[];
activeEntryKey: string | null;
isStarting: boolean;
hasStartError?: boolean;
readyState: PlatformRecommendRuntimeReadyState;
};
export type PlatformPublicGalleryFeedsInput = {
rpgEntries: readonly CustomWorldGalleryCard[];
bigFishEntries: readonly BigFishWorkSummary[];
match3dEntries: readonly Match3DWorkSummary[];
puzzleEntries: readonly PuzzleWorkSummary[];
puzzleClearEntries: readonly PuzzleClearGalleryCardResponse[];
barkBattleGalleryEntries: readonly BarkBattleWorkSummary[];
barkBattleWorks: readonly BarkBattleWorkSummary[];
jumpHopEntries: readonly JumpHopGalleryCardResponse[];
woodenFishEntries: readonly WoodenFishGalleryCardResponse[];
squareHoleEntries: readonly SquareHoleWorkSummary[];
visualNovelEntries: readonly VisualNovelWorkSummary[];
babyObjectMatchDrafts: readonly BabyObjectMatchDraft[];
isBigFishCreationVisible: boolean;
isBabyObjectMatchVisible: boolean;
isVisualNovelCreationOpen: boolean;
};
export type PlatformPublicGalleryFeeds = {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
};
export function getPlatformPublicGalleryEntryTime(
entry: PlatformPublicGalleryCard,
) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
const timestamp = new Date(rawTime).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export function getPlatformPublicGalleryEntryKey(
entry: PlatformPublicGalleryCard,
) {
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isPuzzleClearGalleryEntry(entry)
? 'puzzle-clear'
: isJumpHopGalleryEntry(entry)
? 'jump-hop'
: isWoodenFishGalleryEntry(entry)
? 'wooden-fish'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
export function getPlatformRecommendRuntimeKind(
entry: PlatformPublicGalleryCard,
): RecommendRuntimeKind {
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isPuzzleClearGalleryEntry(entry)) {
return 'puzzle-clear';
}
if (isJumpHopGalleryEntry(entry)) {
return 'jump-hop';
}
if (isWoodenFishGalleryEntry(entry)) {
return 'wooden-fish';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isEdutainmentGalleryEntry(entry)) {
return 'edutainment';
}
return 'rpg';
}
export function resolvePlatformRecommendRuntimeStartIntent(
entry: PlatformPublicGalleryCard,
deps: PlatformRecommendRuntimeStartIntentDeps,
): PlatformRecommendRuntimeStartIntent {
if (isBigFishGalleryEntry(entry)) {
const work = mapPublicWorkDetailToBigFishWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'big-fish',
errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。',
};
}
return {
type: 'start-big-fish',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isPuzzleGalleryEntry(entry)) {
const work =
deps.selectedPuzzleDetail?.profileId === entry.profileId
? deps.selectedPuzzleDetail
: mapPublicWorkDetailToPuzzleWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'puzzle',
errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-puzzle',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isPuzzleClearGalleryEntry(entry)) {
return {
type: 'start-puzzle-clear',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isJumpHopGalleryEntry(entry)) {
return {
type: 'start-jump-hop',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isWoodenFishGalleryEntry(entry)) {
return {
type: 'start-wooden-fish',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isMatch3DGalleryEntry(entry)) {
// 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter避免复制素材归一规则。
const work = deps.mapMatch3DWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'match3d',
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-match3d',
work,
returnStage: 'work-detail',
embedded: true,
};
}
if (isSquareHoleGalleryEntry(entry)) {
const work = mapPublicWorkDetailToSquareHoleWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'square-hole',
errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-square-hole',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isVisualNovelGalleryEntry(entry)) {
return {
type: 'start-visual-novel',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isBarkBattleGalleryEntry(entry)) {
const work =
deps.barkBattleGalleryEntries?.find(
(item) => item.workId === entry.workId,
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'bark-battle',
errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-bark-battle',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isEdutainmentGalleryEntry(entry)) {
return {
type: 'start-edutainment',
entry,
returnStage: 'platform',
embedded: true,
};
}
return {
type: 'mark-ready',
};
}
export function isPlatformRecommendRuntimeReadyForEntry(
entry: PlatformPublicGalleryCard,
state: PlatformRecommendRuntimeReadyState,
) {
const expectedKind = getPlatformRecommendRuntimeKind(entry);
if (state.activeKind !== expectedKind) {
return false;
}
if (expectedKind === 'big-fish') {
return Boolean(state.hasBigFishRun);
}
if (expectedKind === 'jump-hop') {
return Boolean(state.hasJumpHopRun);
}
if (expectedKind === 'wooden-fish') {
return Boolean(state.hasWoodenFishRun);
}
if (expectedKind === 'match3d') {
return Boolean(state.hasMatch3DRun);
}
if (expectedKind === 'puzzle') {
return (
state.puzzleRunEntryProfileId === entry.profileId ||
state.puzzleRunCurrentLevelProfileId === entry.profileId
);
}
if (expectedKind === 'puzzle-clear') {
return Boolean(state.hasPuzzleClearRun);
}
if (expectedKind === 'square-hole') {
return Boolean(state.hasSquareHoleRun);
}
if (expectedKind === 'visual-novel') {
return Boolean(state.hasVisualNovelRun);
}
if (expectedKind === 'bark-battle') {
return true;
}
if (expectedKind === 'edutainment') {
return Boolean(state.hasBabyObjectMatchDraft);
}
return true;
}
export function resolvePlatformRecommendRuntimeAutoStartDecision(
input: PlatformRecommendRuntimeAutoStartInput,
): PlatformRecommendRuntimeAutoStartDecision {
if (
input.isDesktopLayout ||
input.selectionStage !== 'platform' ||
input.platformTab !== 'home' ||
input.isLoadingPlatform
) {
return { type: 'noop' };
}
if (input.entries.length === 0) {
return { type: 'clear' };
}
const activeEntry = input.activeEntryKey
? (input.entries.find(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey,
) ?? null)
: null;
const isActiveRuntimeReady =
activeEntry !== null &&
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
if (
(activeEntry !== null && isActiveRuntimeReady) ||
input.isStarting ||
(activeEntry !== null && input.hasStartError)
) {
return { type: 'noop' };
}
const nextEntry = activeEntry ?? input.entries[0];
return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' };
}
export function isSamePlatformPublicGalleryEntry(
left: PlatformPublicGalleryCard,
right: PlatformPublicGalleryCard,
) {
return (
getPlatformPublicGalleryEntryKey(left) ===
getPlatformPublicGalleryEntryKey(right)
);
}
export function mergePlatformPublicGalleryEntries(
rpgEntries: readonly CustomWorldGalleryCard[],
puzzleEntries: readonly PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values()).sort(
(left, right) =>
getPlatformPublicGalleryEntryTime(right) -
getPlatformPublicGalleryEntryTime(left),
);
}
export function buildPlatformPublicGalleryFeeds(
input: PlatformPublicGalleryFeedsInput,
): PlatformPublicGalleryFeeds {
const bigFishEntries = input.isBigFishCreationVisible
? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard)
: [];
const babyObjectMatchEntries = input.isBabyObjectMatchVisible
? input.babyObjectMatchDrafts
.filter((draft) => draft.publicationStatus === 'published')
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
: [];
const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map(
mapBarkBattleWorkToPlatformGalleryCard,
);
const barkBattleFallbackEntries =
input.barkBattleGalleryEntries.length === 0
? input.barkBattleWorks
.filter((work) => work.status === 'published')
.map(mapBarkBattleWorkToPlatformGalleryCard)
: [];
const visualNovelEntries = input.isVisualNovelCreationOpen
? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard)
: [];
const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
...bigFishEntries,
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
...input.puzzleClearEntries.map(mapPuzzleClearWorkToPlatformGalleryCard),
...barkBattleGalleryEntries,
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
...barkBattleFallbackEntries,
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
...visualNovelEntries,
...babyObjectMatchEntries,
]);
const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
...bigFishEntries,
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
...input.puzzleClearEntries.map(mapPuzzleClearWorkToPlatformGalleryCard),
...(barkBattleGalleryEntries.length > 0
? barkBattleGalleryEntries
: barkBattleFallbackEntries),
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
...visualNovelEntries,
...babyObjectMatchEntries,
]).slice(0, 6);
return {
featuredEntries,
latestEntries,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,195 @@
import { describe, expect, test } from 'vitest';
import type {
PuzzleAnchorPack,
PuzzleDraftLevel,
PuzzleGeneratedImageCandidate,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import {
hasRecoverableGeneratedPuzzleDraft,
normalizeRecoveredPuzzleDraftSession,
} from './platformPuzzleDraftRecoveryModel';
function buildAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildCandidate(
overrides: Partial<PuzzleGeneratedImageCandidate> = {},
): PuzzleGeneratedImageCandidate {
return {
candidateId: 'candidate-1',
imageSrc: '/candidate-cover.png',
assetId: 'asset-candidate-cover',
prompt: '星桥机关',
sourceType: 'generated',
selected: true,
...overrides,
};
}
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
return {
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '星桥机关画面',
candidates: [buildCandidate()],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
...overrides,
};
}
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): PuzzleResultDraft {
const anchorPack = buildAnchorPack();
return {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把碎片拼回原位。',
themeTags: ['星桥', '机关', '修复'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
levels: [buildLevel()],
...overrides,
};
}
function buildSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = buildAnchorPack();
return {
sessionId: 'puzzle-session-1',
seedText: '星桥',
currentTurn: 1,
progressPercent: 100,
stage: 'draft_ready',
anchorPack,
draft: buildDraft(),
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function withCompleteLevelAssets(
overrides: Partial<PuzzleDraftLevel> = {},
): PuzzleDraftLevel {
return buildLevel({
levelSceneImageSrc: '/level-scene.png',
uiSpritesheetImageSrc: '/ui-spritesheet.png',
levelBackgroundImageSrc: '/level-background.png',
...overrides,
});
}
describe('platformPuzzleDraftRecoveryModel', () => {
test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => {
const normalized = normalizeRecoveredPuzzleDraftSession(
buildSession({
draft: buildDraft({
levels: [withCompleteLevelAssets()],
}),
}),
);
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true);
expect(normalized.draft).toMatchObject({
coverImageSrc: '/candidate-cover.png',
coverAssetId: 'asset-candidate-cover',
selectedCandidateId: 'candidate-1',
generationStatus: 'ready',
});
expect(normalized.draft?.levels?.[0]).toMatchObject({
coverImageSrc: '/candidate-cover.png',
coverAssetId: 'asset-candidate-cover',
selectedCandidateId: 'candidate-1',
generationStatus: 'ready',
});
});
test('keeps half-finished draft generating when only cover candidate exists', () => {
const normalized = normalizeRecoveredPuzzleDraftSession(buildSession());
expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false);
expect(normalized.draft).toMatchObject({
coverImageSrc: '/candidate-cover.png',
generationStatus: 'generating',
});
expect(normalized.draft?.levels?.[0]).toMatchObject({
coverImageSrc: '/candidate-cover.png',
generationStatus: 'generating',
});
});
test('requires level scene, ui spritesheet and level background assets together', () => {
expect(
hasRecoverableGeneratedPuzzleDraft(
buildSession({
draft: buildDraft({
coverImageSrc: '/draft-cover.png',
levels: [
withCompleteLevelAssets({
uiSpritesheetImageSrc: null,
uiSpritesheetImageObjectKey: null,
}),
],
}),
}),
),
).toBe(false);
});
test('accepts object keys as recovered asset references', () => {
expect(
hasRecoverableGeneratedPuzzleDraft(
buildSession({
draft: buildDraft({
coverImageSrc: '/draft-cover.png',
levels: [
buildLevel({
levelSceneImageObjectKey: 'level-scene.png',
uiSpritesheetImageObjectKey: 'ui-spritesheet.png',
levelBackgroundImageObjectKey: 'level-background.png',
}),
],
}),
}),
),
).toBe(true);
});
test('leaves sessions without draft unchanged and unrecoverable', () => {
const session = buildSession({ draft: null });
expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session);
expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false);
});
});

View File

@@ -0,0 +1,154 @@
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
function normalizeRecoveryText(value: string | null | undefined) {
return value?.trim() || null;
}
function hasPuzzleAssetReference(
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey));
}
function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) {
return session.draft?.levels?.[0] ?? null;
}
function resolvePuzzleRecoveryCandidate(
session: PuzzleAgentSessionSnapshot,
primaryLevel: PuzzleDraftLevel | null,
) {
const draft = session.draft;
if (!draft) {
return null;
}
return (
primaryLevel?.candidates.find((candidate) => candidate.selected) ??
primaryLevel?.candidates[0] ??
draft.candidates.find((candidate) => candidate.selected) ??
draft.candidates[0] ??
null
);
}
function resolvePuzzleRecoveryCoverFields(
session: PuzzleAgentSessionSnapshot,
) {
const draft = session.draft;
const primaryLevel = resolvePrimaryPuzzleLevel(session);
const selectedCandidate = resolvePuzzleRecoveryCandidate(
session,
primaryLevel,
);
return {
coverImageSrc:
normalizeRecoveryText(draft?.coverImageSrc) ??
normalizeRecoveryText(primaryLevel?.coverImageSrc) ??
normalizeRecoveryText(selectedCandidate?.imageSrc),
coverAssetId:
normalizeRecoveryText(draft?.coverAssetId) ??
normalizeRecoveryText(primaryLevel?.coverAssetId) ??
normalizeRecoveryText(selectedCandidate?.assetId),
selectedCandidateId:
draft?.selectedCandidateId ??
primaryLevel?.selectedCandidateId ??
selectedCandidate?.candidateId ??
null,
};
}
function hasCompleteGeneratedPuzzleLevelAssets(
level: PuzzleDraftLevel | null,
coverImageSrc: string | null,
) {
return Boolean(
normalizeRecoveryText(coverImageSrc) &&
hasPuzzleAssetReference(
level?.levelSceneImageSrc,
level?.levelSceneImageObjectKey,
) &&
hasPuzzleAssetReference(
level?.uiSpritesheetImageSrc,
level?.uiSpritesheetImageObjectKey,
) &&
hasPuzzleAssetReference(
level?.levelBackgroundImageSrc,
level?.levelBackgroundImageObjectKey,
),
);
}
export function hasRecoverableGeneratedPuzzleDraft(
session: PuzzleAgentSessionSnapshot,
) {
const draft = session.draft;
if (!draft) {
return false;
}
const primaryLevel = resolvePrimaryPuzzleLevel(session);
const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session);
return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc);
}
export function normalizeRecoveredPuzzleDraftSession(
session: PuzzleAgentSessionSnapshot,
): PuzzleAgentSessionSnapshot {
const draft = session.draft;
if (!draft) {
return session;
}
const { coverImageSrc, coverAssetId, selectedCandidateId } =
resolvePuzzleRecoveryCoverFields(session);
const nextLevels = draft.levels?.map((level, index) =>
index === 0
? {
...level,
coverImageSrc: normalizeRecoveryText(level.coverImageSrc)
? level.coverImageSrc
: coverImageSrc,
coverAssetId: normalizeRecoveryText(level.coverAssetId)
? level.coverAssetId
: coverAssetId,
selectedCandidateId:
level.selectedCandidateId ?? selectedCandidateId,
}
: level,
);
const nextSession = {
...session,
draft: {
...draft,
coverImageSrc,
coverAssetId,
selectedCandidateId,
levels: nextLevels,
},
} satisfies PuzzleAgentSessionSnapshot;
const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession);
if (!isRecoverable) {
return nextSession;
}
return {
...nextSession,
draft: {
...nextSession.draft,
generationStatus: 'ready',
levels: nextSession.draft.levels?.map((level, index) =>
index === 0
? {
...level,
generationStatus: 'ready',
}
: level,
),
},
};
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
buildPuzzleSessionIdFromProfileId,
} from './platformPuzzleIdentityModel';
describe('platformPuzzleIdentityModel', () => {
test('builds stable puzzle result identities from a session id', () => {
expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe(
'puzzle-profile-ocean',
);
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
'puzzle-work-ocean',
);
});
test('keeps legacy suffix inputs usable', () => {
expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean');
expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean');
});
test('builds draft runtime session ids from profile ids', () => {
expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe(
'puzzle-session-ocean',
);
expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull();
expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull();
});
});

View File

@@ -0,0 +1,36 @@
/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */
export function buildPuzzleResultProfileId(
sessionId: string | null | undefined,
) {
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
return stableSuffix ? `puzzle-profile-${stableSuffix}` : null;
}
export function buildPuzzleResultWorkId(sessionId: string | null | undefined) {
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
return stableSuffix ? `puzzle-work-${stableSuffix}` : null;
}
export function buildPuzzleSessionIdFromProfileId(
profileId: string | null | undefined,
) {
const normalizedProfileId = profileId?.trim();
if (!normalizedProfileId?.startsWith('puzzle-profile-')) {
return null;
}
const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length);
return stableSuffix ? `puzzle-session-${stableSuffix}` : null;
}
function resolvePuzzleSessionStableSuffix(
sessionId: string | null | undefined,
) {
const normalizedSessionId = sessionId?.trim();
if (!normalizedSessionId) {
return null;
}
return normalizedSessionId.startsWith('puzzle-session-')
? normalizedSessionId.slice('puzzle-session-'.length)
: normalizedSessionId;
}

View File

@@ -0,0 +1,197 @@
import { describe, expect, test } from 'vitest';
import type {
PuzzleLeaderboardEntry,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
const currentLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 1,
nickname: '本地玩家',
elapsedMs: 12000,
isCurrentPlayer: true,
},
];
const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 1,
nickname: '服务端玩家',
elapsedMs: 9000,
},
];
const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 2,
nickname: '全局玩家',
elapsedMs: 15000,
},
];
function buildPuzzleLevel(
overrides: Partial<PuzzleRuntimeLevelSnapshot> = {},
): PuzzleRuntimeLevelSnapshot {
return {
runId: 'run-current',
levelIndex: 0,
levelId: 'level-1',
gridSize: 3,
profileId: 'puzzle-profile-current',
levelName: '星桥机关',
authorDisplayName: '玩家',
themeTags: ['星桥'],
coverImageSrc: '/cover.png',
board: {
rows: 3,
cols: 3,
pieces: [],
mergedGroups: [],
selectedPieceId: null,
allTilesResolved: true,
},
status: 'cleared',
startedAtMs: 1000,
clearedAtMs: 13000,
elapsedMs: 12000,
timeLimitMs: 120000,
remainingMs: 108000,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
leaderboardEntries: currentLeaderboard,
...overrides,
};
}
function buildPuzzleRun(
overrides: Partial<PuzzleRunSnapshot> = {},
): PuzzleRunSnapshot {
return {
runId: 'run-current',
entryProfileId: 'puzzle-profile-current',
clearedLevelCount: 1,
currentLevelIndex: 0,
currentGridSize: 3,
playedProfileIds: ['puzzle-profile-current'],
previousLevelTags: ['星桥'],
currentLevel: buildPuzzleLevel(),
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: null,
nextLevelId: null,
recommendedNextWorks: [],
leaderboardEntries: currentLeaderboard,
...overrides,
};
}
describe('platformPuzzleRuntimeStateModel', () => {
test('keeps current run when either current level is missing', () => {
const currentRun = buildPuzzleRun({ currentLevel: null });
expect(
mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()),
).toBe(currentRun);
const serviceRun = buildPuzzleRun({ currentLevel: null });
const playableCurrentRun = buildPuzzleRun();
expect(
mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun),
).toBe(playableCurrentRun);
});
test('merges service leaderboard and next-level handoff without replacing local level state', () => {
const currentRun = buildPuzzleRun({
clearedLevelCount: 2,
currentLevel: buildPuzzleLevel({
runId: 'run-current',
status: 'cleared',
board: {
rows: 3,
cols: 3,
pieces: [
{
pieceId: 'piece-local',
correctRow: 0,
correctCol: 0,
currentRow: 0,
currentCol: 0,
mergedGroupId: null,
},
],
mergedGroups: [],
selectedPieceId: 'piece-local',
allTilesResolved: true,
},
}),
});
const serviceRun = buildPuzzleRun({
runId: 'run-service',
entryProfileId: 'puzzle-profile-service',
clearedLevelCount: 1,
recommendedNextProfileId: 'next-recommended',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'next-profile',
nextLevelId: 'next-level',
recommendedNextWorks: [
{
profileId: 'next-profile',
levelName: '月桥机关',
authorDisplayName: '推荐作者',
themeTags: ['月桥'],
coverImageSrc: '/next-cover.png',
similarityScore: 0.91,
},
],
currentLevel: buildPuzzleLevel({
runId: 'run-service-level',
status: 'playing',
leaderboardEntries: serviceLevelLeaderboard,
}),
});
const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun);
expect(merged.runId).toBe('run-service');
expect(merged.entryProfileId).toBe('puzzle-profile-service');
expect(merged.clearedLevelCount).toBe(2);
expect(merged.recommendedNextProfileId).toBe('next-recommended');
expect(merged.nextLevelMode).toBe('similarWorks');
expect(merged.nextLevelProfileId).toBe('next-profile');
expect(merged.nextLevelId).toBe('next-level');
expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks);
expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard);
expect(merged.currentLevel?.status).toBe('cleared');
expect(merged.currentLevel?.board.pieces).toEqual(
currentRun.currentLevel?.board.pieces,
);
expect(merged.currentLevel?.leaderboardEntries).toEqual(
serviceLevelLeaderboard,
);
});
test('falls back to service run leaderboard, then current level leaderboard', () => {
const currentRun = buildPuzzleRun();
const serviceRun = buildPuzzleRun({
currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }),
leaderboardEntries: serviceRunLeaderboard,
});
expect(
mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel
?.leaderboardEntries,
).toEqual(serviceRunLeaderboard);
expect(
mergePuzzleServiceRuntimeState(currentRun, {
...serviceRun,
leaderboardEntries: [],
}).currentLevel?.leaderboardEntries,
).toEqual(currentLeaderboard);
});
});

View File

@@ -0,0 +1,40 @@
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
export function mergePuzzleServiceRuntimeState(
currentRun: PuzzleRunSnapshot,
serviceRun: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
return currentRun;
}
const serviceLevel = serviceRun.currentLevel;
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
return {
...currentRun,
runId: serviceRun.runId,
entryProfileId: serviceRun.entryProfileId,
clearedLevelCount: Math.max(
currentRun.clearedLevelCount,
serviceRun.clearedLevelCount,
),
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
leaderboardEntries:
leaderboardEntries.length > 0
? leaderboardEntries
: currentRun.currentLevel.leaderboardEntries,
},
};
}

View File

@@ -0,0 +1,96 @@
import { expect, test } from 'vitest';
import {
resolvePlatformRecommendRuntimeAuthPlan,
shouldUsePlatformRecommendRuntimeGuestAuth,
} from './platformRecommendRuntimeAuthModel';
test('uses runtime guest auth for anonymous embedded recommendation runtime', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
});
});
test('uses background auth for signed-in embedded recommendation runtime', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: 'user-1',
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'background',
puzzleRuntimeAuthMode: 'default',
});
});
test('uses background auth when embedded runtime has only a stored access token', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: null,
hasStoredAccessToken: true,
}),
).toEqual({
requestKind: 'background',
puzzleRuntimeAuthMode: 'default',
});
});
test('does not alter auth for non-embedded runtime launches by default', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'none',
puzzleRuntimeAuthMode: 'default',
});
});
test('uses isolated guest auth for anonymous puzzle isolated launch', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
allowRuntimeGuestAuth: true,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
});
});
test('falls back to default puzzle auth when isolated launch has account auth', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
allowRuntimeGuestAuth: true,
authUserId: 'user-1',
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'none',
puzzleRuntimeAuthMode: 'default',
});
});
test('guest auth decision trims user id before treating account as signed in', () => {
expect(
shouldUsePlatformRecommendRuntimeGuestAuth({
allowRuntimeGuestAuth: true,
authUserId: ' ',
hasStoredAccessToken: false,
}),
).toBe(true);
});

View File

@@ -0,0 +1,58 @@
export type PlatformRecommendRuntimeRequestKind =
| 'none'
| 'background'
| 'runtime-guest';
export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated';
export type PlatformRecommendRuntimeAuthPlan = {
requestKind: PlatformRecommendRuntimeRequestKind;
puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode;
};
export type PlatformRecommendRuntimeAuthInput = {
embedded?: boolean;
allowRuntimeGuestAuth?: boolean;
authUserId?: string | null;
hasStoredAccessToken?: boolean;
};
function hasAccountAuth(input: {
authUserId?: string | null;
hasStoredAccessToken?: boolean;
}) {
return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken);
}
export function shouldUsePlatformRecommendRuntimeGuestAuth(
input: Pick<
PlatformRecommendRuntimeAuthInput,
'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken'
>,
) {
return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input);
}
export function resolvePlatformRecommendRuntimeAuthPlan(
input: PlatformRecommendRuntimeAuthInput,
): PlatformRecommendRuntimeAuthPlan {
const embedded = Boolean(input.embedded);
const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded;
const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({
allowRuntimeGuestAuth,
authUserId: input.authUserId,
hasStoredAccessToken: input.hasStoredAccessToken,
});
if (useRuntimeGuestAuth) {
return {
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
};
}
return {
requestKind: embedded ? 'background' : 'none',
puzzleRuntimeAuthMode: 'default',
};
}

View File

@@ -0,0 +1,178 @@
import { describe, expect, test } from 'vitest';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
const NOW_MS = Date.parse('2026-06-07T12:00:00.000Z');
type PublicCardTestParams = {
id: string;
sourceType?: 'puzzle' | 'match3d' | 'jump-hop';
subtitle?: string;
summaryText?: string;
coverImageSrc?: string | null;
themeTags?: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
publishedAt?: string | null;
updatedAt?: string;
};
function buildPublicCard(
params: PublicCardTestParams,
): PlatformPublicGalleryCard {
const sourceType = params.sourceType ?? 'puzzle';
return {
sourceType,
workId: `${sourceType}-work-${params.id}`,
profileId: `${sourceType}-profile-${params.id}`,
publicWorkCode: `${sourceType.toUpperCase()}-${params.id}`,
ownerUserId: `user-${params.id}`,
authorDisplayName: `${params.id} 作者`,
worldName: `${params.id} 作品`,
subtitle: params.subtitle ?? '公开作品',
summaryText: params.summaryText ?? '公开作品摘要。',
coverImageSrc: params.coverImageSrc ?? `${params.id}.png`,
themeTags: params.themeTags ?? ['推荐'],
playCount: params.playCount ?? 0,
remixCount: params.remixCount ?? 0,
likeCount: params.likeCount ?? 0,
recentPlayCount7d: params.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: params.publishedAt ?? '2026-06-01T12:00:00.000Z',
updatedAt:
params.updatedAt ?? params.publishedAt ?? '2026-06-01T12:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
}
describe('buildPlatformRecommendedEntries', () => {
test('combines heat, freshness and featured boost after de-duplicating works', () => {
const coldEntry = buildPublicCard({
id: 'cold',
playCount: 1,
publishedAt: '2026-04-01T12:00:00.000Z',
});
const hotRecentEntry = buildPublicCard({
id: 'hot',
playCount: 8,
likeCount: 4,
recentPlayCount7d: 16,
publishedAt: '2026-06-06T12:00:00.000Z',
});
const curatedEntry = buildPublicCard({
id: 'curated',
playCount: 0,
likeCount: 0,
publishedAt: '2026-05-10T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [curatedEntry],
latestEntries: [coldEntry, hotRecentEntry, curatedEntry],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotRecentEntry.profileId,
curatedEntry.profileId,
coldEntry.profileId,
]);
});
test('interleaves close-score works from different play types', () => {
const firstPuzzle = buildPublicCard({
id: 'puzzle-a',
sourceType: 'puzzle',
likeCount: 2,
});
const secondPuzzle = buildPublicCard({
id: 'puzzle-b',
sourceType: 'puzzle',
likeCount: 2,
});
const match3d = buildPublicCard({
id: 'match3d-a',
sourceType: 'match3d',
likeCount: 2,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle, match3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
match3d.profileId,
secondPuzzle.profileId,
]);
});
test('separates same-type candidates while alternatives remain', () => {
const hotPuzzle = buildPublicCard({
id: 'hot-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 50,
likeCount: 20,
});
const warmPuzzle = buildPublicCard({
id: 'warm-puzzle',
sourceType: 'puzzle',
recentPlayCount7d: 32,
likeCount: 12,
});
const coldMatch3d = buildPublicCard({
id: 'cold-match3d',
sourceType: 'match3d',
publishedAt: '2026-04-01T12:00:00.000Z',
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [hotPuzzle, warmPuzzle, coldMatch3d],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
hotPuzzle.profileId,
coldMatch3d.profileId,
warmPuzzle.profileId,
]);
});
test('falls back to same-type adjacency when no other type remains', () => {
const firstPuzzle = buildPublicCard({
id: 'only-puzzle-a',
sourceType: 'puzzle',
recentPlayCount7d: 8,
});
const secondPuzzle = buildPublicCard({
id: 'only-puzzle-b',
sourceType: 'puzzle',
recentPlayCount7d: 4,
});
const entries = buildPlatformRecommendedEntries(
{
featuredEntries: [],
latestEntries: [firstPuzzle, secondPuzzle],
},
{ nowMs: NOW_MS },
);
expect(entries.map((entry) => entry.profileId)).toEqual([
firstPuzzle.profileId,
secondPuzzle.profileId,
]);
});
});

View File

@@ -0,0 +1,225 @@
import {
buildPlatformPublicGalleryCardKey,
isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkSourceType,
} from '../rpg-entry/rpgEntryWorldPresentation';
const MS_PER_DAY = 86_400_000;
const FEATURED_BONUS = 14;
const MAX_FRESHNESS_SCORE = 12;
export type PlatformRecommendationOptions = {
nowMs?: number;
limit?: number;
};
type RecommendationCandidate = {
entry: PlatformPublicGalleryCard;
key: string;
sourceType: string;
firstSeenIndex: number;
isFeatured: boolean;
timestampMs: number;
score: number;
};
type PlatformRecommendationMetricKey =
| 'playCount'
| 'remixCount'
| 'likeCount'
| 'recentPlayCount7d';
function parseRecommendationTimestamp(value: string | null | undefined) {
if (!value) {
return 0;
}
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
if (absoluteTimestamp >= 1_000_000_000_000_000) {
return rawTimestamp / 1000;
}
if (absoluteTimestamp >= 1_000_000_000_000) {
return rawTimestamp;
}
if (absoluteTimestamp >= 1_000_000_000) {
return rawTimestamp * 1000;
}
}
}
const timestamp = new Date(normalized).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
function getRecommendationTimestamp(entry: PlatformPublicGalleryCard) {
return parseRecommendationTimestamp(entry.publishedAt ?? entry.updatedAt);
}
function getRecommendationMetric(
entry: PlatformPublicGalleryCard,
key: PlatformRecommendationMetricKey,
) {
const value = (
entry as Partial<Record<PlatformRecommendationMetricKey, number>>
)[key];
return Math.max(0, Math.round(Number(value ?? 0) || 0));
}
function getRecommendationSourceType(entry: PlatformPublicGalleryCard) {
if (isEdutainmentGalleryEntry(entry)) {
return `edutainment:${entry.templateId}`;
}
return resolvePlatformPublicWorkSourceType(entry);
}
function getRecommendationThemeTags(entry: PlatformPublicGalleryCard) {
return 'themeTags' in entry && Array.isArray(entry.themeTags)
? entry.themeTags
: [];
}
function scoreRecommendationCandidate(
candidate: Omit<RecommendationCandidate, 'score'>,
nowMs: number,
) {
const entry = candidate.entry;
const ageDays =
candidate.timestampMs > 0
? Math.max(0, (nowMs - candidate.timestampMs) / MS_PER_DAY)
: Number.POSITIVE_INFINITY;
const freshnessScore = Number.isFinite(ageDays)
? MAX_FRESHNESS_SCORE / (1 + ageDays / 7)
: 0;
const coverScore = entry.coverImageSrc ? 1.5 : 0;
const tagScore = Math.min(3, getRecommendationThemeTags(entry).length) * 0.6;
const summaryScore = entry.summaryText.trim() ? 0.8 : 0;
return (
(candidate.isFeatured ? FEATURED_BONUS : 0) +
Math.log1p(getRecommendationMetric(entry, 'recentPlayCount7d')) * 8 +
Math.log1p(getRecommendationMetric(entry, 'likeCount')) * 5 +
Math.log1p(getRecommendationMetric(entry, 'remixCount')) * 3 +
Math.log1p(getRecommendationMetric(entry, 'playCount')) * 2 +
freshnessScore +
coverScore +
tagScore +
summaryScore
);
}
function compareRecommendationCandidates(
left: RecommendationCandidate,
right: RecommendationCandidate,
) {
const scoreDiff = right.score - left.score;
if (scoreDiff !== 0) {
return scoreDiff;
}
const timeDiff = right.timestampMs - left.timestampMs;
if (timeDiff !== 0) {
return timeDiff;
}
if (left.firstSeenIndex !== right.firstSeenIndex) {
return left.firstSeenIndex - right.firstSeenIndex;
}
return left.key.localeCompare(right.key, 'zh-CN');
}
function diversifyAdjacentSourceTypes(candidates: RecommendationCandidate[]) {
const remaining = [...candidates];
const result: RecommendationCandidate[] = [];
while (remaining.length > 0) {
const lastSourceType = result[result.length - 1]?.sourceType ?? null;
let nextIndex = 0;
if (lastSourceType) {
const alternativeIndex = remaining.findIndex(
(candidate) => candidate.sourceType !== lastSourceType,
);
if (alternativeIndex > 0) {
nextIndex = alternativeIndex;
}
}
const [nextCandidate] = remaining.splice(nextIndex, 1);
if (nextCandidate) {
result.push(nextCandidate);
}
}
return result;
}
export function buildPlatformRecommendedEntries(
params: {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
},
options: PlatformRecommendationOptions = {},
) {
const candidateMap = new Map<
string,
Omit<RecommendationCandidate, 'score'>
>();
let firstSeenIndex = 0;
const collectEntries = (
entries: PlatformPublicGalleryCard[],
source: 'featured' | 'latest',
) => {
entries.forEach((entry) => {
const key = buildPlatformPublicGalleryCardKey(entry);
const timestampMs = getRecommendationTimestamp(entry);
const existing = candidateMap.get(key);
if (existing) {
existing.isFeatured = existing.isFeatured || source === 'featured';
if (timestampMs >= existing.timestampMs) {
existing.entry = entry;
existing.timestampMs = timestampMs;
}
return;
}
candidateMap.set(key, {
entry,
key,
sourceType: getRecommendationSourceType(entry),
firstSeenIndex,
isFeatured: source === 'featured',
timestampMs,
});
firstSeenIndex += 1;
});
};
collectEntries(params.featuredEntries, 'featured');
collectEntries(params.latestEntries, 'latest');
const nowMs = options.nowMs ?? Date.now();
const rankedCandidates = Array.from(candidateMap.values())
.map((candidate) => ({
...candidate,
score: scoreRecommendationCandidate(candidate, nowMs),
}))
.sort(compareRecommendationCandidates);
const diversifiedCandidates = diversifyAdjacentSourceTypes(rankedCandidates);
const limit =
typeof options.limit === 'number' && options.limit > 0
? Math.floor(options.limit)
: diversifiedCandidates.length;
return diversifiedCandidates
.slice(0, limit)
.map((candidate) => candidate.entry);
}

View File

@@ -0,0 +1,168 @@
import { describe, expect, test } from 'vitest';
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
import type { CustomWorldProfile } from '../../types';
import {
buildPlatformRpgAgentResultPublishGateView,
type PlatformRpgAgentResultBlockerView,
resolvePlatformRpgAgentResultPreviewSourceLabel,
} from './platformRpgAgentResultPreviewModel';
function buildProfile(
overrides: Record<string, unknown> = {},
): CustomWorldProfile {
return {
...createRpgCreationPublishedProfileFixture(),
worldHook: '潮雾列岛旧灯塔重新点亮。',
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
coreConflicts: ['守灯会与沉船旧案的冲突'],
sceneChapterBlueprints: [
{
id: 'chapter-1',
acts: [
{
id: 'act-1',
},
],
},
],
...overrides,
} as unknown as CustomWorldProfile;
}
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_world_hook',
message: '缺少世界钩子',
};
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_player_premise',
message: '缺少玩家前提',
};
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_core_conflict',
message: '缺少核心冲突',
};
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_main_chapter',
message: '缺少主章节',
};
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_first_act',
message: '缺少首幕',
};
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
missingWorldHookBlocker,
missingPlayerPremiseBlocker,
missingCoreConflictBlocker,
missingMainChapterBlocker,
missingFirstActBlocker,
];
describe('platformRpgAgentResultPreviewModel', () => {
test('uses fallback blockers and publish readiness without a profile', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
null,
structuralBlockers.slice(0, 2),
false,
),
).toEqual({
blockers: ['缺少世界钩子', '缺少玩家前提'],
publishReady: false,
});
});
test('filters structural blockers already satisfied by the profile', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile(),
[
...structuralBlockers,
{
code: 'future_blocker',
message: '未知服务端阻断',
},
],
false,
),
).toEqual({
blockers: ['未知服务端阻断'],
publishReady: false,
});
});
test('keeps unresolved structural blockers when profile fields are empty', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile({
worldHook: '',
playerPremise: '',
settingText: '',
creatorIntent: null,
anchorContent: null,
coreConflicts: [],
chapters: [],
sceneChapterBlueprints: [],
sceneChapters: [],
}),
structuralBlockers,
true,
),
).toEqual({
blockers: structuralBlockers.map((entry) => entry.message),
publishReady: false,
});
});
test('resolves structural blockers from nested profile compatibility fields', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile({
worldHook: '',
playerPremise: '',
settingText: '',
creatorIntent: {
worldHook: '旧灯塔潮路重新开启。',
},
anchorContent: {
playerEntryPoint: {
openingProblem: '玩家被卷入沉船旧案。',
},
},
coreConflicts: [''],
chapters: [],
sceneChapterBlueprints: null,
sceneChapters: [
{
acts: [{}],
},
],
}),
[
missingWorldHookBlocker,
missingPlayerPremiseBlocker,
missingFirstActBlocker,
],
false,
),
).toEqual({
blockers: [],
publishReady: true,
});
});
test('maps preview source to result label', () => {
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
).toBe('已发布世界');
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
).toBe('会话预览');
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
).toBe(
'服务端预览',
);
});
});

View File

@@ -0,0 +1,159 @@
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
import type { CustomWorldProfile } from '../../types';
export type PlatformRpgAgentResultBlockerView = {
code?: string | null;
message: string;
};
export type PlatformRpgAgentResultPublishGateView = {
blockers: string[];
publishReady: boolean;
};
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook',
'publish_missing_player_premise',
'publish_missing_core_conflict',
'publish_missing_main_chapter',
'publish_missing_first_act',
]);
function readProfileTextField(
profile: CustomWorldProfile | null,
paths: string[],
) {
for (const path of paths) {
let current: unknown = profile;
for (const segment of path.split('.')) {
if (!current || typeof current !== 'object') {
current = null;
break;
}
current = (current as Record<string, unknown>)[segment];
}
if (typeof current === 'string' && current.trim()) {
return current.trim();
}
}
return null;
}
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
const value = profile
? (profile as unknown as Record<string, unknown>)[key]
: null;
return Array.isArray(value)
? value.some((entry) => typeof entry === 'string' && entry.trim())
: false;
}
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
const value = profile
? (profile as unknown as Record<string, unknown>)[key]
: null;
return Array.isArray(value) && value.length > 0;
}
function hasSceneAct(profile: CustomWorldProfile | null) {
const rawProfile = profile as unknown as Record<string, unknown> | null;
const chapters =
rawProfile &&
(Array.isArray(rawProfile.sceneChapterBlueprints)
? rawProfile.sceneChapterBlueprints
: Array.isArray(rawProfile.sceneChapters)
? rawProfile.sceneChapters
: []);
return Array.isArray(chapters)
? chapters.some((chapter) => {
const acts =
chapter && typeof chapter === 'object'
? (chapter as Record<string, unknown>).acts
: null;
return Array.isArray(acts) && acts.length > 0;
})
: false;
}
function isAgentResultStructuralBlockerResolved(
profile: CustomWorldProfile,
code: string | null | undefined,
) {
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
return false;
}
if (code === 'publish_missing_world_hook') {
return Boolean(
readProfileTextField(profile, [
'worldHook',
'creatorIntent.worldHook',
'anchorContent.worldPromise',
'anchorContent.worldPromise.hook',
'settingText',
]),
);
}
if (code === 'publish_missing_player_premise') {
return Boolean(
readProfileTextField(profile, [
'playerPremise',
'creatorIntent.playerPremise',
'anchorContent.playerEntryPoint',
'anchorContent.playerEntryPoint.openingIdentity',
'anchorContent.playerEntryPoint.openingProblem',
'anchorContent.playerEntryPoint.entryMotivation',
]),
);
}
if (code === 'publish_missing_core_conflict') {
return hasProfileTextArray(profile, 'coreConflicts');
}
if (code === 'publish_missing_main_chapter') {
return (
hasProfileArray(profile, 'chapters') ||
hasProfileArray(profile, 'sceneChapterBlueprints') ||
hasProfileArray(profile, 'sceneChapters')
);
}
return hasSceneAct(profile);
}
export function buildPlatformRpgAgentResultPublishGateView(
profile: CustomWorldProfile | null,
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
fallbackPublishReady: boolean,
): PlatformRpgAgentResultPublishGateView {
if (!profile) {
return {
blockers: fallbackBlockers.map((entry) => entry.message),
publishReady: fallbackPublishReady,
};
}
const blockers = fallbackBlockers
.filter(
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
)
.map((entry) => entry.message);
return {
blockers,
publishReady: blockers.length === 0,
};
}
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
source: RpgCreationPreviewSource | string | null | undefined,
) {
if (!source) {
return null;
}
if (source === 'published_profile') {
return '已发布世界';
}
if (source === 'session_preview') {
return '会话预览';
}
return '服务端预览';
}

View File

@@ -0,0 +1,278 @@
import { describe, expect, test } from 'vitest';
import type { SelectionStage } from './platformEntryTypes';
import {
type MissingCreationStateParams,
resolveSelectionStageAfterMissingCreationState,
resolveSelectionStageAfterProtectedDataLoss,
} from './platformSelectionStageModel';
describe('platformSelectionStageModel', () => {
test('keeps public and workspace stages after protected data loss', () => {
const stableStages: SelectionStage[] = [
'platform',
'work-detail',
'detail',
'agent-workspace',
'big-fish-agent-workspace',
'match3d-agent-workspace',
'square-hole-agent-workspace',
'jump-hop-workspace',
'wooden-fish-workspace',
'puzzle-agent-workspace',
'bark-battle-workspace',
'visual-novel-agent-workspace',
'baby-object-match-workspace',
'creative-agent-workspace',
'puzzle-gallery-detail',
];
stableStages.forEach((stage) => {
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage);
});
});
test('resets private result, generating, runtime and profile stages to platform', () => {
const resetStages: SelectionStage[] = [
'profile-feedback',
'big-fish-generating',
'big-fish-result',
'big-fish-runtime',
'match3d-generating',
'match3d-result',
'match3d-runtime',
'square-hole-generating',
'square-hole-result',
'square-hole-runtime',
'jump-hop-generating',
'jump-hop-result',
'jump-hop-runtime',
'jump-hop-gallery-detail',
'wooden-fish-generating',
'wooden-fish-result',
'wooden-fish-runtime',
'visual-novel-generating',
'visual-novel-result',
'visual-novel-gallery-detail',
'visual-novel-runtime',
'baby-object-match-generating',
'baby-object-match-result',
'baby-object-match-runtime',
'baby-love-drawing-runtime',
'puzzle-generating',
'puzzle-onboarding',
'puzzle-result',
'puzzle-runtime',
'custom-world-generating',
'custom-world-result',
'bark-battle-generating',
'bark-battle-result',
'bark-battle-runtime',
];
resetStages.forEach((stage) => {
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(
'platform',
);
});
});
test('resolves missing session draft result stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('big-fish-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'match3d-result',
match3d: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('match3d-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'square-hole-result',
squareHole: {
hasSession: true,
hasSessionDraft: false,
hasRun: false,
},
}),
),
).toBe('square-hole-agent-workspace');
});
test('resolves missing session run stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('big-fish-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'match3d-runtime',
match3d: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('match3d-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'square-hole-runtime',
squareHole: {
hasSession: true,
hasSessionDraft: true,
hasRun: false,
},
}),
),
).toBe('square-hole-result');
});
test('resolves visual novel and baby object missing state stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-result',
visualNovel: {
hasSession: true,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('visual-novel-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-runtime',
visualNovel: {
hasSession: true,
hasSessionDraft: false,
hasWork: true,
hasWorkDraft: true,
hasRun: false,
},
}),
),
).toBe('visual-novel-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-gallery-detail',
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'baby-object-match-result',
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
}),
),
).toBe('baby-object-match-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'baby-object-match-runtime',
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
}),
),
).toBe('platform');
});
test('keeps stages when required creation state exists', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('big-fish-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true },
}),
),
).toBe('big-fish-runtime');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-gallery-detail',
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: true,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('visual-novel-gallery-detail');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'platform',
}),
),
).toBe('platform');
});
});
function buildMissingCreationStateParams(
overrides: Partial<MissingCreationStateParams> = {},
): MissingCreationStateParams {
return {
stage: 'platform',
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
match3d: { hasSession: false, hasSessionDraft: false, hasRun: false },
squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false },
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
babyObjectMatch: { hasDraft: false, hasFormPayload: false },
...overrides,
};
}

View File

@@ -0,0 +1,157 @@
import type { SelectionStage } from './platformEntryTypes';
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
platform: true,
'profile-feedback': false,
'work-detail': true,
detail: true,
'agent-workspace': true,
'big-fish-agent-workspace': true,
'big-fish-generating': false,
'big-fish-result': false,
'big-fish-runtime': false,
'match3d-agent-workspace': true,
'match3d-generating': false,
'match3d-result': false,
'match3d-runtime': false,
'square-hole-agent-workspace': true,
'square-hole-generating': false,
'square-hole-result': false,
'square-hole-runtime': false,
'jump-hop-workspace': true,
'jump-hop-generating': false,
'jump-hop-result': false,
'jump-hop-runtime': false,
'jump-hop-gallery-detail': false,
'bark-battle-workspace': true,
'bark-battle-generating': false,
'bark-battle-result': false,
'bark-battle-runtime': false,
'wooden-fish-workspace': true,
'wooden-fish-generating': false,
'wooden-fish-result': false,
'wooden-fish-runtime': false,
'creative-agent-workspace': true,
'visual-novel-agent-workspace': true,
'visual-novel-generating': false,
'visual-novel-result': false,
'visual-novel-gallery-detail': false,
'visual-novel-runtime': false,
'baby-object-match-workspace': true,
'baby-object-match-generating': false,
'baby-object-match-result': false,
'baby-object-match-runtime': false,
'baby-love-drawing-runtime': false,
'puzzle-agent-workspace': true,
'puzzle-generating': false,
'puzzle-onboarding': false,
'puzzle-result': false,
'puzzle-gallery-detail': true,
'puzzle-runtime': false,
'puzzle-clear-workspace': true,
'puzzle-clear-generating': false,
'puzzle-clear-result': false,
'puzzle-clear-runtime': false,
'custom-world-generating': false,
'custom-world-result': false,
} as const satisfies Record<SelectionStage, boolean>;
export function resolveSelectionStageAfterProtectedDataLoss(
stage: SelectionStage,
): SelectionStage {
return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform';
}
type SessionDraftRunState = {
hasSession: boolean;
hasSessionDraft: boolean;
hasRun: boolean;
};
type VisualNovelCreationState = {
hasSession: boolean;
hasSessionDraft: boolean;
hasWork: boolean;
hasWorkDraft: boolean;
hasRun: boolean;
};
type BabyObjectMatchCreationState = {
hasDraft: boolean;
hasFormPayload: boolean;
};
export type MissingCreationStateParams = {
stage: SelectionStage;
bigFish: SessionDraftRunState;
match3d: SessionDraftRunState;
squareHole: SessionDraftRunState;
visualNovel: VisualNovelCreationState;
babyObjectMatch: BabyObjectMatchCreationState;
};
export function resolveSelectionStageAfterMissingCreationState(
params: MissingCreationStateParams,
): SelectionStage {
const { stage } = params;
if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) {
return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform';
}
if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) {
return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform';
}
if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) {
return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform';
}
if (stage === 'match3d-runtime' && !params.match3d.hasRun) {
return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform';
}
if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) {
return params.squareHole.hasSession
? 'square-hole-agent-workspace'
: 'platform';
}
if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) {
return params.squareHole.hasSessionDraft
? 'square-hole-result'
: 'platform';
}
if (
stage === 'visual-novel-result' &&
!params.visualNovel.hasSessionDraft &&
!params.visualNovel.hasWorkDraft
) {
return params.visualNovel.hasSession
? 'visual-novel-agent-workspace'
: 'platform';
}
if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) {
return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft
? 'visual-novel-result'
: 'platform';
}
if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) {
return 'platform';
}
if (
stage === 'baby-object-match-result' &&
!params.babyObjectMatch.hasDraft
) {
return params.babyObjectMatch.hasFormPayload
? 'baby-object-match-workspace'
: 'platform';
}
if (
stage === 'baby-object-match-runtime' &&
!params.babyObjectMatch.hasDraft
) {
return 'platform';
}
return stage;
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { isPuzzleCompileActionReady } from './puzzleDraftGenerationState';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
describe('isPuzzleCompileActionReady', () => {
it('keeps compile action generating until the draft has a cover image', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: null,
levels: [
{
generationStatus: 'generating',
coverImageSrc: null,
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(false);
});
it('keeps compile action generating when only the selected cover exists', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
generationStatus: 'generating',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(false);
});
it('treats compile action as ready after all runtime assets exist', () => {
const session = {
sessionId: 'puzzle-session-1',
draft: {
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levels: [
{
generationStatus: 'ready',
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
levelSceneImageObjectKey:
'generated-puzzle-assets/session/level-scene.png',
uiSpritesheetImageObjectKey:
'generated-puzzle-assets/session/ui-spritesheet.png',
levelBackgroundImageObjectKey:
'generated-puzzle-assets/session/level-background.png',
},
],
},
} as PuzzleAgentSessionSnapshot;
expect(isPuzzleCompileActionReady(session)).toBe(true);
});
});

View File

@@ -0,0 +1,36 @@
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
function hasText(value: string | null | undefined) {
return typeof value === 'string' && value.trim().length > 0;
}
function hasAssetReference(
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
return hasText(imageSrc) || hasText(objectKey);
}
export function isPuzzleCompileActionReady(
session: PuzzleAgentSessionSnapshot,
) {
const draft = session.draft;
if (!draft) {
return false;
}
return (
draft.levels?.some(
(level) =>
(hasText(draft.coverImageSrc) || hasText(level.coverImageSrc)) &&
hasAssetReference(level.levelSceneImageSrc, level.levelSceneImageObjectKey) &&
hasAssetReference(
level.uiSpritesheetImageSrc,
level.uiSpritesheetImageObjectKey,
) &&
hasAssetReference(
level.levelBackgroundImageSrc,
level.levelBackgroundImageObjectKey,
),
) === true
);
}

View File

@@ -300,6 +300,91 @@ function ActionCompleteHarness({
);
}
function BeforeActionHarness({ events }: { events: string[] }) {
const hasOpenedRef = useRef(false);
const flow = usePlatformCreationAgentFlowController<
ActionTestSession,
Record<string, never>,
{ session: ActionTestSession },
TestMessagePayload,
{ action: string },
{ session: ActionTestSession }
>({
client: {
createSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
getSession: async () => ({
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
},
}),
streamMessage: async () => ({
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-draft-1' },
}),
executeAction: async () => {
events.push('executeAction');
return {
session: {
sessionId: 'session-1',
messages: [],
draft: { profileId: 'profile-ready-1' },
},
};
},
selectSession: (response) => response.session,
},
createPayload: {},
workspaceStage: 'match3d-agent-workspace',
resultStage: 'match3d-result',
platformStage: 'platform',
isCompileAction: () => true,
resolveErrorMessage: (error, fallback) =>
error instanceof Error ? error.message : fallback,
errorMessages: {
open: '打开失败',
restoreMissingSession: '缺少会话',
restore: '恢复失败',
submit: '发送失败',
execute: '执行失败',
},
enterCreateTab: () => {},
setSelectionStage: () => {},
beforeExecuteAction: async () => {
events.push('beforeExecuteAction');
await Promise.resolve();
events.push('permissionResolved');
},
});
useEffect(() => {
if (hasOpenedRef.current) {
return;
}
hasOpenedRef.current = true;
void flow.openWorkspace({});
}, [flow]);
return (
<button
type="button"
onClick={() => {
void flow.executeAction({ action: 'match3d_compile_draft' });
}}
>
</button>
);
}
function SessionChangeHarness({
onSessionChanged,
}: {
@@ -547,6 +632,28 @@ test('creation agent flow suppresses compile result stage for background complet
);
});
test('creation agent flow waits for beforeExecuteAction before network action', async () => {
const events: string[] = [];
render(<BeforeActionHarness events={events} />);
await waitFor(() => {
expect(screen.getByRole('button', { name: '执行' })).toBeTruthy();
});
await act(async () => {
screen.getByRole('button', { name: '执行' }).click();
});
await waitFor(() => {
expect(events).toEqual([
'beforeExecuteAction',
'permissionResolved',
'executeAction',
]);
});
});
test('creation agent flow notifies session changes after open restore and compile', async () => {
const onSessionChanged = vi.fn();

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { TextStreamOptions } from '../../services/aiTypes';
import type { SelectionStage } from './platformEntryTypes';
@@ -90,7 +90,7 @@ type PlatformCreationAgentFlowControllerOptions<
beforeExecuteAction?: (params: {
payload: TActionPayload;
session: TSession;
}) => void;
}) => void | Promise<void>;
onActionError?: (params: {
payload: TActionPayload;
error: unknown;
@@ -211,7 +211,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[isBusy, options, resetStreamingReply],
[isBusy, options, resetStreamingReply, setSession],
);
const restoreDraft = useCallback(
@@ -249,7 +249,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[options, resetStreamingReply],
[options, resetStreamingReply, setSession],
);
const submitMessage = useCallback(
@@ -309,7 +309,13 @@ export function usePlatformCreationAgentFlowController<
setIsStreamingReply(false);
}
},
[isStreamingReply, options, session, updateStreamingReplyText],
[
isStreamingReply,
options,
session,
setSession,
updateStreamingReplyText,
],
);
const executeAction = useCallback(
@@ -323,7 +329,7 @@ export function usePlatformCreationAgentFlowController<
setError(null);
try {
options.beforeExecuteAction?.({ payload, session: targetSession });
await options.beforeExecuteAction?.({ payload, session: targetSession });
const response = await options.client.executeAction(
targetSession.sessionId,
payload,
@@ -358,7 +364,7 @@ export function usePlatformCreationAgentFlowController<
setIsBusy(false);
}
},
[isBusy, options, session],
[isBusy, options, session, setSession],
);
const leaveFlow = useCallback(() => {

View File

@@ -0,0 +1,222 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { ImgHTMLAttributes } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import type { PuzzleClearSessionResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { PuzzleClearWorkspace } from './PuzzleClearWorkspace';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
refreshKey: _refreshKey,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
refreshKey?: unknown;
[key: string]: unknown;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
/>
) : null,
}));
vi.mock('../../services/puzzleReferenceImage', () => ({
readPuzzleReferenceImageAsDataUrl: vi.fn(),
}));
vi.mock('../../services/puzzle-clear/puzzleClearClient', () => ({
puzzleClearClient: {
createSession: vi.fn(),
},
}));
function createSessionResponse(): PuzzleClearSessionResponse {
return {
session: {
sessionId: 'puzzle-clear-session-1',
ownerUserId: 'user-1',
status: 'draft',
draft: {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: null,
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '霓虹星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: false,
boardBackgroundAsset: null,
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
atlasAsset: null,
patternGroups: [],
cardAssets: [],
generationStatus: 'draft',
},
createdAt: '2026-05-30T00:00:00.000Z',
updatedAt: '2026-05-30T00:00:00.000Z',
},
};
}
beforeEach(() => {
vi.mocked(puzzleClearClient.createSession).mockReset();
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
});
test('工作台提交结构化表单与底图槽位 payload', async () => {
const response = createSessionResponse();
const onSubmitted = vi.fn();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockResolvedValue(
'data:image/png;base64,board-background',
);
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={onSubmitted}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: ' 星港拼消消 ' },
});
fireEvent.change(screen.getByLabelText('简介'), {
target: { value: ' 霓虹星港主题 ' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: ' 霓虹星港 ' },
});
fireEvent.change(screen.getByLabelText('场地底图'), {
target: { value: '星港中央棋盘底图' },
});
fireEvent.change(screen.getByLabelText('上传底图'), {
target: {
files: [
new File(['fake-image'], 'board.png', {
type: 'image/png',
}),
],
},
});
await waitFor(() =>
expect(readPuzzleReferenceImageAsDataUrl).toHaveBeenCalledTimes(1),
);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() =>
expect(puzzleClearClient.createSession).toHaveBeenCalledWith({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '霓虹星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: false,
boardBackgroundAsset: expect.objectContaining({
imageSrc: 'data:image/png;base64,board-background',
generationProvider: 'local-upload',
prompt: '星港中央棋盘底图',
}),
}),
);
expect(onSubmitted).toHaveBeenCalledWith(
response,
expect.objectContaining({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
themePrompt: '霓虹星港',
}),
);
});
test('工作台不渲染聊天式 Agent 输入', () => {
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
expect(screen.queryByText(/|||/u)).toBeNull();
});
test('关闭 AI 生成底图且未上传底图时不允许提交', async () => {
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: '星港拼消消' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: '霓虹星港' },
});
fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生成底图' }));
expect(
(screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled,
).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() =>
expect(puzzleClearClient.createSession).not.toHaveBeenCalled(),
);
});
test('工作台支持原生表单提交生成', async () => {
const response = createSessionResponse();
const onSubmitted = vi.fn();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={onSubmitted}
/>,
);
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: '星港拼消消' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: '霓虹星港' },
});
fireEvent.change(screen.getByLabelText('场地底图'), {
target: { value: '星港中央棋盘底图' },
});
const submitButton = screen.getByRole('button', { name: '生成' });
const form = submitButton.closest('form');
expect(form).toBeTruthy();
fireEvent.submit(form!);
await waitFor(() =>
expect(puzzleClearClient.createSession).toHaveBeenCalledTimes(1),
);
expect(onSubmitted).toHaveBeenCalledWith(
response,
expect.objectContaining({
templateId: 'puzzle-clear',
workTitle: '星港拼消消',
}),
);
});

View File

@@ -0,0 +1,326 @@
import { ArrowLeft, Loader2, Send } from 'lucide-react';
import { useMemo, useState } from 'react';
import type {
PuzzleClearImageAsset,
PuzzleClearSessionResponse,
PuzzleClearWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { puzzleClearClient } from '../../services/puzzle-clear/puzzleClearClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
type PuzzleClearWorkspaceProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSubmitted: (
result: PuzzleClearSessionResponse,
payload: PuzzleClearWorkspaceCreateRequest,
) => void;
};
type PuzzleClearWorkspaceFormState = {
workTitle: string;
workDescription: string;
themePrompt: string;
boardBackgroundPrompt: string;
boardBackgroundAsset: PuzzleClearImageAsset | null;
boardBackgroundImageSrc: string;
generateBoardBackground: boolean;
};
const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = {
workTitle: '',
workDescription: '',
themePrompt: '',
boardBackgroundPrompt: '',
boardBackgroundAsset: null,
boardBackgroundImageSrc: '',
generateBoardBackground: true,
};
function buildLocalBoardBackgroundAsset(
imageSrc: string,
prompt: string,
): PuzzleClearImageAsset {
return {
assetId: `local-board-background-${Date.now()}`,
imageSrc,
imageObjectKey: '',
assetObjectId: '',
generationProvider: 'local-upload',
prompt,
width: 0,
height: 0,
};
}
export function PuzzleClearWorkspace({
isBusy = false,
error = null,
onBack,
onSubmitted,
}: PuzzleClearWorkspaceProps) {
const [formState, setFormState] = useState(DEFAULT_FORM_STATE);
const [localError, setLocalError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const hasBoardBackgroundInput = useMemo(
() =>
formState.generateBoardBackground ||
Boolean(formState.boardBackgroundAsset || formState.boardBackgroundImageSrc),
[
formState.boardBackgroundAsset,
formState.boardBackgroundImageSrc,
formState.generateBoardBackground,
],
);
const canSubmit = useMemo(
() =>
Boolean(
formState.workTitle.trim() &&
formState.themePrompt.trim() &&
hasBoardBackgroundInput,
),
[formState.themePrompt, formState.workTitle, hasBoardBackgroundInput],
);
const handleSubmit = async () => {
if (!canSubmit || isSubmitting || isBusy) {
setLocalError('请先补全输入。');
return;
}
setIsSubmitting(true);
setLocalError(null);
try {
const boardBackgroundAsset =
formState.boardBackgroundAsset ??
(formState.boardBackgroundImageSrc
? buildLocalBoardBackgroundAsset(
formState.boardBackgroundImageSrc,
formState.boardBackgroundPrompt.trim() ||
formState.themePrompt.trim(),
)
: null);
const payload: PuzzleClearWorkspaceCreateRequest = {
templateId: 'puzzle-clear',
workTitle: formState.workTitle.trim(),
workDescription: formState.workDescription.trim(),
themePrompt: formState.themePrompt.trim(),
boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(),
generateBoardBackground: formState.generateBoardBackground,
boardBackgroundAsset,
};
const response = await puzzleClearClient.createSession(payload);
onSubmitted(response, payload);
} catch (caughtError) {
setLocalError(
caughtError instanceof Error ? caughtError.message : '创建草稿失败。',
);
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
}}
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4"
>
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]">
<section className="platform-subpanel flex min-h-0 flex-col gap-3 overflow-y-auto rounded-[1.25rem] p-4">
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.workTitle}
maxLength={32}
disabled={isBusy || isSubmitting}
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-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<textarea
value={formState.workDescription}
maxLength={120}
disabled={isBusy || isSubmitting}
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-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themePrompt}
maxLength={80}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
themePrompt: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<span className="text-sm font-bold text-[var(--platform-text-strong)]">
AI
</span>
<input
type="checkbox"
checked={formState.generateBoardBackground}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
generateBoardBackground: event.target.checked,
}))
}
className="h-5 w-5 accent-[var(--platform-accent)]"
/>
</label>
{localError || error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
</section>
<div className="flex min-h-[28rem] min-w-0 flex-col">
<CreativeImageInputPanel
disabled={isBusy || isSubmitting}
isSubmitting={isSubmitting}
uploadedImageSrc={formState.boardBackgroundImageSrc}
uploadedImageAlt="场地底图"
mainImageInputId="puzzle-clear-board-background"
promptTextareaId="puzzle-clear-board-background-prompt"
prompt={formState.boardBackgroundPrompt}
promptLabel="场地底图"
promptRows={5}
aiRedraw={formState.generateBoardBackground}
promptReferenceImages={[]}
showSubmitButton={false}
submitLabel="生成"
submitDisabled={!canSubmit || isSubmitting || isBusy}
labels={{
imageField: '中央底图',
uploadImage: '上传底图',
replaceImage: '替换底图',
emptyImageHint: '上传图像',
removeImage: '移除底图',
removeImageConfirmTitle: '移除底图',
removeImageConfirmBody: '移除后将使用主题词生成中央场地底图。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '场地底图参考',
closePromptReferencePreview: '关闭预览',
}}
onMainImageFileSelect={(file) => {
void readPuzzleReferenceImageAsDataUrl(file)
.then((dataUrl) => {
setLocalError(null);
setFormState((current) => ({
...current,
boardBackgroundImageSrc: dataUrl,
boardBackgroundAsset: buildLocalBoardBackgroundAsset(
dataUrl,
current.boardBackgroundPrompt.trim() ||
current.themePrompt.trim(),
),
generateBoardBackground: false,
}));
})
.catch((caughtError) => {
setLocalError(
caughtError instanceof Error
? caughtError.message
: '底图读取失败。',
);
});
}}
onMainImageRemove={() => {
setFormState((current) => ({
...current,
boardBackgroundImageSrc: '',
boardBackgroundAsset: null,
}));
}}
onAiRedrawChange={(value) =>
setFormState((current) => ({
...current,
generateBoardBackground: value,
}))
}
onPromptChange={(value) =>
setFormState((current) => ({
...current,
boardBackgroundPrompt: value,
}))
}
onSubmit={handleSubmit}
/>
</div>
</div>
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
<button
type="submit"
disabled={!canSubmit || isSubmitting || isBusy}
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-5 py-3 ${
!canSubmit || isSubmitting || isBusy
? 'cursor-not-allowed opacity-55'
: ''
}`}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</form>
);
}
export default PuzzleClearWorkspace;

View File

@@ -0,0 +1,184 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { act } from 'react';
import type { ImgHTMLAttributes } from 'react';
import { expect, test, vi } from 'vitest';
import type {
PuzzleClearCardAsset,
PuzzleClearPatternGroup,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { PuzzleClearResultView } from './PuzzleClearResultView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
[key: string]: unknown;
}) =>
src ? (
<img
src={src}
alt={alt}
className={className}
{...(rest as ImgHTMLAttributes<HTMLImageElement>)}
/>
) : null,
}));
function createPatternGroup(index: number): PuzzleClearPatternGroup {
return {
groupId: `group-${index}`,
shape: '1x2',
width: 2,
height: 1,
atlasX: index * 64,
atlasY: 0,
atlasWidth: 128,
atlasHeight: 64,
};
}
function createCard(index: number): PuzzleClearCardAsset {
return {
cardId: `card-${index}`,
groupId: `group-${Math.floor(index / 2)}`,
shape: '1x2',
orientation: 'horizontal',
partX: index % 2,
partY: 0,
imageSrc: `/cards/card-${index}.png`,
imageObjectKey: `generated-puzzle-clear-assets/card-${index}.png`,
assetObjectId: `assetobj_card_${index}`,
sourceAtlasCell: `${index}:0:0`,
};
}
function createProfile(
overrides: Partial<PuzzleClearWorkProfileResponse['summary']> = {},
): PuzzleClearWorkProfileResponse {
const atlasAsset = {
assetId: 'atlas-1',
imageSrc: '/atlas.png',
imageObjectKey: 'generated-puzzle-clear-assets/atlas.png',
assetObjectId: 'assetobj_atlas',
generationProvider: 'gpt-image-2',
prompt: '星港',
width: 2560,
height: 2560,
};
const boardBackgroundAsset = {
...atlasAsset,
assetId: 'board-background-1',
imageSrc: '/board-background.png',
imageObjectKey: 'generated-puzzle-clear-assets/board-background.png',
assetObjectId: 'assetobj_board_background',
};
const patternGroups = Array.from({ length: 35 }, (_, index) =>
createPatternGroup(index),
);
const cardAssets = Array.from({ length: 95 }, (_, index) => createCard(index));
const draft = {
templateId: 'puzzle-clear',
templateName: '拼消消',
profileId: 'puzzle-clear-profile-12345678',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '星港',
boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: true,
boardBackgroundAsset,
cardBackImageSrc: '/creation-type-references/puzzle-clear-card-back.webp',
atlasAsset,
patternGroups,
cardAssets,
generationStatus: 'ready' as const,
};
return {
summary: {
runtimeKind: 'puzzle-clear',
workId: 'puzzle-clear-work-12345678',
profileId: 'puzzle-clear-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-clear-session-1',
workTitle: '星港拼消消',
workDescription: '霓虹星港主题',
themePrompt: '星港',
coverImageSrc: '/atlas.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T00:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
...overrides,
},
draft,
boardBackgroundAsset,
atlasAsset,
patternGroups,
cardAssets,
};
}
test('结果页展示 atlas、中央底图与卡牌预览并触发试玩、发布和图集重试', async () => {
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onRegenerateAtlas = vi.fn();
const { container } = render(
<PuzzleClearResultView
profile={createProfile()}
onBack={vi.fn()}
onEdit={vi.fn()}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateAtlas={onRegenerateAtlas}
/>,
);
expect(screen.getByAltText('场地底图').getAttribute('src')).toBe(
'/board-background.png',
);
expect(screen.getByAltText('素材图集').getAttribute('src')).toBe('/atlas.png');
expect(screen.getByText('35')).not.toBeNull();
expect(screen.getByText('95')).not.toBeNull();
expect(container.querySelectorAll('img[src^="/cards/"]')).toHaveLength(24);
fireEvent.click(screen.getByRole('button', { name: //u }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: //u }));
});
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onRegenerateAtlas).toHaveBeenCalledTimes(1);
});
test('结果页在素材未发布就绪时禁用发布,且不写入规则说明文案', () => {
render(
<PuzzleClearResultView
profile={createProfile({ publishReady: false })}
onBack={vi.fn()}
onEdit={vi.fn()}
onStartTestRun={vi.fn()}
onPublish={vi.fn()}
onRegenerateAtlas={vi.fn()}
/>,
);
expect(
(screen.getByRole('button', { name: //u }) as HTMLButtonElement).disabled,
).toBe(true);
expect(screen.queryByText(/|||/u)).toBeNull();
});

View File

@@ -0,0 +1,223 @@
import { ArrowLeft, Loader2, Play, RefreshCcw, Send } from 'lucide-react';
import { useState } from 'react';
import type {
PuzzleClearDraftResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleClearResultViewProps = {
profile: PuzzleClearDraftResponse | PuzzleClearWorkProfileResponse;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onEdit: () => void;
onStartTestRun: () => void;
onPublish: () => void;
onRegenerateAtlas: () => void;
};
function isPuzzleClearWorkProfile(
profile: PuzzleClearResultViewProps['profile'],
): profile is PuzzleClearWorkProfileResponse {
return 'summary' in profile;
}
function getDraft(profile: PuzzleClearResultViewProps['profile']) {
return isPuzzleClearWorkProfile(profile) ? profile.draft : profile;
}
export function PuzzleClearResultView({
profile,
isBusy = false,
error = null,
onBack,
onEdit,
onStartTestRun,
onPublish,
onRegenerateAtlas,
}: PuzzleClearResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false);
const isWorkProfile = isPuzzleClearWorkProfile(profile);
const draft = getDraft(profile);
const summary = isWorkProfile ? profile.summary : null;
const title = summary?.workTitle?.trim() || draft.workTitle.trim() || '拼消消';
const description =
summary?.workDescription?.trim() || draft.workDescription.trim();
const boardBackgroundAsset = isWorkProfile
? profile.boardBackgroundAsset ?? draft.boardBackgroundAsset
: draft.boardBackgroundAsset;
const atlasAsset = isWorkProfile ? profile.atlasAsset : draft.atlasAsset;
const patternGroups = isWorkProfile ? profile.patternGroups : draft.patternGroups;
const cardAssets = isWorkProfile ? profile.cardAssets : draft.cardAssets;
const previewCards = cardAssets.slice(0, 24);
const canPublish = Boolean(isWorkProfile && summary?.publishReady);
const handlePublish = async () => {
setIsPublishing(true);
try {
await Promise.resolve(onPublish());
} finally {
setIsPublishing(false);
}
};
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={onRegenerateAtlas}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm"
>
<RefreshCcw className="h-4 w-4" />
</button>
</div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(19rem,0.95fr)]">
<section className="platform-subpanel flex min-h-0 flex-col rounded-[1.25rem] p-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{description}
</div>
) : null}
<div className="mt-4 grid min-h-0 flex-1 gap-3 sm:grid-cols-[minmax(0,0.92fr)_minmax(0,1.08fr)]">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{boardBackgroundAsset?.imageSrc ? (
<ResolvedAssetImage
src={boardBackgroundAsset.imageSrc}
alt="场地底图"
className="aspect-[9/16] h-full w-full object-cover"
/>
) : (
<div className="grid aspect-[9/16] place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<div className="flex min-h-0 flex-col gap-3">
<div className="overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/80">
{atlasAsset?.imageSrc ? (
<ResolvedAssetImage
src={atlasAsset.imageSrc}
alt="素材图集"
className="aspect-square w-full object-cover"
/>
) : (
<div className="grid aspect-square place-items-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<div className="grid grid-cols-6 gap-1.5">
{previewCards.map((card) => (
<div
key={card.cardId}
className="aspect-square overflow-hidden rounded-[0.45rem] border border-white/80 bg-white shadow-sm"
>
<ResolvedAssetImage
src={card.imageSrc}
alt=""
className="h-full w-full object-cover"
/>
</div>
))}
</div>
</div>
</div>
</section>
<aside className="flex min-h-0 flex-col gap-3 overflow-y-auto">
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="grid grid-cols-3 gap-2 text-center">
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
<div className="text-xl font-black text-[var(--platform-text-strong)]">
{patternGroups.length}
</div>
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
</div>
</div>
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
<div className="text-xl font-black text-[var(--platform-text-strong)]">
{cardAssets.length}
</div>
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
</div>
</div>
<div className="rounded-[0.9rem] bg-white/76 px-2 py-3">
<div className="text-xl font-black text-[var(--platform-text-strong)]">
{draft.generationStatus}
</div>
<div className="mt-1 text-[0.68rem] font-bold tracking-[0.14em] text-[var(--platform-text-soft)]">
</div>
</div>
</div>
</section>
{error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error}
</div>
) : null}
<section className="platform-subpanel mt-auto rounded-[1.25rem] p-4">
<div className="grid gap-2">
<button
type="button"
onClick={onStartTestRun}
disabled={isBusy || !isWorkProfile}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3"
>
<Play className="h-4 w-4" />
</button>
<button
type="button"
onClick={handlePublish}
disabled={isBusy || isPublishing || !canPublish}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3"
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={onEdit}
disabled={isBusy}
className="platform-button platform-button--ghost min-h-11 justify-center px-4 py-3"
>
</button>
</div>
</section>
</aside>
</div>
</div>
);
}
export default PuzzleClearResultView;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -427,6 +427,19 @@ describe('PuzzleResultView', () => {
const formalImageCard = formalImageTitle
.closest('.creative-image-input-panel__image-field')
?.querySelector('.puzzle-image-upload-card');
fireEvent.click(
within(dialog).getByRole('button', { name: '查看关卡图片' }),
);
const imagePreviewDialog = screen.getByRole('dialog', {
name: '查看关卡图片',
});
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
fireEvent.click(
within(imagePreviewDialog).getByRole('button', {
name: '关闭关卡图片预览',
}),
);
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
@@ -1224,11 +1237,13 @@ describe('PuzzleResultView', () => {
const picker = await screen.findByRole('dialog', {
name: '选择历史图片',
});
expect(await within(picker).findByText('image.png')).toBeTruthy();
expect(await within(picker).findByText(/2024\/04\/21/u)).toBeTruthy();
expect(within(picker).queryByText('image.png')).toBeNull();
expect(within(picker).queryByText('账号 user-1')).toBeNull();
fireEvent.click(
await within(picker).findByRole('button', { name: /image\.png/u }),
await within(picker).findByRole('button', {
name: /选择2024\/04\/21.*的历史图片/u,
}),
);
await waitFor(() => {

View File

@@ -867,6 +867,8 @@ function PuzzleLevelDetailDialog({
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
previewMainImage: '查看关卡图片',
closeMainImagePreview: '关闭关卡图片预览',
history: '选择历史图片',
}}
onMainImageFileSelect={(file) => {

Some files were not shown because too many files have changed in this diff Show More