Sync local updates with origin/master
This commit is contained in:
@@ -39,6 +39,15 @@
|
||||
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应断言任务卡显示 `1 / 1`、领取后显示已完成,且新用户账号也没有 `次级入口` / `填邀请码` 常驻按钮;`npm run typecheck`、`npm run check:encoding` 通过。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
## 2026-05-26 生成页总进度圆弧逆时针回调 5 度
|
||||
|
||||
- 背景:创作生成页的总进度圆弧在 `160deg` 位置仍需轻微向左微调,用户要求向左逆时针回调 `5deg`。
|
||||
- 决策:共用 `GenerationProgressHero` 的 SVG 圆弧起始角从 `160deg` 调整为 `155deg`,track 和 fill 都使用同一个 `rotate(155 200 200)` 变换;仍保持 `270deg` 扫描角和正下方 `90deg` 留空。
|
||||
- 决策:总进度标题与百分比数字在 `GenerationProgressHero` 中显式提升到圆环之上,圆环 SVG 维持背景层级。
|
||||
- 决策:总进度标题与百分比数字的内容区上边距从 `pt-[4%]` 收紧到 `pt-[2%]`,桌面端使用 `sm:pt-[1.5%]`,进一步拉开与圆环弧线的距离。
|
||||
- 影响范围:`src/components/GenerationProgressHero.tsx`、共用 `CustomWorldGenerationView`、汪汪声浪 `BarkBattleGeneratingView` 以及生成页圆环布局文档。
|
||||
- 验证方式:`CustomWorldGenerationView` 和 `BarkBattleGeneratingView` 测试断言 `data-ring-start-degrees=155` 且 track / fill transform 都是 `rotate(155 200 200)`。
|
||||
- 关联文档:`docs/【玩法创作】生成页圆环布局口径-2026-05-23.md`。
|
||||
|
||||
## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态
|
||||
|
||||
@@ -996,6 +1005,14 @@
|
||||
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 2026-05-26 跳一跳地块图集改为专用 2x3 六格切分
|
||||
|
||||
- 背景:跳一跳创作在地块生图阶段误用了通用系列素材图集 helper,`item_names.len() > grid_size` 的校验会让 6 个地块类型在 `grid_size = 3` 时直接失败;即使绕过校验,通用 helper 仍以“每物品多视图”语义切图,不符合跳一跳地块的一次性六格资产模型。
|
||||
- 决策:跳一跳地块图集固定采用专用 `2行*3列` 六格布局,按 `start / normal / target / finish / bonus / accent` 顺序切分并分别持久化为独立 PNG 资产;图集 prompt 不再调用通用系列素材 `build_generated_asset_sheet_prompt`。
|
||||
- 影响范围:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
- 验证方式:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过;六张切片都应有独立 OSS 对象与 `JumpHopTileAsset` 记录,不再只有 atlas 预览路径。
|
||||
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`。
|
||||
|
||||
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
|
||||
|
||||
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据。
|
||||
@@ -1047,3 +1064,10 @@
|
||||
- 影响范围:拼图图片模型选择器、拼图结果页关卡重生成面板、拼图生成进度文案、宝贝识物结果页占位提示和相关错误提示。
|
||||
- 验证方式:前端可见文本中不再出现 `gpt-image-2` / `gemini-3.1-flash-image-preview` / `image-2 资源`;相关交互测试改为断言产品化模式名,但提交 payload 仍保持原有模型 ID。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
## 2026-05-26 敲木鱼发布后作品架与推荐流刷新口径
|
||||
|
||||
- 背景:敲木鱼已具备公开广场投影,但草稿 Tab 的作品架没有当前用户作品列表接口,导致已发布作品在发布后不能立即出现在“已发布”筛选和推荐流里。
|
||||
- 决策:新增 `GET /api/creation/wooden-fish/works` 作为当前用户木鱼作品架事实源,返回 `WoodenFishWorksResponse.items` 摘要;平台壳在发布成功后必须同时刷新作品架和公开广场列表。
|
||||
- 影响范围:`server-rs/crates/api-server/src/wooden_fish.rs`、`server-rs/crates/api-server/src/modules/wooden_fish.rs`、`src/services/wooden-fish/woodenFishClient.ts`、`src/components/custom-world-home/creationWorkShelf.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。
|
||||
- 验证方式:发布一个木鱼作品后,草稿 Tab 的已发布筛选应立刻出现 `WF-*` 作品卡,推荐 / 最新流也应立即刷新出公开卡片。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md`。
|
||||
|
||||
@@ -1138,6 +1138,22 @@
|
||||
- 验证:`PuzzleResultView` 单测覆盖发布弹窗内展示 `泥点余额不足`。
|
||||
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/technical/PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md`、`docs/technical/ASSET_GENERATION_POINTS_CONSUMPTION_2026-04-27.md`。
|
||||
|
||||
## 拼图发布检查阶段会在事件落库时炸 wasm
|
||||
|
||||
- 现象:拼图发布在“发布检查”环节直接报 `The module instance encountered a fatal error`,wasm backtrace 指向 `spacetime_module::puzzle::publish_puzzle_work`,并停在 `procedure_commit_mut_tx` 的 commit 阶段。
|
||||
- 原因:`publish_puzzle_work_tx` 会无条件调用 `emit_puzzle_work_published_event` 写入 `puzzle_event`;该表的 `event_id` 是主键,而事件 ID 由 `profile_id + published_at_micros` 组成。只要同一发布动作被重复执行、重放,或极端情况下发生时间戳碰撞,commit 时就会因主键冲突触发 fatal error。
|
||||
- 处理:待修复。发布事件写入需要改成幂等,或在重复发布时显式跳过已存在的 `event_id`;发布动作本身也应补一层更明确的幂等键,避免把重复提交直接推到事务提交阶段。
|
||||
- 验证:对同一 `session_id/profile_id/published_at_micros` 重复调用 `publish_puzzle_work` 时,不应再在 commit 阶段炸 wasm;正常发布仍应生成作品、更新 session,并可进入公开详情。
|
||||
- 关联:`server-rs/crates/spacetime-module/src/puzzle.rs`、`server-rs/crates/api-server/src/puzzle/handlers.rs`、`server-rs/crates/spacetime-client/src/module_bindings/puzzle_event_table.rs`。
|
||||
|
||||
## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布
|
||||
|
||||
- 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。
|
||||
- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。
|
||||
- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。
|
||||
- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。
|
||||
- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。
|
||||
|
||||
## WebGL 画布在高 DPR 移动端放大溢出
|
||||
|
||||
- 现象:抓大鹅试玩入口进入后,3D 锅体和物体从中心圆形区域向右下溢出,顶部状态和底部备选栏也可能看起来被右侧裁切。
|
||||
@@ -1508,6 +1524,14 @@
|
||||
- 验证:`npm run typecheck`,并跑 `npm test -- src/routing/appPageRoutes.test.ts` 覆盖 JumpHop 阶段路径。
|
||||
- 关联:`src/components/platform-entry/platformEntryTypes.ts`、`src/routing/appPageRoutes.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 跳一跳地块图集不要套通用系列素材 n 行模型
|
||||
|
||||
- 现象:跳一跳初始草稿生成时报 `系列素材图集的物品行数不能超过 n。`,或者即使绕过报错也只生成了 atlas 预览路径,地块切片没有真正落盘。
|
||||
- 原因:跳一跳地块只有 6 个固定 tileType,但旧实现把它塞进通用系列素材 helper,并使用 `grid_size = 3` / `item_names = 6` 的语义冲突模型;随后又只保留 atlas 资产与模拟路径,没把六个切片逐一上传并确认到 `JumpHopTileAsset`。
|
||||
- 处理:跳一跳地块改用专用 `2行*3列` 图集 prompt,按 `start / normal / target / finish / bonus / accent` 顺序切 6 张 PNG,并对每张切片各自走 OSS 上传、asset_object 确认和 entity bind。
|
||||
- 验证:`cargo test -p api-server jump_hop_tile_atlas -- --nocapture` 通过后,再看 `jump_hop.rs` 不应再调用 `build_generated_asset_sheet_prompt` 处理地块图集;公开结果里应能拿到 6 个独立 `JumpHopTileAsset`。
|
||||
- 关联:`server-rs/crates/api-server/src/jump_hop.rs`、`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## image2 dry-run 带参考图时不要直接打印 data URL
|
||||
|
||||
- 现象:使用 VectorEngine `gpt-image-2-all` 生成带参考图的概念图时,如果 dry-run 直接打印完整请求体,参考图会被转成超长 `data:image/png;base64,...`,终端日志会被数百万字符淹没。
|
||||
|
||||
@@ -276,6 +276,7 @@ HTTP 路由:
|
||||
POST /api/creation/wooden-fish/sessions
|
||||
GET /api/creation/wooden-fish/sessions/{sessionId}
|
||||
POST /api/creation/wooden-fish/sessions/{sessionId}/actions
|
||||
GET /api/creation/wooden-fish/works
|
||||
GET /api/creation/wooden-fish/works/{profileId}
|
||||
POST /api/creation/wooden-fish/works/{profileId}/publish
|
||||
GET /api/runtime/wooden-fish/works/{profileId}
|
||||
@@ -304,6 +305,8 @@ finish
|
||||
|
||||
敲木鱼创作请求在前端必须使用长等待窗口,避免 `createSession` 或 `executeAction` 仍沿用共享创作工厂默认的 15 秒超时。因为 `compile-draft` 会串行等待敲击物、背景、返回按钮三次 image2 和 OSS 落库,木鱼 client 需要单独配置与整条 image2 链路匹配的超时。本地测试中该 action 可能达到数分钟级;生成页进度必须按“整理草稿 -> 生成敲击物 -> 生成背景环境图 -> 生成返回按钮图 -> 写入正式草稿”展示,不展示“提示词生成音效”阶段,因为当前木鱼音效只支持上传、录音或默认音。
|
||||
|
||||
作品架使用 `GET /api/creation/wooden-fish/works` 读取当前用户草稿和已发布摘要,前端发布成功后必须刷新该列表和 `GET /api/runtime/wooden-fish/gallery` 公开列表,使刚发布作品立即出现在草稿 Tab 的已发布筛选和推荐 / 最新流中。
|
||||
|
||||
## 9. SpacetimeDB 表和 view
|
||||
|
||||
新增表:
|
||||
|
||||
@@ -168,7 +168,7 @@ jump-hop-gallery-detail
|
||||
|
||||
### 6.2 地块只生一次图集
|
||||
|
||||
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。
|
||||
地块必须只调用一次生图,输出一张 3D 视图的 2D 图片图集,再由后端切成运行态可用的地块资产。该图集使用跳一跳专用 `2行*3列` 六格布局,不套用通用“每个物品一行、每行 n 个不同视图”的系列素材模型。
|
||||
|
||||
地块图集要求:
|
||||
|
||||
@@ -176,17 +176,24 @@ jump-hop-gallery-detail
|
||||
2. 必须表现出顶面、侧面和投影;
|
||||
3. 必须与角色图保持同一光向;
|
||||
4. 必须有清晰的立体层次,但仍然是 2D 图片;
|
||||
5. 必须包含至少以下地块类型:
|
||||
5. 六格必须按固定顺序包含以下地块类型:
|
||||
- 起点地块;
|
||||
- 普通地块;
|
||||
- 目标地块;
|
||||
- 终点地块。
|
||||
- 终点地块;
|
||||
- 奖励地块;
|
||||
- 视觉强调地块。
|
||||
|
||||
建议额外包含:
|
||||
固定格位为:
|
||||
|
||||
1. 奖励地块;
|
||||
2. 视觉强调地块;
|
||||
3. 风格化变体地块。
|
||||
| 格位 | tileType | 语义 |
|
||||
| --- | --- | --- |
|
||||
| 第 1 行第 1 列 | `start` | 起点地块 |
|
||||
| 第 1 行第 2 列 | `normal` | 普通地块 |
|
||||
| 第 1 行第 3 列 | `target` | 目标地块 |
|
||||
| 第 2 行第 1 列 | `finish` | 终点地块 |
|
||||
| 第 2 行第 2 列 | `bonus` | 奖励地块 |
|
||||
| 第 2 行第 3 列 | `accent` | 视觉强调地块 |
|
||||
|
||||
图集生成后按地块类型切分并去掉背景,运行态直接消费切好的 PNG,不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时,才允许再调用一次地块图集生图。
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 使用进入生成页的当前时间,作品摘要 `updatedAt` 只用于排序和摘要展示,不参与假进度起算。
|
||||
7. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
|
||||
8. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
9. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
|
||||
|
||||
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
|
||||
|
||||
@@ -131,7 +132,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
||||
|
||||
1. 初始草稿生成时,角色形象单独调用一次生图;
|
||||
2. 初始草稿生成时,地块单独调用一次生图,输出 3D 视图的 2D 图片图集;
|
||||
3. 地块图集由后端切分为起点、普通、目标、终点等透明 PNG;
|
||||
3. 跳一跳地块图集使用专用 `2行*3列` 六格布局,后端按 `start / normal / target / finish / bonus / accent` 顺序切分为透明 PNG;
|
||||
4. 封面和分享图由角色图与地块图轻量合成,不再额外调用第三次生图;
|
||||
5. 显式重生成角色或地块时,只重生成对应资产槽位。
|
||||
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
- 预计等待 / 已耗时信息卡要压缩为更轻的半透明窄卡,标签使用 `9px-10px`,数值使用 `12px-13px`,字号对齐其他生成页 UI 的小字号,不再使用偏大的提示文本;卡片标题和时间值都居中显示,两个数值只展示时间本身,调用侧不要再拼接“预计还需”或“已耗时”前缀。圆环中心不再保留独立白底块,空心圆环只保留条状进度,圆弧半径继续加大,进度数字与“总进度”标题整体上移,靠近圆环上半区。
|
||||
- 顶部导航区采用“返回创作中心 / 状态胶囊”结构,返回按钮使用左箭头图标,字号使用 `text-xs-sm`,状态胶囊使用 `11px-12px`,展示 `素材生成中`、`草稿生成中` 等调用侧传入文案。
|
||||
- 圆弧区域不再包独立大卡片,左右悬浮信息卡只展示“预计等待”和“已耗时”;总进度数值放在圆弧内侧偏上的位置并保持更小字号。当前圆环外径以 `w-[min(35rem,94vw)] sm:w-[52rem]` 为基准,圆弧使用 `r=166`、`strokeWidth=18` 的 SVG 描边,不再使用 `conic-gradient + mask`,避免进度条边缘模糊。
|
||||
- 圆弧描边以圆心为中心整体按 `155deg` 起始;在当前 SVG 坐标系下,这相对 `160deg` 会向左逆时针回调 `5deg`。track 和 fill 都必须共用同一个 `rotate(155 200 200)` 变换,避免只改视觉起点却让填充和轨道错位。
|
||||
- 总进度标题和百分比数字必须显式高于 SVG 圆环层级渲染,避免被圆环边缘压住;圆环本身只做背景层,不抢文字层。
|
||||
- 总进度标题和百分比数字要比圆环再上移一点,当前内容区上边距以 `pt-[2%]` 为准,桌面端可进一步微调到 `sm:pt-[1.5%]`,确保数字不与进度条弧线重合。
|
||||
- 从作品架或刷新后的持久化生成中草稿进入生成页时,前端必须重置“展示态 startedAtMs”为进入生成页的当前时间;后端 `progressPercent` 只用于后续真实步骤推进,不得参与首帧总进度展示,避免恢复生成页首帧直接显示 `80%+`。
|
||||
- 生成页只展示半透明“当前步骤”单卡,卡片内只保留步骤名称、步骤状态、步骤进度条和轻量加载指示;“当前步骤”标签使用 `10px-11px`,步骤名称使用 `14px-15px`,状态使用 `11px-12px`,不再渲染步骤列表或步骤详情。
|
||||
- 当前作品信息放在圆角信息卡中,标题固定使用 `13px`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。
|
||||
|
||||
@@ -134,6 +134,10 @@ export interface WoodenFishWorkProfileResponse {
|
||||
floatingWords: string[];
|
||||
}
|
||||
|
||||
export interface WoodenFishWorksResponse {
|
||||
items: WoodenFishWorkSummaryResponse[];
|
||||
}
|
||||
|
||||
export interface WoodenFishWorkDetailResponse {
|
||||
item: WoodenFishWorkProfileResponse;
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ use crate::{
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, RuntimePrincipal},
|
||||
generated_asset_sheets::{
|
||||
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
|
||||
slice_generated_asset_sheet,
|
||||
apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte,
|
||||
},
|
||||
generated_image_assets::{
|
||||
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
|
||||
@@ -56,6 +55,15 @@ const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
|
||||
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
|
||||
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
|
||||
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
|
||||
const JUMP_HOP_TILE_ATLAS_ROWS: u32 = 2;
|
||||
const JUMP_HOP_TILE_ATLAS_COLS: u32 = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct JumpHopTileAtlasSlice {
|
||||
tile_type: JumpHopTileType,
|
||||
source_atlas_cell: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
pub async fn create_jump_hop_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -379,7 +387,7 @@ pub async fn get_jump_hop_gallery_detail(
|
||||
async fn maybe_generate_jump_hop_assets(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
_session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &mut JumpHopActionRequest,
|
||||
) -> Result<(), Response> {
|
||||
@@ -457,21 +465,7 @@ async fn maybe_generate_jump_hop_assets(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let sheet_prompt = build_generated_asset_sheet_prompt(&GeneratedAssetSheetPromptInput {
|
||||
subject_text: tile_prompt,
|
||||
item_names: &vec![
|
||||
"start".to_string(),
|
||||
"normal".to_string(),
|
||||
"target".to_string(),
|
||||
"finish".to_string(),
|
||||
"bonus".to_string(),
|
||||
"accent".to_string(),
|
||||
],
|
||||
grid_size: 3,
|
||||
item_name_prompt_template: Some("第{row_index}行:{item_name} 的 {view_count} 个不同视图"),
|
||||
special_prompt: Some("每个格子对应一个 tile 类型,供跳一跳地块裁切使用。"),
|
||||
})
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let sheet_prompt = build_jump_hop_tile_atlas_prompt(tile_prompt);
|
||||
let tile_generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
@@ -494,19 +488,9 @@ async fn maybe_generate_jump_hop_assets(
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let tile_slices = slice_generated_asset_sheet(
|
||||
&tile_image,
|
||||
&vec![
|
||||
"start".to_string(),
|
||||
"normal".to_string(),
|
||||
"target".to_string(),
|
||||
"finish".to_string(),
|
||||
"bonus".to_string(),
|
||||
"accent".to_string(),
|
||||
],
|
||||
3,
|
||||
)
|
||||
.map_err(|error| jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error))?;
|
||||
let tile_slices = slice_jump_hop_tile_atlas(&tile_image).map_err(|error| {
|
||||
jump_hop_error_response(request_context, JUMP_HOP_CREATION_PROVIDER, error)
|
||||
})?;
|
||||
let tile_atlas_asset = persist_jump_hop_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
@@ -520,28 +504,20 @@ async fn maybe_generate_jump_hop_assets(
|
||||
request_context,
|
||||
)
|
||||
.await?;
|
||||
let tile_assets = tile_slices
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, row)| JumpHopTileAsset {
|
||||
tile_type: match index {
|
||||
0 => JumpHopTileType::Start,
|
||||
1 => JumpHopTileType::Normal,
|
||||
2 => JumpHopTileType::Target,
|
||||
3 => JumpHopTileType::Finish,
|
||||
4 => JumpHopTileType::Bonus,
|
||||
_ => JumpHopTileType::Accent,
|
||||
},
|
||||
image_src: format!("/generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||
image_object_key: format!("generated-jump-hop-assets/{profile_id}/tiles/{index}.png"),
|
||||
asset_object_id: format!("{profile_id}-tile-{index}-object"),
|
||||
source_atlas_cell: format!("cell-{index}"),
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut tile_assets = Vec::with_capacity(tile_slices.len());
|
||||
for (index, tile_slice) in tile_slices.into_iter().enumerate() {
|
||||
tile_assets.push(
|
||||
persist_jump_hop_tile_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id.as_str(),
|
||||
index,
|
||||
tile_slice,
|
||||
request_context,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
payload.character_asset = Some(character_asset);
|
||||
payload.tile_atlas_asset = Some(tile_atlas_asset);
|
||||
payload.tile_assets = Some(tile_assets);
|
||||
@@ -553,6 +529,153 @@ async fn maybe_generate_jump_hop_assets(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_jump_hop_tile_atlas_prompt(tile_prompt: &str) -> String {
|
||||
let subject_text = tile_prompt.trim();
|
||||
let subject_text = if subject_text.is_empty() {
|
||||
"等距立体地块图集"
|
||||
} else {
|
||||
subject_text
|
||||
};
|
||||
let cell_plan = [
|
||||
"第1行第1列:start 起点地块",
|
||||
"第1行第2列:normal 普通地块",
|
||||
"第1行第3列:target 目标地块",
|
||||
"第2行第1列:finish 终点地块",
|
||||
"第2行第2列:bonus 奖励地块",
|
||||
"第2行第3列:accent 视觉强调地块",
|
||||
]
|
||||
.join(";");
|
||||
|
||||
format!(
|
||||
"生成一张1:1图片。固定生成2行*3列的跳一跳地块素材图集,画面是{subject_text}。严格按六个单元格排布:{cell_plan}。每个单元格只放一个完整等距/俯视角 2D 地块,必须表现顶面、侧面厚度和统一投影,光向一致,地块主体居中且四周保留留白。每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。素材本身不得使用与绿幕相同的纯绿色;若材质天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子。不要出现文字、水印、UI、边框、网格线、标签、角色或场景。"
|
||||
)
|
||||
}
|
||||
|
||||
fn slice_jump_hop_tile_atlas(
|
||||
image: &crate::openai_image_generation::DownloadedOpenAiImage,
|
||||
) -> Result<Vec<JumpHopTileAtlasSlice>, AppError> {
|
||||
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||
"message": format!("跳一跳地块图集解码失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let source = apply_generated_asset_sheet_green_screen_alpha(source);
|
||||
let width = source.width();
|
||||
let height = source.height();
|
||||
let cell_width = width / JUMP_HOP_TILE_ATLAS_COLS;
|
||||
let cell_height = height / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||
if cell_width == 0 || cell_height == 0 {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||
"message": "跳一跳地块图集尺寸过小,无法切割。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let mut slices = Vec::with_capacity(JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||
for index in 0..JUMP_HOP_TILE_ITEM_NAMES.len() {
|
||||
let row = index as u32 / JUMP_HOP_TILE_ATLAS_COLS;
|
||||
let col = index as u32 % JUMP_HOP_TILE_ATLAS_COLS;
|
||||
let x0 = col.saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||||
let x1 = (col.saturating_add(1)).saturating_mul(width) / JUMP_HOP_TILE_ATLAS_COLS;
|
||||
let y0 = row.saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||
let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
|
||||
let cropped = source.crop_imm(
|
||||
x0,
|
||||
y0,
|
||||
x1.saturating_sub(x0).max(1),
|
||||
y1.saturating_sub(y0).max(1),
|
||||
);
|
||||
let cleaned = crop_generated_asset_sheet_view_edge_matte(cropped);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
cleaned
|
||||
.write_to(&mut cursor, image::ImageFormat::Png)
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": JUMP_HOP_CREATION_PROVIDER,
|
||||
"message": format!("跳一跳地块图集切割失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
slices.push(JumpHopTileAtlasSlice {
|
||||
tile_type: jump_hop_tile_type_by_index(index),
|
||||
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
|
||||
bytes: cursor.into_inner(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(slices)
|
||||
}
|
||||
|
||||
fn jump_hop_tile_type_by_index(index: usize) -> JumpHopTileType {
|
||||
match index {
|
||||
0 => JumpHopTileType::Start,
|
||||
1 => JumpHopTileType::Normal,
|
||||
2 => JumpHopTileType::Target,
|
||||
3 => JumpHopTileType::Finish,
|
||||
4 => JumpHopTileType::Bonus,
|
||||
_ => JumpHopTileType::Accent,
|
||||
}
|
||||
}
|
||||
|
||||
fn jump_hop_tile_asset_slot_name(tile_type: &JumpHopTileType) -> &'static str {
|
||||
match tile_type {
|
||||
JumpHopTileType::Start => "tile-start",
|
||||
JumpHopTileType::Normal => "tile-normal",
|
||||
JumpHopTileType::Target => "tile-target",
|
||||
JumpHopTileType::Finish => "tile-finish",
|
||||
JumpHopTileType::Bonus => "tile-bonus",
|
||||
JumpHopTileType::Accent => "tile-accent",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn persist_jump_hop_tile_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
tile_index: usize,
|
||||
tile_slice: JumpHopTileAtlasSlice,
|
||||
request_context: &RequestContext,
|
||||
) -> Result<JumpHopTileAsset, Response> {
|
||||
let slot = jump_hop_tile_asset_slot_name(&tile_slice.tile_type);
|
||||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||||
bytes: tile_slice.bytes,
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
let persisted = persist_jump_hop_generated_image_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
profile_id,
|
||||
slot,
|
||||
&format!(
|
||||
"跳一跳地块切片 {}:{}",
|
||||
tile_index + 1,
|
||||
tile_slice.source_atlas_cell
|
||||
),
|
||||
image,
|
||||
LegacyAssetPrefix::JumpHopAssets,
|
||||
256,
|
||||
192,
|
||||
request_context,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(JumpHopTileAsset {
|
||||
tile_type: tile_slice.tile_type,
|
||||
image_src: persisted.image_src,
|
||||
image_object_key: persisted.image_object_key,
|
||||
asset_object_id: persisted.asset_object_id,
|
||||
source_atlas_cell: tile_slice.source_atlas_cell,
|
||||
visual_width: 256,
|
||||
visual_height: 192,
|
||||
top_surface_radius: 42.0,
|
||||
landing_radius: 34.0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn persist_jump_hop_generated_image_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -882,3 +1005,71 @@ fn current_utc_micros() -> i64 {
|
||||
.map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jump_hop_tile_atlas_prompt_uses_dedicated_two_by_three_layout() {
|
||||
let prompt = build_jump_hop_tile_atlas_prompt("森林石块风格等距地块");
|
||||
|
||||
assert!(prompt.contains("2行*3列"));
|
||||
assert!(prompt.contains("第1行第1列:start 起点地块"));
|
||||
assert!(prompt.contains("第2行第3列:accent 视觉强调地块"));
|
||||
assert!(!prompt.contains("每个物品生成"));
|
||||
assert!(!prompt.contains("不同视图"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_hop_tile_atlas_slices_one_png_per_tile_type() {
|
||||
let width = 300;
|
||||
let height = 200;
|
||||
let colors = [
|
||||
[220, 24, 24, 255],
|
||||
[240, 150, 32, 255],
|
||||
[248, 220, 72, 255],
|
||||
[52, 168, 84, 255],
|
||||
[38, 132, 255, 255],
|
||||
[156, 92, 220, 255],
|
||||
];
|
||||
let mut atlas = image::RgbaImage::new(width, height);
|
||||
for row in 0..2 {
|
||||
for col in 0..3 {
|
||||
let color = image::Rgba(colors[row * 3 + col]);
|
||||
for y in row as u32 * 100..(row as u32 + 1) * 100 {
|
||||
for x in col as u32 * 100..(col as u32 + 1) * 100 {
|
||||
atlas.put_pixel(x, y, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut encoded = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(atlas)
|
||||
.write_to(&mut encoded, image::ImageFormat::Png)
|
||||
.expect("atlas should encode");
|
||||
let image = crate::openai_image_generation::DownloadedOpenAiImage {
|
||||
bytes: encoded.into_inner(),
|
||||
mime_type: "image/png".to_string(),
|
||||
extension: "png".to_string(),
|
||||
};
|
||||
|
||||
let slices = slice_jump_hop_tile_atlas(&image).expect("atlas should slice");
|
||||
|
||||
assert_eq!(slices.len(), JUMP_HOP_TILE_ITEM_NAMES.len());
|
||||
for (index, slice) in slices.iter().enumerate() {
|
||||
assert_eq!(slice.tile_type, jump_hop_tile_type_by_index(index));
|
||||
assert_eq!(
|
||||
slice.source_atlas_cell,
|
||||
format!("row-{}-col-{}", index / 3 + 1, index % 3 + 1)
|
||||
);
|
||||
let decoded = image::load_from_memory(slice.bytes.as_slice())
|
||||
.expect("tile slice should decode")
|
||||
.to_rgba8();
|
||||
assert!(
|
||||
decoded.pixels().any(|pixel| pixel.0 == colors[index]),
|
||||
"第 {index} 个地块切片应保留对应格子的主体颜色"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ use crate::{
|
||||
wooden_fish::{
|
||||
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
|
||||
finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, publish_wooden_fish_work,
|
||||
start_wooden_fish_run,
|
||||
get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
|
||||
publish_wooden_fish_work, start_wooden_fish_run,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,6 +37,13 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works",
|
||||
get(list_wooden_fish_works).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/creation/wooden-fish/works/{profile_id}/publish",
|
||||
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -21,6 +21,7 @@ use shared_contracts::wooden_fish::{
|
||||
WoodenFishGenerationStatus, WoodenFishImageAsset, WoodenFishRunResponse,
|
||||
WoodenFishSessionResponse, WoodenFishSessionSnapshotResponse, WoodenFishStartRunRequest,
|
||||
WoodenFishWorkDetailResponse, WoodenFishWorkMutationResponse, WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorksResponse,
|
||||
};
|
||||
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
@@ -193,6 +194,31 @@ pub async fn publish_wooden_fish_work(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_wooden_fish_works(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let works = state
|
||||
.spacetime_client()
|
||||
.list_wooden_fish_works(authenticated.claims().user_id().to_string())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
wooden_fish_error_response(
|
||||
&request_context,
|
||||
WOODEN_FISH_CREATION_PROVIDER,
|
||||
map_wooden_fish_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
WoodenFishWorksResponse {
|
||||
items: works.into_iter().map(|work| work.summary).collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_wooden_fish_runtime_work(
|
||||
State(state): State<AppState>,
|
||||
Path(profile_id): Path<String>,
|
||||
|
||||
@@ -203,6 +203,12 @@ pub struct WoodenFishWorkProfileResponse {
|
||||
pub floating_words: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WoodenFishWorksResponse {
|
||||
pub items: Vec<WoodenFishWorkSummaryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WoodenFishWorkDetailResponse {
|
||||
|
||||
@@ -131,7 +131,10 @@ describe('CustomWorldGenerationView', () => {
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[4%]',
|
||||
'z-30',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[2%]',
|
||||
);
|
||||
expect(screen.getByText('总进度').className).toContain('text-[9px]');
|
||||
expect(screen.getByText('42%').className).toContain('text-[1.15rem]');
|
||||
@@ -149,7 +152,7 @@ describe('CustomWorldGenerationView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
).toBe('155');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: progressTitle })
|
||||
@@ -168,6 +171,9 @@ describe('CustomWorldGenerationView', () => {
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||
'z-0',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
@@ -183,6 +189,16 @@ describe('CustomWorldGenerationView', () => {
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef } from 'react';
|
||||
|
||||
import generationHeroVideo from '../../media/create_bg_video.mp4';
|
||||
|
||||
const GENERATION_PROGRESS_RING_START_DEGREES = 225;
|
||||
const GENERATION_PROGRESS_RING_START_DEGREES = 155;
|
||||
const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270;
|
||||
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
|
||||
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
|
||||
@@ -173,7 +173,7 @@ export function GenerationProgressHero({
|
||||
>
|
||||
<svg
|
||||
data-testid="generation-hero-progress-ring"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full"
|
||||
viewBox={`0 0 ${GENERATION_PROGRESS_RING_VIEWBOX} ${GENERATION_PROGRESS_RING_VIEWBOX}`}
|
||||
aria-hidden="true"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
@@ -220,13 +220,13 @@ export function GenerationProgressHero({
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
className="relative z-10 flex h-full w-full flex-col items-center justify-start pt-[4%] text-center sm:pt-[3%]"
|
||||
className="relative z-30 flex h-full w-full flex-col items-center justify-start pt-[2%] text-center sm:pt-[1.5%]"
|
||||
data-testid="generation-hero-progress-content"
|
||||
>
|
||||
<div className="text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
||||
<div className="relative z-30 text-[9px] font-black tracking-[0.16em] text-[#7f441f] sm:text-[10px]">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
||||
<div className="relative z-30 mt-1 text-[1.15rem] font-black leading-none text-[#e45e14] sm:mt-1.5 sm:text-[1.9rem]">
|
||||
{safeProgress}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,10 @@ describe('BarkBattleGeneratingView', () => {
|
||||
'justify-start',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[4%]',
|
||||
'z-30',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-content').className).toContain(
|
||||
'pt-[2%]',
|
||||
);
|
||||
expect(screen.getByText('玩家形象')).toBeTruthy();
|
||||
expect(screen.getByText('进行中 36%')).toBeTruthy();
|
||||
@@ -142,7 +145,7 @@ describe('BarkBattleGeneratingView', () => {
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
.getAttribute('data-ring-start-degrees'),
|
||||
).toBe('225');
|
||||
).toBe('155');
|
||||
expect(
|
||||
screen
|
||||
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
|
||||
@@ -161,6 +164,9 @@ describe('BarkBattleGeneratingView', () => {
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').tagName).toBe(
|
||||
'svg',
|
||||
);
|
||||
expect(screen.getByTestId('generation-hero-progress-ring').getAttribute('class')).toContain(
|
||||
'z-0',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring')
|
||||
@@ -176,6 +182,16 @@ describe('BarkBattleGeneratingView', () => {
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('stroke-width'),
|
||||
).toBe('18');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-track')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
.getAttribute('transform'),
|
||||
).toBe('rotate(155 200 200)');
|
||||
expect(
|
||||
screen
|
||||
.getByTestId('generation-hero-progress-ring-fill')
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
@@ -64,6 +65,9 @@ type CustomWorldCreationHubProps = {
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
|
||||
puzzleItems?: PuzzleWorkSummary[];
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
|
||||
@@ -174,6 +178,9 @@ export function CustomWorldCreationHub({
|
||||
jumpHopItems = [],
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop = null,
|
||||
woodenFishItems = [],
|
||||
onOpenWoodenFishDetail = null,
|
||||
onDeleteWoodenFish = null,
|
||||
puzzleItems = [],
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle = null,
|
||||
@@ -207,6 +214,7 @@ export function CustomWorldCreationHub({
|
||||
match3dItems,
|
||||
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
puzzleItems,
|
||||
babyObjectMatchItems,
|
||||
barkBattleItems,
|
||||
@@ -217,6 +225,7 @@ export function CustomWorldCreationHub({
|
||||
canDeleteSquareHole:
|
||||
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
|
||||
canDeleteJumpHop: Boolean(onDeleteJumpHop),
|
||||
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
|
||||
canDeletePuzzle: Boolean(onDeletePuzzle),
|
||||
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
|
||||
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
|
||||
@@ -232,6 +241,8 @@ export function CustomWorldCreationHub({
|
||||
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
|
||||
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
|
||||
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
|
||||
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
|
||||
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle: onDeletePuzzle ?? undefined,
|
||||
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
|
||||
@@ -259,6 +270,7 @@ export function CustomWorldCreationHub({
|
||||
onDeleteBarkBattle,
|
||||
onDeleteVisualNovel,
|
||||
onDeleteJumpHop,
|
||||
onDeleteWoodenFish,
|
||||
onClaimPuzzlePointIncentive,
|
||||
onOpenBigFishDetail,
|
||||
onOpenDraft,
|
||||
@@ -268,6 +280,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenPuzzleDetail,
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenVisualNovelDetail,
|
||||
onOpenWoodenFishDetail,
|
||||
onEnterPublished,
|
||||
getWorkState,
|
||||
puzzleItems,
|
||||
@@ -275,6 +288,7 @@ export function CustomWorldCreationHub({
|
||||
onOpenSquareHoleDetail,
|
||||
onOpenJumpHopDetail,
|
||||
jumpHopItems,
|
||||
woodenFishItems,
|
||||
visualNovelItems,
|
||||
],
|
||||
);
|
||||
@@ -325,6 +339,9 @@ export function CustomWorldCreationHub({
|
||||
case 'jump-hop':
|
||||
onOpenJumpHopDetail?.(item.source.item);
|
||||
return;
|
||||
case 'wooden-fish':
|
||||
onOpenWoodenFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
|
||||
@@ -60,6 +60,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
|
||||
match3d: '/creation-type-references/match3d.webp',
|
||||
'square-hole': '/creation-type-references/square-hole.webp',
|
||||
'jump-hop': '/creation-type-references/jump-hop.webp',
|
||||
'wooden-fish': '/wooden-fish/default-hit-object.png',
|
||||
puzzle: '/creation-type-references/puzzle.webp',
|
||||
'baby-object-match': '/creation-type-references/creative-agent.webp',
|
||||
'bark-battle': '/creation-type-references/bark-battle.webp',
|
||||
|
||||
@@ -56,6 +56,47 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
|
||||
expect(items[1]?.publicWorkCode).toBeNull();
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
|
||||
const onOpenWoodenFishDetail = vi.fn();
|
||||
const woodenFishWork = {
|
||||
runtimeKind: 'wooden-fish' as const,
|
||||
workId: 'wooden-fish-work-1',
|
||||
profileId: 'wooden-fish-profile-12345678',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-1',
|
||||
workTitle: '苹果敲木鱼',
|
||||
workDescription: '苹果主题木鱼。',
|
||||
themeTags: ['苹果', '休闲'],
|
||||
coverImageSrc: '/wooden-fish/apple-cover.png',
|
||||
publicationStatus: 'published',
|
||||
playCount: 9,
|
||||
updatedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishedAt: '2026-05-20T00:00:00.000Z',
|
||||
publishReady: true,
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [],
|
||||
woodenFishItems: [woodenFishWork],
|
||||
onOpenWoodenFishDetail,
|
||||
});
|
||||
|
||||
items[0]?.actions.open();
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]?.kind).toBe('wooden-fish');
|
||||
expect(items[0]?.status).toBe('published');
|
||||
expect(items[0]?.publicWorkCode).toBe('WF-12345678');
|
||||
expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678');
|
||||
expect(items[0]?.openActionLabel).toBe('查看详情');
|
||||
expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true);
|
||||
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9);
|
||||
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contr
|
||||
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
|
||||
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
buildPuzzlePublicWorkCode,
|
||||
buildSquareHolePublicWorkCode,
|
||||
buildVisualNovelPublicWorkCode,
|
||||
buildWoodenFishPublicWorkCode,
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
@@ -34,6 +36,7 @@ export type CreationWorkShelfKind =
|
||||
| 'match3d'
|
||||
| 'square-hole'
|
||||
| 'jump-hop'
|
||||
| 'wooden-fish'
|
||||
| 'puzzle'
|
||||
| 'baby-object-match'
|
||||
| 'bark-battle'
|
||||
@@ -90,6 +93,10 @@ export type CreationWorkShelfSource =
|
||||
kind: 'jump-hop';
|
||||
item: JumpHopWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'wooden-fish';
|
||||
item: WoodenFishWorkSummaryResponse;
|
||||
}
|
||||
| {
|
||||
kind: 'puzzle';
|
||||
item: PuzzleWorkSummary;
|
||||
@@ -145,6 +152,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems?: Match3DWorkSummary[];
|
||||
squareHoleItems?: SquareHoleWorkSummary[];
|
||||
jumpHopItems?: JumpHopWorkSummaryResponse[];
|
||||
woodenFishItems?: WoodenFishWorkSummaryResponse[];
|
||||
puzzleItems: PuzzleWorkSummary[];
|
||||
babyObjectMatchItems?: BabyObjectMatchDraft[];
|
||||
barkBattleItems?: BarkBattleWorkSummary[];
|
||||
@@ -154,6 +162,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteMatch3D?: boolean;
|
||||
canDeleteSquareHole?: boolean;
|
||||
canDeleteJumpHop?: boolean;
|
||||
canDeleteWoodenFish?: boolean;
|
||||
canDeletePuzzle?: boolean;
|
||||
canDeleteBabyObjectMatch?: boolean;
|
||||
canDeleteBarkBattle?: boolean;
|
||||
@@ -169,6 +178,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteSquareHole?: (item: SquareHoleWorkSummary) => void;
|
||||
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onDeleteJumpHop?: (item: JumpHopWorkSummaryResponse) => void;
|
||||
onOpenWoodenFishDetail?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onDeleteWoodenFish?: (item: WoodenFishWorkSummaryResponse) => void;
|
||||
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
|
||||
onDeletePuzzle?: (item: PuzzleWorkSummary) => void;
|
||||
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
|
||||
@@ -189,6 +200,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
match3dItems = [],
|
||||
squareHoleItems = [],
|
||||
jumpHopItems = [],
|
||||
woodenFishItems = [],
|
||||
puzzleItems,
|
||||
babyObjectMatchItems = [],
|
||||
barkBattleItems = [],
|
||||
@@ -198,6 +210,7 @@ export function buildCreationWorkShelfItems(params: {
|
||||
canDeleteMatch3D = false,
|
||||
canDeleteSquareHole = false,
|
||||
canDeleteJumpHop = false,
|
||||
canDeleteWoodenFish = false,
|
||||
canDeletePuzzle = false,
|
||||
canDeleteBabyObjectMatch = false,
|
||||
canDeleteBarkBattle = false,
|
||||
@@ -213,6 +226,8 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDeleteSquareHole,
|
||||
onOpenJumpHopDetail,
|
||||
onDeleteJumpHop,
|
||||
onOpenWoodenFishDetail,
|
||||
onDeleteWoodenFish,
|
||||
onOpenPuzzleDetail,
|
||||
onDeletePuzzle,
|
||||
onClaimPuzzlePointIncentive,
|
||||
@@ -257,6 +272,12 @@ export function buildCreationWorkShelfItems(params: {
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
...woodenFishItems.map((item) =>
|
||||
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||
onOpen: onOpenWoodenFishDetail,
|
||||
onDelete: onDeleteWoodenFish,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
@@ -815,6 +836,54 @@ function mapJumpHopWorkToShelfItem(
|
||||
};
|
||||
}
|
||||
|
||||
function mapWoodenFishWorkToShelfItem(
|
||||
item: WoodenFishWorkSummaryResponse,
|
||||
canDelete: boolean,
|
||||
adapter: WorkShelfAdapter<WoodenFishWorkSummaryResponse>,
|
||||
): CreationWorkShelfItem {
|
||||
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
|
||||
const publicWorkCode =
|
||||
status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null;
|
||||
const title = item.workTitle.trim() || '敲木鱼';
|
||||
const summary =
|
||||
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
|
||||
|
||||
return {
|
||||
id: item.workId,
|
||||
kind: 'wooden-fish',
|
||||
status,
|
||||
title,
|
||||
summary,
|
||||
authorDisplayName: resolveAuthorDisplayName(item),
|
||||
updatedAt: item.updatedAt,
|
||||
coverImageSrc: normalizeCoverImageSrc(item.coverImageSrc),
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
publicWorkCode,
|
||||
sharePath:
|
||||
publicWorkCode && status === 'published'
|
||||
? buildPublicWorkStagePath('work-detail', publicWorkCode)
|
||||
: null,
|
||||
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
|
||||
canDelete,
|
||||
canShare: status === 'published' && Boolean(publicWorkCode),
|
||||
badges: [
|
||||
buildStatusBadge(status),
|
||||
{ id: 'type', label: '敲木鱼', tone: 'neutral' },
|
||||
],
|
||||
metrics:
|
||||
status === 'published'
|
||||
? buildPublishedMetrics({
|
||||
playCount: item.playCount,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
})
|
||||
: [],
|
||||
actions: buildWorkShelfActions(item, adapter),
|
||||
source: { kind: 'wooden-fish', item },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
function resolveAuthorDisplayName(
|
||||
...sources: Array<unknown>
|
||||
@@ -1026,6 +1095,8 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
case 'wooden-fish':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'bark-battle':
|
||||
return isPersistedBarkBattleDraftGenerating(item.source.item);
|
||||
default:
|
||||
|
||||
@@ -352,6 +352,7 @@ import {
|
||||
type WoodenFishWorkProfileResponse,
|
||||
type WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PublishShareModal } from '../common/PublishShareModal';
|
||||
@@ -2384,6 +2385,15 @@ function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem): string[] {
|
||||
item.source.item.workId,
|
||||
item.source.item.draftId,
|
||||
]);
|
||||
case 'wooden-fish':
|
||||
return collectDraftNoticeKeys('wooden-fish', [
|
||||
item.id,
|
||||
item.source.item.workId,
|
||||
item.source.item.profileId,
|
||||
item.source.item.sourceSessionId,
|
||||
]);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3173,6 +3183,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
>(null);
|
||||
const [woodenFishWork, setWoodenFishWork] =
|
||||
useState<WoodenFishWorkProfileResponse | null>(null);
|
||||
const [woodenFishWorks, setWoodenFishWorks] = useState<
|
||||
WoodenFishWorkSummaryResponse[]
|
||||
>([]);
|
||||
const [woodenFishGalleryEntries, setWoodenFishGalleryEntries] = useState<
|
||||
WoodenFishGalleryCardResponse[]
|
||||
>([]);
|
||||
@@ -3920,6 +3933,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshWoodenFishShelf = useCallback(async () => {
|
||||
try {
|
||||
const worksResponse = await woodenFishClient.listWorks();
|
||||
setWoodenFishWorks(worksResponse.items);
|
||||
return worksResponse.items;
|
||||
} catch (error) {
|
||||
setWoodenFishWorks([]);
|
||||
setWoodenFishError(
|
||||
resolvePuzzleErrorMessage(error, '读取敲木鱼作品列表失败。'),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}, [resolvePuzzleErrorMessage]);
|
||||
|
||||
const refreshPuzzleShelf = useCallback(async () => {
|
||||
setIsPuzzleLoadingLibrary(true);
|
||||
|
||||
@@ -4499,6 +4526,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
],
|
||||
[jumpHopWorks, pendingDraftShelfItems],
|
||||
);
|
||||
const woodenFishShelfItems = useMemo(
|
||||
() => woodenFishWorks,
|
||||
[woodenFishWorks],
|
||||
);
|
||||
const match3dShelfItems = useMemo(
|
||||
() => [
|
||||
...buildPendingMatch3DWorks(pendingDraftShelfItems.match3d, match3dWorks),
|
||||
@@ -4581,6 +4612,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
item.sourceSessionId,
|
||||
]),
|
||||
),
|
||||
...woodenFishShelfItems.flatMap((item) =>
|
||||
collectDraftNoticeKeys('wooden-fish', [
|
||||
item.workId,
|
||||
item.profileId,
|
||||
item.sourceSessionId,
|
||||
]),
|
||||
),
|
||||
...match3dShelfItems.flatMap((item) =>
|
||||
collectDraftNoticeKeys('match3d', [
|
||||
item.workId,
|
||||
@@ -4624,6 +4662,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
barkBattleShelfItems,
|
||||
bigFishShelfItems,
|
||||
jumpHopShelfItems,
|
||||
woodenFishShelfItems,
|
||||
creationHubItems,
|
||||
isSquareHoleCreationVisible,
|
||||
match3dShelfItems,
|
||||
@@ -9088,6 +9127,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const response = await woodenFishClient.publishWork(profileId);
|
||||
setWoodenFishWork(response.item);
|
||||
void refreshWoodenFishShelf();
|
||||
void refreshWoodenFishGallery();
|
||||
openPublishShareModal({
|
||||
title: response.item.summary.workTitle || '敲木鱼',
|
||||
publicWorkCode: buildWoodenFishPublicWorkCode(
|
||||
@@ -9105,6 +9146,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
}, [
|
||||
openPublishShareModal,
|
||||
refreshWoodenFishGallery,
|
||||
refreshWoodenFishShelf,
|
||||
setSelectionStage,
|
||||
woodenFishWork?.summary.profileId,
|
||||
]);
|
||||
@@ -11750,6 +11793,48 @@ export function PlatformEntryFlowShellImpl({
|
||||
[openPublicWorkDetail, setSelectionStage],
|
||||
);
|
||||
|
||||
const openWoodenFishDraft = useCallback(
|
||||
async (item: WoodenFishWorkSummaryResponse) => {
|
||||
markDraftNoticeSeen(
|
||||
collectDraftNoticeKeys('wooden-fish', [
|
||||
item.workId,
|
||||
item.profileId,
|
||||
item.sourceSessionId,
|
||||
]),
|
||||
);
|
||||
|
||||
if (item.publicationStatus === 'published') {
|
||||
void openWoodenFishPublicWorkDetail(item.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
setWoodenFishError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setIsWoodenFishBusy(true);
|
||||
try {
|
||||
const detail = await woodenFishClient.getWorkDetail(item.profileId);
|
||||
setWoodenFishSession(null);
|
||||
setWoodenFishRun(null);
|
||||
setWoodenFishWork(detail.item);
|
||||
setWoodenFishRuntimeReturnStage('wooden-fish-result');
|
||||
enterCreateTab();
|
||||
setSelectionStage('wooden-fish-result');
|
||||
} catch (error) {
|
||||
setWoodenFishError(
|
||||
resolveRpgCreationErrorMessage(error, '读取敲木鱼草稿失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsWoodenFishBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
enterCreateTab,
|
||||
markDraftNoticeSeen,
|
||||
openWoodenFishPublicWorkDetail,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
const openPublicGalleryDetail = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
@@ -14622,6 +14707,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isVisualNovelCreationOpen) {
|
||||
void refreshVisualNovelShelf();
|
||||
}
|
||||
void refreshWoodenFishShelf();
|
||||
void refreshBabyObjectMatchShelf();
|
||||
void refreshBarkBattleShelf();
|
||||
}
|
||||
@@ -14634,6 +14720,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
refreshBarkBattleShelf,
|
||||
refreshMatch3DShelf,
|
||||
refreshPuzzleShelf,
|
||||
refreshWoodenFishShelf,
|
||||
refreshSquareHoleShelf,
|
||||
refreshVisualNovelShelf,
|
||||
selectionStage,
|
||||
@@ -14728,6 +14815,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void refreshSquareHoleShelf();
|
||||
}
|
||||
void refreshPuzzleShelf();
|
||||
void refreshWoodenFishShelf();
|
||||
if (isVisualNovelCreationOpen) {
|
||||
void refreshVisualNovelShelf();
|
||||
}
|
||||
@@ -14781,6 +14869,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
rpgLibraryEntries={platformBootstrap.savedCustomWorldEntries}
|
||||
bigFishItems={isBigFishCreationVisible ? bigFishShelfItems : []}
|
||||
jumpHopItems={isJumpHopCreationVisible ? jumpHopShelfItems : []}
|
||||
woodenFishItems={woodenFishShelfItems}
|
||||
onOpenBigFishDetail={
|
||||
isBigFishCreationVisible
|
||||
? (item) => {
|
||||
@@ -14809,6 +14898,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
: null
|
||||
}
|
||||
onDeleteJumpHop={null}
|
||||
onOpenWoodenFishDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
markCreationFlowReturnToDraftShelf();
|
||||
void openWoodenFishDraft(item);
|
||||
});
|
||||
}}
|
||||
onDeleteWoodenFish={null}
|
||||
match3dItems={match3dShelfItems}
|
||||
onOpenMatch3DDetail={(item) => {
|
||||
runProtectedAction(() => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const { createCreationAgentClientMock } = vi.hoisted(() => ({
|
||||
createCreationAgentClientMock: vi.fn(),
|
||||
}));
|
||||
@@ -9,7 +11,7 @@ vi.mock('../creation-agent', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../apiClient', () => ({
|
||||
requestJson: vi.fn(),
|
||||
requestJson: requestJsonMock,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -22,6 +24,7 @@ beforeEach(() => {
|
||||
streamMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
});
|
||||
|
||||
test('wooden fish creation keeps image2 generation requests alive long enough', async () => {
|
||||
@@ -34,3 +37,16 @@ test('wooden fish creation keeps image2 generation requests alive long enough',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('wooden fish list works uses creation works endpoint', async () => {
|
||||
const { woodenFishClient } = await import('./woodenFishClient');
|
||||
requestJsonMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
await woodenFishClient.listWorks();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/creation/wooden-fish/works',
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
WoodenFishWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/woodenFish';
|
||||
@@ -57,6 +58,7 @@ export type {
|
||||
WoodenFishWorkDetailResponse,
|
||||
WoodenFishWorkMutationResponse,
|
||||
WoodenFishWorkProfileResponse,
|
||||
WoodenFishWorksResponse,
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
};
|
||||
export type CreateWoodenFishSessionRequest = WoodenFishWorkspaceCreateRequest;
|
||||
@@ -186,6 +188,15 @@ export async function getWoodenFishWorkDetail(profileId: string) {
|
||||
return normalizeWoodenFishWorkDetailResponse(response);
|
||||
}
|
||||
|
||||
export async function listWoodenFishWorks() {
|
||||
const response = await requestJson<WoodenFishWorksResponse>(
|
||||
WOODEN_FISH_WORKS_API_BASE,
|
||||
{ method: 'GET' },
|
||||
'读取敲木鱼作品列表失败',
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function listWoodenFishGallery() {
|
||||
return requestJson<WoodenFishGalleryResponse>(
|
||||
`${WOODEN_FISH_RUNTIME_API_BASE}/gallery`,
|
||||
@@ -312,6 +323,7 @@ export const woodenFishClient = {
|
||||
getSession: getWoodenFishCreationSession,
|
||||
getWorkDetail: getWoodenFishWorkDetail,
|
||||
listGallery: listWoodenFishGallery,
|
||||
listWorks: listWoodenFishWorks,
|
||||
publishWork: publishWoodenFishWork,
|
||||
startRun: startWoodenFishRuntimeRun,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user