diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index 4888ef7b..04be2504 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -207,7 +207,7 @@
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
-- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。
+- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index fa82cc45..063f48f8 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -126,7 +126,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
-- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
+- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准。
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
@@ -156,7 +156,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
-推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
+推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `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 等账号/所有权动作仍保持普通用户鉴权。
## 敲木鱼
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
index c87120ba..b4d77e98 100644
--- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
+++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
@@ -545,6 +545,7 @@ type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
type RecommendRuntimeState = {
activeKind: RecommendRuntimeKind | null;
+ barkBattlePublishedConfig: BarkBattlePublishedConfig | null;
babyObjectMatchDraft: BabyObjectMatchDraft | null;
bigFishRun: BigFishRuntimeSnapshotResponse | null;
jumpHopRun: JumpHopRunResponse['run'] | null;
@@ -730,7 +731,7 @@ function isRecommendRuntimeReadyForEntry(
return Boolean(state.visualNovelRun);
}
if (expectedKind === 'bark-battle') {
- return true;
+ return Boolean(state.barkBattlePublishedConfig);
}
if (expectedKind === 'edutainment') {
return Boolean(state.babyObjectMatchDraft);
@@ -15003,6 +15004,29 @@ export function PlatformEntryFlowShellImpl({
isDesktopLayout,
]);
+ const activeRecommendEntry =
+ activeRecommendEntryKey && !isDesktopLayout
+ ? (recommendRuntimeEntries.find(
+ (entry) =>
+ getPlatformPublicGalleryEntryKey(entry) ===
+ activeRecommendEntryKey,
+ ) ?? null)
+ : null;
+ const isActiveRecommendRuntimeReady =
+ activeRecommendEntry !== null &&
+ isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
+ activeKind: activeRecommendRuntimeKind,
+ barkBattlePublishedConfig,
+ babyObjectMatchDraft,
+ bigFishRun,
+ jumpHopRun,
+ match3dRun,
+ puzzleRun,
+ squareHoleRun,
+ visualNovelRun,
+ woodenFishRun,
+ });
+
useEffect(() => {
if (
isDesktopLayout ||
@@ -15020,25 +15044,6 @@ export function PlatformEntryFlowShellImpl({
return;
}
- const activeRecommendEntry = activeRecommendEntryKey
- ? (recommendRuntimeEntries.find(
- (entry) =>
- getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
- ) ?? null)
- : null;
- const isActiveRecommendRuntimeReady =
- activeRecommendEntry !== null &&
- isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
- activeKind: activeRecommendRuntimeKind,
- babyObjectMatchDraft,
- bigFishRun,
- jumpHopRun,
- match3dRun,
- puzzleRun,
- squareHoleRun,
- visualNovelRun,
- woodenFishRun,
- });
if (
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
isStartingRecommendEntry
@@ -15054,9 +15059,12 @@ export function PlatformEntryFlowShellImpl({
}, [
activeRecommendEntryKey,
activeRecommendRuntimeKind,
+ activeRecommendEntry,
+ barkBattlePublishedConfig,
babyObjectMatchDraft,
bigFishRun,
jumpHopRun,
+ isActiveRecommendRuntimeReady,
isStartingRecommendEntry,
match3dRun,
platformBootstrap.isLoadingPlatform,
@@ -16399,6 +16407,7 @@ export function PlatformEntryFlowShellImpl({
onOpenRecommendGalleryDetail={openRecommendGalleryDetail}
recommendRuntimeContent={recommendRuntimeContent}
activeRecommendEntryKey={activeRecommendEntryKey}
+ isRecommendRuntimeReady={isActiveRecommendRuntimeReady}
isStartingRecommendEntry={
isStartingRecommendEntry ||
isBigFishBusy ||
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
index 07f39d36..20f7a2f9 100644
--- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
+++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx
@@ -823,6 +823,7 @@ function renderLoggedOutHomeView(
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'isStartingRecommendEntry'
+ | 'isRecommendRuntimeReady'
| 'recommendRuntimeError'
| 'onSelectNextRecommendEntry'
| 'onSelectPreviousRecommendEntry'
@@ -883,6 +884,7 @@ function renderLoggedOutHomeView(
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
isStartingRecommendEntry={overrides.isStartingRecommendEntry}
+ isRecommendRuntimeReady={overrides.isRecommendRuntimeReady}
recommendRuntimeError={overrides.recommendRuntimeError}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={
@@ -3703,7 +3705,10 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
);
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
- expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
+ expect(
+ document.querySelector('.platform-recommend-runtime-cover'),
+ ).toBeTruthy();
+ expect(screen.queryByText('加载中...')).toBeNull();
expect(
document.querySelector('.platform-public-work-card__cover'),
).toBeNull();
@@ -3712,7 +3717,7 @@ test('logged out mobile recommend page renders runtime instead of cover', () =>
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
-test('mobile recommend loading state is themed instead of hardcoded black', () => {
+test('mobile recommend startup keeps cover visible without loading copy', () => {
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
@@ -3720,8 +3725,123 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
recommendRuntimeContent: null,
});
- expect(document.querySelector('.platform-recommend-cover-only')).toBeNull();
- expect(screen.getByText('加载中...')).toBeTruthy();
+ expect(
+ document.querySelector('.platform-recommend-runtime-cover'),
+ ).toBeTruthy();
+ expect(screen.queryByText('加载中...')).toBeNull();
+ expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
+});
+
+test('mobile recommend next level keeps runtime visual stable when active work changes', async () => {
+ const animationCallbacks: FrameRequestCallback[] = [];
+ Object.defineProperty(window, 'requestAnimationFrame', {
+ configurable: true,
+ writable: true,
+ value: vi.fn((callback: FrameRequestCallback) => {
+ animationCallbacks.push(callback);
+ return animationCallbacks.length;
+ }),
+ });
+ Object.defineProperty(window, 'cancelAnimationFrame', {
+ configurable: true,
+ writable: true,
+ value: vi.fn(),
+ });
+ const firstEntry = {
+ ...puzzlePublicEntry,
+ workId: 'puzzle-work-feed-1',
+ profileId: 'puzzle-profile-feed-1',
+ ownerUserId: 'user-feed-1',
+ publicWorkCode: 'PZ-FEED1',
+ worldName: '当前拼图',
+ coverImageSrc: 'current-cover.png',
+ } satisfies PlatformPublicGalleryCard;
+ const similarEntry = {
+ ...puzzlePublicEntry,
+ workId: 'puzzle-work-similar-1',
+ profileId: 'puzzle-profile-similar-1',
+ ownerUserId: 'user-feed-2',
+ publicWorkCode: 'PZ-SIMILAR1',
+ worldName: '相似拼图',
+ coverImageSrc: 'similar-cover.png',
+ } satisfies PlatformPublicGalleryCard;
+
+ const { rerender } = renderLoggedOutHomeView(vi.fn(), {
+ latestEntries: [firstEntry, similarEntry],
+ activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
+ isRecommendRuntimeReady: true,
+ });
+
+ act(() => {
+ animationCallbacks.splice(0).forEach((callback) => callback(16));
+ });
+ await waitFor(() => {
+ expect(
+ document.querySelector('.platform-recommend-runtime-cover')?.className,
+ ).toContain('platform-recommend-runtime-cover--hidden');
+ });
+
+ rerender(
+