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

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('BarkBattleConfigEditor', () => {
it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => {
it('allows creators to edit v1 descriptions and compile a Bark Battle draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -15,48 +15,92 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect(screen.queryByLabelText('资源 URL')).toBeNull();
expect(screen.queryByLabelText('玩家图片 URL')).toBeNull();
expect(screen.queryByLabelText('对手图片 URL')).toBeNull();
expect(screen.queryByLabelText('UI背景 URL')).toBeNull();
expect(screen.queryByLabelText('排行榜开关')).toBeNull();
expect(
(screen.getByLabelText('拟声词') as HTMLTextAreaElement).value,
).toContain('轰汪!');
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
await userEvent.clear(screen.getByLabelText('玩家角色设定'));
await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角');
await userEvent.clear(screen.getByLabelText('对手角色设定'));
await userEvent.type(screen.getByLabelText('对手角色设定'), '对手');
await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台');
await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '蓝头带哈士奇');
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(
screen.getByLabelText('拟声词'),
'炸场!\n冲啊 / 破阵、Boom!',
);
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.type(
screen.getByLabelText('玩家形象'),
'/generated-bark-battle/player/image.png',
);
await userEvent.type(
screen.getByLabelText('对手形象'),
'https://example.test/opponent.png',
);
await userEvent.type(
screen.getByLabelText('UI背景'),
'/generated-bark-battle/ui/background.png',
);
await userEvent.type(
screen.getByLabelText('狗叫音效'),
'/generated-bark-battle/audio/bark.mp3',
);
await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPreview).toHaveBeenCalledWith({
title: '周末狗狗杯',
title: '狗狗冠军杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
themeDescription: '霓虹公园声浪擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'],
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
});
it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
expect(defaultWords.length).toBeGreaterThanOrEqual(10);
expect(defaultWords).toEqual(
expect.arrayContaining(['轰汪!', '炸场!', '破阵!', '燃起来!']),
);
expect(defaultWords.some((word) => word.includes('喵'))).toBe(false);
expect(defaultWords.some((word) => word.includes('汪'))).toBe(true);
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(
screen.getByLabelText('主题/场景描述'),
'星舰机甲擂台,等离子音浪爆发',
);
await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '星际猫骑士');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手');
const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement)
.value.split(/\n+/u)
.map((word) => word.trim())
.filter(Boolean);
expect(updatedWords).toEqual(
expect.arrayContaining(['能量爆裂!', '超频!', '电光轰鸣!']),
);
expect(updatedWords.some((word) => word.includes('汪'))).toBe(false);
});
it('keeps creator-edited onomatopoeia when descriptions change', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵');
await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台');
expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe(
'轰!\n破阵',
);
});
it('requires a non-empty title before compiling a draft', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -72,7 +116,7 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
error="发布失败"
error="外部错误"
isBusy={false}
onPreview={onPreview}
showBackButton={false}
@@ -83,6 +127,32 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy();
expect(screen.getByText('外部错误')).toBeTruthy();
});
it('keeps the mobile form in the parent scroll flow with a safe submit footer', () => {
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
isBusy={false}
onPreview={onPreview}
showBackButton={false}
title={null}
/>,
);
const editor = screen.getByLabelText('汪汪声浪轻配置编辑器');
expect(editor.className).toContain('overflow-visible');
expect(editor.className).toContain('lg:overflow-y-auto');
expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5');
const themeLabel = screen.getByText('主题/场景描述');
expect(themeLabel.className).toContain('bg-rose-50');
const submitFooter = screen
.getByRole('button', { name: '生成草稿' })
.closest('div');
expect(submitFooter?.className).toContain('shrink-0');
expect(submitFooter?.className).toContain('safe-area-inset-bottom');
});
});

View File

