refactor: 深化前端入口作品流与作品架模块

This commit is contained in:
2026-06-03 15:34:52 +08:00
parent 1eeb14c50f
commit cf0840d9e9
8 changed files with 434 additions and 157 deletions

View File

@@ -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` 通过。 - 验证方式:`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` - 关联文档:`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` 作为作品架打开动作的正式 InterfaceHub 只保留 `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 最近创作只复用创作模板入口 ## 2026-06-03 最近创作只复用创作模板入口
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。 - 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。

View File

@@ -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)。 前端 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),快速建立这个项目的开发共识。 1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -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 映射与 AdapterHub 不再新增 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

View File

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

View File

@@ -7,10 +7,10 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; 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 { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; 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 { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import type { import type {
@@ -20,10 +20,10 @@ import type {
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes'; import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import { import {
buildCreationWorkShelfItems, buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem, type CreationWorkShelfItem,
type CreationWorkShelfMetricId, type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState, type CreationWorkShelfRuntimeState,
getCreationWorkShelfItemTime,
} from './creationWorkShelf'; } from './creationWorkShelf';
import { import {
CustomWorldCreationStartCard, CustomWorldCreationStartCard,
@@ -274,6 +274,7 @@ export function CustomWorldCreationHub({
barkBattleItems, barkBattleItems,
items, items,
match3dItems, match3dItems,
squareHoleItems,
onDeleteBigFish, onDeleteBigFish,
onDeleteMatch3D, onDeleteMatch3D,
onDeleteSquareHole, onDeleteSquareHole,
@@ -341,44 +342,8 @@ export function CustomWorldCreationHub({
function handleOpenShelfItem(item: CreationWorkShelfItem) { function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item); onOpenShelfItem?.(item);
switch (item.source.kind) { // 中文注释:玩法差异由 Work Shelf Adapter 承载Hub 只负责响应卡片点击。
case 'puzzle': item.actions.open();
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);
}
}
} }
function buildDeleteAction(item: CreationWorkShelfItem) { function buildDeleteAction(item: CreationWorkShelfItem) {

View File

@@ -108,6 +108,7 @@ import type {
VisualNovelWorkDetail, VisualNovelWorkDetail,
VisualNovelWorkSummary, VisualNovelWorkSummary,
} from '../../../packages/shared/src/contracts/visualNovel'; } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { import {
buildPublicWorkStagePath, buildPublicWorkStagePath,
@@ -216,17 +217,12 @@ import {
buildSquareHoleGenerationAnchorEntries, buildSquareHoleGenerationAnchorEntries,
buildWoodenFishGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries,
createMiniGameDraftGenerationState, createMiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
type MiniGameDraftGenerationKind, type MiniGameDraftGenerationKind,
type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationPhase,
type MiniGameDraftGenerationState, type MiniGameDraftGenerationState,
resolveMiniGameDraftGenerationStartedAtMs,
} from '../../services/miniGameDraftGenerationProgress'; } from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
import {
getUnifiedCreationSpec,
type UnifiedCreationPlayId,
} from '../unified-creation/unifiedCreationSpecs';
import { import {
buildBabyObjectMatchPublicWorkCode, buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode, buildBarkBattlePublicWorkCode,
@@ -350,7 +346,6 @@ import {
type WoodenFishWorkProfileResponse, type WoodenFishWorkProfileResponse,
type WoodenFishWorkspaceCreateRequest, type WoodenFishWorkspaceCreateRequest,
} from '../../services/wooden-fish/woodenFishClient'; } from '../../services/wooden-fish/woodenFishClient';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext'; import { useAuthUi } from '../auth/AuthUiContext';
import { PublishShareModal } from '../common/PublishShareModal'; import { PublishShareModal } from '../common/PublishShareModal';
@@ -390,6 +385,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld'; import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave'; import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController'; import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
import {
getUnifiedCreationSpec,
type UnifiedCreationPlayId,
} from '../unified-creation/unifiedCreationSpecs';
import { import {
buildVisualNovelEntryGenerationAnchorEntries, buildVisualNovelEntryGenerationAnchorEntries,
buildVisualNovelEntryGenerationProgress, buildVisualNovelEntryGenerationProgress,
@@ -438,11 +438,18 @@ import {
PlatformErrorDialog, PlatformErrorDialog,
type PlatformErrorDialogPayload, type PlatformErrorDialogPayload,
} from './PlatformErrorDialog'; } from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import {
getPlatformPublicGalleryEntryKey,
getPlatformRecommendRuntimeKind,
isSamePlatformPublicGalleryEntry,
mergePlatformPublicGalleryEntries,
type RecommendRuntimeKind,
} from './platformPublicGalleryFlow';
import { import {
PlatformTaskCompletionDialog, PlatformTaskCompletionDialog,
type PlatformTaskCompletionDialogPayload, type PlatformTaskCompletionDialogPayload,
} from './PlatformTaskCompletionDialog'; } from './PlatformTaskCompletionDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView'; import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController'; import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap'; import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
@@ -511,17 +518,6 @@ type BarkBattleRuntimeReturnStage =
| 'work-detail' | 'work-detail'
| 'platform'; | 'platform';
type BigFishRuntimeSessionSource = 'draft' | 'work' | null; 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 = type SquareHoleRuntimeReturnStage =
| 'square-hole-result' | 'square-hole-result'
| 'work-detail' | 'work-detail'
@@ -625,77 +621,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10; const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3; 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( function isRecommendRuntimeReadyForEntry(
entry: PlatformPublicGalleryCard, entry: PlatformPublicGalleryCard,
state: RecommendRuntimeState, state: RecommendRuntimeState,
@@ -739,33 +664,6 @@ function isRecommendRuntimeReadyForEntry(
return true; return true;
} }
function isSamePlatformPublicGalleryEntry(
left: PlatformPublicGalleryCard,
right: PlatformPublicGalleryCard,
) {
return (
getPlatformPublicGalleryEntryKey(left) ===
getPlatformPublicGalleryEntryKey(right)
);
}
function mergePlatformPublicGalleryEntries(
rpgEntries: CustomWorldGalleryCard[],
puzzleEntries: PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values()).sort(
(left, right) =>
getPlatformPublicGalleryEntryTime(right) -
getPlatformPublicGalleryEntryTime(left),
);
}
function mapRpgGalleryCardToPublicWorkDetail( function mapRpgGalleryCardToPublicWorkDetail(
entry: CustomWorldGalleryCard, entry: CustomWorldGalleryCard,
): PlatformPublicGalleryCard { ): PlatformPublicGalleryCard {

View File

@@ -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<TypedPlatformPublicGalleryCard, 'sourceType'>
>;
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): 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);
});

View File

@@ -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<string, PlatformPublicGalleryCard>();
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values()).sort(
(left, right) =>
getPlatformPublicGalleryEntryTime(right) -
getPlatformPublicGalleryEntryTime(left),
);
}