diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index decd8733..b0ab045b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -20,8 +20,9 @@ - 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。 - 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind`、`resolvePlatformPublicWorkDetailOpenStrategy`、`resolvePlatformPublicWorkActionMode`、`resolvePlatformPublicWorkDetailOpenDecision` 和 `resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter,并保留作者请求竞态控制;启动、点赞、remix 和编辑副作用暂不抽走。 +- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。 - 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。 -- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`、公开详情壳层交互回归、`npm run typecheck`、`npm run check:encoding`。 +- 验证方式:`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`。 ## 2026-06-03 平台入口弹窗状态规则收口 diff --git a/docs/README.md b/docs/README.md index cf10707b..d7f50317 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`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。 +统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射的素材归一仍归 `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 1d5bb942..d0348d3a 100644 --- a/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md @@ -13,7 +13,14 @@ - `resolvePlatformPublicWorkActionMode(entry, viewerUserId)` - `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)` - `resolveActivePlatformPublicWorkAuthorEntry(args)` + - `map*WorkToPublicWorkDetail(...)` + - `mapPublicWorkDetailToPuzzleWork(entry)` + - `mapPublicWorkDetailToBigFishWork(entry)` + - `mapPublicWorkDetailToSquareHoleWork(entry)` + - `mapBarkBattlePublicDetailToWorkSummary(entry)` - `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 约束 @@ -26,16 +33,20 @@ - `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id;当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`。 - `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` 的平台公开卡片映射。 +- `mapPublicWorkDetailToPuzzleWork`、`mapPublicWorkDetailToBigFishWork`、`mapPublicWorkDetailToSquareHoleWork` 和 `mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。 +- Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module,因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。 ## Depth / Leverage / Locality -- **Depth**:壳层传入公开作品 entry 和当前用户 id,即可得到详情打开策略和动作模式;玩法判定细则藏在 Module Implementation 内。 -- **Leverage**:新增玩法公开详情时先补 Strategy 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断。 -- **Locality**:公开作品详情入口的纯策略集中到一个小 Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 +- **Depth**:壳层传入公开作品 entry、玩法 work summary 或当前用户 id,即可得到详情打开策略、动作模式和统一详情映射;玩法判定与 DTO 默认值藏在 Module Implementation 内。 +- **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter,不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。 +- **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 Module;Match3D 素材归一仍在 Match3D Module;启动运行态、点赞、改造、编辑等副作用仍留在壳层,避免伪 Seam。 ## 验收 - `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts` +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` - `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` - `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx` - `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3efa1b72..023c4c21 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -374,7 +374,6 @@ import { mapBarkBattleWorkToPlatformGalleryCard, mapBigFishWorkToPlatformGalleryCard, mapJumpHopWorkToPlatformGalleryCard, - mapMatch3DWorkToPlatformGalleryCard, mapPuzzleWorkToPlatformGalleryCard, mapSquareHoleWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard, @@ -500,6 +499,7 @@ import { hasMatch3DRuntimeAsset, hasMatch3DRuntimeBackgroundAsset, mapMatch3DWorksForRuntimeUi, + mapMatch3DWorkToPublicWorkDetail, mapPublicWorkDetailToMatch3DWork, normalizeMatch3DWorkForRuntimeUi, promoteMatch3DGeneratedBackgroundAsset, @@ -516,6 +516,18 @@ import { type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; import { + mapBarkBattlePublicDetailToWorkSummary, + mapBarkBattleWorkToPublicWorkDetail, + mapBigFishWorkToPublicWorkDetail, + mapJumpHopWorkToPublicWorkDetail, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, + mapPuzzleWorkToPublicWorkDetail, + mapRpgGalleryCardToPublicWorkDetail, + mapSquareHoleWorkToPublicWorkDetail, + mapVisualNovelWorkToPublicWorkDetail, + mapWoodenFishWorkToPublicWorkDetail, resolveActivePlatformPublicWorkAuthorEntry, resolvePlatformPublicWorkActionMode, resolvePlatformPublicWorkDetailOpenDecision, @@ -719,18 +731,6 @@ function isRecommendRuntimeReadyForEntry( return true; } -function mapRpgGalleryCardToPublicWorkDetail( - entry: CustomWorldGalleryCard, -): PlatformPublicGalleryCard { - return entry; -} - -function mapPuzzleWorkToPublicWorkDetail( - item: PuzzleWorkSummary, -): PlatformPublicGalleryCard { - return mapPuzzleWorkToPlatformGalleryCard(item); -} - function resolveVisiblePuzzleDetailCoverCount( entry: PlatformPublicGalleryCard | null, run: PuzzleRunSnapshot | null, @@ -747,44 +747,6 @@ function resolveVisiblePuzzleDetailCoverCount( return Math.max(1, run.clearedLevelCount + 1); } -function mapMatch3DWorkToPublicWorkDetail( - item: Match3DWorkSummary, -): PlatformPublicGalleryCard { - return mapMatch3DWorkToPlatformGalleryCard( - normalizeMatch3DWorkForRuntimeUi(item), - ); -} - -function mapSquareHoleWorkToPublicWorkDetail( - item: SquareHoleWorkSummary, -): PlatformPublicGalleryCard { - return mapSquareHoleWorkToPlatformGalleryCard(item); -} - -function mapBigFishWorkToPublicWorkDetail( - item: BigFishWorkSummary, -): PlatformPublicGalleryCard { - return mapBigFishWorkToPlatformGalleryCard(item); -} - -function mapVisualNovelWorkToPublicWorkDetail( - item: VisualNovelWorkSummary, -): PlatformPublicGalleryCard { - return mapVisualNovelWorkToPlatformGalleryCard(item); -} - -function mapJumpHopWorkToPublicWorkDetail( - item: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse, -): PlatformPublicGalleryCard { - return mapJumpHopWorkToPlatformGalleryCard(item); -} - -function mapBarkBattleWorkToPublicWorkDetail( - item: BarkBattleWorkSummary, -): PlatformPublicGalleryCard { - return mapBarkBattleWorkToPlatformGalleryCard(item); -} - function mapBarkBattleWorkToPublishedConfig( work: BarkBattleWorkSummary, ): BarkBattlePublishedConfig { @@ -809,44 +771,6 @@ function mapBarkBattleWorkToPublishedConfig( }; } -function mapBarkBattlePublicDetailToWorkSummary( - entry: PlatformPublicGalleryCard, -): BarkBattleWorkSummary | null { - if (!isBarkBattleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - draftId: entry.sourceSessionId ?? null, - ownerUserId: entry.ownerUserId, - authorDisplayName: entry.authorDisplayName, - title: entry.worldName, - summary: entry.summaryText, - themeDescription: entry.themeTags[0] ?? entry.summaryText, - playerImageDescription: entry.themeTags[1] ?? entry.summaryText, - opponentImageDescription: entry.themeTags[2] ?? entry.summaryText, - onomatopoeia: undefined, - playerCharacterImageSrc: entry.coverCharacterImageSrcs[0] ?? null, - opponentCharacterImageSrc: entry.coverCharacterImageSrcs[1] ?? null, - uiBackgroundImageSrc: entry.coverImageSrc, - difficultyPreset: 'normal', - status: 'published', - generationStatus: 'ready', - publishReady: true, - playCount: entry.playCount ?? 0, - recentPlayCount7d: entry.recentPlayCount7d ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - }; -} - -function mapWoodenFishWorkToPublicWorkDetail( - item: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, -): PlatformPublicGalleryCard { - return mapWoodenFishWorkToPlatformGalleryCard(item); -} - function mapVisualNovelWorkDetailToSession( work: VisualNovelWorkDetail, ): VisualNovelAgentSessionSnapshot { @@ -892,122 +816,6 @@ function resolveMatch3DGenerationStateFromAssets( }; } -function mapPublicWorkDetailToPuzzleWork( - entry: PlatformPublicGalleryCard, -): PuzzleWorkSummary | null { - if (!isPuzzleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - profileId: entry.profileId, - ownerUserId: entry.ownerUserId, - sourceSessionId: - 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' - ? entry.sourceSessionId - : null, - authorDisplayName: entry.authorDisplayName, - levelName: entry.worldName, - summary: entry.summaryText, - themeTags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - publicationStatus: 'published', - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - playCount: entry.playCount ?? 0, - remixCount: entry.remixCount ?? 0, - likeCount: entry.likeCount ?? 0, - pointIncentiveTotalHalfPoints: 0, - pointIncentiveClaimedPoints: 0, - pointIncentiveTotalPoints: 0, - pointIncentiveClaimablePoints: 0, - publishReady: true, - levels: - entry.coverSlides?.map((slide, index) => ({ - levelId: slide.id || `puzzle-level-${index + 1}`, - levelName: slide.label, - pictureDescription: entry.summaryText, - candidates: [], - selectedCandidateId: null, - coverImageSrc: slide.imageSrc, - coverAssetId: null, - generationStatus: 'ready' as const, - })) ?? [], - }; -} - -function mapPublicWorkDetailToBigFishWork( - entry: PlatformPublicGalleryCard, -): BigFishWorkSummary | null { - if (!isBigFishGalleryEntry(entry)) { - return null; - } - - const levelCount = Number.parseInt( - entry.themeTags.find((tag) => /^\d+级$/u.test(tag))?.replace('级', '') ?? - '0', - 10, - ); - - return { - workId: entry.workId, - sourceSessionId: entry.profileId, - ownerUserId: entry.ownerUserId, - authorDisplayName: entry.authorDisplayName, - title: entry.worldName, - subtitle: entry.subtitle, - summary: entry.summaryText, - coverImageSrc: entry.coverImageSrc, - status: 'published', - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - levelCount: Number.isNaN(levelCount) ? 0 : levelCount, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: Boolean(entry.coverImageSrc), - playCount: entry.playCount ?? 0, - remixCount: entry.remixCount ?? 0, - likeCount: entry.likeCount ?? 0, - }; -} - -function mapPublicWorkDetailToSquareHoleWork( - entry: PlatformPublicGalleryCard, -): SquareHoleWorkSummary | null { - if (!isSquareHoleGalleryEntry(entry)) { - return null; - } - - return { - workId: entry.workId, - profileId: entry.profileId, - ownerUserId: entry.ownerUserId, - sourceSessionId: - 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' - ? entry.sourceSessionId - : null, - gameName: entry.worldName, - themeText: entry.themeTags[0] ?? '方洞挑战', - twistRule: entry.subtitle, - summary: entry.summaryText, - tags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景', - backgroundImageSrc: entry.backgroundImageSrc ?? null, - shapeOptions: entry.shapeOptions ?? [], - holeOptions: entry.holeOptions ?? [], - shapeCount: entry.shapeCount ?? 8, - difficulty: entry.difficulty ?? 4, - publicationStatus: 'published', - playCount: entry.playCount ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - }; -} - function buildSquareHoleProfileFromSession( session: SquareHoleSessionSnapshot | null, ): SquareHoleWorkProfile | null { diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts index 56e98a95..92fd3924 100644 --- a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts @@ -10,6 +10,7 @@ import type { import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import { buildMatch3DProfileFromSession, + mapMatch3DWorkToPublicWorkDetail, mapPublicWorkDetailToMatch3DWork, resolveActiveMatch3DRuntimeProfile, resolveMatch3DRuntimeBackgroundImageSrc, @@ -142,6 +143,31 @@ test('Match3D runtime profile maps public detail and promotes item background as expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png'); }); +test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/detail-background.png', + }); + const detail = mapMatch3DWorkToPublicWorkDetail( + buildProfile({ + generatedBackgroundAsset: null, + backgroundImageSrc: null, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }), + ); + + expect(detail).toMatchObject({ + sourceType: 'match3d', + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + backgroundImageSrc: '/generated/match3d/detail-background.png', + generatedBackgroundAsset: backgroundAsset, + }); +}); + test('Match3D runtime profile builds draft profile from session snapshot', () => { const backgroundAsset = buildBackgroundAsset({ imageSrc: '/generated/match3d/draft-background.png', diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts index 4f0325ce..2cfc5701 100644 --- a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts @@ -13,9 +13,18 @@ import { } from '../../services/match3dGeneratedModelCache'; import { isMatch3DGalleryEntry, + mapMatch3DWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; +export function mapMatch3DWorkToPublicWorkDetail( + item: Match3DWorkSummary, +): PlatformPublicGalleryCard { + return mapMatch3DWorkToPlatformGalleryCard( + normalizeMatch3DWorkForRuntimeUi(item), + ); +} + export function mapPublicWorkDetailToMatch3DWork( entry: PlatformPublicGalleryCard, ): Match3DWorkSummary | null { diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts index 2b8df398..973d5fc6 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.test.ts @@ -1,6 +1,13 @@ 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 { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, @@ -8,6 +15,18 @@ import { } from '../rpg-entry/rpgEntryWorldPresentation'; import { getPlatformPublicWorkDetailKind, + mapBarkBattlePublicDetailToWorkSummary, + mapBarkBattleWorkToPublicWorkDetail, + mapBigFishWorkToPublicWorkDetail, + mapJumpHopWorkToPublicWorkDetail, + mapPublicWorkDetailToBigFishWork, + mapPublicWorkDetailToPuzzleWork, + mapPublicWorkDetailToSquareHoleWork, + mapPuzzleWorkToPublicWorkDetail, + mapRpgGalleryCardToPublicWorkDetail, + mapSquareHoleWorkToPublicWorkDetail, + mapVisualNovelWorkToPublicWorkDetail, + mapWoodenFishWorkToPublicWorkDetail, type PlatformPublicWorkDetailKind, type PlatformPublicWorkDetailOpenStrategy, resolveActivePlatformPublicWorkAuthorEntry, @@ -21,10 +40,21 @@ type TypedPlatformPublicGalleryCard = Extract< { sourceType: string } >; type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType']; -type TypedPlatformPublicGalleryCardOverrides = Partial< - Omit +type TypedPlatformPublicGalleryCardOverrides< + TSourceType extends PlatformGallerySourceType, +> = Partial< + Omit< + Extract, + 'sourceType' + > >; +function narrowTypedEntry( + entry: TypedPlatformPublicGalleryCard, +): Extract { + return entry as Extract; +} + function buildRpgEntry( overrides: Partial = {}, ): CustomWorldGalleryCard { @@ -48,10 +78,10 @@ function buildRpgEntry( }; } -function buildTypedEntry( - sourceType: PlatformGallerySourceType, - overrides: TypedPlatformPublicGalleryCardOverrides = {}, -): PlatformPublicGalleryCard { +function buildTypedEntry( + sourceType: TSourceType, + overrides: TypedPlatformPublicGalleryCardOverrides = {}, +): Extract { const common = { workId: `${sourceType}-work`, profileId: `${sourceType}-profile`, @@ -70,31 +100,30 @@ function buildTypedEntry( switch (sourceType) { case 'puzzle': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'big-fish': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'match3d': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'square-hole': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'visual-novel': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'jump-hop': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'wooden-fish': - return { ...common, ...overrides, sourceType }; + return narrowTypedEntry({ ...common, ...overrides, sourceType }); case 'edutainment': - return { + return narrowTypedEntry({ ...common, - ...overrides, sourceType, templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, - }; - case 'bark-battle': - return { - ...common, ...overrides, + }); + case 'bark-battle': + return narrowTypedEntry({ + ...common, sourceType, authorPublicUserCode: null, coverRenderMode: 'image', @@ -102,14 +131,190 @@ function buildTypedEntry( themeMode: 'martial', playableNpcCount: 1, landmarkCount: 1, - }; + ...overrides, + }); default: { - const exhaustive: never = sourceType; - return exhaustive; + throw new Error(`Unsupported source type: ${sourceType}`); } } } +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work', + profileId: 'puzzle-profile', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session', + authorDisplayName: '玩家', + workTitle: '拼图作品', + workDescription: '拼图描述', + levelName: '第一关', + summary: '拼图摘要', + themeTags: ['拼图'], + coverImageSrc: '/puzzle-cover.png', + publicationStatus: 'published', + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + playCount: 3, + remixCount: 2, + likeCount: 1, + publishReady: true, + ...overrides, + }; +} + +function buildBigFishWork( + overrides: Partial = {}, +): BigFishWorkSummary { + return { + workId: 'big-fish-work', + sourceSessionId: 'big-fish-session', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '大鱼作品', + subtitle: '大鱼吃小鱼', + summary: '大鱼摘要', + coverImageSrc: '/big-fish-cover.png', + status: 'published', + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + levelCount: 12, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: true, + playCount: 4, + remixCount: 3, + likeCount: 2, + ...overrides, + }; +} + +function buildSquareHoleWork( + overrides: Partial = {}, +): SquareHoleWorkSummary { + return { + workId: 'square-hole-work', + profileId: 'square-hole-profile', + ownerUserId: 'user-1', + sourceSessionId: 'square-hole-session', + gameName: '方洞作品', + themeText: '形状', + twistRule: '反直觉', + summary: '方洞摘要', + tags: ['方洞'], + coverImageSrc: '/square-hole-cover.png', + backgroundPrompt: '方洞背景', + backgroundImageSrc: '/square-hole-bg.png', + shapeOptions: [], + holeOptions: [], + shapeCount: 8, + difficulty: 4, + publicationStatus: 'published', + playCount: 5, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + publishReady: true, + ...overrides, + }; +} + +function buildVisualNovelWork( + overrides: Partial = {}, +): VisualNovelWorkSummary { + return { + runtimeKind: 'visual-novel', + profileId: 'visual-novel-profile', + ownerUserId: 'user-1', + title: '视觉小说作品', + description: '视觉小说摘要', + coverImageSrc: '/visual-novel-cover.png', + tags: ['视觉小说'], + publishStatus: 'published', + publishReady: true, + playCount: 6, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + +function buildJumpHopGalleryCard( + overrides: Partial = {}, +): JumpHopGalleryCardResponse { + return { + publicWorkCode: 'JH-0001', + workId: 'jump-hop-work', + profileId: 'jump-hop-profile', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '跳一跳作品', + workDescription: '跳一跳摘要', + coverImageSrc: '/jump-hop-cover.png', + themeTags: ['跳一跳'], + difficulty: 'standard', + stylePreset: 'paper-toy', + publicationStatus: 'published', + playCount: 7, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildWoodenFishGalleryCard( + overrides: Partial = {}, +): WoodenFishGalleryCardResponse { + return { + publicWorkCode: 'WF-0001', + workId: 'wooden-fish-work', + profileId: 'wooden-fish-profile', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + workTitle: '木鱼作品', + workDescription: '木鱼摘要', + coverImageSrc: '/wooden-fish-cover.png', + themeTags: ['敲木鱼'], + publicationStatus: 'published', + playCount: 8, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + generationStatus: 'ready', + ...overrides, + }; +} + +function buildBarkBattleWork( + overrides: Partial = {}, +): BarkBattleWorkSummary { + return { + workId: 'bark-battle-work', + draftId: 'bark-battle-draft', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + title: '汪汪声浪作品', + summary: '汪汪摘要', + themeDescription: '森林擂台', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + playerCharacterImageSrc: '/player.png', + opponentCharacterImageSrc: '/opponent.png', + uiBackgroundImageSrc: '/bark-bg.png', + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: 9, + recentPlayCount7d: 2, + updatedAt: '2026-06-01T01:00:00.000Z', + publishedAt: '2026-06-01T00:00:00.000Z', + ...overrides, + }; +} + test('platform public work detail flow resolves detail kind for every play kind', () => { const cases: Array< [sourceType: PlatformGallerySourceType, kind: PlatformPublicWorkDetailKind] @@ -221,6 +426,173 @@ test('platform public work detail flow resolves open strategy', () => { }); }); +test('platform public work detail flow maps work summaries to detail entries', () => { + const rpgEntry = buildRpgEntry(); + + expect(mapRpgGalleryCardToPublicWorkDetail(rpgEntry)).toBe(rpgEntry); + expect(mapPuzzleWorkToPublicWorkDetail(buildPuzzleWork())).toMatchObject({ + sourceType: 'puzzle', + workId: 'puzzle-work', + profileId: 'puzzle-profile', + playCount: 3, + remixCount: 2, + likeCount: 1, + }); + expect(mapBigFishWorkToPublicWorkDetail(buildBigFishWork())).toMatchObject({ + sourceType: 'big-fish', + workId: 'big-fish-work', + profileId: 'big-fish-session', + playCount: 4, + }); + expect( + mapSquareHoleWorkToPublicWorkDetail(buildSquareHoleWork()), + ).toMatchObject({ + sourceType: 'square-hole', + workId: 'square-hole-work', + profileId: 'square-hole-profile', + backgroundPrompt: '方洞背景', + }); + expect( + mapVisualNovelWorkToPublicWorkDetail(buildVisualNovelWork()), + ).toMatchObject({ + sourceType: 'visual-novel', + workId: 'visual-novel-profile', + profileId: 'visual-novel-profile', + playCount: 6, + }); + expect( + mapJumpHopWorkToPublicWorkDetail(buildJumpHopGalleryCard()), + ).toMatchObject({ + sourceType: 'jump-hop', + workId: 'jump-hop-work', + profileId: 'jump-hop-profile', + publicWorkCode: 'JH-0001', + }); + expect( + mapWoodenFishWorkToPublicWorkDetail(buildWoodenFishGalleryCard()), + ).toMatchObject({ + sourceType: 'wooden-fish', + workId: 'wooden-fish-work', + profileId: 'wooden-fish-profile', + publicWorkCode: 'WF-0001', + }); + expect( + mapBarkBattleWorkToPublicWorkDetail(buildBarkBattleWork()), + ).toMatchObject({ + sourceType: 'bark-battle', + workId: 'bark-battle-work', + sourceSessionId: 'bark-battle-draft', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: ['/player.png', '/opponent.png'], + }); +}); + +test('platform public work detail flow maps detail entries back to work summaries', () => { + expect( + mapPublicWorkDetailToPuzzleWork({ + ...buildTypedEntry('puzzle', { + coverSlides: [ + { + id: 'level-1', + imageSrc: '/level-1.png', + label: '第一关', + }, + ], + playCount: 10, + remixCount: 4, + likeCount: 3, + }), + sourceSessionId: 'puzzle-session', + }), + ).toMatchObject({ + workId: 'puzzle-work', + profileId: 'puzzle-profile', + sourceSessionId: 'puzzle-session', + playCount: 10, + remixCount: 4, + likeCount: 3, + pointIncentiveTotalPoints: 0, + levels: [ + { + levelId: 'level-1', + levelName: '第一关', + coverImageSrc: '/level-1.png', + generationStatus: 'ready', + }, + ], + }); + + expect( + mapPublicWorkDetailToBigFishWork( + buildTypedEntry('big-fish', { + themeTags: ['大鱼', '12级'], + coverImageSrc: '/big-fish-cover.png', + }), + ), + ).toMatchObject({ + workId: 'big-fish-work', + sourceSessionId: 'big-fish-profile', + levelCount: 12, + backgroundReady: true, + }); + expect( + mapPublicWorkDetailToBigFishWork( + buildTypedEntry('big-fish', { themeTags: ['大鱼'] }), + )?.levelCount, + ).toBe(0); + + expect( + mapPublicWorkDetailToSquareHoleWork( + buildTypedEntry('square-hole', { themeTags: [] }), + ), + ).toMatchObject({ + workId: 'square-hole-work', + profileId: 'square-hole-profile', + themeText: '方洞挑战', + backgroundPrompt: '方洞挑战运行背景', + shapeOptions: [], + holeOptions: [], + shapeCount: 8, + difficulty: 4, + }); + + expect( + 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', + themeDescription: '森林', + playerImageDescription: '小狗', + opponentImageDescription: '对手', + playerCharacterImageSrc: '/player.png', + opponentCharacterImageSrc: '/opponent.png', + uiBackgroundImageSrc: '/bark-bg.png', + difficultyPreset: 'normal', + playCount: 11, + recentPlayCount7d: 5, + }); + + expect(mapPublicWorkDetailToPuzzleWork(buildTypedEntry('big-fish'))).toBeNull(); + expect(mapPublicWorkDetailToBigFishWork(buildTypedEntry('puzzle'))).toBeNull(); + expect( + mapPublicWorkDetailToSquareHoleWork(buildTypedEntry('puzzle')), + ).toBeNull(); + expect( + mapBarkBattlePublicDetailToWorkSummary(buildTypedEntry('puzzle')), + ).toBeNull(); +}); + test('platform public work detail flow resolves edit mode only for owned works', () => { const entry = buildTypedEntry('puzzle'); diff --git a/src/components/platform-entry/platformPublicWorkDetailFlow.ts b/src/components/platform-entry/platformPublicWorkDetailFlow.ts index a99c3bda..4864f01c 100644 --- a/src/components/platform-entry/platformPublicWorkDetailFlow.ts +++ b/src/components/platform-entry/platformPublicWorkDetailFlow.ts @@ -1,4 +1,17 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { + JumpHopGalleryCardResponse, + JumpHopWorkProfileResponse, +} from '../../../packages/shared/src/contracts/jumpHop'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; +import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; +import type { + WoodenFishGalleryCardResponse, + WoodenFishWorkProfileResponse, +} from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { isBarkBattleGalleryEntry, @@ -10,6 +23,13 @@ import { isSquareHoleGalleryEntry, isVisualNovelGalleryEntry, isWoodenFishGalleryEntry, + mapBarkBattleWorkToPlatformGalleryCard, + mapBigFishWorkToPlatformGalleryCard, + mapJumpHopWorkToPlatformGalleryCard, + mapPuzzleWorkToPlatformGalleryCard, + mapSquareHoleWorkToPlatformGalleryCard, + mapVisualNovelWorkToPlatformGalleryCard, + mapWoodenFishWorkToPlatformGalleryCard, type PlatformPublicGalleryCard, } from '../rpg-entry/rpgEntryWorldPresentation'; import { @@ -94,6 +114,202 @@ export function isRpgPublicWorkDetailEntry( return !('sourceType' in entry); } +export function mapRpgGalleryCardToPublicWorkDetail( + entry: CustomWorldGalleryCard, +): PlatformPublicGalleryCard { + return entry; +} + +export function mapPuzzleWorkToPublicWorkDetail( + item: PuzzleWorkSummary, +): PlatformPublicGalleryCard { + return mapPuzzleWorkToPlatformGalleryCard(item); +} + +export function mapSquareHoleWorkToPublicWorkDetail( + item: SquareHoleWorkSummary, +): PlatformPublicGalleryCard { + return mapSquareHoleWorkToPlatformGalleryCard(item); +} + +export function mapBigFishWorkToPublicWorkDetail( + item: BigFishWorkSummary, +): PlatformPublicGalleryCard { + return mapBigFishWorkToPlatformGalleryCard(item); +} + +export function mapVisualNovelWorkToPublicWorkDetail( + item: VisualNovelWorkSummary, +): PlatformPublicGalleryCard { + return mapVisualNovelWorkToPlatformGalleryCard(item); +} + +export function mapJumpHopWorkToPublicWorkDetail( + item: JumpHopGalleryCardResponse | JumpHopWorkProfileResponse, +): PlatformPublicGalleryCard { + return mapJumpHopWorkToPlatformGalleryCard(item); +} + +export function mapBarkBattleWorkToPublicWorkDetail( + item: BarkBattleWorkSummary, +): PlatformPublicGalleryCard { + return mapBarkBattleWorkToPlatformGalleryCard(item); +} + +export function mapWoodenFishWorkToPublicWorkDetail( + item: WoodenFishGalleryCardResponse | WoodenFishWorkProfileResponse, +): PlatformPublicGalleryCard { + return mapWoodenFishWorkToPlatformGalleryCard(item); +} + +export function mapBarkBattlePublicDetailToWorkSummary( + entry: PlatformPublicGalleryCard, +): BarkBattleWorkSummary | null { + if (!isBarkBattleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + draftId: entry.sourceSessionId ?? null, + ownerUserId: entry.ownerUserId, + authorDisplayName: entry.authorDisplayName, + title: entry.worldName, + summary: entry.summaryText, + themeDescription: entry.themeTags[0] ?? entry.summaryText, + playerImageDescription: entry.themeTags[1] ?? entry.summaryText, + opponentImageDescription: entry.themeTags[2] ?? entry.summaryText, + onomatopoeia: undefined, + playerCharacterImageSrc: entry.coverCharacterImageSrcs[0] ?? null, + opponentCharacterImageSrc: entry.coverCharacterImageSrcs[1] ?? null, + uiBackgroundImageSrc: entry.coverImageSrc, + difficultyPreset: 'normal', + status: 'published', + generationStatus: 'ready', + publishReady: true, + playCount: entry.playCount ?? 0, + recentPlayCount7d: entry.recentPlayCount7d ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + }; +} + +export function mapPublicWorkDetailToPuzzleWork( + entry: PlatformPublicGalleryCard, +): PuzzleWorkSummary | null { + if (!isPuzzleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, + authorDisplayName: entry.authorDisplayName, + levelName: entry.worldName, + summary: entry.summaryText, + themeTags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + publicationStatus: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + pointIncentiveTotalHalfPoints: 0, + pointIncentiveClaimedPoints: 0, + pointIncentiveTotalPoints: 0, + pointIncentiveClaimablePoints: 0, + publishReady: true, + levels: + entry.coverSlides?.map((slide, index) => ({ + levelId: slide.id || `puzzle-level-${index + 1}`, + levelName: slide.label, + pictureDescription: entry.summaryText, + candidates: [], + selectedCandidateId: null, + coverImageSrc: slide.imageSrc, + coverAssetId: null, + generationStatus: 'ready' as const, + })) ?? [], + }; +} + +export function mapPublicWorkDetailToBigFishWork( + entry: PlatformPublicGalleryCard, +): BigFishWorkSummary | null { + if (!isBigFishGalleryEntry(entry)) { + return null; + } + + const levelCount = Number.parseInt( + entry.themeTags.find((tag) => /^\d+级$/u.test(tag))?.replace('级', '') ?? + '0', + 10, + ); + + return { + workId: entry.workId, + sourceSessionId: entry.profileId, + ownerUserId: entry.ownerUserId, + authorDisplayName: entry.authorDisplayName, + title: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + status: 'published', + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + levelCount: Number.isNaN(levelCount) ? 0 : levelCount, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: Boolean(entry.coverImageSrc), + playCount: entry.playCount ?? 0, + remixCount: entry.remixCount ?? 0, + likeCount: entry.likeCount ?? 0, + }; +} + +export function mapPublicWorkDetailToSquareHoleWork( + entry: PlatformPublicGalleryCard, +): SquareHoleWorkSummary | null { + if (!isSquareHoleGalleryEntry(entry)) { + return null; + } + + return { + workId: entry.workId, + profileId: entry.profileId, + ownerUserId: entry.ownerUserId, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, + gameName: entry.worldName, + themeText: entry.themeTags[0] ?? '方洞挑战', + twistRule: entry.subtitle, + summary: entry.summaryText, + tags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + backgroundPrompt: entry.backgroundPrompt ?? '方洞挑战运行背景', + backgroundImageSrc: entry.backgroundImageSrc ?? null, + shapeOptions: entry.shapeOptions ?? [], + holeOptions: entry.holeOptions ?? [], + shapeCount: entry.shapeCount ?? 8, + difficulty: entry.difficulty ?? 4, + publicationStatus: 'published', + playCount: entry.playCount ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + }; +} + export function getPlatformPublicWorkDetailKind( entry: PlatformPublicGalleryCard, ): PlatformPublicWorkDetailKind {