feat: add puzzle clear template runtime

This commit is contained in:
2026-06-03 22:11:46 +08:00
parent 6e74cf5add
commit 1b5e098225
148 changed files with 19588 additions and 241 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

@@ -1,4 +1,4 @@
import { type ImgHTMLAttributes, useEffect, useState } from 'react';
import React, { type ImgHTMLAttributes, useEffect, useState } from 'react';
import { useResolvedAssetReadUrl } from '../hooks/useResolvedAssetReadUrl';

View File

@@ -6,6 +6,7 @@ import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contra
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 { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
@@ -68,6 +69,9 @@ type CustomWorldCreationHubProps = {
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
onDeletePuzzleClear?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
@@ -181,6 +185,9 @@ export function CustomWorldCreationHub({
woodenFishItems = [],
onOpenWoodenFishDetail = null,
onDeleteWoodenFish = null,
puzzleClearItems = [],
onOpenPuzzleClearDetail = null,
onDeletePuzzleClear = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
@@ -215,6 +222,7 @@ export function CustomWorldCreationHub({
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleClearItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
@@ -226,6 +234,7 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzleClear: Boolean(onDeletePuzzleClear),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
@@ -243,6 +252,8 @@ export function CustomWorldCreationHub({
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
onOpenPuzzleClearDetail: onOpenPuzzleClearDetail ?? undefined,
onDeletePuzzleClear: onDeletePuzzleClear ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
@@ -271,6 +282,7 @@ export function CustomWorldCreationHub({
onDeleteVisualNovel,
onDeleteJumpHop,
onDeleteWoodenFish,
onDeletePuzzleClear,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
@@ -281,8 +293,10 @@ export function CustomWorldCreationHub({
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onOpenWoodenFishDetail,
onOpenPuzzleClearDetail,
onEnterPublished,
getWorkState,
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenSquareHoleDetail,
@@ -342,6 +356,9 @@ export function CustomWorldCreationHub({
case 'wooden-fish':
onOpenWoodenFishDetail?.(item.source.item);
return;
case 'puzzle-clear':
onOpenPuzzleClearDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);

View File

@@ -6,6 +6,7 @@ import {
Trash2,
} from 'lucide-react';
import {
default as React,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
@@ -61,6 +62,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',

View File

@@ -97,6 +97,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

@@ -3,6 +3,7 @@ import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
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';
@@ -17,6 +18,7 @@ import {
buildBigFishPublicWorkCode,
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;
@@ -153,6 +160,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[];
jumpHopItems?: JumpHopWorkSummaryResponse[];
woodenFishItems?: WoodenFishWorkSummaryResponse[];
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
@@ -163,6 +171,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeleteJumpHop?: boolean;
canDeleteWoodenFish?: boolean;
canDeletePuzzleClear?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
@@ -180,6 +189,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;
@@ -201,6 +212,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleClearItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
@@ -211,6 +223,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeleteJumpHop = false,
canDeleteWoodenFish = false,
canDeletePuzzleClear = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
@@ -228,6 +241,8 @@ export function buildCreationWorkShelfItems(params: {
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleClearDetail,
onDeletePuzzleClear,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
@@ -278,6 +293,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteWoodenFish,
}),
),
...puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
@@ -884,6 +905,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>
@@ -1097,6 +1168,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:

File diff suppressed because it is too large Load Diff

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,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

@@ -143,6 +143,7 @@ import {
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleClearGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
@@ -1908,6 +1909,9 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼图');
}
if (isPuzzleClearGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼消消');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('抓大鹅');
}

View File

@@ -10,10 +10,12 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isPuzzleClearGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapPuzzleClearWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
@@ -198,6 +200,35 @@ test('maps wooden fish work to platform gallery card with WF public code', () =>
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['敲木鱼']);
});
test('maps puzzle clear work to platform gallery card with PC public code', () => {
const card = mapPuzzleClearWorkToPlatformGalleryCard({
runtimeKind: 'puzzle-clear',
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',
});
expect(isPuzzleClearGalleryEntry(card)).toBe(true);
expect(card.sourceType).toBe('puzzle-clear');
expect(card.publicWorkCode).toBe('PC-12345678');
expect(resolvePlatformPublicWorkCode(card)).toBe('PC-12345678');
expect(resolvePlatformWorldFallbackCoverImage(card)).toBe(
'/creation-type-references/puzzle.webp',
);
expect(buildPlatformWorldDisplayTags(card, 2)).toEqual(['拼消消', '霓虹星港']);
});
test('resolves public work author from display name and public user code before stored author name', () => {
const card = mapWoodenFishWorkToPlatformGalleryCard({
publicWorkCode: 'WF-AUTHOR1',

View File

@@ -7,6 +7,11 @@ import type {
JumpHopGalleryCardResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleClearGalleryCardResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
@@ -36,6 +41,7 @@ import {
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
@@ -56,6 +62,7 @@ export type PlatformWorldCardLike =
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformPuzzleClearGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
@@ -213,6 +220,29 @@ export type PlatformJumpHopGalleryCard = {
stylePreset?: string;
};
export type PlatformPuzzleClearGalleryCard = {
sourceType: 'puzzle-clear';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
themePrompt: string;
};
export type PlatformWoodenFishGalleryCard = {
sourceType: 'wooden-fish';
workId: string;
@@ -294,6 +324,7 @@ export type PlatformPublicGalleryCard =
| PlatformMatch3DGalleryCard
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformPuzzleClearGalleryCard
| PlatformJumpHopGalleryCard
| PlatformWoodenFishGalleryCard
| PlatformVisualNovelGalleryCard
@@ -342,6 +373,12 @@ export function isJumpHopGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'jump-hop';
}
export function isPuzzleClearGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformPuzzleClearGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'puzzle-clear';
}
export function isWoodenFishGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformWoodenFishGalleryCard {
@@ -548,6 +585,68 @@ export function mapJumpHopWorkToPlatformGalleryCard(
};
}
function normalizePuzzleClearThemeTags(summary: PuzzleClearWorkSummaryResponse) {
const themePrompt = summary.themePrompt.trim();
return [
'拼消消',
...(themePrompt ? [themePrompt] : []),
];
}
function getPuzzleClearRecentPlayCount(
summary: PuzzleClearGalleryCardResponse | PuzzleClearWorkSummaryResponse,
) {
if (!('recentPlayCount7d' in summary)) {
return 0;
}
return typeof summary.recentPlayCount7d === 'number'
? summary.recentPlayCount7d
: 0;
}
export function mapPuzzleClearWorkToPlatformGalleryCard(
work:
| PuzzleClearGalleryCardResponse
| PuzzleClearWorkSummaryResponse
| PuzzleClearWorkProfileResponse,
): PlatformPuzzleClearGalleryCard {
const summary = 'summary' in work ? work.summary : work;
return {
sourceType: 'puzzle-clear',
workId: summary.workId,
profileId: summary.profileId,
sourceSessionId:
'sourceSessionId' in summary ? (summary.sourceSessionId ?? null) : null,
publicWorkCode:
'publicWorkCode' in summary &&
typeof summary.publicWorkCode === 'string' &&
summary.publicWorkCode.trim()
? summary.publicWorkCode
: buildPuzzleClearPublicWorkCode(summary.profileId),
ownerUserId: summary.ownerUserId,
authorDisplayName:
'authorDisplayName' in summary &&
typeof summary.authorDisplayName === 'string' &&
summary.authorDisplayName.trim()
? summary.authorDisplayName
: '玩家',
worldName: summary.workTitle.trim() || '拼消消',
subtitle: '拼消消',
summaryText: summary.workDescription,
coverImageSrc: summary.coverImageSrc ?? null,
themeTags: normalizePuzzleClearThemeTags(summary),
playCount: summary.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: getPuzzleClearRecentPlayCount(summary),
visibility: 'published',
publishedAt: summary.publishedAt ?? null,
updatedAt: summary.updatedAt,
themePrompt: summary.themePrompt,
};
}
export function mapWoodenFishWorkToPlatformGalleryCard(
work: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse,
): PlatformWoodenFishGalleryCard {
@@ -708,6 +807,10 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/puzzle.webp';
}
if (isPuzzleClearGalleryEntry(entry)) {
return '/creation-type-references/puzzle.webp';
}
if (isMatch3DGalleryEntry(entry)) {
return '/creation-type-references/match3d.webp';
}
@@ -869,6 +972,9 @@ export function resolvePlatformWorkAuthorDisplayName(
) {
const displayName = authorSummary?.displayName?.trim();
const publicUserCode = authorSummary?.publicUserCode?.trim();
if (displayName && publicUserCode) {
return `${displayName} · ${publicUserCode}`;
}
return displayName || publicUserCode || entry.authorDisplayName.trim() || '玩家';
}
@@ -889,6 +995,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['拼图'];
}
if (isPuzzleClearGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['拼消消'];
}
if (isMatch3DGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
@@ -1003,6 +1115,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isPuzzleClearGalleryEntry(entry)) {
return entry.publicWorkCode;
}
if (isMatch3DGalleryEntry(entry)) {
return entry.publicWorkCode;
}
@@ -1079,4 +1195,4 @@ function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
}
}