refactor: 收口公开作品详情映射

This commit is contained in:
2026-06-04 00:17:31 +08:00
parent dd52848e9c
commit 39522f3b96
8 changed files with 675 additions and 232 deletions

View File

@@ -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 平台入口弹窗状态规则收口

View File

@@ -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)。

View File

@@ -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**:公开作品详情入口的纯策略与通用映射集中到一个小 ModuleMatch3D 素材归一仍在 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"`

View File

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

View File

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

View File

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

View File

@@ -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<TypedPlatformPublicGalleryCard, 'sourceType'>
type TypedPlatformPublicGalleryCardOverrides<
TSourceType extends PlatformGallerySourceType,
> = Partial<
Omit<
Extract<TypedPlatformPublicGalleryCard, { sourceType: TSourceType }>,
'sourceType'
>
>;
function narrowTypedEntry<TSourceType extends PlatformGallerySourceType>(
entry: TypedPlatformPublicGalleryCard,
): Extract<TypedPlatformPublicGalleryCard, { sourceType: TSourceType }> {
return entry as Extract<TypedPlatformPublicGalleryCard, { sourceType: TSourceType }>;
}
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
@@ -48,10 +78,10 @@ function buildRpgEntry(
};
}
function buildTypedEntry(
sourceType: PlatformGallerySourceType,
overrides: TypedPlatformPublicGalleryCardOverrides = {},
): PlatformPublicGalleryCard {
function buildTypedEntry<TSourceType extends PlatformGallerySourceType>(
sourceType: TSourceType,
overrides: TypedPlatformPublicGalleryCardOverrides<TSourceType> = {},
): Extract<TypedPlatformPublicGalleryCard, { sourceType: TSourceType }> {
const common = {
workId: `${sourceType}-work`,
profileId: `${sourceType}-profile`,
@@ -70,31 +100,30 @@ function buildTypedEntry(
switch (sourceType) {
case 'puzzle':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'big-fish':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'match3d':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'square-hole':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'visual-novel':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'jump-hop':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'wooden-fish':
return { ...common, ...overrides, sourceType };
return narrowTypedEntry<TSourceType>({ ...common, ...overrides, sourceType });
case 'edutainment':
return {
return narrowTypedEntry<TSourceType>({
...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<TSourceType>({
...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> = {},
): 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> = {},
): 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> = {},
): 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> = {},
): 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> = {},
): 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> = {},
): 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> = {},
): 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');

View File

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