diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index a0230b47..f081cdcd 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1281,6 +1281,14 @@ - 验证方式:`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 Creation URL State Model 收口 + +- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。 +- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module,Interface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key;新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。 +- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。 +- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`、`npm run test -- src/services/creationUrlState.test.ts`、`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-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 b6eefb52..32f3133f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,6 +45,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台入口创作生成通知、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)。 +平台入口创作恢复 URL 私有 query、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-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/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md new file mode 100644 index 00000000..7c9c9d3f --- /dev/null +++ b/docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md @@ -0,0 +1,36 @@ +# CreationUrlStateModel 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾直接承载多玩法创作恢复 URL 的拼装规则:`sessionId`、`profileId`、`draftId`、`workId` 的优先级、拼图草稿 runtime query、以及空值归一化散在壳层 Implementation 内。平台壳因此需要理解各玩法快照结构,新增玩法或修复刷新恢复时缺少稳定测试面。 + +## 决策 + +- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。 +- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、`normalizeCreationUrlValue`、`hasCreationUrlStateValue`、`hasPuzzleRuntimeUrlStateValue` 与 `buildPuzzleRuntimeUrlStateKey`。 +- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module,统一 `puzzle-session-*`、`puzzle-profile-*`、`puzzle-work-*` 的互推规则。 +- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter;不再在壳层内定义各玩法 URL 状态构造函数。 + +## Interface 约束 + +- 创作恢复私有 query 只使用 `sessionId`、`profileId`、`draftId`、`workId`;不得新增说明性 query 字段。 +- 空字符串、全空白字符串统一视为 `null`,避免刷新恢复时写入无效私有参数。 +- work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。 +- 拼图 runtime query 独立使用 `mode`、`runtimeSessionId`、`runtimeProfileId`、`runtimeLevelId`、`publicWorkCode`,不与创作恢复 query 混写。 +- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`。 + +## Depth / Leverage / Locality + +- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state;各玩法字段优先级藏在 Module Implementation 内。 +- **Leverage**:新增或调整玩法恢复规则时,优先补 Module Interface 测试,再接壳层 Adapter。 +- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module,避免散落在页面壳、作品架和 runtime 打开逻辑中。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts` +- `npm run test -- src/services/creationUrlState.test.ts` +- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts` +- `npx eslint src/components/platform-entry/platformCreationUrlStateModel.ts src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts --max-warnings 0` +- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformDraftGenerationShelfModel.ts --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4e45d4b9..6a27ebf7 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -158,7 +158,6 @@ import { } from '../../services/creationEntryConfigService'; import { clearCreationUrlState, - type CreationUrlState, isCreationRestorePath, readCreationUrlState, writeCreationUrlState, @@ -405,6 +404,23 @@ import { mergeBarkBattleWorkSummary, shouldPreserveLocalBarkBattleWorkOnRefresh, } from './barkBattleWorkCache'; +import { + buildBabyObjectMatchCreationUrlState, + buildBarkBattleCreationUrlState, + buildBigFishCreationUrlState, + buildJumpHopCreationUrlState, + buildMatch3DCreationUrlState, + buildPuzzleCreationUrlState, + buildPuzzleDraftRuntimeUrlState, + buildPuzzlePublishedRuntimeUrlState, + buildPuzzleRuntimeUrlStateKey, + buildSquareHoleCreationUrlState, + buildVisualNovelCreationUrlState, + buildWoodenFishCreationUrlState, + hasCreationUrlStateValue, + hasPuzzleRuntimeUrlStateValue, + normalizeCreationUrlValue, +} from './platformCreationUrlStateModel'; import { buildCreationWorkShelfRuntimeState, buildDraftCompletionDialogSource, @@ -417,8 +433,6 @@ import { buildPendingSquareHoleWorks, buildPendingVisualNovelWorks, buildPendingWoodenFishWorks, - buildPuzzleResultProfileId, - buildPuzzleResultWorkId, collectDraftNoticeKeys, collectVisibleDraftNoticeKeys, createPendingDraftShelfState, @@ -491,6 +505,10 @@ import { mergePlatformPublicGalleryEntries, type RecommendRuntimeKind, } from './platformPublicGalleryFlow'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; import { PlatformTaskCompletionDialog, type PlatformTaskCompletionDialogPayload, @@ -1362,131 +1380,6 @@ function buildAgentResultPublishGateView( }; } -function buildPuzzleSessionIdFromProfileId( - profileId: string | null | undefined, -) { - const normalizedProfileId = profileId?.trim(); - if (!normalizedProfileId?.startsWith('puzzle-profile-')) { - return null; - } - - const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length); - return stableSuffix ? `puzzle-session-${stableSuffix}` : null; -} - -function normalizeCreationUrlValue(value: string | null | undefined) { - return value?.trim() || null; -} - -function hasCreationUrlStateValue(state: CreationUrlState) { - return Boolean( - normalizeCreationUrlValue(state.sessionId) || - normalizeCreationUrlValue(state.profileId) || - normalizeCreationUrlValue(state.draftId) || - normalizeCreationUrlValue(state.workId), - ); -} - -function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) { - return Boolean( - normalizeCreationUrlValue(state.runtimeSessionId) || - normalizeCreationUrlValue(state.runtimeProfileId) || - normalizeCreationUrlValue(state.runtimeLevelId) || - normalizeCreationUrlValue(state.publicWorkCode) || - normalizeCreationUrlValue(state.mode), - ); -} - -function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { - return [ - normalizeCreationUrlValue(state.mode), - normalizeCreationUrlValue(state.runtimeSessionId), - normalizeCreationUrlValue(state.runtimeProfileId), - normalizeCreationUrlValue(state.runtimeLevelId), - normalizeCreationUrlValue(state.publicWorkCode), - ].join('|'); -} - -function buildBigFishCreationUrlState( - session: BigFishSessionSnapshotResponse | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - return { - sessionId, - workId: sessionId ? `big-fish-work-${sessionId}` : null, - }; -} - -function buildMatch3DCreationUrlState( - session: Match3DAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.draft?.profileId ?? session?.publishedProfileId, - ); - return { - sessionId, - profileId, - workId: profileId, - }; -} - -function buildSquareHoleCreationUrlState( - session: SquareHoleSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.draft?.profileId ?? session?.publishedProfileId, - ); - return { - sessionId, - profileId, - workId: profileId, - }; -} - -function buildPuzzleCreationUrlState( - session: PuzzleAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue( - session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId), - ); - return { - sessionId, - profileId, - workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null, - }; -} - -function buildPuzzleDraftRuntimeUrlState( - item: PuzzleWorkSummary, - levelId?: string | null, -): PuzzleRuntimeUrlState { - const runtimeSessionId = - normalizeCreationUrlValue(item.sourceSessionId) ?? - buildPuzzleSessionIdFromProfileId(item.profileId); - - return { - mode: 'draft', - runtimeSessionId, - runtimeProfileId: normalizeCreationUrlValue(item.profileId), - runtimeLevelId: normalizeCreationUrlValue(levelId), - }; -} - -function buildPuzzlePublishedRuntimeUrlState( - item: PuzzleWorkSummary, - levelId?: string | null, -): PuzzleRuntimeUrlState { - return { - mode: 'published', - runtimeProfileId: normalizeCreationUrlValue(item.profileId), - runtimeLevelId: normalizeCreationUrlValue(levelId), - publicWorkCode: buildPuzzlePublicWorkCode(item.profileId), - }; -} - function openPuzzleRuntimeStage( setSelectionStage: (stage: SelectionStage) => void, state: PuzzleRuntimeUrlState, @@ -1531,33 +1424,6 @@ function buildPuzzleRuntimeWorkFromSession( }; } -function buildVisualNovelCreationUrlState( - session: VisualNovelAgentSessionSnapshot | null, -): CreationUrlState { - const sessionId = normalizeCreationUrlValue(session?.sessionId); - const profileId = normalizeCreationUrlValue(session?.draft?.profileId); - return { - sessionId, - profileId, - workId: profileId ?? sessionId, - }; -} - -function buildJumpHopCreationUrlState(params: { - session?: JumpHopSessionSnapshotResponse | null; - work?: JumpHopWorkProfileResponse | null; -}): CreationUrlState { - const sessionId = normalizeCreationUrlValue(params.session?.sessionId); - const profileId = normalizeCreationUrlValue( - params.work?.summary.profileId ?? params.session?.draft?.profileId, - ); - return { - sessionId, - profileId, - workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), - }; -} - function buildJumpHopPendingSession( item: JumpHopWorkSummaryResponse, ): JumpHopSessionSnapshotResponse { @@ -1591,23 +1457,6 @@ function buildJumpHopPendingSession( }; } -function buildWoodenFishCreationUrlState(params: { - session?: WoodenFishSessionSnapshotResponse | null; - work?: WoodenFishWorkProfileResponse | null; -}): CreationUrlState { - const sessionId = normalizeCreationUrlValue(params.session?.sessionId); - const profileId = normalizeCreationUrlValue( - params.work?.summary.profileId ?? params.session?.draft?.profileId, - ); - const draftId = profileId ?? sessionId; - return { - sessionId, - profileId, - draftId, - workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), - }; -} - function buildWoodenFishSessionFromWorkDetail( work: WoodenFishWorkProfileResponse, fallbackItem?: WoodenFishWorkSummaryResponse | null, @@ -1658,26 +1507,6 @@ function buildWoodenFishPendingSession( }; } -function buildBarkBattleCreationUrlState( - draft: BarkBattleDraftConfig | null, -): CreationUrlState { - return { - draftId: normalizeCreationUrlValue(draft?.draftId), - workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId), - }; -} - -function buildBabyObjectMatchCreationUrlState( - draft: BabyObjectMatchDraft | null, -): CreationUrlState { - const profileId = normalizeCreationUrlValue(draft?.profileId); - return { - profileId, - draftId: normalizeCreationUrlValue(draft?.draftId), - workId: profileId, - }; -} - function normalizePlatformErrorMessage(message: string | null | undefined) { const normalized = message?.trim(); return normalized ? normalized : null; diff --git a/src/components/platform-entry/platformCreationUrlStateModel.test.ts b/src/components/platform-entry/platformCreationUrlStateModel.test.ts new file mode 100644 index 00000000..54cf53ef --- /dev/null +++ b/src/components/platform-entry/platformCreationUrlStateModel.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, test } from 'vitest'; + +import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; +import type { + JumpHopSessionSnapshotResponse, + JumpHopWorkProfileResponse, +} from '../../services/jump-hop/jumpHopClient'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkProfileResponse, +} from '../../services/wooden-fish/woodenFishClient'; +import { + buildBabyObjectMatchCreationUrlState, + buildBarkBattleCreationUrlState, + buildBigFishCreationUrlState, + buildJumpHopCreationUrlState, + buildMatch3DCreationUrlState, + buildPuzzleCreationUrlState, + buildPuzzleDraftRuntimeUrlState, + buildPuzzlePublishedRuntimeUrlState, + buildPuzzleRuntimeUrlStateKey, + buildSquareHoleCreationUrlState, + buildVisualNovelCreationUrlState, + buildWoodenFishCreationUrlState, + hasCreationUrlStateValue, + hasPuzzleRuntimeUrlStateValue, + normalizeCreationUrlValue, +} from './platformCreationUrlStateModel'; + +describe('platformCreationUrlStateModel', () => { + test('normalizes private creation url state values', () => { + expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1'); + expect(normalizeCreationUrlValue(' ')).toBeNull(); + expect( + hasCreationUrlStateValue({ + sessionId: ' ', + profileId: null, + draftId: undefined, + workId: 'work-1', + }), + ).toBe(true); + expect(hasCreationUrlStateValue({})).toBe(false); + }); + + test('builds creation restore state for core session based plays', () => { + expect( + buildBigFishCreationUrlState({ + sessionId: ' big-fish-session-1 ', + } as BigFishSessionSnapshotResponse), + ).toEqual({ + sessionId: 'big-fish-session-1', + workId: 'big-fish-work-big-fish-session-1', + }); + + expect( + buildMatch3DCreationUrlState({ + sessionId: 'match3d-session-1', + draft: { profileId: 'match3d-profile-draft' }, + } as Match3DAgentSessionSnapshot), + ).toEqual({ + sessionId: 'match3d-session-1', + profileId: 'match3d-profile-draft', + workId: 'match3d-profile-draft', + }); + + expect( + buildSquareHoleCreationUrlState({ + sessionId: 'square-session-1', + publishedProfileId: 'square-profile-published', + } as SquareHoleSessionSnapshot), + ).toEqual({ + sessionId: 'square-session-1', + profileId: 'square-profile-published', + workId: 'square-profile-published', + }); + + expect( + buildVisualNovelCreationUrlState({ + sessionId: 'visual-session-1', + draft: { profileId: 'visual-profile-1' }, + } as VisualNovelAgentSessionSnapshot), + ).toEqual({ + sessionId: 'visual-session-1', + profileId: 'visual-profile-1', + workId: 'visual-profile-1', + }); + }); + + test('builds puzzle creation and runtime query state', () => { + expect( + buildPuzzleCreationUrlState({ + sessionId: 'puzzle-session-ocean', + } as PuzzleAgentSessionSnapshot), + ).toEqual({ + sessionId: 'puzzle-session-ocean', + profileId: 'puzzle-profile-ocean', + workId: 'puzzle-work-ocean', + }); + + const draftRuntime = buildPuzzleDraftRuntimeUrlState( + buildPuzzleWork({ + profileId: 'puzzle-profile-ocean', + sourceSessionId: null, + }), + 'level-2', + ); + expect(draftRuntime).toEqual({ + mode: 'draft', + runtimeSessionId: 'puzzle-session-ocean', + runtimeProfileId: 'puzzle-profile-ocean', + runtimeLevelId: 'level-2', + }); + expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true); + expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe( + 'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|', + ); + + const publishedRuntime = buildPuzzlePublishedRuntimeUrlState( + buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }), + ); + expect(publishedRuntime.mode).toBe('published'); + expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean'); + expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u); + }); + + test('builds creation state for work backed plays with work id priority', () => { + expect( + buildJumpHopCreationUrlState({ + session: { + sessionId: 'jump-session-1', + draft: { profileId: 'jump-profile-draft' }, + } as JumpHopSessionSnapshotResponse, + work: { + summary: { + profileId: 'jump-profile-work', + workId: 'jump-work-1', + }, + } as JumpHopWorkProfileResponse, + }), + ).toEqual({ + sessionId: 'jump-session-1', + profileId: 'jump-profile-work', + workId: 'jump-work-1', + }); + + expect( + buildWoodenFishCreationUrlState({ + session: { + sessionId: 'wood-session-1', + draft: { profileId: 'wood-profile-draft' }, + } as WoodenFishSessionSnapshotResponse, + work: { + summary: { + profileId: 'wood-profile-work', + workId: 'wood-work-1', + }, + } as WoodenFishWorkProfileResponse, + }), + ).toEqual({ + sessionId: 'wood-session-1', + profileId: 'wood-profile-work', + draftId: 'wood-profile-work', + workId: 'wood-work-1', + }); + }); + + test('builds creation state for draft backed local plays', () => { + expect( + buildBarkBattleCreationUrlState({ + draftId: 'bark-draft-1', + workId: 'bark-work-1', + } as BarkBattleDraftConfig), + ).toEqual({ + draftId: 'bark-draft-1', + workId: 'bark-work-1', + }); + + expect( + buildBabyObjectMatchCreationUrlState({ + draftId: 'baby-draft-1', + profileId: 'baby-profile-1', + } as BabyObjectMatchDraft), + ).toEqual({ + profileId: 'baby-profile-1', + draftId: 'baby-draft-1', + workId: 'baby-profile-1', + }); + }); +}); + +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/platformCreationUrlStateModel.ts b/src/components/platform-entry/platformCreationUrlStateModel.ts new file mode 100644 index 00000000..6ce46fa8 --- /dev/null +++ b/src/components/platform-entry/platformCreationUrlStateModel.ts @@ -0,0 +1,202 @@ +import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle'; +import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; +import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; +import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent'; +import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel'; +import type { CreationUrlState } from '../../services/creationUrlState'; +import type { + JumpHopSessionSnapshotResponse, + JumpHopWorkProfileResponse, +} from '../../services/jump-hop/jumpHopClient'; +import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; +import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState'; +import type { + WoodenFishSessionSnapshotResponse, + WoodenFishWorkProfileResponse, +} from '../../services/wooden-fish/woodenFishClient'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + buildPuzzleSessionIdFromProfileId, +} from './platformPuzzleIdentityModel'; + +/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */ +export function normalizeCreationUrlValue(value: string | null | undefined) { + return value?.trim() || null; +} + +export function hasCreationUrlStateValue(state: CreationUrlState) { + return Boolean( + normalizeCreationUrlValue(state.sessionId) || + normalizeCreationUrlValue(state.profileId) || + normalizeCreationUrlValue(state.draftId) || + normalizeCreationUrlValue(state.workId), + ); +} + +export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) { + return Boolean( + normalizeCreationUrlValue(state.runtimeSessionId) || + normalizeCreationUrlValue(state.runtimeProfileId) || + normalizeCreationUrlValue(state.runtimeLevelId) || + normalizeCreationUrlValue(state.publicWorkCode) || + normalizeCreationUrlValue(state.mode), + ); +} + +export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) { + return [ + normalizeCreationUrlValue(state.mode), + normalizeCreationUrlValue(state.runtimeSessionId), + normalizeCreationUrlValue(state.runtimeProfileId), + normalizeCreationUrlValue(state.runtimeLevelId), + normalizeCreationUrlValue(state.publicWorkCode), + ].join('|'); +} + +export function buildBigFishCreationUrlState( + session: BigFishSessionSnapshotResponse | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + return { + sessionId, + workId: sessionId ? `big-fish-work-${sessionId}` : null, + }; +} + +export function buildMatch3DCreationUrlState( + session: Match3DAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +export function buildSquareHoleCreationUrlState( + session: SquareHoleSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.draft?.profileId ?? session?.publishedProfileId, + ); + return { + sessionId, + profileId, + workId: profileId, + }; +} + +export function buildPuzzleCreationUrlState( + session: PuzzleAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue( + session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId), + ); + return { + sessionId, + profileId, + workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null, + }; +} + +export function buildPuzzleDraftRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + const runtimeSessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? + buildPuzzleSessionIdFromProfileId(item.profileId); + + return { + mode: 'draft', + runtimeSessionId, + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + }; +} + +export function buildPuzzlePublishedRuntimeUrlState( + item: PuzzleWorkSummary, + levelId?: string | null, +): PuzzleRuntimeUrlState { + return { + mode: 'published', + runtimeProfileId: normalizeCreationUrlValue(item.profileId), + runtimeLevelId: normalizeCreationUrlValue(levelId), + publicWorkCode: buildPuzzlePublicWorkCode(item.profileId), + }; +} + +export function buildVisualNovelCreationUrlState( + session: VisualNovelAgentSessionSnapshot | null, +): CreationUrlState { + const sessionId = normalizeCreationUrlValue(session?.sessionId); + const profileId = normalizeCreationUrlValue(session?.draft?.profileId); + return { + sessionId, + profileId, + workId: profileId ?? sessionId, + }; +} + +export function buildJumpHopCreationUrlState(params: { + session?: JumpHopSessionSnapshotResponse | null; + work?: JumpHopWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + return { + sessionId, + profileId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +export function buildWoodenFishCreationUrlState(params: { + session?: WoodenFishSessionSnapshotResponse | null; + work?: WoodenFishWorkProfileResponse | null; +}): CreationUrlState { + const sessionId = normalizeCreationUrlValue(params.session?.sessionId); + const profileId = normalizeCreationUrlValue( + params.work?.summary.profileId ?? params.session?.draft?.profileId, + ); + const draftId = profileId ?? sessionId; + return { + sessionId, + profileId, + draftId, + workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId), + }; +} + +export function buildBarkBattleCreationUrlState( + draft: BarkBattleDraftConfig | null, +): CreationUrlState { + return { + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId), + }; +} + +export function buildBabyObjectMatchCreationUrlState( + draft: BabyObjectMatchDraft | null, +): CreationUrlState { + const profileId = normalizeCreationUrlValue(draft?.profileId); + return { + profileId, + draftId: normalizeCreationUrlValue(draft?.draftId), + workId: profileId, + }; +} diff --git a/src/components/platform-entry/platformDraftGenerationShelfModel.ts b/src/components/platform-entry/platformDraftGenerationShelfModel.ts index 314b7f61..b257cbef 100644 --- a/src/components/platform-entry/platformDraftGenerationShelfModel.ts +++ b/src/components/platform-entry/platformDraftGenerationShelfModel.ts @@ -14,6 +14,15 @@ import { type CreationWorkShelfRuntimeState, resolvePuzzleWorkCoverImageSrc, } from '../custom-world-home/creationWorkShelf'; +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; + +export { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, +} from './platformPuzzleIdentityModel'; export type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; @@ -58,20 +67,6 @@ export type PlatformDraftGenerationVisibleShelfSources = { 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, @@ -825,18 +820,6 @@ export function buildPendingBarkBattleWorks( })); } -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, ) { diff --git a/src/components/platform-entry/platformPuzzleIdentityModel.test.ts b/src/components/platform-entry/platformPuzzleIdentityModel.test.ts new file mode 100644 index 00000000..5d5319b0 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleIdentityModel.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest'; + +import { + buildPuzzleResultProfileId, + buildPuzzleResultWorkId, + buildPuzzleSessionIdFromProfileId, +} from './platformPuzzleIdentityModel'; + +describe('platformPuzzleIdentityModel', () => { + test('builds stable puzzle result identities from a session id', () => { + expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe( + 'puzzle-profile-ocean', + ); + expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe( + 'puzzle-work-ocean', + ); + }); + + test('keeps legacy suffix inputs usable', () => { + expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean'); + expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean'); + }); + + test('builds draft runtime session ids from profile ids', () => { + expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe( + 'puzzle-session-ocean', + ); + expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull(); + expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/platformPuzzleIdentityModel.ts b/src/components/platform-entry/platformPuzzleIdentityModel.ts new file mode 100644 index 00000000..3d493578 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleIdentityModel.ts @@ -0,0 +1,36 @@ +/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */ +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 buildPuzzleSessionIdFromProfileId( + profileId: string | null | undefined, +) { + const normalizedProfileId = profileId?.trim(); + if (!normalizedProfileId?.startsWith('puzzle-profile-')) { + return null; + } + + const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length); + return stableSuffix ? `puzzle-session-${stableSuffix}` : 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; +}