@@ -1,8 +1,9 @@
import { ArrowLeft, Loader2, Play } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
@@ -14,17 +15,26 @@ export type BarkBattleConfigEditorProps = {
title?: string | null;
};
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
const FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]';
const ACCENT_FIELD_LABEL_CLASS =
'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm';
const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台';
const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗';
const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗';
function buildDefaultOnomatopoeiaText(params: {
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
}) {
return buildBarkBattleDefaultOnomatopoeia(params).join('\n');
}
export function BarkBattleConfigEditor({
isBusy = false,
@@ -36,46 +46,72 @@ export function BarkBattleConfigEditor({
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手');
const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState('');
const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState('');
const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState('');
const [barkSoundSrc, setBarkSoundSrc] = useState('');
const [themeDescription, setThemeDescription] = useState(
DEFAULT_THEME_DESCRIPTION,
);
const [playerImageDescription, setPlayerImageDescription] = useState(
DEFAULT_PLAYER_IMAGE_DESCRIPTION,
);
const [opponentImageDescription, setOpponentImageDescription] = useState(
DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
);
const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] =
useState(false);
const [onomatopoeiaText, setOnomatopoeiaText] = useState(() =>
buildDefaultOnomatopoeiaText({
themeDescription: DEFAULT_THEME_DESCRIPTION,
playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION,
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}),
);
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null);
useEffect(() => {
if (isOnomatopoeiaCustomized) {
return;
}
setOnomatopoeiaText(
buildDefaultOnomatopoeiaText({
themeDescription,
playerImageDescription,
opponentImageDescription,
}),
);
}, [
isOnomatopoeiaCustomized,
themeDescription,
playerImageDescription,
opponentImageDescription,
]);
const onomatopoeia = useMemo(
() =>
onomatopoeiaText
.split(/[\n,/|]+/u)
.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24),
[onomatopoeiaText],
);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
...(playerCharacterImageSrc.trim()
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
: {}),
...(opponentCharacterImageSrc.trim()
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
: {}),
...(uiBackgroundImageSrc.trim()
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
: {}),
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
themeDescription: themeDescription.trim(),
playerImageDescription: playerImageDescription.trim(),
opponentImageDescription: opponentImageDescription.trim(),
onomatopoeia,
difficultyPreset,
leaderboardEnabled: true,
}),
[
title,
description,
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
barkSoundSrc,
themeDescription,
playerImageDescription,
opponentImageDescription,
onomatopoeia,
difficultyPreset,
],
);
@@ -87,6 +123,14 @@ export function BarkBattleConfigEditor({
setLocalError('请先填写作品标题');
return;
}
if (!payload.themeDescription) {
setLocalError('请先填写主题/场景描述');
return;
}
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
setLocalError('请先填写双方形象描述');
return;
}
setLocalError(null);
void action(payload);
};
@@ -94,7 +138,7 @@ export function BarkBattleConfigEditor({
return (
<section
className="platform-remap-surface mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col overflow-y-auto overscroll-y-contain pr-0.5"
className="platform-remap-surface mx-auto flex min-h-full w-full max-w-5xl flex-col overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain lg:pr-0.5"
aria-label="汪汪声浪轻配置编辑器"
>
{showBackButton && onBack ? (
@@ -113,7 +157,7 @@ export function BarkBattleConfigEditor({
</div>
) : null}
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 flex-col lg:flex-1">
{headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2">
@@ -128,13 +172,11 @@ export function BarkBattleConfigEditor({
) : null}
<div
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
className={`grid gap-3 lg:flex-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
>
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<input
value={title}
disabled={isBusy}
@@ -146,9 +188,7 @@ export function BarkBattleConfigEditor({
</label>
<label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={description}
disabled={isBusy}
@@ -162,28 +202,7 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select
value={themePreset}
disabled={isBusy}
onChange={(event) => setThemePreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="主题背景"
>
{THEME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<span className={FIELD_LABEL_CLASS}></span>
<select
value={difficultyPreset}
disabled={isBusy}
@@ -202,92 +221,64 @@ export function BarkBattleConfigEditor({
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) => setPlayerDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家角色设定"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) => setOpponentDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手角色设定"
/>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}>
/
</span>
<textarea
value={themeDescription}
disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={240}
placeholder=""
aria-label="主题/场景描述"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerCharacterImageSrc}
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={playerImageDescription}
disabled={isBusy}
onChange={(event) => setPlayerCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="玩家形象"
onChange={(event) => setPlayerImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="玩家形象描述"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentCharacterImageSrc}
<span className={FIELD_LABEL_CLASS}></span>
<textarea
value={opponentImageDescription}
disabled={isBusy}
onChange={(event) => setOpponentCharacterImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="对手形象"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
UI背景
</span>
<input
value={uiBackgroundImageSrc}
disabled={isBusy}
onChange={(event) => setUiBackgroundImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="UI背景"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={barkSoundSrc}
disabled={isBusy}
onChange={(event) => setBarkSoundSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="狗叫音效"
onChange={(event) => setOpponentImageDescription(event.target.value)}
className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={220}
aria-label="对手形象描述"
/>
</label>
</div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span>
<textarea
value={onomatopoeiaText}
disabled={isBusy}
onChange={(event) => {
setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value);
}}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={260}
aria-label="拟声词"
/>
</label>
{visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError}
@@ -299,7 +290,7 @@ export function BarkBattleConfigEditor({
</div>
</div>
<div className="mt-3 flex shrink-0 flex-wrap justify-center gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-4">
<div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={isBusy}

View File

@@ -0,0 +1,306 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
type BarkBattleImageGenerationBatchResult,
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { BarkBattleGeneratingView } from './BarkBattleGeneratingView';
vi.mock('../../services/bark-battle-creation', () => ({
generateAllBarkBattleImageAssets: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
}));
vi.mock('./BarkBattlePreviewCard', () => ({
BarkBattlePreviewCard: () => <div></div>,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
};
describe('BarkBattleGeneratingView', () => {
it('renders all generation slots while parallel generation is still running', async () => {
const onComplete = vi.fn();
let resolveGeneration: (
result: BarkBattleImageGenerationBatchResult,
) => void = () => {};
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
new Promise<BarkBattleImageGenerationBatchResult>((resolve) => {
resolveGeneration = resolve;
}),
);
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
expect(screen.getByText('玩家形象')).toBeTruthy();
expect(screen.getByText('对手形象')).toBeTruthy();
expect(screen.getByText('竞技背景')).toBeTruthy();
expect(onComplete).not.toHaveBeenCalled();
resolveGeneration({
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: {},
});
await waitFor(() => expect(onComplete).toHaveBeenCalled());
});
it('persists generated image assets before entering result view', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
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).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
});
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
configVersion: 3,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
false,
);
expect(onError).toHaveBeenCalledWith(null);
});
it('enters result view with partial failure when only part of the images are generated', async () => {
const onComplete = vi.fn();
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',
},
},
failures: {
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
configVersion: 3,
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
}),
true,
);
});
});
it('still enters result view when generated assets cannot be persisted', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
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).mockRejectedValue(
new Error('保存超时'),
);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
true,
);
});
expect(onError).toHaveBeenCalledWith('保存超时');
});
it('shows generation failures and enters result view when no image asset is generated', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {},
failures: {
'player-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue(draft);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
'泥点不足,本次需要 1 泥点,当前 0 泥点。',
);
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
draftId: draft.draftId,
workId: draft.workId,
title: draft.title,
}),
true,
);
});
});
});

View File

@@ -0,0 +1,357 @@
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type {
BarkBattleAssetSlot,
BarkBattleGeneratedImageAssets,
BarkBattleImageGenerationBatchResult,
BarkBattleImageGenerationFailures,
} from '../../services/bark-battle-creation';
import {
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleGeneratingViewProps = {
draft: BarkBattleDraftConfig;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onComplete: (draft: BarkBattleDraftConfig, partialFailed: boolean) => void;
onError: (message: string | null) => void;
};
type BarkBattleGeneratingSlotStatus = 'generating' | 'ready' | 'failed';
const GENERATION_STEPS = [
{ slot: 'player-character', label: '玩家形象' },
{ slot: 'opponent-character', label: '对手形象' },
{ slot: 'ui-background', label: '竞技背景' },
] as const satisfies readonly {
slot: BarkBattleAssetSlot;
label: string;
}[];
const activeBarkBattleGenerationTasks = new Map<
string,
Promise<BarkBattleImageGenerationBatchResult>
>();
function applyGeneratedAssets(
draft: BarkBattleDraftConfig,
assets: BarkBattleGeneratedImageAssets,
): BarkBattleDraftConfig {
const nextDraft: BarkBattleDraftConfig = {
...draft,
updatedAt: new Date().toISOString(),
};
if (assets['player-character']?.imageSrc) {
nextDraft.playerCharacterImageSrc = assets['player-character'].imageSrc;
}
if (assets['opponent-character']?.imageSrc) {
nextDraft.opponentCharacterImageSrc = assets['opponent-character'].imageSrc;
}
if (assets['ui-background']?.imageSrc) {
nextDraft.uiBackgroundImageSrc = assets['ui-background'].imageSrc;
}
return nextDraft;
}
function hasSlotAsset(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
if (slot === 'player-character') {
return Boolean(draft.playerCharacterImageSrc?.trim());
}
if (slot === 'opponent-character') {
return Boolean(draft.opponentCharacterImageSrc?.trim());
}
return Boolean(draft.uiBackgroundImageSrc?.trim());
}
function mergeSlotAsset(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
imageSrc: string,
): BarkBattleDraftConfig {
if (slot === 'player-character') {
return { ...draft, playerCharacterImageSrc: imageSrc };
}
if (slot === 'opponent-character') {
return { ...draft, opponentCharacterImageSrc: imageSrc };
}
return { ...draft, uiBackgroundImageSrc: imageSrc };
}
function isDraftPersistable(draft: BarkBattleDraftConfig) {
return Boolean(draft.draftId?.trim() && draft.workId?.trim());
}
function resolvePrimaryFailureMessage(
failures: BarkBattleImageGenerationFailures,
) {
for (const step of GENERATION_STEPS) {
const message = failures[step.slot]?.trim();
if (message) {
return message;
}
}
return null;
}
function buildDraftGenerationKey(draft: BarkBattleDraftConfig) {
return [
draft.draftId,
draft.playerCharacterImageSrc ?? '',
draft.opponentCharacterImageSrc ?? '',
draft.uiBackgroundImageSrc ?? '',
].join('|');
}
export function BarkBattleGeneratingView({
draft,
isBusy = false,
error = null,
onBack,
onComplete,
onError,
}: BarkBattleGeneratingViewProps) {
const startedDraftIdRef = useRef<string | null>(null);
const [slotFailures, setSlotFailures] =
useState<BarkBattleImageGenerationFailures>({});
const [previewDraft, setPreviewDraft] = useState(draft);
const [slotStatuses, setSlotStatuses] = useState<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>({});
const primaryFailureMessage = useMemo(
() => resolvePrimaryFailureMessage(slotFailures),
[slotFailures],
);
useEffect(() => {
setPreviewDraft(draft);
setSlotStatuses(
GENERATION_STEPS.reduce<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>((statuses, step) => {
statuses[step.slot] = hasSlotAsset(draft, step.slot)
? 'ready'
: 'generating';
return statuses;
}, {}),
);
}, [draft]);
useEffect(() => {
if (
!draft.draftId ||
(() => {
const draftGenerationKey = buildDraftGenerationKey(draft);
return startedDraftIdRef.current === draftGenerationKey;
})()
) {
return;
}
const startedDraftKey = buildDraftGenerationKey(draft);
startedDraftIdRef.current = startedDraftKey;
let cancelled = false;
const generationTask = generateAllBarkBattleImageAssets({
config: draft,
draftId: draft.draftId,
onSlotComplete: (slot, result) => {
if (cancelled || startedDraftIdRef.current !== startedDraftKey) {
return;
}
if (result.status === 'fulfilled') {
setPreviewDraft((currentDraft) =>
mergeSlotAsset(currentDraft, slot, result.asset.imageSrc),
);
setSlotStatuses((current) => ({ ...current, [slot]: 'ready' }));
setSlotFailures((current) => {
const next = { ...current };
delete next[slot];
return next;
});
return;
}
setSlotStatuses((current) => ({ ...current, [slot]: 'failed' }));
setSlotFailures((current) => ({ ...current, [slot]: result.message }));
},
});
activeBarkBattleGenerationTasks.set(startedDraftKey, generationTask);
onError(null);
setSlotFailures({});
setPreviewDraft(draft);
setSlotStatuses(
GENERATION_STEPS.reduce<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>((statuses, step) => {
statuses[step.slot] = hasSlotAsset(draft, step.slot)
? 'ready'
: 'generating';
return statuses;
}, {}),
);
void generationTask
.then(async ({ assets, failures }) => {
if (cancelled) {
return;
}
setSlotFailures(failures);
const primaryMessage = resolvePrimaryFailureMessage(failures);
if (primaryMessage) {
onError(primaryMessage);
}
const generatedDraft = applyGeneratedAssets(draft, assets);
const partialFailed = GENERATION_STEPS.some(
(step) => !hasSlotAsset(generatedDraft, step.slot),
);
if (!isDraftPersistable(generatedDraft)) {
onComplete(generatedDraft, partialFailed);
return;
}
try {
const persistedDraft = await updateBarkBattleDraftConfig({
draftId: generatedDraft.draftId,
workId: generatedDraft.workId,
configVersion: generatedDraft.configVersion,
rulesetVersion: generatedDraft.rulesetVersion,
title: generatedDraft.title,
description: generatedDraft.description,
themeDescription: generatedDraft.themeDescription,
playerImageDescription: generatedDraft.playerImageDescription,
opponentImageDescription: generatedDraft.opponentImageDescription,
onomatopoeia: generatedDraft.onomatopoeia,
playerCharacterImageSrc: generatedDraft.playerCharacterImageSrc,
opponentCharacterImageSrc: generatedDraft.opponentCharacterImageSrc,
uiBackgroundImageSrc: generatedDraft.uiBackgroundImageSrc,
difficultyPreset: generatedDraft.difficultyPreset,
});
const updatedDraft = applyGeneratedAssets(persistedDraft, assets);
if (!cancelled) {
onComplete(updatedDraft, partialFailed);
}
} catch (persistError) {
if (cancelled) {
return;
}
onError(
persistError instanceof Error
? persistError.message
: '汪汪声浪素材保存失败。',
);
onComplete(generatedDraft, true);
}
})
.catch((generationError) => {
if (cancelled) {
return;
}
onError(
generationError instanceof Error
? generationError.message
: '汪汪声浪素材生成失败。',
);
onComplete(draft, true);
})
.finally(() => {
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
});
return () => {
cancelled = true;
// 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
if (startedDraftIdRef.current === startedDraftKey) {
startedDraftIdRef.current = null;
}
};
}, [draft, onComplete, onError]);
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
</span>
</div>
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
<div className="grid content-start gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
<Sparkles className="h-4 w-4" />
</div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</div>
<div className="grid gap-2">
{GENERATION_STEPS.map((step) => {
const status =
slotStatuses[step.slot] ??
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
const ready = status === 'ready';
const failed =
status === 'failed' || Boolean(slotFailures[step.slot]);
const statusLabel = ready
? `${step.label}已生成`
: failed
? `${step.label}生成失败`
: `${step.label}生成中`;
return (
<div
key={step.slot}
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
aria-label={statusLabel}
>
<span className="text-sm font-black text-[var(--platform-text-strong)]">
{step.label}
</span>
{ready ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : failed ? (
<AlertCircle className="h-4 w-4 text-rose-500" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
)}
</div>
);
})}
</div>
{error || primaryFailureMessage ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error ?? primaryFailureMessage}
</div>
) : null}
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
</div>
</div>
);
}
export default BarkBattleGeneratingView;

View File

@@ -5,12 +5,6 @@ type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
};
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
@@ -18,16 +12,15 @@ const DIFFICULTY_LABELS = {
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
const hasCustomSound = Boolean(config.barkSoundSrc?.trim());
return (
<aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4"
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
aria-label="作品预览卡片"
>
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div
className="relative mb-4 grid min-h-[8.5rem] grid-cols-[1fr_auto_1fr] items-center gap-3 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-4 text-center text-3xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]"
className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl"
data-testid="bark-battle-preview-stage"
aria-hidden="true"
>
{config.uiBackgroundImageSrc ? (
@@ -42,13 +35,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage
src={config.playerCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐕</span>
<span className="text-4xl sm:text-6xl">🐕</span>
)}
</span>
<span className="relative rounded-full bg-white/70 px-3 py-1 text-base font-black text-[var(--platform-text-strong)]">
<span className="relative rounded-full bg-white/70 px-2.5 py-0.5 text-xs font-black text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base">
VS
</span>
<span className="relative grid place-items-center">
@@ -56,48 +49,44 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage
src={config.opponentCharacterImageSrc}
alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24"
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-5xl sm:text-6xl">🐶</span>
<span className="text-4xl sm:text-6xl">🐶</span>
)}
</span>
</div>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]">
<h2 className="text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-lg">
{config.title || '未命名声浪竞技场'}
</h2>
<p className="mt-2 min-h-[2.625rem] text-sm font-semibold leading-6 text-[var(--platform-text-muted)]">
<p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p>
<dl className="mt-4 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dl className="mt-2.5 grid gap-1.5 text-xs sm:mt-4 sm:gap-2 sm:text-sm">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{THEME_LABELS[config.themePreset] ?? config.themePreset}
{config.themeDescription || '声浪擂台'}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{config.playerDogSkinPreset || '主角'}
{config.playerImageDescription || '玩家'}
{' vs '}
{config.opponentDogSkinPreset || '对手'}
{config.opponentImageDescription || '对手'}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]}
</dd>
</div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]">
{[
config.playerCharacterImageSrc || config.opponentCharacterImageSrc ? '形象' : '',
config.uiBackgroundImageSrc ? 'UI' : '',
hasCustomSound ? '狗叫' : '',
].filter(Boolean).join(' / ') || '预设'}
{config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</dd>
</div>
</dl>

View File

@@ -4,7 +4,10 @@ import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { uploadBarkBattleAsset } from '../../services/bark-battle-creation';
import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
@@ -26,13 +29,12 @@ vi.mock('../ResolvedAssetImage', () => ({
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪测试杯',
title: '汪汪冠军杯',
description: '',
themePreset: 'sunny-yard',
playerDogSkinPreset: '主角',
opponentDogSkinPreset: '对手',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
leaderboardEnabled: true,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
@@ -54,7 +56,7 @@ describe('BarkBattleResultView', () => {
/>,
);
expect(screen.getByText('草稿编译')).toBeTruthy();
expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft);
expect(onPublish).not.toHaveBeenCalled();
@@ -63,7 +65,27 @@ describe('BarkBattleResultView', () => {
expect(onPublish).toHaveBeenCalledWith(draft);
});
it('uploads replacement assets into the selected slot', async () => {
it('uses compact mobile-first result layout classes', () => {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className).toContain(
'text-2xl',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain('max-lg:p-2');
expect(screen.getByTestId('bark-battle-preview-stage').className).toContain(
'min-h-[5.75rem]',
);
});
it('uploads replacement image assets into the selected slot', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
@@ -83,7 +105,9 @@ describe('BarkBattleResultView', () => {
/>,
);
const playerSlot = screen.getByText('玩家形象').closest('article');
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
const fileInput = within(playerSlot as HTMLElement).getByLabelText(
'上传玩家形象文件',
@@ -107,4 +131,82 @@ describe('BarkBattleResultView', () => {
}),
);
});
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
draft={{
...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png',
}}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article');
expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect(
within(playerSlot as HTMLElement).queryByText(
'generated-bark-battle-assets/player-character/very-long-object-key.png',
),
).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull();
});
it('keeps result assets to three image slots with per-slot regeneration only', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(regenerateBarkBattleImageAsset).mockResolvedValue({
imageSrc: '/generated-bark-battle-assets/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '玩家形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '对手形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'UI背景' })).toBeTruthy();
expect(screen.queryByText('狗叫音效')).toBeNull();
expect(screen.queryByRole('button', { name: '一次生成' })).toBeNull();
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }),
);
await waitFor(() => {
expect(regenerateBarkBattleImageAsset).toHaveBeenCalledWith({
slot: 'player-character',
config: expect.objectContaining({
title: '汪汪冠军杯',
themeDescription: '霓虹公园擂台',
}),
draftId: 'bark-battle-draft-1',
});
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
});

View File

@@ -6,7 +6,6 @@ import {
Play,
RefreshCw,
Upload,
Volume2,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
@@ -31,22 +30,20 @@ type BarkBattleResultViewProps = {
onPublish: (draft: BarkBattleDraftConfig) => void;
};
type BarkBattleImageSlot = Exclude<BarkBattleAssetSlot, 'bark-sound'>;
const SLOT_LABELS = {
'player-character': '玩家形象',
'opponent-character': '对手形象',
'ui-background': 'UI背景',
'bark-sound': '狗叫音效',
} satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themePreset: draft.themePreset,
playerDogSkinPreset: draft.playerDogSkinPreset,
opponentDogSkinPreset: draft.opponentDogSkinPreset,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
@@ -56,9 +53,7 @@ function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorP
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
};
}
@@ -77,7 +72,7 @@ function applyAssetToDraft(
if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
}
return { ...draft, barkSoundSrc: assetSrc, updatedAt };
return { ...draft, updatedAt };
}
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
@@ -90,7 +85,7 @@ function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot
if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? '';
}
return draft.barkSoundSrc ?? '';
return '';
}
function ResultActionButton({
@@ -111,7 +106,7 @@ function ResultActionButton({
onClick={onClick}
className={`platform-button ${
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
} min-h-11 justify-center disabled:cursor-not-allowed disabled:opacity-55`}
} min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
>
{children}
</button>
@@ -135,7 +130,7 @@ function BarkBattleAssetSlotControl({
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const isImageSlot = slot !== 'bark-sound';
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
@@ -152,7 +147,8 @@ function BarkBattleAssetSlotControl({
file,
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, asset.assetSrc));
const nextDraft = applyAssetToDraft(draft, slot, asset.assetSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。');
} finally {
@@ -161,20 +157,16 @@ function BarkBattleAssetSlotControl({
};
const handleRegenerate = async () => {
if (!isImageSlot) {
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
return;
}
setIsRegenerating(true);
onError(null);
try {
const result = await regenerateBarkBattleImageAsset({
slot: slot as BarkBattleImageSlot,
slot,
config: mapDraftToConfig(draft),
draftId: draft.draftId,
});
onChange(applyAssetToDraft(draft, slot, result.imageSrc));
const nextDraft = applyAssetToDraft(draft, slot, result.imageSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally {
@@ -185,29 +177,27 @@ function BarkBattleAssetSlotControl({
const isSlotBusy = isUploading || isRegenerating;
return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="flex items-center justify-between gap-3">
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3">
<div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0">
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]">
<h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
{SLOT_LABELS[slot]}
</h3>
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{assetSrc || '未替换'}
<div className="mt-0.5 truncate text-[11px] font-semibold text-[var(--platform-text-soft)] sm:mt-1 sm:text-xs">
{assetStatus}
</div>
</div>
{isSlotBusy ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
) : isImageSlot ? (
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
) : (
<Volume2 className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:mt-3 sm:gap-2">
<input
ref={fileInputRef}
type="file"
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'}
accept="image/png,image/jpeg,image/webp"
className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
@@ -216,7 +206,7 @@ function BarkBattleAssetSlotControl({
type="button"
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<Upload className="h-3.5 w-3.5" />
@@ -225,7 +215,7 @@ function BarkBattleAssetSlotControl({
type="button"
disabled={disabled || isSlotBusy}
onClick={handleRegenerate}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55"
className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
>
<RefreshCw className="h-3.5 w-3.5" />
@@ -247,33 +237,34 @@ export function BarkBattleResultView({
const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error;
const isActionBusy = isBusy;
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
disabled={isActionBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isActionBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-black text-emerald-700 sm:px-3 sm:py-1">
稿
</span>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
<div className="grid gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="text-sm font-black text-[var(--platform-text-soft)]">
<section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3">
<div className="grid gap-2.5 lg:gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4">
<div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿
</div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
<h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</div>
@@ -283,14 +274,13 @@ export function BarkBattleResultView({
'player-character',
'opponent-character',
'ui-background',
'bark-sound',
] as const
).map((slot) => (
<BarkBattleAssetSlotControl
key={slot}
draft={draft}
slot={slot}
disabled={isBusy}
disabled={isActionBusy}
onChange={(nextDraft) => {
setLocalError(null);
onDraftChange(nextDraft);
@@ -312,7 +302,7 @@ export function BarkBattleResultView({
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
<ResultActionButton
disabled={isBusy}
disabled={isActionBusy}
onClick={() => onStartTestRun(draft)}
>
<Play className="h-4 w-4" />
@@ -320,7 +310,7 @@ export function BarkBattleResultView({
</ResultActionButton>
<ResultActionButton
tone="primary"
disabled={isBusy}
disabled={isActionBusy}
onClick={() => onPublish(draft)}
>
{isBusy ? (

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
@@ -226,6 +227,42 @@ const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
publishedAt: null,
};
const barkBattleDraftItem: BarkBattleWorkSummary = {
workId: 'bark-battle-work-draft-visible',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '声浪作者',
title: '竖屏声浪草稿',
summary: '生成完成后也必须留在我的草稿里。',
themeDescription: '霓虹竖屏擂台',
playerImageDescription: '红围巾选手',
opponentImageDescription: '蓝头带对手',
onomatopoeia: ['炸场', '破阵'],
playerCharacterImageSrc: '/bark/player.png',
opponentCharacterImageSrc: '/bark/opponent.png',
uiBackgroundImageSrc: '/bark/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-21T10:00:00.000Z',
publishedAt: null,
};
const barkBattlePublishedItem: BarkBattleWorkSummary = {
...barkBattleDraftItem,
workId: 'bark-battle-work-published-visible',
draftId: 'bark-battle-draft-published-visible',
title: '竖屏声浪已发布',
summary: '发布完成后必须留在已发布作品里。',
authorDisplayName: '发布作者',
status: 'published',
playCount: 9,
updatedAt: '2026-05-21T10:10:00.000Z',
publishedAt: '2026-05-21T10:10:00.000Z',
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -592,6 +629,47 @@ test('creation hub shows delete action for baby object match drafts', async () =
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
});
test('creation hub works-only tab filters bark battle draft and published works', async () => {
const user = userEvent.setup();
const onOpenBarkBattleDetail = vi.fn();
render(
<CustomWorldCreationHub
mode="works-only"
items={[]}
barkBattleItems={[barkBattleDraftItem, barkBattlePublishedItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBarkBattleDetail={onOpenBarkBattleDetail}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '草稿 1' }));
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
await user.click(screen.getByRole('button', { name: '已发布 1' }));
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: //u }),
);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
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';
@@ -68,6 +69,9 @@ type CustomWorldCreationHubProps = {
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;
@@ -173,6 +177,9 @@ export function CustomWorldCreationHub({
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
@@ -196,6 +203,7 @@ export function CustomWorldCreationHub({
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
@@ -204,6 +212,7 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
@@ -219,6 +228,8 @@ export function CustomWorldCreationHub({
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
@@ -227,6 +238,7 @@ export function CustomWorldCreationHub({
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
barkBattleItems,
items,
match3dItems,
onDeleteBigFish,
@@ -235,12 +247,14 @@ export function CustomWorldCreationHub({
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
@@ -284,6 +298,9 @@ export function CustomWorldCreationHub({
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;

View File

@@ -61,6 +61,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'square-hole': '/creation-type-references/square-hole.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
@@ -727,6 +728,8 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
<div className="creation-work-card__author">{item.authorDisplayName}</div>
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (

View File

@@ -1,10 +1,16 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({
@@ -50,6 +56,253 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
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,
},
{
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: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:02:00.000Z',
publishedAt: '2026-05-14T10:02:00.000Z',
},
],
});
expect(items).toHaveLength(1);
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BB-TLEWORK1');
expect(items[0]?.authorDisplayName).toBe('测试玩家');
});
test('buildCreationWorkShelfItems keeps separate bark battle draft and published works visible', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-DRAFT001',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player.png',
opponentCharacterImageSrc: '/draft-opponent.png',
uiBackgroundImageSrc: '/draft-background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
workId: 'BB-PUB00001',
draftId: 'bark-battle-draft-published',
ownerUserId: 'user-1',
authorDisplayName: '发布作者',
title: '已发布声浪赛',
summary: '',
themeDescription: '霓虹声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: '/published-player.png',
opponentCharacterImageSrc: '/published-opponent.png',
uiBackgroundImageSrc: '/published-background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 3,
updatedAt: '2026-05-21T00:00:00.000Z',
publishedAt: '2026-05-21T00:00:00.000Z',
},
],
});
expect(items).toHaveLength(2);
expect(items.find((item) => item.status === 'draft')?.id).toBe('BB-DRAFT001');
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
});
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-COVER001',
draftId: 'bark-battle-draft-cover',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '角色封面声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player-cover.png',
opponentCharacterImageSrc: '/draft-opponent-cover.png',
uiBackgroundImageSrc: null,
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'partial_failed',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
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: 'normal',
status: 'draft',
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-19T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
});
test('buildCreationWorkShelfItems keeps bark battle draft author display name', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-draft-author',
draftId: 'bark-battle-draft-author',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('draft');
expect(items[0]?.authorDisplayName).toBe('草稿作者');
});
test('buildCreationWorkShelfItems falls back unknown authors to player label', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d-work-author-fallback',
profileId: 'match3d-profile-author-fallback',
ownerUserId: 'user-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '把水果从透明罐里抓出来。',
tags: [],
coverImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
},
],
});
expect(items[0]?.kind).toBe('match3d');
expect(items[0]?.authorDisplayName).toBe('玩家');
});
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
const onOpenPuzzleDetail = vi.fn();
const onDeletePuzzle = vi.fn();
@@ -672,6 +925,159 @@ test('buildCreationWorkShelfItems uses match3d transparent container reference a
);
});
test('buildCreationWorkShelfItems maps bark battle works with scene role cover and BB code', () => {
const onOpenBarkBattleDetail = vi.fn();
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-12345678',
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: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 6,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
},
],
onOpenBarkBattleDetail,
});
const item = items[0];
item?.actions.open();
expect(item?.kind).toBe('bark-battle');
expect(item?.publicWorkCode).toBe('BB-12345678');
expect(item?.sharePath).toContain('/works/detail?work=BB-12345678');
expect(item?.coverImageSrc).toBe('/generated-bark-battle/background.png');
expect(item?.coverRenderMode).toBe('scene_with_roles');
expect(item?.coverCharacterImageSrcs).toEqual([
'/generated-bark-battle/player.png',
'/generated-bark-battle/opponent.png',
]);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(
expect.objectContaining({ workId: 'bark-battle-work-12345678' }),
);
});
test('bark battle draft generating state follows pending assets or missing three images', () => {
const draft = {
workId: 'bark-battle-work-draft',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地',
playerImageDescription: '柯基',
opponentImageDescription: '哈士奇',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy' as const,
status: 'draft' as const,
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
};
expect(hasBarkBattleRequiredImages(draft)).toBe(false);
expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true);
expect(
isPersistedBarkBattleDraftGenerating({
...draft,
opponentCharacterImageSrc: '/opponent.png',
generationStatus: 'ready',
}),
).toBe(false);
});
test('CustomWorldWorkCard renders author for draft and published works', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
authorDisplayName: string,
): CreationWorkShelfItem => ({
id: `card-${status}`,
kind: 'bark-battle',
status,
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
updatedAt: '2026-05-20T00:00:00.000Z',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
sharePath: null,
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],
actions: { open: () => {} },
source: {
kind: 'bark-battle',
item: {
workId: `bark-battle-${status}`,
draftId: `draft-${status}`,
ownerUserId: 'user-1',
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
themeDescription: '公园舞台',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status,
generationStatus: 'ready',
publishReady: status === 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: status === 'published' ? '2026-05-20T00:00:00.000Z' : null,
},
},
});
const draftHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('draft', '草稿作者'),
onOpen: () => {},
}),
);
const publishedHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('published', '发布作者'),
onOpen: () => {},
}),
);
expect(draftHtml).toContain('作者:草稿作者');
expect(publishedHtml).toContain('作者:发布作者');
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567,

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 { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -9,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
@@ -19,6 +21,9 @@ import type { CustomWorldProfile } from '../../types';
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
'/match3d-background-references/pot-fused-reference.png';
const BARK_BATTLE_REFERENCE_COVER_SRC =
'/creation-type-references/bark-battle.webp';
const DEFAULT_CREATION_WORK_AUTHOR = '玩家';
export type CreationWorkShelfKind =
| 'rpg'
@@ -27,6 +32,7 @@ export type CreationWorkShelfKind =
| 'square-hole'
| 'puzzle'
| 'baby-object-match'
| 'bark-battle'
| 'visual-novel';
export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -84,6 +90,10 @@ export type CreationWorkShelfSource =
kind: 'visual-novel';
item: VisualNovelWorkSummary;
}
| {
kind: 'bark-battle';
item: BarkBattleWorkSummary;
}
| {
kind: 'baby-object-match';
item: BabyObjectMatchDraft;
@@ -103,6 +113,7 @@ export type CreationWorkShelfItem = {
hasUnreadUpdate?: boolean;
title: string;
summary: string;
authorDisplayName: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
@@ -127,6 +138,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
@@ -134,6 +146,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void;
@@ -149,6 +162,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenBarkBattleDetail?: (item: BarkBattleWorkSummary) => void;
onDeleteBarkBattle?: (item: BarkBattleWorkSummary) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
@@ -163,6 +178,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [],
puzzleItems,
babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [],
canDeleteRpg = false,
canDeleteBigFish = false,
@@ -170,6 +186,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false,
canDeletePuzzle = false,
canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
canDeleteVisualNovel = false,
onOpenRpgDraft,
onEnterRpgPublished,
@@ -185,6 +202,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
@@ -229,6 +248,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteBabyObjectMatch,
}),
),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
@@ -259,6 +284,28 @@ export function buildCreationWorkShelfItems(params: {
);
}
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
const byWorkId = new Map<string, BarkBattleWorkSummary>();
for (const item of items) {
const current = byWorkId.get(item.workId);
if (!current) {
byWorkId.set(item.workId, item);
continue;
}
if (current.status !== 'published' && item.status === 'published') {
byWorkId.set(item.workId, { ...current, ...item });
continue;
}
if (current.status === item.status) {
byWorkId.set(item.workId, { ...current, ...item });
}
}
return Array.from(byWorkId.values());
}
type RpgWorkShelfAdapter = {
onOpenDraft?: (item: CustomWorldWorkSummary) => void;
onEnterPublished?: (profileId: string) => void;
@@ -303,6 +350,7 @@ function mapRpgWorkToShelfItem(
status: item.status,
title: item.title,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item, libraryEntry),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
@@ -342,6 +390,7 @@ function mapBigFishWorkToShelfItem(
status: item.status,
title: item.title,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
@@ -386,6 +435,7 @@ function mapMatch3DWorkToShelfItem(
status,
title: item.gameName,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -434,6 +484,7 @@ function mapPuzzleWorkToShelfItem(
item.workDescription?.trim() ||
item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -500,6 +551,7 @@ function mapBabyObjectMatchDraftToShelfItem(
summary:
item.workDescription.trim() ||
`${item.itemNames[0]}${item.itemNames[1]}识物分类`,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -549,6 +601,7 @@ function mapVisualNovelWorkToShelfItem(
status,
title,
summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
@@ -578,6 +631,72 @@ function mapVisualNovelWorkToShelfItem(
};
}
function mapBarkBattleWorkToShelfItem(
item: BarkBattleWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<BarkBattleWorkSummary>,
): CreationWorkShelfItem {
const status = item.status;
const publicWorkCode =
status === 'published' ? buildBarkBattlePublicWorkCode(item.workId) : null;
const playerCharacterImageSrc = normalizeCoverImageSrc(
item.playerCharacterImageSrc,
);
const opponentCharacterImageSrc = normalizeCoverImageSrc(
item.opponentCharacterImageSrc,
);
const coverImageSrc =
normalizeCoverImageSrc(item.uiBackgroundImageSrc) ??
playerCharacterImageSrc ??
opponentCharacterImageSrc ??
BARK_BATTLE_REFERENCE_COVER_SRC;
const coverCharacterImageSrcs = [
playerCharacterImageSrc,
opponentCharacterImageSrc,
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
const canRenderSceneWithRoles =
Boolean(normalizeCoverImageSrc(item.uiBackgroundImageSrc)) &&
coverCharacterImageSrcs.length >= 2;
return {
id: item.workId,
kind: 'bark-battle',
status,
title: item.title.trim() || '汪汪声浪大作战',
summary:
item.summary.trim() ||
item.themeDescription.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : '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: 'bark-battle', item },
};
}
function mapSquareHoleWorkToShelfItem(
item: SquareHoleWorkSummary,
canDelete: boolean,
@@ -596,6 +715,7 @@ function mapSquareHoleWorkToShelfItem(
status,
title: item.gameName,
summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: 'image',
@@ -625,6 +745,26 @@ function mapSquareHoleWorkToShelfItem(
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
for (const source of sources) {
const authorDisplayName =
source &&
typeof source === 'object' &&
'authorDisplayName' in source &&
typeof source.authorDisplayName === 'string'
? source.authorDisplayName.trim()
: '';
if (authorDisplayName) {
return authorDisplayName;
}
}
return DEFAULT_CREATION_WORK_AUTHOR;
}
function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null;
}
@@ -816,11 +956,34 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item);
case 'bark-battle':
return isPersistedBarkBattleDraftGenerating(item.source.item);
default:
return false;
}
}
export function isPersistedBarkBattleDraftGenerating(
item: BarkBattleWorkSummary,
) {
if (item.status === 'published') {
return false;
}
return (
item.generationStatus === 'pending_assets' ||
!hasBarkBattleRequiredImages(item)
);
}
export function hasBarkBattleRequiredImages(item: BarkBattleWorkSummary) {
return Boolean(
normalizeCoverImageSrc(item.playerCharacterImageSrc) &&
normalizeCoverImageSrc(item.opponentCharacterImageSrc) &&
normalizeCoverImageSrc(item.uiBackgroundImageSrc),
);
}
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
if (item.generationStatus !== 'generating') {
return false;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ import {
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
@@ -67,6 +68,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
return '视觉小说';
}
if (isBarkBattleGalleryEntry(entry)) {
return '汪汪声浪';
}
if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName;
}

View File

@@ -0,0 +1,108 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import {
mergeBarkBattleWorksByWorkId,
mergeBarkBattleWorkSummary,
shouldPreserveLocalBarkBattleWorkOnRefresh,
} from './barkBattleWorkCache';
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'BB-cache-race-12345678',
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-21T10:00:00.000Z',
publishedAt: null,
...overrides,
};
}
test('preserves local published bark battle when refresh only returns same work draft', () => {
const published = buildBarkBattleWork({
status: 'published',
playCount: 3,
updatedAt: '2026-05-21T10:02:00.000Z',
publishedAt: '2026-05-21T10:02:00.000Z',
});
const refreshedDraft = buildBarkBattleWork({
status: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: null,
});
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(published, [refreshedDraft])).toBe(
true,
);
const [merged] = mergeBarkBattleWorksByWorkId([refreshedDraft, published]);
expect(merged?.status).toBe('published');
expect(merged?.publishedAt).toBe('2026-05-21T10:02:00.000Z');
expect(merged?.playCount).toBe(3);
});
test('does not let later draft cache updates downgrade an existing published bark battle', () => {
const published = buildBarkBattleWork({
status: 'published',
playCount: 4,
updatedAt: '2026-05-21T10:03:00.000Z',
publishedAt: '2026-05-21T10:03:00.000Z',
});
const staleDraft = buildBarkBattleWork({
title: '旧草稿标题',
status: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: null,
});
const merged = mergeBarkBattleWorkSummary(published, staleDraft);
expect(merged.status).toBe('published');
expect(merged.title).toBe('汪汪测试杯');
expect(merged.playCount).toBe(4);
expect(merged.publishedAt).toBe('2026-05-21T10:03:00.000Z');
});
test('preserves local ready bark battle draft when refresh has not returned it yet', () => {
const readyDraft = buildBarkBattleWork({
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playerCharacterImageSrc: '/generated-bark-battle/player-ready.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent-ready.png',
uiBackgroundImageSrc: '/generated-bark-battle/background-ready.png',
});
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])).toBe(true);
const merged = mergeBarkBattleWorksByWorkId([
...[],
...(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])
? [readyDraft]
: []),
]);
expect(merged).toHaveLength(1);
expect(merged[0]?.workId).toBe('BB-cache-race-12345678');
expect(merged[0]?.generationStatus).toBe('ready');
});

View File

@@ -0,0 +1,112 @@
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
BarkBattleDraftConfig,
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
export type BarkBattleGenerationStatus = SharedBarkBattleGenerationStatus;
export function mergeBarkBattleWorkSummary(
current: BarkBattleWorkSummary,
updated: BarkBattleWorkSummary,
): BarkBattleWorkSummary {
if (current.workId !== updated.workId) {
return current;
}
if (current.status === 'published' && updated.status !== 'published') {
return {
...updated,
...current,
playCount: current.playCount ?? updated.playCount,
recentPlayCount7d: current.recentPlayCount7d ?? updated.recentPlayCount7d,
updatedAt: current.updatedAt || updated.updatedAt,
publishedAt: current.publishedAt ?? updated.publishedAt,
};
}
return { ...current, ...updated };
}
export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary) {
return Boolean(
item.playerCharacterImageSrc?.trim() &&
item.opponentCharacterImageSrc?.trim() &&
item.uiBackgroundImageSrc?.trim(),
);
}
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
item: BarkBattleWorkSummary,
refreshed: readonly BarkBattleWorkSummary[],
) {
if (item.status === 'published') {
return !refreshed.some(
(entry) => entry.workId === item.workId && entry.status === 'published',
);
}
if (refreshed.some((entry) => entry.workId === item.workId)) {
return false;
}
// 中文注释Bark Battle 创建/生成完成/保存后会先把本地摘要塞进作品架,
// 后端 /works 读模型可能短暂落后;只要刷新结果还没有同 workId就保留本地草稿
// 避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿”里消失。
return true;
}
export function buildBarkBattleWorkSummaryFromDraft(
draft: BarkBattleDraftConfig,
user: PublicUserSummary | null | undefined,
generationStatus: BarkBattleGenerationStatus = 'pending_assets',
): BarkBattleWorkSummary {
const workId = draft.workId?.trim() || draft.draftId;
return {
workId,
draftId: draft.draftId,
ownerUserId: user?.id ?? '',
authorDisplayName: user?.displayName ?? '创作者',
title: draft.title,
summary: draft.description ?? '',
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
playerCharacterImageSrc: draft.playerCharacterImageSrc ?? null,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc ?? null,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc ?? null,
difficultyPreset: draft.difficultyPreset,
status: 'draft',
generationStatus,
publishReady: Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
),
playCount: 0,
updatedAt: draft.updatedAt,
publishedAt: null,
};
}
export function mergeBarkBattleWorksByWorkId(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
const byWorkId = new Map<string, BarkBattleWorkSummary>();
for (const item of items) {
const current = byWorkId.get(item.workId);
if (!current) {
byWorkId.set(item.workId, item);
continue;
}
if (current.status !== 'published' && item.status === 'published') {
byWorkId.set(item.workId, { ...current, ...item });
continue;
}
if (current.status === item.status || current.status === 'published') {
byWorkId.set(item.workId, mergeBarkBattleWorkSummary(current, item));
}
}
return Array.from(byWorkId.values());
}

View File

@@ -31,6 +31,7 @@ export type SelectionStage =
| 'square-hole-generating'
| 'square-hole-result'
| 'square-hole-runtime'
| 'bark-battle-generating'
| 'bark-battle-result'
| 'bark-battle-runtime'
| 'creative-agent-workspace'

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);
}

View File

@@ -15,11 +15,67 @@ export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
roundDurationMs: 30_000,
countdownMs: 3_000,
drawThreshold: 12,
barkThreshold: 0.5,
minBarkGapMs: 300,
barkThreshold: 0.35,
minBarkGapMs: 150,
minBarkDurationMs: 90,
maxBarkDurationMs: 900,
balanceFactor: 32,
calibrationMaxWaitMs: 4_000,
opponentBasePower: 0.22,
};
const BASE_ONOMATOPOEIA = [
'轰!',
'炸场!',
'冲啊!',
'破阵!',
'爆发!',
'燃起来!',
'顶上去!',
'压过去!',
'震翻全场!',
'声浪拉满!',
] as const;
const DOG_ONOMATOPOEIA = ['轰汪!', '汪爆!', '嗷呜!'] as const;
const TECH_ONOMATOPOEIA = ['能量爆裂!', '超频!', '电光轰鸣!'] as const;
const FANTASY_ONOMATOPOEIA = ['龙吼!', '雷鸣!', '战鼓!'] as const;
type BarkBattleOnomatopoeiaSeed = {
themeDescription?: string;
playerImageDescription?: string;
opponentImageDescription?: string;
};
function pushUnique(target: string[], words: readonly string[]) {
for (const word of words) {
if (!target.includes(word)) {
target.push(word);
}
}
}
export function buildBarkBattleDefaultOnomatopoeia(
seed: BarkBattleOnomatopoeiaSeed = {},
) {
const joined = [
seed.themeDescription,
seed.playerImageDescription,
seed.opponentImageDescription,
]
.join(' ')
.toLowerCase();
const words: string[] = [];
if (/||||||shiba|husky|corgi|dog/u.test(joined)) {
pushUnique(words, DOG_ONOMATOPOEIA);
}
if (/||||||||laser|robot|mecha|cyber/u.test(joined)) {
pushUnique(words, TECH_ONOMATOPOEIA);
}
if (/||||||dragon|knight|magic/u.test(joined)) {
pushUnique(words, FANTASY_ONOMATOPOEIA);
}
pushUnique(words, BASE_ONOMATOPOEIA);
return words.slice(0, 16);
}

View File

@@ -26,6 +26,11 @@ export class BarkBattleController {
this.restart();
}
updateConfigForActiveRound(config: BarkBattleConfig) {
this.config = config;
this.detector = this.createDetector();
}
finishNow() {
if (this.session.snapshot.phase !== 'playing') {
this.session = this.session.startMockRound();

View File

@@ -72,4 +72,22 @@ describe('BarkBattleController', () => {
expect(controller.getSnapshot().player.barkCount).toBe(2);
});
it('默认阈值和冷却降低后,真实输入能快速连续触发声浪', () => {
const controller = new BarkBattleController({
...DEFAULT_BARK_BATTLE_CONFIG,
countdownMs: 0,
});
controller.startWithMockInput();
controller.submitInputSample(0.36, 0);
controller.submitInputSample(0.38, 150);
controller.submitInputSample(0.1, 170);
controller.submitInputSample(0.39, 300);
controller.submitInputSample(0.1, 320);
expect(DEFAULT_BARK_BATTLE_CONFIG.barkThreshold).toBeLessThanOrEqual(0.35);
expect(DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs).toBeLessThanOrEqual(150);
expect(controller.getSnapshot().player.barkCount).toBe(3);
});
});

View File

@@ -68,23 +68,11 @@ export class BarkBattleSession {
lastEvents: [],
};
if (remainingMs > 0) {
if (remainingMs > 0 && !hasEnergyReachedEdge(energy)) {
return new BarkBattleSession(this.config, nextSnapshot);
}
const result = buildBarkBattleResult({
energy,
drawThreshold: this.config.drawThreshold,
playerBarkCount: nextSnapshot.player.barkCount,
opponentBarkCount: nextSnapshot.opponent.barkCount,
});
return new BarkBattleSession(this.config, {
...nextSnapshot,
phase: 'finished',
uiState: 'finished',
winner: result.winner,
result,
});
return this.finishWithSnapshot(nextSnapshot);
}
applyPlayerBark(event: BarkBattleEvent) {
@@ -93,15 +81,22 @@ export class BarkBattleSession {
}
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
return new BarkBattleSession(this.config, {
const energy = clampEnergy(this.snapshot.energy + event.peakVolume * 12);
const nextSnapshot: BarkBattleSnapshot = {
...this.snapshot,
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12),
energy,
player: {
barkCount: this.snapshot.player.barkCount + 1,
power: playerPower,
},
lastEvents: [event],
});
};
if (hasEnergyReachedEdge(energy)) {
return this.finishWithSnapshot(nextSnapshot);
}
return new BarkBattleSession(this.config, nextSnapshot);
}
failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
@@ -121,6 +116,26 @@ export class BarkBattleSession {
lastEvents,
});
}
private finishWithSnapshot(snapshot: BarkBattleSnapshot) {
const result = buildBarkBattleResult({
energy: snapshot.energy,
drawThreshold: this.config.drawThreshold,
playerBarkCount: snapshot.player.barkCount,
opponentBarkCount: snapshot.opponent.barkCount,
});
return new BarkBattleSession(this.config, {
...snapshot,
phase: 'finished',
uiState: 'finished',
winner: result.winner,
result,
});
}
}
function hasEnergyReachedEdge(energy: number) {
return Math.abs(energy) >= 100;
}
const MICROPHONE_STATUS_KEYS = {

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
import { decideBarkBattleWinner } from '../BarkBattleScoring';
import { createBarkBattleSession } from '../BarkBattleSession';
import { BarkBattleSession, createBarkBattleSession } from '../BarkBattleSession';
describe('BarkBattleSession', () => {
it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
@@ -38,6 +38,50 @@ describe('BarkBattleSession', () => {
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
expect(session.snapshot.energy).toBe(before.energy);
});
it('顶部能量条被玩家推到边界时立刻结算', () => {
const config = {
...DEFAULT_BARK_BATTLE_CONFIG,
roundDurationMs: 10_000,
countdownMs: 0,
balanceFactor: 200,
opponentBasePower: 0,
};
let session = createBarkBattleSession(config).startMockRound();
session = new BarkBattleSession(config, {
...session.snapshot,
energy: 89,
});
session = session.applyPlayerBark({
atMs: 0,
peakVolume: 1,
durationMs: 120,
side: 'player',
});
expect(session.snapshot.phase).toBe('finished');
expect(session.snapshot.remainingMs).toBe(10_000);
expect(session.snapshot.energy).toBe(100);
expect(session.snapshot.result?.winner).toBe('player');
});
it('顶部能量条被对手推到边界时立刻结算', () => {
let session = createBarkBattleSession({
...DEFAULT_BARK_BATTLE_CONFIG,
roundDurationMs: 10_000,
countdownMs: 0,
balanceFactor: 200,
opponentBasePower: 1,
}).startMockRound();
session = session.tick(500);
expect(session.snapshot.phase).toBe('finished');
expect(session.snapshot.remainingMs).toBe(9_500);
expect(session.snapshot.energy).toBe(-100);
expect(session.snapshot.result?.winner).toBe('opponent');
});
});
describe('decideBarkBattleWinner', () => {

View File

@@ -11,6 +11,22 @@
overflow: hidden;
}
.bark-battle-runtime__back-button {
position: fixed;
top: max(12px, env(safe-area-inset-top));
left: 12px;
z-index: 9;
border: 1px solid rgba(255, 247, 237, 0.46);
border-radius: 999px;
padding: 10px 14px;
color: #fff7ed;
background: rgba(15, 23, 42, 0.58);
font-size: 14px;
font-weight: 900;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.28);
backdrop-filter: blur(14px);
}
.bark-battle-hud__background-image {
position: absolute;
inset: 0;
@@ -116,6 +132,23 @@
background: rgba(255, 255, 255, 0.16);
}
.bark-battle-countdown {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
pointer-events: none;
font-size: clamp(82px, 30vw, 168px);
font-weight: 1000;
line-height: 1;
color: #fff7ed;
text-shadow:
0 10px 32px rgba(15, 23, 42, 0.66),
0 0 36px rgba(250, 204, 21, 0.56);
animation: barkBattleCountdownPulse 920ms ease-out infinite;
}
.bark-battle-controls,
.bark-battle-result__stats {
position: relative;
@@ -140,6 +173,20 @@
background: linear-gradient(135deg, #facc15, #fb7185);
}
.bark-battle-runtime-alert {
position: relative;
z-index: 1;
margin: 0 auto 10px;
width: min(92vw, 420px);
border-radius: 999px;
padding: 10px 14px;
text-align: center;
font-weight: 800;
color: #fff7ed;
background: rgba(127, 29, 29, 0.78);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
}
.bark-battle-status-card,
.bark-battle-result {
margin: auto;
@@ -153,6 +200,38 @@
backdrop-filter: blur(18px);
}
.bark-battle-result-modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: max(20px, env(safe-area-inset-top)) 16px max(20px, env(safe-area-inset-bottom));
background: rgba(15, 23, 42, 0.58);
backdrop-filter: blur(10px);
}
.bark-battle-result--modal {
margin: 0;
}
.bark-battle-result__actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 18px;
}
.bark-battle-result__actions button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
color: #1f1147;
background: #fff7ed;
font-weight: 900;
}
.bark-battle-result__stats span {
min-width: 84px;
display: grid;
@@ -302,3 +381,10 @@
42% { opacity: 1; }
to { transform: translateY(-80px) scale(1.14); opacity: 0; }
}
@keyframes barkBattleCountdownPulse {
from { transform: scale(0.84); opacity: 0; }
24% { opacity: 1; }
78% { transform: scale(1.06); opacity: 1; }
to { transform: scale(1.18); opacity: 0; }
}

View File

@@ -11,6 +11,10 @@ type BarkBattleHudProps = {
onMockBark?: () => void;
onMockQuiet?: () => void;
onRestart?: () => void;
enableMockControls?: boolean;
runtimeError?: string | null;
playerBurstText?: string;
opponentBurstText?: string;
playerCharacterImageSrc?: string | null;
opponentCharacterImageSrc?: string | null;
uiBackgroundImageSrc?: string | null;
@@ -36,6 +40,10 @@ export function BarkBattleHud({
onMockBark,
onMockQuiet,
onRestart,
enableMockControls = true,
runtimeError = null,
playerBurstText = '汪',
opponentBurstText = '反击',
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
@@ -43,6 +51,8 @@ export function BarkBattleHud({
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
const isUnavailable = snapshot.phase === 'unavailable';
const isCountingDown = snapshot.phase === 'countdown';
const countdownSeconds = Math.ceil(snapshot.countdownMs / 1000);
return (
<section className="bark-battle-hud" aria-label="汪汪声浪大作战">
@@ -80,8 +90,10 @@ export function BarkBattleHud({
</div>
) : (
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span>
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手声浪角色面向屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true">
{opponentBurstText}
</span>
<span className="bark-battle-dog__body">
{opponentCharacterImageSrc ? (
<ResolvedAssetImage
@@ -96,8 +108,10 @@ export function BarkBattleHud({
<span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span>
</div>
<div className="bark-battle-vs">VS</div>
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span>
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家声浪角色背对屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true">
{playerBurstText}
</span>
<span className="bark-battle-dog__body">
{playerCharacterImageSrc ? (
<ResolvedAssetImage
@@ -114,15 +128,32 @@ export function BarkBattleHud({
</div>
)}
{isCountingDown ? (
<div
className="bark-battle-countdown"
aria-label={`倒计时 ${countdownSeconds}`}
>
{countdownSeconds}
</div>
) : null}
{runtimeError ? (
<div className="bark-battle-runtime-alert" role="alert">
{runtimeError}
</div>
) : null}
<footer className="bark-battle-controls">
{snapshot.phase === 'permission' ? (
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
</button>
) : null}
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
</button>
{enableMockControls ? (
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
</button>
) : null}
{snapshot.phase === 'finished' ? (
<button type="button" onClick={onRestart}></button>
) : null}

View File

@@ -1,9 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl';
import type {
BarkBattleDerivedMetrics,
BarkBattlePublishedConfig,
BarkBattleRuntimeConfig,
BarkBattleRunStartResponse,
BarkBattleServerResult,
} from '../../../../packages/shared/src/contracts/barkBattle';
import {
finishBarkBattleRun,
startBarkBattleRun,
} from '../../../services/bark-battle-runtime';
import {
type BarkBattleConfig,
buildBarkBattleDefaultOnomatopoeia,
DEFAULT_BARK_BATTLE_CONFIG,
} from '../application/BarkBattleConfig';
import { BarkBattleController } from '../application/BarkBattleController';
@@ -13,12 +23,14 @@ import {
startBrowserMicrophoneSampler,
} from '../infrastructure/BrowserMicrophoneInput';
import { BarkBattleHud } from './BarkBattleHud';
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
type BarkBattleRuntimeMode = 'draft' | 'published';
type BarkBattleRuntimeShellProps = {
title?: string;
workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null;
runtimeMode?: BarkBattleRuntimeMode;
onExit?: () => void;
};
@@ -27,6 +39,66 @@ type DebugEvent = {
text: string;
};
type BarkBattleActiveRun = Pick<
BarkBattleRunStartResponse,
| 'runId'
| 'runToken'
| 'workId'
| 'configVersion'
| 'rulesetVersion'
| 'difficultyPreset'
| 'serverStartedAt'
>;
type BarkBattleMetricAccumulator = {
sampleCount: number;
volumeSum: number;
maxVolume: number;
comboMax: number;
currentCombo: number;
};
const BARK_BATTLE_CLIENT_RUNTIME_VERSION = 'bark-battle-web-v1';
function createMetricAccumulator(): BarkBattleMetricAccumulator {
return {
sampleCount: 0,
volumeSum: 0,
maxVolume: 0,
comboMax: 0,
currentCombo: 0,
};
}
function normalizeMetricVolume(volume: number) {
if (!Number.isFinite(volume)) {
return 0;
}
return Math.max(0, Math.min(1, volume));
}
function resolveClientResult(
winner: 'player' | 'opponent' | 'draw' | null,
): BarkBattleServerResult {
if (winner === 'player') {
return 'player_win';
}
if (winner === 'opponent') {
return 'opponent_win';
}
return 'draw';
}
function resolveResultTitle(winner: 'player' | 'opponent' | 'draw' | null) {
if (winner === 'player') {
return '汪力压制成功';
}
if (winner === 'opponent') {
return '对手声浪更强';
}
return '势均力敌';
}
const DEBUG_CONFIG_FIELDS: Array<{
key: keyof Pick<
BarkBattleConfig,
@@ -43,7 +115,13 @@ const DEBUG_CONFIG_FIELDS: Array<{
max: number;
step: number;
}> = [
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 },
{
key: 'roundDurationMs',
label: '局长(ms)',
min: 1000,
max: 60000,
step: 1000,
},
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
@@ -64,8 +142,13 @@ const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
'unknown',
]);
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason {
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
function isMicrophoneFailureReason(
reason: unknown,
): reason is MicrophoneFailureReason {
return (
typeof reason === 'string' &&
MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason)
);
}
function buildRuntimeConfigFromPublishedConfig(
@@ -79,9 +162,9 @@ function buildRuntimeConfigFromPublishedConfig(
BarkBattlePublishedConfig['difficultyPreset'],
Partial<BarkBattleConfig>
> = {
easy: { barkThreshold: 0.42, opponentBasePower: 0.16, drawThreshold: 10 },
normal: { barkThreshold: 0.5, opponentBasePower: 0.22, drawThreshold: 12 },
hard: { barkThreshold: 0.58, opponentBasePower: 0.3, drawThreshold: 14 },
easy: { opponentBasePower: 0.16 },
normal: { opponentBasePower: 0.22 },
hard: { opponentBasePower: 0.3 },
};
return {
@@ -90,10 +173,99 @@ function buildRuntimeConfigFromPublishedConfig(
};
}
function buildRuntimeConfigFromServerConfig(
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattleConfig {
const baseConfig = buildRuntimeConfigFromPublishedConfig({
workId: runtimeConfig.workId,
draftId: null,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
title: '',
description: '',
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
publishedAt: runtimeConfig.updatedAt,
});
return {
...baseConfig,
roundDurationMs: runtimeConfig.durationMs,
drawThreshold: runtimeConfig.drawThreshold,
minBarkGapMs: runtimeConfig.minBarkGapMs,
};
}
function normalizeOnomatopoeiaPool(
publishedConfig?: BarkBattlePublishedConfig | null,
) {
const custom = publishedConfig?.onomatopoeia
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24);
if (custom?.length) {
return custom;
}
return buildBarkBattleDefaultOnomatopoeia({
themeDescription: publishedConfig?.themeDescription,
playerImageDescription: publishedConfig?.playerImageDescription,
opponentImageDescription: publishedConfig?.opponentImageDescription,
});
}
function buildPublishedConfigFromServerRuntimeConfig(
current: BarkBattlePublishedConfig,
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattlePublishedConfig {
return {
...current,
workId: runtimeConfig.workId,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
};
}
function pickRandomOnomatopoeia(
pool: readonly string[],
previous: string,
) {
if (!pool.length) {
return '炸场!';
}
if (pool.length === 1) {
return pool[0] ?? '炸场!';
}
const candidates = pool.filter((word) => word !== previous);
const activePool = candidates.length ? candidates : pool;
const index = Math.min(
activePool.length - 1,
Math.floor(Math.random() * activePool.length),
);
return activePool[index] ?? activePool[0] ?? '炸场!';
}
export function BarkBattleRuntimeShell({
title = '汪汪声浪大作战',
workId,
publishedConfig,
runtimeMode = 'draft',
onExit,
}: BarkBattleRuntimeShellProps) {
const initialConfig = useMemo(
@@ -101,6 +273,7 @@ export function BarkBattleRuntimeShell({
[publishedConfig],
);
const [config, setConfig] = useState(initialConfig);
const runtimeConfigRef = useRef(initialConfig);
const controllerRef = useRef<BarkBattleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new BarkBattleController(config);
@@ -108,31 +281,52 @@ export function BarkBattleRuntimeShell({
const controller = controllerRef.current;
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
const [particleText, setParticleText] = useState('');
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock');
const replacementConfig = publishedConfig ?? null;
const [activePublishedConfig, setActivePublishedConfig] = useState(
replacementConfig,
);
const onomatopoeiaPool = useMemo(
() => normalizeOnomatopoeiaPool(activePublishedConfig),
[activePublishedConfig],
);
const [playerBurstText, setPlayerBurstText] = useState(
() => onomatopoeiaPool[0] ?? '炸场!',
);
const isPublishedRuntime =
runtimeMode === 'published' && Boolean(replacementConfig?.workId);
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>(
isPublishedRuntime ? 'microphone' : 'mock',
);
useEffect(() => {
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
}, [isPublishedRuntime]);
const [liveInputVolume, setLiveInputVolume] = useState(0);
const [isDebugExpanded, setIsDebugExpanded] = useState(false);
const [playerPulseKey, setPlayerPulseKey] = useState(0);
const [opponentPulseKey, setOpponentPulseKey] = useState(0);
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
const barkAudioRef = useRef<HTMLAudioElement | null>(null);
const [runtimeError, setRuntimeError] = useState<string | null>(null);
const heldRef = useRef(false);
const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const replacementConfig = publishedConfig ?? null;
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl(
replacementConfig?.barkSoundSrc ?? null,
const activeRunRef = useRef<BarkBattleActiveRun | null>(null);
const pendingRunStartRef = useRef<Promise<boolean> | null>(null);
const runStartedAtRef = useRef<string | null>(null);
const submittedRunIdsRef = useRef<Set<string>>(new Set());
const autoStartMicrophoneAttemptedRef = useRef(false);
const metricAccumulatorRef = useRef<BarkBattleMetricAccumulator>(
createMetricAccumulator(),
);
const lastOnomatopoeiaRef = useRef('');
// 中文注释:正式公开 runtime 面向玩家只保留真实麦克风入口mock 与调参面板只服务草稿试玩。
const shouldShowDebugPanel = !isPublishedRuntime;
const playBarkSound = useCallback(() => {
const audio = barkAudioRef.current;
if (!audio || !resolvedBarkSoundSrc) {
return;
}
audio.currentTime = 0;
void audio.play().catch(() => {});
}, [resolvedBarkSoundSrc]);
useEffect(() => {
lastOnomatopoeiaRef.current = '';
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
}, [onomatopoeiaPool]);
const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1;
@@ -140,72 +334,296 @@ export function BarkBattleRuntimeShell({
setDebugEvents((current) => [event, ...current].slice(0, 5));
}, []);
const flashOnomatopoeia = useCallback(() => {
const nextWord = pickRandomOnomatopoeia(
onomatopoeiaPool,
lastOnomatopoeiaRef.current,
);
lastOnomatopoeiaRef.current = nextWord;
setPlayerBurstText(nextWord);
setParticleText(nextWord);
window.setTimeout(() => setParticleText(''), 520);
}, [onomatopoeiaPool]);
const resetRuntimeRunState = useCallback(() => {
activeRunRef.current = null;
pendingRunStartRef.current = null;
runStartedAtRef.current = null;
submittedRunIdsRef.current = new Set();
metricAccumulatorRef.current = createMetricAccumulator();
setRuntimeError(null);
}, []);
const recordRuntimeSample = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.sampleCount += 1;
metrics.volumeSum += normalized;
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const recordRuntimeTrigger = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.currentCombo += 1;
metrics.comboMax = Math.max(metrics.comboMax, metrics.currentCombo);
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const buildDerivedMetrics = useCallback((): BarkBattleDerivedMetrics => {
const metrics = metricAccumulatorRef.current;
const nextSnapshot = controller.getSnapshot();
return {
triggerCount: nextSnapshot.player.barkCount,
maxVolume: Number(metrics.maxVolume.toFixed(3)),
averageVolume: Number(
(metrics.sampleCount
? metrics.volumeSum / metrics.sampleCount
: 0
).toFixed(3),
),
finalEnergy: Number(nextSnapshot.energy.toFixed(2)),
comboMax: metrics.comboMax,
};
}, [controller]);
const submitFinishedRunIfNeeded = useCallback(
(nextSnapshot = controller.getSnapshot()) => {
if (!isPublishedRuntime || nextSnapshot.phase !== 'finished') {
return;
}
const activeRun = activeRunRef.current;
if (!activeRun || submittedRunIdsRef.current.has(activeRun.runId)) {
return;
}
submittedRunIdsRef.current.add(activeRun.runId);
const finishedAt = new Date().toISOString();
const startedAt =
runStartedAtRef.current ?? activeRun.serverStartedAt ?? finishedAt;
const durationMs = Math.max(
0,
runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs,
);
void finishBarkBattleRun(activeRun.runId, {
runId: activeRun.runId,
runToken: activeRun.runToken,
workId: activeRun.workId,
configVersion: activeRun.configVersion,
rulesetVersion: activeRun.rulesetVersion,
difficultyPreset: activeRun.difficultyPreset,
clientStartedAt: startedAt,
clientFinishedAt: finishedAt,
durationMs,
derivedMetrics: buildDerivedMetrics(),
clientResult: resolveClientResult(nextSnapshot.winner),
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
})
.then(() => {
appendDebugEvent('正式成绩已提交');
})
.catch((error) => {
setRuntimeError(
error instanceof Error ? error.message : '提交正式成绩失败',
);
appendDebugEvent('正式成绩提交失败');
});
},
[
appendDebugEvent,
buildDerivedMetrics,
controller,
isPublishedRuntime,
],
);
const startFormalRunIfNeeded = useCallback(async (): Promise<boolean> => {
if (!isPublishedRuntime || !replacementConfig?.workId) {
return true;
}
if (activeRunRef.current) {
return true;
}
if (!pendingRunStartRef.current) {
pendingRunStartRef.current = (async () => {
try {
setRuntimeError(null);
const started = await startBarkBattleRun(replacementConfig.workId, {
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
sourceRoute:
typeof window === 'undefined'
? 'bark-battle-runtime'
: window.location.pathname,
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
});
const serverRuntimeConfig = buildRuntimeConfigFromServerConfig(
started.runtimeConfig,
);
// 中文注释:公开卡片可能只带摘要;正式开局后用服务端 runtimeConfig 刷新拟声词和素材。
setActivePublishedConfig((current) =>
buildPublishedConfigFromServerRuntimeConfig(
current ?? replacementConfig,
started.runtimeConfig,
),
);
runtimeConfigRef.current = serverRuntimeConfig;
controller.updateConfigForActiveRound(serverRuntimeConfig);
activeRunRef.current = {
runId: started.runId,
runToken: started.runToken,
workId: started.workId,
configVersion: started.configVersion,
rulesetVersion: started.rulesetVersion,
difficultyPreset: started.difficultyPreset,
serverStartedAt: started.serverStartedAt,
};
runStartedAtRef.current = new Date().toISOString();
appendDebugEvent(`正式对局已登记:${started.runId}`);
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : '启动正式对局失败';
setRuntimeError(message);
appendDebugEvent(message);
return false;
} finally {
pendingRunStartRef.current = null;
}
})();
}
return pendingRunStartRef.current ?? Promise.resolve(true);
}, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]);
const syncSnapshot = useCallback(() => {
const nextSnapshot = controller.getSnapshot();
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
setPlayerPulseKey((current) => current + 1);
playBarkSound();
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`);
recordRuntimeTrigger(nextSnapshot.player.power);
flashOnomatopoeia();
appendDebugEvent(
`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`,
);
}
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) {
if (
nextSnapshot.phase === 'playing' &&
Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >=
0.08
) {
setOpponentPulseKey((current) => current + 1);
playBarkSound();
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`);
appendDebugEvent(
`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`,
);
}
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
lastOpponentPowerRef.current = nextSnapshot.opponent.power;
setSnapshot(nextSnapshot);
}, [appendDebugEvent, controller, playBarkSound]);
submitFinishedRunIfNeeded(nextSnapshot);
}, [
appendDebugEvent,
controller,
flashOnomatopoeia,
recordRuntimeTrigger,
submitFinishedRunIfNeeded,
]);
const stopMicrophone = useCallback(() => {
microphoneSamplerRef.current?.stop();
microphoneSamplerRef.current = null;
}, []);
// 中文注释:领域层沿用 startMockRound 表示“进入对局倒计时”,正式/草稿输入差异由外层 sampler 控制。
const startRuntimeRound = useCallback(() => {
controller.startWithMockInput();
}, [controller]);
useEffect(() => {
setConfig(initialConfig);
runtimeConfigRef.current = initialConfig;
controller.updateConfig(initialConfig);
syncSnapshot();
}, [controller, initialConfig, syncSnapshot]);
setActivePublishedConfig(replacementConfig);
}, [controller, initialConfig, replacementConfig]);
const startMicrophone = useCallback(async () => {
stopMicrophone();
let shouldAcceptMicrophoneSamples = false;
try {
controller.startWithMockInput();
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
if (!shouldAcceptMicrophoneSamples) {
return;
}
setLiveInputVolume(volume);
recordRuntimeSample(volume);
if (volume >= config.barkThreshold) {
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
}
controller.submitInputSample(volume, atMs);
controller.submitInputSample(
volume,
controller.getSampleClockMs() + atMs,
);
});
if (!(await startFormalRunIfNeeded())) {
sampler.stop();
return;
}
startRuntimeRound();
microphoneSamplerRef.current = sampler;
setInputMode('microphone');
shouldAcceptMicrophoneSamples = true;
appendDebugEvent('真实麦克风已开启');
syncSnapshot();
} catch (error) {
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown';
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown';
const reason =
error && typeof error === 'object' && 'reason' in error
? error.reason
: 'unknown';
const failureReason = isMicrophoneFailureReason(reason)
? reason
: 'unknown';
controller.failMicrophone(failureReason);
appendDebugEvent(`麦克风不可用:${failureReason}`);
syncSnapshot();
}
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]);
}, [
appendDebugEvent,
config.barkThreshold,
controller,
recordRuntimeSample,
startFormalRunIfNeeded,
startRuntimeRound,
stopMicrophone,
syncSnapshot,
]);
useEffect(() => {
if (
!isPublishedRuntime ||
snapshot.phase !== 'permission' ||
autoStartMicrophoneAttemptedRef.current
) {
return;
}
// 中文注释:公开作品从详情页“启动”进入运行态后立即申请麦克风,授权成功后直接进入倒计时。
autoStartMicrophoneAttemptedRef.current = true;
void startMicrophone();
}, [isPublishedRuntime, snapshot.phase, startMicrophone]);
useEffect(() => stopMicrophone, [stopMicrophone]);
useEffect(() => {
runtimeConfigRef.current = config;
controller.updateConfig(config);
syncSnapshot();
}, [config, controller, syncSnapshot]);
setSnapshot(controller.getSnapshot());
}, [config, controller]);
useEffect(() => {
const timer = window.setInterval(() => {
controller.tick(100);
if (inputMode === 'mock') {
if (inputMode === 'mock' && !isPublishedRuntime) {
if (heldRef.current) {
recordRuntimeSample(0.88);
controller.submitMockSample(0.88);
} else {
recordRuntimeSample(0.12);
controller.submitMockSample(0.12);
setLiveInputVolume(0);
}
@@ -213,31 +631,52 @@ export function BarkBattleRuntimeShell({
syncSnapshot();
}, 100);
return () => window.clearInterval(timer);
}, [controller, inputMode, syncSnapshot]);
}, [
controller,
inputMode,
isPublishedRuntime,
recordRuntimeSample,
syncSnapshot,
]);
const restart = () => {
heldRef.current = false;
stopMicrophone();
setInputMode('mock');
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
setLiveInputVolume(0);
controller.restart();
setParticleText('');
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
setDebugEvents([]);
resetRuntimeRunState();
autoStartMicrophoneAttemptedRef.current = false;
lastPlayerBarkCountRef.current = 0;
lastOpponentPowerRef.current = 0;
syncSnapshot();
};
const startMock = () => {
const startMock = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
stopMicrophone();
setInputMode('mock');
setLiveInputVolume(0);
controller.startWithMockInput();
startRuntimeRound();
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot();
};
const finishNow = () => {
if (isPublishedRuntime && !activeRunRef.current) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
heldRef.current = false;
stopMicrophone();
controller.finishNow();
@@ -245,89 +684,179 @@ export function BarkBattleRuntimeShell({
syncSnapshot();
};
const bark = () => {
const bark = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
recordRuntimeSample(0.9);
controller.forcePlayerBark(0.9);
syncSnapshot();
setParticleText('汪!');
window.setTimeout(() => setParticleText(''), 680);
};
const exitRuntime = () => {
heldRef.current = false;
stopMicrophone();
onExit?.();
};
return (
<main className="bark-battle-runtime" aria-label={title}>
{resolvedBarkSoundSrc ? (
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" />
{onExit ? (
<button
className="bark-battle-runtime__back-button"
type="button"
onClick={exitRuntime}
>
</button>
) : null}
<BarkBattleHud
snapshot={snapshot}
playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc}
playerCharacterImageSrc={activePublishedConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={activePublishedConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={activePublishedConfig?.uiBackgroundImageSrc}
onStartMicrophone={startMicrophone}
onMockBark={bark}
onMockQuiet={() => {
heldRef.current = false;
}}
onRestart={restart}
enableMockControls={!isPublishedRuntime}
runtimeError={shouldShowDebugPanel ? null : runtimeError}
playerBurstText={playerBurstText}
opponentBurstText="反击"
/>
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板">
<header>
<strong></strong>
<button
type="button"
className="bark-battle-debug-panel__toggle"
aria-expanded={isDebugExpanded}
onClick={() => setIsDebugExpanded((current) => !current)}
{shouldShowDebugPanel ? (
<aside
className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`}
aria-label="调试面板"
>
<header>
<strong></strong>
<button
type="button"
className="bark-battle-debug-panel__toggle"
aria-expanded={isDebugExpanded}
onClick={() => setIsDebugExpanded((current) => !current)}
>
{isDebugExpanded ? '收起' : '展开'}
</button>
<span>{snapshot.phase}</span>
</header>
<div className="bark-battle-debug-panel__body">
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}>
</button>
<button type="button" onClick={finishNow}>
</button>
<button type="button" onClick={restart}>
</button>
{onExit ? (
<button type="button" onClick={onExit}>
</button>
) : null}
</div>
{workId ? (
<p className="bark-battle-debug-panel__work-id">{workId}</p>
) : null}
{runtimeError ? (
<p className="bark-battle-debug-panel__work-id" role="alert">
{runtimeError}
</p>
) : null}
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span className="bark-battle-debug-metrics__wide">
{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}
</span>
<span>{(liveInputVolume * 100).toFixed(0)}%</span>
<span>{controller.getSampleClockMs()}ms</span>
<span>{snapshot.player.barkCount}</span>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span>
<span>{Math.round(snapshot.energy)}</span>
</div>
<ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? (
debugEvents.map((event) => <li key={event.id}>{event.text}</li>)
) : (
<li></li>
)}
</ol>
{DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
aria-label={field.label}
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
</div>
</aside>
) : null}
{particleText ? (
<div className="bark-battle-particles">{particleText}</div>
) : null}
{snapshot.result ? (
<div className="bark-battle-result-modal" role="presentation">
<section
className="bark-battle-result bark-battle-result--modal"
role="dialog"
aria-modal="true"
aria-label="对战结算"
>
{isDebugExpanded ? '收起' : '展开'}
</button>
<span>{snapshot.phase}</span>
</header>
<div className="bark-battle-debug-panel__body">
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}></button>
<button type="button" onClick={finishNow}></button>
<button type="button" onClick={restart}></button>
{onExit ? <button type="button" onClick={onExit}></button> : null}
</div>
{workId ? (
<p className="bark-battle-debug-panel__work-id">{workId}</p>
) : null}
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span className="bark-battle-debug-metrics__wide">{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span>
<span>{(liveInputVolume * 100).toFixed(0)}%</span>
<span>{controller.getSampleClockMs()}ms</span>
<span>{snapshot.player.barkCount}</span>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span>
<span>{Math.round(snapshot.energy)}</span>
</div>
<ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li></li>}
</ol>
{DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
aria-label={field.label}
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
<p className="bark-battle-result__eyebrow"></p>
<h2>{resolveResultTitle(snapshot.result.winner)}</h2>
<div className="bark-battle-result__stats">
<span>
<strong>{snapshot.result.playerBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.opponentBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.score}</strong>
</span>
</div>
<div className="bark-battle-result__actions">
{onExit ? (
<button type="button" onClick={exitRuntime}>
</button>
) : null}
<button
className="bark-battle-primary-button"
type="button"
onClick={restart}
>
</button>
</div>
</section>
</div>
</aside>
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
) : null}
</main>
);
}

