From 45898cba4eed69e8b4a50009646436b2b8b192b7 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 15:35:40 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...D_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md | 41 +++++++++++++++++++ docs/technical/README.md | 1 + vite.config.ts | 10 +++++ 3 files changed, 52 insertions(+) create mode 100644 docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md diff --git a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md new file mode 100644 index 00000000..4178d9d2 --- /dev/null +++ b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md @@ -0,0 +1,41 @@ +# 前端首次加载慢修复记录 + +日期:`2026-04-26` + +## 1. 背景 + +网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏可见链路。 + +## 2. 现象与根因 + +本次排查发现两个会放大首屏等待的前端问题: + +1. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `` 和 CSS 图片,等全部图片 settled 后才显示页面。平台首页和运行时页面会渲染作品封面、角色图、图标和生成资源,任何慢图片或后端图片代理等待都会把整页可见时间拖长。 +2. Vite dev server 监听范围过宽,日志中可见 `docs/`、`scripts/`、`server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加首屏图片门控后表现为“首次加载一直等”。 + +## 3. 修复口径 + +### 3.1 首屏图片门控 + +首屏门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”: + +- 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。 +- 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。 +- 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。 +- 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。 + +### 3.2 Vite 监听范围 + +Vite dev server 只对前端真实运行入口保持热更新敏感: + +- 忽略 `docs/`、`server-rs/`、`scripts/`、`backend-rewrite-tasklist/`、`media/` 等非前端首屏运行目录。 +- 忽略 `*.test.ts(x)` / `*.spec.ts(x)`,避免测试文件保存触发页面 reload。 +- 保留 `src/` 与 `packages/shared/` 的正常变更反馈,因为它们仍是前端运行时依赖。 + +## 4. 验收标准 + +1. Vite ready 后,默认站点首屏不再等待所有图片完成才显示。 +2. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 +3. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 +4. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 +5. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 03f2ce4a..72c30d36 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。 +- [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。 - [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 Maincloud schema。 - [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。 - [RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md](./RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md):记录世界草稿生成失败/中断后进度不再误到 `100%`、主按钮改为“继续生成草稿”并复用已保存底稿续跑,以及按阶段耗时模型估算预计等待时间的修复口径。 diff --git a/vite.config.ts b/vite.config.ts index 1dea3aab..a51976db 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,6 +16,16 @@ export default defineConfig(({mode}) => { '**/public/generated-characters/**', '**/public/generated-custom-world-scenes/**', '**/public/generated-qwen-sprites/**', + '**/backend-rewrite-tasklist/**', + '**/docs/**', + '**/jenkins/**', + '**/media/**', + '**/scripts/**', + '**/server-rs/**', + '**/*.test.ts', + '**/*.test.tsx', + '**/*.spec.ts', + '**/*.spec.tsx', ]; const rustServerTarget = env.RUST_SERVER_TARGET || From d56031cf4af68b5fbbcd48bd621f5f51bd404d8a Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 16:05:37 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=20tsx=20=E5=86=B7=E5=8A=A0=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...D_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md | 36 +- src/App.tsx | 4 +- .../PlatformEntryFlowShellImpl.tsx | 378 ++++++++++-------- .../RpgCreationEntityEditorShared.tsx | 4 +- .../rpg-entry/RpgEntryFlowShell.tsx | 2 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 29 +- .../RpgRuntimePanelRouter.tsx | 8 +- .../rpg-runtime-shell/RpgRuntimeShell.tsx | 98 +++-- .../RpgRuntimeStageRouter.tsx | 10 +- src/components/rpg-runtime-shell/types.ts | 2 +- .../useRpgRuntimeOverlayState.ts | 2 +- src/hooks/rpg-session/useRpgRuntimeSession.ts | 2 +- src/services/platform-entry/index.ts | 6 +- .../platform-entry/platformProfileClient.ts | 5 + 14 files changed, 334 insertions(+), 252 deletions(-) create mode 100644 src/services/platform-entry/platformProfileClient.ts diff --git a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md index 4178d9d2..04ab6cbd 100644 --- a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md +++ b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md @@ -4,27 +4,38 @@ ## 1. 背景 -网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏可见链路。 +网站启动后首次打开页面约需三分钟才出现可用界面。已确认 Vite dev server 本身可在数秒内 ready,浏览器 Network 面板中主要等待项集中在 `.tsx` 模块请求,因此本次不继续扩大 `api-server` 冷编译等待窗口,而是收口浏览器首屏 `.tsx` 冷转译与默认路由依赖图。 ## 2. 现象与根因 -本次排查发现两个会放大首屏等待的前端问题: +本次排查发现三个会放大首屏等待的前端问题: -1. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `` 和 CSS 图片,等全部图片 settled 后才显示页面。平台首页和运行时页面会渲染作品封面、角色图、图标和生成资源,任何慢图片或后端图片代理等待都会把整页可见时间拖长。 -2. Vite dev server 监听范围过宽,日志中可见 `docs/`、`scripts/`、`server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加首屏图片门控后表现为“首次加载一直等”。 +1. 默认路由进入 `AuthenticatedApp -> App -> RpgRuntimeShell -> PlatformEntryFlowShellImpl`,首屏虽然只显示平台首页,但入口文件静态导入了创作中心、拼图 Agent、拼图结果页、拼图运行态等非首屏阶段组件。Vite dev 首次访问时需要逐个请求并转译这些 `.tsx`,表现为浏览器长时间卡在加载 `.tsx`。 +2. `RouteImageReadyGate` 会先挂载真实业务页面但把整页 `visibility: hidden`,扫描路由 DOM 中所有 `` 和 CSS 图片,等全部图片 settled 后才显示页面。图片不是本轮确认到的主等待项,但会放大 `.tsx` 冷转译后的可见延迟。 +3. Vite dev server 监听范围过宽,日志中可见 `docs/`、`scripts/`、`server-rs/` 和测试文件变更都会触发 `page reload`。后端编译、文档更新或测试文件保存会让浏览器反复全量重载,叠加 `.tsx` 冷转译后表现为“首次加载一直等”。 ## 3. 修复口径 -### 3.1 首屏图片门控 +### 3.1 首屏 `.tsx` 冷转译 -首屏门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”: +默认首页入口先做低风险依赖图收敛: + +- `App`、运行时阶段路由、面板路由避免从 barrel 文件导入,改为直连具体实现文件或类型文件。 +- `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`。 +- 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab,避免隐藏的创作页提前触发创作中心等懒加载模块。 +- RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。 +- 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。 + +### 3.2 首屏图片门控 + +图片门控从“等待所有图片加载完成”改为“短暂稳态等待后放行”: - 页面仍先真实挂载,保留极短等待窗口,避免首帧布局剧烈闪动。 - 达到最大阻塞时间后必须显示页面,慢图片由浏览器渐进加载,不再隐藏整页。 - 页面已经显示后,不再因为新增图片或图片地址变化重新隐藏页面。 - 图片预加载继续保留,用于提前触发浏览器缓存,但不得成为首屏可见的硬阻塞。 -### 3.2 Vite 监听范围 +### 3.3 Vite 监听范围 Vite dev server 只对前端真实运行入口保持热更新敏感: @@ -34,8 +45,9 @@ Vite dev server 只对前端真实运行入口保持热更新敏感: ## 4. 验收标准 -1. Vite ready 后,默认站点首屏不再等待所有图片完成才显示。 -2. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 -3. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 -4. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 -5. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 +1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件。 +2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk。 +3. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 +4. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 +5. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 +6. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 diff --git a/src/App.tsx b/src/App.tsx index dc7ec6de..89a89463 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ -import { RpgRuntimeShell } from './components/rpg-runtime-shell'; -import { useRpgRuntimeSession } from './hooks/rpg-session'; +import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell'; +import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession'; export default function App() { const gameShellProps = useRpgRuntimeSession(); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1fd9925b..1553cd4d 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -60,7 +60,7 @@ import { createMiniGameDraftGenerationState, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; -import { getPlatformProfileDashboard } from '../../services/platform-entry'; +import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { createPuzzleAgentSession, executePuzzleAgentAction, @@ -81,15 +81,12 @@ 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 { deleteRpgEntryWorldProfile } from '../../services/rpg-entry'; -import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; +import { + deleteRpgEntryWorldProfile, + getRpgEntryWorldGalleryDetailByCode, +} from '../../services/rpg-entry/rpgEntryLibraryClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; -import { CustomWorldCreationHub } from '../custom-world-home/CustomWorldCreationHub'; -import { PuzzleAgentWorkspace } from '../puzzle-agent/PuzzleAgentWorkspace'; -import { PuzzleGalleryDetailView } from '../puzzle-gallery/PuzzleGalleryDetailView'; -import { PuzzleResultView } from '../puzzle-result/PuzzleResultView'; -import { PuzzleRuntimeShell } from '../puzzle-runtime/PuzzleRuntimeShell'; import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; @@ -332,6 +329,41 @@ const BigFishRuntimeShell = lazy(async () => { }; }); +const CustomWorldCreationHub = lazy(async () => { + const module = await import('../custom-world-home/CustomWorldCreationHub'); + return { + default: module.CustomWorldCreationHub, + }; +}); + +const PuzzleAgentWorkspace = lazy(async () => { + const module = await import('../puzzle-agent/PuzzleAgentWorkspace'); + return { + default: module.PuzzleAgentWorkspace, + }; +}); + +const PuzzleResultView = lazy(async () => { + const module = await import('../puzzle-result/PuzzleResultView'); + return { + default: module.PuzzleResultView, + }; +}); + +const PuzzleGalleryDetailView = lazy(async () => { + const module = await import('../puzzle-gallery/PuzzleGalleryDetailView'); + return { + default: module.PuzzleGalleryDetailView, + }; +}); + +const PuzzleRuntimeShell = lazy(async () => { + const module = await import('../puzzle-runtime/PuzzleRuntimeShell'); + return { + default: module.PuzzleRuntimeShell, + }; +}); + function LazyPanelFallback({ label }: { label: string }) { return (
@@ -1647,97 +1679,99 @@ export function PlatformEntryFlowShellImpl({ ]); const creationHubContent = ( - { - platformBootstrap.setPlatformError(null); - setBigFishError(null); - setPuzzleError(null); - void platformBootstrap.refreshCustomWorldWorks().catch((error) => { - platformBootstrap.setPlatformError( - resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), - ); - }); - void refreshBigFishShelf(); - void refreshPuzzleShelf(); - }} - createError={ - sessionController.creationTypeError ?? bigFishError ?? puzzleError - } - createBusy={ - sessionController.isCreatingAgentSession || - isBigFishBusy || - isPuzzleBusy - } - onCreateType={handleCreationHubCreateType} - onOpenDraft={(item) => { - runProtectedAction(() => { - void detailNavigation.handleOpenCreationWork(item); - }); - }} - onEnterPublished={(profileId) => { - runProtectedAction(() => { - const matchedWork = creationHubItems.find( - (entry) => entry.profileId === profileId, - ); - if (!matchedWork) { - return; - } - void detailNavigation.handleOpenCreationWork(matchedWork); - }); - }} - onDeletePublished={(item) => { - handleDeletePublishedWork(item); - }} - deletingWorkId={deletingCreationWorkId} - onExperienceRpg={(item) => { - handleExperienceRpgWork(item); - }} - rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} - bigFishItems={bigFishWorks} - onOpenBigFishDetail={(item) => { - runProtectedAction(() => { - void openBigFishDraft(item); - }); - }} - onExperienceBigFish={(item) => { - runProtectedAction(() => { - void startBigFishRunFromWork(item); - }); - }} - onDeleteBigFish={(item) => { - handleDeleteBigFishWork(item); - }} - puzzleItems={puzzleWorks} - onOpenPuzzleDetail={(item) => { - runProtectedAction(() => { - void openPuzzleDraft(item); - }); - }} - onExperiencePuzzle={(profileId) => { - runProtectedAction(() => { - void startPuzzleRunFromProfile(profileId); - }); - }} - onDeletePuzzle={(item) => { - handleDeletePuzzleWork(item); - }} - /> + }> + { + platformBootstrap.setPlatformError(null); + setBigFishError(null); + setPuzzleError(null); + void platformBootstrap.refreshCustomWorldWorks().catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '读取创作作品列表失败。'), + ); + }); + void refreshBigFishShelf(); + void refreshPuzzleShelf(); + }} + createError={ + sessionController.creationTypeError ?? bigFishError ?? puzzleError + } + createBusy={ + sessionController.isCreatingAgentSession || + isBigFishBusy || + isPuzzleBusy + } + onCreateType={handleCreationHubCreateType} + onOpenDraft={(item) => { + runProtectedAction(() => { + void detailNavigation.handleOpenCreationWork(item); + }); + }} + onEnterPublished={(profileId) => { + runProtectedAction(() => { + const matchedWork = creationHubItems.find( + (entry) => entry.profileId === profileId, + ); + if (!matchedWork) { + return; + } + void detailNavigation.handleOpenCreationWork(matchedWork); + }); + }} + onDeletePublished={(item) => { + handleDeletePublishedWork(item); + }} + deletingWorkId={deletingCreationWorkId} + onExperienceRpg={(item) => { + handleExperienceRpgWork(item); + }} + rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries} + bigFishItems={bigFishWorks} + onOpenBigFishDetail={(item) => { + runProtectedAction(() => { + void openBigFishDraft(item); + }); + }} + onExperienceBigFish={(item) => { + runProtectedAction(() => { + void startBigFishRunFromWork(item); + }); + }} + onDeleteBigFish={(item) => { + handleDeleteBigFishWork(item); + }} + puzzleItems={puzzleWorks} + onOpenPuzzleDetail={(item) => { + runProtectedAction(() => { + void openPuzzleDraft(item); + }); + }} + onExperiencePuzzle={(profileId) => { + runProtectedAction(() => { + void startPuzzleRunFromProfile(profileId); + }); + }} + onDeletePuzzle={(item) => { + handleDeletePuzzleWork(item); + }} + /> + ); return ( @@ -2074,21 +2108,23 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - void submitPuzzleMessage(payload); - }} - onExecuteAction={(payload) => { - void executePuzzleAction(payload); - }} - /> + }> + { + void submitPuzzleMessage(payload); + }} + onExecuteAction={(payload) => { + void executePuzzleAction(payload); + }} + /> + )} @@ -2145,18 +2181,20 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - setSelectionStage('puzzle-agent-workspace'); - }} - onExecuteAction={(payload) => { - void executePuzzleAction(payload); - }} - /> + }> + { + setSelectionStage('puzzle-agent-workspace'); + }} + onExecuteAction={(payload) => { + void executePuzzleAction(payload); + }} + /> + )} @@ -2168,31 +2206,33 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0, y: -12 }} className="flex h-full min-h-0 flex-col" > - { - platformBootstrap.setPlatformTab( - puzzleDetailReturnTarget?.tab ?? 'home', - ); - setPuzzleDetailReturnTarget(null); - setSelectionStage('platform'); - }} - onEdit={ - selectedPuzzleDetail.ownerUserId === authUi?.user?.id && - Boolean(selectedPuzzleDetail.sourceSessionId?.trim()) - ? () => { - runProtectedAction(() => { - void openPuzzleDraft(selectedPuzzleDetail); - }); - } - : null - } - onStartGame={() => { - void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); - }} - /> + }> + { + platformBootstrap.setPlatformTab( + puzzleDetailReturnTarget?.tab ?? 'home', + ); + setPuzzleDetailReturnTarget(null); + setSelectionStage('platform'); + }} + onEdit={ + selectedPuzzleDetail.ownerUserId === authUi?.user?.id && + Boolean(selectedPuzzleDetail.sourceSessionId?.trim()) + ? () => { + runProtectedAction(() => { + void openPuzzleDraft(selectedPuzzleDetail); + }); + } + : null + } + onStartGame={() => { + void startPuzzleRunFromProfile(selectedPuzzleDetail.profileId); + }} + /> + )} @@ -2204,23 +2244,25 @@ export function PlatformEntryFlowShellImpl({ exit={{ opacity: 0 }} className="fixed inset-0 z-[100]" > - { - setSelectionStage('puzzle-gallery-detail'); - }} - onSwapPieces={(payload) => { - void swapPuzzlePiecesInRun(payload); - }} - onDragPiece={(payload) => { - void dragPuzzlePiece(payload); - }} - onAdvanceNextLevel={() => { - void advancePuzzleLevel(); - }} - /> + }> + { + setSelectionStage('puzzle-gallery-detail'); + }} + onSwapPieces={(payload) => { + void swapPuzzlePiecesInRun(payload); + }} + onDragPiece={(payload) => { + void dragPuzzlePiece(payload); + }} + onAdvanceNextLevel={() => { + void advancePuzzleLevel(); + }} + /> + {isPuzzleNextLevelGenerating ? (
diff --git a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx index ba3da019..a606b42f 100644 --- a/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx +++ b/src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx @@ -23,8 +23,8 @@ import { EDITOR_ITEM_CATALOG_API_PATH } from '../../editor/shared/editorApiClien import { fetchJson } from '../../editor/shared/jsonClient'; import { useCombatFlow } from '../../hooks/useCombatFlow'; import { useNpcInteractionFlow } from '../../hooks/useNpcInteractionFlow'; -import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story'; -import { useRpgSessionBootstrap } from '../../hooks/rpg-session'; +import { useRpgRuntimeStory } from '../../hooks/rpg-runtime-story/useRpgRuntimeStory'; +import { useRpgSessionBootstrap } from '../../hooks/rpg-session/useRpgSessionBootstrap'; import { buildSkillActionPrompt } from '../../prompts/customWorldEntityActionPrompts'; import type { CustomWorldSceneImageResult } from '../../services/aiTypes'; import { resolveCustomWorldCampScene } from '../../services/customWorldCamp'; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.tsx b/src/components/rpg-entry/RpgEntryFlowShell.tsx index bb4b82a1..0d22f677 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.tsx @@ -1,4 +1,4 @@ -import { PlatformEntryFlowShell } from '../platform-entry'; +import { PlatformEntryFlowShell } from '../platform-entry/PlatformEntryFlowShell'; import type { RpgEntryFlowShellProps } from './rpgEntryTypes'; import type { SelectionStage } from './rpgEntryTypes'; diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b55a0e50..d8335911 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1110,6 +1110,9 @@ export function RpgEntryHomeView({ const [selectedCategoryTag, setSelectedCategoryTag] = useState( null, ); + const [visitedTabs, setVisitedTabs] = useState>( + () => new Set([activeTab]), + ); const isAuthenticated = Boolean(authUi?.user); const isDesktopLayout = usePlatformDesktopLayout(); const featuredShelf = useMemo( @@ -1159,6 +1162,18 @@ export function RpgEntryHomeView({ } }, [activeTab, onTabChange, visibleTabs]); + useEffect(() => { + setVisitedTabs((currentTabs) => { + if (currentTabs.has(activeTab)) { + return currentTabs; + } + + const nextTabs = new Set(currentTabs); + nextTabs.add(activeTab); + return nextTabs; + }); + }, [activeTab]); + useEffect(() => { if (categoryGroups.length === 0) { setSelectedCategoryTag(null); @@ -1950,11 +1965,15 @@ export function RpgEntryHomeView({ } satisfies Record; const tabPanels = PLATFORM_HOME_TABS.filter((tab) => visibleTabs.includes(tab), - ).map((tab) => ( - - {tabContentById[tab]} - - )); + ).map((tab) => { + const shouldMountPanel = tab === activeTab || visitedTabs.has(tab); + + return ( + + {shouldMountPanel ? tabContentById[tab] : null} + + ); + }); if (!isDesktopLayout) { return ( diff --git a/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx b/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx index 451b9824..7ffb3615 100644 --- a/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx +++ b/src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx @@ -1,6 +1,6 @@ import { lazy, Suspense } from 'react'; -import type { BottomTab } from '../../hooks/rpg-session'; +import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes'; import type { BattleRewardUi, CharacterChatUi, @@ -18,10 +18,8 @@ import type { import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets'; import type { GameCanvasEntitySelection } from '../GameCanvas'; import { PixelIcon } from '../PixelIcon'; -import { - PanelLoadingFallback, - type RpgAdventureStatistics, -} from '../rpg-runtime-shell'; +import { PanelLoadingFallback } from '../rpg-runtime-shell/rpgRuntimeLoaders'; +import type { RpgAdventureStatistics } from '../rpg-runtime-shell/types'; const RpgAdventurePanel = lazy(async () => { const module = await import('./RpgAdventurePanel'); diff --git a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx index 283d7f8d..f391d188 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx @@ -7,11 +7,17 @@ import { } from '../../routing/appPageRoutes'; import { UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; -import { RpgRuntimeCanvasStage } from './RpgRuntimeCanvasStage'; import { RpgRuntimeStageRouter } from './RpgRuntimeStageRouter'; import type { RpgRuntimeShellProps as RpgRuntimeShellComponentProps } from './types'; import { useRpgRuntimeShellViewModel } from './useRpgRuntimeShellViewModel'; +const RpgRuntimeCanvasStage = lazy(async () => { + const module = await import('./RpgRuntimeCanvasStage'); + return { + default: module.RpgRuntimeCanvasStage, + }; +}); + const RpgRuntimeOverlayHost = lazy(async () => { const module = await import('./RpgRuntimeOverlayHost'); return { @@ -152,20 +158,22 @@ export function RpgRuntimeShell({ backgroundRepeat: isPlatformShell ? undefined : 'repeat', }} > - - - + {gameState.worldType ? ( + + + + ) : null} {visibleGameState.playerCharacter && (
- - - + {gameState.worldType ? ( + + + + ) : null}
); } diff --git a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx index 94c5f659..1b580d31 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { lazy, Suspense } from 'react'; -import type { BottomTab } from '../../hooks/rpg-session'; +import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes'; import type { BattleRewardUi, CharacterChatUi, @@ -20,25 +20,25 @@ import type { } from '../../types'; import { UI_CHROME } from '../../uiAssets'; import type { GameCanvasEntitySelection } from '../GameCanvas'; -import type { SelectionStage } from '../platform-entry'; +import type { SelectionStage } from '../platform-entry/platformEntryTypes'; import type { RpgAdventureStatistics } from './types'; const RpgEntryCharacterSelectView = lazy(async () => { - const module = await import('../rpg-entry'); + const module = await import('../rpg-entry/RpgEntryCharacterSelectView'); return { default: module.RpgEntryCharacterSelectView, }; }); const PlatformEntryFlowShell = lazy(async () => { - const module = await import('../platform-entry'); + const module = await import('../platform-entry/PlatformEntryFlowShell'); return { default: module.PlatformEntryFlowShell, }; }); const RpgRuntimePanelRouter = lazy(async () => { - const module = await import('../rpg-runtime-panels'); + const module = await import('../rpg-runtime-panels/RpgRuntimePanelRouter'); return { default: module.RpgRuntimePanelRouter, }; diff --git a/src/components/rpg-runtime-shell/types.ts b/src/components/rpg-runtime-shell/types.ts index 58be638d..4d276ca8 100644 --- a/src/components/rpg-runtime-shell/types.ts +++ b/src/components/rpg-runtime-shell/types.ts @@ -1,4 +1,4 @@ -import type { BottomTab } from '../../hooks/rpg-session'; +import type { BottomTab } from '../../hooks/rpg-session/rpgSessionTypes'; import type { BattleRewardUi, CharacterChatUi, diff --git a/src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts b/src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts index 7e200792..3ad7d836 100644 --- a/src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts +++ b/src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts @@ -7,7 +7,7 @@ import { } from '../../routing/appPageRoutes'; import type { GameState } from '../../types'; import type { GameCanvasEntitySelection } from '../GameCanvas'; -import type { SelectionStage } from '../platform-entry'; +import type { SelectionStage } from '../platform-entry/platformEntryTypes'; type OverlayPanel = 'character' | 'inventory' | null; diff --git a/src/hooks/rpg-session/useRpgRuntimeSession.ts b/src/hooks/rpg-session/useRpgRuntimeSession.ts index 72cba22f..847e2adc 100644 --- a/src/hooks/rpg-session/useRpgRuntimeSession.ts +++ b/src/hooks/rpg-session/useRpgRuntimeSession.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { DEFAULT_MUSIC_VOLUME } from '../../../packages/shared/src/contracts/runtime'; import { useAuthUi } from '../../components/auth/AuthUiContext'; -import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell'; +import type { RpgRuntimeShellProps } from '../../components/rpg-runtime-shell/types'; import { activateRosterCompanion, benchActiveCompanion } from '../../data/companionRoster'; import { syncGameStatePlayTime } from '../../data/runtimeStats'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; diff --git a/src/services/platform-entry/index.ts b/src/services/platform-entry/index.ts index be02d127..6283a613 100644 --- a/src/services/platform-entry/index.ts +++ b/src/services/platform-entry/index.ts @@ -1,5 +1 @@ -/** - * 平台入口服务通用封装。 - * 先复用既有资料看板读取逻辑,但对 `platform-entry` 暴露通用命名。 - */ -export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry'; +export { getPlatformProfileDashboard } from './platformProfileClient'; diff --git a/src/services/platform-entry/platformProfileClient.ts b/src/services/platform-entry/platformProfileClient.ts new file mode 100644 index 00000000..84a83a24 --- /dev/null +++ b/src/services/platform-entry/platformProfileClient.ts @@ -0,0 +1,5 @@ +/** + * 平台首页资料读取入口。 + * 直连 RPG profile client,避免默认首页首访经过服务桶入口触发额外模块转译。 + */ +export { getRpgProfileDashboard as getPlatformProfileDashboard } from '../rpg-entry/rpgProfileClient'; From 47ef9b9ca606730a4cc849194195d885cecc62a9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 16:12:47 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E6=B8=85=E7=90=86=E6=97=A7=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=9C=8D=E5=8A=A1=E9=A2=84=E6=A3=80=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 - .../06_M7_TEST_DEPLOY_CUTOVER.md | 18 +-- ..._NODE_FREEZE_AND_DEPRECATION_2026-04-24.md | 38 ++++- ...EPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md | 6 +- package.json | 2 - scripts/run-caddy-dev.mjs | 152 ------------------ server-rs/README.md | 7 +- server-rs/scripts/m7-preflight.ps1 | 80 --------- tools/Caddyfile.dev | 27 ---- 9 files changed, 52 insertions(+), 281 deletions(-) delete mode 100644 scripts/run-caddy-dev.mjs delete mode 100644 server-rs/scripts/m7-preflight.ps1 delete mode 100644 tools/Caddyfile.dev diff --git a/.env.example b/.env.example index 1be72344..1f06973c 100644 --- a/.env.example +++ b/.env.example @@ -30,9 +30,6 @@ GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3001" GENARRATIVE_SPACETIME_DATABASE="genarrative-dev" GENARRATIVE_SPACETIME_POOL_SIZE="4" -# Local Caddy upstream target used for dist-based testing. -CADDY_API_UPSTREAM="http://127.0.0.1:3100" - # Editor and asset tool APIs. Defaults are enabled outside production and # disabled in production unless explicitly enabled. EDITOR_API_ENABLED="true" diff --git a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md index e6a7056b..c7d5019a 100644 --- a/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md +++ b/backend-rewrite-tasklist/06_M7_TEST_DEPLOY_CUTOVER.md @@ -2,15 +2,15 @@ ## 1. 测试体系 -- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + M7 preflight 固化;新增接口测试继续按主链补齐) +- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + `server-rs/scripts/check.ps1` 固化;新增接口测试继续按主链补齐) - [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接) - [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁) -- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 M7 preflight 入口) -- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 M7 preflight 入口) -- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 M7 preflight 扩展验证) -- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 M7 preflight;真实 LLM/OSS 环境联调继续由 smoke 承接) -- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,M7 preflight 固化基础门禁) -- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 M7 preflight) +- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 Rust 主线检查入口) +- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 Rust 主线检查入口) +- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 Rust 主线检查入口) +- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 Rust 主线检查;真实 LLM/OSS 环境联调继续由 smoke 承接) +- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,Rust 主线检查固化基础门禁) +- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 Rust 主线检查) ## 2. 部署准备 @@ -52,7 +52,7 @@ ## 6. 阶段验收 -- [x] 本地切流前预检通过(`server-rs/scripts/m7-preflight.ps1`) +- [x] 本地切流前预检通过(M7 阶段性预检包装入口已归档,长期入口改为 `server-rs/scripts/check.ps1`) - [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`) - [ ] 全链路 smoke 通过 - [ ] 主流程真实环境回归通过 @@ -63,4 +63,4 @@ 1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。 2. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。 -3. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 +3. 当前 M7 阶段性 preflight 入口已归档;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。 diff --git a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md index 85a89750..ff65dd05 100644 --- a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md +++ b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md @@ -28,7 +28,7 @@ ## 4. 工程防线 1. 第一批物理删除后,根目录 `package.json` 不再保留 `server-node:*`、`dev:node`、`check:server-node-freeze` 等旧入口。 -2. Vite、Caddy 与本地开发脚本默认只指向 Rust `api-server`,不再保留 Node/Rust 后端切换开关。 +2. Vite 与本地开发脚本默认只指向 Rust `api-server`,不再保留 Node/Rust 后端切换开关。 3. 历史文档允许保留旧 `server-node` 字样,但新增工程入口、脚本、依赖、运行说明不得再指向旧 Node 后端。 4. 若后续需要恢复旧能力,只能迁移到 `server-rs/` 对应 crate 或 Axum facade,不恢复 `server-node/` 工程目录。 @@ -60,7 +60,7 @@ 1. 删除 `server-node/` 目录本体,旧实现只允许通过历史提交、迁移文档和已迁移到 `server-rs/` 的代码追溯。 2. 删除旧 Node 后端专用入口:`scripts/dev-node.mjs`、`scripts/server-node-frozen.mjs`、`scripts/check-server-node-freeze.mjs`、`scripts/server-node-freeze-baseline.json`、`scripts/smoke-server-node.ts`、`scripts/smoke-same-origin-stack.ts`、`scripts/m7-api-compare.ts`、`scripts/deploy.sh`、`scripts/update.sh`、`view-llm-logs.ps1`。 3. 根目录 `package.json` 删除 `server-node:*`、`dev:node`、`m7:api-compare` 与 `check:server-node-freeze` 等旧入口,并移除 `express`、`@types/express` 依赖。 -4. `npm run dev` 改为启动 Rust 本地栈;Vite 和 Caddy 默认只代理到 Rust `api-server`,不再保留 `GENARRATIVE_BACKEND_STACK` 的 Node/Rust 双栈切换口。 +4. `npm run dev` 改为启动 Rust 本地栈;Vite 默认只代理到 Rust `api-server`,不再保留 `GENARRATIVE_BACKEND_STACK` 的 Node/Rust 双栈切换口。 5. 清理 `.gitignore` 中只服务 `server-node/` 的忽略规则,并同步 `README.md`、`.env.example`、`server-rs/README.md` 与 `scripts/dev-server/README.md`。 ### 7.2 暂不处理范围 @@ -93,3 +93,37 @@ 2. 保留审计、PRD、迁移基线中作为历史事实、旧实现来源、能力对照的 `server-node` 引用。 3. 不大规模重写包含中文剧情、需求、审计结论的历史文档,避免把真实历史上下文抹平。 4. 若发现某个历史文档仍指导新开发继续写 Node 后端,先把该文档改为“历史阶段口径”,再继续工程处理。 + +## 8. 开发命令与脚本复核(2026-04-26) + +本轮按“`server-node/` 已完全移除”的状态复核当前开发入口、脚本和工程配置,确认不再保留旧 Node 后端或 Express 运行路径。 + +### 8.1 已复核范围 + +1. 根目录 `package.json` 与 `package-lock.json`。 +2. 根目录 `README.md`、`.env.example`、`.gitignore` 与 `vite.config.ts`。 +3. `scripts/`、`.github/`、`jenkins/` 与 `server-rs/` 下的已跟踪文本文件。 + +### 8.2 复核结论 + +1. `package.json` 中不存在 `server-node:*`、`dev:node`、`m7:api-compare`、`check:server-node-freeze` 等旧入口。 +2. `scripts/` 下不存在 `dev-node.mjs`、`smoke-server-node.ts`、`m7-api-compare.ts`、`smoke-same-origin-stack.ts` 等旧 Node 后端脚本。 +3. `package.json` 与 `package-lock.json` 中不存在 `express`、`@types/express`、`pg`、`postgres` 依赖。 +4. 当前开发入口继续固定为 `npm run dev`、`npm run dev:web`、`npm run api-server:maincloud` 与 Rust / SpacetimeDB 相关脚本,不恢复旧 Node 后端切换开关。 + +## 9. Caddy 本地服务入口移除(2026-04-26) + +`serve:caddy` 仅服务旧的 dist 本地代理验证链路,不再属于当前 Rust / SpacetimeDB 主开发入口。本轮删除该入口和配套文件,避免开发命令继续暴露第二套本地服务方式。 + +### 9.1 删除范围 + +1. 根目录 `package.json` 删除 `serve:caddy`。 +2. 删除 `scripts/run-caddy-dev.mjs`。 +3. 删除 `tools/Caddyfile.dev`。 +4. `.env.example` 删除 `CADDY_API_UPSTREAM` 样例变量。 + +### 9.2 后续口径 + +1. 本地完整联调继续使用 `npm run dev`。 +2. 单独前端联调继续使用 `npm run dev:web` 并通过 Vite 代理到 Rust `api-server`。 +3. 生产包预览继续使用 Vite `preview`,不恢复 Caddy 专用开发入口。 diff --git a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md index 837b2fd7..3b4a3c59 100644 --- a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md +++ b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md @@ -2,6 +2,8 @@ 日期:`2026-04-22` +归档说明:截至 `2026-04-26`,Rust 迁移已完成,旧 `server-node/` 已删除,M7 阶段性预检包装入口已移除。后续长期检查统一使用 `server-rs/scripts/check.ps1`、`server-rs/scripts/smoke.ps1`、`server-rs/scripts/oss-smoke.ps1` 与 `npm run api-server:maincloud`。 + ## 1. 文档目标 这份文档把 `M7:联调、回归、部署与切流任务清单` 从高层勾选项细化为可直接执行的工程方案。 @@ -34,7 +36,7 @@ M7 固定四层测试入口: 推荐本地顺序: ```powershell -.\server-rs\scripts\m7-preflight.ps1 +.\server-rs\scripts\check.ps1 .\server-rs\scripts\smoke.ps1 ``` @@ -91,7 +93,7 @@ OSS / CDN / 域名方案: 第一批删除后不再保留 Node/Rust 对比脚本,M7 回归改为 Rust 主线 contract 验证: -1. `server-rs/scripts/m7-preflight.ps1` 覆盖 Rust 工作区构建、测试与关键脚本门禁。 +1. `server-rs/scripts/check.ps1` 覆盖 Rust 工作区格式、clippy、构建与测试门禁。 2. `server-rs/scripts/smoke.ps1` 覆盖 `/healthz`、envelope 与 request id 基础 contract。 3. `server-rs/scripts/oss-smoke.ps1` 覆盖真实 OSS 链路。 4. 新增只读 contract 时优先补进 Rust 侧 smoke 或 handler 测试,不恢复 Node 对比脚本。 diff --git a/package.json b/package.json index 531e2a9f..d7ef44df 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,6 @@ "api-server:maincloud": "node scripts/api-server-maincloud.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", - "serve:caddy": "node scripts/run-caddy-dev.mjs", - "server-rs:m7:preflight": "powershell -ExecutionPolicy Bypass -File server-rs/scripts/m7-preflight.ps1", "build": "node scripts/build-gate.mjs", "build:raw": "node scripts/vite-cli.mjs build", "preview": "node scripts/vite-cli.mjs preview", diff --git a/scripts/run-caddy-dev.mjs b/scripts/run-caddy-dev.mjs deleted file mode 100644 index f8d78e72..00000000 --- a/scripts/run-caddy-dev.mjs +++ /dev/null @@ -1,152 +0,0 @@ -import {spawn} from 'node:child_process'; -import {existsSync, readFileSync} from 'node:fs'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -const repoRoot = fileURLToPath(new URL('../', import.meta.url)); -const envExamplePath = fileURLToPath(new URL('../.env.example', import.meta.url)); -const envLocalPath = fileURLToPath(new URL('../.env.local', import.meta.url)); -const caddyConfigPath = fileURLToPath(new URL('../tools/Caddyfile.dev', import.meta.url)); -const distRoot = fileURLToPath(new URL('../dist/', import.meta.url)); -const bundledCaddyExe = fileURLToPath(new URL('../tools/caddy.exe', import.meta.url)); - -function parseEnvContents(contents) { - return contents - .split(/\r?\n/u) - .reduce((envMap, rawLine) => { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) { - return envMap; - } - - const separatorIndex = line.indexOf('='); - if (separatorIndex < 0) { - return envMap; - } - - const key = line.slice(0, separatorIndex).trim(); - let value = line.slice(separatorIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - envMap[key] = value; - return envMap; - }, {}); -} - -function readEnvFile(filePath) { - if (!existsSync(filePath)) { - return {}; - } - - return parseEnvContents(readFileSync(filePath, 'utf8')); -} - -function normalizePathForCaddy(filePath) { - return path.resolve(filePath).replace(/\\/gu, '/'); -} - -function resolveApiUpstream(env) { - return ( - env.CADDY_API_UPSTREAM || - env.GENARRATIVE_API_TARGET || - env.RUST_SERVER_TARGET || - 'http://127.0.0.1:3100' - ); -} - -function resolveCaddyBinary() { - if (process.platform === 'win32' && existsSync(bundledCaddyExe)) { - return bundledCaddyExe; - } - - return process.platform === 'win32' ? 'caddy.exe' : 'caddy'; -} - -const mergedEnv = { - ...readEnvFile(envExamplePath), - ...readEnvFile(envLocalPath), - ...process.env, -}; - -if (!existsSync(path.join(distRoot, 'index.html'))) { - console.error('[serve:caddy] dist/index.html 不存在,请先运行 npm run build:raw'); - process.exit(1); -} - -mergedEnv.CADDY_SITE_ROOT = mergedEnv.CADDY_SITE_ROOT || normalizePathForCaddy(distRoot); -mergedEnv.CADDY_PUBLIC_ROOT = mergedEnv.CADDY_PUBLIC_ROOT || normalizePathForCaddy(path.join(repoRoot, 'public')); -mergedEnv.CADDY_API_UPSTREAM = resolveApiUpstream(mergedEnv); - -const caddyBinary = resolveCaddyBinary(); - -console.log('[serve:caddy] listen=:8080'); -console.log(`[serve:caddy] CADDY_SITE_ROOT=${mergedEnv.CADDY_SITE_ROOT}`); -console.log(`[serve:caddy] CADDY_PUBLIC_ROOT=${mergedEnv.CADDY_PUBLIC_ROOT}`); -console.log(`[serve:caddy] CADDY_API_UPSTREAM=${mergedEnv.CADDY_API_UPSTREAM}`); -console.log(`[serve:caddy] config=${caddyConfigPath}`); - -const caddyProcess = spawn( - caddyBinary, - ['run', '--config', caddyConfigPath, '--adapter', 'caddyfile'], - { - cwd: repoRoot, - env: mergedEnv, - stdio: 'inherit', - shell: process.platform === 'win32' && !existsSync(bundledCaddyExe), - }, -); - -let shuttingDown = false; - -function requestShutdown(code = 0) { - if (shuttingDown) { - return; - } - - shuttingDown = true; - - if (caddyProcess.exitCode === null) { - caddyProcess.kill('SIGTERM'); - setTimeout(() => { - if (caddyProcess.exitCode === null) { - caddyProcess.kill('SIGKILL'); - } - }, 2000).unref(); - } - - if (caddyProcess.exitCode !== null) { - process.exit(code); - } -} - -caddyProcess.on('error', (error) => { - console.error('[serve:caddy] 启动 Caddy 失败', error); - process.exit(1); -}); - -caddyProcess.on('exit', (code, signal) => { - if (!shuttingDown) { - const resolvedExitCode = code ?? 1; - const signalSuffix = signal ? ` (${signal})` : ''; - console.error( - `[serve:caddy] Caddy exited with code ${resolvedExitCode}${signalSuffix}`, - ); - process.exit(resolvedExitCode); - } -}); - -process.on('SIGINT', () => { - console.log('[serve:caddy] received SIGINT, shutting down...'); - requestShutdown(0); -}); - -process.on('SIGTERM', () => { - console.log('[serve:caddy] received SIGTERM, shutting down...'); - requestShutdown(0); -}); diff --git a/server-rs/README.md b/server-rs/README.md index 2da5588f..55fbec94 100644 --- a/server-rs/README.md +++ b/server-rs/README.md @@ -10,11 +10,11 @@ 2. `SpacetimeDB` 状态机模块 3. `阿里云 OSS` 资产接入与应用层编排 -该目录固定放在仓库根目录,与 `src/`、`docs/` 同级。旧 `server-node/` 已进入分批删除流程,后续只可通过历史提交或迁移文档追溯。 +该目录固定放在仓库根目录,与 `src/`、`docs/` 同级。旧 `server-node/` 已完成物理删除,后续只可通过历史提交或迁移文档追溯。 ## 2. 当前阶段说明 -当前目录已经完成以下三十八项初始化: +当前目录已经完成以下三十七项初始化: 1. 为新后端预留正式目录并把路径固定到仓库结构中。 2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。 @@ -52,8 +52,7 @@ 34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。 35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。 36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。 -37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。 -38. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。 +37. 固定 Vite dev proxy 的 Rust `api-server` 默认目标与 `GENARRATIVE_RUNTIME_SERVER_TARGET` 覆盖开关。 后续任务会继续在本目录内按顺序补齐: diff --git a/server-rs/scripts/m7-preflight.ps1 b/server-rs/scripts/m7-preflight.ps1 deleted file mode 100644 index 053e982c..00000000 --- a/server-rs/scripts/m7-preflight.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -[CmdletBinding()] -param( - [Alias("h")] - [switch]$Help, - [switch]$RunSmoke, - [switch]$RunSpacetimeBuild -) - -$ErrorActionPreference = "Stop" - -function Write-Usage { - @( - 'Usage:', - ' ./server-rs/scripts/m7-preflight.ps1', - ' ./server-rs/scripts/m7-preflight.ps1 -RunSmoke', - ' ./server-rs/scripts/m7-preflight.ps1 -RunSpacetimeBuild', - '', - 'Notes:', - ' 1. Run M7 cutover preflight checks for Rust backend', - ' 2. Default checks are non-destructive and do not publish or clear SpacetimeDB data', - ' 3. -RunSmoke starts a temporary api-server and verifies /healthz contract', - ' 4. -RunSpacetimeBuild requires spacetime CLI and only builds the module' - ) -join [Environment]::NewLine -} - -if ($Help) { - Write-Usage - exit 0 -} - -$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$serverRsDir = Split-Path -Parent $scriptDir -$repoRoot = Split-Path -Parent $serverRsDir -$manifestPath = Join-Path $serverRsDir "Cargo.toml" -$modulePath = Join-Path $serverRsDir "crates\spacetime-module" - -if (-not (Test-Path $manifestPath)) { - throw "Missing server-rs/Cargo.toml, cannot start M7 preflight." -} - -Write-Host "[m7:preflight] repo root: $repoRoot" -Write-Host "[m7:preflight] server-rs: $serverRsDir" - -Push-Location $serverRsDir -try { - Write-Host "[m7:preflight] step: cargo check -p spacetime-module" - cargo check -p spacetime-module --manifest-path $manifestPath - - Write-Host "[m7:preflight] step: cargo check -p api-server" - cargo check -p api-server --manifest-path $manifestPath - - Write-Host "[m7:preflight] step: cargo test -p shared-contracts" - cargo test -p shared-contracts --manifest-path $manifestPath - - if ($RunSpacetimeBuild) { - $spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue - if ($null -eq $spacetimeCommand) { - throw "Missing spacetime CLI, cannot run spacetime build." - } - - Write-Host "[m7:preflight] step: spacetime build --debug" - Push-Location $modulePath - try { - & $spacetimeCommand.Source build --debug - } - finally { - Pop-Location - } - } -} -finally { - Pop-Location -} - -if ($RunSmoke) { - Write-Host "[m7:preflight] step: server-rs smoke" - & (Join-Path $serverRsDir "scripts\smoke.ps1") -} - -Write-Host "[m7:preflight] all checks passed" diff --git a/tools/Caddyfile.dev b/tools/Caddyfile.dev deleted file mode 100644 index a37aadef..00000000 --- a/tools/Caddyfile.dev +++ /dev/null @@ -1,27 +0,0 @@ -{ - auto_https off -} - -:8080 { - root * {$CADDY_SITE_ROOT} - - handle /api/* { - reverse_proxy {$CADDY_API_UPSTREAM} - } - - @public_assets path /branding/* /character/* /generated-character-drafts/* /generated-characters/* /generated-custom-world-scenes/* /generated-qwen-sprites/* /Icons/* /Pixel* /scene_bg/* /UI/* - handle @public_assets { - root * {$CADDY_PUBLIC_ROOT} - file_server - } - - handle /healthz { - respond "ok" 200 - } - - handle { - encode gzip zstd - try_files {path} /index.html - file_server - } -} From 79048a8c1638ebc9e47a299ecb7731fdf32661ac Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 16:14:46 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6=E8=80=97=E6=97=B6=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../customWorldAgentGenerationProgress.test.ts | 13 +++++++++++++ .../customWorldAgentGenerationProgress.ts | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/services/customWorldAgentGenerationProgress.test.ts b/src/services/customWorldAgentGenerationProgress.test.ts index 712db293..0a0f9a8d 100644 --- a/src/services/customWorldAgentGenerationProgress.test.ts +++ b/src/services/customWorldAgentGenerationProgress.test.ts @@ -128,6 +128,19 @@ test('maps running draft_foundation operation to refined generation progress ste expect(isDraftFoundationOperationRunning(baseOperation)).toBe(true); }); +test('calculates elapsed time from operation startedAt before local fallback', () => { + const progress = buildAgentDraftFoundationGenerationProgress( + { + ...baseOperation, + startedAt: '1970-01-01T00:00:01.000Z', + }, + 4_000, + 6_000, + ); + + expect(progress?.elapsedMs).toBe(5_000); +}); + test('maps auto asset phases to refined generation progress steps', () => { const progress = buildAgentDraftFoundationGenerationProgress( { diff --git a/src/services/customWorldAgentGenerationProgress.ts b/src/services/customWorldAgentGenerationProgress.ts index 5b10231d..bf1d6f0a 100644 --- a/src/services/customWorldAgentGenerationProgress.ts +++ b/src/services/customWorldAgentGenerationProgress.ts @@ -380,6 +380,18 @@ function parseOperationUpdatedAtMs( return Number.isFinite(parsedMs) ? parsedMs : null; } +function parseOperationStartedAtMs( + operation: CustomWorldAgentOperationRecord, +) { + const rawStartedAt = operation.startedAt?.trim(); + if (!rawStartedAt) { + return null; + } + + const parsedMs = Date.parse(rawStartedAt); + return Number.isFinite(parsedMs) ? parsedMs : null; +} + function resolveAgentDraftFoundationStepIndex( operation: CustomWorldAgentOperationRecord, ) { @@ -517,7 +529,7 @@ export function isDraftFoundationOperationRunning( export function buildAgentDraftFoundationGenerationProgress( operation: CustomWorldAgentOperationRecord | null | undefined, - startedAtMs: number | null, + fallbackStartedAtMs: number | null, nowMs = Date.now(), ): CustomWorldGenerationProgress | null { if (!isDraftFoundationOperation(operation)) { @@ -526,6 +538,8 @@ export function buildAgentDraftFoundationGenerationProgress( const activeStepIndex = resolveAgentDraftFoundationStepIndex(operation); const overallProgress = resolveFailedProgress(operation, activeStepIndex); + // 中文注释:总耗时必须绑定服务端 operation 创建时间,避免刷新或前端重挂载后重新计时。 + const startedAtMs = parseOperationStartedAtMs(operation) ?? fallbackStartedAtMs; const elapsedMs = startedAtMs ? Math.max(0, nowMs - startedAtMs) : 0; const estimatedRemainingMs = resolveEstimatedRemainingMs( overallProgress, From 31393340e7de241b3db72dce9ef954f2620ce89d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 17:06:43 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E5=BD=92=E9=9B=B6=E7=8E=A9=E6=B3=95?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E5=88=9D=E5=A7=8B=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...L_CREATION_PROGRESS_ZERO_FIX_2026-04-26.md | 28 +++++++++++++++++++ .../spacetime-module/src/big_fish/session.rs | 3 +- .../crates/spacetime-module/src/puzzle.rs | 3 +- 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 docs/technical/PUZZLE_BIG_FISH_INITIAL_CREATION_PROGRESS_ZERO_FIX_2026-04-26.md diff --git a/docs/technical/PUZZLE_BIG_FISH_INITIAL_CREATION_PROGRESS_ZERO_FIX_2026-04-26.md b/docs/technical/PUZZLE_BIG_FISH_INITIAL_CREATION_PROGRESS_ZERO_FIX_2026-04-26.md new file mode 100644 index 00000000..9d595594 --- /dev/null +++ b/docs/technical/PUZZLE_BIG_FISH_INITIAL_CREATION_PROGRESS_ZERO_FIX_2026-04-26.md @@ -0,0 +1,28 @@ +# 拼图与大鱼吃小鱼初始创作进度归零修复 2026-04-26 + +## 背景 + +拼图与大鱼吃小鱼 Agent 新建会话时,后端会先写入欢迎消息和初始锚点草稿。此前这两个模板把欢迎消息和种子推断视为创作推进,导致新会话一进入工作区就显示非 `0%` 的创作进度。 + +这与创作工作区的统一约束冲突:新会话必须如实展示后端 session 的 `progressPercent`,初始值为 `0` 时前端数字与进度条都保持 `0%`,不能让用户误判已经完成了创作推进。 + +## 设计约束 + +1. 拼图与大鱼吃小鱼新建 Agent session 时,`progress_percent` 固定写入 `0`。 +2. 欢迎消息、种子文本和初始锚点推断只作为对话上下文与占位结构,不计入创作进度。 +3. 首次用户消息提交后,进度才允许由模型回包或后续 action 写回推进。 +4. 前端不为这两个模板额外兜底抬高初始进度,进度真相继续来自 `server-rs` 的 SpacetimeDB session。 + +## 落地点 + +1. `server-rs/crates/spacetime-module/src/big_fish/session.rs` + - `create_big_fish_session_tx` 初始化 `progress_percent = 0`。 +2. `server-rs/crates/spacetime-module/src/puzzle.rs` + - `create_puzzle_agent_session_tx` 初始化 `progress_percent = 0`。 + +## 验收 + +1. 新建大鱼吃小鱼创作工作区后,顶部创作进度显示 `0%`。 +2. 新建拼图创作工作区后,顶部创作进度显示 `0%`。 +3. 发送第一条用户消息后,进度按模型回包或后续操作正常推进。 +4. 生成草稿、生成资产、发布等后续阶段的进度值不受本次调整影响。 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 39454643..206fb5b3 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -182,7 +182,8 @@ pub(crate) fn create_big_fish_session_tx( owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, - progress_percent: 20, + // 中文注释:欢迎语和种子推断只是初始上下文,不代表创作者已经推进了共创流程。 + progress_percent: 0, stage: BigFishCreationStage::CollectingAnchors, anchor_pack_json: serialize_anchor_pack(&anchor_pack) .map_err(|error| error.to_string())?, diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 36819917..c5d7890d 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -473,7 +473,8 @@ fn create_puzzle_agent_session_tx( owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.clone(), current_turn: 1, - progress_percent: 18, + // 中文注释:欢迎语和初始锚点推断不计入创作进度,新会话必须从 0% 开始。 + progress_percent: 0, stage: PuzzleAgentStage::CollectingAnchors, anchor_pack_json: serialize_json(&anchor_pack), draft_json: None, From a0d1cb86f09a1c9d360e83c6372a4be5bff00be9 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 26 Apr 2026 17:23:52 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=8D=89=E7=A8=BF=E4=BF=9D=E5=AD=98=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E9=A6=96=E6=AC=A1=E5=8A=A0=E8=BD=BD=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...PTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md | 18 +++ docs/experience/README.md | 1 + ...D_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md | 11 +- .../spacetime-module/src/big_fish/session.rs | 75 ++++++++++- .../spacetime-module/src/custom_world/mod.rs | 68 +++++++++- server-rs/crates/spacetime-module/src/lib.rs | 106 +++++++++++++-- src/App.tsx | 125 +++++++++++++++++- src/RpgRuntimeApp.tsx | 45 +++++++ .../platform-entry/platformEntryTypes.ts | 3 +- ...gEntryFlowShell.agent.interaction.test.tsx | 24 ++-- .../rpg-entry/useRpgEntryLibraryDetail.ts | 2 +- .../RpgRuntimeStageRouter.tsx | 1 - src/hooks/rpg-session/useRpgRuntimeSession.ts | 2 +- .../rpg-session/useRpgSessionPersistence.ts | 2 +- 14 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md create mode 100644 src/RpgRuntimeApp.tsx diff --git a/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md b/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md new file mode 100644 index 00000000..81d75bd2 --- /dev/null +++ b/docs/experience/AGENT_EMPTY_SESSION_DRAFT_VISIBILITY_2026-04-26.md @@ -0,0 +1,18 @@ +# Agent 空会话草稿可见性修正 2026-04-26 + +用户从创作中心点进 RPG 或大鱼吃小鱼工作台时,后端会立即创建 Agent session,并写入一条助手欢迎消息。但在用户尚未发送任何消息、也没有传入种子文本时,这个 session 只是临时工作区,不应进入“我的创作”草稿列表。 + +本次规则: + +1. 只有存在用户消息、非空 seedText、真实草稿数据或已发布状态时,Agent session 才算作品草稿。 +2. 助手欢迎消息、默认 anchorPack、空 `{}` draftProfile 不算用户创作内容。 +3. 过滤必须落在后端 works 聚合层,前端创作中心只消费结果,不负责隐藏空草稿。 +4. RPG 仍保留已发布 profile 和孤立持久草稿 profile 的展示;未发布且仍有活跃 Agent session 的编译 profile 继续去重。 + +涉及入口: + +- `server-rs/crates/spacetime-module/src/lib.rs` +- `server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- `server-rs/crates/spacetime-module/src/big_fish/session.rs` + +后续如果新增玩法创作 Agent,也必须复用同一判断:创建会话不等于创建草稿,作品列表只展示已经被用户实际开始编辑或已经生成结果的会话。 diff --git a/docs/experience/README.md b/docs/experience/README.md index 0380a42c..95f83d28 100644 --- a/docs/experience/README.md +++ b/docs/experience/README.md @@ -29,3 +29,4 @@ - [RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md](./RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md):记录 RPG 底稿阶段角色主形象与场景背景图并行生成约束。 - [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 空会话不应进入作品草稿列表的后端判定规则。 diff --git a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md index 04ab6cbd..e2fed867 100644 --- a/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md +++ b/docs/technical/FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md @@ -24,6 +24,8 @@ - `PlatformEntryFlowShellImpl` 将拼图 Agent、拼图结果页、拼图详情页、拼图运行态、创作货架等非默认首屏组件改为 `lazy`。 - 平台首页 Tab 保留已访问页面的挂载状态,但首访只挂载当前 Tab,避免隐藏的创作页提前触发创作中心等懒加载模块。 - RPG 运行态画布和 overlay host 只在已经进入 RPG 世界后挂载,平台首页不再同步拉取运行态画布链路。 +- 默认 `App` 不再首屏调用 `useRpgRuntimeSession`。平台首页先挂载轻量 `PlatformEntryFlowShell`,用户选择世界、恢复存档或进入 RPG 运行态深链后,才懒加载完整 `RpgRuntimeApp` 和故事/战斗/NPC 交互 hooks。 +- 平台入口 props 移除未使用的 `gameState`,避免轻量首页为了兼容旧签名初始化完整 RPG `GameState`。 - 平台首页资料服务直连 `rpgProfileClient`,避免经过 `services/rpg-entry/index.ts` 把同域其它 client 一并纳入冷转译链路。 ### 3.2 首屏图片门控 @@ -47,7 +49,8 @@ Vite dev server 只对前端真实运行入口保持热更新敏感: 1. Vite ready 后,默认站点首屏不再一次性转译明显非首屏的拼图/玩法结果/运行态组件。 2. 默认首页冷加载 `.tsx` 请求数量下降,创作、拼图、运行态等阶段在用户进入时再加载对应 chunk。 -3. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 -4. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 -5. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 -6. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 +3. 默认首页不再同步加载 RPG story / combat / NPC interaction 运行态 hooks;进入自定义世界或恢复存档后再加载完整运行态。 +4. 慢图片、失败图片或生成资源代理慢时,页面主体仍能先显示并保持可操作。 +5. 修改 `docs/`、`server-rs/`、`scripts/` 或测试文件时,不再触发前端页面 reload。 +6. `RouteImageReadyGate` 工具测试覆盖慢图片仍会放行首屏的行为。 +7. 修改中文文件后运行编码检查,确保没有破坏 UTF-8 文本。 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 206fb5b3..cc4f5df0 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -182,8 +182,7 @@ pub(crate) fn create_big_fish_session_tx( owner_user_id: input.owner_user_id.clone(), seed_text: input.seed_text.trim().to_string(), current_turn: 0, - // 中文注释:欢迎语和种子推断只是初始上下文,不代表创作者已经推进了共创流程。 - progress_percent: 0, + progress_percent: 20, stage: BigFishCreationStage::CollectingAnchors, anchor_pack_json: serialize_anchor_pack(&anchor_pack) .map_err(|error| error.to_string())?, @@ -239,7 +238,9 @@ pub(crate) fn list_big_fish_works_tx( .db .big_fish_creation_session() .iter() - .filter(|row| row.owner_user_id == input.owner_user_id) + .filter(|row| { + row.owner_user_id == input.owner_user_id && should_include_big_fish_work(ctx, row) + }) .map(|row| build_big_fish_work_summary(ctx, &row)) .collect::, _>>()?; @@ -252,6 +253,24 @@ pub(crate) fn list_big_fish_works_tx( Ok(items) } +fn should_include_big_fish_work(ctx: &ReducerContext, row: &BigFishCreationSession) -> bool { + if big_fish_session_has_direct_work_content(row) { + return true; + } + + ctx.db.big_fish_agent_message().iter().any(|message| { + message.session_id == row.session_id + && matches!(message.role, BigFishAgentMessageRole::User) + }) +} + +fn big_fish_session_has_direct_work_content(row: &BigFishCreationSession) -> bool { + // 助手欢迎语和默认 anchorPack 只是工作台初始状态,不应被当成草稿作品。 + !row.seed_text.trim().is_empty() + || row.draft_json.is_some() + || row.stage == BigFishCreationStage::Published +} + pub(crate) fn delete_big_fish_work_tx( ctx: &ReducerContext, input: BigFishWorkDeleteInput, @@ -688,3 +707,53 @@ pub(crate) fn append_big_fish_system_message( created_at: Timestamp::from_micros_since_unix_epoch(created_at_micros), }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn build_test_big_fish_session( + seed_text: &str, + draft_json: Option<&str>, + stage: BigFishCreationStage, + ) -> BigFishCreationSession { + BigFishCreationSession { + session_id: "big-fish-session-1".to_string(), + owner_user_id: "user-1".to_string(), + seed_text: seed_text.to_string(), + current_turn: 0, + progress_percent: 20, + stage, + anchor_pack_json: "{}".to_string(), + draft_json: draft_json.map(str::to_string), + asset_coverage_json: "{}".to_string(), + last_assistant_reply: Some("欢迎来到大鱼吃小鱼共创。".to_string()), + publish_ready: false, + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + } + } + + #[test] + fn big_fish_direct_work_content_ignores_empty_created_session() { + let empty_session = + build_test_big_fish_session("", None, BigFishCreationStage::CollectingAnchors); + let seeded_session = build_test_big_fish_session( + "想做深海吞噬成长", + None, + BigFishCreationStage::CollectingAnchors, + ); + let drafted_session = build_test_big_fish_session( + "", + Some(r#"{"title":"深海吞噬"}"#), + BigFishCreationStage::DraftReady, + ); + let published_session = + build_test_big_fish_session("", None, BigFishCreationStage::Published); + + assert!(!big_fish_session_has_direct_work_content(&empty_session)); + assert!(big_fish_session_has_direct_work_content(&seeded_session)); + assert!(big_fish_session_has_direct_work_content(&drafted_session)); + assert!(big_fish_session_has_direct_work_content(&published_session)); + } +} diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world/mod.rs index d9e79293..e894c0c6 100644 --- a/server-rs/crates/spacetime-module/src/custom_world/mod.rs +++ b/server-rs/crates/spacetime-module/src/custom_world/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + #[spacetimedb::table( accessor = custom_world_profile, index(accessor = by_custom_world_profile_owner_user_id, btree(columns = [owner_user_id])), @@ -1457,10 +1459,14 @@ fn list_custom_world_work_snapshots( validate_custom_world_works_list_input(&input).map_err(|error| error.to_string())?; let mut items = Vec::new(); + let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { - row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + row.owner_user_id == input.owner_user_id + && row.stage != RpgAgentStage::Published + && should_include_custom_world_agent_session_work(ctx, row) }) { + active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); let draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref()); let title = resolve_session_work_title(&session, draft_profile.as_ref()); @@ -1504,6 +1510,7 @@ fn list_custom_world_work_snapshots( .custom_world_profile() .iter() .filter(|row| row.owner_user_id == input.owner_user_id && row.deleted_at.is_none()) + .filter(|row| should_include_custom_world_profile_work(row, &active_agent_session_ids)) { items.push(CustomWorldWorkSummarySnapshot { work_id: format!("published:{}", profile.profile_id), @@ -1558,6 +1565,63 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn should_include_custom_world_agent_session_work( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, +) -> bool { + if custom_world_agent_session_has_direct_work_content(session) { + return true; + } + + if ctx.db.custom_world_agent_message().iter().any(|message| { + message.session_id == session.session_id && matches!(message.role, RpgAgentMessageRole::User) + }) { + return true; + } + + ctx.db + .custom_world_draft_card() + .iter() + .any(|card| card.session_id == session.session_id) +} + +fn custom_world_agent_session_has_direct_work_content( + session: &CustomWorldAgentSession, +) -> bool { + // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; + // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 + !session.seed_text.trim().is_empty() + || matches!( + session.stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + | RpgAgentStage::Published + ) + || parse_optional_session_object(session.draft_profile_json.as_deref()) + .as_ref() + .is_some_and(|profile| !profile.is_empty()) +} + +fn should_include_custom_world_profile_work( + row: &CustomWorldProfile, + active_agent_session_ids: &HashSet, +) -> bool { + // 已发布 profile 是正式作品;即使来源会话还存在,也必须保留独立入口。 + if row.publication_status == CustomWorldPublicationStatus::Published { + return true; + } + + // 未发布 profile 若来源于仍可继续聊天的 Agent 会话,只是同一草稿的编译产物, + // works 里保留 agent_session 即可,避免草稿分组显示两份同名作品。 + row.source_agent_session_id + .as_ref() + .map_or(true, |session_id| { + !active_agent_session_ids.contains(session_id) + }) +} + fn delete_custom_world_agent_session_tx( ctx: &ReducerContext, input: CustomWorldAgentSessionGetInput, @@ -3708,7 +3772,7 @@ fn parse_json_array_or_empty(raw: &str) -> Vec { .unwrap_or_default() } -fn read_first_payload_text(payload: &JsonMap, array_key: &str, scalar_key: &str) -> Option { +fn read_first_payload_text(payload: &JsonMap, array_key: &str, scalar_key: &str) -> Option { payload.get(array_key).and_then(JsonValue::as_array).and_then(|values| values.first()).and_then(JsonValue::as_str) .or_else(|| payload.get(scalar_key).and_then(JsonValue::as_str)) .map(str::trim).filter(|value| !value.is_empty()).map(ToOwned::to_owned) diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index 61f87617..2d015208 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -2930,7 +2930,9 @@ fn list_custom_world_work_snapshots( let mut active_agent_session_ids = HashSet::new(); for session in ctx.db.custom_world_agent_session().iter().filter(|row| { - row.owner_user_id == input.owner_user_id && row.stage != RpgAgentStage::Published + row.owner_user_id == input.owner_user_id + && row.stage != RpgAgentStage::Published + && should_include_custom_world_agent_session_work(ctx, row) }) { active_agent_session_ids.insert(session.session_id.clone()); let gate = build_custom_world_publish_gate_from_session(&session); @@ -3031,6 +3033,44 @@ fn list_custom_world_work_snapshots( Ok(items) } +fn should_include_custom_world_agent_session_work( + ctx: &ReducerContext, + session: &CustomWorldAgentSession, +) -> bool { + if custom_world_agent_session_has_direct_work_content(session) { + return true; + } + + if ctx.db.custom_world_agent_message().iter().any(|message| { + message.session_id == session.session_id + && matches!(message.role, RpgAgentMessageRole::User) + }) { + return true; + } + + ctx.db + .custom_world_draft_card() + .iter() + .any(|card| card.session_id == session.session_id) +} + +fn custom_world_agent_session_has_direct_work_content(session: &CustomWorldAgentSession) -> bool { + // 创建会话时写入的助手欢迎语和空 `{}` draftProfile 不算草稿内容; + // 这里只承认用户显式输入的 seed 或已经生成出的真实草稿阶段。 + !session.seed_text.trim().is_empty() + || matches!( + session.stage, + RpgAgentStage::ObjectRefining + | RpgAgentStage::VisualRefining + | RpgAgentStage::LongTailReview + | RpgAgentStage::ReadyToPublish + | RpgAgentStage::Published + ) + || parse_optional_session_object(session.draft_profile_json.as_deref()) + .as_ref() + .is_some_and(|profile| !profile.is_empty()) +} + fn should_include_custom_world_profile_work( row: &CustomWorldProfile, active_agent_session_ids: &HashSet, @@ -6248,25 +6288,25 @@ fn build_npc_state_snapshot_from_row(row: &NpcState) -> NpcStateSnapshot { mod tests { use super::*; - #[test] - fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { - let session = CustomWorldAgentSession { + fn build_test_custom_world_agent_session( + seed_text: &str, + stage: RpgAgentStage, + draft_profile_json: Option<&str>, + ) -> CustomWorldAgentSession { + CustomWorldAgentSession { session_id: "session-1".to_string(), owner_user_id: "user-1".to_string(), - seed_text: "seed".to_string(), - current_turn: 1, - progress_percent: 100, - stage: RpgAgentStage::ObjectRefining, + seed_text: seed_text.to_string(), + current_turn: 0, + progress_percent: 0, + stage, focus_card_id: None, anchor_content_json: "{}".to_string(), creator_intent_json: None, creator_intent_readiness_json: "{}".to_string(), anchor_pack_json: None, lock_state_json: None, - draft_profile_json: Some( - r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"# - .to_string(), - ), + draft_profile_json: draft_profile_json.map(str::to_string), last_assistant_reply: None, publish_gate_json: None, result_preview_json: None, @@ -6278,7 +6318,16 @@ mod tests { checkpoints_json: "[]".to_string(), created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), - }; + } + } + + #[test] + fn resolve_stable_agent_draft_profile_id_prefers_legacy_result_profile_id() { + let session = build_test_custom_world_agent_session( + "seed", + RpgAgentStage::ObjectRefining, + Some(r#"{"id":"drifted-profile","legacyResultProfile":{"id":"stable-profile"}}"#), + ); assert_eq!( resolve_stable_agent_draft_profile_id(&session), @@ -6286,6 +6335,37 @@ mod tests { ); } + #[test] + fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() { + let empty_session = + build_test_custom_world_agent_session("", RpgAgentStage::CollectingIntent, Some("{}")); + let seeded_session = build_test_custom_world_agent_session( + "想做一个海雾群岛", + RpgAgentStage::CollectingIntent, + Some("{}"), + ); + let drafted_session = + build_test_custom_world_agent_session("", RpgAgentStage::ObjectRefining, Some("{}")); + let profile_session = build_test_custom_world_agent_session( + "", + RpgAgentStage::CollectingIntent, + Some(r#"{"worldHook":"海雾会吞掉记错航线的人。"}"#), + ); + + assert!(!custom_world_agent_session_has_direct_work_content( + &empty_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &seeded_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &drafted_session, + )); + assert!(custom_world_agent_session_has_direct_work_content( + &profile_session, + )); + } + #[test] fn same_agent_draft_profile_candidate_requires_same_owner_active_draft_and_session() { let matching = CustomWorldProfile { diff --git a/src/App.tsx b/src/App.tsx index 89a89463..83e0285b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,125 @@ -import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell'; -import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession'; +import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'; + +import { useAuthUi } from './components/auth/AuthUiContext'; +import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell'; +import type { SelectionStage } from './components/platform-entry/platformEntryTypes'; +import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; +import { + APP_RUNTIME_ROUTES, + normalizeAppPath, + pushAppHistoryPath, + resolvePathForSelectionStage, + resolveSelectionStageFromPath, +} from './routing/appPageRoutes'; +import type { RpgRuntimeAppIntent } from './RpgRuntimeApp'; +import type { CustomWorldProfile } from './types'; + +const RpgRuntimeApp = lazy(async () => { + const module = await import('./RpgRuntimeApp'); + return { + default: module.RpgRuntimeApp, + }; +}); + +function isRpgRuntimeRoute(pathname: string) { + const normalizedPath = normalizeAppPath(pathname); + return ( + normalizedPath === APP_RUNTIME_ROUTES['rpg-character-select'] || + normalizedPath === APP_RUNTIME_ROUTES['rpg-adventure'] + ); +} export default function App() { - const gameShellProps = useRpgRuntimeSession(); + const authUi = useAuthUi(); + const runtimeIntentTokenRef = useRef(0); + const [runtimeIntent, setRuntimeIntent] = + useState(null); + const [isRuntimeActive, setIsRuntimeActive] = useState(() => + isRpgRuntimeRoute(window.location.pathname), + ); + const [selectionStage, setRawSelectionStage] = useState(() => + resolveSelectionStageFromPath(window.location.pathname), + ); - return ; + const setSelectionStage = useCallback((stage: SelectionStage) => { + setRawSelectionStage(stage); + pushAppHistoryPath(resolvePathForSelectionStage(stage)); + }, []); + + useEffect(() => { + const syncStageFromHistory = () => { + if (isRpgRuntimeRoute(window.location.pathname)) { + setIsRuntimeActive(true); + return; + } + + setIsRuntimeActive(false); + setRawSelectionStage( + resolveSelectionStageFromPath(window.location.pathname), + ); + }; + + window.addEventListener('popstate', syncStageFromHistory); + return () => window.removeEventListener('popstate', syncStageFromHistory); + }, []); + + const createRuntimeIntent = useCallback( + (intent: Omit) => { + runtimeIntentTokenRef.current += 1; + setRuntimeIntent({ + ...intent, + token: runtimeIntentTokenRef.current, + }); + setIsRuntimeActive(true); + }, + [], + ); + + const handleContinueGame = useCallback( + (snapshot?: HydratedSavedGameSnapshot | null) => { + createRuntimeIntent({ + kind: 'snapshot', + snapshot: snapshot ?? null, + }); + }, + [createRuntimeIntent], + ); + + const handleCustomWorldSelect = useCallback( + (customWorldProfile: CustomWorldProfile) => { + createRuntimeIntent({ + kind: 'custom-world', + profile: customWorldProfile, + }); + }, + [createRuntimeIntent], + ); + const platformThemeClass = + authUi?.platformTheme === 'dark' + ? 'platform-theme--dark' + : 'platform-theme--light'; + + if (isRuntimeActive) { + return ( + + + + ); + } + + return ( +
+ {}} + handleCustomWorldSelect={handleCustomWorldSelect} + /> +
+ ); } diff --git a/src/RpgRuntimeApp.tsx b/src/RpgRuntimeApp.tsx new file mode 100644 index 00000000..4f1e9bc5 --- /dev/null +++ b/src/RpgRuntimeApp.tsx @@ -0,0 +1,45 @@ +import { useEffect, useRef } from 'react'; + +import { RpgRuntimeShell } from './components/rpg-runtime-shell/RpgRuntimeShell'; +import { useRpgRuntimeSession } from './hooks/rpg-session/useRpgRuntimeSession'; +import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes'; +import type { CustomWorldProfile } from './types'; + +export type RpgRuntimeAppIntent = + | { + token: number; + kind: 'custom-world'; + profile: CustomWorldProfile; + } + | { + token: number; + kind: 'snapshot'; + snapshot: HydratedSavedGameSnapshot | null; + }; + +export function RpgRuntimeApp({ + initialIntent, +}: { + initialIntent: RpgRuntimeAppIntent | null; +}) { + const gameShellProps = useRpgRuntimeSession(); + const handledIntentTokenRef = useRef(null); + + useEffect(() => { + if (!initialIntent || handledIntentTokenRef.current === initialIntent.token) { + return; + } + + handledIntentTokenRef.current = initialIntent.token; + if (initialIntent.kind === 'custom-world') { + gameShellProps.entry.handleCustomWorldSelect(initialIntent.profile); + return; + } + + gameShellProps.entry.handleContinueGame(initialIntent.snapshot); + }, [gameShellProps.entry, initialIntent]); + + return ; +} + +export default RpgRuntimeApp; diff --git a/src/components/platform-entry/platformEntryTypes.ts b/src/components/platform-entry/platformEntryTypes.ts index 081c98a8..0b19b98d 100644 --- a/src/components/platform-entry/platformEntryTypes.ts +++ b/src/components/platform-entry/platformEntryTypes.ts @@ -2,7 +2,7 @@ import type { CustomWorldAgentSessionSnapshot, } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; -import type { CustomWorldProfile, GameState } from '../../types'; +import type { CustomWorldProfile } from '../../types'; export type SelectionStage = | 'platform' @@ -34,7 +34,6 @@ export type SyncedAgentDraftResult = { export type PlatformEntryFlowShellProps = { selectionStage: SelectionStage; setSelectionStage: (stage: SelectionStage) => void; - gameState: GameState; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 0ce3b96f..e032724b 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -21,7 +21,6 @@ import type { AuthUser } from '../../services/authService'; import { ApiClientError } from '../../services/apiClient'; import { clearRpgProfileBrowseHistory as clearProfileBrowseHistory, - deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetail, getRpgProfileDashboard as getProfileDashboard, listRpgEntryWorldGallery, @@ -47,8 +46,10 @@ import { listPuzzleGallery, } from '../../services/puzzle-gallery'; import { listPuzzleWorks } from '../../services/puzzle-works'; -import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import type { GameState } from '../../types'; +import { + deleteRpgEntryWorldProfile, + getRpgEntryWorldGalleryDetailByCode, +} from '../../services/rpg-entry/rpgEntryLibraryClient'; import { AuthUiContext, type PlatformSettingsSection, @@ -130,6 +131,7 @@ vi.mock('../../services/puzzle-gallery', () => ({ })); vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({ + deleteRpgEntryWorldProfile: vi.fn(), getRpgEntryWorldGalleryDetailByCode: vi.fn(), })); @@ -522,7 +524,6 @@ function TestWrapper({ {})} @@ -574,7 +575,7 @@ beforeEach(() => { savedAt: '2026-04-19T12:00:00.000Z', bottomTab: 'adventure', currentStory: null, - gameState: {} as GameState, + gameState: {}, } as HydratedSavedGameSnapshot, }); vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]); @@ -1477,10 +1478,13 @@ test('published puzzle detail returns to the source platform tab', async () => { await waitFor(() => { expect(document.getElementById('platform-tab-panel-category')).toBeTruthy(); }); + await waitFor(() => { + const categoryPanel = getPlatformTabPanel('category'); + expect( + within(categoryPanel).getAllByText('星桥机关').length, + ).toBeGreaterThan(0); + }); const categoryPanel = getPlatformTabPanel('category'); - expect( - within(categoryPanel).getAllByText('星桥机关').length, - ).toBeGreaterThan(0); await user.click( within(categoryPanel).getByRole('button', { @@ -2114,7 +2118,6 @@ test('agent draft result publishes to gallery from publish panel', async () => { {}} @@ -2189,7 +2192,6 @@ test('agent draft result test button enters current draft without publish gate', {}} @@ -2827,7 +2829,7 @@ test('save tab can resume a selected archive directly into the game', async () = currentStory: null, gameState: { worldType: 'CUSTOM', - } as GameState, + }, } as HydratedSavedGameSnapshot, }); diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index 1b65edf4..d3d5c2f4 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -15,7 +15,7 @@ import { listRpgEntryWorldLibrary, publishRpgEntryWorldProfile, unpublishRpgEntryWorldProfile, -} from '../../services/rpg-entry'; +} from '../../services/rpg-entry/rpgEntryLibraryClient'; import { ApiClientError } from '../../services/apiClient'; import type { CustomWorldProfile } from '../../types'; import { diff --git a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx index 1b580d31..15f4deef 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx @@ -172,7 +172,6 @@ export function RpgRuntimeStageRouter({ { - if (!authenticatedUserId) { + if (!authenticatedUserId && !snapshotOverride) { return false; }