From c81305f2e6d77bb8905c9c2f137796ad1b978f4b Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 21:28:02 +0800 Subject: [PATCH 01/13] Add big fish settlement actions and publish feedback --- ...IG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md | 24 ++++ docs/experience/README.md | 1 + ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 1 + ...FISH_DIRECT_ROUTE_PLAYGROUND_2026-04-24.md | 5 +- src/BigFishPlaygroundApp.tsx | 7 +- .../BigFishResultView.test.tsx | 31 +++++ .../big-fish-result/BigFishResultView.tsx | 32 +++++- .../BigFishRuntimeShell.test.tsx | 80 +++++++++++++ .../big-fish-runtime/BigFishRuntimeShell.tsx | 41 ++++++- .../PlatformEntryFlowShellImpl.tsx | 12 +- ...gEntryFlowShell.agent.interaction.test.tsx | 107 ++++++++++++++++++ 11 files changed, 326 insertions(+), 15 deletions(-) create mode 100644 docs/experience/BIG_FISH_PUBLISH_FEEDBACK_FIX_2026-04-26.md create mode 100644 src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx 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(); From 7694ef57232a9185d1e1716a74c388d312787afe Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 21:41:20 +0800 Subject: [PATCH 02/13] feat: add big fish runtime rules entry --- .../BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md | 33 +++++++++++ .../BigFishRuntimeShell.test.tsx | 19 +++++++ .../big-fish-runtime/BigFishRuntimeShell.tsx | 55 ++++++++++++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md diff --git a/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md new file mode 100644 index 00000000..19b48c12 --- /dev/null +++ b/docs/technical/BIG_FISH_RUNTIME_RULE_ENTRY_2026-04-26.md @@ -0,0 +1,33 @@ +# 大鱼吃小鱼运行页规则入口说明 2026-04-26 + +## 背景 + +大鱼吃小鱼玩法规则已经在 PRD 与运行态技术方案中定义,但网站运行页没有给玩家查看规则的入口。玩家进入 `/big-fish` 或正式运行页后,只能看到当前等级、状态和事件日志,无法在游玩前快速理解吞噬、合成、胜负条件。 + +## 设计结论 + +1. 规则入口放在运行页顶部操作区,使用 `CircleHelp` 图标按钮。 +2. 默认界面不直接铺规则长文案,点击按钮后打开独立模态窗口。 +3. 模态窗口只保留玩家决策所需的核心规则: + - 拖动方向控制移动。 + - 吃掉低级或同级野生实体并收编。 + - 碰到更高级野生实体时,己方实体会被吃掉。 + - 3 个同级己方实体自动合成更高一级。 + - 拥有最高等级后通关,己方实体归零后失败。 +4. 入口必须在移动端单手可点,不遮挡舞台主体。 +5. 规则内容只做说明,不参与任何前端裁决;真实规则仍以后端运行快照为准。 + +## 落地范围 + +1. `src/components/big-fish-runtime/BigFishRuntimeShell.tsx` + - 增加规则按钮与规则模态窗口。 + - 复用 `UnifiedModal`,避免在当前玩法舞台内容流里展开说明。 +2. `src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx` + - 覆盖规则入口打开与关闭。 + +## 验收口径 + +1. 进入大鱼吃小鱼运行页后,右上角可看到规则图标入口。 +2. 点击规则入口后出现独立弹窗。 +3. 弹窗能展示核心吞噬、合成、通关与失败规则。 +4. 关闭弹窗后回到玩法舞台,不改变当前运行快照。 diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx index e6b8597c..51a771db 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.test.tsx @@ -77,4 +77,23 @@ describe('BigFishRuntimeShell', () => { fireEvent.click(screen.getByRole('button', { name: '退出' })); expect(onBack).toHaveBeenCalledTimes(1); }); + + test('opens and closes the runtime rule modal', () => { + render( + {}} + onSubmitInput={() => {}} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '查看规则' })); + + expect(screen.getByRole('dialog', { name: '玩法规则' })).toBeTruthy(); + expect(screen.getByText('低级或同级野生实体会被收编。')).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + + expect(screen.queryByRole('dialog', { name: '玩法规则' })).toBeNull(); + }); }); diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index 344ba674..f4c6b353 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Loader2, RotateCcw } from 'lucide-react'; +import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react'; import { type PointerEvent, useEffect, useRef, useState } from 'react'; import type { @@ -7,6 +7,7 @@ import type { BigFishRuntimeSnapshotResponse, SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; +import { UnifiedModal } from '../common/UnifiedModal'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type TouchOrigin = { @@ -127,6 +128,38 @@ function resolveSettlementCopy(run: BigFishRuntimeSnapshotResponse) { return null; } +function BigFishRuleModal({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) { + return ( + +
+
+ 拖动屏幕控制方向,角色会按固定速度移动。 +
+
+
低级或同级野生实体会被收编。
+
更高级野生实体会吃掉碰到的己方实体。
+
3 个同级己方实体会自动合成更高一级。
+
拥有最高等级实体后通关,己方实体归零后失败。
+
+
+
+ ); +} + function BigFishEntityDot({ entity, run, @@ -194,6 +227,7 @@ export function BigFishRuntimeShell({ }: BigFishRuntimeShellProps) { const stageRef = useRef(null); const [touchOrigin, setTouchOrigin] = useState(null); + const [isRuleModalOpen, setIsRuleModalOpen] = useState(false); const [stick, setStick] = useState({ x: 0, y: 0 }); const stickRef = useRef(stick); @@ -297,8 +331,19 @@ export function BigFishRuntimeShell({ > -
- Lv.{run.playerLevel}/{run.winLevel} · {statusLabel} +
+ +
+ Lv.{run.playerLevel}/{run.winLevel} · {statusLabel} +
@@ -370,6 +415,10 @@ export function BigFishRuntimeShell({
))}
+ setIsRuleModalOpen(false)} + />
); From 8448913d2f5e1481c333df22f31cc7e1872ad483 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 21:43:24 +0800 Subject: [PATCH 03/13] chore: clean platform entry lint --- .../PlatformEntryFlowShellImpl.tsx | 20 +++++---- ...gEntryFlowShell.agent.interaction.test.tsx | 44 +++++++++---------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 0d22a681..ec717465 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -61,6 +61,7 @@ import { type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; +import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { createPuzzleAgentSession, executePuzzleAgentAction, @@ -78,7 +79,6 @@ import { swapLocalPuzzlePieces, } from '../../services/puzzle-runtime/puzzleLocalRuntime'; import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works'; -import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { @@ -87,15 +87,15 @@ import { } from '../../services/rpg-entry/rpgEntryLibraryClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; -import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; -import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; -import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; -import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { isPuzzleGalleryEntry, mapPuzzleWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; +import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; +import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; +import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { @@ -109,8 +109,8 @@ import { } from './platformEntryShared'; import type { PlatformEntryFlowShellProps } from './platformEntryTypes'; import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; -import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; +import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; @@ -1575,7 +1575,9 @@ export function PlatformEntryFlowShellImpl({ try { await tryOpenGalleryEntry(); return; - } catch {} + } catch { + // 作品号优先时允许继续回退到用户号搜索。 + } } if (shouldSearchUserFirst) { @@ -1583,7 +1585,9 @@ export function PlatformEntryFlowShellImpl({ const user = await getPublicAuthUserByCode(normalizedKeyword); setSearchedPublicUser(user); return; - } catch {} + } catch { + // 用户号优先时允许继续回退到作品号搜索。 + } } if (!shouldSearchWorkFirst) { diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index fd448204..69125496 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -8,30 +8,8 @@ import { beforeEach, expect, test, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import { - createRpgCreationSession, - executeRpgCreationAction, - getRpgCreationOperation, - getRpgCreationSession, - listRpgCreationWorks, - streamRpgCreationMessage, - upsertRpgWorldProfile, -} from '../../services/rpg-creation'; -import type { AuthUser } from '../../services/authService'; import { ApiClientError } from '../../services/apiClient'; -import { - clearRpgProfileBrowseHistory as clearProfileBrowseHistory, - getRpgEntryWorldGalleryDetail, - getRpgProfileDashboard as getProfileDashboard, - listRpgEntryWorldGallery, - listRpgEntryWorldLibrary, - listRpgProfileBrowseHistory as listProfileBrowseHistory, - listRpgProfileSaveArchives as listProfileSaveArchives, - publishRpgEntryWorldProfile, - resumeRpgProfileSaveArchive as resumeProfileSaveArchive, - unpublishRpgEntryWorldProfile, - upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory, -} from '../../services/rpg-entry'; +import type { AuthUser } from '../../services/authService'; import { createBigFishCreationSession, executeBigFishCreationAction, @@ -47,6 +25,26 @@ import { listPuzzleGallery, } from '../../services/puzzle-gallery'; import { listPuzzleWorks } from '../../services/puzzle-works'; +import { + createRpgCreationSession, + executeRpgCreationAction, + getRpgCreationOperation, + getRpgCreationSession, + listRpgCreationWorks, + streamRpgCreationMessage, + upsertRpgWorldProfile, +} from '../../services/rpg-creation'; +import { + clearRpgProfileBrowseHistory as clearProfileBrowseHistory, + getRpgEntryWorldGalleryDetail, + getRpgProfileDashboard as getProfileDashboard, + listRpgEntryWorldGallery, + listRpgEntryWorldLibrary, + listRpgProfileBrowseHistory as listProfileBrowseHistory, + listRpgProfileSaveArchives as listProfileSaveArchives, + resumeRpgProfileSaveArchive as resumeProfileSaveArchive, + upsertRpgProfileBrowseHistory as upsertProfileBrowseHistory, +} from '../../services/rpg-entry'; import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, From ea550de6a1227b2262de5bc98f2cace0d507f1d3 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 22:44:04 +0800 Subject: [PATCH 04/13] chore: pass web port through jenkins deploy --- ..._RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md | 14 +++-- jenkins/Jenkinsfile.build-and-deploy | 23 +++++++- jenkins/Jenkinsfile.deploy | 22 +++++++ scripts/jenkins-deploy-release.sh | 58 ++++++++++++++++++- 4 files changed, 108 insertions(+), 9 deletions(-) diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index 271a80c5..d48609bc 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -8,7 +8,7 @@ 1. `构建`:只负责在仓库根目录执行 `npm run deploy:rust:remote -- --skip-upload`,生成发布包。 2. `部署`:只负责把指定发布版本部署到 `/var/lib/jenkins/deploy/Genarrative/`,允许人工按参数启动,并支持按参数决定是否清空 SpacetimeDB 数据。 -3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `80`,并透传是否清库。 +3. `构建并部署`:先构建,再把构建出的版本号传给 `部署` 流水线并等待部署完成;同时暴露 `WEB_PORT` 参数,默认把发布包 Web 端口写成 `25001`,并把同名端口参数继续透传给下游部署,部署阶段以该参数作为最终监听端口。 本次只补 Jenkins 编排与本地部署脚本,不改现有 Rust 发布包构建逻辑,不恢复旧 `server-node` 部署链。 @@ -24,6 +24,7 @@ 8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截。 9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行。 10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限。 +11. `WEB_PORT` 必须在 `构建并部署` 与 `部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退。 ## 3. 节点与工作区要求 @@ -85,6 +86,7 @@ jenkins/Jenkinsfile.deploy scripts/jenkins-deploy-release.sh \ --source-dir /build/ \ --deploy-dir /var/lib/jenkins/deploy/Genarrative \ + --web-port \ [--clear-database] \ --hook-with-sudo ``` @@ -94,12 +96,12 @@ scripts/jenkins-deploy-release.sh \ 1. 若部署目录已有旧版本且存在 `stop.sh`,先执行旧版本 `stop.sh`。 2. 只删除发布产物白名单中的旧文件,例如 `web/`、`api-server`、`spacetime_module.wasm`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md`。 3. 将指定版本目录中的同名发布产物移动到部署目录。 -4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c always`。 +4. 如果 `CLEAR_DATABASE=true`,部署脚本会以 `./start.sh --clear-database` 启动新版本;这样发布阶段的 `spacetime publish` 会追加 `-c=on-conflict`。 5. 执行新版本 `start.sh`。 如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo,否则部署会直接失败,不会进入交互式密码提示。 -这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 仍会以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令。 +这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口配置只停留在上游构建阶段。 ### 4.3 构建并部署 @@ -115,12 +117,13 @@ jenkins/Jenkinsfile.build-and-deploy 2. 复用与 `构建` 相同的构建命令生成 `build//`。 3. 归档 `build//**`。 4. 记录当前 `NODE_NAME`、源码根目录、版本号。 -5. 构建时额外透传 `--web-port `,默认生成监听 `80` 的发布包。 +5. 构建时额外透传 `--web-port `,默认生成监听 `25001` 的发布包。 6. 触发 `部署` 流水线,并传递: - `BUILD_VERSION` - `SOURCE_WORKSPACE_ROOT` - `SOURCE_NODE_NAME` - `DEPLOY_DIRECTORY` + - `WEB_PORT` - `CLEAR_DATABASE` - `EXPECTED_UPSTREAM_JOB` @@ -132,7 +135,7 @@ jenkins/Jenkinsfile.build-and-deploy 2. `GENARRATIVE_WORKSPACE_ROOT`:源码根目录;为空时回退到 Jenkins 当前工作区。 3. `BUILD_VERSION`:发布版本号;为空时回退到 `BUILD_NUMBER`。 4. `RUN_NPM_CI`:是否在构建前执行 `npm ci`。 -5. `WEB_PORT`:发布包内静态网站监听端口;`构建并部署` 默认值为 `80`。 +5. `WEB_PORT`:静态网站监听端口;`构建并部署` 默认值为 `25001`,并通过下游 `部署` 同名参数作为最终启动端口。 6. `CLEAR_DATABASE`:部署阶段是否清空 SpacetimeDB 数据后再发布 wasm;默认 `false`。 如果当前 Jenkins 没有额外配置独立 Agent,而是直接在控制器自身执行任务,`AGENT_LABEL` 应填写 `built-in`。 @@ -147,6 +150,7 @@ jenkins/Jenkinsfile.build-and-deploy 4. `CLEAR_DATABASE` 5. `RUN_DEPLOY_HOOKS_WITH_SUDO` 6. `EXPECTED_UPSTREAM_JOB` +7. `WEB_PORT` 其中仅 `构建并部署` 流水线还需要: diff --git a/jenkins/Jenkinsfile.build-and-deploy b/jenkins/Jenkinsfile.build-and-deploy index 7fb541d2..62554c07 100644 --- a/jenkins/Jenkinsfile.build-and-deploy +++ b/jenkins/Jenkinsfile.build-and-deploy @@ -10,7 +10,7 @@ pipeline { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') - string(name: 'WEB_PORT', defaultValue: '80', description: '发布包内静态网站端口,默认 80') + string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') booleanParam(name: 'RUN_NPM_CI', defaultValue: false, description: '构建前是否执行 npm ci') string(name: 'DEPLOY_JOB_NAME', defaultValue: 'Genarrative-Deploy', description: '部署流水线作业名') @@ -30,6 +30,22 @@ pipeline { env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() + def webPort = params.WEB_PORT?.trim() + if (!webPort) { + error('WEB_PORT 不能为空。') + } + if (!(webPort ==~ /^[0-9]+$/)) { + error("WEB_PORT 必须是数字端口,当前值: ${webPort}") + } + if (webPort.length() > 5) { + error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") + } + def parsedWebPort = webPort.toInteger() + if (parsedWebPort < 1 || parsedWebPort > 65535) { + error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") + } + // 后续构建与下游部署都使用校验后的同一端口值,避免参数空格导致上下游不一致。 + env.EFFECTIVE_WEB_PORT = webPort // 记录当前构建节点名,部署阶段必须回到同一节点读取本地 build 目录。 env.SOURCE_NODE_NAME = env.NODE_NAME } @@ -57,8 +73,8 @@ pipeline { sh """ bash -lc ' set -euo pipefail - # 构建并部署流水线显式透传 Web 端口,确保部署包默认监听 80,同时允许 Jenkins 参数覆盖。 - npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" --web-port "${params.WEB_PORT}" + # 构建并部署流水线显式透传 Web 端口,确保部署包默认监听 25001,同时允许 Jenkins 参数覆盖。 + npm run deploy:rust:remote -- --skip-upload --name "${env.EFFECTIVE_BUILD_VERSION}" --web-port "${env.EFFECTIVE_WEB_PORT}" test -d "build/${env.EFFECTIVE_BUILD_VERSION}" ' """ @@ -79,6 +95,7 @@ pipeline { string(name: 'SOURCE_WORKSPACE_ROOT', value: env.WORKSPACE_ROOT), string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION), string(name: 'DEPLOY_DIRECTORY', value: params.DEPLOY_DIRECTORY), + string(name: 'WEB_PORT', value: env.EFFECTIVE_WEB_PORT), booleanParam(name: 'CLEAR_DATABASE', value: params.CLEAR_DATABASE), booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', value: params.RUN_DEPLOY_HOOKS_WITH_SUDO), string(name: 'EXPECTED_UPSTREAM_JOB', value: env.JOB_NAME), diff --git a/jenkins/Jenkinsfile.deploy b/jenkins/Jenkinsfile.deploy index 6dfdafe0..c2e4e69b 100644 --- a/jenkins/Jenkinsfile.deploy +++ b/jenkins/Jenkinsfile.deploy @@ -11,6 +11,7 @@ pipeline { string(name: 'SOURCE_WORKSPACE_ROOT', defaultValue: '', description: '上游源码根目录') string(name: 'BUILD_VERSION', defaultValue: '', description: '待部署版本号') string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录') + string(name: 'WEB_PORT', defaultValue: '25001', description: '静态网站监听端口,默认 25001,上游构建并部署流水线会透传同名参数') booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '部署时是否清空 SpacetimeDB 数据后再发布 wasm') booleanParam(name: 'RUN_DEPLOY_HOOKS_WITH_SUDO', defaultValue: true, description: 'start.sh / stop.sh 是否通过 sudo -n 执行') string(name: 'EXPECTED_UPSTREAM_JOB', defaultValue: '', description: '允许触发本作业的上游作业名') @@ -53,6 +54,26 @@ pipeline { error('SOURCE_NODE_NAME 不能为空。') } + def webPort = params.WEB_PORT?.trim() + if (!webPort) { + error('WEB_PORT 不能为空。') + } + + if (!(webPort ==~ /^[0-9]+$/)) { + error("WEB_PORT 必须是数字端口,当前值: ${webPort}") + } + + if (webPort.length() > 5) { + error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") + } + + def parsedWebPort = webPort.toInteger() + if (parsedWebPort < 1 || parsedWebPort > 65535) { + error("WEB_PORT 必须在 1-65535 之间,当前值: ${webPort}") + } + // 部署脚本只接收校验后的端口值,避免手工参数前后空格传到 Bash。 + env.EFFECTIVE_WEB_PORT = webPort + if (upstreamCause && !actualUpstreamJob?.trim()) { error('无法从上游触发原因中解析作业名,请检查 Jenkins Pipeline Build Step 插件版本与触发链。') } @@ -85,6 +106,7 @@ pipeline { deploy_args=( --source-dir "build/${params.BUILD_VERSION}" --deploy-dir "${params.DEPLOY_DIRECTORY}" + --web-port "${env.EFFECTIVE_WEB_PORT}" ) if [[ "${params.CLEAR_DATABASE}" == "true" ]]; then deploy_args+=(--clear-database) diff --git a/scripts/jenkins-deploy-release.sh b/scripts/jenkins-deploy-release.sh index eee0c71b..6b3314e9 100644 --- a/scripts/jenkins-deploy-release.sh +++ b/scripts/jenkins-deploy-release.sh @@ -5,7 +5,7 @@ set -euo pipefail usage() { cat <<'EOF' 用法: - ./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative [--clear-database] [--hook-with-sudo] + ./scripts/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--hook-with-sudo] 说明: 1. 如果部署目录已有旧版本且存在 stop.sh,则先执行旧版本 stop.sh。 @@ -17,6 +17,7 @@ usage() { 参数: --source-dir 必填,待部署的发布目录,例如 build/123 --deploy-dir 必填,固定部署目录,例如 /var/lib/jenkins/deploy/Genarrative + --web-port 必填,本次部署后静态网站监听端口 --clear-database 可选,启动新版本时追加 --clear-database --hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行 EOF @@ -32,6 +33,28 @@ require_argument() { fi } +validate_port() { + local value="$1" + local label="$2" + local numeric_value + + if [[ ! "${value}" =~ ^[0-9]+$ ]]; then + echo "[jenkins-deploy] ${label} 必须是数字端口: ${value}" >&2 + exit 1 + fi + + if ((${#value} > 5)); then + echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2 + exit 1 + fi + + numeric_value=$((10#${value})) + if ((numeric_value < 1 || numeric_value > 65535)); then + echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2 + exit 1 + fi +} + normalize_env_file() { local env_file="$1" local temp_file="${env_file}.tmp.$$" @@ -54,8 +77,34 @@ normalize_release_env_files() { normalize_env_file "${release_dir}/web/.env.local" } +write_env_override() { + local env_file="$1" + local key="$2" + local value="$3" + local temp_file="${env_file}.tmp.$$" + + mkdir -p "$(dirname "${env_file}")" + if [[ -f "${env_file}" ]]; then + # 先移除旧的同名变量,再追加 Jenkins 本次部署参数,确保 sudo 启动时也能被 start.sh 读取。 + awk -v target_key="${key}" ' + BEGIN { + pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "=" + } + $0 !~ pattern { + print + } + ' "${env_file}" >"${temp_file}" + else + : >"${temp_file}" + fi + + printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}" + mv "${temp_file}" "${env_file}" +} + SOURCE_DIR="" DEPLOY_DIR="" +WEB_PORT="" CLEAR_DATABASE="0" HOOK_WITH_SUDO="0" DEPLOY_ITEMS=( @@ -84,6 +133,10 @@ while [[ $# -gt 0 ]]; do DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}" shift 2 ;; + --web-port) + WEB_PORT="${2:?缺少 --web-port 的值}" + shift 2 + ;; --clear-database) CLEAR_DATABASE="1" shift @@ -102,6 +155,8 @@ done require_argument "${SOURCE_DIR}" "--source-dir" require_argument "${DEPLOY_DIR}" "--deploy-dir" +require_argument "${WEB_PORT}" "--web-port" +validate_port "${WEB_PORT}" "--web-port" run_hook() { local hook_dir="$1" @@ -179,6 +234,7 @@ if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then fi normalize_release_env_files "${DEPLOY_DIR}" +write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_WEB_PORT" "${WEB_PORT}" echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}" if [[ "${CLEAR_DATABASE}" == "1" ]]; then From b7a507044fb0363e201ea56eabf8df717ad9f258 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 22:44:44 +0800 Subject: [PATCH 05/13] fix: keep generation progress out of chat history --- ...AFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md | 2 ++ .../spacetime-module/src/big_fish/assets.rs | 7 ---- .../spacetime-module/src/big_fish/session.rs | 33 ------------------- .../crates/spacetime-module/src/puzzle.rs | 25 -------------- ...BigFishAgentWorkspace.interaction.test.tsx | 28 ++++++++++++++++ .../BigFishAgentWorkspace.tsx | 10 +++++- .../PuzzleAgentWorkspace.interaction.test.tsx | 26 +++++++++++++++ .../puzzle-agent/PuzzleAgentWorkspace.tsx | 10 +++++- 8 files changed, 74 insertions(+), 67 deletions(-) diff --git a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md index 2edcc6ec..f368eba0 100644 --- a/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md +++ b/docs/technical/PUZZLE_BIG_FISH_DRAFT_PROGRESS_AND_ASSET_CHAIN_2026-04-25.md @@ -43,6 +43,7 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 - 玩法特有的生成进度只通过 `beforeExecuteAction` 与 `onActionError` 这类回调接入:compile action 发起前切到独立生成页并初始化进度,失败时把进度置为 failed。 - compile action 成功后继续由通用控制器切到结果页,页面层只补齐生成资产数量、拼图操作记录、作品架与广场刷新等玩法差异。 - 离开玩法流程时,先清理运行态与生成进度态,再交给通用控制器恢复创作中心,避免流式回复和进度状态在下一次创作中残留。 +- 生成进度页的阶段文本、资产完成文本、草稿写回文本只属于进度读模型和结果页状态,不属于 Agent 聊天历史。后端 compile / asset action 不再追加 `action_result` 聊天消息;前端聊天气泡只展示 `kind === "chat" | "summary" | "warning"` 的消息,历史会话中已经存在的 `action_result` 只作为兼容数据保留,不再渲染。 ## 验收点 @@ -50,4 +51,5 @@ RPG 在点击生成草稿后会离开聊天工作区,进入独立的生成进 - 生成中可看到独立进度页,且进度步骤随 action 完成逐步推进。 - 拼图结果页打开时已有正式图;大鱼结果页打开时主图、动作和背景资产均已写入 `assetSlots`。 - 前端点击生成草稿时不串行调用多个资产 action;多阶段业务编排收敛在 `server-rs`。 +- 返回 Agent 工作区后,聊天区不出现“拼图结果页草稿已生成。”“本级主图已正式生成,可在结果页继续预览。”这类生成进度页状态消息。 - 不新增 server-node 依赖,不复活 legacy public 静态资产路径。 diff --git a/server-rs/crates/spacetime-module/src/big_fish/assets.rs b/server-rs/crates/spacetime-module/src/big_fish/assets.rs index f63f8648..3e68e92f 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/assets.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/assets.rs @@ -112,13 +112,6 @@ pub(crate) fn generate_big_fish_asset_tx( updated_at, }; replace_big_fish_session(ctx, &session, next_session); - append_big_fish_system_message( - ctx, - &input.session_id, - format!("big-fish-message-asset-{}", input.generated_at_micros), - reply, - input.generated_at_micros, - ); get_big_fish_session_tx( ctx, diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index cc4f5df0..50a4c435 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -538,13 +538,6 @@ pub(crate) fn compile_big_fish_draft_tx( updated_at: compiled_at, }; replace_big_fish_session(ctx, &session, next_session); - append_big_fish_system_message( - ctx, - &input.session_id, - format!("big-fish-message-compile-{}", input.compiled_at_micros), - reply, - input.compiled_at_micros, - ); get_big_fish_session_tx( ctx, @@ -682,32 +675,6 @@ pub(crate) fn replace_big_fish_session( ctx.db.big_fish_creation_session().insert(next); } -pub(crate) fn append_big_fish_system_message( - ctx: &ReducerContext, - session_id: &str, - message_id: String, - text: String, - created_at_micros: i64, -) { - if ctx - .db - .big_fish_agent_message() - .message_id() - .find(&message_id) - .is_some() - { - return; - } - ctx.db.big_fish_agent_message().insert(BigFishAgentMessage { - message_id, - session_id: session_id.to_string(), - role: BigFishAgentMessageRole::Assistant, - kind: BigFishAgentMessageKind::ActionResult, - text, - created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), - }); -} - #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index c5d7890d..f2287a90 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -659,12 +659,6 @@ fn compile_puzzle_agent_draft_tx( updated_at: compiled_at, }, ); - append_system_message( - ctx, - &row.session_id, - input.compiled_at_micros, - "拼图结果页草稿已生成。", - )?; get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { @@ -1260,25 +1254,6 @@ fn build_puzzle_suggested_actions( } } -fn append_system_message( - ctx: &TxContext, - session_id: &str, - created_at_micros: i64, - text: &str, -) -> Result<(), String> { - let message_id = format!("{session_id}-system-{created_at_micros}"); - ensure_message_missing(ctx, &message_id)?; - ctx.db.puzzle_agent_message().insert(PuzzleAgentMessageRow { - message_id, - session_id: session_id.to_string(), - role: PuzzleAgentMessageRole::Assistant, - kind: PuzzleAgentMessageKind::ActionResult, - text: text.to_string(), - created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), - }); - Ok(()) -} - fn ensure_session_missing(ctx: &TxContext, session_id: &str) -> Result<(), String> { if ctx .db diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx index 906ca63f..db487c55 100644 --- a/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx @@ -103,3 +103,31 @@ test('big fish workspace hides keyword fill before two turns', () => { expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); }); + +test('big fish workspace does not render progress action messages as chat bubbles', () => { + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + />, + ); + + expect(screen.getByText('爽点和生态已经清楚,继续补剩余关键词。')).toBeTruthy(); + expect( + screen.queryByText('本级主图已正式生成,可在结果页继续预览。'), + ).toBeNull(); +}); diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx index f1389543..f6578817 100644 --- a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx @@ -52,6 +52,14 @@ function mapBigFishAnchor( function mapBigFishSession( session: BigFishSessionSnapshotResponse, ): CreationAgentSessionView { + // 中文注释:生成进度与资产完成记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。 + const chatMessages = session.messages.filter( + (message) => + message.kind === 'chat' || + message.kind === 'summary' || + message.kind === 'warning', + ); + return { sessionId: session.sessionId, // 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。 @@ -65,7 +73,7 @@ function mapBigFishSession( session.anchorPack.growthLadder, session.anchorPack.riskTempo, ].map(mapBigFishAnchor), - messages: session.messages, + messages: chatMessages, recommendedReplies: [], }; } diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index 36dfb586..bd029559 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -102,3 +102,29 @@ test('puzzle workspace hides keyword fill before two turns', () => { expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); }); + +test('puzzle workspace does not render progress action messages as chat bubbles', () => { + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + />, + ); + + expect(screen.getByText('画面主体已经清楚,继续收束剩余关键词。')).toBeTruthy(); + expect(screen.queryByText('拼图结果页草稿已生成。')).toBeNull(); +}); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index ac8edce3..e607be7b 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -44,6 +44,14 @@ const PUZZLE_AGENT_THEME: CreationAgentTheme = { function mapPuzzleSession( session: PuzzleAgentSessionSnapshot, ): CreationAgentSessionView { + // 中文注释:生成进度与草稿写回记录不属于聊天历史,旧会话里的 action_result 也不再渲染为气泡。 + const chatMessages = session.messages.filter( + (message) => + message.kind === 'chat' || + message.kind === 'summary' || + message.kind === 'warning', + ); + return { sessionId: session.sessionId, // 所有玩法的 Agent 聊天页顶部模块只保留操作与进度,不展示标题和引导副文案。 @@ -58,7 +66,7 @@ function mapPuzzleSession( session.anchorPack.compositionHooks, session.anchorPack.tagsAndForbidden, ], - messages: session.messages, + messages: chatMessages, recommendedReplies: [], }; } From 3198370089b66b353850509393eb8f7c6b15e525 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 23:01:42 +0800 Subject: [PATCH 06/13] fix rpg agent refresh restore route --- ...TEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 8 +++- ...gEntryFlowShell.agent.interaction.test.tsx | 41 ++++++++++++++++ .../useRpgCreationSessionController.ts | 32 ++++++++++++- src/services/customWorldAgentUiState.test.ts | 47 +++++++++++++++++++ src/services/customWorldAgentUiState.ts | 43 +++++++++++++++++ 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md b/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md index d418eb8c..57535d24 100644 --- a/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md +++ b/docs/technical/FRONTEND_INDEPENDENT_PAGE_ROUTES_2026-04-25.md @@ -12,6 +12,7 @@ - 正式主应用内部页面路径由 `src/routing/appPageRoutes.ts` 统一维护,不在组件里散落硬编码字符串。 - `/puzzle` 与 `/big-fish` 保持为玩法调试直达入口;正式链路中的拼图和大鱼运行页使用 `/runtime/puzzle`、`/runtime/big-fish`,避免语义冲突。 - 独立路径先解决页面阶段语义和浏览器前进后退;依赖运行中内存对象的详情页、结果页和运行页直接刷新后仍允许回退到平台首页或展示现有恢复态,不在本轮扩展资源 ID 深链加载。 +- `sessionStorage` 里的 RPG Agent 恢复指针只能在当前路径属于 `/creation/rpg/*`,或 URL 显式携带 `customWorldSessionId / customWorldOperationId / customWorldGenerationSource` 时生效;刷新平台首页、分类页、作品详情页时不能被本地残留指针强制跳到 `/creation/rpg/agent`。 ## 页面路径表 @@ -46,3 +47,4 @@ 2. 从页面内切换到结果页、运行页或返回首页时,浏览器路径同步更新。 3. 浏览器后退/前进能驱动 `selectionStage` 回到对应页面。 4. `/puzzle` 与 `/big-fish` 仍进入原有玩法调试直达页。 +5. 仅有 `sessionStorage` 残留 RPG Agent 指针时,刷新 `/` 仍停留平台首页;刷新 `/creation/rpg/agent` 才恢复对应 Agent 工作区。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index ec717465..acedecd6 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -52,7 +52,10 @@ import { deleteBigFishWork, listBigFishWorks, } from '../../services/big-fish-works'; -import { readCustomWorldAgentUiState } from '../../services/customWorldAgentUiState'; +import { + readCustomWorldAgentUiState, + shouldRestoreCustomWorldAgentUiState, +} from '../../services/customWorldAgentUiState'; import { buildBigFishGenerationAnchorEntries, buildMiniGameDraftGenerationProgress, @@ -423,7 +426,8 @@ export function PlatformEntryFlowShellImpl({ >(null); const hadReadableProtectedDataRef = useRef(false); const hasInitialAgentSession = Boolean( - readCustomWorldAgentUiState().activeSessionId, + readCustomWorldAgentUiState().activeSessionId && + shouldRestoreCustomWorldAgentUiState(), ); const platformBootstrap = usePlatformEntryBootstrap({ diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 69125496..2b9674ca 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1556,6 +1556,47 @@ test('restoring an agent workspace ignores a stored session owned by another use expect(window.location.search).toBe(''); }); +test('refreshing platform home ignores stored agent workspace pointer without explicit restore path', async () => { + window.sessionStorage.setItem( + 'genarrative.custom-world-agent-ui.v1', + JSON.stringify({ + activeSessionId: 'custom-world-agent-session-1', + activeOperationId: null, + ownerUserId: 'user-1', + }), + ); + + render(); + + expect(await screen.findByRole('button', { name: '创作' })).toBeTruthy(); + expect(screen.queryByText(/Agent工作区/u)).toBeNull(); + expect(getRpgCreationSession).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe('/'); +}); + +test('refreshing RPG agent path restores stored agent workspace pointer', async () => { + window.history.replaceState(null, '', '/creation/rpg/agent'); + window.sessionStorage.setItem( + 'genarrative.custom-world-agent-ui.v1', + JSON.stringify({ + activeSessionId: 'custom-world-agent-session-1', + activeOperationId: null, + ownerUserId: 'user-1', + }), + ); + + render(); + + await waitFor(() => { + expect(getRpgCreationSession).toHaveBeenCalledWith( + 'custom-world-agent-session-1', + ); + }); + expect( + await screen.findByText('Agent工作区:custom-world-agent-session-1'), + ).toBeTruthy(); +}); + test('new creation entry maps raw bearer token errors to user-facing auth copy', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/useRpgCreationSessionController.ts b/src/components/rpg-entry/useRpgCreationSessionController.ts index 29a04ed2..99940fe8 100644 --- a/src/components/rpg-entry/useRpgCreationSessionController.ts +++ b/src/components/rpg-entry/useRpgCreationSessionController.ts @@ -15,6 +15,7 @@ import { } from '../../services/customWorldAgentGenerationProgress'; import { readCustomWorldAgentUiState, + shouldRestoreCustomWorldAgentUiState, writeCustomWorldAgentUiState, } from '../../services/customWorldAgentUiState'; import { @@ -66,12 +67,16 @@ export function useRpgCreationSessionController( onSessionOpened, } = params; const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); + const shouldRestoreInitialAgentUiStateRef = useRef( + shouldRestoreCustomWorldAgentUiState(), + ); const isInitialAgentUiStateOwnedByCurrentUser = !initialAgentUiStateRef.current.ownerUserId || initialAgentUiStateRef.current.ownerUserId === userId; const isHydratingInitialAgentWorkspaceRef = useRef( Boolean( initialAgentUiStateRef.current.activeSessionId && + shouldRestoreInitialAgentUiStateRef.current && isInitialAgentUiStateOwnedByCurrentUser, ), ); @@ -88,6 +93,7 @@ export function useRpgCreationSessionController( const [activeAgentSessionId, setActiveAgentSessionId] = useState< string | null >(() => + shouldRestoreInitialAgentUiStateRef.current && isInitialAgentUiStateOwnedByCurrentUser ? (initialAgentUiStateRef.current.activeSessionId ?? null) : null, @@ -95,6 +101,7 @@ export function useRpgCreationSessionController( const [activeAgentOperationId, setActiveAgentOperationId] = useState< string | null >(() => + shouldRestoreInitialAgentUiStateRef.current && isInitialAgentUiStateOwnedByCurrentUser ? (initialAgentUiStateRef.current.activeOperationId ?? null) : null, @@ -209,7 +216,25 @@ export function useRpgCreationSessionController( useEffect(() => { const initialAgentSessionId = initialAgentUiStateRef.current.activeSessionId; - if (!initialAgentSessionId || hasAppliedInitialAgentWorkspaceRef.current) { + if ( + !initialAgentSessionId || + hasAppliedInitialAgentWorkspaceRef.current + ) { + return; + } + + if ( + initialAgentUiStateRef.current.ownerUserId && + userId && + initialAgentUiStateRef.current.ownerUserId !== userId + ) { + hasAppliedInitialAgentWorkspaceRef.current = true; + isHydratingInitialAgentWorkspaceRef.current = false; + persistAgentUiState(null, null); + return; + } + + if (!shouldRestoreInitialAgentUiStateRef.current) { return; } @@ -781,7 +806,10 @@ export function useRpgCreationSessionController( }, []); return { - initialAgentSessionId: initialAgentUiStateRef.current.activeSessionId ?? null, + initialAgentSessionId: + shouldRestoreInitialAgentUiStateRef.current + ? (initialAgentUiStateRef.current.activeSessionId ?? null) + : null, isCreatingAgentSession, activeAgentSessionId, activeAgentOperationId, diff --git a/src/services/customWorldAgentUiState.test.ts b/src/services/customWorldAgentUiState.test.ts index 72aeefb2..e9ce7d6f 100644 --- a/src/services/customWorldAgentUiState.test.ts +++ b/src/services/customWorldAgentUiState.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest'; import { clearCustomWorldAgentUiState, readCustomWorldAgentUiState, + shouldRestoreCustomWorldAgentUiState, writeCustomWorldAgentUiState, } from './customWorldAgentUiState'; @@ -73,3 +74,49 @@ test('custom world agent ui state reads from query first and persists to session clearCustomWorldAgentUiState(env); expect(readCustomWorldAgentUiState(env)).toEqual({}); }); + +test('custom world agent ui state only auto restores stored pointers on RPG creation paths', () => { + const sessionStorage = createMemoryStorage(); + sessionStorage.setItem( + 'genarrative.custom-world-agent-ui.v1', + JSON.stringify({ + activeSessionId: 'session-1', + ownerUserId: 'user-1', + }), + ); + + expect( + shouldRestoreCustomWorldAgentUiState({ + location: { + pathname: '/', + search: '', + }, + history: null, + sessionStorage, + }), + ).toBe(false); + + expect( + shouldRestoreCustomWorldAgentUiState({ + location: { + pathname: '/creation/rpg/agent', + search: '', + }, + history: null, + sessionStorage, + }), + ).toBe(true); +}); + +test('custom world agent ui state restores explicit query pointers on any main path', () => { + expect( + shouldRestoreCustomWorldAgentUiState({ + location: { + pathname: '/', + search: '?customWorldSessionId=session-1', + }, + history: null, + sessionStorage: createMemoryStorage(), + }), + ).toBe(true); +}); diff --git a/src/services/customWorldAgentUiState.ts b/src/services/customWorldAgentUiState.ts index ac27041a..5300fcd3 100644 --- a/src/services/customWorldAgentUiState.ts +++ b/src/services/customWorldAgentUiState.ts @@ -56,6 +56,49 @@ function normalizeGenerationSource(value: unknown) { return value === 'agent-draft-foundation' ? value : null; } +function hasExplicitAgentUiStateQuery( + params: URLSearchParams, +) { + return ( + params.has(CUSTOM_WORLD_AGENT_SESSION_QUERY_KEY) || + params.has(CUSTOM_WORLD_AGENT_OPERATION_QUERY_KEY) || + params.has(CUSTOM_WORLD_GENERATION_SOURCE_QUERY_KEY) + ); +} + +function normalizePathname(value: string | undefined) { + const pathname = value?.trim().toLowerCase() ?? ''; + if (!pathname || pathname === '/') { + return '/'; + } + + return pathname.replace(/\/+$/u, ''); +} + +function isRpgCreationRestorePath(pathname: string | undefined) { + const normalizedPathname = normalizePathname(pathname); + return ( + normalizedPathname === '/creation/rpg' || + normalizedPathname.startsWith('/creation/rpg/') + ); +} + +export function shouldRestoreCustomWorldAgentUiState( + env?: CustomWorldAgentUiEnvironment, +) { + const resolved = resolveEnvironment(env); + const params = new URLSearchParams(resolved.location?.search ?? ''); + + // URL 显式恢复参数优先于当前路径,用于支持外部分享或登录回跳后的深链恢复。 + if (hasExplicitAgentUiStateQuery(params)) { + return true; + } + + // sessionStorage 里的残留指针只能在 RPG 创作页面生效, + // 避免刷新平台首页时被旧工作区状态强制带到 Agent 页面。 + return isRpgCreationRestorePath(resolved.location?.pathname); +} + export function readCustomWorldAgentUiState( env?: CustomWorldAgentUiEnvironment, ): CustomWorldAgentUiState { From 44b08dd51a1462e5ef969eceb6a26c4104d69072 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 23:56:14 +0800 Subject: [PATCH 07/13] fix deploy spacetime root sync --- ..._RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md | 2 +- docs/technical/README.md | 1 + ...ND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md | 10 +- ..._SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md | 75 +++++++++ scripts/deploy-rust-remote.sh | 151 ++++++++++++++++-- 5 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md diff --git a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md index d48609bc..d653c97d 100644 --- a/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md +++ b/docs/technical/JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md @@ -101,7 +101,7 @@ scripts/jenkins-deploy-release.sh \ 如果 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,第 1 步和第 4 步会改为 `sudo -n` 调用;这要求 Jenkins 运行用户提前配置免密 sudo,否则部署会直接失败,不会进入交互式密码提示。 -这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口配置只停留在上游构建阶段。 +这样可以满足“发布文件直接覆盖”的要求,同时保留部署目录里像 `.spacetimedb/`、`logs/`、`run/` 这类运行态目录,不会因为部署被整体删除。发布白名单内的 `.env`、`.env.local` 会先以构建产物中的文件为准;部署脚本会在启动 hook 前移除这些环境文件中的 UTF-8 BOM 与 CRLF,并把 Jenkins 部署参数 `WEB_PORT` 写入 `.env.local` 的 `GENARRATIVE_WEB_PORT`,避免 `start.sh` 在 Bash 下把首行变量名误解析成命令,也避免端口配置只停留在上游构建阶段。`start.sh` 会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到部署目录 `.spacetimedb/bin/current/spacetimedb-cli`,后续启动、探活和 root-dir 占用判定都使用部署目录内 `.spacetimedb/`,且不再额外设置 `--data-dir`,避免 Jenkins 机器全局 `spacetime login` 变化影响本地库更新;如遇 `403 Forbidden`,按 `SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md` 排查数据库所有者与 CLI 身份。 ### 4.3 构建并部署 diff --git a/docs/technical/README.md b/docs/technical/README.md index 9e61e28c..e96ef0a5 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -6,6 +6,7 @@ - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse` 真流式输出的后端落地口径。 - [RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md](./RPG_BATTLE_HEALTHBAR_AND_ACTION_PRESENTATION_FIX_2026-04-26.md):记录 RPG 战斗血条安全锚点、服务端战斗回包前端短表现,以及 `battle_use_skill` 指定技能兜底结算的修复口径。 +- [SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md](./SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md):记录发布包 `start.sh` 执行 `spacetime publish` 遇到 `403 Forbidden` 的身份根因、`.spacetimedb/` root-dir 隔离修复和排查步骤。 - [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。 - [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。 - [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。 diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 3a8c0dd8..4d2c9a01 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -142,7 +142,7 @@ npm run deploy:rust:remote 5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。 6. 把仓库根目录的 `.env` 与 `.env.local` 分别复制到目标目录根部和目标目录的 `web/` 下;复制后统一移除 UTF-8 BOM 与 CRLF,避免目标服务器 Bash 加载环境文件失败。 7. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。 -8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 +8. 在目标目录写入 `start.sh` 与 `stop.sh`;`start.sh` 会先按 `KEY=value` 子集加载发布目录根部的 `.env`、`.env.local`,兼容 UTF-8 BOM 与 CRLF,再回退到构建时通过 `--database`、`--api-port`、`--web-port`、`--spacetime-host`、`--spacetime-port` 写入的默认值,并默认导出 `NO_COLOR=1` 与 `CARGO_TERM_COLOR=never`,避免 ANSI 控制码写入日志文件;同时按 Ubuntu 发布环境使用发布目录内 `.spacetimedb/` 作为 root-dir,不再额外设置 `--data-dir`,启动前先执行 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`,当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`;启动参数为 `spacetime --root-dir ./.spacetimedb start --edition standalone --listen-addr :`,探活必须确认 `server ping` 输出包含 `Server is online:`;如果以 `--clear-database` 启动,则内部 `spacetime publish` 会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 9. 默认执行 `scp -r -i ~\.ssh\dsk.pem build/ ubuntu@82.157.175.59:/home/ubuntu/genarrative/` 上传发布包。 发布包结构: @@ -178,7 +178,7 @@ cd build/ ./stop.sh ``` -如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物,不会删除部署目录中的 `spacetimedb-data/`、`logs/`、`run/` 这类运行态目录。 +如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/` 这类运行态目录。 安全边界: @@ -187,8 +187,10 @@ cd build/ 3. `start.sh` 只解析合法 `KEY=value` 环境行,支持不加引号、双引号和单引号;不执行复杂 shell 表达式,避免把环境文件变成脚本入口。 4. `start.sh` 默认不追加清理参数;只有显式执行 `./start.sh --clear-database` 才追加 `-c=on-conflict`,在 schema 冲突时清理旧模块数据后重发。 5. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm;清库模式下会追加 `-c=on-conflict`,仅在 schema 冲突时删除旧模块数据。 -6. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 -7. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。 +6. `start.sh` 会先复用已经按目标地址就绪的 SpacetimeDB;如果同一个 `.spacetimedb/` root-dir 已被其他未就绪实例占用,则按 dev 脚本逻辑输出占用进程并失败,避免误连错端口。 +7. 如果 `spacetime publish` 报 `403 Forbidden`,优先确认 `spacetime --root-dir ./.spacetimedb login show` 输出的身份是否有权更新目标库;`--clear-database` 不能绕过身份授权。 +8. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。 +9. 如只需要本地生成发布包,可传 `--skip-upload` 跳过默认 scp 上传。 目标服务器最小要求: diff --git a/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md new file mode 100644 index 00000000..e8fdd394 --- /dev/null +++ b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md @@ -0,0 +1,75 @@ +# start.sh 发布 SpacetimeDB 遇到 403 的处理方案 + +日期:`2026-04-26` + +## 1. 问题 + +执行发布包内 `start.sh` 时,`spacetime publish` 可能在 `Checking for breaking changes...` 后失败: + +```text +Error: Pre-publish check failed with status 403 Forbidden: is not authorized to perform action on database : update database +``` + +这不是 wasm 构建失败,也不是 schema 冲突。错误含义是:当前 `spacetime` CLI 使用的身份无权更新目标数据库。 + +## 2. 根因 + +发布包 `start.sh` 会启动本地 SpacetimeDB,再把当前包内的 `spacetime_module.wasm` 发布到 `GENARRATIVE_SPACETIME_DATABASE`。 + +SpacetimeDB 的数据库更新权限绑定到创建或被授权的身份。只要出现以下情况之一,就会触发 403: + +1. 部署机上执行 `start.sh` 的用户切换过 `spacetime login` 身份。 +2. 固定部署目录保留了旧 `.spacetimedb/`,但当前 CLI 身份不是旧数据库创建者。 +3. `GENARRATIVE_SPACETIME_SERVER_URL` 指向 Maincloud,而当前 CLI 身份不是该 Maincloud 数据库的所有者或授权成员。 +4. `.env.local` 中的 `GENARRATIVE_SPACETIME_DATABASE` 指向了另一个环境的数据库名或数据库 identity。 + +## 3. 落地修复 + +发布包生成的 `start.sh` 使用发布目录下的 `.spacetimedb/` 作为 SpacetimeDB root: + +```bash +GENARRATIVE_SPACETIME_ROOT_DIR="${SCRIPT_DIR}/.spacetimedb" +``` + +启动、探活和发布统一使用: + +```bash +spacetime --root-dir="${GENARRATIVE_SPACETIME_ROOT_DIR}" ... +``` + +`spacetime start` 不再额外设置 `--data-dir`,启动前会先执行 Ubuntu 专用 `sync_ubuntu_spacetime_install`,优先从 `/usr/.local/share/spacetime/bin//spacetimedb-cli` 或 `$HOME/.local/share/spacetime/bin//spacetimedb-cli` 同步到 `.spacetimedb/bin/current/spacetimedb-cli`;当前线上 `spacetime` 入口为 `/usr/local/bin/spacetime`。启动参数、探活和 root-dir 占用判定都使用同一个 `.spacetimedb/`。这样可以把发布包与部署机全局 `~/.spacetime` 隔离,避免后续人工 `spacetime login` 影响本地发布包。但如果旧 `.spacetimedb/` 已经由另一个身份创建,仍需要按第 4 节处理。 + +## 4. 排查与处理 + +先在执行 `start.sh` 的同一台机器、同一用户下确认身份: + +```bash +spacetime --root-dir ./.spacetimedb login show +spacetime --root-dir ./.spacetimedb list --server http://127.0.0.1:3101 +``` + +如果目标是本地部署库,且允许清空本地数据: + +```bash +./stop.sh +mv .spacetimedb ".spacetimedb.backup.$(date +%Y%m%d-%H%M%S)" +./start.sh +``` + +如果目标是本地部署库,但必须保留数据: + +1. 不要删除 `.spacetimedb/`。 +2. 找到创建该数据库的 SpacetimeDB 身份。 +3. 用该身份对应的 CLI root 执行发布,或在 SpacetimeDB 侧补授权后再发布。 + +如果目标是 Maincloud: + +1. 执行 `spacetime login show` 确认当前身份。 +2. 确认该身份对 `GENARRATIVE_SPACETIME_DATABASE` 有更新权限。 +3. 如果只是连错库,修正 `.env.local` 中的 `GENARRATIVE_SPACETIME_DATABASE` / `GENARRATIVE_SPACETIME_SERVER_URL`。 + +## 5. 约束 + +1. `--clear-database` 只处理 schema 冲突时的数据清理,不会绕过 SpacetimeDB 身份授权。 +2. 不要通过切回旧 `server-node` 或 PostgreSQL 绕过发布错误。 +3. 前端与 `api-server` 的数据库名必须和 `start.sh` 发布的库名一致,否则后续接口会连到未发布或无权限的库。 diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index cdda41ed..7e60da0a 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -518,11 +518,12 @@ done load_env_file "${SCRIPT_DIR}/.env" load_env_file "${SCRIPT_DIR}/.env.local" -SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}" +SPACETIME_ROOT_DIR="${GENARRATIVE_SPACETIME_ROOT_DIR:-${SCRIPT_DIR}/.spacetimedb}" SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-__GENARRATIVE_DEFAULT_SPACETIME_HOST__}" SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-__GENARRATIVE_DEFAULT_SPACETIME_PORT__}" SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}" SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-__GENARRATIVE_DEFAULT_SPACETIME_DATABASE__}" +SPACETIME_TIMEOUT_SECONDS="${GENARRATIVE_SPACETIME_TIMEOUT_SECONDS:-60}" API_HOST="${GENARRATIVE_API_HOST:-__GENARRATIVE_DEFAULT_API_HOST__}" API_PORT="${GENARRATIVE_API_PORT:-__GENARRATIVE_DEFAULT_API_PORT__}" API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}" @@ -543,12 +544,19 @@ require_command() { } wait_for_spacetime() { - local deadline=$((SECONDS + 60)) + local process_pid="${1:-}" + local deadline=$((SECONDS + SPACETIME_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do - if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then + if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then + echo "[start] SpacetimeDB 进程在就绪前退出。" >&2 + exit 1 + fi + + if is_spacetime_ready; then return fi + sleep 0.5 done @@ -556,6 +564,98 @@ wait_for_spacetime() { exit 1 } +is_spacetime_ready() { + local output + + if ! output="$(spacetime --root-dir="${SPACETIME_ROOT_DIR}" server ping "${SPACETIME_SERVER_URL}" 2>&1)"; then + return 1 + fi + + # SpacetimeDB CLI 2.1.0 在 502 Bad Gateway 时仍可能返回 0,不能只依赖退出码。 + [[ "${output}" == *"Server is online:"* ]] +} + +describe_spacetime_root_owner() { + if command -v ps >/dev/null 2>&1; then + ps -ef 2>/dev/null | grep '[s]pacetime' | grep -F "${SPACETIME_ROOT_DIR}" || true + fi +} + +sync_ubuntu_spacetime_install() { + local root_dir="$1" + local target_cli="${root_dir}/bin/current/spacetimedb-cli" + local spacetime_command="" + local resolved_command="" + local install_dir="" + local root_bin="${root_dir}/bin" + local parent_dir="" + local share_bin_dir="" + local version_dir="" + + if [[ -x "${target_cli}" ]]; then + return + fi + + spacetime_command="$(command -v spacetime || true)" + if [[ -z "${spacetime_command}" ]]; then + echo "[start] 缺少 spacetime 命令,无法同步 SpacetimeDB 安装。" >&2 + exit 1 + fi + + resolved_command="${spacetime_command}" + if command -v readlink >/dev/null 2>&1; then + resolved_command="$(readlink -f "${spacetime_command}" 2>/dev/null || echo "${spacetime_command}")" + fi + + install_dir="$(cd -- "$(dirname -- "${resolved_command}")" && pwd)" + mkdir -p "${root_bin}" + + for share_bin_dir in \ + "/usr/.local/share/spacetime/bin" \ + "${HOME:-}/.local/share/spacetime/bin"; do + if [[ -d "${share_bin_dir}" ]]; then + version_dir="$(find "${share_bin_dir}" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + if [[ -n "${version_dir}" && -x "${version_dir}/spacetimedb-cli" ]]; then + echo "[start] 同步 Ubuntu SpacetimeDB CLI: ${version_dir}/spacetimedb-cli -> ${target_cli}" + mkdir -p "${root_bin}/current" + cp -f "${version_dir}/spacetimedb-cli" "${target_cli}" + chmod +x "${target_cli}" + return + fi + fi + done + + if [[ -d "${install_dir}/bin" ]]; then + echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir}/bin -> ${root_bin}" + cp -a "${install_dir}/bin/." "${root_bin}/" + elif [[ -x "${install_dir}/current/spacetimedb-cli" ]]; then + echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${install_dir} -> ${root_bin}" + cp -a "${install_dir}/." "${root_bin}/" + elif [[ -x "${install_dir}/spacetimedb-cli" ]]; then + echo "[start] 同步 Ubuntu SpacetimeDB CLI: ${install_dir}/spacetimedb-cli -> ${target_cli}" + mkdir -p "${root_bin}/current" + cp -f "${install_dir}/spacetimedb-cli" "${target_cli}" + chmod +x "${target_cli}" + elif [[ -f "${resolved_command}" ]]; then + parent_dir="$(cd -- "${install_dir}/.." && pwd)" + if [[ -d "${parent_dir}/bin" && -x "${parent_dir}/bin/current/spacetimedb-cli" ]]; then + echo "[start] 同步 Ubuntu SpacetimeDB 安装: ${parent_dir}/bin -> ${root_bin}" + cp -a "${parent_dir}/bin/." "${root_bin}/" + else + echo "[start] 同步 Ubuntu SpacetimeDB 命令: ${resolved_command} -> ${target_cli}" + mkdir -p "${root_bin}/current" + cp -f "${resolved_command}" "${target_cli}" + chmod +x "${target_cli}" + fi + fi + + if [[ ! -x "${target_cli}" ]]; then + echo "[start] 同步 SpacetimeDB 安装后仍未找到 ${target_cli}。" >&2 + echo "[start] 请确认 Ubuntu 上的 spacetime 安装目录包含 bin/current/spacetimedb-cli,或提供可执行的 spacetime 命令。" >&2 + exit 1 + fi +} + start_process() { local name="$1" shift @@ -575,16 +675,32 @@ start_process() { require_command node require_command spacetime -mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}" +mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_ROOT_DIR}" +sync_ubuntu_spacetime_install "${SPACETIME_ROOT_DIR}" -start_process spacetimedb \ - spacetime \ - start \ - --data-dir "${SPACETIME_DATA_DIR}" \ - --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \ - --non-interactive +SPACETIME_PID="" +if is_spacetime_ready; then + echo "[start] 复用已运行的 SpacetimeDB: ${SPACETIME_SERVER_URL}" +else + SPACETIME_ROOT_OWNER="$(describe_spacetime_root_owner)" + if [[ -n "${SPACETIME_ROOT_OWNER}" ]]; then + echo "[start] 当前 root-dir 已被其他 SpacetimeDB 实例占用,无法再次启动。" >&2 + echo "[start] 目标地址未就绪: ${SPACETIME_SERVER_URL}" >&2 + echo "[start] 如需复用,请把 GENARRATIVE_SPACETIME_PORT 改为占用实例实际端口;如需重启,请先停止下列进程。" >&2 + echo "${SPACETIME_ROOT_OWNER}" >&2 + exit 1 + fi -wait_for_spacetime + start_process spacetimedb \ + spacetime \ + --root-dir="${SPACETIME_ROOT_DIR}" \ + start \ + --edition standalone \ + --listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" + SPACETIME_PID="$(cat "${PID_DIR}/spacetimedb.pid")" +fi + +wait_for_spacetime "${SPACETIME_PID}" PUBLISH_ARGS=( publish @@ -600,7 +716,15 @@ if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then fi echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}" -spacetime "${PUBLISH_ARGS[@]}" +if ! spacetime --root-dir="${SPACETIME_ROOT_DIR}" "${PUBLISH_ARGS[@]}"; then + echo "[start] SpacetimeDB 发布失败。" >&2 + echo "[start] 如果错误包含 403 Forbidden 或 is not authorized,通常是当前 CLI 身份无权更新目标数据库。" >&2 + echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2 + spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true + echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh。" >&2 + echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2 + exit 1 +fi export GENARRATIVE_API_HOST="${API_HOST}" export GENARRATIVE_API_PORT="${API_PORT}" @@ -707,7 +831,8 @@ cat >"${TARGET_DIR}/README.md" < Date: Mon, 27 Apr 2026 00:09:09 +0800 Subject: [PATCH 08/13] fix: show published big fish works in gallery --- ...NTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md | 14 +- .../src/contracts/bigFishWorkSummary.ts | 1 + server-rs/crates/api-server/src/app.rs | 5 +- server-rs/crates/api-server/src/big_fish.rs | 24 ++ server-rs/crates/module-big-fish/src/lib.rs | 5 + .../shared-contracts/src/big_fish_works.rs | 1 + .../crates/spacetime-client/src/big_fish.rs | 22 +- .../crates/spacetime-client/src/mapper.rs | 1 + .../big_fish_works_list_input_type.rs | 1 + .../spacetime-module/src/big_fish/runtime.rs | 12 +- .../spacetime-module/src/big_fish/session.rs | 6 + .../PlatformEntryFlowShellImpl.tsx | 322 +++++++++++++----- ...gEntryFlowShell.agent.interaction.test.tsx | 209 +++++++++++- .../RpgEntryHomeView.recharge.test.tsx | 14 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 26 +- .../rpg-entry/rpgEntryWorldPresentation.ts | 60 +++- .../big-fish-gallery/bigFishGalleryClient.ts | 29 ++ src/services/big-fish-gallery/index.ts | 4 + src/services/publicWorkCode.ts | 21 ++ 19 files changed, 663 insertions(+), 114 deletions(-) create mode 100644 src/services/big-fish-gallery/bigFishGalleryClient.ts create mode 100644 src/services/big-fish-gallery/index.ts 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 78329042..00377a5b 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 @@ -147,7 +147,7 @@ ## 6. HTTP contract -所有接口挂在 `/api/runtime/big-fish/*`,全部需要 Bearer 鉴权。 +所有接口挂在 `/api/runtime/big-fish/*`。创作、私有作品列表、删除、运行态启动与输入推进需要 Bearer 鉴权;公开广场读取接口不要求登录,只返回已发布作品。 开发态本地链路补充约定: @@ -191,12 +191,24 @@ 2. `GET /api/runtime/big-fish/runs/{runId}` 3. `POST /api/runtime/big-fish/runs/{runId}/input` +运行态启动规则: + +1. 当前用户启动自己未发布草稿时,`session.owner_user_id` 必须等于当前登录用户。 +2. 当前用户启动别人作品时,只允许启动 `stage = published` 的公开作品。 +3. 新建的 `big_fish_runtime_run.owner_user_id` 始终写入当前游玩用户,不能写入作品作者,后续 run 查询与输入推进仍按游玩用户隔离。 + ### 6.3 作品列表 1. `GET /api/runtime/big-fish/works` 开发态 Vite 必须把该同源接口代理到 Rust `api-server`;前端作品页只调用同源 `/api/runtime/big-fish/works`,不得直连 Rust 端口或回退到 `server-node`。 +### 6.4 公开广场 + +1. `GET /api/runtime/big-fish/gallery` + +公开广场只返回 `status = published` 的大鱼吃小鱼作品。响应复用 `BigFishWorksResponse`,每个条目必须包含 `ownerUserId`,供前端生成稳定广场卡片 key 与后续运行态权限判断。发布动作完成后,前端必须同时刷新私有作品列表和公开广场列表,保证发布结果能立即出现在首页与分类页。 + `input` 请求体: ```json diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index d8cd49eb..30ec15f1 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -3,6 +3,7 @@ export type BigFishWorkStatus = 'draft' | 'published'; export interface BigFishWorkSummary { workId: string; sourceSessionId: string; + ownerUserId: string; title: string; subtitle: string; summary: string; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 4323ea3b..89e1035f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -34,8 +34,8 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run, - get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message, - submit_big_fish_input, submit_big_fish_message, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, start_big_fish_run, + stream_big_fish_message, submit_big_fish_input, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -562,6 +562,7 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route("/api/runtime/big-fish/gallery", get(list_big_fish_gallery)) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 300c539a..a8e70277 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -144,6 +144,29 @@ pub async fn get_big_fish_works( )) } +pub async fn list_big_fish_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_big_fish_gallery() + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(map_big_fish_work_summary_response) + .collect(), + }, + )) +} + pub async fn delete_big_fish_work( State(state): State, Path(session_id): Path, @@ -917,6 +940,7 @@ fn map_big_fish_work_summary_response( BigFishWorkSummaryResponse { work_id: item.work_id, source_session_id: item.source_session_id, + owner_user_id: item.owner_user_id, title: item.title, subtitle: item.subtitle, summary: item.summary, diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index c71d8166..1dbb594f 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -257,6 +257,7 @@ pub struct BigFishSessionProcedureResult { pub struct BigFishWorkSummarySnapshot { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, @@ -274,6 +275,7 @@ pub struct BigFishWorkSummarySnapshot { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct BigFishWorksListInput { pub owner_user_id: String, + pub published_only: bool, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -747,6 +749,9 @@ pub fn validate_session_get_input(input: &BigFishSessionGetInput) -> Result<(), } pub fn validate_works_list_input(input: &BigFishWorksListInput) -> Result<(), BigFishFieldError> { + if input.published_only { + return Ok(()); + } if normalize_required_string(&input.owner_user_id).is_none() { return Err(BigFishFieldError::MissingOwnerUserId); } diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index acddc497..1f876bf7 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize}; pub struct BigFishWorkSummaryResponse { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index a7931249..168d9c48 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -57,8 +57,28 @@ impl SpacetimeClient { &self, owner_user_id: String, ) -> Result, SpacetimeClientError> { - let procedure_input = BigFishWorksListInput { owner_user_id }; + let procedure_input = BigFishWorksListInput { + owner_user_id, + published_only: false, + }; + self.list_big_fish_works_with_input(procedure_input).await + } + + pub async fn list_big_fish_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.list_big_fish_works_with_input(BigFishWorksListInput { + owner_user_id: String::new(), + published_only: true, + }) + .await + } + + async fn list_big_fish_works_with_input( + &self, + procedure_input: BigFishWorksListInput, + ) -> Result, SpacetimeClientError> { self.call_after_connect(move |connection, sender| { connection .procedures() diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c0213eb7..fc57f93a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -4590,6 +4590,7 @@ pub struct BigFishSessionRecord { pub struct BigFishWorkSummaryRecord { pub work_id: String, pub source_session_id: String, + pub owner_user_id: String, pub title: String, pub subtitle: String, pub summary: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs index 240ce46a..9b23133a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_works_list_input_type.rs @@ -8,6 +8,7 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[sats(crate = __lib)] pub struct BigFishWorksListInput { pub owner_user_id: String, + pub published_only: bool, } impl __sdk::InModule for BigFishWorksListInput { diff --git a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs index 67b80056..d794b653 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/runtime.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/runtime.rs @@ -77,8 +77,12 @@ fn start_big_fish_run_tx( .big_fish_creation_session() .session_id() .find(&input.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + if session.owner_user_id != input.owner_user_id + && session.stage != BigFishCreationStage::Published + { + return Err("big_fish_creation_session 不存在".to_string()); + } let draft = session .draft_json .as_deref() @@ -124,8 +128,12 @@ fn submit_big_fish_input_tx( .big_fish_creation_session() .session_id() .find(&run.session_id) - .filter(|row| row.owner_user_id == input.owner_user_id) .ok_or_else(|| "big_fish_creation_session 不存在".to_string())?; + if session.owner_user_id != input.owner_user_id + && session.stage != BigFishCreationStage::Published + { + return Err("big_fish_creation_session 不存在".to_string()); + } let draft = session .draft_json .as_deref() diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 50a4c435..81f2c5d8 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -239,6 +239,10 @@ pub(crate) fn list_big_fish_works_tx( .big_fish_creation_session() .iter() .filter(|row| { + if input.published_only { + return row.stage == BigFishCreationStage::Published; + } + row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) }) .map(|row| build_big_fish_work_summary(ctx, &row)) @@ -330,6 +334,7 @@ pub(crate) fn delete_big_fish_work_tx( ctx, BigFishWorksListInput { owner_user_id: input.owner_user_id, + published_only: false, }, ) } @@ -642,6 +647,7 @@ pub(crate) fn build_big_fish_work_summary( Ok(BigFishWorkSummarySnapshot { work_id: format!("big-fish-work-{}", row.session_id), source_session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), title, subtitle, summary, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index acedecd6..a8a812f5 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -48,6 +48,7 @@ import { startBigFishRuntimeRun, submitBigFishRuntimeInput, } from '../../services/big-fish-runtime'; +import { listBigFishGallery } from '../../services/big-fish-gallery'; import { deleteBigFishWork, listBigFishWorks, @@ -64,7 +65,10 @@ import { type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { isSamePuzzlePublicWorkCode } from '../../services/publicWorkCode'; +import { + isSameBigFishPublicWorkCode, + isSamePuzzlePublicWorkCode, +} from '../../services/publicWorkCode'; import { createPuzzleAgentSession, executePuzzleAgentAction, @@ -91,7 +95,9 @@ import { import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { + isBigFishGalleryEntry, isPuzzleGalleryEntry, + mapBigFishWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; @@ -146,7 +152,12 @@ function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { } function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { - return `${isPuzzleGalleryEntry(entry) ? 'puzzle' : 'rpg'}:${entry.ownerUserId}:${entry.profileId}`; + const kind = isBigFishGalleryEntry(entry) + ? 'big-fish' + : isPuzzleGalleryEntry(entry) + ? 'puzzle' + : 'rpg'; + return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } function mergePlatformPublicGalleryEntries( @@ -393,6 +404,9 @@ export function PlatformEntryFlowShellImpl({ const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); const [bigFishWorks, setBigFishWorks] = useState([]); + const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState< + BigFishWorkSummary[] + >([]); const [bigFishRun, setBigFishRun] = useState(null); const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false); @@ -460,6 +474,64 @@ export function PlatformEntryFlowShellImpl({ [], ); + const refreshBigFishShelf = useCallback(async () => { + setIsBigFishLoadingLibrary(true); + + try { + const worksResponse = await listBigFishWorks(); + setBigFishWorks(worksResponse.items); + setBigFishError(null); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'), + ); + } finally { + setIsBigFishLoadingLibrary(false); + } + }, [resolveBigFishErrorMessage]); + + const refreshBigFishGallery = useCallback(async () => { + try { + const galleryResponse = await listBigFishGallery(); + setBigFishGalleryEntries(galleryResponse.items); + return galleryResponse.items; + } catch (error) { + setBigFishGalleryEntries([]); + setBigFishError( + resolveBigFishErrorMessage(error, '读取大鱼吃小鱼广场失败。'), + ); + return []; + } + }, [resolveBigFishErrorMessage]); + + const refreshPuzzleShelf = useCallback(async () => { + setIsPuzzleLoadingLibrary(true); + + try { + const worksResponse = await listPuzzleWorks(); + setPuzzleWorks(worksResponse.items); + setPuzzleError(null); + } catch (error) { + setPuzzleError( + resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'), + ); + } finally { + setIsPuzzleLoadingLibrary(false); + } + }, [resolvePuzzleErrorMessage]); + + const refreshPuzzleGallery = useCallback(async () => { + try { + const galleryResponse = await listPuzzleGallery(); + setPuzzleGalleryEntries(galleryResponse.items); + return galleryResponse.items; + } catch (error) { + setPuzzleGalleryEntries([]); + setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。')); + return []; + } + }, [resolvePuzzleErrorMessage]); + const sessionController = useRpgCreationSessionController({ userId: authUi?.user?.id, openLoginModal: authUi?.openLoginModal, @@ -552,6 +624,7 @@ export function PlatformEntryFlowShellImpl({ await Promise.allSettled([ platformBootstrap.refreshPublishedGallery(), platformBootstrap.refreshCustomWorldWorks(), + refreshBigFishGallery(), refreshPuzzleGallery(), ]); return latestSession; @@ -606,21 +679,35 @@ export function PlatformEntryFlowShellImpl({ }, [agentResultPreview]); const featuredGalleryEntries = useMemo(() => { + const bigFishPublicEntries = bigFishGalleryEntries.map( + mapBigFishWorkToPlatformGalleryCard, + ); const puzzlePublicEntries = puzzleGalleryEntries.map( mapPuzzleWorkToPlatformGalleryCard, ); return mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - puzzlePublicEntries, + [...bigFishPublicEntries, ...puzzlePublicEntries], ).slice(0, 6); - }, [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries]); + }, [ + bigFishGalleryEntries, + platformBootstrap.publishedGalleryEntries, + puzzleGalleryEntries, + ]); const latestGalleryEntries = useMemo( () => mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), + [ + ...bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard), + ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), + ], ), - [platformBootstrap.publishedGalleryEntries, puzzleGalleryEntries], + [ + bigFishGalleryEntries, + platformBootstrap.publishedGalleryEntries, + puzzleGalleryEntries, + ], ); const creationHubItems = @@ -680,50 +767,6 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(true); }, [prepareCreationLaunch]); - const refreshBigFishShelf = useCallback(async () => { - setIsBigFishLoadingLibrary(true); - - try { - const worksResponse = await listBigFishWorks(); - setBigFishWorks(worksResponse.items); - setBigFishError(null); - } catch (error) { - setBigFishError( - resolveBigFishErrorMessage(error, '读取大鱼吃小鱼作品列表失败。'), - ); - } finally { - setIsBigFishLoadingLibrary(false); - } - }, [resolveBigFishErrorMessage]); - - const refreshPuzzleShelf = useCallback(async () => { - setIsPuzzleLoadingLibrary(true); - - try { - const worksResponse = await listPuzzleWorks(); - setPuzzleWorks(worksResponse.items); - setPuzzleError(null); - } catch (error) { - setPuzzleError( - resolvePuzzleErrorMessage(error, '读取拼图作品列表失败。'), - ); - } finally { - setIsPuzzleLoadingLibrary(false); - } - }, [resolvePuzzleErrorMessage]); - - const refreshPuzzleGallery = useCallback(async () => { - try { - const galleryResponse = await listPuzzleGallery(); - setPuzzleGalleryEntries(galleryResponse.items); - return galleryResponse.items; - } catch (error) { - setPuzzleGalleryEntries([]); - setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图广场失败。')); - return []; - } - }, [resolvePuzzleErrorMessage]); - const bigFishFlow = usePlatformCreationAgentFlowController< BigFishSessionSnapshotResponse, Record, @@ -761,6 +804,7 @@ export function PlatformEntryFlowShellImpl({ setSession(response.session); if (payload.action === 'big_fish_publish_game') { void refreshBigFishShelf(); + void refreshBigFishGallery(); } if (payload.action !== 'big_fish_compile_draft') { return; @@ -1081,6 +1125,34 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, ]); + const restartBigFishRun = useCallback(async () => { + const sessionId = bigFishSession?.sessionId ?? bigFishRun?.sessionId; + if (!sessionId || isBigFishBusy) { + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + + try { + const { run } = await startBigFishRuntimeRun(sessionId); + setBigFishRun(run); + setSelectionStage('big-fish-runtime'); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '重新开始大鱼吃小鱼玩法失败。'), + ); + } finally { + setIsBigFishBusy(false); + } + }, [ + bigFishRun?.sessionId, + bigFishSession?.sessionId, + isBigFishBusy, + resolveBigFishErrorMessage, + setSelectionStage, + ]); + const startPuzzleRunFromProfile = useCallback( async (profileId: string) => { if (isPuzzleBusy) { @@ -1390,8 +1462,9 @@ export function PlatformEntryFlowShellImpl({ setBigFishError(null); void deleteBigFishWork(work.sourceSessionId) - .then((response) => { + .then(async (response) => { setBigFishWorks(response.items); + await refreshBigFishGallery().catch(() => []); }) .catch((error) => { setBigFishError( @@ -1403,7 +1476,12 @@ export function PlatformEntryFlowShellImpl({ }); }); }, - [deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction], + [ + deletingCreationWorkId, + refreshBigFishGallery, + resolveBigFishErrorMessage, + runProtectedAction, + ], ); const handleDeletePuzzleWork = useCallback( @@ -1499,6 +1577,33 @@ export function PlatformEntryFlowShellImpl({ [openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError], ); + const startBigFishRunFromWork = useCallback( + async (item: BigFishWorkSummary) => { + const sessionId = item.sourceSessionId?.trim(); + if (!sessionId) { + setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); + return; + } + + setIsBigFishBusy(true); + setBigFishError(null); + + try { + const { run } = await startBigFishRuntimeRun(sessionId); + bigFishFlow.setSession(null); + setBigFishRun(run); + setSelectionStage('big-fish-runtime'); + } catch (error) { + setBigFishError( + resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'), + ); + } finally { + setIsBigFishBusy(false); + } + }, + [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], + ); + const handlePublicCodeSearch = useCallback( async (keyword: string) => { const normalizedKeyword = keyword.trim(); @@ -1514,15 +1619,19 @@ export function PlatformEntryFlowShellImpl({ const shouldSearchUserIdFirst = /^user[_-][a-z0-9_-]+$/iu.test( normalizedKeyword, ); + const shouldSearchBigFishFirst = upperKeyword.startsWith('BF'); const shouldSearchPuzzleFirst = upperKeyword.startsWith('PZ'); const shouldSearchWorkFirst = !shouldSearchUserIdFirst && + !shouldSearchBigFishFirst && !shouldSearchPuzzleFirst && (upperKeyword.startsWith('CW') || /^\d{1,8}$/u.test(normalizedKeyword)); const shouldSearchUserFirst = shouldSearchUserIdFirst || upperKeyword.startsWith('SY') || - (!shouldSearchWorkFirst && !shouldSearchPuzzleFirst); + (!shouldSearchWorkFirst && + !shouldSearchBigFishFirst && + !shouldSearchPuzzleFirst); const tryOpenGalleryEntry = async () => { const entry = @@ -1562,6 +1671,21 @@ export function PlatformEntryFlowShellImpl({ tab: platformBootstrap.platformTab, }); }; + const tryOpenBigFishGalleryEntry = async () => { + const entries = + bigFishGalleryEntries.length > 0 + ? bigFishGalleryEntries + : await refreshBigFishGallery(); + const matchedEntry = entries.find((entry) => + isSameBigFishPublicWorkCode(normalizedKeyword, entry.sourceSessionId), + ); + + if (!matchedEntry) { + throw new Error('未找到大鱼吃小鱼作品。'); + } + + await startBigFishRunFromWork(matchedEntry); + }; try { if (shouldSearchUserIdFirst) { @@ -1575,6 +1699,11 @@ export function PlatformEntryFlowShellImpl({ return; } + if (shouldSearchBigFishFirst) { + await tryOpenBigFishGalleryEntry(); + return; + } + if (shouldSearchWorkFirst) { try { await tryOpenGalleryEntry(); @@ -1611,10 +1740,13 @@ export function PlatformEntryFlowShellImpl({ }, [ detailNavigation, + bigFishGalleryEntries, openPuzzleDetail, platformBootstrap.platformTab, puzzleGalleryEntries, + refreshBigFishGallery, refreshPuzzleGallery, + startBigFishRunFromWork, ], ); @@ -1631,39 +1763,12 @@ export function PlatformEntryFlowShellImpl({ [bigFishFlow, refreshBigFishShelf], ); - const startBigFishRunFromWork = useCallback( - async (item: BigFishWorkSummary) => { - const sessionId = item.sourceSessionId?.trim(); - if (!sessionId) { - setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } - - setIsBigFishBusy(true); - setBigFishError(null); - - try { - const { session } = await getBigFishCreationSession(sessionId); - const { run } = await startBigFishRuntimeRun(sessionId); - bigFishFlow.setSession(session); - setBigFishRun(run); - setSelectionStage('big-fish-runtime'); - } catch (error) { - setBigFishError( - resolveBigFishErrorMessage(error, '启动大鱼吃小鱼玩法失败。'), - ); - } finally { - setIsBigFishBusy(false); - } - }, - [bigFishFlow, resolveBigFishErrorMessage, setSelectionStage], - ); - useEffect(() => { if (selectionStage === 'platform') { + void refreshBigFishGallery(); void refreshPuzzleGallery(); } - }, [refreshPuzzleGallery, selectionStage]); + }, [refreshBigFishGallery, refreshPuzzleGallery, selectionStage]); useEffect(() => { if ( @@ -1836,6 +1941,33 @@ export function PlatformEntryFlowShellImpl({ onOpenCreateWorld={openCreationTypePicker} onOpenCreateTypePicker={openCreationTypePicker} onOpenGalleryDetail={(entry) => { + if (isBigFishGalleryEntry(entry)) { + runProtectedAction(() => { + void startBigFishRunFromWork({ + workId: entry.workId, + sourceSessionId: entry.profileId, + ownerUserId: entry.ownerUserId, + title: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + status: 'published', + updatedAt: entry.updatedAt, + publishReady: true, + levelCount: Number.parseInt( + entry.themeTags + .find((tag) => /^\d+级$/u.test(tag)) + ?.replace('级', '') ?? '0', + 10, + ), + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: Boolean(entry.coverImageSrc), + }); + }); + return; + } + if (isPuzzleGalleryEntry(entry)) { void openPuzzleDetail(entry.profileId, { tab: platformBootstrap.platformTab, @@ -2109,10 +2241,12 @@ export function PlatformEntryFlowShellImpl({ isBusy={isBigFishBusy} error={bigFishError} onBack={() => { - setSelectionStage('big-fish-result'); + setSelectionStage( + bigFishSession ? 'big-fish-result' : 'platform', + ); }} onRestart={() => { - void startBigFishRun(); + void restartBigFishRun(); }} onSubmitInput={submitBigFishInput} /> @@ -2128,7 +2262,9 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - }> + } + > - }> + } + > - }> + } + > { - void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); + void startPuzzleRunFromProfile( + selectedPuzzleDetail.profileId, + ); }} /> @@ -2264,7 +2406,9 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0 }} className="fixed inset-0 z-[100]" > - }> + } + > ({ listBigFishWorks: vi.fn(), })); +vi.mock('../../services/big-fish-gallery', () => ({ + listBigFishGallery: vi.fn(), +})); + +vi.mock('../../services/big-fish-runtime', () => ({ + startBigFishRuntimeRun: vi.fn(), + submitBigFishRuntimeInput: vi.fn(), +})); + vi.mock('../../services/puzzle-agent', () => ({ createPuzzleAgentSession: vi.fn(), executePuzzleAgentAction: vi.fn(), @@ -152,6 +164,69 @@ vi.mock('../../services/puzzle-agent', () => ({ streamPuzzleAgentMessage: vi.fn(), })); +vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({ + PuzzleAgentWorkspace: ({ + session, + onBack, + }: { + session: { sessionId: string; messages: Array<{ text: string }> } | null; + onBack: () => void; + }) => ( +
+
拼图工作区:{session?.sessionId ?? 'missing-session'}
+ {session?.messages.map((message) => ( +
{message.text}
+ ))} + +
+ ), +})); + +vi.mock('../puzzle-result/PuzzleResultView', () => ({ + PuzzleResultView: ({ + session, + onBack, + }: { + session: { draft?: { levelName: string } | null }; + onBack: () => void; + }) => ( +
+
拼图结果页
+ + +
+ ), +})); + +vi.mock('../puzzle-gallery/PuzzleGalleryDetailView', () => ({ + PuzzleGalleryDetailView: ({ + item, + onBack, + onStartGame, + }: { + item: { levelName: string }; + onBack: () => void; + onStartGame: () => void; + }) => ( +
+
{item.levelName}
+ + +
+ ), +})); + vi.mock('../big-fish-creation/BigFishAgentWorkspace', () => ({ BigFishAgentWorkspace: ({ session, @@ -232,8 +307,7 @@ const mockSession: CustomWorldAgentSessionSnapshot = { '玩家以返乡守灯人继承者身份切入,回港首夜撞见禁航区假航灯重亮,动机是阻止更多船只误入死潮。', coreConflict: '守灯会与航运公会争夺航路解释权,有人在借假航灯持续清洗旧案证据,玩家返乡当夜就被卷进封航冲突。', - keyRelationships: - '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。', + keyRelationships: '玩家与沈砺旧友互疑,沈砺知道沉船夜的另一半真相。', hiddenLines: '沉船夜与假航灯骗局属于同一操盘链条,表面像海雾自然失控,揭示节奏是先见异常,再见旧案,再见操盘者。', iconicElements: @@ -956,6 +1030,34 @@ beforeEach(() => { vi.mocked(listBigFishWorks).mockResolvedValue({ items: [], }); + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [], + }); + vi.mocked(startBigFishRuntimeRun).mockResolvedValue({ + run: { + runId: 'big-fish-run-1', + sessionId: 'big-fish-session-public-1', + status: 'running', + tick: 0, + playerLevel: 1, + winLevel: 8, + leaderEntityId: 'owned-1', + ownedEntities: [ + { + entityId: 'owned-1', + level: 1, + position: { x: 0, y: 0 }, + radius: 12, + offscreenSeconds: 0, + }, + ], + wildEntities: [], + cameraCenter: { x: 0, y: 0 }, + lastInput: { x: 0, y: 0 }, + eventLog: ['机械鱼群开始巡游。'], + updatedAt: '2026-04-25T12:12:00.000Z', + }, + }); vi.mocked(listPuzzleWorks).mockResolvedValue({ items: [], }); @@ -1329,6 +1431,7 @@ test('creation hub clears all private work shelves immediately after logout stat { workId: 'big-fish-logout-cache-1', sourceSessionId: 'big-fish-logout-cache-session', + ownerUserId: 'user-1', title: '大鱼退出缓存作品', subtitle: '登出后不应继续可见', summary: '这条大鱼私有作品只能在登录态展示。', @@ -1427,6 +1530,48 @@ test('published puzzle works appear on home and category public shelves', async ).toBeGreaterThan(0); }); +test('published big fish works appear on home and category public shelves', async () => { + const user = userEvent.setup(); + const publishedBigFishWork: BigFishWorkSummary = { + workId: 'big-fish-work-public-1', + sourceSessionId: 'big-fish-session-public-1', + ownerUserId: 'user-2', + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化', + summary: '从微光孢子一路吞并成长到深海巨鲲。', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-04-25T10:30:00.000Z', + publishReady: true, + levelCount: 8, + levelMainImageReadyCount: 8, + levelMotionReadyCount: 16, + backgroundReady: true, + }; + + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [publishedBigFishWork], + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('机械深海 大鱼吃小鱼').length).toBeGreaterThan( + 0, + ); + }); + + await user.click(screen.getByRole('button', { name: '分类' })); + + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('机械深海 大鱼吃小鱼').length, + ).toBeGreaterThan(0); + expect( + within(categoryPanel).getAllByRole('button', { name: /大鱼/u }).length, + ).toBeGreaterThan(0); +}); + test('published puzzle detail returns to the source platform tab', async () => { const user = userEvent.setup(); const publishedPuzzleWork = { @@ -1797,8 +1942,9 @@ test('public code search opens a published puzzle by PZ code', async () => { render(); - const searchInput = - await screen.findByPlaceholderText('输入 SY / CW / PZ 编号'); + const searchInput = await screen.findByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); await user.type(searchInput, 'PZ-EPUBLIC1'); await user.click(screen.getByRole('button', { name: '搜索' })); @@ -1812,6 +1958,49 @@ test('public code search opens a published puzzle by PZ code', async () => { expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); +test('public code search opens a published big fish work by BF code', async () => { + const user = userEvent.setup(); + const bigFishWork: BigFishWorkSummary = { + workId: 'big-fish-work-public-1', + sourceSessionId: 'big-fish-session-public-1', + ownerUserId: 'user-2', + title: '机械深海 大鱼吃小鱼', + subtitle: '机械微生物吞并进化', + summary: '从微光孢子一路吞并成长到深海巨鲲。', + coverImageSrc: null, + status: 'published', + updatedAt: '2026-04-25T10:30:00.000Z', + publishReady: true, + levelCount: 8, + levelMainImageReadyCount: 8, + levelMotionReadyCount: 16, + backgroundReady: true, + }; + + vi.mocked(listBigFishGallery).mockResolvedValue({ + items: [bigFishWork], + }); + + render(); + + const searchInput = await screen.findByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); + await user.type(searchInput, 'BF-NPUBLIC1'); + await user.click(screen.getByRole('button', { name: '搜索' })); + + await waitFor(() => { + expect(startBigFishRuntimeRun).toHaveBeenCalledWith( + 'big-fish-session-public-1', + ); + }); + expect(await screen.findByText('Lv.1/8 · 进行中')).toBeTruthy(); + expect(getBigFishCreationSession).not.toHaveBeenCalledWith( + 'big-fish-session-public-1', + ); + expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); +}); + test('big fish draft card restores the bound agent session and opens the result view', async () => { const user = userEvent.setup(); @@ -1820,6 +2009,7 @@ test('big fish draft card restores the bound agent session and opens the result { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1867,10 +2057,12 @@ test('big fish draft card restores the bound agent session and opens the result test('big fish result publish action refreshes creation works', async () => { const user = userEvent.setup(); - const baseBigFishSession = (await getBigFishCreationSession('big-fish-session-1')) - .session; + const baseBigFishSession = ( + await getBigFishCreationSession('big-fish-session-1') + ).session; vi.mocked(getBigFishCreationSession).mockClear(); vi.mocked(listBigFishWorks).mockClear(); + vi.mocked(listBigFishGallery).mockClear(); const publishedBigFishSession = { ...baseBigFishSession, stage: 'published', @@ -1894,6 +2086,7 @@ test('big fish result publish action refreshes creation works', async () => { { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1913,6 +2106,7 @@ test('big fish result publish action refreshes creation works', async () => { { workId: 'big-fish-work-big-fish-session-1', sourceSessionId: 'big-fish-session-1', + ownerUserId: 'user-1', title: '机械深海 大鱼吃小鱼', subtitle: '机械微生物吞并进化 · 偏爽快节奏', summary: '机械微生物吞并进化', @@ -1959,6 +2153,9 @@ test('big fish result publish action refreshes creation works', async () => { await waitFor(() => { expect(listBigFishWorks).toHaveBeenCalled(); }); + await waitFor(() => { + expect(listBigFishGallery).toHaveBeenCalled(); + }); }); test('starting draft generation leaves the agent workspace and shows the generation progress view', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index dad02e7c..7e3204f8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -5,7 +5,10 @@ import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; import { AuthUiContext } from '../auth/AuthUiContext'; -import { RpgEntryHomeView, type RpgEntryHomeViewProps } from './RpgEntryHomeView'; +import { + RpgEntryHomeView, + type RpgEntryHomeViewProps, +} from './RpgEntryHomeView'; import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation'; vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({ @@ -343,7 +346,9 @@ test('mobile home search submits public work code', async () => { , ); - const searchInput = screen.getByPlaceholderText('输入 SY / CW / PZ 编号'); + const searchInput = screen.getByPlaceholderText( + '输入 SY / CW / BF / PZ 编号', + ); await user.type(searchInput, 'PZ-PROFILE1{enter}'); expect(onSearchPublicCode).toHaveBeenCalledWith('PZ-PROFILE1'); @@ -359,8 +364,9 @@ test('public gallery cards hide work code until detail is opened', async () => { }); expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull(); - expect(screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' })) - .toBeNull(); + expect( + screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }), + ).toBeNull(); await user.click(screen.getByRole('button', { name: /查看作品/u })); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 6a6ff596..5b9d370c 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -56,6 +56,7 @@ import { buildPlatformWorldTags, describePlatformThemeLabel, formatPlatformWorldTime, + isBigFishGalleryEntry, isPuzzleGalleryEntry, type PlatformPublicGalleryCard, type PlatformWorldCardLike, @@ -223,7 +224,7 @@ function PublicCodeSearchBar({ onSubmit(); } }} - placeholder="输入 SY / CW / PZ 编号" + placeholder="输入 SY / CW / BF / PZ 编号" className="w-full min-w-0 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]" />