View File

@@ -1,19 +1,27 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
BarkBattlePublishedConfig,
BarkBattleRunStartResponse,
} from '../../../../../packages/shared/src/contracts/barkBattle';
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({
resolvedUrl: source ?? '',
isResolving: false,
shouldResolve: false,
}),
const runtimeClientMock = vi.hoisted(() => ({
startBarkBattleRun: vi.fn(),
finishBarkBattleRun: vi.fn(),
}));
const microphoneInputMock = vi.hoisted(() => ({
startBrowserMicrophoneSampler: vi.fn(),
}));
vi.mock('../../../../services/bark-battle-runtime', () => runtimeClientMock);
vi.mock('../../infrastructure/BrowserMicrophoneInput', () => microphoneInputMock);
vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
@@ -25,35 +33,192 @@ vi.mock('../../../../components/ResolvedAssetImage', () => ({
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
function createPublishedConfig(
overrides: Partial<BarkBattlePublishedConfig> = {},
): BarkBattlePublishedConfig {
return {
workId: 'work-bark-1',
draftId: 'draft-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '周末狗狗杯',
description: '公开汪汪声浪作品',
themeDescription: '霓虹城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柴犬主角',
opponentImageDescription: '戴蓝色头带的哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
difficultyPreset: 'hard',
updatedAt: '2026-05-13T03:00:00.000Z',
publishedAt: '2026-05-13T03:00:00.000Z',
...overrides,
};
}
function createRunStartResponse(
overrides: Partial<BarkBattleRunStartResponse> = {},
): BarkBattleRunStartResponse {
const publishedConfig = createPublishedConfig();
return {
runId: 'run-bark-1',
runToken: 'token-bark-1',
workId: publishedConfig.workId,
configVersion: publishedConfig.configVersion,
rulesetVersion: publishedConfig.rulesetVersion,
difficultyPreset: publishedConfig.difficultyPreset,
runtimeConfig: {
workId: publishedConfig.workId,
configVersion: publishedConfig.configVersion,
rulesetVersion: publishedConfig.rulesetVersion,
playTypeId: 'bark-battle',
durationMs: 30000,
energyMin: 0,
energyMax: 100,
drawThreshold: 12,
minBarkGapMs: 150,
difficultyPreset: publishedConfig.difficultyPreset,
themeDescription: publishedConfig.themeDescription,
playerImageDescription: publishedConfig.playerImageDescription,
opponentImageDescription: publishedConfig.opponentImageDescription,
playerCharacterImageSrc: publishedConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: publishedConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: publishedConfig.uiBackgroundImageSrc,
updatedAt: publishedConfig.updatedAt,
},
serverStartedAt: '2026-05-13T03:00:00.000Z',
expiresAt: '2026-05-13T03:10:00.000Z',
...overrides,
};
}
describe('BarkBattleRuntimeShell 调试面板', () => {
it('从发布配置加载自定义狗叫音效资源', () => {
afterEach(() => {
runtimeClientMock.startBarkBattleRun.mockReset();
runtimeClientMock.finishBarkBattleRun.mockReset();
microphoneInputMock.startBrowserMicrophoneSampler.mockReset();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('发布配置只渲染视觉素材', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
publishedConfig={{
workId: 'work-bark-1',
draftId: 'draft-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '周末狗狗杯',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z',
publishedAt: '2026-05-13T03:00:00.000Z',
}}
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
expect(document.querySelector('audio')).toBeNull();
expect(
document.querySelector('img[src="/generated-bark-battle/player.png"]'),
).toBeTruthy();
expect(
document.querySelector('img[src="/generated-bark-battle/ui.png"]'),
).toBeTruthy();
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
await waitFor(() => {
expect(
microphoneInputMock.startBrowserMicrophoneSampler,
).toHaveBeenCalledTimes(1);
});
});
it('草稿调试参数中难度只覆盖对手基础力,不改阈值和平局线', async () => {
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({ difficultyPreset: 'hard' })}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
expect((screen.getByLabelText('叫声阈值') as HTMLInputElement).value).toBe(
'0.35',
);
expect((screen.getByLabelText('平局阈值') as HTMLInputElement).value).toBe(
'12',
);
expect(
(screen.getByLabelText('叫声间隔(ms)') as HTMLInputElement).value,
).toBe('150');
expect(
(screen.getByLabelText('对手基础力') as HTMLInputElement).value,
).toBe('0.3');
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
});
it('发布配置使用自定义拟声词池并在连续触发时随机展示', async () => {
vi
.spyOn(Math, 'random')
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.7);
const onomatopoeia = ['炸场!', '冲啊!', '破阵!'];
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({ onomatopoeia })}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const firstBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(onomatopoeia.some((word) => firstBurst?.includes(word))).toBe(true);
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const nextBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(onomatopoeia.some((word) => nextBurst?.includes(word))).toBe(true);
expect(screen.queryByText('汪!')).toBeNull();
});
it('没有自定义拟声词时根据主题使用更燥的默认拟声词池', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0.2);
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({
themeDescription: '星舰机甲擂台,等离子音浪爆发',
playerImageDescription: '星际猫骑士',
opponentImageDescription: '机器人拳手',
})}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const burstText = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(
['能量爆裂!', '超频!', '电光轰鸣!', '雷鸣!'].some((word) =>
burstText?.includes(word),
),
).toBe(true);
expect(screen.queryByText('汪!')).toBeNull();
});
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
@@ -61,10 +226,16 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
const debugPanel = screen.getByLabelText('调试面板');
expect(debugPanel).toBeTruthy();
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy();
expect(
within(debugPanel).getByRole('button', { name: '展开' }),
).toBeTruthy();
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy();
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
expect(
within(debugPanel).getByRole('button', { name: '收起' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
@@ -83,18 +254,321 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
});
it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { reason: 'unsupported' }),
);
render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' }));
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
expect(screen.getAllByText(/unsupported/u).length).toBeGreaterThan(0);
expect(
screen.getAllByText(/unsupported/u).length,
).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(screen.getAllByText(/ mock /u).length).toBeGreaterThan(0);
expect(
screen.getAllByText(/ mock /u).length,
).toBeGreaterThan(0);
expect(screen.getByText(/Mock /u)).toBeTruthy();
});
it('草稿试玩不会登记正式对局', async () => {
render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态不渲染 mock 控制和调试面板,并自动申请麦克风权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '开始' })).toBeNull();
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
await waitFor(() => {
expect(
microphoneInputMock.startBrowserMicrophoneSampler,
).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
});
expect(
runtimeClientMock.startBarkBattleRun.mock.calls[0]?.[0],
).toBe('work-bark-1');
expect(createRunStartResponse().runtimeConfig.minBarkGapMs).toBe(150);
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态进入运行态后展示可点击的返回按钮', async () => {
const handleExit = vi.fn();
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
onExit={handleExit}
/>,
);
const backButton = screen.getByRole('button', { name: '返回' });
await userEvent.click(backButton);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('结束后弹出独立结果弹窗,并提供返回和再来一局', async () => {
const handleExit = vi.fn();
render(<BarkBattleRuntimeShell onExit={handleExit} />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
await userEvent.click(screen.getByRole('button', { name: '结束' }));
const resultDialog = screen.getByRole('dialog', { name: '对战结算' });
expect(resultDialog.getAttribute('aria-modal')).toBe('true');
expect(resultDialog.closest('.bark-battle-hud')).toBeNull();
expect(within(resultDialog).getByText('本局结束')).toBeTruthy();
expect(within(resultDialog).getByText('玩家叫声')).toBeTruthy();
expect(within(resultDialog).getByText('对手压制')).toBeTruthy();
expect(within(resultDialog).getByText('声浪分')).toBeTruthy();
expect(
within(resultDialog).getByRole('button', { name: '再来一局' }),
).toBeTruthy();
await userEvent.click(
within(resultDialog).getByRole('button', { name: '返回' }),
);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('发布态麦克风失败不会登记正式对局', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('permission-denied'), {
reason: 'permission-denied',
}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(screen.getByText('麦克风授权被拒绝')).toBeTruthy();
});
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态启动后会直接申请麦克风权限,授权成功后登记 start run 并进入倒计时', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalledWith(
'work-bark-1',
expect.objectContaining({
sourceRoute: expect.any(String),
clientRuntimeVersion: expect.any(String),
}),
);
});
expect(screen.getByLabelText(//u)).toBeTruthy();
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态正式对局使用 start run 返回的服务端 runtimeConfig', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
durationMs: 1000,
drawThreshold: 3,
minBarkGapMs: 150,
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
durationMs: 1000,
}),
);
});
it('发布态正式对局使用服务端 runtimeConfig 刷新自定义拟声词和素材', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockImplementationOnce(
async (onSample: (volume: number, atMs: number) => void) => {
onSample(0.9, 0);
return { stop: vi.fn() };
},
);
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
onomatopoeia: ['喵能爆裂!'],
playerCharacterImageSrc: '/server/player.png',
opponentCharacterImageSrc: '/server/opponent.png',
uiBackgroundImageSrc: '/server/background.png',
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig({ onomatopoeia: undefined })}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3100);
});
expect(
document.querySelector('img[src="/server/player.png"]'),
).toBeTruthy();
expect(screen.getByText('喵能爆裂!')).toBeTruthy();
});
it('发布态真实麦克风对局结算后提交派生指标', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(34_000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
runId: 'run-bark-1',
runToken: 'token-bark-1',
workId: 'work-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
difficultyPreset: 'hard',
derivedMetrics: expect.objectContaining({
triggerCount: expect.any(Number),
finalEnergy: expect.any(Number),
}),
clientResult: expect.any(String),
}),
);
act(() => {
vi.clearAllTimers();
});
vi.useRealTimers();
});
});

