diff --git a/docs/experience/BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md b/docs/experience/BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md new file mode 100644 index 00000000..eb327a6a --- /dev/null +++ b/docs/experience/BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md @@ -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. 返回创作中心后,该作品卡片状态通过刷新后的作品列表体现为已发布。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 95f83d28..679ee7e7 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -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):记录大鱼吃小鱼发布成功后结果页反馈与作品列表刷新的修复口径。 diff --git a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 00df4ac5..78329042 100644 --- a/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -242,6 +242,7 @@ 2. 发送聊天、action 和摇杆输入。 3. 根据后端 snapshot 渲染实体。 4. 当后端 snapshot 返回 `won` 或 `failed` 时,必须在玩法舞台中央显示清晰结算浮层;通关与失败都不能只依赖顶部状态标签或事件日志。 +5. 结算浮层必须提供可继续操作的出口:`failed` 至少包含“重来”和“退出”,`won` 至少包含“退出”。“重来”只能重新启动当前大鱼作品的一局后端 run,不能在前端本地篡改旧 run snapshot;“退出”回到当前作品结果页或直达入口的上级页面。 前端禁止: diff --git a/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md index a0c4b96c..459ed4b9 100644 --- a/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md +++ b/docs/technical/BIG_FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md @@ -20,6 +20,7 @@ ## 验收口径 1. 浏览器访问 `/big-fish` 后直接显示竖屏大鱼吃小鱼舞台。 -2. 左下摇杆可移动玩家实体。 +2. 屏幕任意位置按下并拖动可移动玩家实体。 3. 玩家碰到不高于自身等级的实体后成长,并在事件日志显示成长结果。 -4. 左上返回按钮在直达页语义为重开当前占位局。 +4. 左上返回按钮在直达页语义为退出到平台首页。 +5. 直达页通关或失败后,结算浮层继续复用正式运行态出口;失败态点击“重来”重开本地占位局,点击“退出”回到平台首页。 diff --git a/src/BigFishPlaygroundApp.tsx b/src/BigFishPlaygroundApp.tsx index d11179a4..b701f3c7 100644 --- a/src/BigFishPlaygroundApp.tsx +++ b/src/BigFishPlaygroundApp.tsx @@ -208,11 +208,16 @@ export default function BigFishPlaygroundApp() { setRun(buildInitialRun()); }, []); + const handleExit = useCallback(() => { + window.location.assign('/'); + }, []); + return ( ); diff --git a/src/components/big-fish-result/BigFishResultView.test.tsx b/src/components/big-fish-result/BigFishResultView.test.tsx index e1bca15e..7219673d 100644 --- a/src/components/big-fish-result/BigFishResultView.test.tsx +++ b/src/components/big-fish-result/BigFishResultView.test.tsx @@ -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( + {}} + 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(); + }); }); diff --git a/src/components/big-fish-result/BigFishResultView.tsx b/src/components/big-fish-result/BigFishResultView.tsx index 83829875..14c53a34 100644 --- a/src/components/big-fish-result/BigFishResultView.tsx +++ b/src/components/big-fish-result/BigFishResultView.tsx @@ -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(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 (
@@ -388,14 +397,23 @@ export function BigFishResultView({
@@ -487,7 +505,11 @@ export function BigFishResultView({ 背景 {session.assetCoverage.backgroundReady ? '已完成' : '待生成'} - {blockers.length > 0 ? ( + {isPublished ? ( +
+ 已发布 +
+ ) : blockers.length > 0 ? (
{blockers.slice(0, 4).map((blocker) => (
{blocker}
diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx new file mode 100644 index 00000000..e6b8597c --- /dev/null +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx @@ -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 ? {alt} : 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( + {}} + />, + ); + + 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( + {}} + />, + ); + + expect(screen.getByText('通关完成')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '重来' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '退出' })); + expect(onBack).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index 10088c99..344ba674 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -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(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({
{settlementCopy ? ( -
+
-
+
{settlementCopy.title}
{settlementCopy.message}
+
+ {run.status === 'failed' && onRestart ? ( + + ) : null} + +
) : null} diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index a24b6b78..0d22a681 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -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} /> diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 36ca1769..fd448204 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -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; }) => (
大鱼吃小鱼结果页
{session.draft?.title ?? '缺少草稿标题'}
+ @@ -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(); + + 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();