Add big fish settlement actions and publish feedback

This commit is contained in:
2026-04-26 21:28:02 +08:00
parent 09d3fe59b3
commit c81305f2e6
11 changed files with 326 additions and 15 deletions

View File

@@ -0,0 +1,24 @@
# 大鱼吃小鱼发布反馈修复 2026-04-26
## 背景
大鱼吃小鱼结果页的“发布”按钮已经会向后端发送 `big_fish_publish_game` action。后端发布成功后会把当前 Agent session 的 `stage` 改成 `published`,作品列表也会从 session 聚合出已发布作品。
问题出在前端发布成功后的反馈链路不完整:
1. 结果页没有把 `stage: published` 显示成“已发布”状态,用户点击后看起来没有变化。
2. 平台父层没有在大鱼发布成功后刷新“大鱼吃小鱼”作品列表,创作中心仍可能保留旧的草稿状态。
## 落地口径
1. `BigFishResultView``session.stage === 'published'` 作为已发布态真相。
2. 已发布态下发布按钮显示“已发布”并禁用,避免重复提交。
3. 已发布态下发布校验区显示“已发布”状态,继续保留资源完成度信息。
4. `PlatformEntryFlowShellImpl``big_fish_publish_game` 成功后刷新 `bigFishWorks`
5. 发布失败仍沿用既有错误模态,展示后端 `details.message` 里的具体校验原因。
## 验收
1. 在大鱼结果页点击“发布”会调用 `/api/runtime/big-fish/agent/sessions/{sessionId}/actions``big_fish_publish_game`
2. 后端返回已发布 session 后,结果页按钮变为“已发布”。
3. 返回创作中心后,该作品卡片状态通过刷新后的作品列表体现为已发布。

View File