View File

@@ -21,6 +21,9 @@ const STAGE_ROUTE_ENTRIES = [
['square-hole-agent-workspace', '/creation/square-hole/agent'],
['square-hole-result', '/creation/square-hole/result'],
['square-hole-runtime', '/runtime/square-hole'],
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel/agent'],
['visual-novel-result', '/creation/visual-novel/result'],

View File

@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBarkBattleDraft, publishBarkBattleWork } from './barkBattleCreationClient';
import {
createBarkBattleDraft,
generateAllBarkBattleImageAssets,
publishBarkBattleWork,
regenerateBarkBattleImageAsset,
updateBarkBattleDraftConfig,
} from './barkBattleCreationClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
@@ -13,21 +19,17 @@ describe('barkBattleCreationClient', () => {
requestJsonMock.mockReset();
});
it('creates a lightweight draft through creation API', async () => {
it('creates a v1 lightweight draft through creation API', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await createBarkBattleDraft({
title: '周末狗狗杯',
title: '汪汪冠军杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -36,21 +38,19 @@ describe('barkBattleCreationClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '周末狗狗杯',
title: '汪汪冠军杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
leaderboardEnabled: true,
}),
}),
'创建汪汪声浪大作战草稿失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
);
});
@@ -67,7 +67,146 @@ describe('barkBattleCreationClient', () => {
body: JSON.stringify({ draftId: 'draft-1', workId: 'work-1' }),
}),
'发布汪汪声浪大作战作品失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
);
});
it('persists generated image slots into an existing draft config', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await updateBarkBattleDraftConfig({
draftId: 'draft-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪冠军杯',
description: '',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/drafts/draft-1/config',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draftId: 'draft-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪冠军杯',
description: '',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
}),
}),
'保存汪汪声浪草稿素材失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
timeoutMs: 30_000,
}),
);
});
it('generates an individual image slot from v1 description fields', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
await regenerateBarkBattleImageAsset({
slot: 'player-character',
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
});
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/images/generate',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slot: 'player-character',
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
}),
}),
'生成汪汪声浪素材失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
timeoutMs: 180_000,
}),
);
});
it('reports failed image slots while keeping generated image assets', async () => {
requestJsonMock
.mockResolvedValueOnce({
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
})
.mockRejectedValueOnce(new Error('泥点不足,本次需要 1 泥点,当前 0 泥点。'))
.mockRejectedValueOnce(new Error('场景图片生成失败:上游超时'));
const result = await generateAllBarkBattleImageAssets({
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
});
expect(result.assets['player-character']?.imageSrc).toBe(
'/generated-bark-battle/player.png',
);
expect(result.failures).toEqual({
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
});
});
});

