diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 62a7c9bf..77ff4c31 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-25 平台首页推荐按桌面与移动断点分流 + +- 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 +- 决策:`RpgEntryHomeView` 只接受同一个 `isDesktopLayout` 断点判断;桌面端首页渲染桌面发现壳(`今日游戏`、`推荐`、`作品分类` 等),不挂移动推荐嵌入运行态;移动端 `home` 才渲染推荐卡与嵌入运行态。平台壳和首页视图都必须共用 `usePlatformDesktopLayout()`,不能在不同文件里各自判断断点。 +- 影响范围:`src/components/platform-entry/platformEntryResponsive.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、首页推荐相关测试与 `docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 验证方式:桌面宽度下首页应只看到桌面发现壳,窄屏下首页应只看到移动推荐流;`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation"`、`npm run typecheck`、`npm run check:encoding` 通过。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-25 新增玩法接入必须使用统一 SOP skill - 背景:敲木鱼、跳一跳、汪汪声浪等玩法接入过程中,作品架曾经没有被作为强制闭环验收项,导致玩法可以先完成创作、发布、运行态或广场,但用户在草稿 / 已发布作品架中看不到自己的作品。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 40ce7554..0b84d15f 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -23,6 +23,14 @@ - 验证:点拼图 / 抓大鹅 / 汪汪声浪卡片后,应看到各自既有工作台内容,例如测试中的 `拼图工作区:missing-session`、`抓大鹅工作区:missing-session` 或 `汪汪声浪配置表单`,并且不再出现“X 创作入口”空白页。 - 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 +## 首页推荐分流参数不能条件性调用 hook + +- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。 +- 原因:`RpgEntryHomeView` 曾经写成 `const isDesktopLayout = isDesktopLayoutProp ?? usePlatformDesktopLayout();`,当 `isDesktopLayoutProp` 存在时会跳过 hook 调用,导致 hook 顺序在不同渲染之间变化。 +- 处理:先无条件调用 `usePlatformDesktopLayout()`,再用 `isDesktopLayoutProp ?? detectedDesktopLayout` 合并;不要把 hook 调用藏在条件表达式里。 +- 验证:桌面与窄屏各刷新一次首页,控制台不再出现 hook 顺序错误;`npm run typecheck` 和首页推荐相关测试通过。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/platformEntryResponsive.ts`。 + ## 泥点不足提示不要把用户退回创作入口 - 现象:拼图 / 抓大鹅 / 汪汪声浪等创作表单点击生成时,如果泥点不足,页面直接回到创作 Tab 玩法模板列表,刚填的表单内容随工作台卸载全部丢失。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 325a8b9f..95051672 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -133,7 +133,7 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `次级入口 > 删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。 -推荐页匿名游玩不再限定为跳一跳。推荐页嵌入运行态启动时统一先申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续透传 runtime guest token;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 +推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。推荐页嵌入运行态启动时统一先申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续透传 runtime guest token;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。 ## 敲木鱼 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e9286a98..db40be58 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -411,6 +411,7 @@ import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; +import { usePlatformDesktopLayout } from './platformEntryResponsive'; import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail'; import { usePlatformEntryNavigation } from './usePlatformEntryNavigation'; @@ -2734,6 +2735,7 @@ export function PlatformEntryFlowShellImpl({ authUi?.platformTheme === 'dark' ? 'platform-theme--dark' : 'platform-theme--light'; + const isDesktopLayout = usePlatformDesktopLayout(); const [showCreationTypeModal, setShowCreationTypeModal] = useState(false); const [draftGenerationPointNotice, setDraftGenerationPointNotice] = useState<{ title: string; @@ -11795,6 +11797,7 @@ export function PlatformEntryFlowShellImpl({ const recommendRuntimeContent = useMemo(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || !activeRecommendRuntimeKind @@ -12201,10 +12204,12 @@ export function PlatformEntryFlowShellImpl({ visualNovelSession, visualNovelWork, checkpointWoodenFishRuntimeRun, + isDesktopLayout, ]); useEffect(() => { if ( + isDesktopLayout || selectionStage !== 'platform' || platformBootstrap.platformTab !== 'home' || platformBootstrap.isLoadingPlatform @@ -12260,6 +12265,7 @@ export function PlatformEntryFlowShellImpl({ match3dRun, platformBootstrap.isLoadingPlatform, platformBootstrap.platformTab, + isDesktopLayout, puzzleRun, recommendRuntimeEntries, selectRecommendRuntimeEntry, @@ -13499,6 +13505,7 @@ export function PlatformEntryFlowShellImpl({ isLoadingPlatform={platformBootstrap.isLoadingPlatform} isLoadingDashboard={platformBootstrap.isLoadingDashboard} hasUnreadDraftUpdate={hasUnreadDraftUpdates} + isDesktopLayout={isDesktopLayout} isResumingSaveWorldKey={platformBootstrap.isResumingSaveWorldKey} platformError={ platformBootstrap.isLoadingPlatform diff --git a/src/components/platform-entry/platformEntryResponsive.ts b/src/components/platform-entry/platformEntryResponsive.ts new file mode 100644 index 00000000..8acd0ec6 --- /dev/null +++ b/src/components/platform-entry/platformEntryResponsive.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react'; + +export const PLATFORM_DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)'; + +export function getInitialPlatformDesktopLayout() { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return false; + } + + return window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY).matches; +} + +export function usePlatformDesktopLayout() { + const [isDesktopLayout, setIsDesktopLayout] = useState( + getInitialPlatformDesktopLayout, + ); + + useEffect(() => { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return; + } + + const mediaQuery = window.matchMedia(PLATFORM_DESKTOP_LAYOUT_QUERY); + const updateLayout = (event?: MediaQueryListEvent) => { + setIsDesktopLayout(event?.matches ?? mediaQuery.matches); + }; + + updateLayout(); + + // 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。 + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', updateLayout); + return () => mediaQuery.removeEventListener('change', updateLayout); + } + + mediaQuery.addListener(updateLayout); + return () => mediaQuery.removeListener(updateLayout); + }, []); + + return isDesktopLayout; +} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 12e75aa1..a81ea87e 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; @@ -178,6 +179,35 @@ import { type SelectionStage, } from './RpgEntryFlowShell'; +const authServiceMocks = vi.hoisted(() => ({ + ensureRuntimeGuestToken: vi.fn(async () => ({ + token: 'runtime-guest-token', + expiresAt: '2099-01-01T00:00:00.000Z', + })), + getPublicAuthUserByCode: vi.fn( + async (publicUserCode: string): Promise => ({ + id: `public-user-${publicUserCode}`, + publicUserCode, + displayName: '公开作者', + avatarUrl: null, + }), + ), + getPublicAuthUserById: vi.fn( + async (userId: string): Promise => ({ + id: userId, + publicUserCode: `code-${userId}`, + displayName: '公开作者', + avatarUrl: null, + }), + ), +})); + +vi.mock('../../services/authService', () => ({ + ensureRuntimeGuestToken: authServiceMocks.ensureRuntimeGuestToken, + getPublicAuthUserByCode: authServiceMocks.getPublicAuthUserByCode, + getPublicAuthUserById: authServiceMocks.getPublicAuthUserById, +})); + async function clickFirstButtonByName( user: ReturnType, name: string | RegExp, @@ -276,6 +306,10 @@ const ISOLATED_RUNTIME_AUTH_OPTIONS = { notifyAuthStateChange: false, clearAuthOnUnauthorized: false, }; +const RECOMMEND_RUNTIME_AUTH_OPTIONS = { + ...ISOLATED_RUNTIME_AUTH_OPTIONS, + runtimeGuestToken: 'runtime-guest-token', +}; function getPlatformTabPanel(tab: string) { const panel = document.getElementById(`platform-tab-panel-${tab}`); @@ -2239,6 +2273,10 @@ function TestWrapper({ beforeEach(() => { vi.resetAllMocks(); + vi.mocked(authServiceMocks.ensureRuntimeGuestToken).mockResolvedValue({ + token: 'runtime-guest-token', + expiresAt: '2099-01-01T00:00:00.000Z', + }); vi.mocked( match3dGeneratedModelCache.hasMatch3DGeneratedImageAsset, ).mockImplementation((assets) => @@ -6141,6 +6179,7 @@ test('home recommendation starts embedded puzzle without global auth reset on lo profileId: 'puzzle-profile-public-1', levelId: null, }, + RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); }); @@ -6245,7 +6284,7 @@ test('home recommendation Match3D runtime keeps profile generated models when ca await waitFor(() => { expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledWith( 'match3d-profile-card-1', - ISOLATED_RUNTIME_AUTH_OPTIONS, + RECOMMEND_RUNTIME_AUTH_OPTIONS, ); }); await waitFor(() => { @@ -6564,7 +6603,11 @@ test('home recommendation surfaces start failure instead of staying in loading s expect( await screen.findByText('作品暂时无法进入,请稍后再试。'), ).toBeTruthy(); - expect(screen.queryByText('加载中...')).toBeNull(); + expect( + within(getPlatformTabPanel('home')) + .queryByText('加载中...') + ?.closest('.platform-recommend-runtime-panel'), + ).toBeFalsy(); }); test('published big fish works stay hidden from platform home and game category channel', async () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 74f7ebc4..fad7afd4 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -804,6 +804,7 @@ function renderLoggedOutHomeView( > > = {}, activeTab: RpgEntryHomeViewProps['activeTab'] = 'home', + isDesktopLayout = false, ) { return render( > = {}, + isDesktopLayout = false, ) { const authSpies = { openLoginModal: vi.fn(), @@ -985,6 +988,7 @@ function renderStatefulLoggedOutHomeView( > { ).toBeNull(); }); -test('logged out recommend tab enters runtime without login modal', async () => { +test('logged out recommend tab opens embedded runtime without login modal', async () => { const user = userEvent.setup(); const { container, openLoginModal } = renderStatefulLoggedOutHomeView({ latestEntries: [puzzlePublicEntry], @@ -2723,7 +2727,7 @@ test('logged out recommend tab enters runtime without login modal', async () => expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0); }); -test('logged out recommend page keeps runtime visible without login gate', async () => { +test('logged out recommend runtime keeps detail callback idle', async () => { const user = userEvent.setup(); const onOpenGalleryDetail = vi.fn(); const { openLoginModal } = renderStatefulLoggedOutHomeView({ @@ -2750,16 +2754,15 @@ test('logged out desktop recommend page renders runtime directly', () => { renderLoggedOutHomeView(vi.fn(), { latestEntries: [puzzlePublicEntry], activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1', - }); + }, 'home', true); expect(document.querySelector('.platform-recommend-cover-only')).toBeNull(); - expect(screen.queryByText('今日游戏')).toBeNull(); - expect(screen.queryByText('作品分类')).toBeNull(); - expect(screen.getByTestId('recommend-runtime')).toBeTruthy(); + expect(screen.queryByTestId('recommend-runtime')).toBeNull(); + expect(screen.getByText('今日游戏')).toBeTruthy(); + expect(screen.getByText('作品分类')).toBeTruthy(); }); test('logged out recommend page can enter runtime without login gate', () => { - mockDesktopLayout(); const openLoginModal = vi.fn(); const onOpenGalleryDetail = vi.fn(); renderLoggedOutHomeView(openLoginModal, { @@ -3097,7 +3100,7 @@ test('mobile recommend meta loads real author avatar from public user summary', await waitFor(() => { expect( document - .querySelector('.platform-recommend-cover-only__author img') + .querySelector('.platform-recommend-work-meta__avatar img') ?.getAttribute('src'), ).toBe('data:image/png;base64,AUTHOR'); }); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index b7f6199d..b9983cb7 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -131,6 +131,7 @@ import { findPublicWorkForHistoryEntry, isEdutainmentEntryEnabled, } from '../platform-entry/platformEdutainmentVisibility'; +import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntryResponsive'; import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; @@ -166,6 +167,7 @@ export type PlatformHomeTab = | 'profile'; export interface RpgEntryHomeViewProps { activeTab: PlatformHomeTab; + isDesktopLayout?: boolean; onTabChange: (tab: PlatformHomeTab) => void; hasSavedGame: boolean; savedSnapshot: HydratedSavedGameSnapshot | null; @@ -233,7 +235,6 @@ const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4'; const DESKTOP_DISCOVER_PAGE_STAGE_CLASS = 'platform-remap-surface min-w-0 space-y-5 pb-4'; -const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)'; const PLATFORM_HOME_TABS: PlatformHomeTab[] = [ 'home', 'category', @@ -381,46 +382,6 @@ const PLATFORM_RANKING_TABS: Array<{ emptyText: '公开广场暂时还没有点赞作品。', }, ]; -function usePlatformDesktopLayout() { - const [isDesktopLayout, setIsDesktopLayout] = useState(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return false; - } - - return window.matchMedia(DESKTOP_LAYOUT_QUERY).matches; - }); - - useEffect(() => { - if ( - typeof window === 'undefined' || - typeof window.matchMedia !== 'function' - ) { - return; - } - - const mediaQuery = window.matchMedia(DESKTOP_LAYOUT_QUERY); - const updateLayout = (event?: MediaQueryListEvent) => { - setIsDesktopLayout(event?.matches ?? mediaQuery.matches); - }; - - updateLayout(); - - // 平台页只挂载当前断点外壳,避免隐藏的移动端/桌面端内容重复抢占查询。 - if (typeof mediaQuery.addEventListener === 'function') { - mediaQuery.addEventListener('change', updateLayout); - return () => mediaQuery.removeEventListener('change', updateLayout); - } - - mediaQuery.addListener(updateLayout); - return () => mediaQuery.removeListener(updateLayout); - }, []); - - return isDesktopLayout; -} - function ResolvedAssetBackdrop({ src, fallbackSrc, @@ -3852,6 +3813,7 @@ function ProfilePlayedWorksModal({ export function RpgEntryHomeView({ activeTab, + isDesktopLayout: isDesktopLayoutProp, onTabChange, saveEntries, saveError, @@ -4008,7 +3970,8 @@ export function RpgEntryHomeView({ const [isSavingAvatar, setIsSavingAvatar] = useState(false); const isAuthenticated = Boolean(authUi?.user); const edutainmentEntryEnabled = isEdutainmentEntryEnabled(); - const isDesktopLayout = usePlatformDesktopLayout(); + const [fallbackDesktopLayout] = useState(getInitialPlatformDesktopLayout); + const isDesktopLayout = isDesktopLayoutProp ?? fallbackDesktopLayout; const openRecommendGalleryDetail = onOpenRecommendGalleryDetail ?? onOpenGalleryDetail; const generalFeaturedEntries = useMemo( @@ -6581,10 +6544,7 @@ export function RpgEntryHomeView({ ); const tabContentById = { - home: - !isAuthenticated || !isDesktopLayout - ? mobileRecommendContent - : desktopHomeContent, + home: isDesktopLayout ? desktopHomeContent : mobileRecommendContent, category: categoryContent, create: createContent, saves: savesContent,