@@ -30,3 +30,4 @@
- [PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md](./PLATFORM_HOME_BANNER_IMAGE_SIZE_FIX_2026-04-25.md):记录首页 banner 背景图不能进入普通布局流的修复经验。
- [RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md](./RPG_PUBLISH_GALLERY_REFRESH_FIX_2026-04-25.md):记录 RPG 发布后首页 / 分类页公开作品列表刷新链路。
- [AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md](./AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md):记录 Agent 空会话不应进入作品草稿列表的后端判定规则。
- [BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md](./BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。

View File

@@ -242,6 +242,7 @@
2. 发送聊天、action 和摇杆输入。
3. 根据后端 snapshot 渲染实体。
4. 当后端 snapshot 返回 `won``failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。
5. 结算浮层必须提供可继续操作的出口:`failed` 至少包含“重来”和“退出”,`won` 至少包含“退出”。“重来”只能重新启动当前大鱼作品的一局后端 run不能在前端本地篡改旧 run snapshot“退出”回到当前作品结果页或直达入口的上级页面。
前端禁止:

View File

@@ -20,6 +20,7 @@
## 验收口径
1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。
2. 左下摇杆可移动玩家实体。
2. 屏幕任意位置按下并拖动可移动玩家实体。
3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。
4. 左上返回按钮在直达页语义为重开当前占位局。
4. 左上返回按钮在直达页语义为退出到平台首页。
5. 直达页通关或失败后,结算浮层继续复用正式运行态出口;失败态点击“重来”重开本地占位局,点击“退出”回到平台首页。

View File

@@ -208,11 +208,16 @@ export default function BigFishPlaygroundApp() {
setRun(buildInitialRun());
}, []);
const handleExit = useCallback(() => {
window.location.assign('/');
}, []);
return (
<BigFishRuntimeShell
run={run}
assetSlots={assetSlots}
onBack={handleRestart}
onBack={handleExit}
onRestart={handleRestart}
onSubmitInput={handleSubmitInput}
/>
);

View File

@@ -170,4 +170,35 @@ describe('BigFishResultView', () => {
fireEvent.click(screen.getByRole('button', { name: '知道了' }));
expect(onDismissError).toHaveBeenCalledTimes(1);
});
test('shows published state and prevents duplicate publish clicks', () => {
const onExecuteAction = vi.fn();
render(
<BigFishResultView
session={{
...createSession(),
stage: 'published',
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 1,
levelMotionReadyCount: 2,
backgroundReady: true,
requiredLevelCount: 1,
publishReady: true,
blockers: [],
},
}}
onBack={() => {}}
onExecuteAction={onExecuteAction}
onStartTestRun={() => {}}
/>,
);
const publishedButton = screen.getByRole('button', { name: '已发布' });
expect((publishedButton as HTMLButtonElement).disabled).toBe(true);
expect(screen.getAllByText('已发布').length).toBeGreaterThan(0);
fireEvent.click(publishedButton);
expect(onExecuteAction).not.toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,7 @@ import {
Sparkles,
Waves,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -338,6 +338,7 @@ export function BigFishResultView({
}: BigFishResultViewProps) {
const [studioTarget, setStudioTarget] =
useState<BigFishAssetStudioTarget | null>(null);
const [isPublishSubmitting, setIsPublishSubmitting] = useState(false);
const draft = session.draft;
const backgroundSlot = findAssetSlot(session.assetSlots, 'stage_background');
const backgroundPreviewUrl = buildLevelAssetPreview(backgroundSlot);
@@ -345,6 +346,8 @@ export function BigFishResultView({
() => session.assetCoverage.blockers.filter(Boolean),
[session.assetCoverage.blockers],
);
const isPublished = session.stage === 'published';
const canClickPublish = !isPublished && !isBusy;
const studioPreviewUrl = useMemo(() => {
if (!studioTarget) {
return null;
@@ -352,6 +355,12 @@ export function BigFishResultView({
return buildStudioAssetPreview(session.assetSlots, studioTarget);
}, [session.assetSlots, studioTarget]);
useEffect(() => {
if (!isBusy || isPublished || error) {
setIsPublishSubmitting(false);
}
}, [error, isBusy, isPublished]);
if (!draft) {
return (
<div className="flex h-full items-center justify-center">
@@ -388,14 +397,23 @@ export function BigFishResultView({
</button>
<button
type="button"
disabled={isBusy}
disabled={!canClickPublish}
onClick={() => {
setIsPublishSubmitting(true);
onExecuteAction({ action: 'big_fish_publish_game' });
}}
className="inline-flex items-center gap-2 rounded-full bg-cyan-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<CheckCircle2 className="h-4 w-4" />
{isPublishSubmitting && isBusy && !isPublished ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{isPublished
? '已发布'
: isPublishSubmitting && isBusy
? '发布中'
: '发布'}
</button>
</div>
</div>
@@ -487,7 +505,11 @@ export function BigFishResultView({
{session.assetCoverage.backgroundReady ? '已完成' : '待生成'}
</div>
</div>
{blockers.length > 0 ? (
{isPublished ? (
<div className="mt-3 text-sm font-semibold text-emerald-600">
</div>
) : blockers.length > 0 ? (
<div className="mt-3 space-y-1 text-xs leading-5 text-amber-700">
{blockers.slice(0, 4).map((blocker) => (
<div key={blocker}>{blocker}</div>

View File

@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, test, vi } from 'vitest';
import type { BigFishRuntimeSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import { BigFishRuntimeShell } from './BigFishRuntimeShell';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => (src ? <img src={src} alt={alt} className={className} /> : null),
}));
function createRun(
status: BigFishRuntimeSnapshotResponse['status'],
): BigFishRuntimeSnapshotResponse {
return {
runId: 'big-fish-run-1',
sessionId: 'big-fish-session-1',
status,
tick: 18,
playerLevel: 2,
winLevel: 5,
leaderEntityId: null,
ownedEntities: [],
wildEntities: [],
cameraCenter: { x: 0, y: 0 },
lastInput: { x: 0, y: 0 },
eventLog: ['己方鱼群已经耗尽'],
updatedAt: '2026-04-26T12:00:00.000Z',
};
}
describe('BigFishRuntimeShell', () => {
test('renders restart and exit actions after a failed run', () => {
const onBack = vi.fn();
const onRestart = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('failed')}
onBack={onBack}
onRestart={onRestart}
onSubmitInput={() => {}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '重来' }));
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(screen.getByText('本轮失败')).toBeTruthy();
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
});
test('keeps an exit action after a won run', () => {
const onBack = vi.fn();
render(
<BigFishRuntimeShell
run={createRun('won')}
onBack={onBack}
onSubmitInput={() => {}}
/>,
);
expect(screen.getByText('通关完成')).toBeTruthy();
expect(screen.queryByRole('button', { name: '重来' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '退出' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, Loader2 } from 'lucide-react';
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
BigFishAssetSlotResponse,
@@ -21,6 +21,7 @@ type BigFishRuntimeShellProps = {
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onRestart?: () => void;
onSubmitInput: (payload: SubmitBigFishInputRequest) => void;
};
@@ -188,6 +189,7 @@ export function BigFishRuntimeShell({
isBusy = false,
error = null,
onBack,
onRestart,
onSubmitInput,
}: BigFishRuntimeShellProps) {
const stageRef = useRef<HTMLDivElement | null>(null);
@@ -200,6 +202,10 @@ export function BigFishRuntimeShell({
}, [stick]);
useEffect(() => {
if (run?.status !== 'running') {
return undefined;
}
const timer = window.setInterval(() => {
const current = stickRef.current;
// 即使没有方向输入也持续回传当前状态,让后端持续推进刷怪、清理与胜负裁决。
@@ -209,7 +215,7 @@ export function BigFishRuntimeShell({
return () => {
window.clearInterval(timer);
};
}, [onSubmitInput]);
}, [onSubmitInput, run?.status]);
const submitDirection = (direction: SubmitBigFishInputRequest) => {
setStick(direction);
@@ -318,16 +324,39 @@ export function BigFishRuntimeShell({
</div>
{settlementCopy ? (
<div className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center px-5">
<div className="absolute inset-0 z-40 flex items-center justify-center px-5">
<div
className={`w-full max-w-[20rem] rounded-[2rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
className={`w-full max-w-[20rem] rounded-[1.5rem] border border-white/24 bg-gradient-to-br ${settlementCopy.tone} p-6 text-center shadow-2xl shadow-slate-950/45 backdrop-blur-xl`}
>
<div className="text-3xl font-black tracking-[0.22em] text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
<div className="text-3xl font-black text-white [text-shadow:0_2px_12px_rgba(2,6,23,0.6)]">
{settlementCopy.title}
</div>
<div className="mt-3 text-sm font-semibold leading-6 text-white/82">
{settlementCopy.message}
</div>
<div className="mt-5 grid grid-cols-2 gap-2">
{run.status === 'failed' && onRestart ? (
<button
type="button"
disabled={isBusy}
onClick={onRestart}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-white px-4 text-sm font-bold text-slate-950 shadow-lg shadow-slate-950/20 disabled:opacity-45"
>
<RotateCcw className="h-4 w-4" />
</button>
) : null}
<button
type="button"
onClick={onBack}
className={`inline-flex h-11 items-center justify-center gap-2 rounded-full border border-white/30 bg-black/24 px-4 text-sm font-bold text-white backdrop-blur ${
run.status === 'failed' && onRestart ? '' : 'col-span-2'
}`}
>
<ArrowLeft className="h-4 w-4" />
退
</button>
</div>
</div>
</div>
) : null}

