diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2abff478..6b037331 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -176,7 +176,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 -推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。遮罩层级必须高于并隔离下层 runtime,防止运行态 HUD、canvas 或高 `z-index` 子层穿透到封面上;ready 前不展示“加载中”文案,但封面内必须保留无文案加载动效或进度条,避免用户误以为卡片损坏,也不得把未准备好的运行态直接暴露给用户。切换推荐作品时,如果上一条作品的启动请求、退出收口或目标玩法 busy 状态尚未结束,应继续显示当前作品卡面遮罩并等待下一轮自动启动;只有目标作品启动明确失败时,才显示“作品暂时无法进入,请稍后再试。”这类失败态。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e143cb0a..20ee3f6a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -12794,6 +12794,54 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); } }, [activeRecommendRuntimeKind, setPuzzleError]); + const isRecommendRuntimeStartPending = useCallback( + (runtimeKind: RecommendRuntimeKind) => { + if (runtimeKind === 'big-fish') { + return isBigFishBusy; + } + if (runtimeKind === 'match3d') { + return isMatch3DBusy; + } + if (runtimeKind === 'puzzle') { + return isPuzzleBusy || puzzleStartInFlightKeyRef.current !== null; + } + if (runtimeKind === 'jump-hop') { + return isJumpHopBusy; + } + if (runtimeKind === 'puzzle-clear') { + return isPuzzleClearBusy; + } + if (runtimeKind === 'wooden-fish') { + return isWoodenFishBusy; + } + if (runtimeKind === 'square-hole') { + return isSquareHoleBusy; + } + if (runtimeKind === 'visual-novel') { + return isVisualNovelBusy; + } + if (runtimeKind === 'bark-battle') { + return isBarkBattleBusy; + } + if (runtimeKind === 'edutainment') { + return isBabyObjectMatchBusy; + } + + return false; + }, + [ + isBabyObjectMatchBusy, + isBarkBattleBusy, + isBigFishBusy, + isJumpHopBusy, + isMatch3DBusy, + isPuzzleBusy, + isPuzzleClearBusy, + isSquareHoleBusy, + isVisualNovelBusy, + isWoodenFishBusy, + ], + ); const leaveAgentWorkspace = useCallback(() => { sessionController.resetSessionViewState(); @@ -15843,6 +15891,11 @@ export function PlatformEntryFlowShellImpl({ if (started) { setActiveRecommendRuntimeKind(runtimeKind); setActiveRecommendRuntimeError(null); + } else if (isRecommendRuntimeStartPending(runtimeKind)) { + // 中文注释:切换推荐作品时,旧作品启动请求或退出收口可能仍在进行; + // 这类中间态继续保留封面遮罩,不能误报成作品不可进入。 + setActiveRecommendRuntimeKind(runtimeKind); + setActiveRecommendRuntimeError(null); } else { setActiveRecommendRuntimeKind(null); setActiveRecommendRuntimeError('作品暂时无法进入,请稍后再试。'); @@ -15863,6 +15916,7 @@ export function PlatformEntryFlowShellImpl({ [ activeRecommendEntryKey, barkBattleGalleryEntries, + isRecommendRuntimeStartPending, saveAndExitRecommendPuzzleRuntime, selectedPuzzleDetail, setBarkBattleError, @@ -16394,7 +16448,9 @@ export function PlatformEntryFlowShellImpl({ if ( (activeRecommendEntry !== null && isActiveRecommendRuntimeReady) || - isStartingRecommendEntry + isStartingRecommendEntry || + (activeRecommendRuntimeKind !== null && + isRecommendRuntimeStartPending(activeRecommendRuntimeKind)) ) { return; } @@ -16413,6 +16469,7 @@ export function PlatformEntryFlowShellImpl({ bigFishRun, jumpHopRun, isActiveRecommendRuntimeReady, + isRecommendRuntimeStartPending, isStartingRecommendEntry, match3dRun, platformBootstrap.isLoadingPlatform, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 40159ab2..fa77aa1f 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -7658,6 +7658,113 @@ test('logged out home recommendation next starts the next puzzle work', async () }); }); +test('home recommendation keeps cover while switching during a pending puzzle start', async () => { + const user = userEvent.setup(); + const firstWork = { + workId: 'puzzle-work-pending-next-1', + profileId: 'puzzle-profile-pending-next-1', + ownerUserId: 'user-2', + sourceSessionId: 'puzzle-session-pending-next-1', + authorDisplayName: '拼图作者', + levelName: '雨港电路', + summary: '第一张公开拼图仍在启动。', + themeTags: ['雨港', '拼图'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T10:00:00.000Z', + publishedAt: '2026-04-25T10:00:00.000Z', + playCount: 47, + likeCount: 1, + publishReady: true, + } satisfies PuzzleWorkSummary; + const secondWork = { + ...firstWork, + workId: 'puzzle-work-pending-next-2', + profileId: 'puzzle-profile-pending-next-2', + ownerUserId: 'user-3', + sourceSessionId: 'puzzle-session-pending-next-2', + authorDisplayName: '贝壳作者', + levelName: '贝壳潮汐', + summary: '第二张公开拼图。', + themeTags: ['贝壳', '拼图'], + playCount: 1, + likeCount: 0, + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + } satisfies PuzzleWorkSummary; + let resolveFirstRun!: (value: { run: PuzzleRunSnapshot }) => void; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [firstWork, secondWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ + item: profileId === secondWork.profileId ? secondWork : firstWork, + })); + vi.mocked(startPuzzleRun).mockImplementationOnce( + (async () => + new Promise((resolve) => { + resolveFirstRun = resolve; + })) as typeof startPuzzleRun, + ); + + render( + {}, + requireAuth: (action) => action(), + })} + />, + ); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: firstWork.profileId, + levelId: null, + }, + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + }), + ); + }); + + await user.click(await screen.findByRole('button', { name: '下一个' })); + + expect( + screen.queryByText('作品暂时无法进入,请稍后再试。'), + ).toBeNull(); + expect( + await screen.findByLabelText('贝壳潮汐 作品信息', undefined, { + timeout: 3000, + }), + ).toBeTruthy(); + expect(startPuzzleRun).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstRun({ + run: buildMockPuzzleRun(firstWork.profileId, '后端拼图关卡'), + }); + }); + + await waitFor(() => { + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: secondWork.profileId, + levelId: null, + }, + expect.objectContaining({ + runtimeGuestToken: 'runtime-guest-token', + }), + ); + }); + expect( + screen.queryByText('作品暂时无法进入,请稍后再试。'), + ).toBeNull(); +}); + test('home recommendation puzzle next level uses unified recommend switching', async () => { const user = userEvent.setup(); const entryWork = {