View File

@@ -1,21 +1,28 @@
import type {
BarkBattleAssetSlot,
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattleDraftConfigUpdateRequest,
BarkBattleDraftCreateRequest,
BarkBattleGeneratedImageAsset,
BarkBattleImageAssetGenerateRequest,
BarkBattlePublishedConfig,
BarkBattleWorkPublishRequest,
BarkBattleWorksResponse,
} from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldSceneImageResult } from '../aiTypes';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
export type { BarkBattleAssetSlot } from '../../../packages/shared/src/contracts/barkBattle';
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle';
const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES = 20 * 1024 * 1024;
const BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS = 180_000;
const BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS = 30_000;
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
@@ -32,12 +39,6 @@ export type BarkBattleCreationRequestOptions = Pick<
| 'clearAuthOnUnauthorized'
>;
export type BarkBattleAssetSlot =
| 'player-character'
| 'opponent-character'
| 'ui-background'
| 'bark-sound';
export type BarkBattleUploadedAsset = {
assetObjectId: string;
assetKind: string;
@@ -45,6 +46,23 @@ export type BarkBattleUploadedAsset = {
assetSrc: string;
};
export type BarkBattleGeneratedImageAssets = Partial<
Record<BarkBattleAssetSlot, BarkBattleGeneratedImageAsset>
>;
export type BarkBattleImageGenerationFailures = Partial<
Record<BarkBattleAssetSlot, string>
>;
export type BarkBattleSlotGenerationResult =
| { status: 'fulfilled'; asset: BarkBattleGeneratedImageAsset }
| { status: 'rejected'; message: string };
export type BarkBattleImageGenerationBatchResult = {
assets: BarkBattleGeneratedImageAssets;
failures: BarkBattleImageGenerationFailures;
};
type DirectUploadTicketResponse = {
upload: {
bucket: string;
@@ -82,16 +100,10 @@ const SLOT_UPLOAD_CONFIG = {
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'bark-sound': {
acceptKind: 'audio',
assetKind: 'bark_battle_bark_sound',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES,
},
} satisfies Record<
BarkBattleAssetSlot,
{
acceptKind: 'image' | 'audio';
acceptKind: 'image';
assetKind: string;
legacyPrefix: string;
maxSizeBytes: number;
@@ -101,11 +113,7 @@ const SLOT_UPLOAD_CONFIG = {
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
png: 'image/png',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp',
};
@@ -129,9 +137,6 @@ function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
throw new Error('请选择图片素材。');
}
if (config.acceptKind === 'audio' && !contentType.startsWith('audio/')) {
throw new Error('请选择音频素材。');
}
return contentType;
}
@@ -174,27 +179,29 @@ async function postDirectUploadFile(
}
}
function buildBarkBattleImagePrompt(
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>,
payload: BarkBattleConfigEditorPayload,
) {
const slotPrompt = {
'player-character': `玩家角色形象:${payload.playerDogSkinPreset}`,
'opponent-character': `对手角色形象:${payload.opponentDogSkinPreset}`,
'ui-background': `游戏 UI 背景:${payload.themePreset}`,
} satisfies Record<Exclude<BarkBattleAssetSlot, 'bark-sound'>, string>;
function withBarkBattleGenerationTimeout<T>(
promise: Promise<T>,
slot: BarkBattleAssetSlot,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${slot} 生成超时`));
}, BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS);
});
return [
`汪汪声浪大作战《${payload.title}`,
payload.description ?? '',
slotPrompt[slot],
slot === 'ui-background'
? '竖屏移动端游戏背景,无文字,无按钮,无角色遮挡'
: '游戏角色立绘,完整主体,透明感背景,无文字,无 UI',
]
.map((part) => part.trim())
.filter(Boolean)
.join('');
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
function resolveBarkBattleGenerationFailureMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
return '汪汪声浪素材生成失败。';
}
export function createBarkBattleDraft(
@@ -219,6 +226,31 @@ export function createBarkBattleDraft(
);
}
export function updateBarkBattleDraftConfig(
payload: BarkBattleDraftConfigUpdateRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleDraftConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/drafts/${encodeURIComponent(
payload.draftId,
)}/config`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'保存汪汪声浪草稿素材失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
timeoutMs: BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function publishBarkBattleWork(
payload: BarkBattleWorkPublishRequest,
options: BarkBattleCreationRequestOptions = {},
@@ -241,6 +273,34 @@ export function publishBarkBattleWork(
);
}
export function listBarkBattleWorks(
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/works`,
{ method: 'GET' },
'读取汪汪声浪作品架失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function listBarkBattleGallery() {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取汪汪声浪公开广场失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
},
);
}
export async function uploadBarkBattleAsset(payload: {
slot: BarkBattleAssetSlot;
file: File;
@@ -302,45 +362,90 @@ export async function uploadBarkBattleAsset(payload: {
}
export function regenerateBarkBattleImageAsset(payload: {
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>;
slot: BarkBattleAssetSlot;
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
}): Promise<CustomWorldSceneImageResult> {
return generateRpgWorldSceneImage({
profile: {
id: payload.draftId?.trim() || 'bark-battle-draft',
name: payload.config.title.trim() || '汪汪声浪大作战',
subtitle: '汪汪声浪',
summary: payload.config.description?.trim() || payload.config.themePreset,
tone: payload.config.themePreset,
playerGoal: '用声浪压过对手',
settingText: [
payload.config.themePreset,
payload.config.playerDogSkinPreset,
payload.config.opponentDogSkinPreset,
]
.map((part) => part.trim())
.filter(Boolean)
.join('\n'),
}): Promise<BarkBattleGeneratedImageAsset> {
const request: BarkBattleImageAssetGenerateRequest = {
slot: payload.slot,
draftId: payload.draftId ?? null,
config: payload.config,
};
return requestJson<BarkBattleGeneratedImageAsset>(
`${BARK_BATTLE_CREATION_API_BASE}/images/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
landmark: {
id: payload.slot,
name:
payload.slot === 'ui-background'
? '声浪竞技 UI 背景'
: payload.slot === 'player-character'
? payload.config.playerDogSkinPreset || '玩家角色'
: payload.config.opponentDogSkinPreset || '对手角色',
description: buildBarkBattleImagePrompt(payload.slot, payload.config),
'生成汪汪声浪素材失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
timeoutMs: BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS,
},
userPrompt: buildBarkBattleImagePrompt(payload.slot, payload.config),
size: payload.slot === 'ui-background' ? '1024*1792' : '1024*1024',
);
}
export async function generateAllBarkBattleImageAssets(payload: {
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
onSlotComplete?: (
slot: BarkBattleAssetSlot,
result: BarkBattleSlotGenerationResult,
) => void;
}): Promise<BarkBattleImageGenerationBatchResult> {
const slots = [
'player-character',
'opponent-character',
'ui-background',
] as const;
const results = await Promise.allSettled(
slots.map(async (slot) => [
slot,
await withBarkBattleGenerationTimeout(
regenerateBarkBattleImageAsset({
slot,
config: payload.config,
draftId: payload.draftId,
}),
slot,
)
.then((asset) => {
payload.onSlotComplete?.(slot, { status: 'fulfilled', asset });
return asset;
})
.catch((error) => {
const message = resolveBarkBattleGenerationFailureMessage(error);
payload.onSlotComplete?.(slot, { status: 'rejected', message });
throw new Error(message);
}),
] as const),
);
const assets: BarkBattleGeneratedImageAssets = {};
const failures: BarkBattleImageGenerationFailures = {};
results.forEach((result, index) => {
const slot = slots[index];
if (!slot) {
return;
}
if (result.status === 'fulfilled') {
assets[slot] = result.value[1];
return;
}
failures[slot] = resolveBarkBattleGenerationFailureMessage(result.reason);
});
return { assets, failures };
}
export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft,
generateAllImageAssets: generateAllBarkBattleImageAssets,
listGallery: listBarkBattleGallery,
listWorks: listBarkBattleWorks,
regenerateImageAsset: regenerateBarkBattleImageAsset,
publish: publishBarkBattleWork,
updateDraftConfig: updateBarkBattleDraftConfig,
uploadAsset: uploadBarkBattleAsset,
};

View File

@@ -2,9 +2,16 @@ export {
type BarkBattleAssetSlot,
barkBattleCreationClient,
type BarkBattleCreationRequestOptions,
type BarkBattleGeneratedImageAssets,
type BarkBattleImageGenerationBatchResult,
type BarkBattleImageGenerationFailures,
type BarkBattleUploadedAsset,
createBarkBattleDraft,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
publishBarkBattleWork,
regenerateBarkBattleImageAsset,
updateBarkBattleDraftConfig,
uploadBarkBattleAsset,
} from './barkBattleCreationClient';

View File

@@ -1,4 +1,4 @@
export function normalizePublicCodeText(value: string) {
export function normalizePublicCodeText(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9]/gu, '')
@@ -53,6 +53,20 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`;
}
function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(workId);
const withoutPrefix = normalized.startsWith('BB')
? normalized.slice(2)
: normalized;
const fallback = withoutPrefix || normalized || '00000000';
return fallback.slice(-8).padStart(8, '0');
}
export function buildBarkBattlePublicWorkCode(workId: string) {
return `BB-${normalizeBarkBattlePublicWorkCodeSuffix(workId)}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -124,3 +138,14 @@ export function isSameBabyObjectMatchPublicWorkCode(
normalizedKeyword === normalizePublicCodeText(profileId)
);
}
export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildBarkBattlePublicWorkCode(workId)) ||
normalizedKeyword === normalizePublicCodeText(workId) ||
normalizedKeyword === normalizeBarkBattlePublicWorkCodeSuffix(workId)
);
}