fix: route recommend puzzle next through feed

This commit is contained in:
2026-06-07 14:14:16 +08:00
parent e56a25243c
commit 8f460feb41
11 changed files with 84 additions and 126 deletions

View File

@@ -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 曾固定主题赛

View File

@@ -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` 内。

View File

@@ -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 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼

View File

@@ -136,7 +136,6 @@ export interface DragPuzzlePieceRequest {
export interface AdvancePuzzleNextLevelRequest {
targetProfileId?: string | null;
preferSimilarWork?: boolean;
}
export interface UsePuzzleRuntimePropRequest {

View File

@@ -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,

View File

@@ -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({
vi.mocked(startPuzzleRun).mockImplementation(async (payload) => {
const run =
payload.profileId === nextRecommendWork.profileId
? nextRecommendRun
: startedRun;
return {
run: {
...startedRun,
...run,
currentLevel: {
...startedRun.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(<TestWrapper withAuth />);
@@ -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 () => {

View File

@@ -1039,7 +1039,7 @@ function renderStatefulLoggedOutHomeView(
function StatefulLoggedOutHomeView() {
const [activeTab, setActiveTab] =
useState<RpgEntryHomeViewProps['activeTab']>('category');
useState<RpgEntryHomeViewProps['activeTab']>('home');
return (
<AuthUiContext.Provider
@@ -3640,24 +3640,19 @@ test('public gallery cards hide phone masked author and public user code', async
expect(within(card).queryByText('SY-00000003')).toBeNull();
});
test('logged out mobile shell defaults to discover tab', () => {
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={<div data-testid="recommend-runtime" />}
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) {

View File

@@ -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 (

View File

@@ -74,8 +74,7 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTabState] =
useState<PlatformHomeTab>('category');
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(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,
]);

View File

@@ -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<PuzzleRunResponse>(

View File

@@ -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,