codex/tiaoyitiao #58

Merged
kdletters merged 6 commits from codex/tiaoyitiao into master 2026-06-07 00:58:04 +08:00
24 changed files with 369 additions and 88 deletions

View File

@@ -1313,3 +1313,11 @@
- 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。 - 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。
- 验证方式:`npm run spacetime:generate``npm run check:encoding``npm run check:server-rs-ddd``cargo test -p module-puzzle-clear``cargo test -p spacetime-client puzzle_clear -- --nocapture``npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts` - 验证方式:`npm run spacetime:generate``npm run check:encoding``npm run check:server-rs-ddd``cargo test -p module-puzzle-clear``cargo test -p spacetime-client puzzle_clear -- --nocapture``npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md``docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-06 统一创作页表头按契约 title 原样显示
- 背景:统一创作页长期使用固定表头 `想做个什么玩法?`,导致跳一跳等玩法希望按自身语义展示标题时只能改前端或默认契约。
- 决策:`creationTypes[].unifiedCreationSpec.title` 继续作为统一创作页表头传输字段,但读取和保存时都按契约 JSON 原样显示和持久化,不再用入口 `title` 自动覆盖。默认 spec 可以给出玩法中文名;旧库中已经持久化为 `想做个什么玩法?` 的契约也保持原样,若需要改表头应直接修改后台契约 JSON 的 `title` 字段。
- 影响范围:`shared-contracts` 默认 spec、`module-runtime` 入口配置响应、`spacetime-module` 后台保存校验、后台入口开关页摘要和前端 fallback spec。
- 验证方式:`GET /api/creation-entry/config` 中各玩法 `unifiedCreationSpec.title` 等于已保存契约内容;后台只修改入口名称时不应隐式改写已保存的统一创作页表头。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`

View File

