refactor: 收口小游戏会话映射

This commit is contained in:
2026-06-04 02:59:42 +08:00
parent 5dd73186b0
commit 671f5da86a
7 changed files with 538 additions and 118 deletions

View File

@@ -1379,6 +1379,14 @@
- 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Mini Game Session Mapping Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 detail 恢复和敲木鱼 pending session 四段纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID 和 pending draft 默认值。
- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession``buildJumpHopPendingSession``buildWoodenFishSessionFromWorkDetail``buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue``platformPuzzleIdentityModel`壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。
- 影响范围:拼图 runtime URL 恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架打开和敲木鱼草稿 detail 恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 Public Work Presentation 收口
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。

View File

@@ -55,6 +55,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()``setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md)。
平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.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)。
平台入口创作恢复 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)。

View File

@@ -0,0 +1,40 @@
# 【前端架构】Platform Mini Game Session Mapping Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、跳一跳 pending session、敲木鱼 work detail 恢复和敲木鱼 pending session 四段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用却住在大型平台壳内新增或修正生成中草稿恢复时需要在壳层里理解 sessionId 优先级、拼图稳定 ID、pending draft 默认值和木鱼 fallback 规则。
这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。
## 决策
新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为:
- `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`
- `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。
- `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session并按 summary / fallback / profileId 决定 sessionId。
- `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session``set*Work``set*Run``createMiniGameDraftGenerationState(...)``writeCreationUrlState(...)``enterCreateTab()``setSelectionStage(...)`
## Interface 约束
- 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。
- 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。
- 拼图 owner 缺省为 `current-user` / `玩家``publishReady` 来自 `session.resultPreview?.publishReady`
- 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。
- 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`
- 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。
## Depth / Leverage / Locality
- **Depth**:壳层以四个函数取得恢复用 DTOID 优先级和默认 draft 字段藏入 Module Implementation。
- **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。
- **Locality**:拼图、跳一跳和敲木鱼的恢复映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameSessionMappingModel.ts src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -12,6 +12,8 @@
生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()``setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。
拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg``big-fish``puzzle``match3d``jump-hop``wooden-fish``square-hole``bark-battle``visual-novel``baby-object-match``creative-agent``airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace``UnifiedGenerationPage``UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace``Match3DCreationWorkspace``JumpHopCreationWorkspace``WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config``creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec字段类型只保留 `text``select``image``audio``UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。

View File

@@ -513,6 +513,12 @@ import {
resolveMatch3DRuntimeGeneratedBackgroundAsset,
resolveMatch3DRuntimeGeneratedItemAssets,
} from './platformMatch3DRuntimeProfile';
import {
buildJumpHopPendingSession,
buildPuzzleRuntimeWorkFromSession,
buildWoodenFishPendingSession,
buildWoodenFishSessionFromWorkDetail,
} from './platformMiniGameSessionMappingModel';
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
import {
type PlatformPublicCodeSearchStep,
@@ -1156,124 +1162,6 @@ function openPuzzleRuntimeStage(
writePuzzleRuntimeUrlState(state);
}
function buildPuzzleRuntimeWorkFromSession(
session: PuzzleAgentSessionSnapshot,
owner: { userId?: string | null; displayName?: string | null },
): PuzzleWorkSummary | null {
const draft = session.draft;
const profileId =
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
return null;
}
return {
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
profileId,
ownerUserId: owner.userId ?? 'current-user',
sourceSessionId: session.sessionId,
authorDisplayName: owner.displayName ?? '玩家',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
publicationStatus: 'draft',
updatedAt: session.updatedAt,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(session.resultPreview?.publishReady),
levels: draft.levels,
};
}
function buildJumpHopPendingSession(
item: JumpHopWorkSummaryResponse,
): JumpHopSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
difficulty: item.difficulty,
stylePreset: item.stylePreset,
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
function buildWoodenFishSessionFromWorkDetail(
work: WoodenFishWorkProfileResponse,
fallbackItem?: WoodenFishWorkSummaryResponse | null,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
work.summary.profileId;
return {
sessionId,
ownerUserId: work.summary.ownerUserId,
status: work.summary.generationStatus,
draft: work.draft,
createdAt: work.summary.updatedAt,
updatedAt: work.summary.updatedAt,
};
}
function buildWoodenFishPendingSession(
item: WoodenFishWorkSummaryResponse,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
/** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */
function createMiniGameDraftGenerationStateForRestoredDraft(
kind: MiniGameDraftGenerationKind,

View File

@@ -0,0 +1,344 @@
import { describe, expect, test } from 'vitest';
import type {
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type {
WoodenFishAudioAsset,
WoodenFishImageAsset,
WoodenFishWorkProfileResponse,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import {
buildJumpHopPendingSession,
buildPuzzleRuntimeWorkFromSession,
buildWoodenFishPendingSession,
buildWoodenFishSessionFromWorkDetail,
} from './platformMiniGameSessionMappingModel';
function buildAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleDraft(
overrides: Partial<PuzzleResultDraft> = {},
): PuzzleResultDraft {
const anchorPack = buildAnchorPack();
return {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把星桥碎片拼回原位。',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-cover.png',
coverAssetId: 'asset-cover',
generationStatus: 'ready',
levels: [
{
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '星桥',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-level-cover.png',
coverAssetId: 'asset-level-cover',
generationStatus: 'ready',
},
],
...overrides,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const draft = buildPuzzleDraft();
return {
sessionId: 'puzzle-session-12345678',
seedText: '星桥',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: draft.anchorPack,
draft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft,
blockers: [],
qualityFindings: [],
publishReady: true,
},
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function buildJumpHopSummary(
overrides: Partial<JumpHopWorkSummaryResponse> = {},
): JumpHopWorkSummaryResponse {
return {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-1',
profileId: 'jump-hop-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' jump-hop-session-1 ',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: '/jump-hop-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T11:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
const woodenFishImageAsset: WoodenFishImageAsset = {
assetId: 'asset-hit',
imageSrc: '/hit.png',
imageObjectKey: 'hit.png',
assetObjectId: 'asset-object-hit',
generationProvider: 'test',
prompt: '木鱼',
width: 512,
height: 512,
};
const woodenFishAudioAsset: WoodenFishAudioAsset = {
assetId: 'asset-sound',
audioSrc: '/hit.mp3',
audioObjectKey: 'hit.mp3',
assetObjectId: 'asset-object-sound',
source: 'test',
};
function buildWoodenFishSummary(
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
): WoodenFishWorkSummaryResponse {
return {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-1',
profileId: 'wooden-fish-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' wooden-fish-session-1 ',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
coverImageSrc: '/wooden-fish-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
function buildWoodenFishWorkProfile(
overrides: Partial<WoodenFishWorkProfileResponse> = {},
): WoodenFishWorkProfileResponse {
const summary = buildWoodenFishSummary();
const draft = {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: summary.profileId,
workTitle: summary.workTitle,
workDescription: summary.workDescription,
themeTags: summary.themeTags,
hitObjectPrompt: '星灯',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
coverImageSrc: summary.coverImageSrc,
generationStatus: summary.generationStatus,
};
return {
summary,
draft,
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
floatingWords: ['功德 +1'],
...overrides,
};
}
describe('platformMiniGameSessionMappingModel', () => {
test('builds a draft puzzle runtime work from a session', () => {
expect(
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
userId: 'user-1',
displayName: '玩家一号',
}),
).toMatchObject({
workId: 'puzzle-work-12345678',
profileId: 'puzzle-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-12345678',
authorDisplayName: '玩家一号',
workTitle: '星桥拼图',
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'draft',
publishedAt: null,
publishReady: true,
});
});
test('prefers published puzzle profile id when present', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
publishedProfileId: 'published-puzzle-profile',
}),
{},
),
).toMatchObject({
profileId: 'published-puzzle-profile',
workId: 'puzzle-work-12345678',
ownerUserId: 'current-user',
authorDisplayName: '玩家',
});
});
test('returns null for puzzle runtime work without draft or cover', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: null,
}),
{},
),
).toBeNull();
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
}),
{},
),
).toBeNull();
});
test('builds jump hop pending session from work summary', () => {
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
sessionId: 'jump-hop-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-1',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: '/jump-hop-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T11:00:00.000Z',
updatedAt: '2026-06-01T11:00:00.000Z',
});
});
test('builds wooden fish pending session from work summary', () => {
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
sessionId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: '/wooden-fish-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T12:00:00.000Z',
updatedAt: '2026-06-01T12:00:00.000Z',
});
});
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
buildWoodenFishSummary({
sourceSessionId: ' fallback-session ',
}),
),
).toMatchObject({
sessionId: 'fallback-session',
ownerUserId: 'user-1',
status: 'generating',
});
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
null,
).sessionId,
).toBe('wooden-fish-profile-1');
});
});

View File

@@ -0,0 +1,136 @@
import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
export type PlatformMiniGameSessionOwner = {
userId?: string | null;
displayName?: string | null;
};
export function buildPuzzleRuntimeWorkFromSession(
session: PuzzleAgentSessionSnapshot,
owner: PlatformMiniGameSessionOwner,
): PuzzleWorkSummary | null {
const draft = session.draft;
const profileId =
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
return null;
}
return {
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
profileId,
ownerUserId: owner.userId ?? 'current-user',
sourceSessionId: session.sessionId,
authorDisplayName: owner.displayName ?? '玩家',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
publicationStatus: 'draft',
updatedAt: session.updatedAt,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(session.resultPreview?.publishReady),
levels: draft.levels,
};
}
export function buildJumpHopPendingSession(
item: JumpHopWorkSummaryResponse,
): JumpHopSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
difficulty: item.difficulty,
stylePreset: item.stylePreset,
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
export function buildWoodenFishSessionFromWorkDetail(
work: WoodenFishWorkProfileResponse,
fallbackItem?: WoodenFishWorkSummaryResponse | null,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
work.summary.profileId;
return {
sessionId,
ownerUserId: work.summary.ownerUserId,
status: work.summary.generationStatus,
draft: work.draft,
createdAt: work.summary.updatedAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildWoodenFishPendingSession(
item: WoodenFishWorkSummaryResponse,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}