fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type {
@@ -43,7 +44,11 @@ import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService';
import {
createBarkBattleDraft,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
publishBarkBattleWork,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import {
createBigFishCreationSession,
@@ -475,8 +480,12 @@ vi.mock('../../services/big-fish-runtime', () => ({
vi.mock('../../services/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(),
generateAllBarkBattleImageAssets: vi.fn(),
listBarkBattleGallery: vi.fn(),
listBarkBattleWorks: vi.fn(),
publishBarkBattleWork: vi.fn(),
regenerateBarkBattleImageAsset: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
}));
@@ -1000,11 +1009,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
onPreview: (payload: {
title: string;
description: string;
themePreset: string;
playerDogSkinPreset: string;
opponentDogSkinPreset: string;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
difficultyPreset: 'normal';
leaderboardEnabled: boolean;
}) => void;
}) => (
<div className="bark-battle-config-editor-mock">
@@ -1026,11 +1034,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
onPreview({
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
leaderboardEnabled: true,
});
}}
>
@@ -1122,14 +1129,27 @@ vi.mock('../../games/bark-battle/ui/BarkBattleRuntimeShell', () => ({
BarkBattleRuntimeShell: ({
title,
workId,
runtimeMode,
publishedConfig,
onExit,
}: {
title?: string;
workId?: string;
runtimeMode?: string;
publishedConfig?: { workId?: string; playerCharacterImageSrc?: string | null } | null;
onExit?: () => void;
}) => (
<div className="bark-battle-runtime-shell-mock">
<div>{title ?? '未命名'} / {workId ?? 'missing-work'}</div>
<div data-testid="bark-battle-runtime-mode">
{runtimeMode ?? 'missing-mode'}
</div>
<div data-testid="bark-battle-runtime-work-id">
{publishedConfig?.workId ?? 'missing-config-work'}
</div>
<div data-testid="bark-battle-runtime-player-src">
{publishedConfig?.playerCharacterImageSrc ?? 'missing-player-src'}
</div>
<button type="button" onClick={onExit}>
</button>
@@ -1311,6 +1331,34 @@ function buildMockBabyObjectMatchDraft(
};
}
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'BB-C661A45F',
draftId: 'bark-battle-draft-public-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪公开杯',
summary: '',
themeDescription: '霓虹城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柴犬主角',
opponentImageDescription: '戴蓝色头带的哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
finishCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleAgentSession(
overrides: Partial<
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
@@ -2837,15 +2885,61 @@ beforeEach(() => {
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
leaderboardEnabled: true,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
});
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockImplementation(async (payload) => ({
draftId: payload.draftId,
workId: payload.workId ?? 'bark-battle-work-1',
title: payload.title,
description: payload.description,
themeDescription: payload.themeDescription,
playerImageDescription: payload.playerImageDescription,
opponentImageDescription: payload.opponentImageDescription,
playerCharacterImageSrc: payload.playerCharacterImageSrc,
opponentCharacterImageSrc: payload.opponentCharacterImageSrc,
uiBackgroundImageSrc: payload.uiBackgroundImageSrc,
difficultyPreset: payload.difficultyPreset,
configVersion: (payload.configVersion ?? 1) + 1,
rulesetVersion: payload.rulesetVersion ?? 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:01:00.000Z',
}));
vi.mocked(listBarkBattleWorks).mockResolvedValue({ items: [] });
vi.mocked(listBarkBattleGallery).mockResolvedValue({ items: [] });
vi.mocked(publishBarkBattleWork).mockResolvedValue({
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
@@ -2854,11 +2948,10 @@ beforeEach(() => {
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z',
});
@@ -3233,6 +3326,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
await openCreateTemplateHub(user);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain(
'scroll-px-3',
);
expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
).toBe('true');
@@ -3309,7 +3405,7 @@ test('create tab switches bark battle into the embedded config form', async () =
expect(publishBarkBattleWork).not.toHaveBeenCalled();
});
test('bark battle draft result can test before publish and return to the embedded form', async () => {
test('bark battle draft result can test before publish and publish to work detail', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
@@ -3321,11 +3417,21 @@ test('bark battle draft result can test before publish and return to the embedde
expect(createBarkBattleDraft).toHaveBeenCalledWith({
title: '汪汪测试杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal',
leaderboardEnabled: true,
});
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
expect(await screen.findByText('作品IDbark-battle-work-1')).toBeTruthy();
@@ -3345,17 +3451,154 @@ test('bark battle draft result can test before publish and return to the embedde
workId: 'bark-battle-work-1',
publishedSnapshot: expect.objectContaining({
title: '汪汪测试杯',
leaderboardEnabled: true,
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
}),
});
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
expect(window.location.search).toBe('?work=BB-TLEWORK1');
});
expect(await screen.findByText('分享给朋友')).toBeTruthy();
expect(screen.getByText(/作品号BB-TLEWORK1/u)).toBeTruthy();
expect(screen.queryByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeNull();
});
await user.click(screen.getByRole('button', { name: '返回配置' }));
test('direct bark battle runtime public code opens published runtime', async () => {
const publicWork = buildMockBarkBattleWork();
vi.mocked(listBarkBattleGallery).mockResolvedValueOnce({
items: [publicWork],
});
window.history.replaceState(
null,
'',
'/runtime/bark-battle?work=BB-C661A45F',
);
render(<TestWrapper withAuth />);
expect(await screen.findByText(/汪汪声浪运行态:汪汪公开杯/u)).toBeTruthy();
expect(screen.getByTestId('bark-battle-runtime-mode').textContent).toBe(
'published',
);
expect(screen.getByTestId('bark-battle-runtime-work-id').textContent).toBe(
'BB-C661A45F',
);
expect(screen.getByTestId('bark-battle-runtime-player-src').textContent).toBe(
'/generated-bark-battle/player.png',
);
expect(screen.queryByText('分享给朋友')).toBeNull();
});
test('bark battle form checks mud points before creating image assets', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 2,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
).toBe('true');
await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
).toBeTruthy();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
test('bark battle draft is visible in draft shelf while image assets are generating', async () => {
const user = userEvent.setup();
vi.mocked(generateAllBarkBattleImageAssets).mockImplementation(
() =>
new Promise<Awaited<ReturnType<typeof generateAllBarkBattleImageAssets>>>(
() => undefined,
),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
expect(
await within(panel).findByRole('button', {
name: /继续创作《汪汪测试杯》/u,
}),
).toBeTruthy();
await expectDraftHubGeneratingBadgeCountAtLeast(1);
expect(listBarkBattleWorks).toHaveBeenCalled();
});
test('published bark battle stays visible when refresh temporarily returns only the duplicate draft', async () => {
const user = userEvent.setup();
vi.mocked(listBarkBattleWorks).mockResolvedValueOnce({
items: [
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:01:00.000Z',
publishedAt: null,
},
],
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
}),
);
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
expect(within(panel).getByRole('button', { name: /查看详情《汪汪测试杯》/u })).toBeTruthy();
});
test('running match3d form generation can return to draft tab and reopen progress', async () => {
@@ -4590,7 +4833,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy
).toHaveProperty('textContent', '1');
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull();
@@ -4638,7 +4881,7 @@ test('completed baby object match draft viewed immediately does not keep unread
expect(screen.queryByText('宝贝识物结果页')).toBeNull();
});
expect(await screen.findByLabelText('物品 A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
expect(

View File

@@ -118,6 +118,7 @@ import {
findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import {
@@ -126,6 +127,7 @@ import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
@@ -263,6 +265,7 @@ type PlatformCategoryKindFilter =
| 'match3d'
| 'square-hole'
| 'visual-novel'
| 'bark-battle'
| 'big-fish'
| 'custom-world';
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
@@ -302,6 +305,7 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
{ id: 'match3d', label: '抓鹅' },
{ id: 'square-hole', label: '方洞' },
{ id: 'visual-novel', label: '视觉' },
{ id: 'bark-battle', label: '汪汪' },
{ id: 'big-fish', label: '大鱼' },
{ id: 'custom-world', label: 'RPG' },
];
@@ -415,6 +419,43 @@ function ResolvedAssetBackdrop({
);
}
function PlatformWorkCoverArtwork({
entry,
imageSrc,
fallbackSrc,
alt,
className,
}: {
entry: PlatformPublicGalleryCard;
imageSrc?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
}) {
if (isBarkBattleGalleryEntry(entry)) {
return (
<CustomWorldCoverArtwork
imageSrc={imageSrc}
fallbackImageSrc={fallbackSrc}
title={entry.worldName}
fallbackLabel="封面"
renderMode={entry.coverRenderMode}
characterImageSrcs={entry.coverCharacterImageSrcs}
className={className}
/>
);
}
return (
<ResolvedAssetBackdrop
src={imageSrc}
fallbackSrc={fallbackSrc}
alt={alt}
className={className}
/>
);
}
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
<div className="mb-3">
@@ -609,8 +650,9 @@ function WorldCard({
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
@@ -713,8 +755,9 @@ function RecommendCoverOnlyCard({
className="platform-recommend-cover-only"
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackCoverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
@@ -873,8 +916,9 @@ function RecommendRuntimePreviewCard({
data-preview-position={position}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
@@ -1258,8 +1302,9 @@ function DesktopTrendingItem({
>
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
@@ -1339,8 +1384,9 @@ function PlatformRankingItem({
<div className="platform-ranking-item__rank">{rank}</div>
<div className="platform-ranking-item__cover">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
@@ -1406,8 +1452,9 @@ function PlatformCategoryGameItem({
>
<div className="platform-category-game-item__cover">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt={entry.worldName}
className="h-full w-full object-cover"
/>
@@ -1732,9 +1779,11 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
@@ -1846,9 +1895,11 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '方洞'
: isVisualNovelGalleryEntry(entry)
? '视觉'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformThemeLabel(entry.themeMode);
: isBarkBattleGalleryEntry(entry)
? '汪汪'
: isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind);
}
@@ -2016,6 +2067,10 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
@@ -5949,12 +6004,21 @@ export function RpgEntryHomeView({
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
>
{desktopHeroCover ? (
<ResolvedAssetBackdrop
src={desktopHeroCover}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
desktopHeroEntry ? (
<PlatformWorkCoverArtwork
entry={desktopHeroEntry}
imageSrc={desktopHeroCover}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : (
<ResolvedAssetBackdrop
src={desktopHeroCover}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
)
) : null}
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
@@ -5998,10 +6062,10 @@ export function RpgEntryHomeView({
>
<div className="relative aspect-[1.35/1] overflow-hidden">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
<PlatformWorkCoverArtwork
entry={entry}
imageSrc={coverImage}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : null}

View File

@@ -8,9 +8,11 @@ import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard,
@@ -235,3 +237,98 @@ test('maps baby object match draft to edutainment public card', () => {
expect(card.coverImageSrc).toBe('/apple.png');
expect(card.themeTags[0]).toBe('寓教于乐');
});
test('maps bark battle work to BB public card with scene roles cover', () => {
const card = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'bark-battle-work-abcdef12',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '公园声浪赛',
summary: '柯基和哈士奇比拼声浪。',
themeDescription: '傍晚公园擂台',
playerImageDescription: '红围巾柯基',
opponentImageDescription: '蓝头带哈士奇',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 9,
recentPlayCount7d: 4,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
expect(isBarkBattleGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('BB-ABCDEF12');
expect(resolvePlatformPublicWorkCode(card)).toBe('BB-ABCDEF12');
expect(card.coverImageSrc).toBe('/generated-bark-battle/background.png');
expect(card.coverRenderMode).toBe('scene_with_roles');
expect(card.coverCharacterImageSrcs).toEqual([
'/generated-bark-battle/player.png',
'/generated-bark-battle/opponent.png',
]);
expect(buildPlatformWorldDisplayTags(card, 3)).toEqual([
'汪汪声浪',
'高能',
'傍晚公园',
]);
});
test('maps bark battle public card cover from character or reference fallback', () => {
const characterCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'BB-COVER001',
draftId: 'bark-battle-draft-cover',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '角色封面赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/bark/player-cover.png',
opponentCharacterImageSrc: '/bark/opponent-cover.png',
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
const fallbackCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'BB-COVER002',
draftId: 'bark-battle-draft-cover-fallback',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '默认封面赛',
summary: '',
themeDescription: '夜市声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'easy',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
expect(characterCoverCard.coverImageSrc).toBe('/bark/player-cover.png');
expect(characterCoverCard.coverCharacterImageSrcs).toEqual([
'/bark/player-cover.png',
'/bark/opponent-cover.png',
]);
expect(fallbackCoverCard.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
expect(fallbackCoverCard.publicWorkCode).toBe('BB-COVER002');
});

View File

@@ -1,3 +1,4 @@
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 { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -22,6 +23,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
@@ -43,6 +45,7 @@ export type PlatformWorldCardLike =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard;
export type PlatformPuzzleGalleryCard = {
@@ -196,6 +199,34 @@ export type PlatformEdutainmentGalleryCard = {
updatedAt: string;
};
export type PlatformBarkBattleGalleryCard = {
sourceType: 'bark-battle';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorPublicUserCode: string | null;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
themeTags: string[];
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard
| PlatformBigFishGalleryCard
@@ -203,6 +234,7 @@ export type PlatformPublicGalleryCard =
| PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard;
export function isLibraryWorldEntry(
@@ -247,6 +279,12 @@ export function isEdutainmentGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'edutainment';
}
export function isBarkBattleGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformBarkBattleGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
}
export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard {
@@ -422,6 +460,64 @@ export function mapBabyObjectMatchDraftToPlatformGalleryCard(
};
}
export function mapBarkBattleWorkToPlatformGalleryCard(
work: BarkBattleWorkSummary,
): PlatformBarkBattleGalleryCard {
const playerCharacterImageSrc = normalizePlatformOptionalImageSrc(
work.playerCharacterImageSrc,
);
const opponentCharacterImageSrc = normalizePlatformOptionalImageSrc(
work.opponentCharacterImageSrc,
);
const backgroundImageSrc = normalizePlatformOptionalImageSrc(
work.uiBackgroundImageSrc,
);
const coverImageSrc =
backgroundImageSrc ??
playerCharacterImageSrc ??
opponentCharacterImageSrc ??
'/creation-type-references/bark-battle.webp';
const coverCharacterImageSrcs = [
playerCharacterImageSrc,
opponentCharacterImageSrc,
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
const canRenderSceneWithRoles =
Boolean(backgroundImageSrc) && coverCharacterImageSrcs.length >= 2;
return {
sourceType: 'bark-battle',
workId: work.workId,
profileId: work.workId,
sourceSessionId: work.draftId ?? null,
publicWorkCode: buildBarkBattlePublicWorkCode(work.workId),
ownerUserId: work.ownerUserId,
authorPublicUserCode: null,
authorDisplayName: work.authorDisplayName,
worldName: work.title.trim() || '汪汪声浪大作战',
subtitle: `汪汪声浪 · ${describeBarkBattleDifficultyLabel(
work.difficultyPreset,
)}`,
summaryText:
work.summary.trim() ||
work.themeDescription.trim() ||
'用声音能量挑战对手。',
coverImageSrc,
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
coverCharacterImageSrcs,
themeTags: buildBarkBattleThemeTags(work),
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -473,6 +569,10 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/creative-agent.webp';
}
if (isBarkBattleGalleryEntry(entry)) {
return '/creation-type-references/bark-battle.webp';
}
return '/creation-type-references/rpg.webp';
}
@@ -634,6 +734,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: [entry.templateName];
}
if (isBarkBattleGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['汪汪声浪'];
}
if (!isLibraryWorldEntry(entry)) {
return [
describePlatformThemeLabel(entry.themeMode),
@@ -724,6 +830,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode;
}
if (isBarkBattleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode;
}
@@ -745,3 +855,31 @@ export function describePlatformThemeLabel(
return '回响';
}
}
function normalizePlatformOptionalImageSrc(value?: string | null) {
return value?.trim() || null;
}
function describeBarkBattleDifficultyLabel(
difficulty: BarkBattleWorkSummary['difficultyPreset'],
) {
switch (difficulty) {
case 'easy':
return '轻松';
case 'hard':
return '高能';
default:
return '普通';
}
}
function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
return [
'汪汪声浪',
describeBarkBattleDifficultyLabel(work.difficultyPreset),
work.themeDescription,
]
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
}