diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 83bd37c1..4fb35137 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -240,9 +240,9 @@ ## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态 - 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。 -- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置。 +- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。推荐页拼图“下一关”必须走推荐页统一相邻作品切换流程,前端不得传递 `preferSimilarWork`,也不得让拼图 runtime 自己把当前 run handoff 到其它作品。 - 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。 -- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。 +- 验证方式:点击推荐页拼图“下一关”后,页面先保留 `puzzle-board`,且不出现 `加载中...` 占位;随后应调用推荐页统一下一作品启动逻辑,而不是调用 `advancePuzzleNextLevel(...)`。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-05-24 创作入口页 banner 曾固定主题赛 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a4c67529..c3e5ad97 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1532,7 +1532,7 @@ ## 推荐页嵌入拼图通关结算不要放在运行态内部 absolute 层 -- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜、下一关按钮或相似作品卡被截断。 +- 现象:推荐页里玩拼图通关后,结算面板只显示上半部分,排行榜或下一关按钮被截断。 - 原因:推荐页把运行态放在滑动作品卡的视觉区内,`platform-recommend-swipe-page`、`platform-recommend-swipe-card__visual` 和 `platform-recommend-runtime-viewport` 都是 `overflow: hidden`;拼图通关结算如果仍是运行态内部 `absolute inset-0` 弹层,就只能在半屏卡片区域里显示。 - 处理:`PuzzleRuntimeShell` 在 `embedded` 模式下把通关结算层通过 portal 挂到 `document.body`,使用 `puzzle-runtime-modal-overlay--fixed` 页面级 fixed 浮层;非嵌入态继续使用运行态内部覆盖层。 - 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "推荐页嵌入拼图通关结算使用页面级浮层避免卡片裁剪"`,确认弹层不再位于 `.platform-recommend-runtime-viewport` 内。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a9da1df2..561a690c 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -124,15 +124,15 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 拼图试玩和正式运行态刷新恢复不复用创作私有 query。进入 `/runtime/puzzle` 时必须写入 `runtimeProfileId`、草稿 `runtimeSessionId`、可选 `runtimeLevelId`、公开作品 `work` 和 `mode=draft|published`;进入运行态的导航顺序必须先切到 `/runtime/puzzle`,再写这些 runtime query,避免被阶段导航清掉后刷新停在“正在进入拼图关卡”。 - 结果页生成关卡图时若关卡名为空,前端必须传 `shouldAutoNameLevel=true`,后端复用首关命名契约先按画面描述生成关卡名,再在图片生成后用视觉命名结果精修,并把生成名和 UI 背景提示词随本次关卡快照写回。 - 拼图运行态背景优先读取当前关卡 `levelBackgroundImageSrc/levelBackgroundImageObjectKey`,旧数据才兼容 `uiBackgroundImageSrc/uiBackgroundImageObjectKey`;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺关卡背景时必须继承同作品首个可用关卡背景,仍缺失时才沿用当前运行态快照背景或默认 UI。运行态按钮视觉优先读取当前关卡 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`,先按透明 alpha 自动边界检测识别 spritesheet 中的独立按钮展示矩形,再按原图位置从左到右、从上到下映射到返回、设置、下一关、提示、原图、冻结;同一组件还要按较高 alpha 阈值派生紧致点击热区,透明留白和柔边低 alpha 区域尽量不响应点击。检测失败时回退旧固定六格裁切,缺失时才用现有图标按钮兜底。有 spritesheet 时,返回、设置和下一关的点击容器只提供透明点击区,不再叠加默认白色圆形底、胶囊主按钮底或额外文字;下一关按钮在通关弹窗和底部入口中都直接使用 spritesheet 裁切出的 next 素材作为按钮本体。底部提示、原图、冻结三枚素材按检测矩形的原始宽高比显示,不能强行拉伸成正圆或铺满整列。底部道具区不再使用连片胶囊背景,提示、原图、冻结三个按钮均匀分布;运行态只展示按钮素材本身,不额外叠加“提示 / 原图 / 冻结”文字。 -- 推荐页本身不是登录门禁入口,未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 +- 推荐页本身不是登录门禁入口,平台首页默认落点也是推荐页;未登录用户点击底部或侧边栏的推荐 Tab 应直接进入嵌入运行态,不主动打开登录弹窗。推荐页嵌入运行态必须按真实身份分流:已登录用户或本地已有 access token 时,启动拼图和后续排行榜 / 下一关等正式请求继续走账号 Bearer;只有确认为匿名访客时才申请并透传 runtime guest token。`/api/runtime/puzzle/runs*` 后端统一接受 `RuntimePrincipal`,可识别账号用户和匿名 runtime guest;推荐卡片的后台读写请求仍使用 local auth impact,避免单卡 401 清空整站登录态。创作、个人作品、删除、发布、Remix 等账号或所有权动作仍保持普通用户鉴权。 - 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。 - 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。 - 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。 - 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。 - 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。 - 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。 -- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。 -- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。 +- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜和下一关按钮。 +- 推荐页嵌入拼图运行态时,“下一关”必须走推荐页统一相邻作品切换流程,不得由拼图 runtime 自己传递 `preferSimilarWork` 或私自把当前 run handoff 到其它拼图作品。点击后应与推荐页底部“下一个”使用同一套 `activeRecommendEntryKey` / 推荐队列切换和新作品启动语义,推荐卡标题、分享 / 点赞 / 改造基准都由统一推荐切换结果决定。切换发起前仍必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;后续局部同步状态由推荐页启动新作品的统一 busy 表现承接。 - 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`。 - 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。 - 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。 @@ -175,7 +175,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 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 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 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 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/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 11d91c27..5e705782 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -136,7 +136,6 @@ export interface DragPuzzlePieceRequest { export interface AdvancePuzzleNextLevelRequest { targetProfileId?: string | null; - preferSimilarWork?: boolean; } export interface UsePuzzleRuntimePropRequest { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 6f206be9..812e70ed 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -5808,10 +5808,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); setSelectedPuzzleDetail(null); - platformBootstrap.setPlatformTab(authUi?.user ? 'home' : 'category'); + platformBootstrap.setPlatformTab('home'); setSelectionStage('platform'); clearPuzzleRuntimeUrlState(); - }, [authUi?.user, platformBootstrap, setSelectionStage]); + }, [platformBootstrap, setSelectionStage]); useEffect(() => { if ( @@ -12566,10 +12566,6 @@ export function PlatformEntryFlowShellImpl({ ? await buildRecommendRuntimeGuestOptions() : {}; const targetProfileId = _target?.profileId?.trim() ?? ''; - const preferSimilarWork = - activeRecommendRuntimeKind === 'puzzle' && - puzzleRuntimeReturnStage === 'platform' && - puzzleRun.nextLevelMode === 'sameWork'; if (puzzleRun.nextLevelMode === 'similarWorks' && targetProfileId) { const itemPromise = selectedPuzzleDetail?.profileId === targetProfileId @@ -12609,13 +12605,10 @@ export function PlatformEntryFlowShellImpl({ puzzleRuntimeAuthMode === 'isolated' ? await advancePuzzleNextLevel( puzzleRun.runId, - preferSimilarWork ? { preferSimilarWork: true } : {}, + {}, runtimeGuestOptions, ) - : await advancePuzzleNextLevel( - puzzleRun.runId, - preferSimilarWork ? { preferSimilarWork: true } : {}, - ); + : await advancePuzzleNextLevel(puzzleRun.runId, {}); const nextProfileId = run.currentLevel?.profileId?.trim() ?? ''; if ( nextProfileId && @@ -16011,8 +16004,8 @@ export function PlatformEntryFlowShellImpl({ onDragPiece={(payload) => { void dragPuzzlePiece(payload); }} - onAdvanceNextLevel={(target) => { - void advancePuzzleLevel(target); + onAdvanceNextLevel={() => { + selectAdjacentRecommendRuntimeEntry(1, activeRecommendEntryKey); }} onRestartLevel={() => { void restartPuzzleCurrentLevel(); @@ -16266,9 +16259,9 @@ export function PlatformEntryFlowShellImpl({ squareHoleRun, submitBigFishInput, submitVisualNovelRuntimeAction, - advancePuzzleLevel, dragPuzzlePiece, restartPuzzleCurrentLevel, + selectAdjacentRecommendRuntimeEntry, setSquareHoleError, swapPuzzlePiecesInRun, syncPuzzleRuntimeTimeout, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index a5e77bc8..cc7efee6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -13,16 +13,16 @@ import type { CustomWorldAgentSessionSnapshot, CustomWorldWorkSummary, } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { + BabyObjectMatchDraft, + CreateBabyObjectMatchDraftRequest, +} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { JumpHopRuntimeRunSnapshotResponse, JumpHopWorkDetailResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, } from '../../../packages/shared/src/contracts/jumpHop'; -import type { - BabyObjectMatchDraft, - CreateBabyObjectMatchDraftRequest, -} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; @@ -71,7 +71,6 @@ import { submitBigFishInput, } from '../../services/big-fish-runtime'; import { listBigFishWorks } from '../../services/big-fish-works'; -import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { type CreationEntryConfig, fetchCreationEntryConfig, @@ -91,6 +90,7 @@ import { regenerateBabyObjectMatchDraftAssets, saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; +import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { @@ -334,10 +334,6 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = { notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; -const RECOMMEND_RUNTIME_AUTH_OPTIONS = { - ...ISOLATED_RUNTIME_AUTH_OPTIONS, - runtimeGuestToken: 'runtime-guest-token', -}; const LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS = ISOLATED_RUNTIME_AUTH_OPTIONS; function getPlatformTabPanel(tab: string) { @@ -7505,7 +7501,7 @@ test('logged out home recommendation next starts the next puzzle work', async () }); }); -test('home recommendation puzzle next level switches to similar work detail', async () => { +test('home recommendation puzzle next level uses unified recommend switching', async () => { const user = userEvent.setup(); const entryWork = { workId: 'puzzle-work-public-guest-1', @@ -7547,17 +7543,17 @@ test('home recommendation puzzle next level switches to similar work detail', as }, ], } satisfies PuzzleWorkSummary; - const similarWork = { + const nextRecommendWork = { ...entryWork, - workId: 'puzzle-work-similar-guest-1', - profileId: 'puzzle-profile-similar-guest-1', + workId: 'puzzle-work-public-guest-2', + profileId: 'puzzle-profile-public-guest-2', levelName: '风塔试炼', - summary: '另一套奇幻机关拼图。', + summary: '另一套推荐拼图。', levels: [ { - levelId: 'similar-level-1', + levelId: 'next-recommend-level-1', levelName: '风塔试炼', - pictureDescription: '相似作品首关。', + pictureDescription: '推荐队列下一张拼图。', candidates: [], selectedCandidateId: null, coverImageSrc: null, @@ -7586,47 +7582,35 @@ test('home recommendation puzzle next level switches to similar work detail', as entryWork.profileId, entryWork.levelName, ); - const similarRun = { - ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName), - runId: clearedRun.runId, - entryProfileId: entryWork.profileId, - currentLevelIndex: 2, - currentLevel: { - ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName) - .currentLevel!, - runId: clearedRun.runId, - levelIndex: 2, - levelId: 'similar-level-1', - startedAtMs: Date.now(), - }, - }; + const nextRecommendRun = buildMockPuzzleRun( + nextRecommendWork.profileId, + nextRecommendWork.levelName, + ); vi.mocked(listPuzzleGallery).mockResolvedValue({ - items: [entryWork], + items: [entryWork, nextRecommendWork], }); vi.mocked(getPuzzleGalleryDetail).mockImplementation(async (profileId) => ({ - item: profileId === similarWork.profileId ? similarWork : entryWork, + item: profileId === nextRecommendWork.profileId ? nextRecommendWork : entryWork, })); - vi.mocked(startPuzzleRun).mockResolvedValue({ - run: { - ...startedRun, - currentLevel: { - ...startedRun.currentLevel!, - startedAtMs: Date.now(), + vi.mocked(startPuzzleRun).mockImplementation(async (payload) => { + const run = + payload.profileId === nextRecommendWork.profileId + ? nextRecommendRun + : startedRun; + return { + run: { + ...run, + currentLevel: { + ...run.currentLevel!, + startedAtMs: Date.now(), + }, }, - }, + }; }); vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: clearedRunWithSameWorkNext, }); - let resolveAdvancePuzzleNextLevel!: (value: { - run: PuzzleRunSnapshot; - }) => void; - vi.mocked(advancePuzzleNextLevel).mockReturnValue( - new Promise((resolve) => { - resolveAdvancePuzzleNextLevel = resolve; - }), - ); vi.mocked(swapLocalPuzzlePieces).mockReturnValue(clearedRun); render(); @@ -7655,24 +7639,23 @@ test('home recommendation puzzle next level switches to similar work detail', as await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, { - preferSimilarWork: true, - }); + expect(startPuzzleRun).toHaveBeenCalledWith( + { + profileId: nextRecommendWork.profileId, + levelId: null, + }, + LOGGED_IN_RECOMMEND_RUNTIME_AUTH_OPTIONS, + ); }); + expect(advancePuzzleNextLevel).not.toHaveBeenCalled(); expect(screen.getByTestId('puzzle-board')).toBeTruthy(); expect(screen.queryByText('加载中...')).toBeNull(); - - resolveAdvancePuzzleNextLevel({ run: similarRun }); - await waitFor(() => { - expect(getPuzzleGalleryDetail).toHaveBeenCalledWith(similarWork.profileId); - }); expect( await screen.findByLabelText('风塔试炼 作品信息', undefined, { timeout: 3000, }), ).toBeTruthy(); - expect(screen.getAllByText('风塔试炼').length).toBeGreaterThan(0); - expect(startPuzzleRun).toHaveBeenCalledTimes(1); + expect(startPuzzleRun).toHaveBeenCalledTimes(2); }); test('home recommendation Match3D runtime keeps profile generated models when card summary is stale', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 4c467e98..684729f4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -1039,7 +1039,7 @@ function renderStatefulLoggedOutHomeView( function StatefulLoggedOutHomeView() { const [activeTab, setActiveTab] = - useState('category'); + useState('home'); return ( { +test('logged out mobile shell defaults to recommend tab', () => { const { container } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], }); const activePanel = container.querySelector('.platform-tab-panel--active'); - expect(activePanel?.id).toBe('platform-tab-panel-category'); - expect( - screen.getByPlaceholderText('搜索作品号、名称、作者、描述'), - ).toBeTruthy(); - expect(container.querySelector('.platform-mobile-topbar')).toBeTruthy(); + expect(activePanel?.id).toBe('platform-tab-panel-home'); expect( container.querySelector('.platform-mobile-entry-shell--recommend'), - ).toBeNull(); + ).toBeTruthy(); }); test('logged out recommend tab opens embedded runtime without login modal', async () => { - const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', @@ -3667,10 +3662,6 @@ test('logged out recommend tab opens embedded runtime without login modal', asyn throw new Error('缺少底部导航'); } - await user.click( - within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), - ); - expect(openLoginModal).not.toHaveBeenCalled(); expect(container.querySelector('.platform-recommend-cover-only')).toBeNull(); expect(container.querySelector('.platform-mobile-topbar')).toBeNull(); @@ -3683,7 +3674,6 @@ test('logged out recommend tab opens embedded runtime without login modal', asyn }); test('logged out recommend runtime keeps detail callback idle', async () => { - const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], @@ -3695,10 +3685,6 @@ test('logged out recommend runtime keeps detail callback idle', async () => { throw new Error('缺少底部导航'); } - await user.click( - within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }), - ); - expect(openLoginModal).not.toHaveBeenCalled(); expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); expect(onOpenGalleryDetail).not.toHaveBeenCalled(); @@ -3920,7 +3906,7 @@ test('mobile recommend startup keeps cover visible without loading copy', () => expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); -test('mobile recommend next level keeps runtime visual stable when active work changes', async () => { +test('mobile recommend keeps runtime visual stable when active entry changes', async () => { const animationCallbacks: FrameRequestCallback[] = []; Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, @@ -3944,18 +3930,18 @@ test('mobile recommend next level keeps runtime visual stable when active work c worldName: '当前拼图', coverImageSrc: 'current-cover.png', } satisfies PlatformPublicGalleryCard; - const similarEntry = { + const nextEntry = { ...puzzlePublicEntry, - workId: 'puzzle-work-similar-1', - profileId: 'puzzle-profile-similar-1', + workId: 'puzzle-work-next-1', + profileId: 'puzzle-profile-next-1', ownerUserId: 'user-feed-2', - publicWorkCode: 'PZ-SIMILAR1', - worldName: '相似拼图', - coverImageSrc: 'similar-cover.png', + publicWorkCode: 'PZ-NEXT1', + worldName: '下一张拼图', + coverImageSrc: 'next-cover.png', } satisfies PlatformPublicGalleryCard; const { rerender } = renderLoggedOutHomeView(vi.fn(), { - latestEntries: [firstEntry, similarEntry], + latestEntries: [firstEntry, nextEntry], activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1', isRecommendRuntimeReady: true, }); @@ -3998,7 +3984,7 @@ test('mobile recommend next level keeps runtime visual stable when active work c saveEntries={[]} saveError={null} featuredEntries={[]} - latestEntries={[firstEntry, similarEntry]} + latestEntries={[firstEntry, nextEntry]} myEntries={[]} historyEntries={[]} profileDashboard={null} @@ -4013,7 +3999,7 @@ test('mobile recommend next level keeps runtime visual stable when active work c onOpenCreateTypePicker={vi.fn()} onOpenGalleryDetail={vi.fn()} recommendRuntimeContent={
} - activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-similar-1" + activeRecommendEntryKey="puzzle:user-feed-2:puzzle-profile-next-1" isRecommendRuntimeReady onOpenLibraryDetail={vi.fn()} onSearchPublicCode={vi.fn()} @@ -4026,7 +4012,7 @@ test('mobile recommend next level keeps runtime visual stable when active work c ) as HTMLElement | null; expect(rail?.className).toContain('platform-recommend-swipe-rail--settled'); expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)'); - expect(screen.getByLabelText('相似拼图 作品信息')).toBeTruthy(); + expect(screen.getByLabelText('下一张拼图 作品信息')).toBeTruthy(); expect( document.querySelector('.platform-recommend-runtime-cover')?.className, ).toContain('platform-recommend-runtime-cover--hidden'); @@ -4394,6 +4380,7 @@ test('mobile discover recommend feed only rotates the card closest to screen cen }); test('mobile discover recommend feed renders cover fallback for legacy browsers', async () => { + const user = userEvent.setup(); renderStatefulLoggedOutHomeView({ latestEntries: [ { @@ -4403,6 +4390,7 @@ test('mobile discover recommend feed renders cover fallback for legacy browsers' }, ], }); + await user.click(screen.getByRole('button', { name: '发现' })); const discoverPanel = document.getElementById('platform-tab-panel-category'); if (!discoverPanel) { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index e5f62d21..525437ff 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1019,8 +1019,8 @@ function RecommendRuntimeVisual({ } previousEntryKeyRef.current = activeEntryKey; setIsRuntimeMounted((currentValue) => { - // 中文注释:拼图推荐流“下一关”会在同一个 run 内切到相似作品; - // 此时只更新作品信息和分享基准,不应重显封面造成运行态闪跳。 + // 中文注释:推荐运行态已挂载后,用户切换推荐作品只更新作品信息; + // 不重显封面,避免已 ready 的运行态视觉闪跳。 if (currentValue && !isStarting && isRuntimeReady) { return currentValue; } @@ -4425,9 +4425,9 @@ export function RpgEntryHomeView({ useEffect(() => { if (!visibleTabs.includes(activeTab)) { - onTabChange(isAuthenticated ? 'home' : 'category'); + onTabChange('home'); } - }, [activeTab, isAuthenticated, onTabChange, visibleTabs]); + }, [activeTab, onTabChange, visibleTabs]); useEffect(() => { if ( diff --git a/src/components/rpg-entry/useRpgEntryBootstrap.ts b/src/components/rpg-entry/useRpgEntryBootstrap.ts index ded70c37..2e149512 100644 --- a/src/components/rpg-entry/useRpgEntryBootstrap.ts +++ b/src/components/rpg-entry/useRpgEntryBootstrap.ts @@ -74,8 +74,7 @@ export function useRpgEntryBootstrap( PlatformBrowseHistoryEntry[] >([]); const [saveEntries, setSaveEntries] = useState([]); - const [platformTab, setPlatformTabState] = - useState('category'); + const [platformTab, setPlatformTabState] = useState('home'); const [platformError, setPlatformError] = useState(null); const [dashboardError, setDashboardError] = useState(null); const [historyError, setHistoryError] = useState(null); @@ -351,8 +350,8 @@ export function useRpgEntryBootstrap( !hasInitialAgentSession && !hasExplicitPlatformTabSelectionRef.current ) { - // 中文注释:新用户先进入发现页;推荐页可直接进入,真正受保护的动作再单独做登录门禁。 - setPlatformTabState(isAuthenticated ? 'home' : 'category'); + // 中文注释:新用户也先进入推荐页;真正受保护的动作再单独做登录门禁。 + setPlatformTabState('home'); } } finally { if (isActive) { @@ -369,7 +368,6 @@ export function useRpgEntryBootstrap( canReadProtectedData, getProfileDashboard, hasInitialAgentSession, - isAuthenticated, user, ]); diff --git a/src/services/puzzle-runtime/puzzleRuntimeClient.ts b/src/services/puzzle-runtime/puzzleRuntimeClient.ts index be9393ef..8c72b0e1 100644 --- a/src/services/puzzle-runtime/puzzleRuntimeClient.ts +++ b/src/services/puzzle-runtime/puzzleRuntimeClient.ts @@ -1,6 +1,6 @@ import type { - DragPuzzlePieceRequest, AdvancePuzzleNextLevelRequest, + DragPuzzlePieceRequest, PuzzleRunResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, @@ -119,7 +119,7 @@ export async function dragPuzzlePieceOrGroup( } /** - * 进入推荐出的下一关。 + * 进入当前 run 的下一关。 */ export async function advancePuzzleNextLevel( runId: string, @@ -128,10 +128,8 @@ export async function advancePuzzleNextLevel( ) { const requestOptions = buildRuntimeGuestAuthOptions(options); const targetProfileId = payload.targetProfileId?.trim() ?? ''; - const preferSimilarWork = payload.preferSimilarWork === true; const requestPayload = { ...(targetProfileId ? { targetProfileId } : {}), - ...(preferSimilarWork ? { preferSimilarWork: true } : {}), }; const hasRequestPayload = Object.keys(requestPayload).length > 0; return requestJson( diff --git a/src/services/recommendedRuntimeGuestLaunch.test.ts b/src/services/recommendedRuntimeGuestLaunch.test.ts index eb5307c0..e7cd8345 100644 --- a/src/services/recommendedRuntimeGuestLaunch.test.ts +++ b/src/services/recommendedRuntimeGuestLaunch.test.ts @@ -13,8 +13,8 @@ vi.mock('./apiClient', async () => { }; }); -import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; import { startBarkBattleRun } from './bark-battle-runtime/barkBattleRuntimeClient'; +import { startBigFishRun } from './big-fish-runtime/bigFishRuntimeClient'; import { startJumpHopRuntimeRun } from './jump-hop/jumpHopClient'; import { startMatch3DRun } from './match3d-runtime/match3dRuntimeClient'; import { @@ -130,10 +130,10 @@ describe('recommended runtime guest launch clients', () => { }, ); - it('puzzle next level can carry preferSimilarWork through the runtime guest request', async () => { + it('puzzle next level keeps the default current-run handoff without a request body', async () => { await advancePuzzleNextLevel( 'run-puzzle-1', - { preferSimilarWork: true }, + {}, { runtimeGuestToken: 'runtime-guest-token' }, ); @@ -144,11 +144,10 @@ describe('recommended runtime guest launch clients', () => { method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer runtime-guest-token', - 'Content-Type': 'application/json', }), - body: JSON.stringify({ preferSimilarWork: true }), }), ); + expect(init.body).toBeUndefined(); expect(options).toEqual( expect.objectContaining({ skipAuth: true,