feat: wire bark battle platform loop
Some checks failed
CI / verify (pull_request) Has been cancelled
Some checks failed
CI / verify (pull_request) Has been cancelled
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
161
src/components/bark-battle-creation/BarkBattleConfigEditor.tsx
Normal file
161
src/components/bark-battle-creation/BarkBattleConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user