diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 4ac34916..a0230b47 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1273,6 +1273,14 @@ - 验证方式:`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 Draft Generation Shelf Model 收口 + +- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。 +- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf Module,Interface 收口 `collectDraftNoticeKeys`、`getGenerationNoticeShelfKeys`、`createPendingDraftShelfState`、各玩法 `buildPending*Works`、`buildCreationWorkShelfRuntimeState`、`collectVisibleDraftNoticeKeys`、`hasUnreadDraftGenerationUpdates`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。 +- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。 +- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-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 b4d20565..b6eefb52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 创作中心作品架打开动作由 `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)。 +平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 + 小游戏 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)。 diff --git a/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md new file mode 100644 index 00000000..c19c6a3c --- /dev/null +++ b/docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md @@ -0,0 +1,36 @@ +# 【前端架构】Draft Generation Shelf Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。 + +该 **Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。 + +## 决策 + +新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf **Module**。其 **Interface** 收口为: + +- `collectDraftNoticeKeys(kind, ids)` / `getGenerationNoticeShelfKeys(item)`:统一把玩法草稿身份映射为 notice key。 +- `createPendingDraftShelfState(...)` 与 `buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。 +- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。 +- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。 +- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)`、`isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、启动生成、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总和作品架 runtime state 规则。 + +## 约定 + +- 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。 +- pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`,pending 占位必须让位。 +- 失败 notice 优先级高于持久化 generating,且可通过 pending metadata 提供更具体 summary;否则回退玩法默认失败摘要。 +- 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。 +- 本 **Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**。 + +## 验证 + +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"` +- 针对新 **Module** 与测试执行 ESLint;`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings,不在本切片扩大处理。 +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 48e7019c..4e45d4b9 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -405,6 +405,37 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + buildCreationWorkShelfRuntimeState, + buildDraftCompletionDialogSource, + buildDraftFailedShelfSummary, + buildPendingBarkBattleWorks, + buildPendingBigFishWorks, + buildPendingJumpHopWorks, + buildPendingMatch3DWorks, + buildPendingPuzzleWorks, + buildPendingSquareHoleWorks, + buildPendingVisualNovelWorks, + buildPendingWoodenFishWorks, + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + collectDraftNoticeKeys, + collectVisibleDraftNoticeKeys, + createPendingDraftShelfState, + type DraftGenerationNoticeMap, + type DraftGenerationNoticeStatus, + getDraftGenerationNotice, + getGenerationNoticeShelfKeys, + hasDraftGenerationNoticeStatus, + hasUnreadDraftGenerationUpdates, + hasUnreadReadyDraftGenerationNotice, + isPersistedDraftFailed, + isPersistedDraftGenerating, + normalizeDraftNoticeId, + type PendingDraftShelfKind, + type PendingDraftShelfMap, + type PendingDraftShelfMetadata, +} from './platformDraftGenerationShelfModel'; import { canExposePublicWork, EDUTAINMENT_HIDDEN_MESSAGE, @@ -474,28 +505,7 @@ type AgentResultPublishGateView = { blockers: string[]; publishReady: boolean; }; -type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; -type DraftGenerationNotice = { - status: DraftGenerationNoticeStatus; - seen: boolean; - completedAtMs?: number; - message?: string; -}; -type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; -type PendingDraftShelfState = { - status: DraftGenerationNoticeStatus; - seen: boolean; - updatedAt: string; - title?: string; - summary?: string; -}; -type PendingDraftShelfMap = Partial< - Record< - Exclude, - Record - > ->; type CreationFlowReturnTarget = 'create' | 'draft-shelf'; type Match3DBackgroundCompileTask = { session: Match3DAgentSessionSnapshot; @@ -1352,28 +1362,6 @@ function buildAgentResultPublishGateView( }; } -function buildPuzzleResultProfileId(sessionId: string | null | undefined) { - const normalizedSessionId = sessionId?.trim(); - if (!normalizedSessionId) { - return null; - } - const stableSuffix = normalizedSessionId.startsWith('puzzle-session-') - ? normalizedSessionId.slice('puzzle-session-'.length) - : normalizedSessionId; - return `puzzle-profile-${stableSuffix}`; -} - -function buildPuzzleResultWorkId(sessionId: string | null | undefined) { - const normalizedSessionId = sessionId?.trim(); - if (!normalizedSessionId) { - return null; - } - const stableSuffix = normalizedSessionId.startsWith('puzzle-session-') - ? normalizedSessionId.slice('puzzle-session-'.length) - : normalizedSessionId; - return `puzzle-work-${stableSuffix}`; -} - function buildPuzzleSessionIdFromProfileId( profileId: string | null | undefined, ) { @@ -1690,62 +1678,6 @@ function buildBabyObjectMatchCreationUrlState( }; } -function buildDraftNoticeKey(kind: CreationWorkShelfKind, id: string) { - return `${kind}:${id}`; -} - -function collectDraftNoticeKeys( - kind: CreationWorkShelfKind, - ids: Array, -) { - const keys = new Set(); - for (const id of ids) { - const normalizedId = id?.trim(); - if (normalizedId) { - keys.add(buildDraftNoticeKey(kind, normalizedId)); - } - } - return Array.from(keys); -} - -function normalizeDraftNoticeId(id: string | null | undefined) { - return id?.trim() || null; -} - -function normalizePendingDraftShelfLookupId( - kind: Exclude, - id: string | null | undefined, -) { - const normalizedId = normalizeDraftNoticeId(id); - if (!normalizedId) { - return null; - } - - const noticePrefix = `${kind}:`; - if (!normalizedId.startsWith(noticePrefix)) { - return normalizedId; - } - - return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); -} - -function createPendingDraftShelfState( - status: DraftGenerationNoticeStatus, - seen = false, - updatedAt = new Date().toISOString(), - metadata?: { title?: string | null; summary?: string | null }, -): PendingDraftShelfState { - const title = metadata?.title?.trim(); - const summary = metadata?.summary?.trim(); - return { - status, - seen, - updatedAt, - ...(title ? { title } : {}), - ...(summary ? { summary } : {}), - }; -} - function normalizePlatformErrorMessage(message: string | null | undefined) { const normalized = message?.trim(); return normalized ? normalized : null; @@ -1756,51 +1688,10 @@ function formatPlatformErrorSource(label: string, id?: string | null) { return normalizedId ? `${label} ${normalizedId}` : label; } -function formatPlatformTaskCompletionSource(label: string, id?: string | null) { - const normalizedId = id?.trim(); - return normalizedId ? `${label} ${normalizedId}` : label; -} - function isBackgroundGenerationStillRunningMessage(message: string) { return /仍在后台处理|后台仍在处理|仍在生成|后台生成/u.test(message); } -function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { - switch (kind) { - case 'puzzle': - return '拼图草稿生成失败,可重新打开处理。'; - case 'match3d': - return '玩法素材生成失败,可重新打开处理。'; - case 'big-fish': - return '草稿生成失败,可重新打开处理。'; - case 'square-hole': - return '挑战素材生成失败,可重新打开处理。'; - case 'jump-hop': - return '跳一跳玩法草稿生成失败,可重新打开处理。'; - case 'wooden-fish': - return '敲木鱼草稿生成失败,可重新打开处理。'; - case 'visual-novel': - return '视觉小说草稿生成失败,可重新打开处理。'; - case 'bark-battle': - return '声浪竞技素材生成失败,可重新打开处理。'; - case 'baby-object-match': - return '宝贝识物草稿生成失败,可重新打开处理。'; - default: - return '草稿生成失败,可重新打开处理。'; - } -} - -function isDraftShelfSummaryPlaceholder(value: string | null | undefined) { - const normalized = value?.trim(); - if (!normalized) { - return true; - } - - return /^(正在生成|.*生成失败,可重新打开处理。$|未填写作品描述$)/u.test( - normalized, - ); -} - function buildPlatformErrorDialogDismissKey( error: (PlatformErrorDialogPayload & { key: string }) | null, ) { @@ -1820,53 +1711,6 @@ function buildPlatformTaskCompletionDialogDismissKey( : null; } -function pickDraftCompletionDialogSourceId( - ids: Array, -) { - const normalizedIds = ids - .map((id) => id?.trim() ?? '') - .filter((id) => Boolean(id)); - return ( - normalizedIds.find((id) => /session/i.test(id)) ?? - normalizedIds.find((id) => /work/i.test(id)) ?? - normalizedIds.find((id) => /draft/i.test(id)) ?? - normalizedIds.find((id) => /run/i.test(id)) ?? - normalizedIds.find((id) => /profile/i.test(id)) ?? - normalizedIds[0] ?? - null - ); -} - -function buildDraftCompletionDialogSource( - kind: CreationWorkShelfKind, - ids: Array, -): string { - const sourceId = pickDraftCompletionDialogSourceId(ids); - switch (kind) { - case 'rpg': - return formatPlatformTaskCompletionSource('RPG 草稿', sourceId); - case 'big-fish': - return formatPlatformTaskCompletionSource('大鱼吃小鱼草稿', sourceId); - case 'match3d': - return formatPlatformTaskCompletionSource('抓大鹅草稿', sourceId); - case 'square-hole': - return formatPlatformTaskCompletionSource('方洞挑战草稿', sourceId); - case 'jump-hop': - return formatPlatformTaskCompletionSource('跳一跳草稿', sourceId); - case 'wooden-fish': - return formatPlatformTaskCompletionSource('敲木鱼草稿', sourceId); - case 'puzzle': - return formatPlatformTaskCompletionSource('拼图草稿', sourceId); - case 'visual-novel': - return formatPlatformTaskCompletionSource('视觉小说草稿', sourceId); - case 'bark-battle': - return formatPlatformTaskCompletionSource('汪汪声浪草稿', sourceId); - case 'baby-object-match': - return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId); - } - return formatPlatformTaskCompletionSource('创作草稿', sourceId); -} - /** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, @@ -2166,80 +2010,6 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] { - switch (item.source.kind) { - case 'rpg': - return collectDraftNoticeKeys('rpg', [ - item.id, - item.source.item.workId, - item.source.item.sessionId, - item.source.item.profileId, - ]); - case 'big-fish': - return collectDraftNoticeKeys('big-fish', [ - item.id, - item.source.item.workId, - item.source.item.sourceSessionId, - ]); - case 'match3d': - return collectDraftNoticeKeys('match3d', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'square-hole': - return collectDraftNoticeKeys('square-hole', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'jump-hop': - return collectDraftNoticeKeys('jump-hop', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - case 'puzzle': - return collectDraftNoticeKeys('puzzle', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - buildPuzzleResultWorkId(item.source.item.sourceSessionId), - buildPuzzleResultProfileId(item.source.item.sourceSessionId), - ]); - case 'visual-novel': - return collectDraftNoticeKeys('visual-novel', [ - item.id, - item.source.item.profileId, - ]); - case 'baby-object-match': - return collectDraftNoticeKeys('baby-object-match', [ - item.id, - item.source.item.profileId, - item.source.item.draftId, - ]); - case 'bark-battle': - return collectDraftNoticeKeys('bark-battle', [ - item.id, - item.source.item.workId, - item.source.item.draftId, - ]); - case 'wooden-fish': - return collectDraftNoticeKeys('wooden-fish', [ - item.id, - item.source.item.workId, - item.source.item.profileId, - item.source.item.sourceSessionId, - ]); - default: - return []; - } -} - function isMiniGameDraftReady(state: MiniGameDraftGenerationState | null) { return state?.phase === 'ready'; } @@ -2248,15 +2018,6 @@ function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) { return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); } -function isPersistedDraftGenerating(value: string | null | undefined) { - return value?.trim() === 'generating'; -} - -function isPersistedDraftFailed(value: string | null | undefined) { - const normalized = value?.trim(); - return normalized === 'failed' || normalized === 'partial_failed'; -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { @@ -2311,347 +2072,6 @@ function reconcileProfileWalletLocalDeltaWithServerDashboard( return Math.max(0, normalizedDelta - reflectedCredit); } -function buildPendingBigFishWorks( - pending: Record | undefined, - existingItems: readonly BigFishWorkSummary[], -): BigFishWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const isFailed = state.status === 'failed'; - return { - workId: `big-fish-work-${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: '', - authorDisplayName: '', - title: '大鱼吃小鱼草稿', - subtitle: isFailed ? '生成失败待重试' : '草稿生成中', - summary: isFailed - ? '草稿生成失败,可重新打开处理。' - : '正在生成玩法草稿。', - coverImageSrc: null, - status: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - playCount: 0, - remixCount: 0, - likeCount: 0, - }; - }); -} - -function buildPendingJumpHopWorks( - pending: Record | undefined, - existingItems: readonly JumpHopWorkSummaryResponse[], -): JumpHopWorkSummaryResponse[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const generationStatus = - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready'; - return { - runtimeKind: 'jump-hop', - workId: `jump-hop-work-${sessionId}`, - profileId: `jump-hop-profile-${sessionId}`, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '跳一跳草稿', - workDescription: - state.status === 'failed' - ? '跳一跳玩法草稿生成失败,可重新打开处理。' - : '正在生成跳一跳玩法草稿。', - themeTags: [], - difficulty: 'standard', - stylePreset: 'minimal-blocks', - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus, - }; - }); -} - -function buildPendingWoodenFishWorks( - pending: Record | undefined, - existingItems: readonly WoodenFishWorkSummaryResponse[], -): WoodenFishWorkSummaryResponse[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const generationStatus = - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready'; - return { - runtimeKind: 'wooden-fish', - workId: `wooden-fish-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '敲木鱼草稿', - workDescription: - state.status === 'failed' - ? '敲木鱼草稿生成失败,可重新打开处理。' - : '正在生成敲木鱼草稿。', - themeTags: ['敲木鱼'], - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus, - }; - }); -} - -function buildPendingMatch3DWorks( - pending: Record | undefined, - existingItems: readonly Match3DWorkSummary[], -): Match3DWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const themeText = state.summary?.trim() || state.title?.trim() || ''; - const fallbackSummary = - state.status === 'failed' - ? '玩法素材生成失败,可重新打开处理。' - : '正在生成玩法素材。'; - return { - workId: `match3d-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '抓大鹅草稿', - themeText, - summary: themeText || fallbackSummary, - tags: [], - coverImageSrc: null, - referenceImageSrc: null, - clearCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: - state.status === 'failed' - ? 'failed' - : state.status === 'generating' - ? 'generating' - : 'ready', - generatedItemAssets: [], - }; - }); -} - -function buildPendingSquareHoleWorks( - pending: Record | undefined, - existingItems: readonly SquareHoleWorkSummary[], -): SquareHoleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => ({ - workId: `square-hole-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '方洞挑战草稿', - themeText: '', - twistRule: '', - summary: - state.status === 'failed' - ? '挑战素材生成失败,可重新打开处理。' - : '正在生成挑战素材。', - tags: [], - coverImageSrc: null, - backgroundPrompt: '', - backgroundImageSrc: null, - shapeOptions: [], - holeOptions: [], - shapeCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - })); -} - -function buildPendingPuzzleWorks( - pending: Record | undefined, - existingItems: readonly PuzzleWorkSummary[], -): PuzzleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([sessionId]) => - existingItems.every((item) => item.sourceSessionId !== sessionId), - ) - .map(([sessionId, state]) => { - const profileId = - buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; - const title = state.title?.trim() || '拼图草稿'; - const summary = - state.summary?.trim() || - (state.status === 'failed' - ? '拼图草稿生成失败,可重新打开处理。' - : '正在生成拼图草稿。'); - return { - workId: - buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, - profileId, - ownerUserId: '', - sourceSessionId: sessionId, - authorDisplayName: '', - workTitle: title, - workDescription: summary, - levelName: title, - summary, - themeTags: [], - coverImageSrc: null, - coverAssetId: null, - publicationStatus: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - playCount: 0, - remixCount: 0, - likeCount: 0, - publishReady: false, - generationStatus: - state.status === 'generating' - ? 'generating' - : state.status === 'failed' - ? 'failed' - : 'ready', - levels: [], - }; - }); -} - -function buildPendingVisualNovelWorks( - pending: Record | undefined, - existingItems: readonly VisualNovelWorkSummary[], -): VisualNovelWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([profileId]) => - existingItems.every((item) => item.profileId !== profileId), - ) - .map(([profileId, state]) => ({ - runtimeKind: 'visual-novel', - profileId, - ownerUserId: '', - title: '视觉小说草稿', - description: - state.status === 'failed' - ? '视觉小说草稿生成失败,可重新打开处理。' - : '正在生成视觉小说草稿。', - coverImageSrc: null, - tags: [], - publishStatus: 'draft', - publishReady: false, - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - })); -} - -function buildPendingBarkBattleWorks( - pending: Record | undefined, - existingItems: readonly BarkBattleWorkSummary[], -): BarkBattleWorkSummary[] { - if (!pending) { - return []; - } - - return Object.entries(pending) - .filter(([id]) => - existingItems.every((item) => item.workId !== id && item.draftId !== id), - ) - .map(([id, state]) => ({ - workId: id, - draftId: id, - ownerUserId: '', - authorDisplayName: '', - title: '汪汪声浪草稿', - summary: - state.status === 'failed' - ? '声浪竞技素材生成失败,可重新打开处理。' - : '正在生成声浪竞技素材。', - themeDescription: '', - playerImageDescription: '', - opponentImageDescription: '', - onomatopoeia: [], - playerCharacterImageSrc: null, - opponentCharacterImageSrc: null, - uiBackgroundImageSrc: null, - difficultyPreset: 'normal', - status: 'draft', - generationStatus: - state.status === 'generating' - ? 'pending_assets' - : state.status === 'failed' - ? 'partial_failed' - : 'ready', - publishReady: false, - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - })); -} - function buildPuzzleCompileActionFromFormPayload( payload: CreatePuzzleAgentSessionRequest | null, ): PuzzleAgentActionRequest { @@ -3525,11 +2945,11 @@ export function PlatformEntryFlowShellImpl({ const updatePendingDraftShelfItem = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, status: DraftGenerationNoticeStatus, seen = false, - metadata?: { title?: string | null; summary?: string | null }, + metadata?: PendingDraftShelfMetadata, ) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { @@ -3555,10 +2975,7 @@ export function PlatformEntryFlowShellImpl({ [], ); const clearPendingDraftShelfItem = useCallback( - ( - kind: Exclude, - id: string | null | undefined, - ) => { + (kind: PendingDraftShelfKind, id: string | null | undefined) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { return; @@ -3638,36 +3055,6 @@ export function PlatformEntryFlowShellImpl({ }); }, []); - const getDraftGenerationNotice = useCallback( - (keys: string[]) => { - for (const key of keys) { - const notice = draftGenerationNotices[key]; - if (notice) { - return notice; - } - } - return null; - }, - [draftGenerationNotices], - ); - const getPendingDraftShelfState = useCallback( - (kind: Exclude, keys: string[]) => { - const entries = pendingDraftShelfItems[kind]; - if (!entries) { - return null; - } - - for (const key of keys) { - const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); - const pending = normalizedKey ? entries[normalizedKey] : null; - if (pending) { - return pending; - } - } - return null; - }, - [pendingDraftShelfItems], - ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { setPendingPlatformTaskCompletionDialog(null); @@ -3739,9 +3126,9 @@ export function PlatformEntryFlowShellImpl({ ); const markPendingDraftGenerating = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, - metadata?: { title?: string | null; summary?: string | null }, + metadata?: PendingDraftShelfMetadata, ) => { setPendingPlatformTaskCompletionDialog(null); setPendingPlatformTaskFailureDialog(null); @@ -3755,7 +3142,7 @@ export function PlatformEntryFlowShellImpl({ ); const markPendingDraftReady = useCallback( ( - kind: Exclude, + kind: PendingDraftShelfKind, id: string | null | undefined, viewedImmediately: boolean, ) => { @@ -3764,10 +3151,7 @@ export function PlatformEntryFlowShellImpl({ [updatePendingDraftShelfItem], ); const markPendingDraftFailed = useCallback( - ( - kind: Exclude, - id: string | null | undefined, - ) => { + (kind: PendingDraftShelfKind, id: string | null | undefined) => { updatePendingDraftShelfItem(kind, id, 'failed', false); }, [updatePendingDraftShelfItem], @@ -3944,28 +3328,23 @@ export function PlatformEntryFlowShellImpl({ ); }, []); const isDraftNoticeGenerating = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some( - (key) => draftGenerationNotices[key]?.status === 'generating', - ); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasDraftGenerationNoticeStatus( + draftGenerationNotices, + kind, + ids, + 'generating', + ), [draftGenerationNotices], ); const isDraftNoticeFailed = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some( - (key) => draftGenerationNotices[key]?.status === 'failed', - ); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasDraftGenerationNoticeStatus(draftGenerationNotices, kind, ids, 'failed'), [draftGenerationNotices], ); const isDraftNoticeReadyUnread = useCallback( - (kind: CreationWorkShelfKind, ids: Array) => { - return collectDraftNoticeKeys(kind, ids).some((key) => { - const notice = draftGenerationNotices[key]; - return notice?.status === 'ready' && !notice.seen; - }); - }, + (kind: CreationWorkShelfKind, ids: Array) => + hasUnreadReadyDraftGenerationNotice(draftGenerationNotices, kind, ids), [draftGenerationNotices], ); const ensureEnoughDraftGenerationPointsFromServer = useCallback( @@ -4806,128 +4185,30 @@ export function PlatformEntryFlowShellImpl({ [barkBattleWorks, pendingDraftShelfItems], ); const getCreationWorkShelfState = useCallback( - (item: CreationWorkShelfItem) => { - const noticeKeys = getGenerationNoticeShelfKeys(item); - const notice = getDraftGenerationNotice(noticeKeys); - if (notice?.status === 'failed') { - const failedSummary = buildDraftFailedShelfSummary(item.source.kind); - const pending = - item.source.kind === 'rpg' - ? null - : getPendingDraftShelfState(item.source.kind, noticeKeys); - const pendingSummary = pending?.summary?.trim(); - return { - isGenerating: false, - hasGenerationFailure: true, - generationFailureSummary: failedSummary, - hasUnreadUpdate: false, - suppressPersistedGenerating: true, - titleOverride: - item.source.kind === 'puzzle' && - item.status === 'draft' && - !item.source.item.workTitle?.trim() - ? '拼图草稿' - : undefined, - summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) - ? (pendingSummary ?? failedSummary) - : undefined, - }; - } - if ( - item.source.kind === 'puzzle' && - isPersistedDraftFailed(item.source.item.generationStatus) - ) { - const failedSummary = buildDraftFailedShelfSummary('puzzle'); - return { - isGenerating: false, - hasGenerationFailure: true, - generationFailureSummary: failedSummary, - hasUnreadUpdate: false, - suppressPersistedGenerating: true, - titleOverride: - item.status === 'draft' && !item.source.item.workTitle?.trim() - ? '拼图草稿' - : undefined, - summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) - ? failedSummary - : undefined, - }; - } - const isNoticeGenerating = - notice?.status === 'generating' && - (item.source.kind !== 'puzzle' || - !resolvePuzzleWorkCoverImageSrc(item.source.item)); - return { - isGenerating: isNoticeGenerating || item.isGenerating === true, - hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, - }; - }, - [getDraftGenerationNotice, getPendingDraftShelfState], + (item: CreationWorkShelfItem) => + buildCreationWorkShelfRuntimeState({ + item, + notices: draftGenerationNotices, + pendingShelfItems: pendingDraftShelfItems, + }), + [draftGenerationNotices, pendingDraftShelfItems], ); const visibleDraftNoticeKeys = useMemo( - () => [ - ...creationHubItems.flatMap((item) => - collectDraftNoticeKeys('rpg', [ - item.workId, - item.sessionId, - item.profileId, - ]), - ), - ...bigFishShelfItems.flatMap((item) => - collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), - ), - ...jumpHopShelfItems.flatMap((item) => - collectDraftNoticeKeys('jump-hop', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...woodenFishShelfItems.flatMap((item) => - collectDraftNoticeKeys('wooden-fish', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...match3dShelfItems.flatMap((item) => - collectDraftNoticeKeys('match3d', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ), - ...(isSquareHoleCreationVisible - ? squareHoleShelfItems.flatMap((item) => - collectDraftNoticeKeys('square-hole', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ) - : []), - ...puzzleShelfItems.flatMap((item) => - collectDraftNoticeKeys('puzzle', [ - item.workId, - item.profileId, - item.sourceSessionId, - buildPuzzleResultWorkId(item.sourceSessionId), - buildPuzzleResultProfileId(item.sourceSessionId), - ]), - ), - ...visualNovelShelfItems.flatMap((item) => - collectDraftNoticeKeys('visual-novel', [item.profileId]), - ), - ...barkBattleShelfItems.flatMap((item) => - collectDraftNoticeKeys('bark-battle', [item.workId, item.draftId]), - ), - ...babyObjectMatchDrafts.flatMap((item) => - collectDraftNoticeKeys('baby-object-match', [ - item.profileId, - item.draftId, - ]), - ), - ], + () => + collectVisibleDraftNoticeKeys({ + rpgItems: creationHubItems, + bigFishItems: bigFishShelfItems, + jumpHopItems: jumpHopShelfItems, + woodenFishItems: woodenFishShelfItems, + match3dItems: match3dShelfItems, + squareHoleItems: isSquareHoleCreationVisible + ? squareHoleShelfItems + : [], + puzzleItems: puzzleShelfItems, + visualNovelItems: visualNovelShelfItems, + barkBattleItems: barkBattleShelfItems, + babyObjectMatchItems: babyObjectMatchDrafts, + }), [ babyObjectMatchDrafts, barkBattleShelfItems, @@ -4944,10 +4225,10 @@ export function PlatformEntryFlowShellImpl({ ); const hasUnreadDraftUpdates = useMemo( () => - visibleDraftNoticeKeys.some((key) => { - const notice = draftGenerationNotices[key]; - return notice?.status === 'ready' && !notice.seen; - }), + hasUnreadDraftGenerationUpdates( + draftGenerationNotices, + visibleDraftNoticeKeys, + ), [draftGenerationNotices, visibleDraftNoticeKeys], ); const resultViewError = @@ -12656,7 +11937,10 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); - const failedNotice = getDraftGenerationNotice(noticeKeys); + const failedNotice = getDraftGenerationNotice( + draftGenerationNotices, + noticeKeys, + ); const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ item.workId, @@ -12882,8 +12166,8 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + draftGenerationNotices, getPuzzleBackgroundCompileTask, - getDraftGenerationNotice, isDraftNoticeFailed, isDraftNoticeGenerating, markDraftNoticeSeen, @@ -12930,7 +12214,10 @@ export function PlatformEntryFlowShellImpl({ return; } - const failedNotice = getDraftGenerationNotice(noticeKeys); + const failedNotice = getDraftGenerationNotice( + draftGenerationNotices, + noticeKeys, + ); const hasFailedNotice = isDraftNoticeFailed('match3d', [ item.workId, item.profileId, @@ -13149,8 +12436,8 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + draftGenerationNotices, getMatch3DBackgroundCompileTask, - getDraftGenerationNotice, isDraftNoticeFailed, isDraftNoticeGenerating, isDraftNoticeReadyUnread, diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts new file mode 100644 index 00000000..25c70f45 --- /dev/null +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'vitest'; + +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf'; +import { + buildCreationWorkShelfRuntimeState, + buildPendingPuzzleWorks, + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + collectVisibleDraftNoticeKeys, + createPendingDraftShelfState, + type DraftGenerationNoticeMap, + getGenerationNoticeShelfKeys, + hasUnreadDraftGenerationUpdates, +} from './platformDraftGenerationShelfModel'; + +describe('platformDraftGenerationShelfModel', () => { + test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => { + const pending = buildPendingPuzzleWorks( + { + 'puzzle-session-ocean': createPendingDraftShelfState( + 'failed', + false, + '2026-06-03T08:00:00.000Z', + ), + }, + [], + ); + + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + sourceSessionId: 'puzzle-session-ocean', + workTitle: '拼图草稿', + summary: '拼图草稿生成失败,可重新打开处理。', + generationStatus: 'failed', + }); + }); + + test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => { + const pending = buildPendingPuzzleWorks( + { + 'puzzle-session-ocean': createPendingDraftShelfState( + 'generating', + false, + '2026-06-03T08:00:00.000Z', + ), + }, + [buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })], + ); + + expect(pending).toEqual([]); + }); + + test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => { + const [item] = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + buildPuzzleWork({ + workId: 'puzzle-work-empty', + profileId: 'puzzle-profile-empty', + sourceSessionId: 'puzzle-session-empty', + workTitle: '', + workDescription: '', + levelName: '', + summary: '正在生成拼图草稿。', + generationStatus: 'generating', + }), + ], + }); + expect(item).toBeTruthy(); + + const noticeKeys = getGenerationNoticeShelfKeys(item!); + const notices = Object.fromEntries( + noticeKeys.map((key) => [ + key, + { status: 'failed', seen: false }, + ]), + ) as DraftGenerationNoticeMap; + + const state = buildCreationWorkShelfRuntimeState({ + item: item!, + notices, + pendingShelfItems: { + puzzle: { + 'puzzle-session-empty': createPendingDraftShelfState( + 'failed', + false, + '2026-06-03T08:00:00.000Z', + { summary: '图片生成超时,可重新打开处理。' }, + ), + }, + }, + }); + + expect(state).toMatchObject({ + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: '拼图草稿生成失败,可重新打开处理。', + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: '拼图草稿', + summaryOverride: '图片生成超时,可重新打开处理。', + }); + }); + + test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => { + const puzzle = buildPuzzleWork({ + workId: 'puzzle-work-ocean', + profileId: 'puzzle-profile-ocean', + sourceSessionId: 'puzzle-session-ocean', + }); + const visibleKeys = collectVisibleDraftNoticeKeys({ + rpgItems: [], + bigFishItems: [], + jumpHopItems: [], + woodenFishItems: [], + match3dItems: [], + squareHoleItems: [], + puzzleItems: [puzzle], + visualNovelItems: [], + barkBattleItems: [], + babyObjectMatchItems: [], + }); + + expect(visibleKeys).toContain('puzzle:puzzle-work-ocean'); + expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean'); + expect(visibleKeys).toContain('puzzle:puzzle-session-ocean'); + expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe( + 'puzzle-work-ocean', + ); + expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe( + 'puzzle-profile-ocean', + ); + + expect( + hasUnreadDraftGenerationUpdates( + { + 'puzzle:puzzle-profile-ocean': { + status: 'ready', + seen: false, + }, + }, + visibleKeys, + ), + ).toBe(true); + expect( + hasUnreadDraftGenerationUpdates( + { + 'puzzle:puzzle-profile-ocean': { + status: 'ready', + seen: true, + }, + }, + visibleKeys, + ), + ).toBe(false); + }); +}); + +function buildPuzzleWork( + overrides: Partial = {}, +): PuzzleWorkSummary { + return { + workId: 'puzzle-work-base', + profileId: 'puzzle-profile-base', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-base', + authorDisplayName: '测试作者', + workTitle: '潮雾拼图', + workDescription: '潮雾港口拼图。', + levelName: '潮雾拼图', + summary: '潮雾港口拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-06-03T08:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + levels: [], + ...overrides, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts new file mode 100644 index 00000000..314b7f61 --- /dev/null +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -0,0 +1,860 @@ +import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +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 { 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 CreationWorkShelfItem, + type CreationWorkShelfKind, + type CreationWorkShelfRuntimeState, + resolvePuzzleWorkCoverImageSrc, +} from '../custom-world-home/creationWorkShelf'; + +export type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; + +export type DraftGenerationNotice = { + status: DraftGenerationNoticeStatus; + seen: boolean; + completedAtMs?: number; + message?: string; +}; + +export type DraftGenerationNoticeMap = Record; + +export type PendingDraftShelfState = { + status: DraftGenerationNoticeStatus; + seen: boolean; + updatedAt: string; + title?: string; + summary?: string; +}; + +export type PendingDraftShelfKind = Exclude; + +export type PendingDraftShelfMap = Partial< + Record> +>; + +export type PendingDraftShelfMetadata = { + title?: string | null; + summary?: string | null; +}; + +export type PlatformDraftGenerationVisibleShelfSources = { + rpgItems: readonly CustomWorldWorkSummary[]; + bigFishItems: readonly BigFishWorkSummary[]; + jumpHopItems: readonly JumpHopWorkSummaryResponse[]; + woodenFishItems: readonly WoodenFishWorkSummaryResponse[]; + match3dItems: readonly Match3DWorkSummary[]; + squareHoleItems: readonly SquareHoleWorkSummary[]; + puzzleItems: readonly PuzzleWorkSummary[]; + visualNovelItems: readonly VisualNovelWorkSummary[]; + barkBattleItems: readonly BarkBattleWorkSummary[]; + babyObjectMatchItems: readonly BabyObjectMatchDraft[]; +}; + +export function buildPuzzleResultProfileId( + sessionId: string | null | undefined, +) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-profile-${stableSuffix}` : null; +} + +export function buildPuzzleResultWorkId( + sessionId: string | null | undefined, +) { + const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId); + return stableSuffix ? `puzzle-work-${stableSuffix}` : null; +} + +export function buildDraftNoticeKey( + kind: CreationWorkShelfKind, + id: string, +) { + return `${kind}:${id}`; +} + +export function collectDraftNoticeKeys( + kind: CreationWorkShelfKind, + ids: Array, +) { + const keys = new Set(); + for (const id of ids) { + const normalizedId = id?.trim(); + if (normalizedId) { + keys.add(buildDraftNoticeKey(kind, normalizedId)); + } + } + return Array.from(keys); +} + +export function normalizeDraftNoticeId(id: string | null | undefined) { + return id?.trim() || null; +} + +export function normalizePendingDraftShelfLookupId( + kind: PendingDraftShelfKind, + id: string | null | undefined, +) { + const normalizedId = normalizeDraftNoticeId(id); + if (!normalizedId) { + return null; + } + + const noticePrefix = `${kind}:`; + if (!normalizedId.startsWith(noticePrefix)) { + return normalizedId; + } + + return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); +} + +export function createPendingDraftShelfState( + status: DraftGenerationNoticeStatus, + seen = false, + updatedAt = new Date().toISOString(), + metadata?: PendingDraftShelfMetadata, +): PendingDraftShelfState { + const title = metadata?.title?.trim(); + const summary = metadata?.summary?.trim(); + return { + status, + seen, + updatedAt, + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), + }; +} + +export function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { + switch (kind) { + case 'puzzle': + return '拼图草稿生成失败,可重新打开处理。'; + case 'match3d': + return '玩法素材生成失败,可重新打开处理。'; + case 'big-fish': + return '草稿生成失败,可重新打开处理。'; + case 'square-hole': + return '挑战素材生成失败,可重新打开处理。'; + case 'jump-hop': + return '跳一跳玩法草稿生成失败,可重新打开处理。'; + case 'wooden-fish': + return '敲木鱼草稿生成失败,可重新打开处理。'; + case 'visual-novel': + return '视觉小说草稿生成失败,可重新打开处理。'; + case 'bark-battle': + return '声浪竞技素材生成失败,可重新打开处理。'; + case 'baby-object-match': + return '宝贝识物草稿生成失败,可重新打开处理。'; + default: + return '草稿生成失败,可重新打开处理。'; + } +} + +export function buildDraftCompletionDialogSource( + kind: CreationWorkShelfKind, + ids: Array, +): string { + const sourceId = pickDraftCompletionDialogSourceId(ids); + switch (kind) { + case 'rpg': + return formatDraftTaskCompletionSource('RPG 草稿', sourceId); + case 'big-fish': + return formatDraftTaskCompletionSource('大鱼吃小鱼草稿', sourceId); + case 'match3d': + return formatDraftTaskCompletionSource('抓大鹅草稿', sourceId); + case 'square-hole': + return formatDraftTaskCompletionSource('方洞挑战草稿', sourceId); + case 'jump-hop': + return formatDraftTaskCompletionSource('跳一跳草稿', sourceId); + case 'wooden-fish': + return formatDraftTaskCompletionSource('敲木鱼草稿', sourceId); + case 'puzzle': + return formatDraftTaskCompletionSource('拼图草稿', sourceId); + case 'visual-novel': + return formatDraftTaskCompletionSource('视觉小说草稿', sourceId); + case 'bark-battle': + return formatDraftTaskCompletionSource('汪汪声浪草稿', sourceId); + case 'baby-object-match': + return formatDraftTaskCompletionSource('宝贝识物草稿', sourceId); + } + return formatDraftTaskCompletionSource('创作草稿', sourceId); +} + +export function isDraftShelfSummaryPlaceholder( + value: string | null | undefined, +) { + const normalized = value?.trim(); + if (!normalized) { + return true; + } + + return /^(正在生成|.*生成失败,可重新打开处理。$|未填写作品描述$)/u.test( + normalized, + ); +} + +export function isPersistedDraftGenerating(value: string | null | undefined) { + return value?.trim() === 'generating'; +} + +export function isPersistedDraftFailed(value: string | null | undefined) { + const normalized = value?.trim(); + return normalized === 'failed' || normalized === 'partial_failed'; +} + +export function getGenerationNoticeShelfKeys( + item: CreationWorkShelfItem, +): string[] { + switch (item.source.kind) { + case 'rpg': + return collectDraftNoticeKeys('rpg', [ + item.id, + item.source.item.workId, + item.source.item.sessionId, + item.source.item.profileId, + ]); + case 'big-fish': + return collectDraftNoticeKeys('big-fish', [ + item.id, + item.source.item.workId, + item.source.item.sourceSessionId, + ]); + case 'match3d': + return collectDraftNoticeKeys('match3d', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'square-hole': + return collectDraftNoticeKeys('square-hole', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'jump-hop': + return collectDraftNoticeKeys('jump-hop', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + case 'puzzle': + return collectDraftNoticeKeys('puzzle', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + buildPuzzleResultWorkId(item.source.item.sourceSessionId), + buildPuzzleResultProfileId(item.source.item.sourceSessionId), + ]); + case 'visual-novel': + return collectDraftNoticeKeys('visual-novel', [ + item.id, + item.source.item.profileId, + ]); + case 'baby-object-match': + return collectDraftNoticeKeys('baby-object-match', [ + item.id, + item.source.item.profileId, + item.source.item.draftId, + ]); + case 'bark-battle': + return collectDraftNoticeKeys('bark-battle', [ + item.id, + item.source.item.workId, + item.source.item.draftId, + ]); + case 'wooden-fish': + return collectDraftNoticeKeys('wooden-fish', [ + item.id, + item.source.item.workId, + item.source.item.profileId, + item.source.item.sourceSessionId, + ]); + default: + return []; + } +} + +export function getDraftGenerationNotice( + notices: DraftGenerationNoticeMap, + keys: readonly string[], +) { + for (const key of keys) { + const notice = notices[key]; + if (notice) { + return notice; + } + } + return null; +} + +export function getPendingDraftShelfState( + pendingShelfItems: PendingDraftShelfMap, + kind: PendingDraftShelfKind, + keys: readonly string[], +) { + const entries = pendingShelfItems[kind]; + if (!entries) { + return null; + } + + for (const key of keys) { + const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); + const pending = normalizedKey ? entries[normalizedKey] : null; + if (pending) { + return pending; + } + } + return null; +} + +export function hasDraftGenerationNoticeStatus( + notices: DraftGenerationNoticeMap, + kind: CreationWorkShelfKind, + ids: Array, + status: DraftGenerationNoticeStatus, +) { + return collectDraftNoticeKeys(kind, ids).some( + (key) => notices[key]?.status === status, + ); +} + +export function hasUnreadReadyDraftGenerationNotice( + notices: DraftGenerationNoticeMap, + kind: CreationWorkShelfKind, + ids: Array, +) { + return collectDraftNoticeKeys(kind, ids).some((key) => { + const notice = notices[key]; + return notice?.status === 'ready' && !notice.seen; + }); +} + +export function buildCreationWorkShelfRuntimeState(params: { + item: CreationWorkShelfItem; + notices: DraftGenerationNoticeMap; + pendingShelfItems: PendingDraftShelfMap; +}): CreationWorkShelfRuntimeState { + const { item, notices, pendingShelfItems } = params; + const noticeKeys = getGenerationNoticeShelfKeys(item); + const notice = getDraftGenerationNotice(notices, noticeKeys); + + if (notice?.status === 'failed') { + const failedSummary = buildDraftFailedShelfSummary(item.source.kind); + const pending = + item.source.kind === 'rpg' + ? null + : getPendingDraftShelfState( + pendingShelfItems, + item.source.kind, + noticeKeys, + ); + const pendingSummary = pending?.summary?.trim(); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.source.kind === 'puzzle' && + item.status === 'draft' && + !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? (pendingSummary ?? failedSummary) + : undefined, + }; + } + + if ( + item.source.kind === 'puzzle' && + isPersistedDraftFailed(item.source.item.generationStatus) + ) { + const failedSummary = buildDraftFailedShelfSummary('puzzle'); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.status === 'draft' && !item.source.item.workTitle?.trim() + ? '拼图草稿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? failedSummary + : undefined, + }; + } + + const isNoticeGenerating = + notice?.status === 'generating' && + (item.source.kind !== 'puzzle' || + !resolvePuzzleWorkCoverImageSrc(item.source.item)); + return { + isGenerating: isNoticeGenerating || item.isGenerating === true, + hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, + }; +} + +export function collectVisibleDraftNoticeKeys( + sources: PlatformDraftGenerationVisibleShelfSources, +) { + return [ + ...sources.rpgItems.flatMap((item) => + collectDraftNoticeKeys('rpg', [ + item.workId, + item.sessionId, + item.profileId, + ]), + ), + ...sources.bigFishItems.flatMap((item) => + collectDraftNoticeKeys('big-fish', [item.workId, item.sourceSessionId]), + ), + ...sources.jumpHopItems.flatMap((item) => + collectDraftNoticeKeys('jump-hop', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.woodenFishItems.flatMap((item) => + collectDraftNoticeKeys('wooden-fish', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.match3dItems.flatMap((item) => + collectDraftNoticeKeys('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.squareHoleItems.flatMap((item) => + collectDraftNoticeKeys('square-hole', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]), + ), + ...sources.puzzleItems.flatMap((item) => + collectDraftNoticeKeys('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]), + ), + ...sources.visualNovelItems.flatMap((item) => + collectDraftNoticeKeys('visual-novel', [item.profileId]), + ), + ...sources.barkBattleItems.flatMap((item) => + collectDraftNoticeKeys('bark-battle', [item.workId, item.draftId]), + ), + ...sources.babyObjectMatchItems.flatMap((item) => + collectDraftNoticeKeys('baby-object-match', [ + item.profileId, + item.draftId, + ]), + ), + ]; +} + +export function hasUnreadDraftGenerationUpdates( + notices: DraftGenerationNoticeMap, + visibleKeys: readonly string[], +) { + return visibleKeys.some((key) => { + const notice = notices[key]; + return notice?.status === 'ready' && !notice.seen; + }); +} + +export function buildPendingBigFishWorks( + pending: Record | undefined, + existingItems: readonly BigFishWorkSummary[], +): BigFishWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const isFailed = state.status === 'failed'; + return { + workId: `big-fish-work-${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: '', + authorDisplayName: '', + title: '大鱼吃小鱼草稿', + subtitle: isFailed ? '生成失败待重试' : '草稿生成中', + summary: isFailed + ? '草稿生成失败,可重新打开处理。' + : '正在生成玩法草稿。', + coverImageSrc: null, + status: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + playCount: 0, + remixCount: 0, + likeCount: 0, + }; + }); +} + +export function buildPendingJumpHopWorks( + pending: Record | undefined, + existingItems: readonly JumpHopWorkSummaryResponse[], +): JumpHopWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳草稿', + workDescription: + state.status === 'failed' + ? '跳一跳玩法草稿生成失败,可重新打开处理。' + : '正在生成跳一跳玩法草稿。', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); +} + +export function buildPendingWoodenFishWorks( + pending: Record | undefined, + existingItems: readonly WoodenFishWorkSummaryResponse[], +): WoodenFishWorkSummaryResponse[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'wooden-fish', + workId: `wooden-fish-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '敲木鱼草稿', + workDescription: + state.status === 'failed' + ? '敲木鱼草稿生成失败,可重新打开处理。' + : '正在生成敲木鱼草稿。', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); +} + +export function buildPendingMatch3DWorks( + pending: Record | undefined, + existingItems: readonly Match3DWorkSummary[], +): Match3DWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const themeText = state.summary?.trim() || state.title?.trim() || ''; + const fallbackSummary = + state.status === 'failed' + ? '玩法素材生成失败,可重新打开处理。' + : '正在生成玩法素材。'; + return { + workId: `match3d-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '抓大鹅草稿', + themeText, + summary: themeText || fallbackSummary, + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready', + generatedItemAssets: [], + }; + }); +} + +export function buildPendingSquareHoleWorks( + pending: Record | undefined, + existingItems: readonly SquareHoleWorkSummary[], +): SquareHoleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => ({ + workId: `square-hole-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '方洞挑战草稿', + themeText: '', + twistRule: '', + summary: + state.status === 'failed' + ? '挑战素材生成失败,可重新打开处理。' + : '正在生成挑战素材。', + tags: [], + coverImageSrc: null, + backgroundPrompt: '', + backgroundImageSrc: null, + shapeOptions: [], + holeOptions: [], + shapeCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + })); +} + +export function buildPendingPuzzleWorks( + pending: Record | undefined, + existingItems: readonly PuzzleWorkSummary[], +): PuzzleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([sessionId]) => + existingItems.every((item) => item.sourceSessionId !== sessionId), + ) + .map(([sessionId, state]) => { + const profileId = + buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; + const title = state.title?.trim() || '拼图草稿'; + const summary = + state.summary?.trim() || + (state.status === 'failed' + ? '拼图草稿生成失败,可重新打开处理。' + : '正在生成拼图草稿。'); + return { + workId: + buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, + profileId, + ownerUserId: '', + sourceSessionId: sessionId, + authorDisplayName: '', + workTitle: title, + workDescription: summary, + levelName: title, + summary, + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: + state.status === 'generating' + ? 'generating' + : state.status === 'failed' + ? 'failed' + : 'ready', + levels: [], + }; + }); +} + +export function buildPendingVisualNovelWorks( + pending: Record | undefined, + existingItems: readonly VisualNovelWorkSummary[], +): VisualNovelWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([profileId]) => + existingItems.every((item) => item.profileId !== profileId), + ) + .map(([profileId, state]) => ({ + runtimeKind: 'visual-novel', + profileId, + ownerUserId: '', + title: '视觉小说草稿', + description: + state.status === 'failed' + ? '视觉小说草稿生成失败,可重新打开处理。' + : '正在生成视觉小说草稿。', + coverImageSrc: null, + tags: [], + publishStatus: 'draft', + publishReady: false, + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + })); +} + +export function buildPendingBarkBattleWorks( + pending: Record | undefined, + existingItems: readonly BarkBattleWorkSummary[], +): BarkBattleWorkSummary[] { + if (!pending) { + return []; + } + + return Object.entries(pending) + .filter(([id]) => + existingItems.every((item) => item.workId !== id && item.draftId !== id), + ) + .map(([id, state]) => ({ + workId: id, + draftId: id, + ownerUserId: '', + authorDisplayName: '', + title: '汪汪声浪草稿', + summary: + state.status === 'failed' + ? '声浪竞技素材生成失败,可重新打开处理。' + : '正在生成声浪竞技素材。', + themeDescription: '', + playerImageDescription: '', + opponentImageDescription: '', + onomatopoeia: [], + playerCharacterImageSrc: null, + opponentCharacterImageSrc: null, + uiBackgroundImageSrc: null, + difficultyPreset: 'normal', + status: 'draft', + generationStatus: + state.status === 'generating' + ? 'pending_assets' + : state.status === 'failed' + ? 'partial_failed' + : 'ready', + publishReady: false, + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + })); +} + +function resolvePuzzleSessionStableSuffix( + sessionId: string | null | undefined, +) { + const normalizedSessionId = sessionId?.trim(); + if (!normalizedSessionId) { + return null; + } + return normalizedSessionId.startsWith('puzzle-session-') + ? normalizedSessionId.slice('puzzle-session-'.length) + : normalizedSessionId; +} + +function pickDraftCompletionDialogSourceId( + ids: Array, +) { + const normalizedIds = ids + .map((id) => id?.trim() ?? '') + .filter((id) => Boolean(id)); + return ( + normalizedIds.find((id) => /session/i.test(id)) ?? + normalizedIds.find((id) => /work/i.test(id)) ?? + normalizedIds.find((id) => /draft/i.test(id)) ?? + normalizedIds.find((id) => /run/i.test(id)) ?? + normalizedIds.find((id) => /profile/i.test(id)) ?? + normalizedIds[0] ?? + null + ); +} + +function formatDraftTaskCompletionSource(label: string, id?: string | null) { + const normalizedId = id?.trim(); + return normalizedId ? `${label} ${normalizedId}` : label; +}