From cf0840d9e9aa0adbce216ef38c3857ac66a6f1c5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:34:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=B7=B1=E5=8C=96=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=85=A5=E5=8F=A3=E4=BD=9C=E5=93=81=E6=B5=81=E4=B8=8E?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E6=9E=B6=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 16 ++ docs/README.md | 4 + ...架构】WorkShelfModule收口计划-2026-06-03.md | 27 +++ ...入口PublicGalleryFlowModule收口计划-2026-06-03.md | 35 +++ .../CustomWorldCreationHub.tsx | 45 +--- .../PlatformEntryFlowShellImpl.tsx | 132 ++---------- .../platformPublicGalleryFlow.test.ts | 204 ++++++++++++++++++ .../platformPublicGalleryFlow.ts | 128 +++++++++++ 8 files changed, 434 insertions(+), 157 deletions(-) create mode 100644 docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md create mode 100644 docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md create mode 100644 src/components/platform-entry/platformPublicGalleryFlow.test.ts create mode 100644 src/components/platform-entry/platformPublicGalleryFlow.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index f9f9bdd3..a3aff1f8 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -24,6 +24,22 @@ - 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。 - 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。 +## 2026-06-03 平台入口公开作品流身份规则收口 + +- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。 +- 决策:公开作品身份和排序规则统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。 +- 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。 + +## 2026-06-03 Work Shelf 打开动作交由 item Adapter + +- 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 +- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。 +- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、后续新增玩法作品架接入。 +- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 +- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 + ## 2026-06-03 最近创作只复用创作模板入口 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 diff --git a/docs/README.md b/docs/README.md index 154cd937..5bb6fbd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,10 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。 +平台入口公开作品身份、跨玩法去重、推荐运行态 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)。 + +创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】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)。 + ## 推荐阅读顺序 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md new file mode 100644 index 00000000..a1bbc0c0 --- /dev/null +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -0,0 +1,27 @@ +# 【前端架构】Work Shelf Module 收口计划 + +## 背景 + +创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter,但 `CustomWorldCreationHub.tsx` 仍在点击作品卡时按玩法 `kind` 再写一遍打开逻辑,导致调用方仍须理解每种玩法。 + +## 决策 + +`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。 + +此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: + +- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem`,不需要知道每种玩法的打开规则。 +- **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 +- **Locality**:作品架点击行为的错误集中在 `creationWorkShelf.ts` 与其测试里定位。 + +## 后续深化 + +下一步可把 `buildCreationWorkShelfItems` 当前的长参数列表继续收口为 per-kind Source Adapter registry。届时 Hub / 平台壳传入玩法数据源和回调时,可逐步减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 + +## 验证 + +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md new file mode 100644 index 00000000..5cb2ca6c --- /dev/null +++ b/docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md @@ -0,0 +1,35 @@ +# 【前端架构】平台入口 Public Gallery Flow Module 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。 + +## 决策 + +新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为: + +- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。 +- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。 +- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。 +- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。 + +入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份和排序规则。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。 + +## 玩法身份规则 + +- `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 使用自身 `sourceType` 作为 key kind。 +- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind,避免后续幼教类模板共用 `sourceType` 时互相覆盖。 +- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`。 +- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。 +- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。 + +## 后续深化 + +下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity 与 runtime kind 的修改集中在一处。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts` +- `npm run typecheck` +- `npm run check:encoding` +- 针对变更文件执行 ESLint diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 971a8f31..e4be3bd6 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -7,10 +7,10 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldLibraryEntry } 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 { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CreationEntryConfig } from '../../services/creationEntryConfigService'; import type { CustomWorldProfile } from '../../types'; import type { @@ -20,10 +20,10 @@ import type { import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import { buildCreationWorkShelfItems, - getCreationWorkShelfItemTime, type CreationWorkShelfItem, type CreationWorkShelfMetricId, type CreationWorkShelfRuntimeState, + getCreationWorkShelfItemTime, } from './creationWorkShelf'; import { CustomWorldCreationStartCard, @@ -274,6 +274,7 @@ export function CustomWorldCreationHub({ barkBattleItems, items, match3dItems, + squareHoleItems, onDeleteBigFish, onDeleteMatch3D, onDeleteSquareHole, @@ -341,44 +342,8 @@ export function CustomWorldCreationHub({ function handleOpenShelfItem(item: CreationWorkShelfItem) { onOpenShelfItem?.(item); - switch (item.source.kind) { - case 'puzzle': - onOpenPuzzleDetail?.(item.source.item); - return; - case 'baby-object-match': - onOpenBabyObjectMatchDetail?.(item.source.item); - return; - case 'visual-novel': - onOpenVisualNovelDetail?.(item.source.item); - return; - case 'bark-battle': - onOpenBarkBattleDetail?.(item.source.item); - return; - case 'big-fish': - onOpenBigFishDetail?.(item.source.item); - return; - case 'match3d': - onOpenMatch3DDetail?.(item.source.item); - return; - case 'square-hole': - onOpenSquareHoleDetail?.(item.source.item); - return; - case 'jump-hop': - onOpenJumpHopDetail?.(item.source.item); - return; - case 'wooden-fish': - onOpenWoodenFishDetail?.(item.source.item); - return; - case 'rpg': - if (item.status === 'draft') { - onOpenDraft(item.source.item); - return; - } - - if (item.source.item.profileId) { - onEnterPublished(item.source.item.profileId); - } - } + // 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。 + item.actions.open(); } function buildDeleteAction(item: CreationWorkShelfItem) { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b305027c..71489366 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -108,6 +108,7 @@ import type { VisualNovelWorkDetail, VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; +import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildPublicWorkStagePath, @@ -216,17 +217,12 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, - resolveMiniGameDraftGenerationStartedAtMs, type MiniGameDraftGenerationKind, type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; -import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; -import { - getUnifiedCreationSpec, - type UnifiedCreationPlayId, -} from '../unified-creation/unifiedCreationSpecs'; import { buildBabyObjectMatchPublicWorkCode, buildBarkBattlePublicWorkCode, @@ -350,7 +346,6 @@ import { type WoodenFishWorkProfileResponse, type WoodenFishWorkspaceCreateRequest, } from '../../services/wooden-fish/woodenFishClient'; -import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { PublishShareModal } from '../common/PublishShareModal'; @@ -390,6 +385,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; +import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage'; +import { + getUnifiedCreationSpec, + type UnifiedCreationPlayId, +} from '../unified-creation/unifiedCreationSpecs'; import { buildVisualNovelEntryGenerationAnchorEntries, buildVisualNovelEntryGenerationProgress, @@ -438,11 +438,18 @@ import { PlatformErrorDialog, type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; +import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { + getPlatformPublicGalleryEntryKey, + getPlatformRecommendRuntimeKind, + isSamePlatformPublicGalleryEntry, + mergePlatformPublicGalleryEntries, + type RecommendRuntimeKind, +} from './platformPublicGalleryFlow'; import { PlatformTaskCompletionDialog, type PlatformTaskCompletionDialogPayload, } from './PlatformTaskCompletionDialog'; -import { PlatformFeedbackView } from './PlatformFeedbackView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; @@ -511,17 +518,6 @@ type BarkBattleRuntimeReturnStage = | 'work-detail' | 'platform'; type BigFishRuntimeSessionSource = 'draft' | 'work' | null; -type RecommendRuntimeKind = - | 'bark-battle' - | 'big-fish' - | 'edutainment' - | 'jump-hop' - | 'match3d' - | 'puzzle' - | 'square-hole' - | 'wooden-fish' - | 'visual-novel' - | 'rpg'; type SquareHoleRuntimeReturnStage = | 'square-hole-result' | 'work-detail' @@ -625,77 +621,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; -function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) { - const rawTime = entry.publishedAt ?? entry.updatedAt; - const timestamp = new Date(rawTime).getTime(); - return Number.isNaN(timestamp) ? 0 : timestamp; -} - -function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { - const kind = isBigFishGalleryEntry(entry) - ? 'big-fish' - : isPuzzleGalleryEntry(entry) - ? 'puzzle' - : isJumpHopGalleryEntry(entry) - ? 'jump-hop' - : isWoodenFishGalleryEntry(entry) - ? 'wooden-fish' - : isMatch3DGalleryEntry(entry) - ? 'match3d' - : isSquareHoleGalleryEntry(entry) - ? 'square-hole' - : isVisualNovelGalleryEntry(entry) - ? 'visual-novel' - : isBarkBattleGalleryEntry(entry) - ? 'bark-battle' - : isEdutainmentGalleryEntry(entry) - ? `edutainment:${entry.templateId}` - : 'rpg'; - return `${kind}:${entry.ownerUserId}:${entry.profileId}`; -} - -function getPlatformRecommendRuntimeKind( - entry: PlatformPublicGalleryCard, -): RecommendRuntimeKind { - if (isBigFishGalleryEntry(entry)) { - return 'big-fish'; - } - - if (isPuzzleGalleryEntry(entry)) { - return 'puzzle'; - } - - if (isJumpHopGalleryEntry(entry)) { - return 'jump-hop'; - } - - if (isWoodenFishGalleryEntry(entry)) { - return 'wooden-fish'; - } - - if (isMatch3DGalleryEntry(entry)) { - return 'match3d'; - } - - if (isSquareHoleGalleryEntry(entry)) { - return 'square-hole'; - } - - if (isVisualNovelGalleryEntry(entry)) { - return 'visual-novel'; - } - - if (isBarkBattleGalleryEntry(entry)) { - return 'bark-battle'; - } - - if (isEdutainmentGalleryEntry(entry)) { - return 'edutainment'; - } - - return 'rpg'; -} - function isRecommendRuntimeReadyForEntry( entry: PlatformPublicGalleryCard, state: RecommendRuntimeState, @@ -739,33 +664,6 @@ function isRecommendRuntimeReadyForEntry( return true; } -function isSamePlatformPublicGalleryEntry( - left: PlatformPublicGalleryCard, - right: PlatformPublicGalleryCard, -) { - return ( - getPlatformPublicGalleryEntryKey(left) === - getPlatformPublicGalleryEntryKey(right) - ); -} - -function mergePlatformPublicGalleryEntries( - rpgEntries: CustomWorldGalleryCard[], - puzzleEntries: PlatformPublicGalleryCard[], -) { - const entryMap = new Map(); - - [...rpgEntries, ...puzzleEntries].forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); - - return Array.from(entryMap.values()).sort( - (left, right) => - getPlatformPublicGalleryEntryTime(right) - - getPlatformPublicGalleryEntryTime(left), - ); -} - function mapRpgGalleryCardToPublicWorkDetail( entry: CustomWorldGalleryCard, ): PlatformPublicGalleryCard { diff --git a/src/components/platform-entry/platformPublicGalleryFlow.test.ts b/src/components/platform-entry/platformPublicGalleryFlow.test.ts new file mode 100644 index 00000000..7003243c --- /dev/null +++ b/src/components/platform-entry/platformPublicGalleryFlow.test.ts @@ -0,0 +1,204 @@ +import { expect, test } from 'vitest'; + +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; +import { + getPlatformPublicGalleryEntryKey, + getPlatformPublicGalleryEntryTime, + getPlatformRecommendRuntimeKind, + isSamePlatformPublicGalleryEntry, + mergePlatformPublicGalleryEntries, + type RecommendRuntimeKind, +} from './platformPublicGalleryFlow'; + +type TypedPlatformPublicGalleryCard = Extract< + PlatformPublicGalleryCard, + { sourceType: string } +>; +type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType']; +type TypedPlatformPublicGalleryCardOverrides = Partial< + Omit +>; + +function buildRpgEntry( + overrides: Partial = {}, +): CustomWorldGalleryCard { + return { + ownerUserId: 'user-1', + profileId: 'rpg-profile', + publicWorkCode: 'CW-RPG', + authorPublicUserCode: null, + visibility: 'published', + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + authorDisplayName: '玩家', + worldName: 'RPG 世界', + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + ...overrides, + }; +} + +function buildTypedEntry( + sourceType: PlatformGallerySourceType, + overrides: TypedPlatformPublicGalleryCardOverrides = {}, +): PlatformPublicGalleryCard { + const common = { + workId: `${sourceType}-work`, + profileId: `${sourceType}-profile`, + publicWorkCode: `${sourceType}-code`, + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: `${sourceType} 作品`, + subtitle: '公开作品', + summaryText: '公开作品摘要', + coverImageSrc: null, + themeTags: [sourceType], + visibility: 'published' as const, + publishedAt: '2026-06-01T00:00:00.000Z', + updatedAt: '2026-06-01T01:00:00.000Z', + }; + + switch (sourceType) { + case 'puzzle': + return { ...common, ...overrides, sourceType }; + case 'big-fish': + return { ...common, ...overrides, sourceType }; + case 'match3d': + return { ...common, ...overrides, sourceType }; + case 'square-hole': + return { ...common, ...overrides, sourceType }; + case 'visual-novel': + return { ...common, ...overrides, sourceType }; + case 'jump-hop': + return { ...common, ...overrides, sourceType }; + case 'wooden-fish': + return { ...common, ...overrides, sourceType }; + case 'edutainment': + return { + ...common, + ...overrides, + sourceType, + templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID, + templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME, + }; + case 'bark-battle': + return { + ...common, + ...overrides, + sourceType, + authorPublicUserCode: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + themeMode: 'martial', + playableNpcCount: 1, + landmarkCount: 1, + }; + default: { + const exhaustive: never = sourceType; + return exhaustive; + } + } +} + +test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => { + const cases: Array< + [sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind] + > = [ + ['big-fish', 'big-fish', 'big-fish'], + ['puzzle', 'puzzle', 'puzzle'], + ['jump-hop', 'jump-hop', 'jump-hop'], + ['wooden-fish', 'wooden-fish', 'wooden-fish'], + ['match3d', 'match3d', 'match3d'], + ['square-hole', 'square-hole', 'square-hole'], + ['visual-novel', 'visual-novel', 'visual-novel'], + ['bark-battle', 'bark-battle', 'bark-battle'], + [ + 'edutainment', + `edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`, + 'edutainment', + ], + ]; + + cases.forEach(([sourceType, keyKind, kind]) => { + const entry = buildTypedEntry(sourceType); + + expect(getPlatformPublicGalleryEntryKey(entry)).toBe( + `${keyKind}:user-1:${sourceType}-profile`, + ); + expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind); + }); + + const rpgEntry = buildRpgEntry(); + + expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe( + 'rpg:user-1:rpg-profile', + ); + expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg'); +}); + +test('platform public gallery flow compares entries by resolved identity', () => { + const left = buildTypedEntry('puzzle'); + const sameIdentity = buildTypedEntry('puzzle', { + workId: 'other-work', + worldName: '新标题', + }); + const otherKind = buildTypedEntry('match3d', { + ownerUserId: left.ownerUserId, + profileId: left.profileId, + }); + + expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true); + expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false); +}); + +test('platform public gallery flow merges duplicate identities and sorts newest first', () => { + const staleRpgEntry = buildRpgEntry({ + profileId: 'shared-rpg', + worldName: '旧版 RPG', + publishedAt: '2026-06-01T00:00:00.000Z', + }); + const freshRpgEntry = buildRpgEntry({ + profileId: 'shared-rpg', + worldName: '新版 RPG', + publishedAt: '2026-06-04T00:00:00.000Z', + }); + const middleRpgEntry = buildRpgEntry({ + profileId: 'middle-rpg', + worldName: '中间 RPG', + publishedAt: '2026-06-02T00:00:00.000Z', + }); + const updatedOnlyEntry = buildTypedEntry('big-fish', { + profileId: 'updated-only', + publishedAt: null, + updatedAt: '2026-06-03T00:00:00.000Z', + }); + const invalidTimeEntry = buildTypedEntry('puzzle', { + profileId: 'invalid-time', + publishedAt: 'not-a-date', + updatedAt: 'still-not-a-date', + }); + + const merged = mergePlatformPublicGalleryEntries( + [staleRpgEntry, middleRpgEntry], + [invalidTimeEntry, updatedOnlyEntry, freshRpgEntry], + ); + + expect(merged).toHaveLength(4); + expect(merged.map((entry) => entry.profileId)).toEqual([ + 'shared-rpg', + 'updated-only', + 'middle-rpg', + 'invalid-time', + ]); + expect(merged[0]?.worldName).toBe('新版 RPG'); + expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0); +}); diff --git a/src/components/platform-entry/platformPublicGalleryFlow.ts b/src/components/platform-entry/platformPublicGalleryFlow.ts new file mode 100644 index 00000000..6a28e0c9 --- /dev/null +++ b/src/components/platform-entry/platformPublicGalleryFlow.ts @@ -0,0 +1,128 @@ +import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime'; +import { + isBarkBattleGalleryEntry, + isBigFishGalleryEntry, + isEdutainmentGalleryEntry, + isJumpHopGalleryEntry, + isMatch3DGalleryEntry, + isPuzzleGalleryEntry, + isSquareHoleGalleryEntry, + isVisualNovelGalleryEntry, + isWoodenFishGalleryEntry, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export type RecommendRuntimeKind = + | 'bark-battle' + | 'big-fish' + | 'edutainment' + | 'jump-hop' + | 'match3d' + | 'puzzle' + | 'square-hole' + | 'wooden-fish' + | 'visual-novel' + | 'rpg'; + +export function getPlatformPublicGalleryEntryTime( + entry: PlatformPublicGalleryCard, +) { + const rawTime = entry.publishedAt ?? entry.updatedAt; + const timestamp = new Date(rawTime).getTime(); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +export function getPlatformPublicGalleryEntryKey( + entry: PlatformPublicGalleryCard, +) { + // 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。 + const kind = isBigFishGalleryEntry(entry) + ? 'big-fish' + : isPuzzleGalleryEntry(entry) + ? 'puzzle' + : isJumpHopGalleryEntry(entry) + ? 'jump-hop' + : isWoodenFishGalleryEntry(entry) + ? 'wooden-fish' + : isMatch3DGalleryEntry(entry) + ? 'match3d' + : isSquareHoleGalleryEntry(entry) + ? 'square-hole' + : isVisualNovelGalleryEntry(entry) + ? 'visual-novel' + : isBarkBattleGalleryEntry(entry) + ? 'bark-battle' + : isEdutainmentGalleryEntry(entry) + ? `edutainment:${entry.templateId}` + : 'rpg'; + return `${kind}:${entry.ownerUserId}:${entry.profileId}`; +} + +export function getPlatformRecommendRuntimeKind( + entry: PlatformPublicGalleryCard, +): RecommendRuntimeKind { + if (isBigFishGalleryEntry(entry)) { + return 'big-fish'; + } + + if (isPuzzleGalleryEntry(entry)) { + return 'puzzle'; + } + + if (isJumpHopGalleryEntry(entry)) { + return 'jump-hop'; + } + + if (isWoodenFishGalleryEntry(entry)) { + return 'wooden-fish'; + } + + if (isMatch3DGalleryEntry(entry)) { + return 'match3d'; + } + + if (isSquareHoleGalleryEntry(entry)) { + return 'square-hole'; + } + + if (isVisualNovelGalleryEntry(entry)) { + return 'visual-novel'; + } + + if (isBarkBattleGalleryEntry(entry)) { + return 'bark-battle'; + } + + if (isEdutainmentGalleryEntry(entry)) { + return 'edutainment'; + } + + return 'rpg'; +} + +export function isSamePlatformPublicGalleryEntry( + left: PlatformPublicGalleryCard, + right: PlatformPublicGalleryCard, +) { + return ( + getPlatformPublicGalleryEntryKey(left) === + getPlatformPublicGalleryEntryKey(right) + ); +} + +export function mergePlatformPublicGalleryEntries( + rpgEntries: CustomWorldGalleryCard[], + puzzleEntries: PlatformPublicGalleryCard[], +) { + const entryMap = new Map(); + + [...rpgEntries, ...puzzleEntries].forEach((entry) => { + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); + + return Array.from(entryMap.values()).sort( + (left, right) => + getPlatformPublicGalleryEntryTime(right) - + getPlatformPublicGalleryEntryTime(left), + ); +}