Sync local updates with origin/master

This commit is contained in:
2026-05-26 23:00:08 +08:00
parent 6b9c0fb3db
commit 927dcf5664
21 changed files with 655 additions and 73 deletions

View File

@@ -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`

View File

@@ -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,...`,终端日志会被数百万字符淹没。

View File

@@ -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
新增表:

View File

@@ -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不在前端做复杂拼接。只有用户在结果页明确点击“重生成地块”时才允许再调用一次地块图集生图。

View File

@@ -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. 显式重生成角色或地块时,只重生成对应资产槽位。

View File

@@ -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`;有结构化字段时以两列信息块展示,例如“题材 / 素材数量”,无结构化字段时才展示纯文本设定。

View File

@@ -134,6 +134,10 @@ export interface WoodenFishWorkProfileResponse {
floatingWords: string[];
}
export interface WoodenFishWorksResponse {
items: WoodenFishWorkSummaryResponse[];
}
export interface WoodenFishWorkDetailResponse {
item: WoodenFishWorkProfileResponse;
}

View File

@@ -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} 个地块切片应保留对应格子的主体颜色"
);
}
}
}

View File

@@ -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(

View File

@@ -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>,

View File

@@ -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 {

View File

@@ -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')

View File

@@ -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>

View File

@@ -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')

View File

@@ -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);

View File

@@ -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',

View File

@@ -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: [],

View File

@@ -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:

View File

@@ -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(() => {

View File

@@ -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' },
'读取敲木鱼作品列表失败',
);
});

View File

@@ -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,
};