diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 5ee2ac85..243a3e96 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -19,11 +19,12 @@ ## 2026-06-03 平台入口公开作品详情 Strategy 收口 - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 -- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 +- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用不搬入 Module。 - 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。 - 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。 - 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口;Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案,壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。 +- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口;Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 - 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`。 diff --git a/docs/README.md b/docs/README.md index db0dd44e..bf0f499e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,7 +41,7 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 -统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md index 3b273e1e..3160a28d 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -13,6 +13,7 @@ - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` - `resolvePlatformPublicWorkLikeIntent(entry)` - `resolvePlatformPublicWorkRemixIntent(entry)` + - `resolvePlatformPublicWorkStartIntent(entry, deps)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` - `map*WorkToPublicWorkDetail(...)` @@ -24,7 +25,7 @@ - `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter:根据 open strategy 调用 `openPublicWorkDetail`、`openPuzzlePublicWorkDetail`、`openJumpHopPublicWorkDetail`、`openWoodenFishPublicWorkDetail`、`openVisualNovelPublicWorkDetail` 或 `openRpgPublicWorkDetail`。 - 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface,不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。 - `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。 -- 本次不抽 `startSelectedPublicWork`、`likePublicWork`、`remixPublicWork`、`editOwnedPublicWork`。这些函数牵涉运行态启动、计数写入、草稿恢复、作品架缓存和多路错误 setter;若直接搬进一个 Hook,会形成浅 Interface。 +- 公开详情启动、点赞和改造只抽“意图” Interface,不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、busy 状态、缓存同步、stage 切换和错误 setter,避免形成伪 Seam。 ## Interface 约束 @@ -36,6 +37,8 @@ - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 - `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent;宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误,不再持有这组能力矩阵。 - `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage,旧 RPG gallery fallback 返回可执行 intent,其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。 +- `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent,否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。 +- `resolvePlatformPublicWorkStartIntent` 的 `deps` 只接启动决策所需的当前拼图详情、当前 RPG 详情、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module,以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。 - `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。 - `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry,在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry;作者请求、竞态 request key 和缓存仍留壳层。 - `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry;公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。 @@ -45,7 +48,7 @@ ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id 或当前拼图 run,即可得到详情打开策略、动作模式、点赞 / 改造意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 deps,即可得到详情打开策略、动作模式、点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。 - **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 - **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 9e563408..b3474814 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -534,6 +534,7 @@ import { resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, + resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; import { @@ -12611,134 +12612,93 @@ export function PlatformEntryFlowShellImpl({ } runProtectedAction(() => { - if (isBigFishGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。'); - return; - } - startBigFishRunFromWork(work); - return; - } + const intent = resolvePlatformPublicWorkStartIntent( + selectedPublicWorkDetail, + { + selectedPuzzleDetail, + selectedRpgDetailEntry: selectedDetailEntry, + barkBattleGalleryEntries, + barkBattleWorks, + mapMatch3DWork: mapPublicWorkDetailToMatch3DWork, + }, + ); - if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { - const work = - selectedPuzzleDetail?.profileId === selectedPublicWorkDetail.profileId - ? selectedPuzzleDetail - : mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前拼图作品信息不完整,暂时无法进入玩法。', + switch (intent.type) { + case 'blocked': + setPublicWorkDetailError(intent.errorMessage); + return; + case 'start-big-fish': + startBigFishRunFromWork(intent.work, intent.returnStage); + return; + case 'start-puzzle': + setPublicWorkDetailError(null); + void startPuzzleRunFromProfile( + intent.work.profileId, + intent.returnStage, + intent.work, + true, + null, + { authMode: intent.authMode }, ); return; - } - setPublicWorkDetailError(null); - void startPuzzleRunFromProfile( - work.profileId, - 'work-detail', - work, - true, - null, - { authMode: 'isolated' }, - ); - return; - } - - if (isJumpHopGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startJumpHopRunFromProfile(selectedPublicWorkDetail.profileId, { - returnStage: 'work-detail', - }); - return; - } - - if (isWoodenFishGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startWoodenFishRunFromProfile(selectedPublicWorkDetail.profileId, { - returnStage: 'work-detail', - }); - return; - } - - if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + case 'start-jump-hop': + setPublicWorkDetailError(null); + void startJumpHopRunFromProfile(intent.profileId, { + returnStage: intent.returnStage, + }); + return; + case 'start-wooden-fish': + setPublicWorkDetailError(null); + void startWoodenFishRunFromProfile(intent.profileId, { + returnStage: intent.returnStage, + }); + return; + case 'start-match3d': + setPublicWorkDetailError(null); + void startMatch3DRunFromProfile( + intent.work, + intent.returnStage, + true, ); return; - } - setPublicWorkDetailError(null); - void startMatch3DRunFromProfile(work, 'work-detail', true); - return; - } - - if (isSquareHoleGalleryEntry(selectedPublicWorkDetail)) { - const work = mapPublicWorkDetailToSquareHoleWork( - selectedPublicWorkDetail, - ); - if (!work) { - setPublicWorkDetailError( - '当前方洞挑战作品信息不完整,暂时无法进入玩法。', + case 'start-square-hole': + setPublicWorkDetailError(null); + void startSquareHoleRunFromProfile( + intent.work, + intent.returnStage, + true, ); return; - } - setPublicWorkDetailError(null); - void startSquareHoleRunFromProfile(work, 'work-detail', true); - return; - } - - if (isVisualNovelGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startVisualNovelRunFromProfile( - selectedPublicWorkDetail.profileId, - 'work-detail', - ); - return; - } - - if (isBarkBattleGalleryEntry(selectedPublicWorkDetail)) { - const work = - barkBattleGalleryEntries.find( - (item) => item.workId === selectedPublicWorkDetail.workId, - ) ?? - barkBattleWorks.find( - (item) => item.workId === selectedPublicWorkDetail.workId, - ) ?? - mapBarkBattlePublicDetailToWorkSummary(selectedPublicWorkDetail); - if (!work) { - setPublicWorkDetailError( - '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', + case 'start-visual-novel': + setPublicWorkDetailError(null); + void startVisualNovelRunFromProfile( + intent.profileId, + intent.returnStage, ); return; + case 'start-bark-battle': + setPublicWorkDetailError(null); + startBarkBattleRunFromWork(intent.work, intent.returnStage); + return; + case 'start-edutainment': + setPublicWorkDetailError(null); + void startBabyObjectMatchRuntimeFromEntry( + intent.entry, + intent.returnStage, + ); + return; + case 'record-rpg-gallery-play': + break; + default: { + const exhaustive: never = intent; + return exhaustive; } - setPublicWorkDetailError(null); - startBarkBattleRunFromWork(work, 'work-detail'); - return; - } - - if (isEdutainmentGalleryEntry(selectedPublicWorkDetail)) { - setPublicWorkDetailError(null); - void startBabyObjectMatchRuntimeFromEntry( - selectedPublicWorkDetail, - 'work-detail', - ); - return; - } - - const launchEntry = - selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId - ? selectedDetailEntry - : null; - if (!launchEntry) { - setPublicWorkDetailError('作品详情尚未读取完成。'); - return; } setIsPublicWorkDetailBusy(true); void recordRpgEntryWorldGalleryPlay( - launchEntry.ownerUserId, - launchEntry.profileId, + intent.entry.ownerUserId, + intent.entry.profileId, ) .then((updatedEntry) => { setSelectedDetailEntry(updatedEntry); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index c42cf770..50886cd2 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -3,6 +3,7 @@ import { expect, test } from 'vitest'; import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; @@ -30,12 +31,14 @@ import { mapWoodenFishWorkToPublicWorkDetail, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, + type PlatformPublicWorkStartIntentDeps, resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, resolvePlatformPublicWorkDetailOpenStrategy, resolvePlatformPublicWorkLikeIntent, resolvePlatformPublicWorkRemixIntent, + resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; @@ -56,7 +59,10 @@ type TypedPlatformPublicGalleryCardOverrides< function narrowTypedEntry( entry: TypedPlatformPublicGalleryCard, ): Extract { - return entry as Extract; + return entry as Extract< + TypedPlatformPublicGalleryCard, + { sourceType: TSourceType } + >; } function buildRpgEntry( @@ -104,19 +110,47 @@ function buildTypedEntry( switch (sourceType) { case 'puzzle': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'big-fish': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'match3d': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'square-hole': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'visual-novel': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'jump-hop': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'wooden-fish': - return narrowTypedEntry({ ...common, ...overrides, sourceType }); + return narrowTypedEntry({ + ...common, + ...overrides, + sourceType, + }); case 'edutainment': return narrowTypedEntry({ ...common, @@ -337,6 +371,45 @@ function buildBarkBattleWork( }; } +function buildMatch3DWork( + overrides: Partial = {}, +): Match3DWorkSummary { + return { + workId: 'match3d-work', + profileId: 'match3d-profile', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session', + gameName: '抓大鹅作品', + themeText: '经典消除', + summary: '抓大鹅摘要', + tags: ['抓大鹅'], + coverImageSrc: '/match3d-cover.png', + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: 10, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + generatedItemAssets: [], + ...overrides, + }; +} + +function buildStartIntentDeps( + overrides: Partial = {}, +): PlatformPublicWorkStartIntentDeps { + return { + selectedPuzzleDetail: null, + selectedRpgDetailEntry: null, + barkBattleGalleryEntries: [], + barkBattleWorks: [], + mapMatch3DWork: () => buildMatch3DWork(), + ...overrides, + }; +} + test('platform public work detail flow resolves detail kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] @@ -579,18 +652,16 @@ test('platform public work detail flow maps detail entries back to work summarie }); expect( - mapBarkBattlePublicDetailToWorkSummary( - { - ...buildTypedEntry('bark-battle', { - themeTags: ['森林', '小狗', '对手'], - coverImageSrc: '/bark-bg.png', - coverCharacterImageSrcs: ['/player.png', '/opponent.png'], - playCount: 11, - recentPlayCount7d: 5, - }), - sourceSessionId: 'bark-draft', - }, - ), + mapBarkBattlePublicDetailToWorkSummary({ + ...buildTypedEntry('bark-battle', { + themeTags: ['森林', '小狗', '对手'], + coverImageSrc: '/bark-bg.png', + coverCharacterImageSrcs: ['/player.png', '/opponent.png'], + playCount: 11, + recentPlayCount7d: 5, + }), + sourceSessionId: 'bark-draft', + }), ).toMatchObject({ workId: 'bark-battle-work', draftId: 'bark-draft', @@ -605,8 +676,12 @@ test('platform public work detail flow maps detail entries back to work summarie recentPlayCount7d: 5, }); - expect(mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish'))).toBeNull(); - expect(mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle'))).toBeNull(); + expect( + mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish')), + ).toBeNull(); + expect( + mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle')), + ).toBeNull(); expect( mapPublicWorkDetailToSquareHoleWork(buildTypedEntry('puzzle')), ).toBeNull(); @@ -654,13 +729,15 @@ test('platform public work detail flow resolves edit mode only for owned works', }); test('platform public work detail flow resolves like intent', () => { - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('big-fish'))).toEqual( - { - type: 'like-big-fish', - profileId: 'big-fish-profile', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('big-fish')), + ).toEqual({ + type: 'like-big-fish', + profileId: 'big-fish-profile', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('puzzle')), + ).toEqual({ type: 'like-puzzle', profileId: 'puzzle-profile', }); @@ -669,42 +746,50 @@ test('platform public work detail flow resolves like intent', () => { ownerUserId: 'user-1', profileId: 'rpg-profile', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d'))).toEqual( - { - type: 'like-rpg-gallery', - ownerUserId: 'user-1', - profileId: 'match3d-profile', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('match3d')), + ).toEqual({ + type: 'like-rpg-gallery', + ownerUserId: 'user-1', + profileId: 'match3d-profile', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('edutainment')), + ).toEqual({ type: 'unsupported', errorMessage: '宝贝识物点赞将在后续版本开放。', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('bark-battle'))).toEqual( - { - type: 'unsupported', - errorMessage: '汪汪声浪点赞将在后续版本开放。', - }, - ); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('square-hole'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('bark-battle')), + ).toEqual({ + type: 'unsupported', + errorMessage: '汪汪声浪点赞将在后续版本开放。', + }); + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('square-hole')), + ).toEqual({ type: 'unsupported', errorMessage: '方洞挑战点赞将在后续版本开放。', }); - expect(resolvePlatformPublicWorkLikeIntent(buildTypedEntry('visual-novel'))).toEqual({ + expect( + resolvePlatformPublicWorkLikeIntent(buildTypedEntry('visual-novel')), + ).toEqual({ type: 'unsupported', errorMessage: '视觉小说点赞将在后续版本开放。', }); }); test('platform public work detail flow resolves remix intent', () => { - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish'))).toEqual( - { - type: 'remix-big-fish', - profileId: 'big-fish-profile', - selectionStage: 'big-fish-result', - }, - ); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('puzzle'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('big-fish')), + ).toEqual({ + type: 'remix-big-fish', + profileId: 'big-fish-profile', + selectionStage: 'big-fish-result', + }); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('puzzle')), + ).toEqual({ type: 'remix-puzzle', profileId: 'puzzle-profile', selectionStage: 'puzzle-result', @@ -714,38 +799,236 @@ test('platform public work detail flow resolves remix intent', () => { ownerUserId: 'user-1', profileId: 'rpg-profile', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('match3d'))).toEqual( - { - type: 'unsupported', - errorMessage: '抓大鹅作品改造将在后续版本开放。', - }, - ); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('square-hole'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('match3d')), + ).toEqual({ + type: 'unsupported', + errorMessage: '抓大鹅作品改造将在后续版本开放。', + }); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('square-hole')), + ).toEqual({ type: 'unsupported', errorMessage: '方洞挑战作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('jump-hop'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('jump-hop')), + ).toEqual({ type: 'unsupported', errorMessage: '跳一跳作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('wooden-fish'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('wooden-fish')), + ).toEqual({ type: 'unsupported', errorMessage: '敲木鱼作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('visual-novel'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('visual-novel')), + ).toEqual({ type: 'unsupported', errorMessage: '视觉小说作品改造将在后续版本开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('edutainment'))).toEqual({ + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('edutainment')), + ).toEqual({ type: 'unsupported', errorMessage: '宝贝识物作品改造将在创作链路接入后开放。', }); - expect(resolvePlatformPublicWorkRemixIntent(buildTypedEntry('bark-battle'))).toEqual( - { - type: 'unsupported', - errorMessage: '汪汪声浪作品改造将在后续版本开放。', - }, - ); + expect( + resolvePlatformPublicWorkRemixIntent(buildTypedEntry('bark-battle')), + ).toEqual({ + type: 'unsupported', + errorMessage: '汪汪声浪作品改造将在后续版本开放。', + }); +}); + +test('platform public work detail flow resolves start intent for direct launches', () => { + const bigFishEntry = buildTypedEntry('big-fish'); + expect( + resolvePlatformPublicWorkStartIntent(bigFishEntry, buildStartIntentDeps()), + ).toEqual({ + type: 'start-big-fish', + work: mapPublicWorkDetailToBigFishWork(bigFishEntry), + returnStage: 'work-detail', + }); + + const selectedPuzzleDetail = buildPuzzleWork({ + profileId: 'puzzle-profile', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('puzzle'), + buildStartIntentDeps({ selectedPuzzleDetail }), + ), + ).toEqual({ + type: 'start-puzzle', + work: selectedPuzzleDetail, + returnStage: 'work-detail', + authMode: 'isolated', + }); + + const puzzleEntry = buildTypedEntry('puzzle', { + profileId: 'fallback-puzzle-profile', + }); + expect( + resolvePlatformPublicWorkStartIntent( + puzzleEntry, + buildStartIntentDeps({ + selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }), + }), + ), + ).toEqual({ + type: 'start-puzzle', + work: mapPublicWorkDetailToPuzzleWork(puzzleEntry), + returnStage: 'work-detail', + authMode: 'isolated', + }); + + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('jump-hop'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-jump-hop', + profileId: 'jump-hop-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('wooden-fish'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-wooden-fish', + profileId: 'wooden-fish-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('visual-novel'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-visual-novel', + profileId: 'visual-novel-profile', + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + buildTypedEntry('edutainment'), + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-edutainment', + entry: buildTypedEntry('edutainment'), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves start intent for mapper-backed launches', () => { + const match3DEntry = buildTypedEntry('match3d'); + const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' }); + expect( + resolvePlatformPublicWorkStartIntent( + match3DEntry, + buildStartIntentDeps({ + mapMatch3DWork: (entry) => + entry === match3DEntry ? match3DWork : null, + }), + ), + ).toEqual({ + type: 'start-match3d', + work: match3DWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + match3DEntry, + buildStartIntentDeps({ mapMatch3DWork: () => null }), + ), + ).toEqual({ + type: 'blocked', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }); + + const squareHoleEntry = buildTypedEntry('square-hole'); + expect( + resolvePlatformPublicWorkStartIntent( + squareHoleEntry, + buildStartIntentDeps(), + ), + ).toEqual({ + type: 'start-square-hole', + work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves bark battle start work priority', () => { + const entry = buildTypedEntry('bark-battle'); + const galleryWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + title: '作品架缓存', + }); + const loadedWork = buildBarkBattleWork({ + workId: 'bark-battle-work', + title: '完整作品列表', + }); + + expect( + resolvePlatformPublicWorkStartIntent( + entry, + buildStartIntentDeps({ + barkBattleGalleryEntries: [galleryWork], + barkBattleWorks: [loadedWork], + }), + ), + ).toEqual({ + type: 'start-bark-battle', + work: galleryWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent( + entry, + buildStartIntentDeps({ + barkBattleWorks: [loadedWork], + }), + ), + ).toEqual({ + type: 'start-bark-battle', + work: loadedWork, + returnStage: 'work-detail', + }); + expect( + resolvePlatformPublicWorkStartIntent(entry, buildStartIntentDeps()), + ).toEqual({ + type: 'start-bark-battle', + work: mapBarkBattlePublicDetailToWorkSummary(entry), + returnStage: 'work-detail', + }); +}); + +test('platform public work detail flow resolves rpg start intent from loaded detail', () => { + const rpgEntry = buildRpgEntry(); + + expect( + resolvePlatformPublicWorkStartIntent( + rpgEntry, + buildStartIntentDeps({ selectedRpgDetailEntry: rpgEntry }), + ), + ).toEqual({ + type: 'record-rpg-gallery-play', + entry: rpgEntry, + }); + expect( + resolvePlatformPublicWorkStartIntent(rpgEntry, buildStartIntentDeps()), + ).toEqual({ + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }); }); test('platform public work detail flow resolves direct open decision', () => { diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index 7dd983c0..2dc79d32 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -4,6 +4,7 @@ import type { JumpHopGalleryCardResponse, JumpHopWorkProfileResponse, } from '../../../packages/shared/src/contracts/jumpHop'; +import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; @@ -121,6 +122,72 @@ export type PlatformPublicWorkRemixIntent = errorMessage: string; }; +export type PlatformPublicWorkStartIntent = + | { + type: 'blocked'; + errorMessage: string; + } + | { + type: 'start-big-fish'; + work: BigFishWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-puzzle'; + work: PuzzleWorkSummary; + returnStage: 'work-detail'; + authMode: 'isolated'; + } + | { + type: 'start-jump-hop'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-wooden-fish'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-match3d'; + work: Match3DWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-square-hole'; + work: SquareHoleWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-visual-novel'; + profileId: string; + returnStage: 'work-detail'; + } + | { + type: 'start-bark-battle'; + work: BarkBattleWorkSummary; + returnStage: 'work-detail'; + } + | { + type: 'start-edutainment'; + entry: PlatformPublicGalleryCard; + returnStage: 'work-detail'; + } + | { + type: 'record-rpg-gallery-play'; + entry: CustomWorldGalleryCard; + }; + +export type PlatformPublicWorkStartIntentDeps = { + selectedPuzzleDetail?: PuzzleWorkSummary | null; + selectedRpgDetailEntry?: CustomWorldGalleryCard | null; + barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[]; + barkBattleWorks?: readonly BarkBattleWorkSummary[]; + mapMatch3DWork: ( + entry: PlatformPublicGalleryCard, + ) => Match3DWorkSummary | null; +}; + export type PlatformPublicWorkDetailOpenDecision = | { type: 'blocked'; @@ -622,6 +689,149 @@ export function resolvePlatformPublicWorkRemixIntent( }; } +export function resolvePlatformPublicWorkStartIntent( + entry: PlatformPublicGalleryCard, + deps: PlatformPublicWorkStartIntentDeps, +): PlatformPublicWorkStartIntent { + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。', + }; + } + + return { + type: 'start-big-fish', + work, + returnStage: 'work-detail', + }; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + deps.selectedPuzzleDetail?.profileId === entry.profileId + ? deps.selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-puzzle', + work, + returnStage: 'work-detail', + authMode: 'isolated', + }; + } + + if (isJumpHopGalleryEntry(entry)) { + return { + type: 'start-jump-hop', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isWoodenFishGalleryEntry(entry)) { + return { + type: 'start-wooden-fish', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isMatch3DGalleryEntry(entry)) { + // 中文注释:抓大鹅运行态素材归一仍归 Match3D Module,公开详情 Flow 只接其 Adapter。 + const work = deps.mapMatch3DWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-match3d', + work, + returnStage: 'work-detail', + }; + } + + if (isSquareHoleGalleryEntry(entry)) { + const work = mapPublicWorkDetailToSquareHoleWork(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-square-hole', + work, + returnStage: 'work-detail', + }; + } + + if (isVisualNovelGalleryEntry(entry)) { + return { + type: 'start-visual-novel', + profileId: entry.profileId, + returnStage: 'work-detail', + }; + } + + if (isBarkBattleGalleryEntry(entry)) { + const work = + deps.barkBattleGalleryEntries?.find( + (item) => item.workId === entry.workId, + ) ?? + deps.barkBattleWorks?.find((item) => item.workId === entry.workId) ?? + mapBarkBattlePublicDetailToWorkSummary(entry); + if (!work) { + return { + type: 'blocked', + errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。', + }; + } + + return { + type: 'start-bark-battle', + work, + returnStage: 'work-detail', + }; + } + + if (isEdutainmentGalleryEntry(entry)) { + return { + type: 'start-edutainment', + entry, + returnStage: 'work-detail', + }; + } + + const launchEntry = + deps.selectedRpgDetailEntry?.profileId === entry.profileId + ? deps.selectedRpgDetailEntry + : null; + if (!launchEntry) { + return { + type: 'blocked', + errorMessage: '作品详情尚未读取完成。', + }; + } + + return { + type: 'record-rpg-gallery-play', + entry: launchEntry, + }; +} + export function resolvePlatformPublicWorkDetailOpenDecision( entry: PlatformPublicGalleryCard, deps: PlatformPublicWorkDetailOpenDecisionDeps = {},