View File

@@ -755,6 +755,9 @@ export function PlatformEntryFlowShellImpl({
},
onActionComplete: ({ payload, response, setSession }) => {
setSession(response.session);
if (payload.action === 'big_fish_publish_game') {
void refreshBigFishShelf();
}
if (payload.action !== 'big_fish_compile_draft') {
return;
}
@@ -1099,7 +1102,11 @@ export function PlatformEntryFlowShellImpl({
const submitBigFishInput = useCallback(
(payload: SubmitBigFishInputRequest) => {
if (!bigFishRun || bigFishInputInFlightRef.current) {
if (
!bigFishRun ||
bigFishRun.status !== 'running' ||
bigFishInputInFlightRef.current
) {
return;
}
@@ -2096,6 +2103,9 @@ export function PlatformEntryFlowShellImpl({
onBack={() => {
setSelectionStage('big-fish-result');
}}
onRestart={() => {
void startBigFishRun();
}}
onSubmitInput={submitBigFishInput}
/>
</Suspense>

View File

@@ -34,6 +34,7 @@ import {
} from '../../services/rpg-entry';
import {
createBigFishCreationSession,
executeBigFishCreationAction,
getBigFishCreationSession,
} from '../../services/big-fish-creation';
import { listBigFishWorks } from '../../services/big-fish-works';
@@ -172,13 +173,23 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
BigFishResultView: ({
session,
onBack,
onExecuteAction,
}: {
session: { draft?: { title: string } | null };
onBack: () => void;
onExecuteAction: (payload: { action: string }) => void;
}) => (
<div className="big-fish-result-view-mock">
<div></div>
<div>{session.draft?.title ?? '缺少草稿标题'}</div>
<button
type="button"
onClick={() => {
onExecuteAction({ action: 'big_fish_publish_game' });
}}
>
</button>
<button type="button" onClick={onBack}>
</button>
@@ -1815,6 +1826,102 @@ test('big fish draft card restores the bound agent session and opens the result
expect(screen.getByText('我想做机械深海里微生物互相吞并进化。')).toBeTruthy();
});
test('big fish result publish action refreshes creation works', async () => {
const user = userEvent.setup();
const baseBigFishSession = (await getBigFishCreationSession('big-fish-session-1'))
.session;
vi.mocked(getBigFishCreationSession).mockClear();
vi.mocked(listBigFishWorks).mockClear();
const publishedBigFishSession = {
...baseBigFishSession,
stage: 'published',
publishReady: true,
assetCoverage: {
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
requiredLevelCount: 8,
publishReady: true,
blockers: [],
},
updatedAt: '2026-04-22T12:20:00.000Z',
};
vi.mocked(executeBigFishCreationAction).mockResolvedValue({
session: publishedBigFishSession,
});
vi.mocked(listBigFishWorks)
.mockResolvedValueOnce({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-04-22T12:10:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
})
.mockResolvedValue({
items: [
{
workId: 'big-fish-work-big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
title: '机械深海 大鱼吃小鱼',
subtitle: '机械微生物吞并进化 · 偏爽快节奏',
summary: '机械微生物吞并进化',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-22T12:20:00.000Z',
publishReady: true,
levelCount: 8,
levelMainImageReadyCount: 8,
levelMotionReadyCount: 16,
backgroundReady: true,
},
],
});
render(<TestWrapper withAuth />);
await openCreationHub(user);
const title = await screen.findByText('机械深海 大鱼吃小鱼');
const card = title.closest('.platform-surface');
if (!(card instanceof HTMLElement)) {
throw new Error('Missing big fish draft card');
}
await user.click(card);
await waitFor(() => {
expect(getBigFishCreationSession).toHaveBeenCalledWith(
'big-fish-session-1',
);
});
vi.mocked(listBigFishWorks).mockClear();
expect(await screen.findByText('大鱼吃小鱼结果页')).toBeTruthy();
await user.click(await screen.findByRole('button', { name: '发布' }));
await waitFor(() => {
expect(executeBigFishCreationAction).toHaveBeenCalledWith(
'big-fish-session-1',
{
action: 'big_fish_publish_game',
},
);
});
await waitFor(() => {
expect(listBigFishWorks).toHaveBeenCalled();
});
});
test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => {
const user = userEvent.setup();