feat: wire bark battle platform loop
Some checks failed
CI / verify (pull_request) Has been cancelled

This commit is contained in:
2026-05-14 18:20:46 +08:00
parent 8c6ec9e6e4
commit 1d7ef7e4b6
73 changed files with 7933 additions and 107 deletions

View File

@@ -0,0 +1,50 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('BarkBattleConfigEditor', () => {
it('allows creators to edit lightweight config and publish a Bark Battle work', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
expect(screen.getByText('轻配置作品')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal');
expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park');
await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba');
await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky');
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.click(screen.getByLabelText('开启排行榜'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).toHaveBeenCalledWith({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: false,
});
});
it('requires a non-empty title before publishing', async () => {
const onPublish = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />);
await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy();
});
});

View File

@@ -0,0 +1,161 @@
import { useMemo, useState } from 'react';
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = {
isBusy?: boolean;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void;
};
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DOG_SKIN_OPTIONS = [
{ value: 'corgi', label: '柯基' },
{ value: 'shiba', label: '柴犬' },
{ value: 'husky', label: '哈士奇' },
];
const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' },
{ value: 'hard', label: '硬核' },
];
export function BarkBattleConfigEditor({
isBusy = false,
onPublish,
onBack,
}: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard');
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi');
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky');
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
const [error, setError] = useState<string | null>(null);
const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({
title: title.trim(),
description: description.trim(),
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
}),
[
title,
description,
themePreset,
playerDogSkinPreset,
opponentDogSkinPreset,
difficultyPreset,
leaderboardEnabled,
],
);
const handlePublish = () => {
if (!payload.title) {
setError('请先填写作品标题');
return;
}
setError(null);
void onPublish(payload);
};
return (
<section className="min-h-screen bg-slate-950 px-4 py-6 text-slate-50 sm:px-6" aria-label="Bark Battle 轻配置编辑器">
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 lg:grid lg:grid-cols-[minmax(0,1fr)_360px]">
<div className="rounded-3xl border border-cyan-300/20 bg-slate-900/90 p-5 shadow-2xl shadow-cyan-950/40">
<div className="mb-5 flex items-start justify-between gap-3">
<div>
<p className="mb-2 inline-flex rounded-full bg-cyan-300/10 px-3 py-1 text-xs font-bold text-cyan-100"></p>
<h1 className="text-2xl font-black tracking-tight sm:text-3xl"></h1>
<p className="mt-2 text-sm text-slate-300"></p>
</div>
{onBack ? (
<button type="button" onClick={onBack} className="rounded-full border border-slate-600 px-3 py-2 text-sm text-slate-200">
</button>
) : null}
</div>
<div className="grid gap-4">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={40}
/>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
className="min-h-[88px] rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-base text-white outline-none focus:border-cyan-300"
maxLength={160}
placeholder="一句话告诉玩家这场声浪对决的氛围"
/>
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={themePreset} onChange={(event) => setThemePreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{THEME_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={difficultyPreset} onChange={(event) => setDifficultyPreset(event.target.value as BarkBattleDifficultyPreset)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DIFFICULTY_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={playerDogSkinPreset} onChange={(event) => setPlayerDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="grid gap-2 text-sm font-semibold text-slate-200">
<select value={opponentDogSkinPreset} onChange={(event) => setOpponentDogSkinPreset(event.target.value)} className="rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-white">
{DOG_SKIN_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
<label className="flex items-center justify-between gap-3 rounded-2xl border border-slate-700 bg-slate-950 px-4 py-3 text-sm font-semibold text-slate-100">
<span>
<span className="block text-xs font-normal text-slate-400"></span>
</span>
<input aria-label="开启排行榜" type="checkbox" checked={leaderboardEnabled} onChange={(event) => setLeaderboardEnabled(event.target.checked)} className="h-5 w-5" />
</label>
{error ? <p className="rounded-2xl bg-rose-500/15 px-4 py-3 text-sm font-semibold text-rose-100">{error}</p> : null}
<button type="button" disabled={isBusy} onClick={handlePublish} className="rounded-full bg-cyan-200 px-5 py-3 text-sm font-black text-slate-950 disabled:opacity-50">
{isBusy ? '发布中…' : '发布并试玩'}
</button>
</div>
</div>
<BarkBattlePreviewCard config={payload} />
</div>
</section>
);
}

View File

@@ -0,0 +1,56 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload;
};
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DOG_LABELS: Record<string, string> = {
corgi: '柯基',
shiba: '柴犬',
husky: '哈士奇',
};
const DIFFICULTY_LABELS = {
easy: '轻松',
normal: '标准',
hard: '硬核',
};
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
return (
<aside className="rounded-3xl border border-cyan-300/20 bg-gradient-to-br from-slate-900 via-slate-950 to-cyan-950 p-5 text-slate-50 shadow-2xl shadow-cyan-950/40" aria-label="作品预览卡片">
<p className="mb-3 text-xs font-bold uppercase tracking-[0.25em] text-cyan-200">Preview</p>
<div className="rounded-3xl border border-white/10 bg-white/10 p-5">
<div className="mb-5 flex min-h-40 items-center justify-center rounded-3xl bg-cyan-200/10 text-6xl" aria-hidden="true">
🐶 VS 🐺
</div>
<h2 className="text-xl font-black">{config.title || '未命名声浪竞技场'}</h2>
<p className="mt-2 min-h-[42px] text-sm text-slate-300">{config.description || '30 秒声浪拔河,喊出你的能量优势。'}</p>
<dl className="mt-5 grid gap-3 text-sm">
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{THEME_LABELS[config.themePreset] ?? config.themePreset}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset} vs {DOG_LABELS[config.opponentDogSkinPreset] ?? config.opponentDogSkinPreset}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{DIFFICULTY_LABELS[config.difficultyPreset]}</dd>
</div>
<div className="flex justify-between gap-3 rounded-2xl bg-slate-950/60 px-3 py-2">
<dt className="text-slate-400"></dt>
<dd className="font-bold">{config.leaderboardEnabled ? '开启' : '关闭'}</dd>
</div>
</dl>
</div>
</aside>
);
}

View File

@@ -13,6 +13,10 @@ import {
} from 'react';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
BarkBattleConfigEditorPayload,
BarkBattlePublishedConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import type {
BigFishRuntimeSnapshotResponse,
BigFishSessionSnapshotResponse,
@@ -109,6 +113,10 @@ import {
getPublicAuthUserByCode,
getPublicAuthUserById,
} from '../../services/authService';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
@@ -1766,6 +1774,20 @@ const SquareHoleRuntimeShell = lazy(async () => {
};
});
const BarkBattleConfigEditor = lazy(async () => {
const module = await import('../bark-battle-creation/BarkBattleConfigEditor');
return {
default: module.BarkBattleConfigEditor,
};
});
const BarkBattleRuntimeShell = lazy(async () => {
const module = await import('../../games/bark-battle/ui/BarkBattleRuntimeShell');
return {
default: module.BarkBattleRuntimeShell,
};
});
const CustomWorldCreationHub = lazy(async () => {
const module = await import('../custom-world-home/CustomWorldCreationHub');
return {
@@ -1944,6 +1966,10 @@ export function PlatformEntryFlowShellImpl({
useState(false);
const [squareHoleGenerationState, setSquareHoleGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
const [barkBattlePublishedConfig, setBarkBattlePublishedConfig] =
useState<BarkBattlePublishedConfig | null>(null);
const [barkBattleError, setBarkBattleError] = useState<string | null>(null);
const [isBarkBattleBusy, setIsBarkBattleBusy] = useState(false);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
@@ -4379,6 +4405,15 @@ export function PlatformEntryFlowShellImpl({
return;
}
if (type === 'bark-battle') {
enterCreateTab();
setShowCreationTypeModal(false);
setActiveCreationFormType('bark-battle');
setBarkBattleError(null);
setSelectionStage('bark-battle-config');
return;
}
if (type === 'visual-novel') {
enterCreateTab();
setShowCreationTypeModal(false);
@@ -4395,9 +4430,11 @@ export function PlatformEntryFlowShellImpl({
runProtectedAction,
sessionController,
setActiveCreationFormType,
setBarkBattleError,
setMatch3DError,
setPuzzleCreationError,
setPuzzleError,
setSelectionStage,
setVisualNovelError,
],
);
@@ -4426,6 +4463,37 @@ export function PlatformEntryFlowShellImpl({
squareHoleFlow.leaveFlow();
}, [squareHoleFlow]);
const leaveBarkBattleFlow = useCallback(() => {
setBarkBattlePublishedConfig(null);
setBarkBattleError(null);
setIsBarkBattleBusy(false);
setSelectionStage('platform');
}, [setSelectionStage]);
const publishBarkBattleConfig = useCallback(
async (payload: BarkBattleConfigEditorPayload) => {
setBarkBattleError(null);
setIsBarkBattleBusy(true);
try {
const draft = await createBarkBattleDraft(payload);
const published = await publishBarkBattleWork({
draftId: draft.draftId,
workId: draft.workId,
publishedSnapshot: payload,
});
setBarkBattlePublishedConfig(published);
setSelectionStage('bark-battle-runtime');
} catch (error) {
setBarkBattleError(
resolvePuzzleErrorMessage(error, '发布汪汪声浪大作战作品失败。'),
);
} finally {
setIsBarkBattleBusy(false);
}
},
[resolvePuzzleErrorMessage, setSelectionStage],
);
const leavePuzzleFlow = useCallback(() => {
setPuzzleOperation(null);
setPuzzleRun(null);
@@ -10515,6 +10583,56 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'bark-battle-config' && (
<motion.div
key="bark-battle-config"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪配置..." />}
>
<BarkBattleConfigEditor
isBusy={isBarkBattleBusy}
onBack={leaveBarkBattleFlow}
onPublish={(payload) => {
void publishBarkBattleConfig(payload);
}}
/>
{barkBattleError ? (
<div className="platform-subpanel mx-auto mt-3 max-w-5xl rounded-2xl px-4 py-3 text-sm text-rose-200">
{barkBattleError}
</div>
) : null}
</Suspense>
</motion.div>
)}
{selectionStage === 'bark-battle-runtime' && barkBattlePublishedConfig && (
<motion.div
key="bark-battle-runtime"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<Suspense
fallback={<LazyPanelFallback label="正在加载汪汪声浪试玩..." />}
>
<BarkBattleRuntimeShell
title={barkBattlePublishedConfig.title}
workId={barkBattlePublishedConfig.workId}
publishedConfig={barkBattlePublishedConfig}
onExit={() => {
setSelectionStage('bark-battle-config');
}}
/>
</Suspense>
</motion.div>
)}
{selectionStage === 'custom-world-result' &&
sessionController.generatedCustomWorldProfile && (
<motion.div

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle';
import {
type BarkBattleConfig,
DEFAULT_BARK_BATTLE_CONFIG,
@@ -15,6 +16,9 @@ import { BarkBattleResultPanel } from './BarkBattleResultPanel';
type BarkBattleRuntimeShellProps = {
title?: string;
workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null;
onExit?: () => void;
};
type DebugEvent = {
@@ -63,8 +67,39 @@ function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailure
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason);
}
export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: BarkBattleRuntimeShellProps) {
const [config, setConfig] = useState(DEFAULT_BARK_BATTLE_CONFIG);
function buildRuntimeConfigFromPublishedConfig(
publishedConfig?: BarkBattlePublishedConfig | null,
): BarkBattleConfig {
if (!publishedConfig) {
return DEFAULT_BARK_BATTLE_CONFIG;
}
const difficultyOverrides: Record<
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 },
};
return {
...DEFAULT_BARK_BATTLE_CONFIG,
...difficultyOverrides[publishedConfig.difficultyPreset],
};
}
export function BarkBattleRuntimeShell({
title = '汪汪声浪大作战',
workId,
publishedConfig,
onExit,
}: BarkBattleRuntimeShellProps) {
const initialConfig = useMemo(
() => buildRuntimeConfigFromPublishedConfig(publishedConfig),
[publishedConfig],
);
const [config, setConfig] = useState(initialConfig);
const controllerRef = useRef<BarkBattleController | null>(null);
if (!controllerRef.current) {
controllerRef.current = new BarkBattleController(config);
@@ -110,6 +145,12 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
microphoneSamplerRef.current = null;
}, []);
useEffect(() => {
setConfig(initialConfig);
controller.updateConfig(initialConfig);
syncSnapshot();
}, [controller, initialConfig, syncSnapshot]);
const startMicrophone = useCallback(async () => {
stopMicrophone();
try {
@@ -225,7 +266,11 @@ export function BarkBattleRuntimeShell({ title = '汪汪声浪大作战' }: Bark
<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>

View File

@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBarkBattleDraft, publishBarkBattleWork } from './barkBattleCreationClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('barkBattleCreationClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
});
it('creates a lightweight draft through creation API', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await createBarkBattleDraft({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: true,
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/drafts',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: '周末狗狗杯',
description: '',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
difficultyPreset: 'hard',
leaderboardEnabled: true,
}),
}),
'创建汪汪声浪大作战草稿失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
it('publishes a draft and returns stable work config', async () => {
requestJsonMock.mockResolvedValueOnce({ workId: 'work-1' });
await publishBarkBattleWork({ draftId: 'draft-1', workId: 'work-1' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/works/publish',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draftId: 'draft-1', workId: 'work-1' }),
}),
'发布汪汪声浪大作战作品失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
});

View File

@@ -0,0 +1,77 @@
import type {
BarkBattleDraftConfig,
BarkBattleDraftCreateRequest,
BarkBattlePublishedConfig,
BarkBattleWorkPublishRequest,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type BarkBattleCreationRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export function createBarkBattleDraft(
payload: BarkBattleDraftCreateRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleDraftConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/drafts`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'创建汪汪声浪大作战草稿失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function publishBarkBattleWork(
payload: BarkBattleWorkPublishRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattlePublishedConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/works/publish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'发布汪汪声浪大作战作品失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft,
publish: publishBarkBattleWork,
};

View File

@@ -0,0 +1,6 @@
export {
barkBattleCreationClient,
type BarkBattleCreationRequestOptions,
createBarkBattleDraft,
publishBarkBattleWork,
} from './barkBattleCreationClient';

View File

@@ -0,0 +1,88 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
finishBarkBattleRun,
getBarkBattleRuntimeConfig,
startBarkBattleRun,
} from './barkBattleRuntimeClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
vi.mock('../apiClient', () => ({
requestJson: requestJsonMock,
}));
describe('barkBattleRuntimeClient', () => {
afterEach(() => {
requestJsonMock.mockReset();
});
it('reads runtime config from stable work route', async () => {
requestJsonMock.mockResolvedValueOnce({ workId: 'work-1' });
await getBarkBattleRuntimeConfig('work/1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/bark-battle/works/work%2F1/config',
{ method: 'GET' },
'读取汪汪声浪大作战配置失败',
expect.objectContaining({ retry: expect.objectContaining({ maxRetries: 1 }) }),
);
});
it('starts a formal run with workId in body', async () => {
requestJsonMock.mockResolvedValueOnce({ runId: 'run-1' });
await startBarkBattleRun('work-1', { sourceRoute: '/play/work-1' });
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/runtime/bark-battle/works/work-1/runs',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceRoute: '/play/work-1', workId: 'work-1' }),
}),
'启动汪汪声浪大作战正式局失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }),
);
});
it('finishes a run using derived metrics only', async () => {
requestJsonMock.mockResolvedValueOnce({ status: 'accepted' });
await finishBarkBattleRun('run-1', {
runId: 'run-1',
runToken: 'token-1',
workId: 'work-1',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
difficultyPreset: 'normal',
clientStartedAt: '2026-05-13T00:00:00Z',
clientFinishedAt: '2026-05-13T00:00:30Z',
durationMs: 30000,
derivedMetrics: {
triggerCount: 12,
maxVolume: 0.82,
averageVolume: 0.36,
finalEnergy: 58,
comboMax: 4,
},
clientResult: 'player_win',
});
const [, init] = requestJsonMock.mock.calls[0];
expect(requestJsonMock.mock.calls[0][0]).toBe(
'/api/runtime/bark-battle/runs/run-1/finish',
);
expect(JSON.parse(init.body)).toEqual(
expect.objectContaining({
runId: 'run-1',
runToken: 'token-1',
derivedMetrics: expect.objectContaining({ finalEnergy: 58 }),
}),
);
expect(init.body).not.toContain('audio');
expect(init.body).not.toContain('waveform');
expect(init.body).not.toContain('pcm');
});
});

View File

@@ -0,0 +1,121 @@
import type {
BarkBattleFinishResponse,
BarkBattleRunFinishRequest,
BarkBattleRunStartRequest,
BarkBattleRunStartResponse,
BarkBattleRuntimeConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type ApiRequestOptions,
type ApiRetryOptions,
requestJson,
} from '../apiClient';
const BARK_BATTLE_RUNTIME_READ_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
};
const BARK_BATTLE_RUNTIME_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
export type BarkBattleRuntimeRequestOptions = Pick<
ApiRequestOptions,
| 'authImpact'
| 'skipRefresh'
| 'notifyAuthStateChange'
| 'clearAuthOnUnauthorized'
>;
export function getBarkBattleRuntimeConfig(
workId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleRuntimeConfig>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/config`,
{ method: 'GET' },
'读取汪汪声浪大作战配置失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function startBarkBattleRun(
workId: string,
payload: Partial<BarkBattleRunStartRequest> = {},
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleRunStartResponse>(
`/api/runtime/bark-battle/works/${encodeURIComponent(workId)}/runs`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
workId: payload.workId ?? workId,
}),
},
'启动汪汪声浪大作战正式局失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function getBarkBattleRun(
runId: string,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<unknown>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}`,
{ method: 'GET' },
'读取汪汪声浪大作战单局失败',
{
retry: BARK_BATTLE_RUNTIME_READ_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function finishBarkBattleRun(
runId: string,
payload: BarkBattleRunFinishRequest,
options: BarkBattleRuntimeRequestOptions = {},
) {
return requestJson<BarkBattleFinishResponse>(
`/api/runtime/bark-battle/runs/${encodeURIComponent(runId)}/finish`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...payload,
runId: payload.runId ?? runId,
}),
},
'提交汪汪声浪大作战成绩失败',
{
retry: BARK_BATTLE_RUNTIME_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}

View File

@@ -0,0 +1,7 @@
export {
type BarkBattleRuntimeRequestOptions,
finishBarkBattleRun,
getBarkBattleRun,
getBarkBattleRuntimeConfig,
startBarkBattleRun,
} from './barkBattleRuntimeClient';