@@ -27,7 +27,7 @@ vi.mock('../api/adminApiClient', () => ({
const puzzleSpec: UnifiedCreationSpecPayload = { const puzzleSpec: UnifiedCreationSpecPayload = {
playId: 'puzzle', playId: 'puzzle',
title: '想做个什么玩法?', title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
@@ -88,6 +88,9 @@ test('创作入口后台展示并保存统一创作契约', async () => {
await screen.findByText('pictureDescription'); await screen.findByText('pictureDescription');
expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull(); expect(container.querySelector('.admin-subsection .admin-info-list')).not.toBeNull();
expect(
container.querySelector('.admin-subsection .admin-info-list')?.textContent,
).toContain('拼图');
expect(container.querySelector('.admin-panel .admin-panel')).toBeNull(); expect(container.querySelector('.admin-panel .admin-panel')).toBeNull();
expect(container.querySelector('.admin-muted')).toBeNull(); expect(container.querySelector('.admin-muted')).toBeNull();

View File

@@ -707,6 +707,10 @@ function UnifiedCreationSpecSummary({specJson}: {specJson: string}) {
<dt></dt> <dt></dt>
<dd>{parsed.spec.playId}</dd> <dd>{parsed.spec.playId}</dd>
</div> </div>
<div>
<dt></dt>
<dd>{parsed.spec.title}</dd>
</div>
<div> <div>
<dt></dt> <dt></dt>
<dd> <dd>

View File

@@ -66,6 +66,7 @@ npm run check:server-rs-ddd
- 方洞挑战:`/api/creation/square-hole/*``/api/runtime/square-hole/*` - 方洞挑战:`/api/creation/square-hole/*``/api/runtime/square-hole/*`
- 视觉小说:`/api/creation/visual-novel/*``/api/runtime/visual-novel/*` - 视觉小说:`/api/creation/visual-novel/*``/api/runtime/visual-novel/*`
- 大鱼吃小鱼:`/api/runtime/big-fish/*` - 大鱼吃小鱼:`/api/runtime/big-fish/*`
- 跳一跳:`/api/creation/jump-hop/*``/api/runtime/jump-hop/*`
- 汪汪声浪:`/api/runtime/bark-battle/*` - 汪汪声浪:`/api/runtime/bark-battle/*`
- 儿童向创作:`/api/creation/edutainment/*` - 儿童向创作:`/api/creation/edutainment/*`
- AI task`/api/ai/tasks*` - AI task`/api/ai/tasks*`
@@ -341,7 +342,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`CreationEntryTypeConfig` - Rust 结构体:`CreationEntryTypeConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs` - 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`id``title``subtitle``badge``image_src``visible``open``sort_order``updated_at``category_id``category_label``category_sort_order``unified_creation_spec_json` - 字段:`id``title``subtitle``badge``image_src``visible``open``sort_order``updated_at``category_id``category_label``category_sort_order``unified_creation_spec_json`
- 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时只回退首批 `puzzle``match3d``wooden-fish` 默认 spec - 迁移兼容:旧迁移包缺少入口分类字段或统一创作契约字段时,由 `migration.rs` 写入 `None` / `0` / `None` 默认值;入口分组展示由 `module-runtime` 和前端展示派生消费,统一创作契约由 `module-runtime` 解析为 `creationTypes[].unifiedCreationSpec`,为空时`shared-contracts` 中当前支持的统一创作默认 spec 回退。`unifiedCreationSpec.title` 是统一创作页表头契约内容,读取和保存时不按入口 `title` 自动覆盖
### `custom_world_agent_message` ### `custom_world_agent_message`

View File

@@ -14,7 +14,7 @@
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
统一创作入口覆盖当前可进入创作链路的已有模板:`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*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 统一创作入口覆盖当前可进入创作链路的已有模板:`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`统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,读取和保存时不再用入口名称自动覆盖;需要改表头时应直接修改后台契约 JSON 的 `title` 字段。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap确保白字、浅色边框和进度条底色不会被全局规则改成深色不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。 创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
@@ -156,9 +156,11 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改; 3. 地块只调用一次 image2输出一张 `5行*5列``1:1`、单一纯洋红 `#FF00FF` key 背景的主题地块图集;跳一跳地块常包含草地、花、雪、白石和云朵,后端透明化必须使用跳一跳专用洋红 key不启用近白底扣除也不清理非边缘连通的 key 色像素,避免把绿色或白色主体误扣;后处理必须对边缘连通 key 色做容差清理、去彩边 defringe 和底部残影清理,主体图不得自带洋红阴影、紫色底边、粉色脏边、彩色光晕或发光底边,运行态阴影统一由 DOM 绘制地块造型提示词要求以主题物体本身外轮廓为准允许苹果近似圆形、香蕉近似长条或长方形、西瓜近似扇形等自然差异只统一单格规格、安全留白、正面30度视角和 2D/2.5D 手绘风格包装所有地块素材必须保持统一正面30度视角相机位于物体正前方略高位置、镜头向下约30度必须看到清晰正面、侧壁、下沿、明显自身厚度和少量上表面主体正面或侧壁可见面积必须接近或大于顶面面积顶面只能作为辅助可见面水果主题需要明确要求橙瓣看到橙皮正面外侧和果肉厚度、椰子看到壳的正面侧壁和切口厚度、浆果不能只是从上往下看的圆形球顶避免生成纯俯视、正上方俯拍、鸟瞰地图块、平铺俯拍、圆形顶视图或扁平图标主题物体本身必须是唯一可落脚体只能用自身切面、边缘厚度、花瓣层或果皮边表现承重禁止在主题物体下方额外垫石台、土墩、木板、圆台、托盘、岛屿底座或通用地板前端和后端默认 `tilePrompt` 都必须使用“正面30度视角主题物体图集物体本身作为跳跃落点”的口径不再提交“平台素材 / 跳台 / 地块 / 地砖”等会把模型拉回通用平台造型的词,后端生成前也会清洗旧草稿遗留的这些词;当主题或地块提示词命中宝可梦 / 神奇宝贝 / 口袋妖怪 / Pokemon / Pikachu / 精灵球等宝可梦相关词时,仅生图请求侧改写为“原创幻想萌宠冒险道具 / 彩色冒险能量球 / 黄色闪电萌宠符号”,用户草稿标题和主题展示不改;
4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版左上角返回按钮不允许画进背景而是单独生成 `backButtonAsset` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile 4. 背景底图同样由 image2 生成,复用现有 `coverComposite` / `coverImageSrc` 作为运行态背景读写字段OSS 槽位固定为 `background/image.png`;提示词必须严格以用户主题关键词为背景主题,结构以左右两侧氛围为主,中央纵轴 1/2 区域保持少元素、简洁、可读且有纵深感两侧允许更强立体层次和行进感背景只作为底图禁止生成跳板、地块、落脚物、角色、UI、返回按钮、文字、路径箭头或海报排版左上角返回按钮不允许画进背景而是单独生成 `backButtonAsset` 透明 PNGOSS 槽位固定为 `back-button/image.png`,提示词要求标准圆形、主题色材质包装、居中左箭头、纯绿色 key 背景,后端去绿后写入作品 profile
5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位; 5. 后端按从上到下、从左到右均匀切分为 `tile-01``tile-25` 的透明 PNG每个切片必须使用唯一 slot/path 持久化,不能按重复的 `tileType` 复用槽位;
6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽; 6. 结果页只展示陶泥儿 logo 透明角色预览、地块池预览和首屏 3 地块预览;不再提供旧角色图生成槽;移动端结果页必须由结果页根容器承接纵向滚动并保留底部安全区,确保素材预览较长时仍能下滑到返回编辑、试玩和发布按钮;
7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。 7. 前端跳一跳创作 client 的创建会话与执行生成动作请求都必须使用 20 分钟等待窗口,避免背景底图、地块图集、切片、抠图和 OSS 写入仍在后端执行时被共创会话默认 15 秒超时中断。
生成页“当前跳一跳信息”只展示实际参与创作提示词的主题、地块提示词等用户可理解信息;`stylePreset` 等未参与当前 image2 提示词组装的内部风格枚举不得作为兜底内容展示,避免把 `minimal-blocks``paper-toy` 等工程值暴露给创作者。
运行态规则真相必须沉到 `module-jump-hop`前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。 运行态规则真相必须沉到 `module-jump-hop`前端只做拖拽蓄力、角色位移、投影和落地反馈。失败、成功跳跃次数、游戏时长冻结、运行态快照和发布作品状态以后端为准。v1 不保留公开 combo / perfect / 通关语义,旧 `score` 兼容映射为成功跳跃次数。公开列表应走 `jump_hop_gallery_card_view` 订阅缓存,不要每次 HTTP 请求调用 procedure 组装全量列表。
每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数不展示计时器或右上角重开按钮生成背景和游戏舞台必须覆盖整个运行态视口HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。 每屏只展示 3 个地块:当前地块、目标地块和下一预览地块。平台流按同一 seed 无限生成,前端不得自行生成正式路径。运行态 HUD 顶部只保留返回按钮和成功跳跃次数不展示计时器或右上角重开按钮生成背景和游戏舞台必须覆盖整个运行态视口HUD 直接绝对定位压在背景上,不再用外层白底、居中窄栏、卡片边框或游戏区域圆角裁切背景。返回按钮固定在左上角安全区,交互热区固定为移动端 `56px`、桌面约 `62px`,不显示“返回”文字,并通过顶部锚点微调与得分标题牌保持协调;运行态优先使用独立 `backButtonAsset` 透明 PNG 作为真实可点击按钮图,旧作品缺失该字段时才使用同尺寸 CSS 主题色圆形按钮兜底。上方成功跳跃次数 UI 复用拼图模板顶部 HUD 结构:`puzzle-runtime-header-card` 内包含陶泥儿 IP logo、居中的“得分”标题牌以及下挂 `puzzle-runtime-timer-card / puzzle-runtime-timer` 居中数字卡;数字卡展示成功跳跃次数而不是倒计时。游玩中不显示左下角“进行中”状态,也不在屏幕底部常驻排行榜。排行榜按作品维度展示玩家 ID、成功跳跃次数和游戏时长每位玩家只保留 1 条最佳记录,排序固定为 `成功跳跃次数 desc -> 游戏时长 asc -> 更新时间 asc`,并只在失败结算弹窗内展示,弹窗保留重开和返回动作。
@@ -169,7 +171,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'``JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace``jump-hop-generating``jump-hop-result``jump-hop-runtime``jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace``/creation/jump-hop/generating``/creation/jump-hop/result``/gallery/jump-hop/detail``/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。 平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='jump-hop'``JH-*` 公开作品号识别跳一跳作品;从公开详情或推荐流启动运行态时,若卡片摘要不足以携带地块图集和路径配置,必须先补读完整 work profile 再传入运行态。平台壳层必须同步注册 `jump-hop-workspace``jump-hop-generating``jump-hop-result``jump-hop-runtime``jump-hop-gallery-detail` 阶段,并在 `appPageRoutes.ts` 映射 `/creation/jump-hop/workspace``/creation/jump-hop/generating``/creation/jump-hop/result``/gallery/jump-hop/detail``/runtime/jump-hop`,同时持有 session、work、run、gallery、busy/error 与生成进度状态,避免只合入渲染分支但遗漏状态源或分享路径导致 typecheck 失败、刷新回首页。
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已发布作品点击后会先按 profileId 读取完整详情再进入详情或运行态。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。 跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已完成但未发布草稿点击后必须通过私有创作接口 `GET /api/creation/jump-hop/works/{profile_id}` 读取完整详情并进入创作结果页;已发布作品点击后才通过公开运行态接口 `GET /api/runtime/jump-hop/works/{profile_id}` 读取完整详情再进入公开详情或运行态,该公开接口保持 published-only 校验。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。 跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -222,6 +222,34 @@ pub async fn list_jump_hop_works(
)) ))
} }
pub async fn get_jump_hop_work_detail(
State(state): State<AppState>,
Path(profile_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &profile_id, "profileId")?;
let work = state
.spacetime_client()
.get_jump_hop_work_profile(
profile_id,
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| {
jump_hop_error_response(
&request_context,
JUMP_HOP_CREATION_PROVIDER,
map_jump_hop_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
JumpHopWorkDetailResponse { item: work },
))
}
pub async fn delete_jump_hop_work( pub async fn delete_jump_hop_work(
State(state): State<AppState>, State(state): State<AppState>,
Path(profile_id): Path<String>, Path(profile_id): Path<String>,
@@ -231,7 +259,10 @@ pub async fn delete_jump_hop_work(
ensure_non_empty(&request_context, &profile_id, "profileId")?; ensure_non_empty(&request_context, &profile_id, "profileId")?;
let works = state let works = state
.spacetime_client() .spacetime_client()
.delete_jump_hop_work(profile_id, authenticated.claims().user_id().to_string()) .delete_jump_hop_work(
profile_id,
authenticated.claims().user_id().to_string(),
)
.await .await
.map_err(|error| { .map_err(|error| {
jump_hop_error_response( jump_hop_error_response(

View File

@@ -1,6 +1,6 @@
use axum::{ use axum::{
middleware, middleware,
routing::{delete, get, post}, routing::{get, post},
Router, Router,
}; };
@@ -9,8 +9,9 @@ use crate::{
jump_hop::{ jump_hop::{
create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action, create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
publish_jump_hop_work, restart_jump_hop_run, start_jump_hop_run, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
start_jump_hop_run,
}, },
state::AppState, state::AppState,
}; };
@@ -47,10 +48,12 @@ pub fn router(state: AppState) -> Router<AppState> {
) )
.route( .route(
"/api/creation/jump-hop/works/{profile_id}", "/api/creation/jump-hop/works/{profile_id}",
delete(delete_jump_hop_work).route_layer(middleware::from_fn_with_state( get(get_jump_hop_work_detail)
state.clone(), .delete(delete_jump_hop_work)
require_bearer_auth, .route_layer(middleware::from_fn_with_state(
)), state.clone(),
require_bearer_auth,
)),
) )
.route( .route(
"/api/creation/jump-hop/works/{profile_id}/publish", "/api/creation/jump-hop/works/{profile_id}/publish",

View File

@@ -161,10 +161,9 @@ fn normalize_creation_entry_announcement_banner_value(
); );
} }
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object( let banner =
object.clone(), serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
)) .map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
normalize_creation_entry_event_banner_response(index, banner) normalize_creation_entry_event_banner_response(index, banner)
} }
@@ -243,8 +242,8 @@ pub fn resolve_creation_entry_event_banner_responses(
banners banners
} }
.into_iter() .into_iter()
.map(build_creation_entry_event_banner_response) .map(build_creation_entry_event_banner_response)
.collect() .collect()
} }
/// 把领域公告快照转换为 HTTP 响应字段。 /// 把领域公告快照转换为 HTTP 响应字段。
@@ -332,10 +331,7 @@ fn normalize_banner_html_code(
} }
let lower_html_code = html_code.to_ascii_lowercase(); let lower_html_code = html_code.to_ascii_lowercase();
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") { if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
return Err(format!( return Err(format!("{} 条 HTML 公告含有不允许的脚本代码", index + 1));
"{} 条 HTML 公告含有不允许的脚本代码",
index + 1
));
} }
Ok(Some(html_code)) Ok(Some(html_code))

View File

@@ -339,12 +339,20 @@ mod tests {
assert_eq!(banners.len(), 1); assert_eq!(banners.len(), 1);
assert_eq!(banners[0].render_mode, "html"); assert_eq!(banners[0].render_mode, "html");
assert_eq!(banners[0].title, "创作公告"); assert_eq!(banners[0].title, "创作公告");
assert!(banners[0].html_code.as_deref().unwrap_or("").contains("创作公告")); assert!(
assert!(banners[0] banners[0]
.html_code .html_code
.as_deref() .as_deref()
.unwrap_or("") .unwrap_or("")
.contains("/creation-type-references/puzzle.webp")); .contains("创作公告")
);
assert!(
banners[0]
.html_code
.as_deref()
.unwrap_or("")
.contains("/creation-type-references/puzzle.webp")
);
assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src); assert_ne!(banners[0].cover_image_src, legacy_banner.cover_image_src);
} }
@@ -485,6 +493,60 @@ mod tests {
assert_eq!(jump_hop.category_sort_order, 20); assert_eq!(jump_hop.category_sort_order, 20);
} }
#[test]
fn creation_entry_response_uses_unified_creation_contract_title() {
let response = build_creation_entry_config_response(CreationEntryConfigSnapshot {
config_id: CREATION_ENTRY_CONFIG_GLOBAL_ID.to_string(),
start_card: CreationEntryStartCardSnapshot {
title: DEFAULT_CREATION_ENTRY_START_TITLE.to_string(),
description: DEFAULT_CREATION_ENTRY_START_DESCRIPTION.to_string(),
idle_badge: DEFAULT_CREATION_ENTRY_START_IDLE_BADGE.to_string(),
busy_badge: DEFAULT_CREATION_ENTRY_START_BUSY_BADGE.to_string(),
},
type_modal: CreationEntryTypeModalSnapshot {
title: DEFAULT_CREATION_ENTRY_MODAL_TITLE.to_string(),
description: DEFAULT_CREATION_ENTRY_MODAL_DESCRIPTION.to_string(),
},
event_banner: default_creation_entry_event_banner_snapshots()
.into_iter()
.next()
.expect("default banner"),
event_banners_json: Some(default_creation_entry_event_banners_json()),
creation_types: vec![CreationEntryTypeSnapshot {
id: "puzzle".to_string(),
title: "定制拼图".to_string(),
subtitle: "拼图关卡创作".to_string(),
badge: "可创建".to_string(),
image_src: "/creation-type-references/puzzle.webp".to_string(),
visible: true,
open: true,
sort_order: 30,
category_id: "recommended".to_string(),
category_label: "热门推荐".to_string(),
category_sort_order: 20,
updated_at_micros: 1,
unified_creation_spec_json: Some(
r#"{"playId":"puzzle","title":"想做个什么玩法?","workspaceStage":"puzzle-agent-workspace","generationStage":"puzzle-generating","resultStage":"puzzle-result","fields":[{"id":"pictureDescription","kind":"text","label":"画面描述","required":true}]}"#
.to_string(),
),
}],
updated_at_micros: 1,
});
let puzzle = response
.creation_types
.iter()
.find(|item| item.id == "puzzle")
.expect("puzzle entry");
assert_eq!(
puzzle
.unified_creation_spec
.as_ref()
.map(|spec| spec.title.as_str()),
Some("想做个什么玩法?")
);
}
#[test] #[test]
fn normalized_clamps_music_volume_into_valid_range() { fn normalized_clamps_music_volume_into_valid_range() {
let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light); let low = RuntimeSettings::normalized(-1.0, RuntimePlatformTheme::Light);

View File

@@ -137,16 +137,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
"jump-hop-workspace", "jump-hop-workspace",
"jump-hop-generating", "jump-hop-generating",
"jump-hop-result", "jump-hop-result",
vec![ vec![unified_creation_field("themeText", "text", "主题", true)],
unified_creation_field("workTitle", "text", "作品标题", true),
unified_creation_field("workDescription", "text", "作品简介", true),
unified_creation_field("themeTags", "text", "主题标签", true),
unified_creation_field("difficulty", "select", "难度", true),
unified_creation_field("stylePreset", "select", "风格", true),
unified_creation_field("characterPrompt", "text", "角色提示词", true),
unified_creation_field("tilePrompt", "text", "地块提示词", true),
unified_creation_field("endMoodPrompt", "text", "终点氛围", false),
],
), ),
"wooden-fish" => ( "wooden-fish" => (
"wooden-fish-workspace", "wooden-fish-workspace",
@@ -172,18 +163,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
vec![ vec![
unified_creation_field("title", "text", "作品标题", true), unified_creation_field("title", "text", "作品标题", true),
unified_creation_field("themeDescription", "text", "主题/场景描述", true), unified_creation_field("themeDescription", "text", "主题/场景描述", true),
unified_creation_field( unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
"playerImageDescription", unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
"text",
"玩家形象描述",
true,
),
unified_creation_field(
"opponentImageDescription",
"text",
"对手形象描述",
true,
),
unified_creation_field("onomatopoeia", "text", "拟声词", false), unified_creation_field("onomatopoeia", "text", "拟声词", false),
unified_creation_field("difficultyPreset", "select", "难度", true), unified_creation_field("difficultyPreset", "select", "难度", true),
], ],
@@ -220,7 +201,7 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
Some(UnifiedCreationSpecResponse { Some(UnifiedCreationSpecResponse {
play_id: play_id.to_string(), play_id: play_id.to_string(),
title: "想做个什么玩法?".to_string(), title: default_unified_creation_title(play_id)?.to_string(),
workspace_stage: workspace_stage.to_string(), workspace_stage: workspace_stage.to_string(),
generation_stage: generation_stage.to_string(), generation_stage: generation_stage.to_string(),
result_stage: result_stage.to_string(), result_stage: result_stage.to_string(),
@@ -228,6 +209,23 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
}) })
} }
pub fn default_unified_creation_title(play_id: &str) -> Option<&'static str> {
match play_id {
"rpg" => Some("文字冒险"),
"big-fish" => Some("摸鱼"),
"puzzle" => Some("拼图"),
"match3d" => Some("抓大鹅"),
"jump-hop" => Some("跳一跳"),
"wooden-fish" => Some("敲木鱼"),
"square-hole" => Some("方洞"),
"bark-battle" => Some("汪汪声浪"),
"visual-novel" => Some("视觉小说"),
"baby-object-match" => Some("宝贝识物"),
"creative-agent" => Some("智能体创作"),
_ => None,
}
}
pub fn validate_unified_creation_spec_response( pub fn validate_unified_creation_spec_response(
spec: &UnifiedCreationSpecResponse, spec: &UnifiedCreationSpecResponse,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -338,10 +336,12 @@ mod tests {
#[test] #[test]
fn phase1_unified_creation_specs_cover_existing_templates() { fn phase1_unified_creation_specs_cover_existing_templates() {
let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec"); let puzzle = build_phase1_unified_creation_spec("puzzle").expect("puzzle spec");
assert_eq!(puzzle.title, "拼图");
assert_eq!(puzzle.fields[0].id, "pictureDescription"); assert_eq!(puzzle.fields[0].id, "pictureDescription");
assert_eq!(puzzle.fields[1].kind, "image"); assert_eq!(puzzle.fields[1].kind, "image");
let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec"); let match3d = build_phase1_unified_creation_spec("match3d").expect("match3d spec");
assert_eq!(match3d.title, "抓大鹅");
assert_eq!( assert_eq!(
match3d match3d
.fields .fields
@@ -352,18 +352,9 @@ mod tests {
); );
let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec"); let jump_hop = build_phase1_unified_creation_spec("jump-hop").expect("jump-hop spec");
assert!( assert_eq!(jump_hop.title, "跳一跳");
jump_hop assert_eq!(jump_hop.fields.len(), 1);
.fields assert_eq!(jump_hop.fields[0].id, "themeText");
.iter()
.any(|field| field.id == "stylePreset")
);
assert!(
jump_hop
.fields
.iter()
.any(|field| field.id == "endMoodPrompt")
);
let wooden_fish = let wooden_fish =
build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec"); build_phase1_unified_creation_spec("wooden-fish").expect("wooden-fish spec");
@@ -389,6 +380,30 @@ mod tests {
); );
} }
#[test]
fn unified_creation_spec_title_uses_contract_content() {
let raw = r#"{
"playId": "puzzle",
"title": "想做个什么玩法?",
"workspaceStage": "puzzle-agent-workspace",
"generationStage": "puzzle-generating",
"resultStage": "puzzle-result",
"fields": [
{
"id": "pictureDescription",
"kind": "text",
"label": "画面描述",
"required": true
}
]
}"#;
let spec =
resolve_unified_creation_spec_response("puzzle", Some(raw)).expect("puzzle spec");
assert_eq!(spec.title, "想做个什么玩法?");
}
#[test] #[test]
fn creation_entry_event_banner_defaults_to_structured_render_mode() { fn creation_entry_event_banner_defaults_to_structured_render_mode() {
let banner = serde_json::from_str::<CreationEntryEventBannerResponse>( let banner = serde_json::from_str::<CreationEntryEventBannerResponse>(

View File

@@ -83,6 +83,24 @@ test('跳一跳结果页默认角色预览使用陶泥儿透明 logo', () => {
); );
}); });
test('跳一跳结果页根容器允许移动端向下滚动到操作按钮', () => {
const { container } = render(
<JumpHopResultView
profile={buildProfile()}
onBack={() => {}}
onEdit={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
onRegenerateTiles={() => {}}
/>,
);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain('overflow-y-auto');
expect(root.className).toContain('overscroll-contain');
expect(root.className).toContain('safe-area-inset-bottom');
});
test('跳一跳草稿结果页不请求公开排行榜', () => { test('跳一跳草稿结果页不请求公开排行榜', () => {
render( render(
<JumpHopResultView <JumpHopResultView

View File

@@ -300,7 +300,7 @@ export function JumpHopResultView({
}; };
return ( return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4"> <div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-y-auto overscroll-contain px-3 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 sm:px-4 sm:pt-4">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<button <button
type="button" type="button"

View File

@@ -13913,12 +13913,18 @@ export function PlatformEntryFlowShellImpl({
} }
try { try {
const detail = await jumpHopClient.getWorkDetail(item.profileId); const detail = await jumpHopClient.getWorkDetail(item.profileId, {
audience: 'creation',
});
setJumpHopSession(null); setJumpHopSession(null);
setJumpHopRun(null); setJumpHopRun(null);
setJumpHopWork(detail.item); setJumpHopWork(detail.item);
setJumpHopRuntimeReturnStage('jump-hop-result'); setJumpHopRuntimeReturnStage('jump-hop-result');
enterCreateTab(); enterCreateTab();
pushAppHistoryPath('/creation/jump-hop/result');
writeCreationUrlState(
buildJumpHopCreationUrlState({ work: detail.item }),
);
setSelectionStage('jump-hop-result'); setSelectionStage('jump-hop-result');
} catch (error) { } catch (error) {
setJumpHopError( setJumpHopError(
@@ -15240,7 +15246,11 @@ export function PlatformEntryFlowShellImpl({
let work: JumpHopWorkProfileResponse | null = null; let work: JumpHopWorkProfileResponse | null = null;
try { try {
if (profileId) { if (profileId) {
work = (await jumpHopClient.getWorkDetail(profileId)).item; work = (
await jumpHopClient.getWorkDetail(profileId, {
audience: 'creation',
})
).item;
} }
} catch { } catch {
work = null; work = null;

View File

@@ -8331,10 +8331,84 @@ test('direct jump hop result route restores work detail by profile id', async ()
expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull(); expect(screen.queryByText('跳一跳草稿未恢复')).toBeNull();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith( expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-restore-1', 'jump-hop-profile-restore-1',
{ audience: 'creation' },
); );
expect(jumpHopClient.getSession).not.toHaveBeenCalled(); expect(jumpHopClient.getSession).not.toHaveBeenCalled();
}); });
test('completed unpublished jump hop draft opens result page without starting runtime', async () => {
const user = userEvent.setup();
const work = buildMockJumpHopWork({
summary: {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-draft-ready-1',
profileId: 'jump-hop-profile-draft-ready-1',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-draft-ready-1',
themeText: '未发布跳一跳草稿',
workTitle: '未发布跳一跳草稿',
workDescription: '已经生成完成,但还没有发布。',
themeTags: ['草稿'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
});
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
items: [work.summary],
});
vi.mocked(jumpHopClient.getWorkDetail).mockResolvedValueOnce({
item: work,
} satisfies JumpHopWorkDetailResponse);
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
...testCreationEntryConfig,
creationTypes: [
...testCreationEntryConfig.creationTypes,
{
id: 'jump-hop',
title: '跳一跳',
subtitle: '主题驱动平台跳跃',
badge: '可创建',
imageSrc: '/creation-type-references/jump-hop.webp',
visible: true,
open: true,
sortOrder: 55,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
});
render(<TestWrapper withAuth />);
await openDraftHub(user);
const draftPanel = getPlatformTabPanel('saves');
await user.click(
await within(draftPanel).findByRole('button', {
name: /继续创作《未发布跳一跳草稿》/u,
}),
);
expect(await screen.findByText('未发布跳一跳草稿')).toBeTruthy();
expect(jumpHopClient.getWorkDetail).toHaveBeenCalledWith(
'jump-hop-profile-draft-ready-1',
{ audience: 'creation' },
);
expect(jumpHopClient.startRun).not.toHaveBeenCalled();
expect(window.location.pathname).toBe('/creation/jump-hop/result');
expect(window.location.search).toContain(
'profileId=jump-hop-profile-draft-ready-1',
);
});
test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => { test('embedded puzzle form maps raw bearer token errors to user-facing auth copy', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
@@ -11854,6 +11928,7 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
profileId: 'jump-hop-profile-delete', profileId: 'jump-hop-profile-delete',
ownerUserId: 'user-1', ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-delete', sourceSessionId: 'jump-hop-session-delete',
themeText: '跳台删除草稿',
workTitle: '跳台删除草稿', workTitle: '跳台删除草稿',
workDescription: '跳一跳草稿也应接入统一删除。', workDescription: '跳一跳草稿也应接入统一删除。',
themeText: '跳台', themeText: '跳台',

View File

@@ -36,41 +36,49 @@ describe('unified creation specs', () => {
test('主要链路都映射到统一创作、生成、结果阶段', () => { test('主要链路都映射到统一创作、生成、结果阶段', () => {
expect(getUnifiedCreationSpec('rpg')).toMatchObject({ expect(getUnifiedCreationSpec('rpg')).toMatchObject({
title: '文字冒险',
workspaceStage: 'agent-workspace', workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating', generationStage: 'custom-world-generating',
resultStage: 'custom-world-result', resultStage: 'custom-world-result',
}); });
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({ expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
}); });
expect(getUnifiedCreationSpec('match3d')).toMatchObject({ expect(getUnifiedCreationSpec('match3d')).toMatchObject({
title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace', workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating', generationStage: 'match3d-generating',
resultStage: 'match3d-result', resultStage: 'match3d-result',
}); });
expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({ expect(getUnifiedCreationSpec('jump-hop')).toMatchObject({
title: '跳一跳',
workspaceStage: 'jump-hop-workspace', workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating', generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result', resultStage: 'jump-hop-result',
}); });
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({ expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace', workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating', generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result', resultStage: 'wooden-fish-result',
}); });
expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({ expect(getUnifiedCreationSpec('bark-battle')).toMatchObject({
title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace', workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating', generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result', resultStage: 'bark-battle-result',
}); });
expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({ expect(getUnifiedCreationSpec('visual-novel')).toMatchObject({
title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace', workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating', generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result', resultStage: 'visual-novel-result',
}); });
expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({ expect(getUnifiedCreationSpec('baby-object-match')).toMatchObject({
title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace', workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating', generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result', resultStage: 'baby-object-match-result',

View File

@@ -27,7 +27,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
> = { > = {
rpg: { rpg: {
playId: 'rpg', playId: 'rpg',
title: '想做个什么玩法?', title: '文字冒险',
workspaceStage: 'agent-workspace', workspaceStage: 'agent-workspace',
generationStage: 'custom-world-generating', generationStage: 'custom-world-generating',
resultStage: 'custom-world-result', resultStage: 'custom-world-result',
@@ -42,7 +42,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'big-fish': { 'big-fish': {
playId: 'big-fish', playId: 'big-fish',
title: '想做个什么玩法?', title: '摸鱼',
workspaceStage: 'big-fish-agent-workspace', workspaceStage: 'big-fish-agent-workspace',
generationStage: 'big-fish-generating', generationStage: 'big-fish-generating',
resultStage: 'big-fish-result', resultStage: 'big-fish-result',
@@ -57,7 +57,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
puzzle: { puzzle: {
playId: 'puzzle', playId: 'puzzle',
title: '想做个什么玩法?', title: '拼图',
workspaceStage: 'puzzle-agent-workspace', workspaceStage: 'puzzle-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',
@@ -84,7 +84,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
match3d: { match3d: {
playId: 'match3d', playId: 'match3d',
title: '想做个什么玩法?', title: '抓大鹅',
workspaceStage: 'match3d-agent-workspace', workspaceStage: 'match3d-agent-workspace',
generationStage: 'match3d-generating', generationStage: 'match3d-generating',
resultStage: 'match3d-result', resultStage: 'match3d-result',
@@ -105,7 +105,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'jump-hop': { 'jump-hop': {
playId: 'jump-hop', playId: 'jump-hop',
title: '想做个什么玩法?', title: '跳一跳',
workspaceStage: 'jump-hop-workspace', workspaceStage: 'jump-hop-workspace',
generationStage: 'jump-hop-generating', generationStage: 'jump-hop-generating',
resultStage: 'jump-hop-result', resultStage: 'jump-hop-result',
@@ -120,7 +120,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'wooden-fish': { 'wooden-fish': {
playId: 'wooden-fish', playId: 'wooden-fish',
title: '想做个什么玩法?', title: '敲木鱼',
workspaceStage: 'wooden-fish-workspace', workspaceStage: 'wooden-fish-workspace',
generationStage: 'wooden-fish-generating', generationStage: 'wooden-fish-generating',
resultStage: 'wooden-fish-result', resultStage: 'wooden-fish-result',
@@ -153,7 +153,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'square-hole': { 'square-hole': {
playId: 'square-hole', playId: 'square-hole',
title: '想做个什么玩法?', title: '方洞',
workspaceStage: 'square-hole-agent-workspace', workspaceStage: 'square-hole-agent-workspace',
generationStage: 'square-hole-generating', generationStage: 'square-hole-generating',
resultStage: 'square-hole-result', resultStage: 'square-hole-result',
@@ -168,7 +168,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'bark-battle': { 'bark-battle': {
playId: 'bark-battle', playId: 'bark-battle',
title: '想做个什么玩法?', title: '汪汪声浪',
workspaceStage: 'bark-battle-workspace', workspaceStage: 'bark-battle-workspace',
generationStage: 'bark-battle-generating', generationStage: 'bark-battle-generating',
resultStage: 'bark-battle-result', resultStage: 'bark-battle-result',
@@ -213,7 +213,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'visual-novel': { 'visual-novel': {
playId: 'visual-novel', playId: 'visual-novel',
title: '想做个什么玩法?', title: '视觉小说',
workspaceStage: 'visual-novel-agent-workspace', workspaceStage: 'visual-novel-agent-workspace',
generationStage: 'visual-novel-generating', generationStage: 'visual-novel-generating',
resultStage: 'visual-novel-result', resultStage: 'visual-novel-result',
@@ -234,7 +234,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'baby-object-match': { 'baby-object-match': {
playId: 'baby-object-match', playId: 'baby-object-match',
title: '想做个什么玩法?', title: '宝贝识物',
workspaceStage: 'baby-object-match-workspace', workspaceStage: 'baby-object-match-workspace',
generationStage: 'baby-object-match-generating', generationStage: 'baby-object-match-generating',
resultStage: 'baby-object-match-result', resultStage: 'baby-object-match-result',
@@ -255,7 +255,7 @@ const FALLBACK_UNIFIED_CREATION_SPECS: Record<
}, },
'creative-agent': { 'creative-agent': {
playId: 'creative-agent', playId: 'creative-agent',
title: '想做个什么玩法?', title: '智能体创作',
workspaceStage: 'creative-agent-workspace', workspaceStage: 'creative-agent-workspace',
generationStage: 'puzzle-generating', generationStage: 'puzzle-generating',
resultStage: 'puzzle-result', resultStage: 'puzzle-result',

View File

@@ -78,7 +78,7 @@ test('match3d workspace submits derived entry form payload instead of agent chat
/>, />,
); );
expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); expect(screen.getByText('抓大鹅')).toBeTruthy();
expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy(); expect(screen.getByLabelText('想做一个什么题材的抓大鹅?')).toBeTruthy();
expect(screen.queryByText('2D素材风格')).toBeNull(); expect(screen.queryByText('2D素材风格')).toBeNull();
expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull(); expect(screen.queryByRole('button', { name: '扁平图标' })).toBeNull();
@@ -130,7 +130,7 @@ test('match3d workspace can defer visible chrome to the unified creation page',
expect(workspace?.className).not.toContain('h-full'); expect(workspace?.className).not.toContain('h-full');
expect(workspace?.className).not.toContain('overflow-hidden'); expect(workspace?.className).not.toContain('overflow-hidden');
expect(workspace?.className).not.toContain('platform-remap-surface'); expect(workspace?.className).not.toContain('platform-remap-surface');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); expect(screen.queryByRole('heading', { name: '抓大鹅' })).toBeNull();
const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?'); const themeInput = screen.getByLabelText('想做一个什么题材的抓大鹅?');
expect(themeInput).toBeTruthy(); expect(themeInput).toBeTruthy();
expect(themeInput.className).not.toContain('h-full'); expect(themeInput.className).not.toContain('h-full');

View File

@@ -115,7 +115,7 @@ export function Match3DCreationWorkspace({
onCreateFromForm, onCreateFromForm,
initialFormPayload = null, initialFormPayload = null,
showBackButton = true, showBackButton = true,
title = '想做个什么玩法?', title = '抓大鹅',
unifiedChrome = false, unifiedChrome = false,
}: Match3DCreationWorkspaceProps) { }: Match3DCreationWorkspaceProps) {
const [formState, setFormState] = useState<Match3DFormState>(() => const [formState, setFormState] = useState<Match3DFormState>(() =>

View File

@@ -188,7 +188,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
expect(screen.queryByLabelText('作品名称')).toBeNull(); expect(screen.queryByLabelText('作品名称')).toBeNull();
expect(screen.queryByLabelText('作品描述')).toBeNull(); expect(screen.queryByLabelText('作品描述')).toBeNull();
expect(screen.getByText('想做个什么玩法?')).toBeTruthy(); expect(screen.getByText('拼图')).toBeTruthy();
expect(screen.queryByText('try')).toBeNull(); expect(screen.queryByText('try')).toBeNull();
expect(screen.queryByText('Template')).toBeNull(); expect(screen.queryByText('Template')).toBeNull();
@@ -238,7 +238,7 @@ test('puzzle workspace can defer visible chrome to the unified creation page', (
expect(workspace?.className).not.toContain('platform-remap-surface'); expect(workspace?.className).not.toContain('platform-remap-surface');
expect(imagePanel?.className).toContain('flex-none'); expect(imagePanel?.className).toContain('flex-none');
expect(imagePanel?.className).not.toContain('flex-1'); expect(imagePanel?.className).not.toContain('flex-1');
expect(screen.queryByRole('heading', { name: '想做个什么玩法?' })).toBeNull(); expect(screen.queryByRole('heading', { name: '拼图' })).toBeNull();
expect(screen.getByLabelText('画面描述')).toBeTruthy(); expect(screen.getByLabelText('画面描述')).toBeTruthy();
}); });

View File

@@ -246,7 +246,7 @@ export function PuzzleCreationWorkspace({
onAutoSaveForm, onAutoSaveForm,
initialFormPayload = null, initialFormPayload = null,
showBackButton = true, showBackButton = true,
title = '想做个什么玩法?', title = '拼图',
unifiedChrome = false, unifiedChrome = false,
}: PuzzleCreationWorkspaceProps) { }: PuzzleCreationWorkspaceProps) {
const [formState, setFormState] = useState<PuzzleFormState>(() => const [formState, setFormState] = useState<PuzzleFormState>(() =>

View File

@@ -187,9 +187,16 @@ export function executeJumpHopCreationAction(
.then(normalizeJumpHopActionResponse); .then(normalizeJumpHopActionResponse);
} }
export async function getJumpHopWorkDetail(profileId: string) { export async function getJumpHopWorkDetail(
profileId: string,
options: { audience?: 'creation' | 'runtime' } = {},
) {
const base =
options.audience === 'creation'
? JUMP_HOP_WORKS_API_BASE
: `${JUMP_HOP_RUNTIME_API_BASE}/works`;
const response = await requestJson<JumpHopWorkDetailResponse>( const response = await requestJson<JumpHopWorkDetailResponse>(
`${JUMP_HOP_RUNTIME_API_BASE}/works/${encodeURIComponent(profileId)}`, `${base}/${encodeURIComponent(profileId)}`,
{ method: 'GET' }, { method: 'GET' },
'读取跳一跳作品详情失败', '读取跳一跳作品详情失败',
); );

View File

@@ -530,6 +530,44 @@ describe('miniGameDraftGenerationProgress', () => {
]); ]);
}); });
test('jump hop generation anchors hide unused style preset fallback', () => {
const entries = buildJumpHopGenerationAnchorEntries({
sessionId: 'jump-hop-session-style-hidden',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-style-hidden',
themeText: '水果',
workTitle: '水果跳一跳',
workDescription: '水果主题跳一跳。',
themeTags: ['水果'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
characterPrompt: '内置默认 3D 角色',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'generating',
},
createdAt: '2026-06-06T10:00:00.000Z',
updatedAt: '2026-06-06T10:00:00.000Z',
});
expect(entries).toEqual([
{
id: 'jump-hop-theme',
label: '主题',
value: '水果',
},
]);
});
test('wooden fish draft generation exposes hit object, background and back button pipeline', () => { test('wooden fish draft generation exposes hit object, background and back button pipeline', () => {
const state = createMiniGameDraftGenerationState('wooden-fish'); const state = createMiniGameDraftGenerationState('wooden-fish');

View File

@@ -1163,7 +1163,7 @@ export function buildJumpHopGenerationAnchorEntries(
workTitle?: string; workTitle?: string;
themeText?: string; themeText?: string;
characterPrompt?: string; characterPrompt?: string;
stylePreset?: string; tilePrompt?: string;
} | null; } | null;
} }
| null | null
@@ -1187,7 +1187,7 @@ export function buildJumpHopGenerationAnchorEntries(
value: value:
formPayload?.tilePrompt?.trim() || formPayload?.tilePrompt?.trim() ||
config?.tilePrompt?.trim() || config?.tilePrompt?.trim() ||
draft?.stylePreset?.trim() || draft?.tilePrompt?.trim() ||
'', '',
}, },
]; ];