diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 32626c48..4ac34916 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1265,6 +1265,14 @@ - 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`。 +## 2026-06-03 Match3D Runtime Profile 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内仍直接承载抓大鹅公开详情转 work、session draft 转 profile、生成背景资产提升、runtime active profile 选择和 run / profile / public detail 素材优先级,平台壳需要理解抓大鹅生成素材内部结构。 +- 决策:新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts` 作为抓大鹅 runtime profile Module,Interface 收口 `mapPublicWorkDetailToMatch3DWork`、`buildMatch3DProfileFromSession`、`normalizeMatch3DWorkForRuntimeUi`、`mapMatch3DWorksForRuntimeUi`、`promoteMatch3DGeneratedBackgroundAsset`、`hasMatch3DRuntimeAsset`、`hasMatch3DRuntimeBackgroundAsset`、`resolveActiveMatch3DRuntimeProfile` 与 runtime item/background/backgroundImage 解析函数;平台壳只保留启动 run、预加载、路由、错误和 state 编排。 +- 影响范围:抓大鹅作品架、公开详情试玩、推荐 runtime、正式 runtime 与草稿结果页试玩前素材规范化。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 8f3b7c0e..b4d20565 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`,Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 +抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + 公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md new file mode 100644 index 00000000..a39d835b --- /dev/null +++ b/docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md @@ -0,0 +1,34 @@ +# 【前端架构】Match3D Runtime Profile 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时编排抓大鹅创作、作品详情、推荐 runtime 和正式 runtime。运行态启动前的 profile 规范化、公开详情转 work、生成背景资产提升、run / profile / public detail 优先级和 runtime 素材选择原本都在平台壳 **Implementation** 内,导致平台壳必须理解抓大鹅生成素材的内部结构。 + +## 决策 + +新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,作为抓大鹅 runtime profile **Module**。该 **Module** 的 **Interface** 收口为: + +- `mapPublicWorkDetailToMatch3DWork(entry)`:把公开作品详情映射为可启动 runtime 的 Match3D work,并补齐生成背景资产。 +- `buildMatch3DProfileFromSession(session)`:从创作 session draft 生成 runtime profile。 +- `normalizeMatch3DWorkForRuntimeUi(profile)` / `mapMatch3DWorksForRuntimeUi(profiles)`:统一作品列表进入 UI / runtime 前的素材规范化。 +- `promoteMatch3DGeneratedBackgroundAsset(profile)`:从 `generatedBackgroundAsset` 或 `generatedItemAssets[].backgroundAsset` 提升背景图、对象 key 与 prompt。 +- `hasMatch3DRuntimeAsset(profile.generatedItemAssets)` / `hasMatch3DRuntimeBackgroundAsset(profile)`:统一判断 runtime 是否具备物品与背景素材。 +- `resolveActiveMatch3DRuntimeProfile(run, runtimeProfile, profile)`:按 run 的 `profileId` 选择当前 profile,避免切屏时误用旧草稿。 +- `resolveMatch3DRuntimeGeneratedItemAssets(...)`、`resolveMatch3DRuntimeGeneratedBackgroundAsset(...)`、`resolveMatch3DRuntimeBackgroundImageSrc(...)`:统一 run / profile / public detail 的素材优先级。 + +`PlatformEntryFlowShellImpl.tsx` 只保留启动 run、预加载、路由、错误和 state 编排;抓大鹅素材规则集中到该 **Module**,提升 **Locality** 与测试 **Leverage**。 + +## 约定 + +- 公开详情补 runtime 素材时,只有 `profileId` 与 run 匹配才优先使用公开详情;错配时不得污染当前 run。 +- 当前启动时拿到的 `runtimeProfile` 优先于旧草稿 profile;若 run 指向旧草稿 profile,才使用草稿 profile。 +- 背景资产提升不得覆盖已有显式 `backgroundImageSrc` / `backgroundImageObjectKey` / `generatedBackgroundAsset`,只补缺。 +- 本 **Module** 只放纯 profile / asset 规则,不引入启动 run、预加载、URL、状态机或 UI 副作用。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"` +- `npm run typecheck` +- `npm run check:encoding` +- 针对新 Module 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index e7ee35c6..48e7019c 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -49,7 +49,6 @@ import type { } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { - Match3DGeneratedBackgroundAsset, Match3DGeneratedItemAsset, Match3DWorkProfile, Match3DWorkSummary, @@ -202,7 +201,6 @@ import { listMatch3DWorks, } from '../../services/match3d-works'; import { - hasMatch3DGeneratedImageAsset, mergeMatch3DGeneratedItemAssetsForRuntime, normalizeMatch3DGeneratedItemAssetsForRuntime, preloadMatch3DGeneratedRuntimeAssets, @@ -442,6 +440,19 @@ import { type PlatformErrorDialogPayload, } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { + buildMatch3DProfileFromSession, + hasMatch3DRuntimeAsset, + hasMatch3DRuntimeBackgroundAsset, + mapMatch3DWorksForRuntimeUi, + mapPublicWorkDetailToMatch3DWork, + normalizeMatch3DWorkForRuntimeUi, + promoteMatch3DGeneratedBackgroundAsset, + resolveActiveMatch3DRuntimeProfile, + resolveMatch3DRuntimeBackgroundImageSrc, + resolveMatch3DRuntimeGeneratedBackgroundAsset, + resolveMatch3DRuntimeGeneratedItemAssets, +} from './platformMatch3DRuntimeProfile'; import { getPlatformPublicGalleryEntryKey, getPlatformRecommendRuntimeKind, @@ -811,317 +822,6 @@ function mapVisualNovelWorkDetailToSession( }; } -function mapPublicWorkDetailToMatch3DWork( - entry: PlatformPublicGalleryCard, -): Match3DWorkSummary | null { - if (!isMatch3DGalleryEntry(entry)) { - return null; - } - - return promoteMatch3DGeneratedBackgroundAsset({ - 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] ?? '经典消除', - summary: entry.summaryText, - tags: entry.themeTags, - coverImageSrc: entry.coverImageSrc, - referenceImageSrc: null, - clearCount: 12, - difficulty: 4, - publicationStatus: 'published', - playCount: entry.playCount ?? 0, - updatedAt: entry.updatedAt, - publishedAt: entry.publishedAt, - publishReady: true, - backgroundPrompt: entry.backgroundPrompt ?? null, - backgroundImageSrc: entry.backgroundImageSrc ?? null, - backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null, - generatedBackgroundAsset: - entry.generatedBackgroundAsset ?? - entry.generatedItemAssets - ?.map((asset) => asset.backgroundAsset ?? null) - .find(Boolean) ?? - null, - generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( - entry.generatedItemAssets ?? [], - ), - }); -} - -function findMatch3DGeneratedBackgroundAsset( - generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, -): Match3DGeneratedBackgroundAsset | null { - return ( - generatedItemAssets - ?.map((asset) => asset.backgroundAsset ?? null) - .find(Boolean) ?? null - ); -} - -function promoteMatch3DGeneratedBackgroundAsset< - T extends Pick< - Match3DWorkSummary, - | 'backgroundPrompt' - | 'backgroundImageSrc' - | 'backgroundImageObjectKey' - | 'generatedBackgroundAsset' - | 'generatedItemAssets' - >, ->(profile: T): T { - const backgroundAsset = - profile.generatedBackgroundAsset ?? - findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets); - if (!backgroundAsset) { - return profile; - } - - return { - ...profile, - backgroundPrompt: - profile.backgroundPrompt ?? backgroundAsset.prompt ?? null, - backgroundImageSrc: - profile.backgroundImageSrc ?? - backgroundAsset.imageSrc ?? - backgroundAsset.imageObjectKey ?? - null, - backgroundImageObjectKey: - profile.backgroundImageObjectKey ?? - backgroundAsset.imageObjectKey ?? - backgroundAsset.imageSrc ?? - null, - generatedBackgroundAsset: - profile.generatedBackgroundAsset ?? backgroundAsset, - }; -} - -function normalizeMatch3DWorkForRuntimeUi( - profile: T, -): T { - return promoteMatch3DGeneratedBackgroundAsset({ - ...profile, - generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( - profile.generatedItemAssets, - ), - }); -} - -function mapMatch3DWorksForRuntimeUi( - profiles: readonly T[], -): T[] { - return profiles.map(normalizeMatch3DWorkForRuntimeUi); -} - -function buildMatch3DProfileFromSession( - session: Match3DAgentSessionSnapshot | null, -): Match3DWorkProfile | null { - const draft = session?.draft; - if (!session || !draft?.profileId) { - return null; - } - - const now = session.updatedAt || new Date().toISOString(); - const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime( - draft.generatedItemAssets, - ); - return promoteMatch3DGeneratedBackgroundAsset({ - workId: draft.profileId, - profileId: draft.profileId, - ownerUserId: 'current-user', - sourceSessionId: session.sessionId, - gameName: draft.gameName, - themeText: draft.themeText, - summary: draft.summary ?? draft.summaryText ?? '', - tags: draft.tags, - coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null, - referenceImageSrc: draft.referenceImageSrc ?? null, - clearCount: draft.clearCount, - difficulty: draft.difficulty, - publicationStatus: 'draft', - playCount: 0, - updatedAt: now, - publishedAt: null, - publishReady: Boolean(draft.publishReady), - backgroundPrompt: draft.backgroundPrompt ?? null, - backgroundImageSrc: draft.backgroundImageSrc ?? null, - backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null, - generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null, - generatedItemAssets, - }); -} - -function hasMatch3DRuntimeAsset( - assets: readonly Match3DGeneratedItemAsset[] | null | undefined, -) { - return hasMatch3DGeneratedImageAsset(assets); -} - -function hasMatch3DRuntimeBackgroundAsset( - profile: Pick< - Match3DWorkSummary, - | 'backgroundImageSrc' - | 'backgroundImageObjectKey' - | 'generatedBackgroundAsset' - | 'generatedItemAssets' - >, -) { - return Boolean( - profile.backgroundImageSrc?.trim() || - profile.backgroundImageObjectKey?.trim() || - profile.generatedBackgroundAsset?.imageSrc?.trim() || - profile.generatedBackgroundAsset?.imageObjectKey?.trim() || - profile.generatedBackgroundAsset?.containerImageSrc?.trim() || - profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() || - profile.generatedItemAssets?.some( - (asset) => - asset.backgroundAsset?.imageSrc?.trim() || - asset.backgroundAsset?.imageObjectKey?.trim() || - asset.backgroundAsset?.containerImageSrc?.trim() || - asset.backgroundAsset?.containerImageObjectKey?.trim(), - ), - ); -} - -function resolveMatch3DRuntimeGeneratedItemAssets( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const profileAssets = profile?.generatedItemAssets ?? []; - const publicDetailAssets = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? (publicWorkDetail.generatedItemAssets ?? []) - : []; - - if (runProfileId && profile?.profileId === runProfileId) { - if (hasMatch3DRuntimeAsset(profileAssets)) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - if ( - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return hasMatch3DRuntimeAsset(publicDetailAssets) - ? mergeMatch3DGeneratedItemAssetsForRuntime( - publicDetailAssets, - profileAssets, - ) - : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets); - } - - if (hasMatch3DRuntimeAsset(profileAssets)) { - return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); - } - return publicDetailAssets.length > 0 - ? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets) - : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); -} - -function resolveMatch3DRuntimeGeneratedBackgroundAsset( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const profileBackground = profile - ? (promoteMatch3DGeneratedBackgroundAsset(profile) - .generatedBackgroundAsset ?? null) - : null; - const publicBackground = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) - .generatedBackgroundAsset ?? null) - : null; - - if (runProfileId && profile?.profileId === runProfileId) { - return profileBackground ?? publicBackground; - } - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return publicBackground ?? profileBackground; - } - return profileBackground ?? publicBackground; -} - -function resolveActiveMatch3DRuntimeProfile( - run: Match3DRunSnapshot | null, - runtimeProfile: Match3DWorkProfile | null, - profile: Match3DWorkProfile | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - if (runProfileId && runtimeProfile?.profileId === runProfileId) { - return runtimeProfile; - } - if (runProfileId && profile?.profileId === runProfileId) { - return profile; - } - return runtimeProfile ?? profile; -} - -function resolveMatch3DRuntimeBackgroundImageSrc( - run: Match3DRunSnapshot | null, - profile: Match3DWorkProfile | null, - publicWorkDetail: PlatformPublicGalleryCard | null, -) { - const runProfileId = run?.profileId?.trim() ?? ''; - const resolvedProfile = profile - ? promoteMatch3DGeneratedBackgroundAsset(profile) - : null; - const resolvedPublicWork = - publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) - ? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) - : null; - const profileBackground = - resolvedProfile?.backgroundImageSrc?.trim() || - resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() || - resolvedProfile?.backgroundImageObjectKey?.trim() || - resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() || - ''; - const publicBackground = - resolvedPublicWork?.backgroundImageSrc?.trim() || - resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() || - resolvedPublicWork?.backgroundImageObjectKey?.trim() || - resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() || - ''; - - if (runProfileId && profile?.profileId === runProfileId) { - return profileBackground || publicBackground || null; - } - if ( - runProfileId && - publicWorkDetail && - isMatch3DGalleryEntry(publicWorkDetail) && - publicWorkDetail.profileId === runProfileId - ) { - return publicBackground || profileBackground || null; - } - return profileBackground || publicBackground || null; -} - function resolveMatch3DGenerationStateFromAssets( current: MiniGameDraftGenerationState | null, assets: readonly Match3DGeneratedItemAsset[] | null | undefined, diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts new file mode 100644 index 00000000..56e98a95 --- /dev/null +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from 'vitest'; + +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; +import type { + Match3DGeneratedBackgroundAsset, + Match3DGeneratedItemAsset, + Match3DWorkProfile, +} from '../../../packages/shared/src/contracts/match3dWorks'; +import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import { + buildMatch3DProfileFromSession, + mapPublicWorkDetailToMatch3DWork, + resolveActiveMatch3DRuntimeProfile, + resolveMatch3DRuntimeBackgroundImageSrc, + resolveMatch3DRuntimeGeneratedBackgroundAsset, + resolveMatch3DRuntimeGeneratedItemAssets, +} from './platformMatch3DRuntimeProfile'; + +function buildBackgroundAsset( + overrides: Partial = {}, +): Match3DGeneratedBackgroundAsset { + return { + prompt: '森林棋盘', + imageSrc: '/generated/match3d/background.png', + imageObjectKey: null, + status: 'ready', + ...overrides, + }; +} + +function buildItemAsset( + overrides: Partial = {}, +): Match3DGeneratedItemAsset { + return { + itemId: 'item-1', + itemName: '蘑菇', + imageSrc: '/generated/match3d/item.png', + imageObjectKey: null, + status: 'image_ready', + ...overrides, + }; +} + +function buildProfile( + overrides: Partial = {}, +): Match3DWorkProfile { + return { + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-1', + gameName: '森林抓鹅', + themeText: '森林', + summary: '找出蘑菇。', + tags: ['森林', '蘑菇'], + coverImageSrc: '/cover.png', + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: 1, + updatedAt: '2026-05-20T00:00:00.000Z', + publishedAt: '2026-05-20T00:00:00.000Z', + publishReady: true, + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [buildItemAsset()], + ...overrides, + }; +} + +function buildRun(overrides: Partial = {}): Match3DRunSnapshot { + return { + runId: 'match3d-run-1', + profileId: 'match3d-profile-1', + status: 'running', + snapshotVersion: 1, + startedAtMs: 1000, + durationLimitMs: 60000, + remainingMs: 55000, + clearCount: 12, + totalItemCount: 12, + clearedItemCount: 0, + items: [], + traySlots: [], + ...overrides, + }; +} + +function buildPublicWork( + overrides: Partial = {}, +): PlatformMatch3DGalleryCard { + return { + sourceType: 'match3d', + workId: 'match3d-work-1', + profileId: 'match3d-profile-1', + sourceSessionId: 'match3d-session-1', + publicWorkCode: 'M3D-00000001', + ownerUserId: 'user-1', + authorDisplayName: '玩家', + worldName: '森林抓鹅', + subtitle: '抓大鹅', + summaryText: '找出蘑菇。', + coverImageSrc: '/cover.png', + backgroundPrompt: null, + backgroundImageSrc: null, + backgroundImageObjectKey: null, + generatedBackgroundAsset: null, + generatedItemAssets: [buildItemAsset()], + themeTags: ['森林', '蘑菇'], + visibility: 'published', + publishedAt: '2026-05-20T00:00:00.000Z', + updatedAt: '2026-05-20T00:00:00.000Z', + ...overrides, + }; +} + +test('Match3D runtime profile maps public detail and promotes item background asset', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/background-from-item.png', + imageObjectKey: 'oss/background-from-item.png', + }); + const work = mapPublicWorkDetailToMatch3DWork( + buildPublicWork({ + generatedBackgroundAsset: null, + backgroundImageSrc: null, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }), + ); + + expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset); + expect(work?.backgroundImageSrc).toBe( + '/generated/match3d/background-from-item.png', + ); + expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png'); +}); + +test('Match3D runtime profile builds draft profile from session snapshot', () => { + const backgroundAsset = buildBackgroundAsset({ + imageSrc: '/generated/match3d/draft-background.png', + }); + const session: Match3DAgentSessionSnapshot = { + sessionId: 'match3d-session-draft', + currentTurn: 2, + progressPercent: 100, + stage: 'draft_compiled', + anchorPack: { + theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' }, + clearCount: { + key: 'clearCount', + label: '消除数', + value: '12', + status: 'confirmed', + }, + difficulty: { + key: 'difficulty', + label: '难度', + value: '4', + status: 'confirmed', + }, + }, + messages: [], + lastAssistantReply: null, + updatedAt: '2026-05-21T00:00:00.000Z', + draft: { + profileId: 'match3d-draft-profile', + gameName: '草稿抓鹅', + themeText: '森林', + summaryText: '草稿摘要', + tags: ['森林'], + coverImageSrc: null, + referenceImageSrc: '/reference.png', + clearCount: 12, + difficulty: 4, + publishReady: true, + generatedItemAssets: [ + buildItemAsset({ + backgroundAsset, + }), + ], + }, + }; + + const profile = buildMatch3DProfileFromSession(session); + + expect(profile?.profileId).toBe('match3d-draft-profile'); + expect(profile?.sourceSessionId).toBe('match3d-session-draft'); + expect(profile?.publicationStatus).toBe('draft'); + expect(profile?.coverImageSrc).toBe('/reference.png'); + expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset); + expect(profile?.backgroundImageSrc).toBe( + '/generated/match3d/draft-background.png', + ); +}); + +test('Match3D runtime profile selects active profile by run profile id', () => { + const runtimeProfile = buildProfile({ + profileId: 'runtime-profile', + gameName: '运行态抓鹅', + }); + const draftProfile = buildProfile({ + profileId: 'draft-profile', + gameName: '旧草稿抓鹅', + }); + + expect( + resolveActiveMatch3DRuntimeProfile( + buildRun({ profileId: 'runtime-profile' }), + runtimeProfile, + draftProfile, + ), + ).toBe(runtimeProfile); + expect( + resolveActiveMatch3DRuntimeProfile( + buildRun({ profileId: 'draft-profile' }), + runtimeProfile, + draftProfile, + ), + ).toBe(draftProfile); +}); + +test('Match3D runtime profile resolves generated assets from matching public detail', () => { + const staleProfile = buildProfile({ + profileId: 'stale-profile', + generatedBackgroundAsset: buildBackgroundAsset({ + imageSrc: '/generated/match3d/stale-background.png', + }), + generatedItemAssets: [ + buildItemAsset({ + itemId: 'stale-item', + imageSrc: '/generated/match3d/stale-item.png', + }), + ], + }); + const publicBackground = buildBackgroundAsset({ + imageSrc: '/generated/match3d/public-background.png', + }); + const publicWork = buildPublicWork({ + profileId: 'public-profile', + generatedBackgroundAsset: publicBackground, + generatedItemAssets: [ + buildItemAsset({ + itemId: 'public-item', + imageSrc: '/generated/match3d/public-item.png', + }), + ], + }); + const run = buildRun({ profileId: 'public-profile' }); + + expect( + resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some( + (asset) => asset.imageSrc === '/generated/match3d/public-item.png', + ), + ).toBe(true); + expect( + resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork), + ).toEqual(publicBackground); + expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe( + '/generated/match3d/public-background.png', + ); +}); diff --git a/src/components/platform-entry/platformMatch3DRuntimeProfile.ts b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts new file mode 100644 index 00000000..4f0325ce --- /dev/null +++ b/src/components/platform-entry/platformMatch3DRuntimeProfile.ts @@ -0,0 +1,326 @@ +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; +import type { + Match3DGeneratedBackgroundAsset, + Match3DGeneratedItemAsset, + Match3DWorkProfile, + Match3DWorkSummary, +} from '../../../packages/shared/src/contracts/match3dWorks'; +import { + hasMatch3DGeneratedImageAsset, + mergeMatch3DGeneratedItemAssetsForRuntime, + normalizeMatch3DGeneratedItemAssetsForRuntime, +} from '../../services/match3dGeneratedModelCache'; +import { + isMatch3DGalleryEntry, + type PlatformPublicGalleryCard, +} from '../rpg-entry/rpgEntryWorldPresentation'; + +export function mapPublicWorkDetailToMatch3DWork( + entry: PlatformPublicGalleryCard, +): Match3DWorkSummary | null { + if (!isMatch3DGalleryEntry(entry)) { + return null; + } + + return promoteMatch3DGeneratedBackgroundAsset({ + 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] ?? '经典消除', + summary: entry.summaryText, + tags: entry.themeTags, + coverImageSrc: entry.coverImageSrc, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'published', + playCount: entry.playCount ?? 0, + updatedAt: entry.updatedAt, + publishedAt: entry.publishedAt, + publishReady: true, + backgroundPrompt: entry.backgroundPrompt ?? null, + backgroundImageSrc: entry.backgroundImageSrc ?? null, + backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null, + generatedBackgroundAsset: + entry.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ?? + null, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + entry.generatedItemAssets ?? [], + ), + }); +} + +export function findMatch3DGeneratedBackgroundAsset( + generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined, +): Match3DGeneratedBackgroundAsset | null { + return ( + generatedItemAssets + ?.map((asset) => asset.backgroundAsset ?? null) + .find(Boolean) ?? null + ); +} + +export function promoteMatch3DGeneratedBackgroundAsset< + T extends Pick< + Match3DWorkSummary, + | 'backgroundPrompt' + | 'backgroundImageSrc' + | 'backgroundImageObjectKey' + | 'generatedBackgroundAsset' + | 'generatedItemAssets' + >, +>(profile: T): T { + const backgroundAsset = + profile.generatedBackgroundAsset ?? + findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets); + if (!backgroundAsset) { + return profile; + } + + return { + ...profile, + backgroundPrompt: + profile.backgroundPrompt ?? backgroundAsset.prompt ?? null, + backgroundImageSrc: + profile.backgroundImageSrc ?? + backgroundAsset.imageSrc ?? + backgroundAsset.imageObjectKey ?? + null, + backgroundImageObjectKey: + profile.backgroundImageObjectKey ?? + backgroundAsset.imageObjectKey ?? + backgroundAsset.imageSrc ?? + null, + generatedBackgroundAsset: + profile.generatedBackgroundAsset ?? backgroundAsset, + }; +} + +export function normalizeMatch3DWorkForRuntimeUi( + profile: T, +): T { + return promoteMatch3DGeneratedBackgroundAsset({ + ...profile, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + profile.generatedItemAssets, + ), + }); +} + +export function mapMatch3DWorksForRuntimeUi( + profiles: readonly T[], +): T[] { + return profiles.map(normalizeMatch3DWorkForRuntimeUi); +} + +export function buildMatch3DProfileFromSession( + session: Match3DAgentSessionSnapshot | null, +): Match3DWorkProfile | null { + const draft = session?.draft; + if (!session || !draft?.profileId) { + return null; + } + + const now = session.updatedAt || new Date().toISOString(); + const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime( + draft.generatedItemAssets, + ); + return promoteMatch3DGeneratedBackgroundAsset({ + workId: draft.profileId, + profileId: draft.profileId, + ownerUserId: 'current-user', + sourceSessionId: session.sessionId, + gameName: draft.gameName, + themeText: draft.themeText, + summary: draft.summary ?? draft.summaryText ?? '', + tags: draft.tags, + coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null, + referenceImageSrc: draft.referenceImageSrc ?? null, + clearCount: draft.clearCount, + difficulty: draft.difficulty, + publicationStatus: 'draft', + playCount: 0, + updatedAt: now, + publishedAt: null, + publishReady: Boolean(draft.publishReady), + backgroundPrompt: draft.backgroundPrompt ?? null, + backgroundImageSrc: draft.backgroundImageSrc ?? null, + backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null, + generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null, + generatedItemAssets, + }); +} + +export function hasMatch3DRuntimeAsset( + assets: readonly Match3DGeneratedItemAsset[] | null | undefined, +) { + return hasMatch3DGeneratedImageAsset(assets); +} + +export function hasMatch3DRuntimeBackgroundAsset( + profile: Pick< + Match3DWorkSummary, + | 'backgroundImageSrc' + | 'backgroundImageObjectKey' + | 'generatedBackgroundAsset' + | 'generatedItemAssets' + >, +) { + return Boolean( + profile.backgroundImageSrc?.trim() || + profile.backgroundImageObjectKey?.trim() || + profile.generatedBackgroundAsset?.imageSrc?.trim() || + profile.generatedBackgroundAsset?.imageObjectKey?.trim() || + profile.generatedBackgroundAsset?.containerImageSrc?.trim() || + profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() || + profile.generatedItemAssets?.some( + (asset) => + asset.backgroundAsset?.imageSrc?.trim() || + asset.backgroundAsset?.imageObjectKey?.trim() || + asset.backgroundAsset?.containerImageSrc?.trim() || + asset.backgroundAsset?.containerImageObjectKey?.trim(), + ), + ); +} + +export function resolveMatch3DRuntimeGeneratedItemAssets( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const profileAssets = profile?.generatedItemAssets ?? []; + const publicDetailAssets = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? (publicWorkDetail.generatedItemAssets ?? []) + : []; + + if (runProfileId && profile?.profileId === runProfileId) { + if (hasMatch3DRuntimeAsset(profileAssets)) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + if ( + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return hasMatch3DRuntimeAsset(publicDetailAssets) + ? mergeMatch3DGeneratedItemAssetsForRuntime( + publicDetailAssets, + profileAssets, + ) + : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets); + } + + if (hasMatch3DRuntimeAsset(profileAssets)) { + return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); + } + return publicDetailAssets.length > 0 + ? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets) + : normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets); +} + +export function resolveMatch3DRuntimeGeneratedBackgroundAsset( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const profileBackground = profile + ? (promoteMatch3DGeneratedBackgroundAsset(profile) + .generatedBackgroundAsset ?? null) + : null; + const publicBackground = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) + .generatedBackgroundAsset ?? null) + : null; + + if (runProfileId && profile?.profileId === runProfileId) { + return profileBackground ?? publicBackground; + } + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return publicBackground ?? profileBackground; + } + return profileBackground ?? publicBackground; +} + +export function resolveActiveMatch3DRuntimeProfile( + run: Match3DRunSnapshot | null, + runtimeProfile: Match3DWorkProfile | null, + profile: Match3DWorkProfile | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + if (runProfileId && runtimeProfile?.profileId === runProfileId) { + return runtimeProfile; + } + if (runProfileId && profile?.profileId === runProfileId) { + return profile; + } + return runtimeProfile ?? profile; +} + +export function resolveMatch3DRuntimeBackgroundImageSrc( + run: Match3DRunSnapshot | null, + profile: Match3DWorkProfile | null, + publicWorkDetail: PlatformPublicGalleryCard | null, +) { + const runProfileId = run?.profileId?.trim() ?? ''; + const resolvedProfile = profile + ? promoteMatch3DGeneratedBackgroundAsset(profile) + : null; + const resolvedPublicWork = + publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail) + ? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail) + : null; + const profileBackground = + resolvedProfile?.backgroundImageSrc?.trim() || + resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() || + resolvedProfile?.backgroundImageObjectKey?.trim() || + resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() || + ''; + const publicBackground = + resolvedPublicWork?.backgroundImageSrc?.trim() || + resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() || + resolvedPublicWork?.backgroundImageObjectKey?.trim() || + resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() || + ''; + + if (runProfileId && profile?.profileId === runProfileId) { + return profileBackground || publicBackground || null; + } + if ( + runProfileId && + publicWorkDetail && + isMatch3DGalleryEntry(publicWorkDetail) && + publicWorkDetail.profileId === runProfileId + ) { + return publicBackground || profileBackground || null; + } + return profileBackground || publicBackground || null; +}