diff --git a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md index b8fef21f..6a034833 100644 --- a/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md +++ b/docs/design/MOBILE_CREATION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md @@ -18,6 +18,7 @@ 4. 模块本体使用 `max-height: 33svh` 作为硬约束,内容超出时优先在模板入口行内横向滚动,不撑高页面。 5. 桌面端保持网格入口,但同步收紧内边距和卡片留白,避免移动端与桌面端表现割裂。 6. 横向滚动模板行必须隐藏原生滚动条,保留滑动能力,避免底部出现过粗的视觉条。 +7. 模板入口排序以可创作为第一优先级:可创建卡片保持原配置内相对顺序排在前面,锁定且展示“敬请期待”的卡片保持原配置内相对顺序排在后面。 ## 文案约束 diff --git a/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md b/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md index bfe41790..715d33a9 100644 --- a/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md +++ b/docs/design/PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md @@ -29,5 +29,5 @@ ## 4. 验收点 - 平台“选择创作类型”弹层不再显示“大鱼吃小鱼”卡片。 -- RPG、拼图、“敬请期待”类卡片顺序与交互保持稳定。 +- 可创建卡片排在前面,展示“敬请期待”的锁定卡片排在后面,交互状态保持稳定。 - 代码层不引入对 Big Fish 运行时或结果页的额外耦合修改。 diff --git a/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md index ef4970ff..ef43aec0 100644 --- a/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md +++ b/docs/design/PLATFORM_HOME_MOBILE_FEED_CARD_REDESIGN_2026-04-28.md @@ -6,7 +6,7 @@ 1. 桌面端首页布局保持现有顶部栏、侧边导航、Hero、趋势区与下方网格结构,不调整桌面端区块顺序。 2. 移动端首页改为参考图式信息流:顶部搜索框、横向频道 Tab、纵向作品列表、底部主导航。 -3. 双端公开作品卡统一结构:上方 `16:9` 封面图,下方作品名称、作品描述、标签、点赞数。 +3. 双端公开作品卡统一结构:上方 `16:9` 封面图,下方作品名称、作者信息、作品描述与玩法类型。 4. 点赞数必须来自作品读模型字段,前端只负责展示,不把游玩数或评分临时改名成点赞。 ## 2. 数据契约 @@ -16,7 +16,7 @@ 公开作品卡和创作中心复用的作品摘要都增加: ```ts -likeCount: number +likeCount: number; ``` 当前阶段只做只读展示,不新增点赞按钮和点击 reducer。后端对尚未接入真实点赞表的作品返回 `0`,保证接口 shape 稳定,后续可无 UI 结构迁移地接入真实互动计数。 @@ -62,12 +62,15 @@ RpgEntryHomeView 每张公开作品卡固定为: 1. 封面区域:`aspect-ratio: 16 / 9`,图片 `object-cover`;无封面时使用轻量主题底。 -2. 信息区域: - - 第一行:作品名称,右侧点赞数。 - - 第二行:作品描述,两行截断。 - - 第三行:最多三个标签。 -3. 点赞数展示在参考图评分位置,使用心形图标 + 紧凑数字,例如 `128`、`1.2万`。 -4. 不展示作品号;作品号仍只在详情页或分享路径中使用。 +2. 封面左上角不展示“推荐”标签,也不展示作者昵称标签,避免遮挡作品主视觉。 +3. 封面右下角展示三项轻量指标:游玩、改造、点赞;统一为图标 + 紧凑数字,例如 `128`、`1.2万`,不写额外说明长文案。 +4. 信息区域: + - 第一行:作品名称,右侧展示玩法类型。拼图玩法展示“拼图”,大鱼玩法展示“大鱼”,RPG 作品展示题材短标签。 + - 第二行:原副标题位置展示作者头像和昵称。当前公开列表只返回作者昵称时,头像使用昵称首字生成的轻量头像;后续接入作者头像 URL 后复用同一位置。 + - 第三行:作品描述,两行截断。 + - 第四行:最多三个标签。 +5. 点赞数仍必须来自作品读模型字段,只是展示位置从信息区右侧迁移到封面右下角。 +6. 不展示作品号;作品号仍只在详情页或分享路径中使用。 ## 5. 验收 diff --git a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md index 4b6370c5..f68970bd 100644 --- a/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md +++ b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md @@ -1,11 +1,11 @@ # 平台统一作品详情页与 Remix 数据链路设计 -更新时间:`2026-04-29` +更新时间:`2026-05-01` ## 1. 本次目标 1. 平台首页、公开广场、分类列表中的每个公开作品点击后,统一先进入作品详情页,不再直接启动玩法。 -2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧“作品改造”按钮、四项统计、简介内容、底部启动按钮。 +2. 作品详情页结构参考 TapTap 详情页:顶部封面图、作品基础信息、右侧“点赞”按钮、四项统计、简介内容、底部“作品改造 + 启动”同行动作。 3. 删除参考图顶部 Tab,不接入评价和论坛功能,不展示“开发者的话”模块。 4. 统计数据必须从数据库读模型贯穿到前端展示,禁止在前端用假字段、游玩数冒充点赞数或固定文案代替真实字段。 5. Remix 按钮必须由后端事务复制公开作品为当前用户草稿,并同步增加原作品改造次数,成功后前端进入新草稿详情/结果页。 @@ -15,21 +15,22 @@ 统一详情页只做作品展示与动作入口,不承担规则说明。 1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。 -2. 封面区:固定 `16:9` 比例,使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 -3. 基础信息区: +2. 封面区:固定 `16:9` 比例,默认使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。拼图作品详情页若详情数据包含多个关卡图,则顶部封面区优先按关卡正式图轮播展示,每张图对应一个关卡;无可用关卡图时再回退作品封面图。 +3. 移动端首页“推荐”和“今日游戏”列表中,只有最接近屏幕垂直中心的作品卡片进入封面轮播态;若该拼图作品有多张关卡封面,则按详情页同源封面序列自动轮换。用户滚动后,离开中心的旧卡片必须立即恢复首张封面,新中心卡片再开始轮播;“游戏分类”、排行、桌面端列表不启用该自动轮播。 +4. 基础信息区: - 左侧作品图标使用作品封面或首图。 - 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位。 - - 右侧原 TapTap 评分位置替换为 `作品改造` 按钮。 -4. 统计区固定四项: - - 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。 + - 右侧原 TapTap 评分位置替换为 `点赞` 按钮;点击后调用后端点赞接口,由后端记录当前登录用户对该公开作品的点赞关系并返回更新后的真实 `likeCount` 读模型,前端不伪造点赞增长。 +5. 统计区固定四项: - 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。 + - 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。 - 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。 - - 最近更新:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。 + - 日期:优先展示 `updatedAt`,缺失时回退 `publishedAt`;前端只负责格式化显示,必须兼容后端当前 `seconds.microsZ` 与 ISO 字符串两种真实时间文本,显示为完整 `YYYY-MM-DD`,使用更小字号并保持单行不换行。 - 四项统计需要使用浅色图标底强化识别,但不得追加规则说明类文案。 -5. 简介区:展示玩法标签和作品简介;不追加说明类文案。 -6. 底部动作:主按钮为“启动”,点击后进入对应玩法运行态并记录游玩次数。 -7. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。 -8. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。 +6. 简介区:展示玩法标签和作品简介;不追加说明类文案。 +7. 底部动作:左侧按钮为“作品改造”,右侧主按钮为“启动”;两个按钮必须位于同一行,点击“启动”后进入对应玩法运行态并记录游玩次数。 +8. 页面配色必须跟随平台明暗主题变量;亮色主题使用平台浅色底、深色文字和主按钮渐变,暗色主题使用平台暗色底、亮色文字和对应主按钮渐变,不在详情页写死独立黑色皮肤。 +9. 字号规范跟随平台页面既有节奏:标题/主按钮使用 `1rem` 级别,作品名使用卡片标题同级 `1rem`,辅助信息与简介使用 `0.8125rem` / `0.875rem`,标签与统计标签使用 `0.75rem`,避免在详情页使用随视口放大的独立大字号。 ## 3. 数据真相源 @@ -38,7 +39,8 @@ 1. `custom_world_profile` 增加 `play_count`、`remix_count`、`like_count`。 2. `custom_world_gallery_entry` 同步这三项统计,作为公开详情和首页卡片读模型。 3. `record_custom_world_profile_play` 负责在公开作品启动前递增 `play_count`,只更新已发布且未删除作品。 -4. `remix_custom_world_profile` 在同一事务内: +4. `record_custom_world_profile_like` 负责记录当前用户对公开作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count` 并同步刷新 `custom_world_gallery_entry`。 +5. `remix_custom_world_profile` 在同一事务内: - 校验源作品已发布、未删除。 - 递增源作品 `remix_count` 并刷新源作品 gallery。 - 复制源 profile payload 为当前用户草稿,清空公开编号、发布时间与统计。 @@ -48,7 +50,8 @@ 1. `puzzle_work_profile` 保留既有 `play_count`,新增 `remix_count`、`like_count`。 2. `start_puzzle_run` 继续作为游玩次数递增入口。 -3. `remix_puzzle_work` 在同一事务内: +3. `record_puzzle_work_like` 负责记录当前用户对公开拼图作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count`。 +4. `remix_puzzle_work` 在同一事务内: - 校验源 profile 为已发布作品。 - 递增源作品 `remix_count`。 - 新建当前用户拼图 Agent session,并把源作品锚点、封面、简介复制为草稿。 @@ -60,7 +63,8 @@ 1. `big_fish_creation_session` 现有 `play_count` 继续作为游玩统计,新增 `remix_count`、`like_count`、`published_at`。 2. `publish_big_fish_game` 写入 `published_at` 与 `updated_at`,公开列表和详情优先用 `updated_at` 展示最近更新。 3. `record_big_fish_play` 继续作为游玩次数递增入口。 -4. `remix_big_fish_work` 在同一事务内: +4. `record_big_fish_like` 负责记录当前用户对公开大鱼作品的点赞;同一用户对同一公开作品只计入一次,首次点赞时递增 `like_count`。 +5. `remix_big_fish_work` 在同一事务内: - 校验源 session 为已发布作品。 - 递增源作品 `remix_count`。 - 新建当前用户创作 session,复制锚点、草稿和资源槽,阶段回到可编辑草稿态。 @@ -74,14 +78,19 @@ - RPG:`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/remix` - 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/remix` - 大鱼:`POST /api/runtime/big-fish/gallery/{session_id}/remix` -3. 前端统一详情页只消费读模型字段,不自行派生统计。 -4. 首页卡片点击只设置统一详情状态;启动与 Remix 只能在详情页触发。 -5. Remix 成功后的跳转: +3. 点赞 API: + - RPG:`POST /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like` + - 拼图:`POST /api/runtime/puzzle/gallery/{profile_id}/like` + - 大鱼:`POST /api/runtime/big-fish/gallery/{session_id}/like` + - 三个接口都必须走登录态鉴权,后端使用当前登录用户身份写入点赞关系;重复点击返回当前最新读模型,不重复增加 `likeCount`。 +4. 前端统一详情页只消费读模型字段,不自行派生统计。 +5. 首页卡片点击只设置统一详情状态;启动、点赞与 Remix 只能在详情页触发。 +6. Remix 成功后的跳转: - RPG:进入复制出的草稿详情。 - 拼图:进入复制出的拼图结果页草稿。 - 大鱼:进入复制出的大鱼结果页草稿。 -6. 拼图作品详情页启动时复用当前详情页已经展示的公开作品读模型,直接调用 `POST /api/runtime/puzzle/runs` 记录游玩并进入运行态;不得在启动前额外依赖 `GET /api/runtime/puzzle/gallery/{profile_id}`,避免开发代理或详情读取短断点阻塞启动链路。 -7. 本地开发时 `localhost:3000` 是 Vite 前端端口,`/api/**` 默认代理到 Rust `api-server:3100`;若 3100 未监听,点击启动或作品改造会在浏览器显示 `/api/... 500`,此时真实断点是 Rust 后端未启动,不允许用前端假数据替代后端事务。 +7. 拼图作品详情页启动时复用当前详情页已经展示的公开作品读模型,直接调用 `POST /api/runtime/puzzle/runs` 记录游玩并进入运行态;不得在启动前额外依赖 `GET /api/runtime/puzzle/gallery/{profile_id}`,避免开发代理或详情读取短断点阻塞启动链路。 +8. 本地开发时 `localhost:3000` 是 Vite 前端端口,`/api/**` 默认代理到 Rust `api-server:3100`;若 3100 未监听,点击启动、点赞或作品改造会在浏览器显示 `/api/... 500`,此时真实断点是 Rust 后端未启动,不允许用前端假数据替代后端事务。 ## 5. 验收点 @@ -89,5 +98,7 @@ 2. 详情页无评价、论坛 Tab,无开发者的话模块。 3. 四项统计在前端、共享契约、API facade、SpacetimeDB 表之间字段一致。 4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。 -5. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。 -6. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 +5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。 +6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。 +7. 移动端首页“推荐”和“今日游戏”列表滚动时,仅中心卡片自动轮播多封面;旧中心卡离开后回到首张封面,新的中心卡接续轮播。 +8. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 diff --git a/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md index 6fabf6c0..71574a80 100644 --- a/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md +++ b/docs/design/PUZZLE_RUNTIME_HEADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md @@ -29,6 +29,14 @@ 网格规模仍可作为运行时内部状态存在,但不默认写在 UI 顶栏中。 +### 1.1 2026-04-30 顶栏与底部工具补充 + +1. 顶栏作者信息不再只显示一行作者名,必须展示为作者头像与昵称组合;当前运行态只提供昵称时,用昵称首字生成圆形占位头像。 +2. 倒计时组件提升为顶栏中的强信息,字号、内边距和图标尺寸都需要明显大于作者昵称与关卡序号。 +3. 底部只保留 `提示 / 原图 / 冻结` 三个功能按钮,并整体居中展示;三个按钮触控面积和图标字号都需要放大。 +4. 底部不再展示“等待下一关候选”这类状态占位。通关后在三个道具按钮上方固定展示“下一关”按钮,展示条件只依赖当前关卡已通关,不依赖 `recommendedNextProfileId` 是否已有值。 +5. 点击底部“下一关”按钮继续调用运行时壳层已有 `onAdvanceNextLevel` 事件;正式 run 由后端 `next-level` 选择候选,本地 run 由 `local-next-level` 生成或接续下一关,前端不在按钮层自行决定下一关来源。 + ### 2. 拼图块显示规则 运行时单块右下角编号全部移除。 diff --git a/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md b/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md index c2512006..d9284043 100644 --- a/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md +++ b/docs/design/RPG_NPC_CHAT_HOSTILE_TERMINATION_AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md @@ -11,14 +11,17 @@ 1. 负好感或敌对 NPC 进入聊天后,不再设置固定 5 回合上限。 2. 负好感或敌对 NPC 每轮回复后,模型必须判断本轮是否结束聊天。 3. 敌对 NPC 判定时应偏向随时结束聊天并进入对峙,但必须结合玩家刚说的话、NPC 性格、当前剧情压力和对话历史。 -4. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天。 -5. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。 -6. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。 -7. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。 -8. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。 -9. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。 -10. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。 -11. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。 +4. 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 `shouldEndChat=true`。 +5. 敌对 NPC 已聊天轮次超过 4 轮后,应倾向立即 `shouldEndChat=true`,避免敌对关系被拖成长时间闲聊。 +6. 敌对 NPC 最后一轮回复必须像战斗、驱逐或正面对峙前的狠话:短促、带压迫感、明确把局势推向行动前一刻,但不在对白中直接结算战斗。 +7. 好感度大于等于 0 且非敌对 NPC 不启用模型终止判定,玩家可一直聊天。 +8. 模型判定终止后,聊天面板不再继续提供聊天输入,只显示“继续”按钮,点击后沿用原流程继续生成冒险选项。 +9. 点击“退出聊天”不再直接收起聊天页,也不立即进入剧情推理;它会发送一条结束聊天的玩家输入,对方回复后同样只显示“继续”按钮。 +10. 正向 NPC 的退出聊天只是玩家主动收束,不代表模型强制中止,也不展示战斗/逃跑选项。 +11. 对负好感或敌对 NPC,在聊天终止后的后续流程仍沿用敌对出口:继续推进后展示一个“战斗”选项,以及按相邻场景和当前场景起点展开的多个逃跑选项。 +12. 聊天候选中允许混入当前 NPC 可执行 function,例如交易、送礼、请求帮助、招募、接任务、交任务、开战、离开等。 +13. Function 候选进入聊天上下文时只作为可触发动作,不在 UI 中展示说明类文本。 +14. “换一换”在聊天态可用,用于在不推进对话的情况下改排/轮换当前候选;它不调用后端,不改变聊天历史。 ## 3. 后端契约 @@ -43,12 +46,14 @@ 1. 敌对聊天可随时中止,NPC 更偏好结束谈判转入战斗或驱逐。 2. 终止不等于在回复正文里直接执行战斗,只需要用台词把对话收束到对峙、威胁、驱逐、最后通牒或行动前一刻。 3. 玩家主动退出聊天时,NPC 回复要对这次收束作出回应,并留下自然的后续入口。 +4. 若玩家本轮发言明显负面,或敌对聊天已进入第 5 轮及之后,回复 prompt 要提示 NPC 直接给出战斗前、驱逐前或正面对峙前的狠话。 建议 prompt 需要明确: 1. 常规聊天候选继续生成玩家台词。 2. Function 候选要根据提供的 function 列表,改写成玩家可直接点击的动作文本。 3. 不输出规则说明,不把 functionId 暴露给玩家。 +4. 敌对聊天判断 `shouldEndChat` 时,负面发言和已聊天轮次超过 4 轮都应作为强收束信号;如果返回 `shouldEndChat=true`,`terminationReason` 使用 `hostile_breakoff`。 ## 5. 前端流程 @@ -81,8 +86,10 @@ 1. 负好感主 NPC 不再出现固定 `turnLimit: 5`。 2. 敌对 NPC 每轮请求会向后端传 `terminationMode: hostile_model`。 3. 模型返回 `forceExit: true` 后,聊天输入消失,只显示继续按钮。 -4. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式。 -5. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板。 -6. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。 -7. 选项文字前出现中文 function 标签,且标签不改变原 actionText。 -8. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。 +4. 玩家对敌对 NPC 说出挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界类发言时,模型应倾向返回 `shouldEndChat=true`,NPC 最后一轮回复带战斗前狠话。 +5. 敌对 NPC 已聊天轮次超过 4 轮后,模型应倾向返回 `shouldEndChat=true`,NPC 最后一轮回复带战斗前狠话。 +6. 好感度大于等于 0 的 NPC 聊天不传敌对中止模式。 +7. 点击退出聊天会新增玩家结束聊天气泡与 NPC 回复,而不是直接切走面板。 +8. 聊天态可看到并点击 function 候选,且“换一换”可改变候选顺序。 +9. 选项文字前出现中文 function 标签,且标签不改变原 actionText。 +10. 聊天结束后的“继续冒险”直接进入下一幕;最后一幕则展示多个相邻场景方向入口。 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 91a41b85..a6e69c01 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -82,7 +82,7 @@ 5. 玩家从广场进入某个作品时,第 1 关必须先显示当前作品本身。 6. 第 2 关及以后必须按照“标签相似度权重 `70%` + 同作者权重 `30%`”选择下一关。 7. 游戏运行时必须全屏展示拼图画布。 -8. 新游戏进入时难度必须从 `3*3` 开始,完成 `3` 关后切为 `4*4`,后续持续为 `4*4`。 +8. 新游戏进入时难度必须从第 `1` 关的 `3*3` 开始,并按关卡配置推进到 `4*4`、`5*5`、`6*6`、`7*7`;第 `11` 关起每 `6` 关循环复用第 `5~10` 关配置。 9. 拼图运行时必须支持: - 点击选择两块并交换 - 正确相邻后自动合并 @@ -504,7 +504,8 @@ tagSimilarityScore = 1. 拼图舞台占满可用全屏区域 2. 真正可操作的拼图棋盘按正方形比例填满安全区域,并在移动端贴近屏幕两侧边缘 3. 棋盘外延空间用同图模糊背景或纯净氛围底承接 -4. 不默认堆玩法说明文字 +4. 基础单块和合并块都使用圆角,基础单块图片需要被圆角容器裁剪 +5. 不默认堆玩法说明文字 ## 9.2 HUD 必显信息 @@ -516,21 +517,35 @@ tagSimilarityScore = 本次建议同时显示: 1. 当前关卡序号 -2. 当前网格规格,例如 `3x3` 或 `4x4` +2. 当前网格规格,例如 `3x3`、`5x5` 或 `7x7` ## 9.3 难度与关卡推进规则 -每次新 run 都必须从最低难度开始: +每次新 run 都必须从第 `1` 关配置开始: -1. 第 `1~3` 关固定为 `3x3` -2. 第 `4` 关开始固定为 `4x4` -3. 后续全部关卡保持 `4x4` +| 关卡 | 切割规格 | 限时 | +| ---------- | -------- | -------------- | +| 第 `1` 关 | `3x3` | `5` 分钟 | +| 第 `2` 关 | `4x4` | `5` 分钟 | +| 第 `3` 关 | `5x5` | `5` 分钟 | +| 第 `4` 关 | `5x5` | `3` 分 `30` 秒 | +| 第 `5` 关 | `5x5` | `3` 分 `30` 秒 | +| 第 `6` 关 | `6x6` | `4` 分钟 | +| 第 `7` 关 | `5x5` | `3` 分 `30` 秒 | +| 第 `8` 关 | `7x7` | `4` 分 `30` 秒 | +| 第 `9` 关 | `5x5` | `4` 分钟 | +| 第 `10` 关 | `7x7` | `4` 分 `30` 秒 | + +第 `11` 关开始,每 `6` 关循环复用第 `5~10` 关配置。 对应函数建议: ```ts -function resolvePuzzleGridSize(clearedLevelCount: number): 3 | 4 { - return clearedLevelCount >= 3 ? 4 : 3; +function resolvePuzzleLevelConfig(levelIndex: number): { + gridSize: 3 | 4 | 5 | 6 | 7; + timeLimitMs: number; +} { + // 统一从关卡序号解析切割规格和倒计时。 } ``` @@ -645,8 +660,8 @@ V1 规则如下: `2026-04-29` 起,拼图运行时加入倒计时: -1. `3x3` 关卡限时 `180` 秒。 -2. `4x4` 关卡限时 `300` 秒。 +1. 倒计时必须使用第 `9.3` 节的关卡配置函数,不允许在 UI 或本地兜底里按网格规模另写一套时间表。 +2. 第 `1~10` 关按配置表执行;第 `11` 关起每 `6` 关循环复用第 `5~10` 关配置。 3. 规定时间内未完成拼图,关卡状态变为 `failed`。 4. 弹窗、查看原图覆盖、冻结时间生效期间不消耗倒计时。 5. 通关成绩只统计有效消耗时间,不统计暂停与冻结时间。 @@ -692,7 +707,7 @@ interface PuzzleProfile { interface PuzzleRuntimeLevelSnapshot { runId: string; levelIndex: number; - gridSize: 3 | 4; + gridSize: 3 | 4 | 5 | 6 | 7; profileId: string; levelName: string; authorDisplayName: string; @@ -737,7 +752,7 @@ interface PuzzleRunSnapshot { entryProfileId: string; clearedLevelCount: number; currentLevelIndex: number; - currentGridSize: 3 | 4; + currentGridSize: 3 | 4 | 5 | 6 | 7; playedProfileIds: string[]; previousLevelTags: string[]; currentLevel: PuzzleRuntimeLevelSnapshot | null; @@ -1166,7 +1181,7 @@ interface PuzzleRunSnapshot { 先做: -1. `3x3 / 4x4` 切图 +1. `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 切图 2. 点击两块交换 3. 正确连接自动合并 4. 合并块整体拖动 @@ -1201,14 +1216,15 @@ interface PuzzleRunSnapshot { 4. 发布后的拼图作品能进入平台广场。 5. 玩家从广场进入时,第 `1` 关必定是当前作品本身。 6. 第 `2` 关及以后按照“标签相似度 `70%` + 同作者 `30%`”计算下一关。 -7. 新 run 前 `3` 关为 `3x3`,之后固定为 `4x4`。 +7. 新 run 的关卡切割和倒计时符合第 `9.3` 节配置,并且第 `11` 关起按第 `5~10` 关配置循环。 8. 运行时支持点击两块交换。 9. 交换后正确相邻的块会自动合并。 10. 合并块可以整体拖动。 11. 单块拖到合并块位置时可以拆分合并块。 12. 游戏画面能显示作者信息和关卡名。 -13. 拼图玩法没有继续错误复用 `customWorld` 或 `rpgWorld` 域命名。 -14. 新增脚本命名符合平台现有规范。 +13. 基础单块和合并块都使用圆角,基础单块图片不会露出直角。 +14. 拼图玩法没有继续错误复用 `customWorld` 或 `rpgWorld` 域命名。 +15. 新增脚本命名符合平台现有规范。 --- diff --git a/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md index 3f5b4c51..caddc4e2 100644 --- a/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md @@ -4,7 +4,7 @@ ## 0. 目标 -把“剩余陶泥币 / 总游戏时长 / 玩过作品”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。 +把“陶泥币 / 游戏时长 / 玩过”这一排信息卡,从静态数字展示升级成稳定的个人数据看板,让玩家在进入“我的”页时一眼知道自己的账号资产和游玩投入。 --- @@ -13,8 +13,8 @@ 当前三个数字来源并不统一: 1. 陶泥币来自当前存档上下文,不等于账号总资产 -2. 总游戏时长依赖当前快照,不代表全账号累计 -3. 玩过作品当前几乎是硬编码推导,不是真实统计 +2. 游戏时长依赖当前快照,不代表全账号累计 +3. 玩过当前几乎是硬编码推导,不是真实统计 这会导致“我的”页看到的数据不可信。 @@ -39,7 +39,7 @@ ## 3. 指标定义 -## 3.1 剩余陶泥币 +## 3.1 陶泥币 定义: @@ -49,7 +49,7 @@ - 当前单个存档里的临时货币数值 -## 3.2 总游戏时长 +## 3.2 游戏时长 定义: @@ -60,7 +60,7 @@ - 只累计进入有效游戏流程的时长 - 后台挂机超阈值后停止累计 -## 3.3 玩过作品 +## 3.3 玩过 定义: @@ -82,19 +82,20 @@ 1. 陶泥币卡 - 打开资产流水抽屉 -2. 总游戏时长卡 +2. 游戏时长卡 - 打开游玩统计抽屉 -3. 玩过作品卡 - - 打开玩过作品列表 +3. 玩过卡 + - 打开玩过列表 如果本期不做明细页,点击可先无动作,但必须预留可扩展事件位。 ## 4.2 展示规则 1. 数字过大时做单位缩略展示 -2. “总游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天 -3. 进入页面先展示骨架屏 -4. 数据请求失败时展示降级文案,不展示假数字 +2. “游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天 +3. “玩过”卡展示值始终带 `个` 单位,例如 `0个`、`1个`、`1.2万个` +4. 进入页面先展示骨架屏 +5. 数据请求失败时展示降级文案,不展示假数字 --- @@ -131,7 +132,7 @@ 返回: - 游玩时长分布 -- 玩过作品列表摘要 +- 玩过列表摘要 --- @@ -139,7 +140,7 @@ 1. 钱包余额从后端钱包台账聚合 2. 游戏时长从运行时会话日志或快照汇总 -3. 玩过作品数从有效游玩记录去重计算 +3. 玩过数从有效游玩记录去重计算 禁止继续采用: @@ -153,4 +154,4 @@ 2. 切换设备后看板数据一致 3. 没有存档时也能正常展示账号级数据 4. 数据加载失败时页面表现可控 -5. “总游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时` +5. “游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时` diff --git a/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md b/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md index aea275f9..a6b3303b 100644 --- a/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md +++ b/docs/technical/BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md @@ -47,7 +47,7 @@ server-rs/crates/api-server/src/prompt/big_fish.rs 同时把 `prompt/mod.rs` 补齐为正式导出入口,和现有: -1. `puzzle_image.rs` +1. `puzzle/image.rs` 2. `character_visual.rs` 3. `character_animation.rs` 4. `scene_background.rs` diff --git a/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md new file mode 100644 index 00000000..3b7bb215 --- /dev/null +++ b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md @@ -0,0 +1,79 @@ +# RPG 剧情与模板创作模型路由调整(2026-04-30) + +## 1. 背景 + +当前 `server-rs` 的文本模型主链统一通过 `platform-llm` 走 Ark OpenAI 兼容 `/chat/completions`。本轮模型切换有两个不同边界: + +1. RPG 运行时剧情推理继续使用 Ark `/chat/completions`,但模型固定为 `doubao-seed-character-251128`。 +2. 模板创作流程的大模型推理统一使用 Ark `/responses`,模型固定为 `deepseek-v3-2-251201`,并按 Responses API 的 `tools: [{ type: "web_search", max_keyword: 3 }]` 方式启用联网搜索。 + +因此本次不能只替换 `GENARRATIVE_LLM_MODEL` 默认值。默认值仍可能被通用代理或其他兼容调用使用;RPG 剧情与模板创作需要在业务请求上显式覆盖模型和协议,避免两条主链互相污染。 + +## 2. 落地范围 + +### 2.1 RPG 剧情推理 + +以下运行时 RPG 推理请求必须显式使用: + +- model: `doubao-seed-character-251128` +- protocol: `/chat/completions` + +覆盖入口: + +1. `runtime_story/compat/ai.rs` + - 首段剧情与继续剧情。 + - NPC 对话剧情文本。 + - 预留的动作结果叙事生成。 +2. `runtime_chat.rs` + - NPC 单轮聊天回复。 + - NPC 单轮后续建议。 +3. `runtime_chat_plain.rs` + - 角色私聊回复、建议、摘要。 + - NPC 对话、招募对话等纯文本流。 + +### 2.2 模板创作流程 + +以下创作链路必须显式使用: + +- model: `deepseek-v3-2-251201` +- protocol: `/responses` +- web_search: 开启时映射为 `tools: [{ "type": "web_search", "max_keyword": 3 }]` + +覆盖入口: + +1. `creation_agent_llm_turn.rs` + - RPG/自定义世界 Agent 单轮 JSON turn。 + - 大鱼吃小鱼 Agent 单轮 JSON turn。 + - 拼图 Agent 单轮 JSON turn。 + - 动态状态判断等非流式 JSON turn。 +2. `custom_world_foundation_draft.rs` + - 世界框架、角色、场景、角色详情等分阶段底稿生成。 + - JSON 修复阶段。 +3. `custom_world_agent_entities.rs` + - 结果页新增角色/地点生成。 +4. `custom_world_ai.rs` + - 结果页兜底补齐实体生成。 +5. `big_fish_draft_compiler.rs` + - 大鱼吃小鱼草稿结构化编译与 JSON 修复。 + +图片、视频、OSS、SpacetimeDB reducer 不属于本次模型切换范围。 + +## 3. 平台层改造 + +`platform-llm` 保留原 `/chat/completions` 能力,并新增 Responses 协议: + +1. `LlmTextProtocol::ChatCompletions` +2. `LlmTextProtocol::Responses` +3. `LlmTextRequest::with_responses_api()` +4. `LlmConfig::responses_url()` + +Responses 非流式解析优先读取 `output_text`,再兼容 `output[].content[].text`。Responses 流式解析只把 `response.output_text.delta` 的 `delta` 推给上层,避免把 reasoning summary、工具事件或完成事件误拼进玩家可见文本。 + +## 4. 验收标准 + +1. RPG 运行时 LLM 请求在代码层显式带 `doubao-seed-character-251128`。 +2. 创作模板 LLM 请求在代码层显式带 `deepseek-v3-2-251201` 与 Responses 协议。 +3. `platform-llm` 单测覆盖 Responses 非流式、Responses SSE、Responses web_search tools 请求体。 +4. `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml` 通过。 +5. `cargo test -p api-server creation_agent_llm_turn --manifest-path server-rs/Cargo.toml` 通过。 +6. 修改后按项目约束使用 `npm run api-server:maincloud` 重新启动后端,并执行相应自动测试。 diff --git a/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md b/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md index 88be7bd0..52e908b2 100644 --- a/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md +++ b/docs/technical/MY_TAB_PROFILE_EDIT_AND_AVATAR_CROP_2026-04-29.md @@ -23,11 +23,12 @@ 1. 点击昵称右侧编辑按钮打开独立弹窗。 2. 弹窗内只提供昵称输入、取消、保存。 -3. 前端先做长度与字符校验: +3. 弹窗面板使用平台标准不透明面板底,不复用透明轻量面板。 +4. 前端先做长度与字符校验: - `2-20` 个字符。 - 允许中文、英文、数字、下划线。 - 不允许纯空白。 -4. 保存调用 `PATCH /api/profile/me`,成功后即时回写 `AuthUiContext.user`。 +5. 保存调用 `PATCH /api/profile/me`,成功后即时回写 `AuthUiContext.user`。 ### 2.3 头像上传与裁剪 @@ -36,9 +37,10 @@ - MIME 类型仅允许 `image/jpeg`、`image/png`、`image/webp`。 - 单文件不超过 `5MB`。 3. 校验通过后读取为图片,打开裁剪弹窗。 -4. 裁剪工具使用正方形裁剪框,支持拖动裁剪区域与缩放图片。 -5. 保存时前端输出 `256x256` 的 PNG data URL,调用 `PATCH /api/profile/me` 保存为账号头像。 -6. 成功后资料卡头像立即展示新图。 +4. 裁剪弹窗面板使用平台标准不透明面板底,避免底层资料卡内容透出。 +5. 裁剪工具使用正方形裁剪框,支持拖动裁剪区域与缩放图片。 +6. 保存时前端输出 `256x256` 的 PNG data URL,调用 `PATCH /api/profile/me` 保存为账号头像。 +7. 成功后资料卡头像立即展示新图。 ## 3. 后端契约 @@ -85,6 +87,7 @@ SpacetimeDB 正式表 `user_account` 需要增加 `avatar_url: Option` 2. “我的”页陶泥号复制按钮点击后显示 `已复制`。 3. “我的”页不展示 `手机号` 与 `正常` 标签。 4. 昵称编辑成功后,资料卡与顶部账号入口同步新昵称。 -5. 非法头像文件不会进入裁剪流程。 -6. 裁剪保存成功后,资料卡头像展示裁剪后的图片。 -7. 桌面右上角账号入口与“我的”资料卡共用 `avatarUrl`,有已保存头像时展示头像图片,缺失时才回退到首字头像。 +5. 昵称与头像裁剪弹窗面板不透明,不能露出底层页面内容。 +6. 非法头像文件不会进入裁剪流程。 +7. 裁剪保存成功后,资料卡头像展示裁剪后的图片。 +8. 桌面右上角账号入口与“我的”资料卡共用 `avatarUrl`,有已保存头像时展示头像图片,缺失时才回退到首字头像。 diff --git a/docs/technical/PLATFORM_DESKTOP_HOME_MODULE_CONTENT_SYNC_2026-04-30.md b/docs/technical/PLATFORM_DESKTOP_HOME_MODULE_CONTENT_SYNC_2026-04-30.md new file mode 100644 index 00000000..abbbc3de --- /dev/null +++ b/docs/technical/PLATFORM_DESKTOP_HOME_MODULE_CONTENT_SYNC_2026-04-30.md @@ -0,0 +1,22 @@ +# 网页端首页模块内容同步移动端首页 2026-04-30 + +## 背景 + +平台首页移动端已经收口为 `推荐 / 今日游戏 / 游戏分类` 三个频道。网页端首页保留宽屏布局,但模块文案和数据语义仍残留 `趋势关注`、`最新发布`、`作品广场` 等旧入口口径,导致双端首页内容不一致。 + +## 落地规则 + +1. 网页端首页只调整模块内容和文案,不改变现有宽屏栅格、面板数量与卡片布局。 +2. `推荐` 使用移动端推荐频道同源数据:精选作品优先,并与最新公开作品去重合并。 +3. `趋势关注` 改为 `今日游戏`,数据只取今天首次发布的公开作品,不把今天更新的旧作品计入今日游戏。 +4. `最新发布` 改为 `作品分类`,数据使用当前分类组内按综合指标排序后的作品。 +5. 首页首屏和快捷区域不再展示 `作品广场` 文案。 +6. 删除首页中的 `公开作品` 兜底模块;快捷区域只在存在最近作品或最近浏览时显示,不再用空模块占位。 + +## 验收标准 + +1. 网页端首页仍保持原有 hero、右侧列表、中部双栏与底部网格布局。 +2. 网页端可见模块包含推荐、今日游戏、作品分类。 +3. 网页端首页不再出现 `趋势关注`、`最新发布`、`作品广场`。 +4. 无最近作品和最近浏览时,网页端首页不再展示 `公开作品` 快捷模块。 +5. 今日游戏与移动端 `今日游戏` 频道使用同一发布时间过滤规则。 diff --git a/docs/technical/PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md b/docs/technical/PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md new file mode 100644 index 00000000..9d963f0f --- /dev/null +++ b/docs/technical/PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md @@ -0,0 +1,98 @@ +# 拼图失败续时与存档投影设计 2026-05-01 + +## 背景 + +拼图运行时已经有倒计时失败态、道具确认扣费、下一关推荐和个人存档页,但失败后的玩家选择与拼图作品存档投影还没有闭环: + +1. 倒计时结束后只能返回,不能重新开始或付费继续。 +2. 进入拼图作品后,存档页没有稳定出现一条可恢复的拼图游戏存档。 +3. 每通过一关后,存档应该更新到下一关入口,而不是停留在旧关卡。 + +本轮只补齐拼图运行态与存档投影,不迁移旧 `server-node`,不新增平行存档页。 + +## 目标 + +1. 限定时间内未完成时弹出失败面板。 +2. 失败面板提供两个选择: + - `重新开始`:重新开启当前拼图关卡,不扣陶泥币。 + - `继续1分钟`:先弹出确认窗口,确认后消耗 `1` 陶泥币,并把当前失败关卡恢复为 `playing`,剩余时间固定为 `60000ms`。 +3. 进入拼图作品后立即写入 `profile_save_archive`,存档页显示拼图存档。 +4. 每次进入下一关后更新同一条拼图存档,使存档恢复时指向最新可继续的关卡。 + +## 运行态规则 + +### 失败续时 + +`PuzzleRuntimePropKind` 增加 `extendTime`,沿用现有道具确认与扣费接口: + +1. 前端只在 `runtimeStatus = failed` 时开放 `继续1分钟`。 +2. 点击后打开独立确认弹窗,文案只显示短标题和 `消耗 1 陶泥币`。 +3. 正式 run 继续走 `POST /api/runtime/puzzle/runs/:runId/props`。 +4. `api-server` 将 `extendTime` 映射为账单 `asset_kind = puzzle_prop_extend_time`。 +5. SpacetimeDB 侧只允许失败关卡续时;续时成功后: + - `status = playing` + - `remaining_ms = 60000` + - `elapsed_ms = None` + - `cleared_at_ms = None` + - 清空暂停与冻结生效点 + - 调整 `paused_accumulated_ms`,保证从确认成功那一刻开始完整倒计时 `60` 秒 + +本地调试 run 没有真实钱包,沿用本地道具兜底:仍弹确认窗,但不扣真实陶泥币。 + +### 重新开始 + +重新开始不复用旧失败棋盘,而是重新创建当前关卡的 run: + +1. 前端从当前 `currentLevel.profileId` 和 `currentLevel.levelId` 调用 `startPuzzleRun`。 +2. 新 run 的棋盘重新打乱、倒计时重置。 +3. 如果当前关卡来自作品内部第 N 关,必须携带 `levelId`,避免重开误回作品第 1 关。 +4. 旧失败 run 保留为历史运行记录,不在前端继续使用。 + +为支持第 3 点,`PuzzleRuntimeLevelSnapshot` 增加 `levelId: string | null`。 + +## 存档投影规则 + +复用现有 `profile_save_archive` 表,不新增拼图专属存档表。拼图存档固定规则: + +1. `world_key = puzzle:{entry_profile_id}`。 +2. `world_type = PUZZLE`。 +3. `profile_id = entry_profile_id`,保证同一个作品链只覆盖一条存档。 +4. `world_name` 使用当前可恢复关卡名。 +5. `subtitle` 使用 `第 N 关`。 +6. `summary_text` 使用可恢复关卡状态: + - playing:`拼图进行中` + - failed:`关卡失败` + - cleared:`关卡已完成` +7. `cover_image_src` 使用可恢复关卡正式图。 +8. `game_state_json` 保存最小拼图恢复载荷: + - `runtimeKind = "puzzle"` + - `runId` + - `entryProfileId` + - `currentProfileId` + - `currentLevelIndex` + - `currentLevelId` + - `status` + +通关存档投影有一个额外规则:如果当前关卡已通关,并且 `refresh_next_level_handoff` 已经确认同作品存在下一关,则存档立即投影到同作品下一关入口,`status` 写为 `playing`,`subtitle / world_name / cover_image_src / currentLevelId` 都使用下一关。若当前作品没有下一关、只存在相似作品候选,存档保持当前已通关关卡,等待玩家在结算弹窗里选择相似作品,不能提前替玩家切换到某个候选作品。 + +## 写入时机 + +SpacetimeDB 拼图运行态每次持久化 run 时同步刷新存档: + +1. `start_puzzle_run`:创建 run 后立即写入拼图存档。 +2. `advance_puzzle_next_level`:进入下一关后更新同一条存档。 +3. `use_puzzle_runtime_prop(extendTime)`:续时成功后更新状态。 +4. `get_puzzle_run` 导致失败态落库时,也同步更新为失败存档。 +5. `submit_puzzle_leaderboard_entry`:正式 run 提交成绩并把当前关标记为已通关时,先刷新下一关 handoff,再按上面的通关投影规则同步存档。 + +前端在 `startPuzzleRun / usePuzzleProp / submitPuzzleLeaderboard / advancePuzzleLevel / getPuzzleRun` 成功后主动刷新存档列表,避免存档页停留在进入作品前或上一关的旧投影。 + +## 验收 + +1. 倒计时归零后失败弹窗有 `重新开始` 和 `继续1分钟`。 +2. 点击 `继续1分钟` 后先出现扣费确认,确认成功后失败弹窗关闭并恢复 `60` 秒倒计时。 +3. 陶泥币余额不足时确认弹窗保留,并展示错误。 +4. 点击 `重新开始` 后当前关卡重新打乱并重置倒计时。 +5. 进入拼图作品后,存档页出现 `worldType = PUZZLE` 的拼图存档。 +6. 通过一关后,只要后端确认同作品下一关存在,同一条存档立即更新到新关卡;没有同作品下一关时保留已完成关卡,等待玩家选择相似作品。 +7. 定向前端测试、Rust 拼图模块测试与编码检查通过。 diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index f3a5aab1..7504983e 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -2,25 +2,41 @@ ## 背景 -拼图创作入口不再使用 Agent 对话收集题材锚点。新流程只让玩家填写两个字段:拼图标题、画面描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。 +拼图创作入口不再使用 Agent 对话收集题材锚点。新流程让玩家填写作品名称、作品描述、画面描述三类信息,其中画面描述只服务首关画面生成与关卡画面语义,不再作为作品详情页的作品描述。画面描述支持上传参考图。玩家确认后直接进入草稿生成进度页,后续草稿生成、首图生成、正式图选择、结果页编辑和发布沿用现有后端编排。 ## 入口表单 -1. 拼图标题为必填字段,保存到 `seedText`,同时作为 `levelName` 的优先来源。 -2. 画面描述为必填字段,保存到 `pictureDescription`,同时作为 `summary` 和首图生成 prompt 的优先来源;支持多行文本,后端解析不得截断首行之后的内容。 -3. 参考图为可选字段,保存到 `referenceImageSrc`。表单支持本地图片上传为 Data URL;草稿首图生成时直接传入现有拼图图生图接口。 -4. 表单确认后前端先创建拼图 session,再立即执行 `compile_puzzle_draft`,并传入 `promptText = pictureDescription`、`referenceImageSrc`。 -5. 表单提交 payload 需要在前端创作流程中暂存,生成进度页失败重试时必须继续携带同一份画面描述与参考图。 -6. 入口不再展示拼图 Agent 聊天气泡、快捷补齐或多锚点卡片;新建拼图时必须清空旧 session,只有从当前生成进度页返回表单时保留本轮内容。 +### 2026-04-30 初始表单草稿保存补充 + +1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。 +2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。 +3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。参考图只保存在当前前端会话内,不落入 SpacetimeDB。 +4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。 +5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗陶泥币,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。 +6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。 +7. 若 Maincloud 仍运行旧 wasm,缺少 `save_puzzle_form_draft` procedure,前端提交生成或生成失败页重试时不得继续复用空 `seedText` 的表单 session,必须用当前表单 payload 新建带真实 seed 的 session 再执行 `compile_puzzle_draft`。 +8. api-server 也要兼容旧 wasm:`save_puzzle_form_draft` 缺失时,自动保存 action 降级返回当前 session;`compile_puzzle_draft` 前置保存缺失且当前 session 为空 seed 时,创建一条带表单 seed 的替代 session 后继续编译,避免再次暴露 `No such procedure`。 +9. 正式修复仍是发布最新 SpacetimeDB wasm。当前 Maincloud `xushi-p4wfr` 的迁移操作员表为空,但旧库引导密钥来自旧 wasm,本次临时生成的新引导密钥无法授权导出迁移,需使用已有迁移操作员 token 或数据库 owner 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。 + +1. 作品名称为必填字段,保存到 `workTitle`,兼容写入旧 `seedText`,同时作为作品级 `workTitle` 的真相源。 +2. 作品描述为必填字段,保存到 `workDescription`,作为作品详情页、作品列表和发布资料中的 `summary` 真相源。 +3. 画面描述为必填字段,保存到 `pictureDescription`,只作为首关画面生成 prompt、首关 `pictureDescription` 和关卡命名输入,不再覆盖作品描述。 +4. 参考图为可选字段,保存到 `referenceImageSrc`。表单支持本地图片上传为 Data URL;草稿首图生成时直接传入现有拼图图生图接口。 +5. 表单确认后前端先创建拼图 session,再立即执行 `compile_puzzle_draft`,并传入 `promptText = pictureDescription`、`referenceImageSrc`。 +6. 表单提交 payload 需要在前端创作流程中暂存,生成进度页失败重试时必须继续携带同一份作品名称、作品描述、画面描述与参考图。 +7. 生成进度页的“当前拼图信息”必须优先读取这份表单 payload,而不是读取 session 中旧 Agent 锚点或编译后的关卡名,避免用户确认后看到的标题、作品描述、画面描述发生漂移。 +8. `compile_puzzle_draft` action 必须显式携带 `workTitle`、`workDescription`、`pictureDescription`、`promptText` 与 `referenceImageSrc`。`seedText` 只作为 SpacetimeDB 旧表结构兼容载体,不能成为前端生成页展示和失败重试的唯一来源。 +9. 入口不再展示拼图 Agent 聊天气泡、快捷补齐或多锚点卡片;新建拼图时必须清空旧 session,只有从当前生成进度页返回表单时保留本轮内容。 ## 锚点映射 -拼图模式锚点收口为两个玩家输入源: +拼图模式锚点收口为三个玩家输入源: | 新字段 | 落地字段 | 说明 | | --- | --- | --- | -| 拼图标题 | `themePromise.value`、`levelName`、`creatorIntent.themePromise` | 作为题材承诺与关卡名称的真相源 | -| 画面描述 | `visualSubject.value`、`summary`、首图 `promptText` | 作为画面主体与生图 prompt 的真相源 | +| 作品名称 | `themePromise.value`、`workTitle`、旧 `levelName` 兼容字段、`creatorIntent.themePromise` | 作为作品名称与题材承诺真相源 | +| 作品描述 | `workDescription`、旧 `summary` 兼容字段 | 作为作品详情页描述、列表描述和发布描述真相源 | +| 画面描述 | `visualSubject.value`、`levels[0].pictureDescription`、首图 `promptText` | 作为首关画面主体、首图生成 prompt 和首关关卡命名输入 | 兼容旧结构时仍保留 `visualMood`、`compositionHooks`、`tagsAndForbidden` 字段,但它们不再由 Agent 问答收集: @@ -28,31 +44,66 @@ 2. `compositionHooks` 固定标记为系统推断,值为“主体轮廓、色块分区、局部细节”。 3. `tagsAndForbidden` 根据拼图标题和画面描述生成 3 到 6 个题材标签;禁忌只保留通用图像约束,不写入 UI。 -生成进度页的“当前拼图信息”只展示玩家输入锚点:拼图标题、画面描述。题材标签仅作为草稿结果页内容展示,不在进度页混入旧五锚点结构。 +生成进度页的“当前拼图信息”只展示玩家输入锚点:作品名称、作品描述、画面描述。题材标签仅作为草稿结果页内容展示,不在进度页混入旧五锚点结构。 + +## 草稿数据结构 + +拼图草稿从单关卡字段升级为作品级信息与关卡列表并存: + +1. `PuzzleResultDraft.workTitle`:作品名称,旧 `levelName` 只作为兼容字段同步为当前主关卡名称或作品名称。 +2. `PuzzleResultDraft.workDescription`:作品描述,旧 `summary` 只作为兼容字段同步为作品描述。 +3. `PuzzleResultDraft.themeTags`:作品标签,仍限制 3 到 6 个。 +4. `PuzzleResultDraft.levels[]`:关卡列表。每个关卡包含 `levelId`、`levelName`、`pictureDescription`、`candidates`、`selectedCandidateId`、`coverImageSrc`、`coverAssetId`、`generationStatus`。 +5. 首次草稿生成时必须创建一个默认关卡,`levelId = puzzle-level-1`,`pictureDescription = 表单画面描述`,草稿设置阶段 `levelName` 为空;首图生成后可由后端根据画面描述和图片语义生成关卡名称并写入该关卡。 +6. 关卡名称由后端基于画面描述和图片语义输入生成;无可用语义时按题材标签与序号兜底,禁止继续直接使用作品名称作为关卡名称。 +7. 旧草稿或旧作品缺少 `levels` 时,读取层必须由旧 `levelName`、`summary`、`coverImageSrc`、`candidates` 补出一个兼容关卡,避免历史草稿无法打开。 ## 后端编译 -1. `CreatePuzzleAgentSessionRequest` 新增 `pictureDescription` 与 `referenceImageSrc`,但不改 SpacetimeDB 表结构。 -2. api-server 创建 session 时把标题和画面描述合成 `seedText` 传入 SpacetimeDB;SpacetimeDB reducer 只做确定性锚点生成,不接触图片或外部服务。 -3. `compile_puzzle_draft_with_initial_cover` 新增首图 prompt 和参考图参数。若前端传入画面描述,则首图生成直接使用这段文本;若传入参考图,则走现有 DashScope 图生图链路。 -4. 图片生成仍在 api-server 内完成,遵守 SpacetimeDB reducer 不做网络 I/O 的约束。 +1. `CreatePuzzleAgentSessionRequest` 新增 `workTitle`、`workDescription`、`pictureDescription` 与 `referenceImageSrc`,但不改 SpacetimeDB 表结构。 +2. api-server 创建 session 时把作品名称、作品描述和画面描述合成 `seedText` 传入 SpacetimeDB;SpacetimeDB reducer 只做确定性锚点生成,不接触图片或外部服务。 +3. `compile_puzzle_draft_with_initial_cover` 新增首图 prompt 和参考图参数。若前端传入画面描述,则首图生成直接使用这段文本;若传入参考图,则走现有 DashScope 图生图链路;生成结果写入默认第一关。 +4. 首图文生图 prompt 由 api-server 拼接固定拼图约束后统一压缩到 `500` 字符以内,避免玩家长画面描述触发 DashScope 参数非法;进度页和结果页仍展示玩家原始画面描述,不展示压缩后的内部 prompt。 +5. 图片生成仍在 api-server 内完成,遵守 SpacetimeDB reducer 不做网络 I/O 的约束。 +6. 参考图以 Data URL 进入 `POST /api/runtime/puzzle/agent/sessions` 和 `POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions`,这两条路由必须单独放宽 JSON body 上限;不要放大全局默认 body limit。 +7. 前端仍应优先压缩参考图;后端 body 上限只用于容纳合理尺寸的单张参考图,超大原图不应直接落入 SpacetimeDB 或作为作品字段持久化。 +8. 作品更新接口 `PUT /api/runtime/puzzle/works/{profileId}` 必须支持作品信息和关卡列表一起写入,前端自动保存不得只写旧单关字段。 +9. `StartPuzzleRunRequest` 新增可选 `levelId`。详情页或草稿结果页单独体验某关时传入目标关卡,后端从作品/草稿的 `levels` 中选取该关卡生成运行态。 +10. `ExecutePuzzleAgentActionRequest` 必须保留 `pictureDescription` 字段。表单直达生成时,`compile_puzzle_draft` 优先用 `pictureDescription` 作为首图 prompt,再回退到旧 `promptText`;避免生成页展示的是玩家画面描述,但后端实际用作品名称或旧摘要出图。 +11. `compile_puzzle_draft` 中的图片上游失败不得映射成 `400 BAD_REQUEST`。DashScope 返回 `InvalidParameter` 或任务失败时,api-server 统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留“拼图图片生成失败:...”的业务原因,避免生成页只显示“请求参数不合法”。 +12. `compile_puzzle_draft` 前置陶泥币预扣失败不得映射成 `400 BAD_REQUEST`。余额不足返回 `409 CONFLICT`,SpacetimeDB procedure 不可用、绑定不匹配、钱包服务异常等统一按 `502 UPSTREAM_ERROR` 暴露,并在 `details.message` 中保留真实钱包错误。 +13. 生成拼图作品草稿动作涉及的表单 seed prompt 与首图 prompt 来源选择统一收口在 `server-rs/crates/api-server/src/prompt/puzzle/draft.rs`;`puzzle.rs` 只负责调用 SpacetimeDB、计费、图片服务和持久化,不再直接拼草稿 prompt 文本。 ## 结果页 -拼图草稿结果页不再区分 Tab,合并为一个可滚动列表页,内容顺序固定为: +拼图草稿结果页分为两个 Tab: -1. 关卡名称。 -2. 画面预览。 -3. 画面描述。 -4. 重新生成画面按钮。 -5. 题材标签。 +1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。 +2. 作品信息:展示并编辑作品名称、作品描述、作品标签。 -画面描述区域不再展示候选图实际 prompt 或“请生成一张适合……”之类内部提示词模块。参考图入口保留在画面描述编辑区域内,便于重新生成时继续带入。结果页编辑画面描述时必须同步更新 `summary`,确保自动保存、作品测试、发布和重新生成画面使用同一份描述。 +### 2026-04-30 关卡列表卡片交互补充 + +1. 关卡列表卡片的删除按钮与关卡名称放在同一信息行,按钮固定在卡片右下角;不得再单独占用一整条底部分隔栏。 +2. 关卡图片、序号与名称区域仍作为打开关卡详情的主点击区;删除按钮只触发删除,不进入详情。 + +### 2026-04-30 关卡详情面板交互补充 + +1. 关卡详情面板内容区按移动端优先的单列顺序展示:`关卡名称 -> 画面图 -> 画面描述`。其中画面图只在该关卡已有正式图时出现;新建关卡或画面为空的关卡不展示空图占位模块。 +2. 画面生成主按钮固定吸底,始终位于关卡详情面板底部操作区。若当前关卡还没有正式图,按钮文案为“生成画面”;已有正式图后,按钮文案为“重新生成画面”。 +3. 关卡已有正式图后,底部操作区在生成按钮上方新增单独的关卡测试入口,原“体验该关”文案收口为“关卡测试”。无正式图时不展示该入口。 +4. 底部吸底操作区只承载动作按钮,不默认写玩法说明或规则解释,避免压缩移动端编辑空间。 +5. 关卡详情面板内触发生成画面时,前端必须把当前编辑态完整 `levelsJson` 随 `generate_puzzle_images` action 一起提交。这样新建关卡在自动保存完成前立即生成,也能由后端写回目标关卡。 +6. api-server 处理 `generate_puzzle_images` 时,若 action 带有 `levelsJson`,必须用这份关卡快照覆盖本次生成的草稿关卡视图后再定位 `levelId`。若请求明确传入 `levelId` 但关卡列表中不存在该关卡,必须返回错误,不得静默回退第一关。 +7. 历史拼图素材入口只在已有正式图的 `画面图` 区域右下角展示,不再放在 `画面描述` 输入区;本地上传参考图入口仍保留在画面描述输入区右下角。 +8. 历史拼图素材列表必须由服务端按当前登录账号过滤,只返回 `asset_kind = puzzle_cover_image` 且 `owner_user_id = 当前账号` 的资产;不得依赖前端过滤,也不得展示其他账号素材。 + +画面描述区域不再展示候选图实际 prompt 或“请生成一张适合……”之类内部提示词模块。参考图入口保留在画面描述编辑区域内,便于重新生成时继续带入。结果页编辑关卡画面描述时只同步该关卡 `pictureDescription`;作品描述只在作品信息 Tab 编辑,作品详情页不得再回退使用画面描述。 ## 验收 -1. 从拼图创作入口只能看到标题、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。 +1. 从拼图创作入口只能看到作品名称、作品描述、画面描述和参考图上传,不出现 Agent 聊天输入、补齐设定、锚点问答。 2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。 -3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图。 -4. 结果页为单列表,顺序符合上文要求,不展示 Tab 和内部实际 prompt。 -5. 发布、作品测试、自动保存标题、画面描述和标签仍可用。 +3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。 +4. 结果页包含“拼图关卡”和“作品信息”两个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。 +5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。 +6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md index deaa95c0..4f9846c2 100644 --- a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md +++ b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md @@ -16,8 +16,12 @@ 1. 拼图生成图固定使用 `1024*1024`。 2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成竖屏或横版图。 -3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。 -4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 +3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。 +4. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免 DashScope 旧 text2image 协议把超长 prompt 判为“请求参数不合法”。 +5. DashScope 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。 +6. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 +7. 拼图文生图请求体按 DashScope Wan text2image 协议收口:`input` 放 `prompt` 与非空 `negative_prompt`,`parameters` 放 `n`、`size`、`prompt_extend`、`watermark`。不要在 `input` 与 `parameters` 里重复写入反向提示词,否则上游容易返回参数非法。 +8. 陶泥币预扣失败属于钱包或 SpacetimeDB 服务链路错误,不得映射成 `400 BAD_REQUEST`。除余额不足返回 `409 CONFLICT` 外,其余预扣异常统一按上游/服务错误暴露,避免生成页误提示“请求参数不合法”。 ### 2. 前端规则裁决 @@ -33,10 +37,19 @@ 3. 单格不设置固定最小高度,避免移动端被单格撑破。 4. 顶部 HUD 与底部道具仍保留安全区,不能遮挡棋盘可操作区域。 +### 4. 拼块视觉圆角 + +1. 基础单块和合并块都必须使用圆角,不能只让合并后的外轮廓有圆角。 +2. 基础单块的图片层必须跟随单块容器裁剪,避免图片直角从圆角边框里露出。 +3. 合并块继续按实际拼块外轮廓描边,内部相邻边不额外显示边框。 + ## 验收 1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `1024*1024`。 2. 图片提示词包含 `1:1 正方形拼图关卡`。 -3. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。 -4. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。 -5. 下一关、道具、排行榜仍走现有后端链路,不把外部 I/O 或扣费逻辑塞回前端。 +3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、`避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。 +4. DashScope 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus` 和 `rawExcerpt`。 +5. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。 +6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。 +7. 基础单块和合并块都能看到圆角,且基础单块图片不会溢出圆角裁剪。 +8. 下一关、道具、排行榜仍走现有后端链路,不把外部 I/O 或扣费逻辑塞回前端。 diff --git a/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md b/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md index 54e8a675..cc612ec4 100644 --- a/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md +++ b/docs/technical/PUZZLE_IMAGE_PROMPT_MODULE_EXTRACTION_2026-04-27.md @@ -6,7 +6,7 @@ ## 本轮落地边界 -1. 拼图图片提示词统一放到 `server-rs/crates/api-server/src/prompt/puzzle_image.rs`。 +1. 拼图图片提示词统一放到 `server-rs/crates/api-server/src/prompt/puzzle/image.rs`。 2. `puzzle.rs` 只负责读取提示词构建结果,并继续处理 DashScope、OSS、SpacetimeDB 写回。 3. 提示词模块只暴露: - `build_puzzle_image_prompt(level_name, prompt)` @@ -18,7 +18,7 @@ 1. 不把图片生成逻辑下沉到 SpacetimeDB reducer;外部 I/O 必须留在 `api-server`。 2. 不改候选图 JSON 持久化结构,仍使用 `module-puzzle::PuzzleGeneratedImageCandidate` 对应的 snake_case 字段。 3. 不改前端 UI 文案和交互;本轮只拆后端提示词脚本。 -4. 后续若调整拼图图片风格、尺寸、禁止元素或切块可读性要求,优先修改 `prompt/puzzle_image.rs`,再按需补测试。 +4. 后续若调整拼图图片风格、尺寸、禁止元素或切块可读性要求,优先修改 `prompt/puzzle/image.rs`,再按需补测试。 ## 验收 diff --git a/docs/technical/PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md b/docs/technical/PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md new file mode 100644 index 00000000..e568476b --- /dev/null +++ b/docs/technical/PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md @@ -0,0 +1,37 @@ +# 拼图排行榜前端关卡提交与 RPG 敬请期待 2026-04-30 + +## 背景 + +拼图运行态的交换、拖动、合并、拆分与本关通关判定已经交给前端即时计算。第二关通过后端 `local-next-level` 兼容接口生成下一关时,前端当前关卡已经推进到新作品,但 SpacetimeDB 中的旧 run 快照可能仍停留在上一关。 + +因此第二关通关后提交排行榜,如果后端继续要求 `run.currentLevel.profileId == payload.profileId`,会误报: + +```text +提交成绩的拼图作品与当前关卡不匹配 +``` + +本轮同时把 RPG 创作入口设置为“敬请期待”,只调整平台入口展示与分流防线,不删除 RPG 既有代码、历史作品详情或运行时兼容能力。 + +## 落地口径 + +### 1. 拼图排行榜 + +1. 排行榜提交仍必须校验 run 归属,避免跨用户提交。 +2. 排行榜写入以本次提交的 `profileId`、`gridSize`、`elapsedMs` 为准。 +3. 当 SpacetimeDB 旧 run 的当前关卡与提交关卡一致时,后端可以把真实榜单合并回服务端关卡快照。 +4. 当旧 run 当前关卡与提交关卡不一致时,不再报错;后端只写入真实榜单,并把榜单放到 run 顶层 `leaderboardEntries` 返回给前端。 +5. 前端继续用当前本地 run 合并后端返回的 `leaderboardEntries`,不能用服务端旧棋盘覆盖本地第二关棋盘。 + +### 2. RPG 敬请期待 + +1. 平台创作类型元数据中,`rpg` 改为 `locked: true`。 +2. RPG 创作卡片 badge 与副标题统一显示 `敬请期待`。 +3. 创作类型弹窗与创作首页卡带复用同一份元数据,因此入口自动禁用。 +4. 分流函数中继续防御 `rpg` 类型直达触发,避免旧测试或隐藏入口绕过禁用态。 + +## 验收 + +1. 第二关拼图通关后提交排行榜,不再出现“提交成绩的拼图作品与当前关卡不匹配”。 +2. 排行榜返回后,前端仍保留当前第二关棋盘与通关状态。 +3. 创作页 RPG 卡片显示 `敬请期待` 且不可点击。 +4. 拼图、大鱼和其它已锁定玩法的显示状态不被本轮改动影响。 diff --git a/docs/technical/PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md b/docs/technical/PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md new file mode 100644 index 00000000..8cf7e366 --- /dev/null +++ b/docs/technical/PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md @@ -0,0 +1,75 @@ +# 拼图下一关与相似作品接续设计 2026-04-30 + +## 背景 + +拼图通关结算弹窗已有“下一关”按钮,但当前按钮依赖 `recommendedNextProfileId`。这会带来两个问题: + +1. 当前作品还有未玩的内部关卡时,按钮可能因为没有跨作品推荐而被禁用。 +2. 当前作品全部关卡玩完后,只返回单个推荐作品,无法满足“三个相似作品由用户选择”的交互。 + +本轮只修复拼图运行态接续链路,不迁移旧 `server-node`,不在前端计算正式相似度。 + +## 目标 + +1. 通关后默认点击“下一关”,优先加载当前拼图作品的下一关。 +2. 当前作品没有下一关时,后端按标签语义相似度选出相似度最高的三个已发布作品。 +3. 用户在通关弹窗里点击候选作品后,进入该作品并从第 `1` 关重新开始。 +4. 移动端优先,候选卡片要紧凑,不写玩法说明类文案。 + +## 数据契约 + +`PuzzleRunSnapshot` 增加: + +1. `nextLevelMode: "sameWork" | "similarWorks" | "none"`。 +2. `nextLevelProfileId: string | null`:同作品下一关或跨作品推荐的默认目标。 +3. `nextLevelId: string | null`:同作品下一关的 `levelId`;跨作品时为 `null`。 +4. `recommendedNextWorks: PuzzleRecommendedNextWork[]`:跨作品候选,最多 3 个。 + +`PuzzleRecommendedNextWork` 字段: + +1. `profileId` +2. `levelName` +3. `authorDisplayName` +4. `themeTags` +5. `coverImageSrc` +6. `similarityScore` + +保留 `recommendedNextProfileId` 作为旧字段兼容,但前端新逻辑不再只依赖它。 + +## 后端规则 + +1. SpacetimeDB 侧在 `start / get / swap / drag / leaderboard / advance` 后刷新下一关状态。 +2. 当前作品存在未玩的下一张关卡图时: + - `nextLevelMode = "sameWork"` + - `nextLevelProfileId = 当前作品 profileId` + - `nextLevelId = 下一关 levelId` + - `recommendedNextWorks = []` +3. 当前作品没有内部下一关时: + - 使用拼图现有 `recommendation_score = tagSimilarity * 0.7 + sameAuthor * 0.3` + - `tagSimilarity` 优先复用 RPG/build 标签语义亲和度模型;两侧标签未命中该语义模型时,回退到规范化标签 Jaccard + - 排除当前 run 已玩过的作品;若池子为空,允许回收但不连续重复上一关作品 + - 返回最高的 3 个候选 +4. `advance_puzzle_next_level`: + - `nextLevelMode = sameWork` 时加载当前作品的下一关,并继续当前 run。 + - `nextLevelMode = similarWorks` 时默认加载候选第一项,并把 `entryProfileId / clearedLevelCount / currentLevelIndex` 重置到目标作品第 `1` 关。 +5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时返回 `similarWorks` 候选并保持当前通关 run,只有候选池为空时才进入旧草稿兜底。 + +## 前端规则 + +1. 结算弹窗: + - `sameWork`:主按钮显示“下一关”,直接触发默认推进。 + - `similarWorks`:展示最多 3 个作品候选卡;用户点击卡片进入候选作品。 + - `none`:禁用下一关入口。 +2. 底部通关后入口: + - `sameWork` 保留“下一关”。 + - `similarWorks` 显示“换个作品”,点击后打开结算弹窗供选择。 +3. 所有正式相似度计算只信任后端返回,不在 UI 里重新算。 +4. 本地/草稿 run 通关提交本地排行榜后,会异步调用 `local-next-level` 刷新 handoff;若拿到 `similarWorks`,只合并候选字段,不把已通关弹窗改成新的 playing 关卡。 + +## 验收 + +1. 当前作品有下一关时,点击“下一关”进入当前作品下一关。 +2. 当前作品没有下一关时,通关弹窗显示最多 3 个相似作品。 +3. 点击相似作品后进入该作品第 `1` 关,HUD 关卡序号、切割规格和倒计时都按第 `1` 关显示。 +4. 旧 `recommendedNextProfileId` 为空时,只要 `nextLevelMode = sameWork`,按钮仍可用。 +5. 拼图 runtime 单测、Rust 拼图模块测试和编码检查通过。 diff --git a/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md b/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md index 7fa465b9..7d7c4eb8 100644 --- a/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md +++ b/docs/technical/PUZZLE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md @@ -41,6 +41,15 @@ 2. 单块交换、拖到合并块后拆分、合并块整体重排,继续沿用当前本地运行态规则。 3. 不新增前端本地裁决,不把玩法真相从既有运行态实现中分叉出去。 +### 3.4 点击触觉反馈 + +移动端用户每次按下可交互拼图片时,需要触发一次短促手机震动: + +1. 震动触发点放在 `pointerdown`,让点击选中、按住准备拖动与拖起都有一致手感。 +2. 同一次按下会话只触发一次震动,后续连续移动不重复震动。 +3. 使用浏览器标准 `navigator.vibrate([12])`,不支持震动能力的设备静默跳过。 +4. 该反馈只属于前端表现层,不影响拖拽落点、交换、合并、拆分与通关判定。 + ## 4. 验收标准 1. 单块拖动时拼块视觉位置应紧跟手指或鼠标,不再出现明显缓动拖尾。 @@ -48,3 +57,4 @@ 3. 点击选中与拖动阈值判定仍保持原语义,不因为优化误触发交换。 4. 运行时现有结算弹窗、排行榜和下一关入口不受影响。 5. 定向测试覆盖拖动提交坐标的行为,并运行编码检查确保中文文档未被写坏。 +6. 移动端点击拼图片时立即触发一次短震,同一次按下后的连续移动不重复触发。 diff --git a/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md index 5b8d06fc..acc5e584 100644 --- a/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md +++ b/docs/technical/PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md @@ -24,12 +24,28 @@ ## 难度限时 -第一版按网格规模定义限时: +拼图关卡切割规格和倒计时由统一关卡配置函数解析,不再按网格规模单独推导时间: -1. `3x3`:`180000ms`。 -2. `4x4`:`300000ms`。 +| 关卡 | 切割规格 | 限时 | +| -------- | -------- | ---------- | +| 第 1 关 | `3x3` | `300000ms` | +| 第 2 关 | `4x4` | `300000ms` | +| 第 3 关 | `5x5` | `300000ms` | +| 第 4 关 | `5x5` | `210000ms` | +| 第 5 关 | `5x5` | `210000ms` | +| 第 6 关 | `6x6` | `240000ms` | +| 第 7 关 | `5x5` | `210000ms` | +| 第 8 关 | `7x7` | `270000ms` | +| 第 9 关 | `5x5` | `240000ms` | +| 第 10 关 | `7x7` | `270000ms` | -后续若扩展更多难度,只能通过同一个难度解析函数扩展,不允许在 UI 里写死另一套时间。 +第 11 关开始,每 6 关循环复用第 5 关到第 10 关的配置,即 `5x5/210000ms`、`6x6/240000ms`、`5x5/210000ms`、`7x7/270000ms`、`5x5/240000ms`、`7x7/270000ms`。 + +同作品下一关必须使用同一个运行时关卡序号继续推进。跨作品相似推荐代表进入新作品,必须从目标作品第 `1` 关重新开始。 + +失败状态点击“重新开始”时,不进入作品第 `1` 关,而是重开当前失败关卡:前端需要传当前关 `levelId`,服务端按该 `levelId` 在作品内的位置恢复 `currentLevelIndex`、切割规格和倒计时。 + +后续若扩展更多难度,只能通过同一个关卡配置解析函数扩展,不允许在 UI 里写死另一套时间。 ## 计时规则 diff --git a/docs/technical/PUZZLE_WORK_POINT_INCENTIVE_2026-05-01.md b/docs/technical/PUZZLE_WORK_POINT_INCENTIVE_2026-05-01.md new file mode 100644 index 00000000..3c753717 --- /dev/null +++ b/docs/technical/PUZZLE_WORK_POINT_INCENTIVE_2026-05-01.md @@ -0,0 +1,60 @@ +# 拼图作品积分激励链路设计 + +更新时间:`2026-05-01` + +## 1. 目标 + +1. 拼图草稿页“新增关卡”按钮下方显示一行小字:“获得更多积分激励”。 +2. 创作页的已发布拼图作品卡展示当前作品的积分激励总数、待领取积分数和领取按钮。 +3. 用户在他人已发布拼图作品中消耗陶泥币时,作品作者获得消耗陶泥币数量的一半作为积分激励。 +4. 作者领取时只能领取整数个陶泥币,待领取值向下取整;未满 1 个陶泥币的半数余额继续保留。 + +## 2. 数据模型 + +拼图作品激励归属到 `puzzle_work_profile`。 + +1. `point_incentive_total_half_points: u64` + - 记录该作品累计获得的激励,单位为“半个陶泥币”。 + - 每消耗 `N` 个陶泥币,增加 `N` 个 half points;当前拼图道具每次消耗 1 个陶泥币,因此每次为作者增加 0.5。 +2. `point_incentive_claimed_points: u64` + - 记录作者已领取的整数陶泥币数量。 +3. 前端展示: + - 激励总数 = `pointIncentiveTotalHalfPoints / 2`,允许展示一位小数。 + - 待领取积分 = `floor(pointIncentiveTotalHalfPoints / 2) - pointIncentiveClaimedPoints`。 + - 领取按钮仅在待领取积分大于 0 时可用。 + +## 3. 后端事务 + +1. 拼图运行道具扣费成功、道具效果成功落库后,后端根据 run 的当前作品 `profile_id` 查找作者。 +2. 若使用者不是作品作者,则给该作品累积 `consumed_points` 个 half points。 +3. 若使用者是作者本人,视为作者自测,不产生积分激励。 +4. 若后续业务操作失败并触发扣费退款,不写入激励。 +5. 领取接口: + - 只允许作品作者领取。 + - 计算可领取整数 `claimable = total_half_points / 2 - claimed_points`。 + - `claimable <= 0` 时拒绝领取。 + - 同一事务内更新作品 `claimed_points += claimable`,并向作者钱包增加 `claimable` 陶泥币,钱包流水来源使用 `puzzle_author_incentive_claim`。 + +## 4. API 与前端 + +1. `PuzzleWorkSummary` / `PuzzleWorkProfile` 增加: + - `pointIncentiveTotalHalfPoints` + - `pointIncentiveClaimedPoints` + - `pointIncentiveTotalPoints` + - `pointIncentiveClaimablePoints` +2. 新增领取接口: + - `POST /api/runtime/puzzle/works/{profile_id}/point-incentive/claim` + - 返回更新后的 `PuzzleWorkProfile`。 +3. 创作页仅对已发布拼图作品显示积分激励块;RPG、大鱼和草稿卡不显示。 +4. 领取成功后刷新对应拼图作品列表状态,按钮立即禁用或显示新的待领取数,并同步刷新个人钱包看板。 +5. `spacetime-client` 映射层继续兼容历史拼图运行快照:旧 `run_json` 若缺少 `started_at_ms`,API 记录回填为非 0 值,避免前端计时器拿到无效开始时间。 + +## 5. 验收点 + +1. 拼图草稿页新增关卡按钮下方显示“获得更多积分激励”。 +2. 已发布拼图作品卡展示“积分激励总数”和“待领取”两个数值。 +3. 待领取积分为 0 时领取按钮禁用。 +4. 非作者游玩他人拼图并使用付费道具后,该作品累计 half points 增加。 +5. 作者领取后钱包增加向下取整后的整数陶泥币,作品待领取数归零或保留不足 1 的小数余额。 +6. 领取成功后顶部/我的页钱包余额随个人看板刷新。 +7. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 diff --git a/docs/technical/README.md b/docs/technical/README.md index bd1fa939..ff86b070 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,8 @@ ## 文档列表 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 +- [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 +- [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。 - [PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md](./PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md):冻结邀请码从“我的 Tab 填写”迁到注册环节的前后端边界、`profile_invite_code.metadata_json` 表结构扩展、管理员邀请码虚拟主体和奖励规则。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 - [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts,以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。 @@ -23,7 +25,14 @@ - [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。 - [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。 - [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。 +- [PUZZLE_FORM_CREATION_FLOW_2026-04-29.md](./PUZZLE_FORM_CREATION_FLOW_2026-04-29.md):冻结拼图填表式创作入口、初始表单自动保存草稿、生成前退出后的表单恢复,以及草稿编译/首图生成的前后端边界。 +- [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。 +- [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。 +- [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。 +- [PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md](./PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md):记录拼图关卡切割、倒计时、失败态和三个运行时道具的统一规则;2026-05-01 起关卡切割与限时按第 1-10 关配置,并从第 11 关按第 5-10 关六关循环。 +- [RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md](./RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md):记录编辑器幕预览卡在“正在载入这一幕”时的启动态根因,收口预览本地运行态装配与禁持久化首段 story 注入。 - [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。 +- [WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md](./WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md):记录作品作者以 `owner_user_id` 为真相源,API 按用户 ID 解析最新昵称与公开用户码,历史 `author_display_name` 仅作为兼容回退。 - [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。 - [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse` 真流式输出的后端落地口径。 - [SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md](./SPACETIMEDB_START_SH_ROOT_OWNER_FALSE_POSITIVE_FIX_2026-04-27.md):记录发布包 `start.sh` root-dir 占用检测把 `grep -F .../.spacetimedb` 误判为 SpacetimeDB 实例的根因、脚本修复和现场处理方式。 diff --git a/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md index ddb9277d..0def3346 100644 --- a/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md +++ b/docs/technical/ROUTE_IMAGE_READY_GATE_2026-04-25.md @@ -15,11 +15,13 @@ ## 体验规则 - 等待态继续复用 `RouteLoadingScreen`,只显示简短加载文案,不在 UI 中追加规则说明。 +- `RouteLoadingScreen` 必须读取 `tavernrealms.settings.v1` 中的 `platformTheme`,并复用 `platform-theme--light / platform-theme--dark` 与 `platform-body-fill / platform-text-*` token,不能硬编码独立深色背景。 - 页面主体隐藏时使用 `visibility: hidden`,不能用 `display: none`,否则浏览器可能不触发布局与图片加载。 - 图片加载失败不直接改写业务 UI;后续仍由原页面的兜底图、占位图或错误态处理。 ## 涉及文件 - `src/routing/RouteImageReadyGate.tsx` +- `src/routing/RouteLoadingScreen.tsx` - `src/routing/RouteImageReadyGate.test.ts` - `src/main.tsx` diff --git a/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md b/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md index 17ed7fba..10c3f3ca 100644 --- a/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md +++ b/docs/technical/RPG_FUNCTION_AND_PROMPT_EDITOR_REORGANIZATION_2026-04-28.md @@ -51,7 +51,10 @@ server-rs/crates/api-server/src/prompt/ ├─ big_fish.rs ├─ character_animation.rs ├─ character_visual.rs -├─ puzzle_image.rs +├─ puzzle/ +│ ├─ agent_chat.rs +│ ├─ image.rs +│ └─ mod.rs ├─ scene_background.rs ├─ mod.rs └─ rpg/ diff --git a/docs/technical/RPG_OPENING_ACT_NPC_FLOW_ALIGNMENT_2026-04-30.md b/docs/technical/RPG_OPENING_ACT_NPC_FLOW_ALIGNMENT_2026-04-30.md new file mode 100644 index 00000000..974afd37 --- /dev/null +++ b/docs/technical/RPG_OPENING_ACT_NPC_FLOW_ALIGNMENT_2026-04-30.md @@ -0,0 +1,67 @@ +# RPG 开局首幕 NPC 流程收口方案(2026-04-30) + +## 目标 + +本轮只收口“进入游戏开局场景后遇到第一幕第一批人”的运行时流程: + +1. 对方主角色好感度 `>= 0` 时,聊天过程中允许出现 `npc_chat`、任务、送礼、交易、获得帮助等 NPC 功能选项;聊天结束后界面只保留一个 `story_continue_adventure`,点击后直接推进到下一幕。 +2. 对方主角色好感度 `< 0` 时,聊天过程中只允许 `npc_chat`;聊天可以由模型中断,也可以由玩家主动中断。中断后只允许 `npc_fight` 与 `battle_escape_breakout`。 +3. 删除这条主流程里的干扰分支:正好感聊天结束后不再展开旧 NPC 目录或相邻场景旅行;负好感聊天中不再混入交易、送礼、求助、任务、招募、切磋、离开等 function。 + +## 工程落点 + +1. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` + - `buildNpcChatFunctionOptionCatalog(...)` 按当前 NPC 好感过滤功能候选。 + - 负好感聊天候选只保留 `npc_chat`。 + - 正好感聊天结束后的 `story_continue_adventure` 只揭开下一幕入口;若当前场景没有下一幕,才退回相邻场景入口。 +2. `src/hooks/rpg-runtime-story/choiceActions.ts` + - 应用 `deferredRuntimeState.storyEngineMemory`,保证点击继续后真正切到下一幕的 `currentSceneActState`。 +3. `server-rs/crates/api-server/src/runtime_story/compat/presentation.rs` + - 服务端 active NPC option catalog 与前端同规则对齐。 + - 负好感 active NPC 只返回 `npc_chat`。 + - 非负好感 active NPC 返回聊天、帮助、交易、送礼、任务、招募等功能,不再返回战斗、切磋、离开。 + +## 验收 + +1. 正好感 NPC 主动退出聊天后,只显示 `story_continue_adventure`。 +2. 点击 `story_continue_adventure` 后,`storyEngineMemory.currentSceneActState.currentActId` 推进到下一幕。 +3. 负好感 NPC 聊天请求中的 `functionOptions` 为空,聊天 UI 不出现非聊天 function。 +4. 负好感聊天中断后只出现“战斗”和逃跑选项。 +5. 服务端 state catalog 对负好感 active NPC 不返回交易、送礼、帮助、任务、招募、切磋、离开等干扰入口。 + +## 2026-04-30 补充:负好感主动中止恢复 + +### 问题 + +敌对聊天的模型主动中止依赖后端建议 JSON 中的 `shouldEndChat` 字段,但部分入口没有把负好感 NPC 标记为 `terminationMode: hostile_model`,导致后端即使收到 `shouldEndChat: true` 也会按非敌对聊天忽略。另一个缺口是 NPC 主动开场第一轮只展示后续候选,没有处理 `chatDirective.forceExit`,因此第一轮开场也无法被模型主动中止。 + +### 落地 + +1. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` + - 构造 `NpcChatDirective` 时直接读取当前 NPC 好感度。 + - 只要 `affinity < 0`,统一写入 `limitReason: negative_affinity`、`terminationMode: hostile_model`、`isHostileChat: true`。 + - NPC 主动开场收到 `chatTurn.chatDirective.forceExit === true` 时,立即收起 `npcChatState`,展示战斗与逃跑选项。 +### 补充验收 + +1. 任意负好感 NPC 聊天轮都必须向后端传 `terminationMode: hostile_model`,不能只依赖第一幕主 NPC 场景幕状态。 +2. 负好感 NPC 主动开场第一轮若返回 `forceExit: true`,聊天输入立即关闭,只显示战斗与逃跑。 + +## 2026-04-30 补充:聊天首句统一由模型 NPC 发起 + +### 问题 + +NPC 主动开场链路本身已经存在,并会以空玩家消息调用模型,同时传入 `npcInitiatesConversation: true`。但运行时入口曾把这条链路限制在 `firstMeaningfulContactResolved !== true`,导致角色完成首次有效接触后,再次从 NPC 入口或交互选项进入聊天时,会退回旧的 `enterNpcChat(...)` 本地入口:界面先展示玩家可点的话题,没有模型生成的 NPC 首句。负好感且非限定场景幕时,还存在一条本地敌对宣言分支,会直接给战斗/逃跑,绕过“先聊天再中断”的主流程。 + +### 落地 + +1. `enterNpcInteraction(...)` 不再用 `firstMeaningfulContactResolved` 决定是否走 NPC 主动开场;只要是从 NPC 入口新开聊天,都调用 `startNpcInitiatedOpening(...)`。 +2. `handleNpcInteraction(...)` 的 `chat` 分支保留“当前已经在同一段 `npcChatState` 内时,点击 `npc_chat` 作为玩家回复”的行为;若不在已有聊天内,统一调用 `startNpcInitiatedOpening(...)`。 +3. 删除负好感入口的本地敌对宣言分支。负好感只通过 `NpcChatDirective` 影响模型语气、功能选项和 `forceExit` 后的战斗/逃跑收束,不再跳过模型首句。 +4. `enterNpcChat(...)` 仅保留为缺少角色/世界类型或模型开场失败时的兜底入口,不作为正常聊天开场路径。 + +### 补充验收 + +1. 不论好感度正负,也不论 `firstMeaningfulContactResolved` 是否为 `true`,新开聊天首轮都必须调用 `streamNpcChatTurn(..., '', { npcInitiatesConversation: true })`。 +2. 新开聊天最终展示的第一条 `dialogue` 必须是模型返回的 NPC 文本,`npcChatState.openingSource` 必须是 `npc_initiated`。 +3. 已经处于同一段 `npcChatState` 中时,点击 `npc_chat` 仍作为玩家本轮回复进入 `handleNpcChatTurn(...)`,不能重新开一段 NPC 首句。 +4. 负好感入口不能直接显示本地战斗/逃跑;只有模型或玩家中断聊天后,才显示 `npc_fight` 与 `battle_escape_breakout`。 diff --git a/docs/technical/RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md b/docs/technical/RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md new file mode 100644 index 00000000..ff4482ad --- /dev/null +++ b/docs/technical/RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md @@ -0,0 +1,66 @@ +# RPG 幕预览启动卡载入修复(2026-04-30) + +## 背景 + +编辑器内点击“幕预览”后,独立预览层会一直停在“正在载入这一幕的游戏流程...”,无法进入真实 RPG 运行壳。 + +## 根因 + +`SceneActPreviewRuntime` 先调用 `handleCustomWorldSelect(profile)`,紧接着调用 `handleCharacterSelect(previewCharacter)`。 + +但 `handleCharacterSelect()` 读取的是当前 render 闭包中的旧 `gameState`。此时 `handleCustomWorldSelect()` 写入的 `worldType` 还没有完成 React 状态提交,所以选角入口看到 `worldType` 为空后直接返回。随后幕预览虽然又手动写入了 `currentScene / currentScenePreset / currentEncounter`,却没有写入 `playerCharacter`,导致 `isPreviewReady` 永远不成立。 + +另一个隐患是:有 `currentEncounter` 时 story controller 不会主动生成普通首段剧情,而是交给 NPC 交互流接管;若预览没有显式注入一个可展示的 `currentStory`,运行面板也可能无法稳定挂载。 + +## 本轮继续修复 + +继续复测时发现,`SceneActPreviewRuntime` 虽然已经不再调用 `handleCharacterSelect()`,但仍会调用 `handleCustomWorldSelect(profile)` 来同步 runtime 静态资料。 + +这个入口是正式运行态的“选择世界”入口,会排队写入“已选择世界、尚未选角”的中间 `GameState`。在幕预览本地 `setGameState()` 写入玩家、场景与故事后,这个中间态仍可能覆盖回来,导致 `currentScenePreset` 或 `playerCharacter` 被清掉,预览层重新停在“正在载入这一幕的游戏流程...”。 + +本轮调整后: + +1. 幕预览不再调用 `handleCustomWorldSelect(profile)`。 +2. 幕预览直接调用 `setRuntimeCustomWorldProfile(profile)` 与 `setRuntimeCharacterOverrides(buildCustomWorldRuntimeCharacters(profile))` 同步静态资料层。 +3. `isPreviewReady` 同时校验: + - `currentScene === "Story"` + - `runtimeSessionId === "runtime-scene-act-preview"` + - 当前玩家就是本次预览角色 + - 当前场景就是本次预览场景 + - 当前 story 已经完成注入 +4. 这样 preview ready 只依赖本次预览自己的完整启动结果,不再被正式选世界中间态影响。 + +## 修复口径 + +1. 幕预览不再调用 `handleCharacterSelect()` 触发后端开局副作用。 +2. 幕预览不调用正式 `handleCustomWorldSelect(profile)`,而是直接同步 runtime 静态资料层。 +3. 随后在同一个 `setGameState` 中一次性写入: + - `playerCharacter` + - `runtimeMode: "play"` + - `runtimePersistenceDisabled: true` + - `currentScene / currentScenePreset / currentEncounter` + - 玩家血蓝、技能冷却、装备、统计、进度、队伍、任务等运行态基础字段 + - 当前幕 `currentSceneActState` +4. 幕预览使用固定临时 `runtimeSessionId: "runtime-scene-act-preview"`,并通过禁持久化快照保持不写正式存档。 +5. 启动时同步 `hydrateStoryState()`,注入当前幕 NPC 的本地开场 story,让 `RpgRuntimeShell` 立即满足挂载条件。 + +## 约束 + +1. 幕预览继续复用正式 `play` 运行链,不恢复旧 `preview/test` 行为分支。 +2. 幕预览只允许前端做临时运行态装配;正式游戏开局仍由 `server-rs` 裁决。 +3. 后续如把幕预览也迁到后端 bootstrap,应新增专门的禁持久化 bootstrap 入口,而不是再次依赖 `handleCharacterSelect()` 的异步状态顺序。 + +## 验证 + +新增回归覆盖: + +```bash +npm test -- --run src/components/CustomWorldEntityEditorModal.test.tsx +``` + +断言幕预览打开后: + +1. 不再显示“正在载入这一幕的游戏流程...”。 +2. `RpgRuntimeShell` 已收到预览玩家角色。 +3. 运行态为 `play` 且禁用持久化。 +4. 当前 story 已注入为当前幕 NPC 开场内容。 diff --git a/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md index 20d02fe5..dcc1cf75 100644 --- a/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md +++ b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md @@ -57,3 +57,15 @@ - LLM 不可用时的聊天 reply、普通 choice、function choice 兜底生成。 3. `server-rs/crates/api-server/src/runtime_chat.rs` 只保留 Axum SSE、LLM 调用、解析、好感变化、结束聊天判断等流程编排,不再直接承载提示词正文或 choice 文案兜底。 4. 后续调整聊天 choice 语气、候选数量、`functionOptions` 描述方式、敌对聊天收束策略时,优先修改 `prompt/runtime_chat.rs`。 + +## 7. 拼图 Prompt 独立目录收口 + +2026-04-30 追加收口: + +1. 拼图提示词参考 RPG 的目录组织,统一迁入 `server-rs/crates/api-server/src/prompt/puzzle/`。 +2. `prompt/puzzle/agent_chat.rs` 承接拼图共创 Agent 的 system prompt、单轮 JSON 输出契约、用户提示词与 anchor pack / 聊天记录提示词组装。 +3. `prompt/puzzle/draft.rs` 承接生成拼图作品草稿动作里的表单 seed prompt、草稿首图 prompt 来源选择、单关图片再生成 prompt 来源选择。 +4. `prompt/puzzle/image.rs` 承接拼图图片生成正式提示词与默认反向提示词。 +5. `puzzle_agent_turn.rs` 只保留 LLM 调用、结果解析、阶段判断和 SpacetimeDB 写回输入构造,不再内联拼图聊天提示词正文。 +6. `puzzle.rs` 只保留拼图路由、计费、DashScope、OSS、候选图持久化和运行态编排,不再内联拼图草稿或图片提示词正文。 +7. 后续调整拼图共创问法、输出契约、生成草稿 prompt 来源、图片画面约束或反向提示词时,优先修改 `prompt/puzzle/`,不要在 `puzzle.rs` 或 `puzzle_agent_turn.rs` 中新增提示词正文。 diff --git a/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md b/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md new file mode 100644 index 00000000..b7817b50 --- /dev/null +++ b/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md @@ -0,0 +1,88 @@ +# SpacetimeDB 本地 replica identity 不一致处理方案 + +日期:`2026-04-30` + +## 1. 问题 + +本地启动 SpacetimeDB standalone 时出现: + +```text +error starting database: failed to init replica 1 for : mismatched database identity: != +``` + +本次现场日志中,`server-rs/.spacetimedb/local/data/logs/spacetime-standalone.log` 显示: + +1. `2026-04-30T12:17:26Z` 开始按 `c2006f3d846a8259512006a556b1bc3f751a9aef6608fc0ee75788deea6d9331` 启动数据库。 +2. `replica 1` 的持久化数据仍带有旧库 `c20037fcfaac4e5c4b1f492f026a4f6119a98f56319b77f21ef021ededf8b7ae`。 +3. SpacetimeDB 因同一个副本目录中 identity 不一致而拒绝继续启动。 + +这不是 Rust 编译错误,也不是 `api-server:maincloud` 的 token 错误。只要错误来自 `server-rs/.spacetimedb/local/.../spacetime-standalone.log`,优先按本地 root-dir 数据目录污染处理。 + +## 2. 根因 + +`spacetime start --edition standalone` 会在同一个 `--root-dir` 下保存控制库、程序字节、WAL 与 replica 数据。当前仓库默认本地 root-dir 是: + +```text +server-rs/.spacetimedb/local +``` + +当这个目录曾经启动并发布过旧 database identity,之后又用同一个 root-dir 初始化或发布到另一个 database identity 时,可能出现: + +1. `control-db` 记录的是新库。 +2. `data/replicas/1` 里仍残留旧库 WAL 或快照。 +3. 启动时 SpacetimeDB 尝试把旧 replica 当作新库加载,触发 `mismatched database identity`。 + +## 3. 处理原则 + +1. 不在脚本里默认删除 `.spacetimedb` 数据,避免误删本地开发数据。 +2. 如果只是本地开发库且数据可丢弃,优先备份后重建 `data` 目录。 +3. 如果数据必须保留,不要清理目录;应改回创建旧库时使用的 database/root-dir,或先导出迁移数据。 +4. Maincloud 发布与本地 standalone root-dir 是两条链路;不要通过切回 `server-node` 或 PostgreSQL 绕过。 + +## 4. 本地可丢弃数据时的修复 + +PowerShell: + +```powershell +$root = "C:\Genarrative\server-rs\.spacetimedb\local" +Get-CimInstance Win32_Process | + Where-Object { $_.Name -match "spacetime" -and $_.CommandLine -and $_.CommandLine.Replace("/", "\") -like "*$($root.Replace("/", "\"))*" } | + Select-Object ProcessId, Name, CommandLine +``` + +确认占用进程后停止: + +```powershell +Stop-Process -Id -Force +``` + +备份运行态数据目录: + +```powershell +$stamp = Get-Date -Format "yyyyMMdd-HHmmss" +Move-Item -LiteralPath "C:\Genarrative\server-rs\.spacetimedb\local\data" -Destination "C:\Genarrative\server-rs\.spacetimedb\local\data.identity-mismatch-backup.$stamp" +``` + +重新启动本地链路: + +```powershell +npm run dev:rust +``` + +`npm run dev:rust` 会重新启动 standalone、发布 `spacetime-module`,并生成新的本地数据库运行态。 + +## 5. 需要保留数据时的处理 + +不要移动或删除 `server-rs/.spacetimedb/local/data`。先确认旧库 identity 对应的数据库名、root-dir 与发布命令,然后选择: + +1. 用旧库对应的 database/root-dir 重新启动。 +2. 使用迁移导出脚本导出旧数据,再清理本地 root-dir 并导入到新库。 +3. 如目标其实是 Maincloud,改用 `npm run api-server:maincloud` 连接云端,避免误启动本地 standalone。 + +## 6. 脚本诊断 + +`scripts/dev-rust-stack.sh` 已补充本地启动失败诊断: + +1. SpacetimeDB 进程在就绪前退出时,会打印 `spacetime-standalone.log` 尾部。 +2. 若日志包含 `mismatched database identity`,会提示本地 `data/replicas/1` 与当前 control-db identity 不一致。 +3. 诊断只输出建议,不自动清理数据。 diff --git a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md index 713f4101..db0fd9cf 100644 --- a/docs/technical/SPACETIMEDB_TABLE_CATALOG.md +++ b/docs/technical/SPACETIMEDB_TABLE_CATALOG.md @@ -407,6 +407,7 @@ SELECT * FROM custom_world_gallery_entry WHERE public_work_code = '`, `last_assistant_reply: Option`, `published_profile_id: Option`, `created_at: Timestamp`, `updated_at: Timestamp`。 +- 说明:填表式拼图入口会在点击“拼图”时立即创建空 session;生成草稿前的表单自动保存复用 `seed_text` 与 `draft_json`,不新增表字段,`stage` 保持 `CollectingAnchors`。 - 索引:`owner_user_id`。 ```sql @@ -426,8 +427,10 @@ SELECT * FROM puzzle_agent_message WHERE session_id = '' ORDER BY cr ### `puzzle_work_profile` -- 作用:拼图作品主表,保存作品信息、封面、发布状态、游玩次数和锚点包。 -- 结构:`profile_id PK: String`, `work_id: String`, `owner_user_id: String`, `source_session_id: Option`, `author_display_name: String`, `level_name: String`, `summary: String`, `theme_tags_json: String`, `cover_image_src: Option`, `cover_asset_id: Option`, `publication_status: PuzzlePublicationStatus`, `play_count: u32`, `anchor_pack_json: String`, `publish_ready: bool`, `created_at: Timestamp`, `updated_at: Timestamp`, `published_at: Option`。 +- 作用:拼图作品主表,保存作品信息、多关卡草稿、封面、发布状态、游玩次数和锚点包。 +- 结构:`profile_id PK: String`, `work_id: String`, `owner_user_id: String`, `source_session_id: Option`, `author_display_name: String`, `work_title: String`, `work_description: String`, `level_name: String`, `summary: String`, `theme_tags_json: String`, `cover_image_src: Option`, `cover_asset_id: Option`, `levels_json: String`, `publication_status: PuzzlePublicationStatus`, `play_count: u32`, `anchor_pack_json: String`, `publish_ready: bool`, `created_at: Timestamp`, `updated_at: Timestamp`, `published_at: Option`。 +- 说明:`work_title`/`work_description` 是作品详情页展示来源;`levels_json` 保存拼图关卡列表,`level_name`/`summary` 继续作为首关兼容字段和旧数据回退来源。 +- 说明:拼图初始表单草稿也写入本表作为创作中心卡片投影;未生成图片前 `cover_image_src = None`、`publish_ready = false`,再次打开草稿时通过 `source_session_id` 恢复表单。 - 索引:`owner_user_id`, `publication_status`。 ```sql diff --git a/docs/technical/WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md b/docs/technical/WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md new file mode 100644 index 00000000..a110c5e2 --- /dev/null +++ b/docs/technical/WORK_AUTHOR_ID_RESOLUTION_2026-04-30.md @@ -0,0 +1,42 @@ +# 作品作者按用户 ID 解析设计 2026-04-30 + +## 背景 + +作品列表、公开广场和详情页需要展示作者信息。旧链路里部分作品表会同时写入 `author_display_name`,如果用户后续修改昵称,旧作品仍会显示发布时的昵称快照,造成作者信息不一致。 + +## 目标 + +1. 作品作者的真相源统一使用 `owner_user_id`。 +2. API 返回作品读模型时,通过 `owner_user_id` 读取账号公开资料,并使用最新 `display_name` 作为 `authorDisplayName`。 +3. `author_display_name` 暂时保留为历史兼容字段,只在用户资料不存在或读取失败时作为回退值。 +4. 前端详情页优先展示按 `ownerUserId` 读取到的公开用户资料;作品字段里的作者名只作为兜底展示。 + +## 落地规则 + +### SpacetimeDB 存储 + +1. `custom_world_profile.owner_user_id` / `custom_world_gallery_entry.owner_user_id` 是 RPG 作品作者 ID。 +2. `puzzle_work_profile.owner_user_id` 是拼图作品作者 ID。 +3. `big_fish_creation_session.owner_user_id` 是大鱼吃小鱼作品作者 ID。 +4. 现有 `author_display_name` 不再作为作者真相源,不新增依赖它做权限、同作者推荐或作者资料展示的逻辑。 +5. 本次不删除 `author_display_name`,避免破坏历史迁移包、生成绑定和旧客户端兼容;后续若要删除,必须单独做 schema 迁移和绑定刷新。 + +### API facade + +1. 输出 `authorDisplayName` 时先用 `owner_user_id` 查询认证用户表。 +2. 查询成功时使用用户最新 `display_name`,并同步补齐 `public_user_code`。 +3. 查询失败或用户缺失时才回退作品表旧 `author_display_name`。 +4. 大鱼吃小鱼公开作品不再由前端硬编码作者名,API 根据 `owner_user_id` 输出作者显示名。 + +### 前端 + +1. 统一作品详情页已按 `ownerUserId` 读取公开用户摘要,用于头像和作者名。 +2. 详情页展示作者名时优先使用公开用户摘要的 `displayName`,缺失时回退作品读模型的 `authorDisplayName`。 +3. 新增作品类型接入平台详情页时,不允许只在前端写固定作者昵称。 + +## 验收点 + +1. 用户修改昵称后,RPG / 拼图 / 大鱼公开作品列表与详情页能展示新昵称。 +2. 旧作品缺少可读取用户资料时,仍能用历史 `author_display_name` 或“玩家”兜底。 +3. 作品权限和“同作者”判断继续使用 `owner_user_id`。 +4. 本次不改变 SpacetimeDB 表结构,因此不需要调整 `migration.rs` 白名单或导入补字段逻辑。 diff --git a/packages/shared/src/contracts/bigFishWorkSummary.ts b/packages/shared/src/contracts/bigFishWorkSummary.ts index 4a1cd5f2..e338bb0b 100644 --- a/packages/shared/src/contracts/bigFishWorkSummary.ts +++ b/packages/shared/src/contracts/bigFishWorkSummary.ts @@ -4,6 +4,7 @@ export interface BigFishWorkSummary { workId: string; sourceSessionId: string; ownerUserId: string; + authorDisplayName: string; title: string; subtitle: string; summary: string; diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index abbb41d4..5d96445c 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -13,6 +13,7 @@ export interface PuzzleAgentSuggestedAction { } export type PuzzleAgentActionType = + | 'save_puzzle_form_draft' | 'compile_puzzle_draft' | 'generate_puzzle_images' | 'select_puzzle_image' @@ -39,27 +40,43 @@ export interface PuzzleAgentOperationRecord { } export type PuzzleAgentActionRequest = + | { + action: 'save_puzzle_form_draft'; + promptText?: string | null; + workTitle?: string; + workDescription?: string; + pictureDescription?: string; + } | { action: 'compile_puzzle_draft'; promptText?: string | null; + workTitle?: string; + workDescription?: string; + pictureDescription?: string; referenceImageSrc?: string | null; candidateCount?: number; } | { action: 'generate_puzzle_images'; + levelId?: string | null; promptText?: string | null; referenceImageSrc?: string | null; candidateCount?: number; + levelsJson?: string; } | { action: 'select_puzzle_image'; + levelId?: string | null; candidateId: string; } | { action: 'publish_puzzle_work'; + workTitle?: string; + workDescription?: string; levelName?: string; summary?: string; themeTags?: string[]; + levelsJson?: string; }; /** diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 2336e4cf..2bf925b9 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -42,7 +42,20 @@ export interface PuzzleGeneratedImageCandidate { selected: boolean; } +export interface PuzzleDraftLevel { + levelId: string; + levelName: string; + pictureDescription: string; + candidates: PuzzleGeneratedImageCandidate[]; + selectedCandidateId: string | null; + coverImageSrc: string | null; + coverAssetId: string | null; + generationStatus: 'idle' | 'generating' | 'ready'; +} + export interface PuzzleResultDraft { + workTitle?: string; + workDescription?: string; levelName: string; summary: string; themeTags: string[]; @@ -54,5 +67,11 @@ export interface PuzzleResultDraft { coverImageSrc: string | null; coverAssetId: string | null; generationStatus: 'idle' | 'generating' | 'ready'; + levels?: PuzzleDraftLevel[]; + formDraft?: { + workTitle?: string; + workDescription?: string; + pictureDescription?: string; + } | null; metadata?: JsonObject | null; } diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index ac4d0747..f580f0a6 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -27,6 +27,7 @@ export interface PuzzleAgentMessage { export interface PuzzleAgentSessionSnapshot { sessionId: string; + seedText?: string; currentTurn: number; progressPercent: number; stage: PuzzleAgentStage; @@ -42,6 +43,8 @@ export interface PuzzleAgentSessionSnapshot { export interface CreatePuzzleAgentSessionRequest { seedText?: string; + workTitle?: string; + workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; } diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 30fb5592..1d195172 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -1,4 +1,4 @@ -export type PuzzleGridSize = 3 | 4; +export type PuzzleGridSize = 3 | 4 | 5 | 6 | 7; export interface PuzzleCellPosition { row: number; @@ -29,7 +29,11 @@ export interface PuzzleLeaderboardEntry { export type PuzzleRuntimeLevelStatus = 'playing' | 'cleared' | 'failed'; -export type PuzzleRuntimePropKind = 'hint' | 'reference' | 'freezeTime'; +export type PuzzleRuntimePropKind = + | 'hint' + | 'reference' + | 'freezeTime' + | 'extendTime'; export interface PuzzleBoardSnapshot { rows: number; @@ -43,6 +47,7 @@ export interface PuzzleBoardSnapshot { export interface PuzzleRuntimeLevelSnapshot { runId: string; levelIndex: number; + levelId: string | null; gridSize: PuzzleGridSize; profileId: string; levelName: string; @@ -64,6 +69,17 @@ export interface PuzzleRuntimeLevelSnapshot { leaderboardEntries: PuzzleLeaderboardEntry[]; } +export type PuzzleNextLevelMode = 'sameWork' | 'similarWorks' | 'none'; + +export interface PuzzleRecommendedNextWork { + profileId: string; + levelName: string; + authorDisplayName: string; + themeTags: string[]; + coverImageSrc: string | null; + similarityScore: number; +} + export interface PuzzleRunSnapshot { runId: string; entryProfileId: string; @@ -74,11 +90,16 @@ export interface PuzzleRunSnapshot { previousLevelTags: string[]; currentLevel: PuzzleRuntimeLevelSnapshot | null; recommendedNextProfileId: string | null; + nextLevelMode?: PuzzleNextLevelMode; + nextLevelProfileId?: string | null; + nextLevelId?: string | null; + recommendedNextWorks?: PuzzleRecommendedNextWork[]; leaderboardEntries: PuzzleLeaderboardEntry[]; } export interface StartPuzzleRunRequest { profileId: string; + levelId?: string | null; } export interface AdvanceLocalPuzzleNextLevelRequest { diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index 649d516f..3bfd4a44 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -1,5 +1,5 @@ import type { JsonObject } from './common'; -import type { PuzzleAnchorPack } from './puzzleAgentDraft'; +import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft'; export type PuzzleWorkPublicationStatus = 'draft' | 'published'; @@ -9,6 +9,8 @@ export interface PuzzleWorkSummary { ownerUserId: string; sourceSessionId?: string | null; authorDisplayName: string; + workTitle?: string; + workDescription?: string; levelName: string; summary: string; themeTags: string[]; @@ -21,7 +23,12 @@ export interface PuzzleWorkSummary { remixCount?: number; likeCount?: number; recentPlayCount7d?: number; + pointIncentiveTotalHalfPoints?: number; + pointIncentiveClaimedPoints?: number; + pointIncentiveTotalPoints?: number; + pointIncentiveClaimablePoints?: number; publishReady: boolean; + levels?: PuzzleDraftLevel[]; } export interface PuzzleWorkProfile extends PuzzleWorkSummary { diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index 830a1f41..cc233248 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -64,7 +64,8 @@ export type ProfileWalletLedgerEntry = { | 'points_recharge' | 'asset_operation_consume' | 'asset_operation_refund' - | 'redeem_code_reward'; + | 'redeem_code_reward' + | 'puzzle_author_incentive_claim'; createdAt: string; }; diff --git a/scripts/dev-rust-stack.sh b/scripts/dev-rust-stack.sh index 02120232..b9a4f1dd 100644 --- a/scripts/dev-rust-stack.sh +++ b/scripts/dev-rust-stack.sh @@ -77,6 +77,7 @@ wait_for_spacetime() { while ((SECONDS < deadline)); do if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2 + print_spacetime_start_failure_diagnostics "${root_dir}" exit 1 fi @@ -88,6 +89,7 @@ wait_for_spacetime() { done echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2 + print_spacetime_start_failure_diagnostics "${root_dir}" exit 1 } @@ -115,6 +117,28 @@ request.on("error", () => process.exit(1)); ' "${server}" >/dev/null 2>&1 } +print_spacetime_start_failure_diagnostics() { + local root_dir="$1" + local log_file="${root_dir}/data/logs/spacetime-standalone.log" + + echo "[dev:rust] SpacetimeDB root-dir: ${root_dir}" >&2 + + if [[ ! -f "${log_file}" ]]; then + echo "[dev:rust] 未找到 SpacetimeDB standalone 日志: ${log_file}" >&2 + return + fi + + echo "[dev:rust] 最近 SpacetimeDB standalone 日志: ${log_file}" >&2 + tail -n 80 "${log_file}" >&2 || true + + if grep -q "mismatched database identity" "${log_file}" 2>/dev/null; then + echo "[dev:rust] 检测到本地 replica 与当前数据库 identity 不一致。" >&2 + echo "[dev:rust] 常见原因是同一个 root-dir 保留了旧库 data/replicas/1,但 control-db 已指向新库。" >&2 + echo "[dev:rust] 若这是可丢弃的本地开发库,请先停止 SpacetimeDB,再备份或移走 ${root_dir}/data 后重新启动。" >&2 + echo "[dev:rust] 若需要保留数据,不要清理目录;请改回创建旧库的 database/root-dir,或先走迁移导出。" >&2 + fi +} + describe_spacetime_root_owner() { local root_dir="$1" local windows_root_dir="${root_dir}" diff --git a/scripts/generate-spacetime-bindings.mjs b/scripts/generate-spacetime-bindings.mjs index 99d78c91..d31389a2 100644 --- a/scripts/generate-spacetime-bindings.mjs +++ b/scripts/generate-spacetime-bindings.mjs @@ -100,8 +100,15 @@ async function recreateTempDir(dir) { async function replaceGeneratedDir(fromDir, toDir) { assertInside(toDir, REPO_ROOT, '仓库生成目录'); await rm(toDir, {recursive: true, force: true}); - await mkdir(path.dirname(toDir), {recursive: true}); - await cp(fromDir, toDir, {recursive: true}); + await mkdir(toDir, {recursive: true}); + const entries = await readdir(fromDir, {withFileTypes: true}); + + for (const entry of entries) { + await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), { + recursive: true, + force: true, + }); + } } function assertInside(candidate, parent, label) { diff --git a/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.input.json b/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.input.json new file mode 100644 index 00000000..a8e4fd2b --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:{\"hook\":\"在失真的海图上追查一场被篡改的沉船事故。\"}\n玩家切入口:{\"entryMotivation\":\"查清父亲沉船真相\",\"openingIdentity\":\"被停职返乡的守灯人\",\"openingProblem\":\"灯塔记录被人改写\"}\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.output.txt b/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.output.txt new file mode 100644 index 00000000..0681f0ad --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777569756314-63624-000001-parse_response_failed.output.txt @@ -0,0 +1 @@ +{"choices":[{"message":{"content":"{\"name\":\"雾港归航\",\"subtitle\":\"失灯旧案\",\"summary\":\"守灯人与群岛议会围绕沉船旧案对峙。\",\"tone\":\"海雾悬疑\",\"playerGoal\":\"查清父亲沉船真相\",\"templateWorldType\":\"WUXIA\",\"majorFactions\":[\"群岛议会\",\"灯塔署\"],\"coreConflicts\":[\"守灯塔的旧档案被人改写。\"],\"attributeSchema\":{\"slots\":[{\"name\":\"灯骨\"},{\"name\":\"潮步\"},{\"name\":\"灯识\"},{\"name\":\"雾魄\"},{\"name\":\"旧约\"},{\"name\":\"回澜\"}]},\"camp\":{\"name\":\"旧灯塔归舍\",\"description\":\"海雾边缘的守灯人旧居。\"}}"}}],"id":"resp_01"} \ No newline at end of file diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c0124920..e441d494 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1,7 +1,7 @@ use axum::{ Router, body::Body, - extract::Extension, + extract::{DefaultBodyLimit, Extension}, http::Request, middleware, routing::{delete, get, post}, @@ -31,8 +31,9 @@ use crate::{ auth_sessions::auth_sessions, big_fish::{ create_big_fish_session, delete_big_fish_work, execute_big_fish_action, - get_big_fish_session, get_big_fish_works, list_big_fish_gallery, record_big_fish_play, - remix_big_fish_gallery_work, stream_big_fish_message, submit_big_fish_message, + get_big_fish_session, get_big_fish_works, list_big_fish_gallery, + record_big_fish_gallery_like, record_big_fish_play, remix_big_fish_gallery_work, + stream_big_fish_message, submit_big_fish_message, }, character_animation_assets::{ generate_character_animation, get_character_animation_job, get_character_workflow_cache, @@ -53,9 +54,9 @@ use crate::{ get_custom_world_gallery_detail_by_code, get_custom_world_library, get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery, publish_custom_world_library_profile, put_custom_world_library_profile, - record_custom_world_gallery_play, remix_custom_world_gallery_profile, - stream_custom_world_agent_message, submit_custom_world_agent_message, - unpublish_custom_world_library_profile, + record_custom_world_gallery_like, record_custom_world_gallery_play, + remix_custom_world_gallery_profile, stream_custom_world_agent_message, + submit_custom_world_agent_message, unpublish_custom_world_library_profile, }, custom_world_ai::{ generate_custom_world_cover_image, generate_custom_world_entity, @@ -75,21 +76,23 @@ use crate::{ logout::logout, logout_all::logout_all, match3d::{ - click_match3d_item, create_match3d_agent_session, delete_match3d_work, - execute_match3d_agent_action, finish_match3d_time_up, get_match3d_agent_session, - get_match3d_run, get_match3d_work_detail, get_match3d_works, list_match3d_gallery, - publish_match3d_work, put_match3d_work, restart_match3d_run, start_match3d_run, - stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message, + click_match3d_item, compile_match3d_agent_draft, create_match3d_agent_session, + delete_match3d_work, execute_match3d_agent_action, finish_match3d_time_up, + get_match3d_agent_session, get_match3d_run, get_match3d_work_detail, get_match3d_works, + list_match3d_gallery, publish_match3d_work, put_match3d_work, restart_match3d_run, + start_match3d_run, stop_match3d_run, stream_match3d_agent_message, + submit_match3d_agent_message, }, password_entry::password_entry, password_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, profile_identity::update_profile_identity, puzzle::{ - advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session, - delete_puzzle_work, execute_puzzle_agent_action, get_puzzle_agent_session, - get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, - list_puzzle_gallery, put_puzzle_work, remix_puzzle_gallery_work, start_puzzle_run, + advance_local_puzzle_next_level, advance_puzzle_next_level, + claim_puzzle_work_point_incentive, create_puzzle_agent_session, delete_puzzle_work, + execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail, + get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, + put_puzzle_work, record_puzzle_gallery_like, remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop, }, @@ -130,6 +133,8 @@ use crate::{ wechat_auth::{bind_wechat_phone, handle_wechat_callback, start_wechat_login}, }; +const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; + // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { let slow_request_threshold_ms = state.config.slow_request_threshold_ms; @@ -554,6 +559,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}/like", + post(record_custom_world_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/custom-world-gallery/by-code/{code}", get(get_custom_world_gallery_detail_by_code), @@ -673,6 +685,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/big-fish/gallery/{session_id}/like", + post(record_big_fish_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/big-fish/works/{session_id}", delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state( @@ -731,7 +750,7 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/creation/match3d/sessions/{session_id}/compile", - post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state( + post(compile_match3d_agent_draft).route_layer(middleware::from_fn_with_state( state.clone(), require_bearer_auth, )), @@ -806,10 +825,15 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/runtime/puzzle/agent/sessions", - post(create_puzzle_agent_session).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(create_puzzle_agent_session) + // 中文注释:拼图表单会携带单张参考图 Data URL,需只给该写入入口放宽 body 上限。 + .layer(DefaultBodyLimit::max( + PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}", @@ -834,10 +858,15 @@ pub fn build_router(state: AppState) -> Router { ) .route( "/api/runtime/puzzle/agent/sessions/{session_id}/actions", - post(execute_puzzle_agent_action).route_layer(middleware::from_fn_with_state( - state.clone(), - require_bearer_auth, - )), + post(execute_puzzle_agent_action) + // 中文注释:生成草稿/重新出图会复用 referenceImageSrc,避免默认 2MB JSON limit 拦截。 + .layer(DefaultBodyLimit::max( + PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES, + )) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), ) .route( "/api/runtime/puzzle/works", @@ -856,6 +885,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/works/{profile_id}/point-incentive/claim", + post(claim_puzzle_work_point_incentive).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route("/api/runtime/puzzle/gallery", get(list_puzzle_gallery)) .route( "/api/runtime/puzzle/gallery/{profile_id}", @@ -868,6 +904,13 @@ pub fn build_router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/puzzle/gallery/{profile_id}/like", + post(record_puzzle_gallery_like).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/runtime/puzzle/runs", post(start_puzzle_run).route_layer(middleware::from_fn_with_state( @@ -1359,6 +1402,30 @@ mod tests { .await } + fn sign_test_user_token( + state: &AppState, + user: &module_auth::AuthUser, + session_id: &str, + ) -> String { + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user.id.clone(), + session_id: session_id.to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: user.token_version, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some(user.display_name.clone()), + }, + state.auth_jwt_config(), + OffsetDateTime::now_utc(), + ) + .expect("claims should build"); + + sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign") + } + async fn password_login_request( app: Router, phone_number: &str, @@ -1616,6 +1683,88 @@ mod tests { ); } + #[tokio::test] + async fn puzzle_agent_actions_accept_reference_image_body_above_default_limit() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138024", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_reference_body"); + let app = build_router(state); + let reference_image_src = format!("data:image/png;base64,{}", "A".repeat(3 * 1024 * 1024)); + let request_body = serde_json::json!({ + "action": "unsupported_large_reference_test", + "referenceImageSrc": reference_image_src, + }) + .to_string(); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/puzzle/agent/sessions/puzzle-session-large/actions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("unsupported_large_reference_test"), + "handler should parse the oversized reference payload before rejecting the action: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + + #[tokio::test] + async fn puzzle_agent_session_creation_accepts_reference_image_body_above_default_limit() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138025", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_puzzle_form_reference_body"); + let app = build_router(state); + let request_body = format!( + "{{\"seedText\":\"大参考图拼图\",\"pictureDescription\":\"一张用于验证 body limit 的参考图。\",\"referenceImageSrc\":\"data:image/png;base64,{}\"", + "A".repeat(3 * 1024 * 1024) + ); + assert!(request_body.len() > 2 * 1024 * 1024); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/runtime/puzzle/agent/sessions") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("EOF") || body_text.contains("expected"), + "handler should parse the oversized form payload before rejecting malformed JSON: {body_text}" + ); + assert!(!body_text.contains("length limit exceeded")); + } + #[tokio::test] async fn password_entry_rejects_unknown_phone_without_registration() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 4c0406fb..521ed418 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -85,8 +85,13 @@ async fn refund_asset_operation_points( } pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> AppError { + let message = error.to_string(); + tracing::warn!( + provider = "profile-wallet", + error = %message, + "资产操作陶泥币预扣失败" + ); let status = match &error { - SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("陶泥币余额不足") => { StatusCode::CONFLICT } @@ -95,7 +100,7 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A AppError::from_status(status).with_details(json!({ "provider": "profile-wallet", - "message": error.to_string(), + "message": message, })) } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index b38e3ba6..40ac4bce 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -23,8 +23,8 @@ use shared_contracts::assets::{ use spacetime_client::SpacetimeClientError; use crate::{ - api_response::json_success_body, http_error::AppError, request_context::RequestContext, - state::AppState, + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 @@ -119,6 +119,7 @@ pub async fn get_asset_read_url( pub async fn get_asset_history( State(state): State, Extension(request_context): Extension, + Extension(authenticated): Extension, Query(query): Query, ) -> Result, AppError> { let asset_kind = query.kind.trim().to_string(); @@ -133,18 +134,23 @@ pub async fn get_asset_history( let entries = state .spacetime_client() - .list_asset_history(module_assets::AssetHistoryListInput { - asset_kind, - limit: query.limit.unwrap_or(120).clamp(1, 120), - }) + .list_asset_history(build_asset_history_list_input(asset_kind, query.limit)) .await .map_err(map_confirm_asset_object_error)?; + let owner_user_id = authenticated.claims().user_id().to_string(); Ok(json_success_body( Some(&request_context), AssetHistoryListResponse { assets: entries .into_iter() + // 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。 + .filter(|entry| { + is_asset_history_owned_by( + entry.owner_user_id.as_deref(), + owner_user_id.as_str(), + ) + }) .map(|entry| AssetHistoryEntryPayload { owner_label: format_asset_owner_label(entry.owner_user_id.as_deref()), asset_object_id: entry.asset_object_id, @@ -296,6 +302,25 @@ fn is_supported_asset_history_kind(asset_kind: &str) -> bool { SUPPORTED_ASSET_HISTORY_KINDS.contains(&asset_kind) } +fn is_asset_history_owned_by(entry_owner_user_id: Option<&str>, owner_user_id: &str) -> bool { + let owner_user_id = owner_user_id.trim(); + !owner_user_id.is_empty() + && entry_owner_user_id + .map(str::trim) + .filter(|value| !value.is_empty()) + == Some(owner_user_id) +} + +fn build_asset_history_list_input( + asset_kind: String, + limit: Option, +) -> module_assets::AssetHistoryListInput { + module_assets::AssetHistoryListInput { + asset_kind, + limit: limit.unwrap_or(120).clamp(1, 120), + } +} + fn supported_asset_history_kind_message() -> String { format!( "历史素材类型只支持 {}", @@ -490,6 +515,29 @@ mod tests { ); } + #[test] + fn asset_history_owner_filter_keeps_only_authenticated_owner_assets() { + assert!(super::is_asset_history_owned_by( + Some("user-current"), + "user-current" + )); + assert!(!super::is_asset_history_owned_by( + Some("user-other"), + "user-current" + )); + assert!(!super::is_asset_history_owned_by(None, "user-current")); + assert!(!super::is_asset_history_owned_by(Some("user-current"), "")); + } + + #[test] + fn asset_history_input_clamps_limit_for_spacetime_query() { + let input = + super::build_asset_history_list_input("puzzle_cover_image".to_string(), Some(240)); + + assert_eq!(input.asset_kind, "puzzle_cover_image"); + assert_eq!(input.limit, 120); + } + #[tokio::test] async fn direct_upload_ticket_returns_service_unavailable_when_oss_missing() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/big_fish.rs b/server-rs/crates/api-server/src/big_fish.rs index 1528ecc2..82f31bb1 100644 --- a/server-rs/crates/api-server/src/big_fish.rs +++ b/server-rs/crates/api-server/src/big_fish.rs @@ -33,9 +33,10 @@ use spacetime_client::{ BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, BigFishGameDraftRecord, - BigFishLevelBlueprintRecord, BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, - BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, BigFishSessionRecord, - BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, SpacetimeClientError, + BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, BigFishMessageSubmitRecordInput, + BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, + BigFishSessionRecord, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, + SpacetimeClientError, }; use tokio::time::sleep; @@ -60,6 +61,7 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; pub async fn create_big_fish_session( @@ -144,7 +146,7 @@ pub async fn get_big_fish_works( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -176,7 +178,7 @@ pub async fn list_big_fish_gallery( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -203,7 +205,7 @@ pub async fn delete_big_fish_work( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -245,7 +247,38 @@ pub async fn record_big_fish_play( BigFishWorksResponse { items: items .into_iter() - .map(map_big_fish_work_summary_response) + .map(|item| map_big_fish_work_summary_response(&state, item)) + .collect(), + }, + )) +} + +pub async fn record_big_fish_gallery_like( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, &session_id, "sessionId")?; + + let items = state + .spacetime_client() + .record_big_fish_like(BigFishLikeReportRecordInput { + session_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + big_fish_error_response(&request_context, map_big_fish_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + BigFishWorksResponse { + items: items + .into_iter() + .map(|item| map_big_fish_work_summary_response(&state, item)) .collect(), }, )) @@ -924,12 +957,15 @@ fn map_big_fish_agent_message_response( } fn map_big_fish_work_summary_response( + state: &AppState, item: BigFishWorkSummaryRecord, ) -> BigFishWorkSummaryResponse { + let author = resolve_work_author_by_user_id(state, &item.owner_user_id, None, None); BigFishWorkSummaryResponse { work_id: item.work_id, source_session_id: item.source_session_id, owner_user_id: item.owner_user_id, + author_display_name: author.display_name, title: item.title, subtitle: item.subtitle, summary: item.summary, diff --git a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs index 89e6e984..a26566c6 100644 --- a/server-rs/crates/api-server/src/big_fish_draft_compiler.rs +++ b/server-rs/crates/api-server/src/big_fish_draft_compiler.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use serde_json::Value as JsonValue; use crate::creation_agent_llm_turn::parse_json_response_text; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; const BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT: &str = r#"你是一个负责把“大鱼吃小鱼”玩法锚点编译成首版可实现草稿的中文玩法策划。 @@ -108,10 +109,15 @@ async fn request_big_fish_json_stage( empty_response_message: &str, ) -> Result { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(BIG_FISH_DRAFT_JSON_ONLY_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| { BigFishDraftCompileError::new(format!("{debug_label} LLM 请求失败:{error}")) @@ -124,12 +130,16 @@ async fn request_big_fish_json_stage( Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), - LlmMessage::user(format!( - "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" - )), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(BIG_FISH_DRAFT_JSON_REPAIR_SYSTEM_PROMPT), + LlmMessage::user(format!( + "请把下面这段文本修复成单个合法 JSON 对象,不要补充额外解释:\n\n{text}" + )), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| { BigFishDraftCompileError::new(format!( diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 9aee3da5..7579d214 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -1,6 +1,8 @@ use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde_json::Value as JsonValue; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + #[derive(Clone, Copy, Debug)] pub(crate) struct CreationAgentLlmTurnErrorMessages<'a> { pub model_unavailable: &'a str, @@ -69,6 +71,8 @@ fn build_creation_agent_llm_request( LlmMessage::system(system_prompt), LlmMessage::user(user_prompt), ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() .with_web_search(enable_web_search) } @@ -79,10 +83,14 @@ pub(crate) async fn request_creation_agent_json_turn( build_error: impl Fn(String) -> E, ) -> Result { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| build_error(error.to_string()))?; parse_json_response_text(response.content.as_str()) @@ -160,6 +168,8 @@ fn read_reply_text(parsed: &JsonValue) -> Option { #[cfg(test)] mod tests { + use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + use super::{ build_creation_agent_llm_request, extract_reply_text_from_partial_json, parse_json_response_text, @@ -188,6 +198,8 @@ mod tests { build_creation_agent_llm_request("系统提示".to_string(), "用户提示".to_string(), true); assert!(request.enable_web_search); + assert_eq!(request.model.as_deref(), Some(CREATION_TEMPLATE_LLM_MODEL)); + assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.messages.len(), 2); } } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 7493fb00..2946bec9 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -38,10 +38,10 @@ use spacetime_client::{ CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, - CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, SpacetimeClientError, + CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, + CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, + CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, + CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, }; use std::{collections::BTreeSet, convert::Infallible, sync::Arc, time::Instant}; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; @@ -73,6 +73,7 @@ use crate::{ }, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; @@ -414,7 +415,6 @@ pub async fn get_custom_world_library( Extension(authenticated): Extension, ) -> Result, Response> { let owner_user_id = authenticated.claims().user_id().to_string(); - let author_display_name = resolve_author_display_name(&state, &authenticated); let entries = state .spacetime_client() .list_custom_world_works(owner_user_id.clone()) @@ -430,9 +430,9 @@ pub async fn get_custom_world_library( .into_iter() .filter_map(|item| { map_custom_world_library_entry_response_from_work_summary( + &state, item, &owner_user_id, - &author_display_name, ) }) .collect(), @@ -467,7 +467,7 @@ pub async fn get_custom_world_library_detail( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } @@ -548,8 +548,11 @@ pub async fn put_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -584,7 +587,7 @@ pub async fn delete_custom_world_library_profile( CustomWorldLibraryResponse { entries: entries .into_iter() - .map(map_custom_world_library_entry_response) + .map(|entry| map_custom_world_library_entry_response(&state, entry)) .collect(), }, )) @@ -636,8 +639,11 @@ pub async fn publish_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -675,8 +681,11 @@ pub async fn unpublish_custom_world_library_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -698,7 +707,7 @@ pub async fn list_custom_world_gallery( CustomWorldGalleryResponse { entries: entries .into_iter() - .map(map_custom_world_gallery_card_response) + .map(|entry| map_custom_world_gallery_card_response(&state, entry)) .collect(), }, )) @@ -730,7 +739,7 @@ pub async fn get_custom_world_gallery_detail( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } @@ -761,7 +770,7 @@ pub async fn get_custom_world_gallery_detail_by_code( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(detail.entry), + entry: map_custom_world_library_entry_response(&state, detail.entry), }, )) } @@ -800,8 +809,11 @@ pub async fn remix_custom_world_gallery_profile( Ok(json_success_body( Some(&request_context), CustomWorldLibraryMutationResponse { - entry: map_custom_world_library_entry_response(mutation.entry.clone()), - entries: vec![map_custom_world_library_entry_response(mutation.entry)], + entry: map_custom_world_library_entry_response(&state, mutation.entry.clone()), + entries: vec![map_custom_world_library_entry_response( + &state, + mutation.entry, + )], }, )) } @@ -837,7 +849,44 @@ pub async fn record_custom_world_gallery_play( Ok(json_success_body( Some(&request_context), CustomWorldGalleryDetailResponse { - entry: map_custom_world_library_entry_response(mutation.entry), + entry: map_custom_world_library_entry_response(&state, mutation.entry), + }, + )) +} + +pub async fn record_custom_world_gallery_like( + State(state): State, + Path((owner_user_id, profile_id)): Path<(String, String)>, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + if owner_user_id.trim().is_empty() || profile_id.trim().is_empty() { + return Err(custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-gallery", + "message": "ownerUserId and profileId are required", + })), + )); + } + + let mutation = state + .spacetime_client() + .record_custom_world_profile_like(CustomWorldProfileLikeReportRecordInput { + owner_user_id, + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + custom_world_error_response(&request_context, map_custom_world_client_error(error)) + })?; + + Ok(json_success_body( + Some(&request_context), + CustomWorldGalleryDetailResponse { + entry: map_custom_world_library_entry_response(&state, mutation.entry), }, )) } @@ -2697,18 +2746,25 @@ async fn upsert_custom_world_draft_foundation_progress( } fn map_custom_world_library_entry_response( + state: &AppState, entry: CustomWorldLibraryEntryRecord, ) -> CustomWorldLibraryEntryResponse { + let author = resolve_work_author_by_user_id( + state, + &entry.owner_user_id, + Some(&entry.author_display_name), + entry.author_public_user_code.as_deref(), + ); CustomWorldLibraryEntryResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, - author_public_user_code: entry.author_public_user_code, + author_public_user_code: author.public_user_code.or(entry.author_public_user_code), profile: entry.profile, visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, - author_display_name: entry.author_display_name, + author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, @@ -2724,23 +2780,24 @@ fn map_custom_world_library_entry_response( } fn map_custom_world_library_entry_response_from_work_summary( + state: &AppState, item: CustomWorldWorkSummaryRecord, owner_user_id: &str, - author_display_name: &str, ) -> Option { let profile_id = item.profile_id.as_ref()?.clone(); let profile = build_custom_world_library_list_profile_payload(&item, &profile_id); + let author = resolve_work_author_by_user_id(state, owner_user_id, None, None); Some(CustomWorldLibraryEntryResponse { owner_user_id: owner_user_id.to_string(), public_work_code: (item.status == "published") .then(|| build_public_work_code_from_profile_id(&profile_id)), profile_id, - author_public_user_code: None, + author_public_user_code: author.public_user_code, profile, visibility: item.status, published_at: item.published_at, updated_at: item.updated_at, - author_display_name: author_display_name.to_string(), + author_display_name: author.display_name, world_name: item.title, subtitle: item.subtitle, summary_text: item.summary, @@ -2803,17 +2860,26 @@ fn build_custom_world_library_list_profile_payload( } fn map_custom_world_gallery_card_response( + state: &AppState, entry: CustomWorldGalleryEntryRecord, ) -> CustomWorldGalleryCardResponse { + let author = resolve_work_author_by_user_id( + state, + &entry.owner_user_id, + Some(&entry.author_display_name), + Some(&entry.author_public_user_code), + ); CustomWorldGalleryCardResponse { owner_user_id: entry.owner_user_id, profile_id: entry.profile_id, public_work_code: entry.public_work_code, - author_public_user_code: entry.author_public_user_code, + author_public_user_code: author + .public_user_code + .unwrap_or(entry.author_public_user_code), visibility: entry.visibility, published_at: entry.published_at, updated_at: entry.updated_at, - author_display_name: entry.author_display_name, + author_display_name: author.display_name, world_name: entry.world_name, subtitle: entry.subtitle, summary_text: entry.summary_text, diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index be247b2a..1e36853c 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -1,6 +1,8 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; + +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use spacetime_client::CustomWorldAgentSessionRecord; const CUSTOM_WORLD_AGENT_CHARACTER_EXPANSION_SYSTEM_PROMPT: &str = @@ -92,10 +94,15 @@ pub async fn generate_custom_world_agent_entities( }; let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| format!("{action} LLM 请求失败:{error}"))?; let generated_entities = parse_json_array_response(response.content.as_str()) diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 225a8fee..66559c70 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -35,6 +35,7 @@ use crate::{ build_result_scene_npc_system_prompt, build_result_scene_npc_user_prompt, }, http_error::AppError, + llm_model_routing::CREATION_TEMPLATE_LLM_MODEL, prompt::scene_background::{ DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark, SceneImagePromptParams, SceneImagePromptProfile, build_custom_world_scene_image_prompt, @@ -1039,7 +1040,10 @@ async fn generate_entity_with_fallback(state: &AppState, profile: &Value, kind: let request = LlmTextRequest::new(vec![ LlmMessage::system(build_result_entity_system_prompt()), LlmMessage::user(build_result_entity_user_prompt(profile, kind, &fallback)), - ]); + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true); llm_client .request_text(request) @@ -1065,7 +1069,10 @@ async fn generate_scene_npc_with_fallback( landmark_id, &fallback, )), - ]); + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true); llm_client .request_text(request) diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 7036decc..8314eb99 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -11,6 +11,8 @@ use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; +use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldFoundationDraftResult { pub draft_profile_json: String, @@ -174,10 +176,15 @@ where F: Fn(&str) -> String, { let response = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT), - LlmMessage::user(user_prompt), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(true), + ) .await .map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?; let text = response.content.trim(); @@ -188,10 +195,14 @@ where Ok(value) => Ok(value), Err(_) => { let repaired = llm_client - .request_text(LlmTextRequest::new(vec![ - LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT), - LlmMessage::user(repair_prompt_builder(text)), - ])) + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(FOUNDATION_JSON_REPAIR_SYSTEM_PROMPT), + LlmMessage::user(repair_prompt_builder(text)), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api(), + ) .await .map_err(|error| format!("{repair_debug_label} LLM 请求失败:{error}"))?; parse_json_response_text(repaired.content.as_str()) diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 52425df7..7ad3c59c 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -4,7 +4,7 @@ use axum::{ http::StatusCode, response::Response, }; -use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextRequest}; +use platform_llm::{LlmError, LlmMessage, LlmMessageRole, LlmTextProtocol, LlmTextRequest}; use serde_json::Value; use shared_contracts::llm::{ LlmChatCompletionRequest, LlmChatCompletionResponse, LlmChatMessagePayload, LlmChatMessageRole, @@ -39,6 +39,7 @@ pub async fn proxy_llm_chat_completions( let request = LlmTextRequest { model: payload.model, + protocol: LlmTextProtocol::ChatCompletions, messages: payload .messages .into_iter() diff --git a/server-rs/crates/api-server/src/llm_model_routing.rs b/server-rs/crates/api-server/src/llm_model_routing.rs new file mode 100644 index 00000000..78d5cdda --- /dev/null +++ b/server-rs/crates/api-server/src/llm_model_routing.rs @@ -0,0 +1,2 @@ +pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128"; +pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201"; diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 84cc2300..cd6fdece 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,6 +36,7 @@ mod health; mod http_error; mod legacy_generated_assets; mod llm; +mod llm_model_routing; mod login_options; mod logout; mod logout_all; @@ -64,6 +65,7 @@ mod story_battles; mod story_sessions; mod wechat_auth; mod wechat_provider; +mod work_author; use shared_logging::init_tracing; use tokio::net::TcpListener; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index cf6762e3..00968841 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -68,6 +68,19 @@ struct Match3DConfigJson { difficulty: u32, } +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CompileMatch3DDraftRequest { + #[serde(default)] + game_name: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + tags: Option>, + #[serde(default)] + cover_image_src: Option, +} + pub async fn create_match3d_agent_session( State(state): State, Extension(request_context): Extension, @@ -245,47 +258,59 @@ pub async fn execute_match3d_agent_action( )); } - let owner_user_id = authenticated.claims().user_id().to_string(); - let session = state - .spacetime_client() - .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let config = resolve_config_or_default(session.config.as_ref()); - let tags_json = payload - .tags - .as_ref() - .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); - let session = state - .spacetime_client() - .compile_match3d_draft(Match3DCompileDraftRecordInput { - session_id, - owner_user_id, - profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX), - author_display_name: resolve_author_display_name(&state, &authenticated), - game_name: payload - .game_name - .or_else(|| Some(format!("{}抓大鹅", config.theme_text))), - summary_text: payload.summary, - tags_json, - cover_image_src: payload.cover_image_src, - cover_asset_id: None, - compiled_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; + let session = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn compile_match3d_agent_draft( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = payload + .map(|Json(payload)| payload) + .unwrap_or(CompileMatch3DDraftRequest { + game_name: None, + summary: None, + tags: None, + cover_image_src: None, + }); + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + ) + .await?; Ok(json_success_body( Some(&request_context), @@ -818,6 +843,57 @@ async fn submit_and_finalize_match3d_message( }) } +async fn compile_match3d_draft_for_session( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + game_name: Option, + summary: Option, + tags: Option>, + cover_image_src: Option, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let config = resolve_config_or_default(session.config.as_ref()); + let tags_json = tags + .as_ref() + .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); + + state + .spacetime_client() + .compile_match3d_draft(Match3DCompileDraftRecordInput { + session_id, + owner_user_id, + profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX), + author_display_name: resolve_author_display_name(state, authenticated), + game_name: game_name.or_else(|| Some(format!("{}抓大鹅", config.theme_text))), + summary_text: summary, + tags_json, + cover_image_src, + cover_asset_id: None, + compiled_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + fn map_match3d_agent_session_response( session: Match3DAgentSessionRecord, ) -> Match3DAgentSessionSnapshotResponse { diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt/mod.rs index e00f1991..026a6877 100644 --- a/server-rs/crates/api-server/src/prompt/mod.rs +++ b/server-rs/crates/api-server/src/prompt/mod.rs @@ -1,7 +1,7 @@ pub(crate) mod big_fish; pub(crate) mod character_animation; pub(crate) mod character_visual; -pub(crate) mod puzzle_image; +pub(crate) mod puzzle; pub(crate) mod rpg; pub(crate) mod scene_background; diff --git a/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs b/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs new file mode 100644 index 00000000..f2a06525 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/agent_chat.rs @@ -0,0 +1,212 @@ +use module_puzzle::{PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack}; +use serde_json::{Value as JsonValue, json}; +use spacetime_client::{ + PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, PuzzleAnchorPackRecord, +}; + +use crate::creation_agent_anchor_templates::{ + get_creation_agent_anchor_template, render_anchor_question_block, +}; +use crate::creation_agent_chat::render_quick_fill_extra_rules; + +/// 拼图共创 Agent 的系统提示词。 +/// +/// 这里作为拼图聊天提示词主源,业务文件只负责调用 LLM、解析结果和写回状态。 +pub(crate) const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。 + +你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 + +你必须同时输出: +1. 一段直接发给用户的中文回复 replyText +2. 当前进度 progressPercent +3. 下一轮完整可用的 nextAnchorPack + +硬约束: +1. 只能输出 JSON,不能输出代码块或解释 +2. nextAnchorPack 必须是完整对象,不能只输出 patch +3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 +4. replyText 一次最多推进一个最关键问题 +5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问 +6. progressPercent 范围只能是 0 到 100 +7. status 只能使用 missing / inferred / confirmed / locked +"#; + +/// 拼图共创 Agent 单轮 JSON 输出契约。 +const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: +{ + "replyText": "", + "progressPercent": 0, + "nextAnchorPack": { + "themePromise": { + "key": "themePromise", + "label": "题材承诺", + "value": "", + "status": "missing" + }, + "visualSubject": { + "key": "visualSubject", + "label": "画面主体", + "value": "", + "status": "missing" + }, + "visualMood": { + "key": "visualMood", + "label": "视觉气质", + "value": "", + "status": "missing" + }, + "compositionHooks": { + "key": "compositionHooks", + "label": "拼图记忆点", + "value": "", + "status": "missing" + }, + "tagsAndForbidden": { + "key": "tagsAndForbidden", + "label": "标签与禁忌", + "value": "", + "status": "missing" + } + } +}"#; + +/// 拼图共创 Agent 的用户提示词,用于触发模型按系统约定返回单轮 JSON。 +pub(crate) const PUZZLE_AGENT_JSON_TURN_USER_PROMPT: &str = "请按约定输出这一轮的 JSON。"; + +/// 拼图草稿生成对话提示词脚本。 +pub(crate) fn build_puzzle_agent_prompt( + session: &PuzzleAgentSessionRecord, + quick_fill_requested: bool, +) -> String { + let anchor_question_block = get_creation_agent_anchor_template("puzzle") + .map(render_anchor_question_block) + .unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string()); + let quick_fill_rules = if quick_fill_requested { + format!( + "\n\n{}", + render_quick_fill_extra_rules( + "当前题材方向里的拼图关键词", + "不要要求用户再提供素材、风格或禁忌", + "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", + "生成结果页", + ) + ) + } else { + String::new() + }; + format!( + "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", + anchor_question_block = anchor_question_block, + quick_fill_rules = quick_fill_rules, + turn = session.current_turn.saturating_add(1), + progress = session.progress_percent, + quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, + anchor_pack = serialize_puzzle_record_anchor_pack(&session.anchor_pack), + chat_history = + serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) + .unwrap_or_else(|_| "[]".to_string()), + contract = PUZZLE_AGENT_OUTPUT_CONTRACT, + ) +} + +/// 将 SpacetimeDB 记录态锚点序列化成提示词可读 JSON。 +pub(crate) fn serialize_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> String { + serde_json::to_string_pretty(&map_puzzle_record_anchor_pack(record)).unwrap_or_else(|_| { + serde_json::to_string_pretty(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()) + }) +} + +fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec { + messages + .iter() + .map(|message| { + json!({ + "role": message.role, + "kind": message.kind, + "content": message.text, + }) + }) + .collect() +} + +fn map_puzzle_record_anchor_pack(record: &PuzzleAnchorPackRecord) -> PuzzleAnchorPack { + PuzzleAnchorPack { + theme_promise: map_puzzle_record_anchor_item(&record.theme_promise), + visual_subject: map_puzzle_record_anchor_item(&record.visual_subject), + visual_mood: map_puzzle_record_anchor_item(&record.visual_mood), + composition_hooks: map_puzzle_record_anchor_item(&record.composition_hooks), + tags_and_forbidden: map_puzzle_record_anchor_item(&record.tags_and_forbidden), + } +} + +fn map_puzzle_record_anchor_item( + record: &spacetime_client::PuzzleAnchorItemRecord, +) -> module_puzzle::PuzzleAnchorItem { + module_puzzle::PuzzleAnchorItem { + key: record.key.clone(), + label: record.label.clone(), + value: record.value.clone(), + status: parse_puzzle_anchor_status(record.status.as_str()), + } +} + +fn parse_puzzle_anchor_status(value: &str) -> PuzzleAnchorStatus { + match value { + "confirmed" => PuzzleAnchorStatus::Confirmed, + "locked" => PuzzleAnchorStatus::Locked, + "inferred" => PuzzleAnchorStatus::Inferred, + _ => PuzzleAnchorStatus::Missing, + } +} + +#[cfg(test)] +mod tests { + use super::build_puzzle_agent_prompt; + + fn anchor_item( + key: &str, + label: &str, + value: &str, + status: &str, + ) -> spacetime_client::PuzzleAnchorItemRecord { + spacetime_client::PuzzleAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: status.to_string(), + } + } + + fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { + spacetime_client::PuzzleAgentSessionRecord { + session_id: "puzzle-session-test".to_string(), + seed_text: "雨夜猫咪遗迹".to_string(), + current_turn: 2, + progress_percent: 60, + stage: "collecting_anchors".to_string(), + anchor_pack: spacetime_client::PuzzleAnchorPackRecord { + theme_promise: anchor_item("themePromise", "题材承诺", "雨夜猫咪遗迹", "confirmed"), + visual_subject: anchor_item("visualSubject", "画面主体", "", "missing"), + visual_mood: anchor_item("visualMood", "视觉气质", "", "missing"), + composition_hooks: anchor_item("compositionHooks", "拼图记忆点", "", "missing"), + tags_and_forbidden: anchor_item("tagsAndForbidden", "标签与禁忌", "", "missing"), + }, + draft: None, + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: "2026-04-24T10:00:00.000Z".to_string(), + } + } + + #[test] + fn quick_fill_prompt_forbids_follow_up_questions() { + let prompt = build_puzzle_agent_prompt(&empty_session_record(), true); + + assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); + assert!(prompt.contains("不要再继续提问")); + assert!(prompt.contains("progressPercent 直接输出为 100")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/draft.rs b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs new file mode 100644 index 00000000..ada339c0 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/draft.rs @@ -0,0 +1,86 @@ +/// 拼图作品草稿生成动作的提示词主源。 +/// +/// 拼图结果页草稿本体仍由 SpacetimeDB reducer 按表单/锚点确定性编译; +/// 这里收口 api-server 在生成草稿前后需要写入 reducer 的表单 seed 文本, +/// 以及草稿首图生成时的 prompt 来源选择,避免业务路由直接拼提示词文本。 +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct PuzzleFormSeedPromptParts<'a> { + pub(crate) title: Option<&'a str>, + pub(crate) work_description: Option<&'a str>, + pub(crate) picture_description: Option<&'a str>, +} + +/// 将填表式拼图输入编译成 SpacetimeDB 可恢复的表单 seed prompt。 +pub(crate) fn build_puzzle_form_seed_prompt(parts: PuzzleFormSeedPromptParts<'_>) -> String { + [ + ("作品名称", normalize_prompt_part(parts.title)), + ("作品描述", normalize_prompt_part(parts.work_description)), + ("画面描述", normalize_prompt_part(parts.picture_description)), + ] + .into_iter() + .filter_map(|(label, value)| value.map(|value| format!("{label}:{value}"))) + .collect::>() + .join("\n") +} + +/// 生成作品草稿时,首图 prompt 优先使用玩家当前表单里的画面描述。 +pub(crate) fn resolve_puzzle_draft_cover_prompt( + explicit_prompt: Option<&str>, + level_picture_description: &str, + draft_summary: &str, +) -> String { + normalize_prompt_part(explicit_prompt) + .or_else(|| normalize_prompt_part(Some(level_picture_description))) + .or_else(|| normalize_prompt_part(Some(draft_summary))) + .unwrap_or_default() + .to_string() +} + +/// 结果页单关重新生成时,优先使用面板当前编辑态 prompt,再回退关卡画面描述。 +pub(crate) fn resolve_puzzle_level_image_prompt( + explicit_prompt: Option<&str>, + level_picture_description: &str, +) -> String { + normalize_prompt_part(explicit_prompt) + .or_else(|| normalize_prompt_part(Some(level_picture_description))) + .unwrap_or_default() + .to_string() +} + +fn normalize_prompt_part(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_seed_prompt_keeps_only_user_visible_fields() { + let prompt = build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: Some(" 暖灯猫街 "), + work_description: Some("雨夜礼物拼图"), + picture_description: Some("猫咪在灯牌下回头"), + }); + + assert_eq!( + prompt, + "作品名称:暖灯猫街\n作品描述:雨夜礼物拼图\n画面描述:猫咪在灯牌下回头" + ); + } + + #[test] + fn draft_cover_prompt_prefers_current_picture_description() { + let prompt = + resolve_puzzle_draft_cover_prompt(Some(" 当前表单画面 "), "旧关卡画面", "作品简介"); + + assert_eq!(prompt, "当前表单画面"); + } + + #[test] + fn level_image_prompt_falls_back_to_level_description() { + let prompt = resolve_puzzle_level_image_prompt(Some(" "), "关卡画面描述"); + + assert_eq!(prompt, "关卡画面描述"); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/image.rs b/server-rs/crates/api-server/src/prompt/puzzle/image.rs new file mode 100644 index 00000000..f4370549 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/image.rs @@ -0,0 +1,103 @@ +/// 拼图图片生成的默认反向提示词。 +/// +/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排 +/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。 +pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = + "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; + +/// wan2.2 / wan2.1 文生图旧协议的正向 prompt 上限。 +/// +/// 中文注释:DashScope 旧 text2image 接口会把超长 prompt 判成请求参数不合法, +/// 所以这里先在拼图提示词模块内压缩,保证固定玩法约束不会被用户长描述挤掉。 +pub(crate) const PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS: usize = 500; + +const PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS: usize = 40; +const PUZZLE_IMAGE_PROMPT_FALLBACK: &str = "清晰、有辨识度的拼图画面"; + +/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。 +pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { + let level_name = + truncate_puzzle_prompt_segment(level_name.trim(), PUZZLE_IMAGE_LEVEL_NAME_MAX_CHARS); + let prompt = prompt.trim(); + let prompt = if prompt.is_empty() { + PUZZLE_IMAGE_PROMPT_FALLBACK + } else { + prompt + }; + let template_chars = build_puzzle_image_prompt_text(level_name.as_str(), "") + .chars() + .count(); + let prompt_max_chars = PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS.saturating_sub(template_chars); + let prompt = truncate_puzzle_prompt_segment(prompt, prompt_max_chars); + let image_prompt = build_puzzle_image_prompt_text(level_name.as_str(), prompt.as_str()); + + debug_assert!( + image_prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS, + "puzzle image prompt should fit DashScope wan2.2 limit" + ); + image_prompt +} + +fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String { + format!( + concat!( + "请生成一张高清插画。", + "画面主体:{prompt}。", + "画面要求:1:1", + "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", + "避免文字、水印、边框和 UI 元素。" + ), + prompt = prompt, + ) +} + +fn truncate_puzzle_prompt_segment(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + + const MARKER: &str = "..."; + if max_chars <= MARKER.chars().count() { + return value.chars().take(max_chars).collect(); + } + + let keep_chars = max_chars - MARKER.chars().count(); + format!( + "{}{MARKER}", + value.chars().take(keep_chars).collect::() + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() { + let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); + + assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); + assert!(prompt.contains("1:1")); + assert!(prompt.contains("主体要清晰集中")); + assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); + } + + #[test] + fn build_puzzle_image_prompt_trims_long_user_description_for_wan22() { + let long_level_name = "雨夜神庙".repeat(20); + let long_description = + "发光遗迹、猫咪、漂浮碎片、雨水反光、远处灯塔、适合拼图切块。".repeat(50); + let prompt = build_puzzle_image_prompt(long_level_name.as_str(), long_description.as_str()); + + assert!(prompt.chars().count() <= PUZZLE_TEXT_TO_IMAGE_PROMPT_MAX_CHARS); + assert!(prompt.contains("1:1")); + assert!(prompt.contains("主体要清晰集中")); + assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); + } + + #[test] + fn default_negative_prompt_blocks_text_and_low_quality_assets() { + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度")); + assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印")); + } +} diff --git a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs new file mode 100644 index 00000000..c579b9c0 --- /dev/null +++ b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod agent_chat; +pub(crate) mod draft; +pub(crate) mod image; diff --git a/server-rs/crates/api-server/src/prompt/puzzle_image.rs b/server-rs/crates/api-server/src/prompt/puzzle_image.rs deleted file mode 100644 index ebb9b204..00000000 --- a/server-rs/crates/api-server/src/prompt/puzzle_image.rs +++ /dev/null @@ -1,44 +0,0 @@ -/// 拼图图片生成的默认反向提示词。 -/// -/// 这里单独收口拼图图片提示词,避免图片生成链路、候选图持久化和 DashScope 请求编排 -/// 混在同一个脚本里,后续调画风或资产约束时只需要改这一处。 -pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = - "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; - -/// 根据拼图关卡名和陶泥主输入构造最终发给图片模型的提示词。 -pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { - format!( - concat!( - "请生成一张适合 1:1 正方形拼图关卡的高清插画。", - "关卡名:{level_name}。", - "画面主体:{prompt}。", - "画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,", - "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", - "避免文字、水印、边框和 UI 元素。" - ), - level_name = level_name, - prompt = prompt, - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_puzzle_image_prompt_keeps_puzzle_asset_constraints() { - let prompt = build_puzzle_image_prompt("雨夜神庙", "猫咪在发光遗迹前寻找线索"); - - assert!(prompt.contains("雨夜神庙")); - assert!(prompt.contains("猫咪在发光遗迹前寻找线索")); - assert!(prompt.contains("1:1 正方形拼图关卡")); - assert!(prompt.contains("3x3 或 4x4")); - assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); - } - - #[test] - fn default_negative_prompt_blocks_text_and_low_quality_assets() { - assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("低清晰度")); - assert!(PUZZLE_DEFAULT_NEGATIVE_PROMPT.contains("文字水印")); - } -} diff --git a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs index 4e8e6ea7..f02d87eb 100644 --- a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs @@ -240,7 +240,10 @@ JSON 结构: - functionSuggestions 只能从用户提示提供的 functionOptions 中挑选,不要发明 functionId。 - functionSuggestions 的 actionText 必须像玩家可点击动作,不暴露 functionId,不写规则说明。 - 非敌对聊天 shouldEndChat 必须为 false。 -- 敌对聊天可以随时 shouldEndChat=true,且敌对 NPC 更偏好在话不投机、被威胁、玩家退出、底线被触碰时结束聊天。"#; +- 敌对聊天可以随时 shouldEndChat=true。 +- 敌对 NPC 感知到玩家负面发言时,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,应倾向立即 shouldEndChat=true。 +- 敌对 NPC 已聊天轮次达到 4 轮或以上时,本轮结束后会超过 4 轮,应倾向立即 shouldEndChat=true。 +- shouldEndChat=true 时 terminationReason 使用 hostile_breakoff,suggestions 与 functionSuggestions 可以为空。"#; #[derive(Debug)] pub(crate) struct NpcChatTurnPromptInput<'a> { @@ -394,6 +397,19 @@ pub(crate) fn build_npc_chat_turn_reply_prompt(payload: &NpcChatTurnPromptInput< } else { None }, + if is_hostile_model_chat { + Some("如果玩家刚才的话被 NPC 感知为负面发言,例如挑衅、威胁、羞辱、逼问、拒绝退让、直接宣战或强行越界,本轮回复应倾向写成最后通牒、驱逐前警告或战斗前狠话。".to_string()) + } else { + None + }, + if is_hostile_model_chat && chatted_count >= 4.0 { + Some(format!( + "敌对聊天已持续 {} 轮,本轮结束后会超过 4 轮;回复应明显倾向立即收束,像开战前最后一句狠话,而不是继续闲聊。", + format_prompt_number(chatted_count) + )) + } else { + None + }, if is_player_exit_turn { Some("玩家正在主动结束这轮聊天。请对这个收束动作作出回应,并留下自然的下一步入口。回复后聊天会结束。".to_string()) } else { @@ -474,6 +490,9 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt( .and_then(|record| read_string(record.get("terminationReason"))) .as_deref() == Some("player_exit"); + let chatted_count = as_record(payload.npc_state) + .and_then(|record| read_number(record.get("chattedCount"))) + .unwrap_or(0.0); let function_options_block = chat_directive .and_then(|record| record.get("functionOptions")) .map(describe_function_options) @@ -498,6 +517,14 @@ pub(crate) fn build_npc_chat_turn_suggestion_prompt( } else { Some("这是非敌对聊天,shouldEndChat 必须为 false。".to_string()) }, + if is_hostile_model_chat { + Some(format!( + "敌对聊天判定:已聊天轮次为 {}。若玩家刚才的话可被 NPC 感知为负面发言,或已聊天轮次达到 4 轮及以上,本轮应倾向 shouldEndChat=true,并使用 terminationReason=hostile_breakoff。", + format_prompt_number(chatted_count) + )) + } else { + None + }, if is_player_exit_turn { Some("玩家已经选择结束聊天,shouldEndChat 必须为 true,terminationReason 必须为 player_exit。".to_string()) } else { @@ -526,6 +553,20 @@ pub(crate) fn build_deterministic_npc_reply( format!("{npc_name}听完你的话,回应道:“{player_message}。我明白你的意思,我们继续说。”") } +pub(crate) fn build_deterministic_hostile_breakoff_reply( + npc_name: &str, + player_message: &str, +) -> String { + // 中文注释:当模型不可用而敌对聊天必须中止时,兜底文案也保持“战斗前狠话”的语气。 + let player_signal = player_message.trim(); + if player_signal.is_empty() { + return format!("{npc_name}冷声说道:“话已经够多了。再往前一步,就别指望还能全身而退。”"); + } + format!( + "{npc_name}冷声说道:“{player_signal}?话已经够多了。再往前一步,就别指望还能全身而退。”" + ) +} + pub(crate) fn build_character_chat_reply_fallback( target_character: &Value, player_message: &str, @@ -1066,3 +1107,55 @@ fn format_prompt_number(value: f64) -> String { value.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn hostile_prompt_input(npc_state: Value) -> NpcChatTurnPromptInput<'static> { + NpcChatTurnPromptInput { + world_type: "CUSTOM", + character: Box::leak(Box::new(Value::Null)), + encounter: Box::leak(Box::new(Value::Null)), + monsters: &[], + history: &[], + context: Box::leak(Box::new(Value::Null)), + conversation_history: &[], + dialogue: &[], + combat_context: None, + player_message: "少废话,让开。", + npc_state: Box::leak(Box::new(npc_state)), + npc_initiates_conversation: false, + chat_directive: Some(Box::leak(Box::new(json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + })))), + } + } + + #[test] + fn hostile_reply_prompt_mentions_final_threat_after_four_turns() { + let input = hostile_prompt_input(json!({ + "affinity": -12, + "chattedCount": 4, + })); + let prompt = build_npc_chat_turn_reply_prompt(&input); + + assert!(prompt.contains("已聊天轮次:4")); + assert!(prompt.contains("战斗前狠话")); + assert!(prompt.contains("本轮结束后会超过 4 轮")); + } + + #[test] + fn hostile_suggestion_prompt_mentions_should_end_chat_signals() { + let input = hostile_prompt_input(json!({ + "affinity": -12, + "chattedCount": 4, + })); + let prompt = build_npc_chat_turn_suggestion_prompt(&input, "再往前一步,就别想回头。"); + + assert!(prompt.contains("shouldEndChat=true")); + assert!(prompt.contains("terminationReason=hostile_breakoff")); + assert!(prompt.contains("已聊天轮次为 4")); + } +} diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 3474b9d7..a1c26f29 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -17,7 +17,10 @@ use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; -use module_puzzle::{PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; +use module_puzzle::{ + PuzzleBoardSnapshot, PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus, + PuzzleWorkProfile, resolve_puzzle_level_config, +}; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, @@ -29,18 +32,19 @@ use shared_contracts::{ PuzzleAgentActionResponse, PuzzleAgentMessageResponse, PuzzleAgentOperationResponse, PuzzleAgentSessionResponse, PuzzleAgentSessionSnapshotResponse, PuzzleAgentSuggestedActionResponse, PuzzleAnchorItemResponse, PuzzleAnchorPackResponse, - PuzzleCreatorIntentResponse, PuzzleGeneratedImageCandidateResponse, - PuzzleResultDraftResponse, PuzzleResultPreviewBlockerResponse, - PuzzleResultPreviewEnvelopeResponse, PuzzleResultPreviewFindingResponse, - SendPuzzleAgentMessageRequest, + PuzzleCreatorIntentResponse, PuzzleDraftLevelResponse, PuzzleFormDraftResponse, + PuzzleGeneratedImageCandidateResponse, PuzzleResultDraftResponse, + PuzzleResultPreviewBlockerResponse, PuzzleResultPreviewEnvelopeResponse, + PuzzleResultPreviewFindingResponse, SendPuzzleAgentMessageRequest, }, puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse}, puzzle_runtime::{ AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, - PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse, - PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, - SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest, + PuzzlePieceStateResponse, PuzzleRecommendedNextWorkResponse, PuzzleRunResponse, + PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, + SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, + UsePuzzleRuntimePropRequest, }, puzzle_works::{ PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse, @@ -52,14 +56,16 @@ use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, - PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, - PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, + PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, + PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, + PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, + PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, - PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; @@ -71,13 +77,20 @@ use crate::{ asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, http_error::AppError, - prompt::puzzle_image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, + prompt::puzzle::{ + draft::{ + PuzzleFormSeedPromptParts, build_puzzle_form_seed_prompt, + resolve_puzzle_draft_cover_prompt, resolve_puzzle_level_image_prompt, + }, + image::{PUZZLE_DEFAULT_NEGATIVE_PROMPT, build_puzzle_image_prompt}, + }, puzzle_agent_turn::{ PuzzleAgentTurnRequest, build_failed_finalize_record_input, build_finalize_record_input, run_puzzle_agent_turn, }, request_context::RequestContext, state::AppState, + work_author::resolve_work_author_by_user_id, }; const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; @@ -445,8 +458,44 @@ pub async fn execute_puzzle_agent_action( let now = current_utc_micros(); let action = payload.action.trim().to_string(); let billing_asset_id = format!("{session_id}:{now}"); + tracing::info!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + action = %action, + prompt_chars = payload + .prompt_text + .as_deref() + .map(|value| value.chars().count()) + .unwrap_or(0), + has_reference_image = payload + .reference_image_src + .as_deref() + .map(|value| !value.trim().is_empty()) + .unwrap_or(false), + "拼图 Agent action 开始执行" + ); let (operation_type, phase_label, phase_detail, session) = match action.as_str() { "compile_puzzle_draft" => { + let prompt_text = payload + .picture_description + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| payload.prompt_text.as_deref()); + let compile_session_id = match save_puzzle_form_payload_before_compile( + &state, + &request_context, + &session_id, + &owner_user_id, + &payload, + now, + ) + .await + { + Ok(next_session_id) => next_session_id, + Err(response) => return Err(response), + }; let session = execute_billable_asset_operation( &state, &owner_user_id, @@ -455,14 +504,14 @@ pub async fn execute_puzzle_agent_action( async { compile_puzzle_draft_with_initial_cover( &state, - session_id.clone(), + compile_session_id.clone(), owner_user_id.clone(), - payload.prompt_text.as_deref(), + prompt_text, payload.reference_image_src.as_deref(), now, ) .await - .map_err(map_puzzle_client_error) + .map_err(map_puzzle_compile_error) }, ) .await @@ -476,37 +525,106 @@ pub async fn execute_puzzle_agent_action( session, ) } + "save_puzzle_form_draft" => { + let seed_text = build_puzzle_form_seed_text_from_parts( + payload.work_title.as_deref(), + payload.work_description.as_deref(), + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.clone(), + seed_text, + saved_at_micros: now, + }) + .await; + let session = match save_result { + Ok(session) => Ok(session), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + // 中文注释:Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图表单自动保存 procedure 缺失,降级返回当前会话" + ); + state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|fallback_error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(fallback_error), + ) + }) + } + Err(error) => Err(puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + }; + ( + "save_puzzle_form_draft", + "表单草稿保存", + "拼图表单草稿已保存。", + session, + ) + } "generate_puzzle_images" => { + let target_level_id = payload.level_id.clone(); + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": message, + })) + }); let session = execute_billable_asset_operation( &state, &owner_user_id, "puzzle_generated_image", &billing_asset_id, async { + let levels_json = levels_json?; let session = state .spacetime_client() .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) .await .map_err(map_puzzle_client_error)?; - let draft = session.draft.clone().ok_or_else(|| { + let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, "message": "拼图结果页草稿尚未生成", })) })?; - let prompt = payload - .prompt_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| draft.summary.clone()); + if let Some(levels_json) = levels_json.as_ref() { + draft.levels = parse_puzzle_level_records_from_module_json(levels_json)?; + } + let target_level = + select_puzzle_level_for_api(&draft, target_level_id.as_deref())?; + let prompt = resolve_puzzle_level_image_prompt( + payload.prompt_text.as_deref(), + &target_level.picture_description, + ); // 拼图结果页从多候选抽卡收口为单图替换,前端传入的旧 candidateCount 只做兼容忽略。 let candidate_count = 1; - let candidate_start_index = draft.candidates.len(); + let candidate_start_index = target_level.candidates.len(); let candidates = generate_puzzle_image_candidates( &state, owner_user_id.as_str(), &session.session_id, - &draft.level_name, + &target_level.level_name, &prompt, payload.reference_image_src.as_deref(), candidate_count, @@ -536,6 +654,8 @@ pub async fn execute_puzzle_agent_action( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id), + levels_json, candidates_json, saved_at_micros: now, }) @@ -571,6 +691,7 @@ pub async fn execute_puzzle_agent_action( .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id: session_id.clone(), owner_user_id: owner_user_id.clone(), + level_id: payload.level_id.clone(), candidate_id, selected_at_micros: now, }) @@ -590,6 +711,19 @@ pub async fn execute_puzzle_agent_action( ) } "publish_puzzle_work" => { + let levels_json = normalize_puzzle_levels_json_for_module( + payload.levels_json.as_deref(), + ) + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": error, + })), + ) + })?; let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id); let author_display_name = resolve_author_display_name(&state, &authenticated); let profile = execute_billable_asset_operation( @@ -607,9 +741,12 @@ pub async fn execute_puzzle_agent_action( work_id: work_id.clone(), profile_id, author_display_name, + work_title: payload.work_title.clone(), + work_description: payload.work_description.clone(), level_name: payload.level_name.clone(), summary: payload.summary.clone(), theme_tags: payload.theme_tags.clone(), + levels_json, published_at_micros: now, }) .await @@ -699,7 +836,7 @@ pub async fn get_puzzle_works( PuzzleWorksResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) @@ -733,7 +870,7 @@ pub async fn get_puzzle_work_detail( Ok(json_success_body( Some(&request_context), PuzzleWorkDetailResponse { - item: map_puzzle_work_profile_response(item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -767,11 +904,17 @@ pub async fn put_puzzle_work( .update_puzzle_work(PuzzleWorkUpsertRecordInput { profile_id, owner_user_id: authenticated.claims().user_id().to_string(), + work_title: payload.work_title, + work_description: payload.work_description, level_name: payload.level_name, summary: payload.summary, theme_tags: payload.theme_tags, cover_image_src: payload.cover_image_src, cover_asset_id: payload.cover_asset_id, + levels_json: Some(serialize_puzzle_levels_response( + &request_context, + &payload.levels, + )?), updated_at_micros: current_utc_micros(), }) .await @@ -786,7 +929,7 @@ pub async fn put_puzzle_work( Ok(json_success_body( Some(&request_context), PuzzleWorkMutationResponse { - item: map_puzzle_work_profile_response(item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -821,12 +964,49 @@ pub async fn delete_puzzle_work( PuzzleWorksResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) } +pub async fn claim_puzzle_work_point_incentive( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .claim_puzzle_work_point_incentive(PuzzleWorkPointIncentiveClaimRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + claimed_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_WORKS_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleWorkMutationResponse { + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + pub async fn list_puzzle_gallery( State(state): State, Extension(request_context): Extension, @@ -848,7 +1028,7 @@ pub async fn list_puzzle_gallery( PuzzleGalleryResponse { items: items .into_iter() - .map(map_puzzle_work_summary_response) + .map(|item| map_puzzle_work_summary_response(&state, item)) .collect(), }, )) @@ -881,7 +1061,44 @@ pub async fn get_puzzle_gallery_detail( Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { - item: map_puzzle_work_summary_response(item), + item: map_puzzle_work_profile_response(&state, item), + }, + )) +} + +pub async fn record_puzzle_gallery_like( + State(state): State, + AxumPath(profile_id): AxumPath, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + PUZZLE_GALLERY_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .record_puzzle_work_like(PuzzleWorkLikeReportRecordInput { + profile_id, + user_id: authenticated.claims().user_id().to_string(), + liked_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + puzzle_error_response( + &request_context, + PUZZLE_GALLERY_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + PuzzleGalleryDetailResponse { + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -958,6 +1175,7 @@ pub async fn start_puzzle_run( run_id: build_prefixed_uuid_id("puzzle-run-"), owner_user_id: authenticated.claims().user_id().to_string(), profile_id: payload.profile_id, + level_id: payload.level_id, started_at_micros: current_utc_micros(), }) .await @@ -972,7 +1190,7 @@ pub async fn start_puzzle_run( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1000,7 +1218,7 @@ pub async fn get_puzzle_run( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1057,7 +1275,7 @@ pub async fn swap_puzzle_pieces( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1089,7 +1307,7 @@ pub async fn advance_puzzle_next_level( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1133,7 +1351,7 @@ pub async fn update_puzzle_run_pause( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1169,6 +1387,7 @@ pub async fn use_puzzle_runtime_prop( "hint" => "puzzle_prop_hint", "reference" => "puzzle_prop_preview", "freezeTime" | "freeze_time" => "puzzle_prop_freeze_time", + "extendTime" | "extend_time" => "puzzle_prop_extend_time", _ => { return Err(puzzle_bad_request( &request_context, @@ -1192,6 +1411,7 @@ pub async fn use_puzzle_runtime_prop( owner_user_id: reducer_owner_user_id, prop_kind, used_at_micros: current_utc_micros(), + spent_points: crate::asset_billing::ASSET_OPERATION_POINTS_COST, }) .await .map_err(map_puzzle_client_error) @@ -1203,7 +1423,7 @@ pub async fn use_puzzle_runtime_prop( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1233,7 +1453,7 @@ pub async fn advance_local_puzzle_next_level( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1281,7 +1501,7 @@ pub async fn submit_puzzle_leaderboard( Ok(json_success_body( Some(&request_context), PuzzleRunResponse { - run: map_puzzle_run_response(run), + run: map_puzzle_run_response(enrich_puzzle_run_author_name(&state, run).await), }, )) } @@ -1291,6 +1511,7 @@ fn map_puzzle_agent_session_response( ) -> PuzzleAgentSessionSnapshotResponse { PuzzleAgentSessionSnapshotResponse { session_id: session.session_id, + seed_text: session.seed_text, current_turn: session.current_turn, progress_percent: session.progress_percent, stage: session.stage, @@ -1338,6 +1559,8 @@ fn map_puzzle_anchor_item_response(anchor: PuzzleAnchorItemRecord) -> PuzzleAnch fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleResultDraftResponse { PuzzleResultDraftResponse { + work_title: draft.work_title, + work_description: draft.work_description, level_name: draft.level_name, summary: draft.summary, theme_tags: draft.theme_tags, @@ -1353,6 +1576,37 @@ fn map_puzzle_result_draft_response(draft: PuzzleResultDraftRecord) -> PuzzleRes cover_image_src: draft.cover_image_src, cover_asset_id: draft.cover_asset_id, generation_status: draft.generation_status, + levels: draft + .levels + .into_iter() + .map(map_puzzle_draft_level_response) + .collect(), + form_draft: draft.form_draft.map(map_puzzle_form_draft_response), + } +} + +fn map_puzzle_form_draft_response(draft: PuzzleFormDraftRecord) -> PuzzleFormDraftResponse { + PuzzleFormDraftResponse { + work_title: draft.work_title, + work_description: draft.work_description, + picture_description: draft.picture_description, + } +} + +fn map_puzzle_draft_level_response(level: PuzzleDraftLevelRecord) -> PuzzleDraftLevelResponse { + PuzzleDraftLevelResponse { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + candidates: level + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate_response) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, } } @@ -1447,13 +1701,24 @@ fn map_puzzle_result_preview_finding_response( } } -fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkSummaryResponse { +fn map_puzzle_work_summary_response( + state: &AppState, + item: PuzzleWorkProfileRecord, +) -> PuzzleWorkSummaryResponse { + let author = resolve_work_author_by_user_id( + state, + &item.owner_user_id, + Some(&item.author_display_name), + None, + ); PuzzleWorkSummaryResponse { work_id: item.work_id, profile_id: item.profile_id, owner_user_id: item.owner_user_id, source_session_id: item.source_session_id, - author_display_name: item.author_display_name, + author_display_name: author.display_name, + work_title: item.work_title, + work_description: item.work_description, level_name: item.level_name, summary: item.summary, theme_tags: item.theme_tags, @@ -1466,13 +1731,31 @@ fn map_puzzle_work_summary_response(item: PuzzleWorkProfileRecord) -> PuzzleWork remix_count: item.remix_count, like_count: item.like_count, recent_play_count_7d: item.recent_play_count_7d, + point_incentive_total_half_points: item.point_incentive_total_half_points, + point_incentive_claimed_points: item.point_incentive_claimed_points, + point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0, + point_incentive_claimable_points: item + .point_incentive_total_half_points + .saturating_div(2) + .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, + levels: Vec::new(), } } -fn map_puzzle_work_profile_response(item: PuzzleWorkProfileRecord) -> PuzzleWorkProfileResponse { +fn map_puzzle_work_profile_response( + state: &AppState, + item: PuzzleWorkProfileRecord, +) -> PuzzleWorkProfileResponse { + let mut summary = map_puzzle_work_summary_response(state, item.clone()); + summary.levels = item + .levels + .into_iter() + .map(map_puzzle_draft_level_response) + .collect(); + PuzzleWorkProfileResponse { - summary: map_puzzle_work_summary_response(item.clone()), + summary, anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack), } } @@ -1488,6 +1771,14 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_runtime_level_response), recommended_next_profile_id: run.recommended_next_profile_id, + next_level_mode: run.next_level_mode, + next_level_profile_id: run.next_level_profile_id, + next_level_id: run.next_level_id, + recommended_next_works: run + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work_response) + .collect(), leaderboard_entries: run .leaderboard_entries .into_iter() @@ -1496,6 +1787,42 @@ fn map_puzzle_run_response(run: PuzzleRunRecord) -> PuzzleRunSnapshotResponse { } } +fn map_puzzle_recommended_next_work_response( + item: PuzzleRecommendedNextWorkRecord, +) -> PuzzleRecommendedNextWorkResponse { + PuzzleRecommendedNextWorkResponse { + profile_id: item.profile_id, + level_name: item.level_name, + author_display_name: item.author_display_name, + theme_tags: item.theme_tags, + cover_image_src: item.cover_image_src, + similarity_score: item.similarity_score, + } +} + +async fn enrich_puzzle_run_author_name( + state: &AppState, + mut run: PuzzleRunRecord, +) -> PuzzleRunRecord { + if let Some(level) = run.current_level.as_mut() { + if let Ok(profile) = state + .spacetime_client() + .get_puzzle_gallery_detail(level.profile_id.clone()) + .await + { + level.author_display_name = resolve_work_author_by_user_id( + state, + &profile.owner_user_id, + Some(&profile.author_display_name), + None, + ) + .display_name; + } + } + + run +} + fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRecord { PuzzleRunRecord { run_id: run.run_id, @@ -1507,6 +1834,14 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec previous_level_tags: run.previous_level_tags, current_level: run.current_level.map(map_puzzle_level_request_record), recommended_next_profile_id: run.recommended_next_profile_id, + next_level_mode: run.next_level_mode, + next_level_profile_id: run.next_level_profile_id, + next_level_id: run.next_level_id, + recommended_next_works: run + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work_request_record) + .collect(), leaderboard_entries: run .leaderboard_entries .into_iter() @@ -1515,12 +1850,26 @@ fn map_puzzle_run_request_record(run: PuzzleRunSnapshotResponse) -> PuzzleRunRec } } +fn map_puzzle_recommended_next_work_request_record( + item: PuzzleRecommendedNextWorkResponse, +) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: item.profile_id, + level_name: item.level_name, + author_display_name: item.author_display_name, + theme_tags: item.theme_tags, + cover_image_src: item.cover_image_src, + similarity_score: item.similarity_score, + } +} + fn map_puzzle_level_request_record( level: PuzzleRuntimeLevelSnapshotResponse, ) -> PuzzleRuntimeLevelRecord { PuzzleRuntimeLevelRecord { run_id: level.run_id, level_index: level.level_index, + level_id: level.level_id, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, @@ -1598,7 +1947,8 @@ fn map_puzzle_board_request_record(board: PuzzleBoardSnapshotResponse) -> Puzzle fn map_puzzle_runtime_level_response( level: spacetime_client::PuzzleRuntimeLevelRecord, ) -> PuzzleRuntimeLevelSnapshotResponse { - let timer_defaults = build_puzzle_runtime_timer_response_defaults(level.grid_size); + let timer_defaults = + build_puzzle_runtime_timer_response_defaults(level.level_index, level.grid_size); let time_limit_ms = if level.time_limit_ms == 0 { timer_defaults.time_limit_ms } else { @@ -1613,6 +1963,7 @@ fn map_puzzle_runtime_level_response( PuzzleRuntimeLevelSnapshotResponse { run_id: level.run_id, level_index: level.level_index, + level_id: level.level_id, grid_size: level.grid_size, profile_id: level.profile_id, level_name: level.level_name, @@ -1644,9 +1995,14 @@ struct PuzzleRuntimeTimerResponseDefaults { } fn build_puzzle_runtime_timer_response_defaults( + level_index: u32, grid_size: u32, ) -> PuzzleRuntimeTimerResponseDefaults { - let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size); + let time_limit_ms = if level_index > 0 { + module_puzzle::resolve_puzzle_level_time_limit_ms_by_index(level_index) + } else { + module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size) + }; PuzzleRuntimeTimerResponseDefaults { time_limit_ms } } @@ -1723,21 +2079,289 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String { } fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { - let title = payload.seed_text.as_deref().unwrap_or_default().trim(); - let picture_description = payload - .picture_description - .as_deref() - .unwrap_or_default() - .trim(); + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: payload + .work_title + .as_deref() + .or(payload.seed_text.as_deref()), + work_description: payload.work_description.as_deref(), + picture_description: payload.picture_description.as_deref(), + }) +} - if title.is_empty() && picture_description.is_empty() { - return String::new(); - } - if title.is_empty() || picture_description.is_empty() { - return format!("{title}{picture_description}"); +fn build_puzzle_form_seed_text_from_parts( + title: Option<&str>, + work_description: Option<&str>, + picture_description: Option<&str>, +) -> String { + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title, + work_description, + picture_description, + }) +} + +async fn save_puzzle_form_payload_before_compile( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + now: i64, +) -> Result { + let seed_text = build_puzzle_form_seed_text_from_parts( + payload.work_title.as_deref(), + payload.work_description.as_deref(), + payload + .picture_description + .as_deref() + .or(payload.prompt_text.as_deref()), + ); + if seed_text.trim().is_empty() { + return Ok(session_id.to_string()); } - format!("拼图标题:{title}\n画面描述:{picture_description}") + let save_result = state + .spacetime_client() + .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + saved_at_micros: now, + }) + .await + .map(|_| ()); + match save_result { + Ok(()) => Ok(session_id.to_string()), + Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { + create_seeded_puzzle_session_when_form_save_missing( + state, + request_context, + session_id, + owner_user_id, + seed_text, + now, + &error, + ) + .await + } + Err(error) => Err(puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + )), + } +} + +async fn create_seeded_puzzle_session_when_form_save_missing( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + seed_text: String, + now: i64, + original_error: &SpacetimeClientError, +) -> Result { + let current_session = state + .spacetime_client() + .get_puzzle_agent_session(session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + if !current_session.seed_text.trim().is_empty() { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,沿用已有 seed_text 编译" + ); + return Ok(session_id.to_string()); + } + + // 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 + let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); + let replacement = state + .spacetime_client() + .create_puzzle_agent_session(PuzzleAgentSessionCreateRecordInput { + session_id: replacement_session_id.clone(), + owner_user_id: owner_user_id.to_string(), + seed_text: seed_text.clone(), + welcome_message_id: build_prefixed_uuid_id("puzzle-message-"), + welcome_message_text: build_puzzle_welcome_text(&seed_text), + created_at_micros: now, + }) + .await + .map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_AGENT_API_BASE_PROVIDER, + map_puzzle_client_error(error), + ) + })?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + old_session_id = %session_id, + new_session_id = %replacement.session_id, + owner_user_id, + error = %original_error, + "拼图表单草稿保存 procedure 缺失,已创建带表单 seed 的替代 session" + ); + Ok(replacement.session_id) +} + +fn select_puzzle_level_for_api( + draft: &PuzzleResultDraftRecord, + level_id: Option<&str>, +) -> Result { + let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); + if let Some(target_id) = normalized_level_id { + return draft + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡不存在:{target_id}"), + })) + }); + } + let level = draft.levels.first().cloned(); + level.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + }) +} + +fn parse_puzzle_level_records_from_module_json( + value: &str, +) -> Result, AppError> { + let levels: Vec = + serde_json::from_str(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图关卡列表 JSON 非法:{error}"), + })) + })?; + Ok(levels + .into_iter() + .map(|level| PuzzleDraftLevelRecord { + level_id: level.level_id, + level_name: level.level_name, + picture_description: level.picture_description, + candidates: level + .candidates + .into_iter() + .map(|candidate| PuzzleGeneratedImageCandidateRecord { + candidate_id: candidate.candidate_id, + image_src: candidate.image_src, + asset_id: candidate.asset_id, + prompt: candidate.prompt, + actual_prompt: candidate.actual_prompt, + source_type: candidate.source_type, + selected: candidate.selected, + }) + .collect(), + selected_candidate_id: level.selected_candidate_id, + cover_image_src: level.cover_image_src, + cover_asset_id: level.cover_asset_id, + generation_status: level.generation_status, + }) + .collect()) +} + +fn serialize_puzzle_levels_response( + request_context: &RequestContext, + levels: &[PuzzleDraftLevelResponse], +) -> Result { + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload).map_err(|error| { + puzzle_error_response( + request_context, + PUZZLE_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_WORKS_PROVIDER, + "message": format!("拼图关卡列表序列化失败:{error}"), + })), + ) + }) +} + +fn normalize_puzzle_levels_json_for_module(value: Option<&str>) -> Result, String> { + let Some(raw) = value.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok(None); + }; + let levels: Vec = + serde_json::from_str(raw).map_err(|error| format!("拼图关卡列表 JSON 非法:{error}"))?; + let payload = levels + .iter() + .map(|level| { + json!({ + "level_id": level.level_id, + "level_name": level.level_name, + "picture_description": level.picture_description, + "candidates": level + .candidates + .iter() + .map(|candidate| { + json!({ + "candidate_id": candidate.candidate_id, + "image_src": candidate.image_src, + "asset_id": candidate.asset_id, + "prompt": candidate.prompt, + "actual_prompt": candidate.actual_prompt, + "source_type": candidate.source_type, + "selected": candidate.selected, + }) + }) + .collect::>(), + "selected_candidate_id": level.selected_candidate_id, + "cover_image_src": level.cover_image_src, + "cover_asset_id": level.cover_asset_id, + "generation_status": level.generation_status, + }) + }) + .collect::>(); + serde_json::to_string(&payload) + .map(Some) + .map_err(|error| format!("拼图关卡列表序列化失败:{error}")) } fn build_stable_puzzle_work_ids(session_id: &str) -> (String, String) { @@ -1766,19 +2390,23 @@ async fn compile_puzzle_draft_with_initial_cover( .draft .clone() .ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?; + let target_level = select_puzzle_level_for_api(&draft, None) + .map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?; + let image_prompt = resolve_puzzle_draft_cover_prompt( + prompt_text, + &target_level.picture_description, + &draft.summary, + ); // 点击生成草稿时一次性完成首图生成与正式图选定,前端只展示进度,不再承担业务编排。 let candidates = generate_puzzle_image_candidates( state, owner_user_id.as_str(), &compiled_session.session_id, - &draft.level_name, - prompt_text - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(draft.summary.as_str()), + &target_level.level_name, + &image_prompt, reference_image_src, 1, - draft.candidates.len(), + target_level.candidates.len(), ) .await .map_err(SpacetimeClientError::Runtime)?; @@ -1800,6 +2428,8 @@ async fn compile_puzzle_draft_with_initial_cover( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), owner_user_id: owner_user_id.clone(), + level_id: Some(target_level.level_id.clone()), + levels_json: None, candidates_json, saved_at_micros: current_utc_micros(), }) @@ -1809,6 +2439,7 @@ async fn compile_puzzle_draft_with_initial_cover( .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, owner_user_id, + level_id: Some(target_level.level_id), candidate_id: selected_candidate_id, selected_at_micros: current_utc_micros(), }) @@ -1872,6 +2503,63 @@ fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { })) } +fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> bool { + matches!(error, SpacetimeClientError::Procedure(message) if + message.contains("save_puzzle_form_draft") + && (message.contains("No such procedure") + || message.contains("不存在") + || message.contains("does not exist") + || message.contains("not found"))) +} + +fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { + let message = error.to_string(); + let provider = if message.contains("DashScope") || message.contains("dashscope") { + "dashscope" + } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { + "puzzle-assets" + } else { + "spacetimedb" + }; + let status = if matches!(error, SpacetimeClientError::Runtime(_)) + && (message.contains("生成") + || message.contains("上游") + || message.contains("DashScope") + || message.contains("dashscope") + || message.contains("参考图") + || message.contains("图片") + || message.contains("OSS") + || message.contains("oss")) + { + StatusCode::BAD_GATEWAY + } else { + match &error { + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("当前模型不可用") + || message.contains("生成失败") + || message.contains("解析失败") + || message.contains("缺少有效回复") => + { + StatusCode::BAD_GATEWAY + } + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::BAD_GATEWAY, + } + }; + + AppError::from_status(status).with_details(json!({ + "provider": provider, + "message": message, + })) +} + fn puzzle_error_response( request_context: &RequestContext, provider: &str, @@ -1914,6 +2602,15 @@ fn puzzle_sse_error_event_message(message: String) -> Event { Event::default().event("error").data(payload) } +fn map_puzzle_generation_app_error(error: AppError) -> String { + let body_text = error.body_text(); + if error.code() == "UPSTREAM_ERROR" { + format!("拼图图片生成失败:{body_text}") + } else { + body_text + } +} + async fn generate_puzzle_image_candidates( state: &AppState, owner_user_id: &str, @@ -1926,10 +2623,22 @@ async fn generate_puzzle_image_candidates( ) -> Result, String> { let count = candidate_count.clamp(1, 1); let settings = - require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?; - let http_client = build_puzzle_dashscope_http_client(&settings) - .map_err(|error| error.message().to_string())?; + require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?; + let http_client = + build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?; let actual_prompt = build_puzzle_image_prompt(level_name, prompt); + tracing::info!( + provider = "dashscope", + session_id, + level_name, + prompt_chars = prompt.chars().count(), + actual_prompt_chars = actual_prompt.chars().count(), + has_reference_image = reference_image_src + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false), + "拼图图片生成请求已准备" + ); let reference_image = match reference_image_src .map(str::trim) .filter(|value| !value.is_empty()) @@ -1937,7 +2646,7 @@ async fn generate_puzzle_image_candidates( Some(source) => Some( resolve_puzzle_reference_image_as_data_url(state, &http_client, source) .await - .map_err(|error| error.message().to_string())?, + .map_err(map_puzzle_generation_app_error)?, ), None => None, }; @@ -1968,7 +2677,7 @@ async fn generate_puzzle_image_candidates( .await } } - .map_err(|error| error.message().to_string())?; + .map_err(map_puzzle_generation_app_error)?; let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { @@ -1987,7 +2696,7 @@ async fn generate_puzzle_image_candidates( current_utc_micros(), ) .await - .map_err(|error| error.message().to_string())?; + .map_err(map_puzzle_generation_app_error)?; items.push(PuzzleGeneratedImageCandidateResponse { candidate_id, image_src: asset.image_src, @@ -2035,11 +2744,21 @@ async fn build_local_next_puzzle_run( ); } - if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { - return Ok(build_next_run_from_puzzle_work(run, gallery_item)); + let source_session_id = payload.source_session_id.unwrap_or_default(); + if let Some(next_run) = + build_same_work_local_next_puzzle_run(state, &run, &source_session_id, owner_user_id) + .await? + { + return Ok(next_run); + } + + let current_work = fetch_local_current_work_detail(state, &run).await?; + let similar_works = + resolve_gallery_similar_puzzle_works(state, &run, current_work.as_ref()).await?; + if !similar_works.is_empty() { + return Ok(build_local_similar_works_handoff(run, similar_works)); } - let source_session_id = payload.source_session_id.unwrap_or_default(); if source_session_id.trim().is_empty() { return Err( AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -2101,6 +2820,8 @@ async fn build_local_next_puzzle_run( .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: session.session_id, owner_user_id: owner_user_id.to_string(), + level_id: None, + levels_json: None, candidates_json, saved_at_micros: current_utc_micros(), }) @@ -2128,23 +2849,282 @@ async fn build_local_next_puzzle_run( )) } -async fn resolve_gallery_next_puzzle_work( +async fn build_same_work_local_next_puzzle_run( + state: &AppState, + run: &PuzzleRunRecord, + source_session_id: &str, + owner_user_id: &str, +) -> Result, AppError> { + if !should_use_same_work_next_level(run) { + return Ok(None); + } + + if let Some(work) = fetch_local_current_work_detail(state, run).await? { + if let Some(level) = select_local_next_level(&work.levels, run) { + let next_after_level = + select_next_level_after_level_id(&work.levels, level.level_id.as_str()) + .map(|item| item.level_id.clone()); + return Ok(Some(build_next_run_from_draft_level( + run.clone(), + level, + Some(work.profile_id), + work.author_display_name, + work.theme_tags, + next_after_level, + ))); + } + } + + let normalized_session_id = source_session_id.trim(); + if normalized_session_id.is_empty() { + return Ok(None); + } + let session = state + .spacetime_client() + .get_puzzle_agent_session(normalized_session_id.to_string(), owner_user_id.to_string()) + .await + .map_err(map_puzzle_client_error)?; + let Some(draft) = session.draft.as_ref() else { + return Ok(None); + }; + if let Some(level) = select_local_next_level(&draft.levels, run) { + let next_after_level = + select_next_level_after_level_id(&draft.levels, level.level_id.as_str()) + .map(|item| item.level_id.clone()); + return Ok(Some(build_next_run_from_draft_level( + run.clone(), + level, + Some(run.entry_profile_id.clone()), + "当前草稿".to_string(), + draft.theme_tags.clone(), + next_after_level, + ))); + } + + Ok(None) +} + +fn should_use_same_work_next_level(run: &PuzzleRunRecord) -> bool { + run.next_level_mode == module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK + || run + .next_level_id + .as_ref() + .is_some_and(|value| !value.trim().is_empty()) +} + +async fn fetch_local_current_work_detail( state: &AppState, run: &PuzzleRunRecord, ) -> Result, AppError> { + let profile_id = run + .next_level_profile_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + run.current_level + .as_ref() + .map(|level| level.profile_id.as_str()) + .filter(|value| !value.trim().is_empty()) + }) + .unwrap_or(run.entry_profile_id.as_str()); + match state + .spacetime_client() + .get_puzzle_gallery_detail(profile_id.to_string()) + .await + { + Ok(work) => Ok(Some(work)), + Err(SpacetimeClientError::Procedure(message)) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + Ok(None) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +async fn resolve_gallery_similar_puzzle_works( + state: &AppState, + run: &PuzzleRunRecord, + current_work: Option<&PuzzleWorkProfileRecord>, +) -> Result, AppError> { + let Some(current_profile) = build_recommendation_current_profile(run, current_work) else { + return Ok(Vec::new()); + }; let items = state .spacetime_client() .list_puzzle_gallery() .await .map_err(map_puzzle_client_error)?; - Ok(items.into_iter().find(|item| { - item.publication_status == "published" - && item - .cover_image_src - .as_ref() - .is_some_and(|value| !value.is_empty()) - && !run.played_profile_ids.contains(&item.profile_id) - })) + let candidates = items + .iter() + .map(map_puzzle_work_profile_domain) + .collect::>(); + Ok(module_puzzle::select_next_profiles( + ¤t_profile, + &run.played_profile_ids, + &candidates, + 3, + ) + .into_iter() + .map(|candidate| build_recommended_next_work_record(¤t_profile, candidate)) + .collect()) +} + +fn build_local_similar_works_handoff( + mut run: PuzzleRunRecord, + recommended_next_works: Vec, +) -> PuzzleRunRecord { + let next_profile_id = recommended_next_works + .first() + .map(|item| item.profile_id.clone()); + run.recommended_next_profile_id = next_profile_id.clone(); + run.next_level_mode = module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string(); + run.next_level_profile_id = next_profile_id; + run.next_level_id = None; + run.recommended_next_works = recommended_next_works; + run +} + +fn build_recommendation_current_profile( + run: &PuzzleRunRecord, + current_work: Option<&PuzzleWorkProfileRecord>, +) -> Option { + if let Some(work) = current_work { + return Some(map_puzzle_work_profile_domain(work)); + } + + let level = run.current_level.as_ref()?; + Some(PuzzleWorkProfile { + work_id: format!("runtime-work-{}", level.profile_id), + profile_id: level.profile_id.clone(), + owner_user_id: String::new(), + source_session_id: None, + author_display_name: level.author_display_name.clone(), + work_title: level.level_name.clone(), + work_description: String::new(), + level_name: level.level_name.clone(), + summary: String::new(), + theme_tags: level.theme_tags.clone(), + cover_image_src: level.cover_image_src.clone(), + cover_asset_id: None, + levels: Vec::new(), + publication_status: module_puzzle::PuzzlePublicationStatus::Published, + updated_at_micros: 0, + published_at_micros: None, + play_count: 0, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, + publish_ready: true, + anchor_pack: module_puzzle::empty_anchor_pack(), + }) +} + +fn map_puzzle_work_profile_domain(item: &PuzzleWorkProfileRecord) -> PuzzleWorkProfile { + PuzzleWorkProfile { + work_id: item.work_id.clone(), + profile_id: item.profile_id.clone(), + owner_user_id: item.owner_user_id.clone(), + source_session_id: item.source_session_id.clone(), + author_display_name: item.author_display_name.clone(), + work_title: item.work_title.clone(), + work_description: item.work_description.clone(), + level_name: item.level_name.clone(), + summary: item.summary.clone(), + theme_tags: item.theme_tags.clone(), + cover_image_src: item.cover_image_src.clone(), + cover_asset_id: item.cover_asset_id.clone(), + levels: item + .levels + .iter() + .map(map_puzzle_draft_level_domain) + .collect(), + publication_status: match item.publication_status.as_str() { + "published" => module_puzzle::PuzzlePublicationStatus::Published, + _ => module_puzzle::PuzzlePublicationStatus::Draft, + }, + updated_at_micros: parse_puzzle_record_timestamp_micros(&item.updated_at), + published_at_micros: item + .published_at + .as_deref() + .map(parse_puzzle_record_timestamp_micros), + play_count: item.play_count, + remix_count: item.remix_count, + like_count: item.like_count, + recent_play_count_7d: item.recent_play_count_7d, + point_incentive_total_half_points: item.point_incentive_total_half_points, + point_incentive_claimed_points: item.point_incentive_claimed_points, + publish_ready: item.publish_ready, + anchor_pack: module_puzzle::empty_anchor_pack(), + } +} + +fn map_puzzle_draft_level_domain( + level: &PuzzleDraftLevelRecord, +) -> module_puzzle::PuzzleDraftLevel { + module_puzzle::PuzzleDraftLevel { + level_id: level.level_id.clone(), + level_name: level.level_name.clone(), + picture_description: level.picture_description.clone(), + candidates: level + .candidates + .iter() + .map(map_puzzle_generated_image_candidate_domain) + .collect(), + selected_candidate_id: level.selected_candidate_id.clone(), + cover_image_src: level.cover_image_src.clone(), + cover_asset_id: level.cover_asset_id.clone(), + generation_status: level.generation_status.clone(), + } +} + +fn map_puzzle_generated_image_candidate_domain( + candidate: &PuzzleGeneratedImageCandidateRecord, +) -> PuzzleGeneratedImageCandidate { + PuzzleGeneratedImageCandidate { + candidate_id: candidate.candidate_id.clone(), + image_src: candidate.image_src.clone(), + asset_id: candidate.asset_id.clone(), + prompt: candidate.prompt.clone(), + actual_prompt: candidate.actual_prompt.clone(), + source_type: candidate.source_type.clone(), + selected: candidate.selected, + } +} + +fn build_recommended_next_work_record( + current_profile: &PuzzleWorkProfile, + candidate: &PuzzleWorkProfile, +) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: candidate.profile_id.clone(), + level_name: candidate.level_name.clone(), + author_display_name: candidate.author_display_name.clone(), + theme_tags: candidate.theme_tags.clone(), + cover_image_src: candidate.cover_image_src.clone(), + similarity_score: module_puzzle::tag_similarity_score( + ¤t_profile.theme_tags, + &candidate.theme_tags, + ), + } +} + +fn parse_puzzle_record_timestamp_micros(value: &str) -> i64 { + let Some((seconds, rest)) = value.split_once('.') else { + return 0; + }; + let micros = rest.strip_suffix('Z').unwrap_or(rest); + let Ok(seconds) = seconds.parse::() else { + return 0; + }; + let Ok(micros) = micros.parse::() else { + return 0; + }; + seconds.saturating_mul(1_000_000).saturating_add(micros) } fn pick_unused_puzzle_candidate<'a>( @@ -2159,18 +3139,74 @@ fn pick_unused_puzzle_candidate<'a>( }) } -fn build_next_run_from_puzzle_work( - run: PuzzleRunRecord, - item: PuzzleWorkProfileRecord, -) -> PuzzleRunRecord { - build_next_run_from_parts( - run, - item.profile_id, - item.level_name, - item.author_display_name, - item.theme_tags, - item.cover_image_src, - ) +fn select_local_next_level<'a>( + levels: &'a [PuzzleDraftLevelRecord], + run: &PuzzleRunRecord, +) -> Option<&'a PuzzleDraftLevelRecord> { + if levels.is_empty() { + return None; + } + if let Some(next_level_id) = run + .next_level_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(level) = levels.iter().find(|level| level.level_id == next_level_id) { + return Some(level); + } + } + + let current_level = run.current_level.as_ref()?; + let matched_index = levels + .iter() + .position(|level| { + level.cover_image_src == current_level.cover_image_src + && level.level_name == current_level.level_name + }) + .or_else(|| { + current_level + .level_index + .checked_sub(1) + .and_then(|index| ((index as usize) < levels.len()).then_some(index as usize)) + })?; + levels.get(matched_index + 1) +} + +fn select_next_level_after_level_id<'a>( + levels: &'a [PuzzleDraftLevelRecord], + level_id: &str, +) -> Option<&'a PuzzleDraftLevelRecord> { + let matched_index = levels.iter().position(|level| level.level_id == level_id)?; + levels.get(matched_index + 1) +} + +fn resolve_level_cover_image_src(level: &PuzzleDraftLevelRecord) -> Option { + level + .cover_image_src + .as_ref() + .filter(|value| !value.trim().is_empty()) + .cloned() + .or_else(|| { + level + .selected_candidate_id + .as_ref() + .and_then(|candidate_id| { + level + .candidates + .iter() + .find(|candidate| candidate.candidate_id == *candidate_id) + }) + .map(|candidate| candidate.image_src.clone()) + .filter(|value| !value.trim().is_empty()) + }) + .or_else(|| { + level + .candidates + .iter() + .find(|candidate| !candidate.image_src.trim().is_empty()) + .map(|candidate| candidate.image_src.clone()) + }) } fn build_next_run_from_candidate( @@ -2197,6 +3233,34 @@ fn build_next_run_from_candidate( ) } +fn build_next_run_from_draft_level( + mut run: PuzzleRunRecord, + level: &PuzzleDraftLevelRecord, + profile_id: Option, + author_display_name: String, + theme_tags: Vec, + next_after_level_id: Option, +) -> PuzzleRunRecord { + // 中文注释:当前关卡 id 必须取本次选中的目标 level,避免旧 run 的空值或脏值影响后续同作品接续。 + run.next_level_id = Some(level.level_id.clone()); + let fallback_profile_id = run + .current_level + .as_ref() + .map(|level| level.profile_id.clone()) + .unwrap_or_else(|| level.level_id.clone()); + build_next_run_from_parts_with_handoff( + run, + profile_id + .filter(|value| !value.trim().is_empty()) + .unwrap_or(fallback_profile_id), + level.level_name.clone(), + author_display_name, + theme_tags, + resolve_level_cover_image_src(level), + next_after_level_id, + ) +} + fn build_next_run_from_parts( run: PuzzleRunRecord, profile_id: String, @@ -2204,11 +3268,33 @@ fn build_next_run_from_parts( author_display_name: String, theme_tags: Vec, cover_image_src: Option, +) -> PuzzleRunRecord { + build_next_run_from_parts_with_handoff( + run, + profile_id, + level_name, + author_display_name, + theme_tags, + cover_image_src, + None, + ) +} + +fn build_next_run_from_parts_with_handoff( + run: PuzzleRunRecord, + profile_id: String, + level_name: String, + author_display_name: String, + theme_tags: Vec, + cover_image_src: Option, + next_after_level_id: Option, ) -> PuzzleRunRecord { let next_level_index = run.current_level_index + 1; - let grid_size = if run.cleared_level_count >= 3 { 4 } else { 3 }; - let time_limit_ms = module_puzzle::resolve_puzzle_level_time_limit_ms(grid_size); + let level_config = resolve_puzzle_level_config(next_level_index); + let grid_size = level_config.grid_size; + let time_limit_ms = level_config.time_limit_ms; let mut played_profile_ids = run.played_profile_ids.clone(); + let current_level_id = run.next_level_id.clone(); if !played_profile_ids.contains(&profile_id) { played_profile_ids.push(profile_id.clone()); } @@ -2224,8 +3310,9 @@ fn build_next_run_from_parts( current_level: Some(PuzzleRuntimeLevelRecord { run_id: run.run_id, level_index: next_level_index, + level_id: current_level_id, grid_size, - profile_id, + profile_id: profile_id.clone(), level_name, author_display_name, theme_tags, @@ -2245,6 +3332,13 @@ fn build_next_run_from_parts( leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + next_level_mode: next_after_level_id + .as_ref() + .map(|_| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string()) + .unwrap_or_else(|| module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string()), + next_level_profile_id: next_after_level_id.as_ref().map(|_| profile_id), + next_level_id: next_after_level_id, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), } } @@ -2358,10 +3452,134 @@ mod tests { assert!(!has_original_neighbor_pair(&third)); } + fn test_recommended_work(profile_id: &str, score: f32) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: profile_id.to_string(), + level_name: format!("{profile_id} 关"), + author_display_name: "作者".to_string(), + theme_tags: vec!["奇幻".to_string()], + cover_image_src: Some(format!("/{profile_id}.png")), + similarity_score: score, + } + } + + #[test] + fn local_similar_works_handoff_keeps_cleared_run_for_user_choice() { + let run = PuzzleRunRecord { + run_id: "local-puzzle-run-a".to_string(), + entry_profile_id: "profile-current".to_string(), + cleared_level_count: 1, + current_level_index: 1, + current_grid_size: 3, + played_profile_ids: vec!["profile-current".to_string()], + previous_level_tags: vec!["奇幻".to_string()], + current_level: Some(PuzzleRuntimeLevelRecord { + run_id: "local-puzzle-run-a".to_string(), + level_index: 1, + level_id: Some("puzzle-level-1".to_string()), + grid_size: 3, + profile_id: "profile-current".to_string(), + level_name: "当前拼图".to_string(), + author_display_name: "当前作者".to_string(), + theme_tags: vec!["奇幻".to_string()], + cover_image_src: Some("/current.png".to_string()), + board: build_local_puzzle_board(3, "local-puzzle-run-a", "profile-current", 1), + status: "cleared".to_string(), + started_at_ms: 1_000, + cleared_at_ms: Some(2_000), + elapsed_ms: Some(1_000), + time_limit_ms: 300_000, + remaining_ms: 0, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, + leaderboard_entries: Vec::new(), + }), + recommended_next_profile_id: None, + next_level_mode: module_puzzle::PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), + leaderboard_entries: Vec::new(), + }; + + let next_run = build_local_similar_works_handoff( + run, + vec![ + test_recommended_work("profile-a", 0.9), + test_recommended_work("profile-b", 0.8), + test_recommended_work("profile-c", 0.7), + ], + ); + + assert_eq!( + next_run.next_level_mode, + module_puzzle::PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS + ); + assert_eq!( + next_run.recommended_next_profile_id.as_deref(), + Some("profile-a") + ); + assert_eq!(next_run.next_level_profile_id.as_deref(), Some("profile-a")); + assert_eq!(next_run.next_level_id, None); + assert_eq!(next_run.recommended_next_works.len(), 3); + assert_eq!(next_run.current_level_index, 1); + assert_eq!( + next_run + .current_level + .as_ref() + .map(|level| level.status.as_str()), + Some("cleared") + ); + } + + #[test] + fn puzzle_record_timestamp_parser_matches_shared_format() { + assert_eq!( + parse_puzzle_record_timestamp_micros("1713686401.234567Z"), + 1_713_686_401_234_567 + ); + assert_eq!(parse_puzzle_record_timestamp_micros("bad-value"), 0); + } + #[test] fn puzzle_generated_image_size_is_square_1_1() { assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); } + + #[test] + fn puzzle_text_to_image_request_places_negative_prompt_in_input_when_present() { + let body = build_puzzle_text_to_image_request_body( + "一只猫在雨夜灯牌下回头。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_GENERATED_IMAGE_SIZE, + 3, + ); + + assert_eq!(body["input"]["prompt"], "一只猫在雨夜灯牌下回头。"); + assert_eq!( + body["input"]["negative_prompt"], + PUZZLE_DEFAULT_NEGATIVE_PROMPT + ); + assert!(body["parameters"].get("negative_prompt").is_none()); + assert_eq!(body["parameters"]["size"], PUZZLE_GENERATED_IMAGE_SIZE); + assert_eq!(body["parameters"]["n"], 1); + } + + #[test] + fn puzzle_dashscope_upstream_error_keeps_status_and_raw_excerpt() { + let error = map_puzzle_dashscope_upstream_error( + reqwest::StatusCode::BAD_REQUEST, + r#"{"code":"InvalidParameter","message":"请求参数不合法"}"#, + "创建拼图图片生成任务失败", + ); + + assert_eq!(error.body_text(), "请求参数不合法"); + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + } } struct PuzzleDashScopeSettings { @@ -2463,17 +3681,6 @@ async fn create_puzzle_text_to_image_generation( size: &str, candidate_count: u32, ) -> Result { - let mut parameters = Map::from_iter([ - ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("size".to_string(), Value::String(size.to_string())), - ("prompt_extend".to_string(), Value::Bool(true)), - ("watermark".to_string(), Value::Bool(false)), - ]); - parameters.insert( - "negative_prompt".to_string(), - Value::String(negative_prompt.to_string()), - ); - let response = http_client .post(format!( "{}/services/aigc/text2image/image-synthesis", @@ -2485,11 +3692,12 @@ async fn create_puzzle_text_to_image_generation( ) .header(reqwest::header::CONTENT_TYPE, "application/json") .header("X-DashScope-Async", "enable") - .json(&json!({ - "model": PUZZLE_TEXT_TO_IMAGE_MODEL, - "input": { "prompt": prompt }, - "parameters": parameters, - })) + .json(&build_puzzle_text_to_image_request_body( + prompt, + negative_prompt, + size, + candidate_count, + )) .send() .await .map_err(|error| { @@ -2501,6 +3709,7 @@ async fn create_puzzle_text_to_image_generation( })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + status, response_text.as_str(), "创建拼图图片生成任务失败", )); @@ -2532,6 +3741,7 @@ async fn create_puzzle_text_to_image_generation( })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); @@ -2563,6 +3773,7 @@ async fn create_puzzle_text_to_image_generation( } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "拼图图片生成任务失败", )); @@ -2578,6 +3789,33 @@ async fn create_puzzle_text_to_image_generation( ) } +fn build_puzzle_text_to_image_request_body( + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, +) -> Value { + let parameters = Map::from_iter([ + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + let mut input = Map::from_iter([("prompt".to_string(), Value::String(prompt.to_string()))]); + if !negative_prompt.trim().is_empty() { + input.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } + + json!({ + "model": PUZZLE_TEXT_TO_IMAGE_MODEL, + "input": input, + "parameters": parameters, + }) +} + async fn resolve_puzzle_reference_image_as_data_url( state: &AppState, http_client: &reqwest::Client, @@ -2689,6 +3927,18 @@ async fn create_puzzle_image_to_image_generation( ) -> Result { let mut content = vec![json!({ "image": reference_image })]; content.push(json!({ "text": prompt })); + let mut parameters = Map::from_iter([ + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + if !negative_prompt.trim().is_empty() { + parameters.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.trim().to_string()), + ); + } let response = http_client .post(format!( @@ -2710,13 +3960,7 @@ async fn create_puzzle_image_to_image_generation( } ], }, - "parameters": { - "n": candidate_count.clamp(1, 1), - "size": size, - "negative_prompt": negative_prompt, - "prompt_extend": true, - "watermark": false, - }, + "parameters": parameters, })) .send() .await @@ -2729,6 +3973,7 @@ async fn create_puzzle_image_to_image_generation( })?; if !status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + status, response_text.as_str(), "创建拼图参考图生成任务失败", )); @@ -2791,6 +4036,7 @@ async fn wait_puzzle_generated_images( })?; if !poll_status.is_success() { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), "查询拼图图片生成任务失败", )); @@ -2827,6 +4073,7 @@ async fn wait_puzzle_generated_images( } if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { return Err(map_puzzle_dashscope_upstream_error( + poll_status, poll_text.as_str(), failure_message, )); @@ -3107,10 +4354,26 @@ fn map_puzzle_dashscope_request_error(message: String) -> AppError { })) } -fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { +fn map_puzzle_dashscope_upstream_error( + upstream_status: reqwest::StatusCode, + raw_text: &str, + fallback_message: &str, +) -> AppError { + let message = parse_puzzle_api_error_message(raw_text, fallback_message); + let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); + tracing::warn!( + provider = "dashscope", + upstream_status = upstream_status.as_u16(), + message = %message, + raw_excerpt = %raw_excerpt, + "拼图 DashScope 上游请求失败" + ); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "dashscope", - "message": parse_puzzle_api_error_message(raw_text, fallback_message), + "upstreamStatus": upstream_status.as_u16(), + "message": message, + "rawExcerpt": raw_excerpt, })) } @@ -3127,6 +4390,19 @@ fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> Str fallback_message.to_string() } +fn trim_puzzle_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { + let normalized = raw_text.split_whitespace().collect::>().join(" "); + if normalized.chars().count() <= max_chars { + return normalized; + } + + let keep_chars = max_chars.saturating_sub(3); + format!( + "{}...", + normalized.chars().take(keep_chars).collect::() + ) +} + fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "aliyun-oss", diff --git a/server-rs/crates/api-server/src/puzzle_agent_turn.rs b/server-rs/crates/api-server/src/puzzle_agent_turn.rs index 34af6f05..1ddb6f0e 100644 --- a/server-rs/crates/api-server/src/puzzle_agent_turn.rs +++ b/server-rs/crates/api-server/src/puzzle_agent_turn.rs @@ -1,18 +1,16 @@ use module_puzzle::{PuzzleAgentStage, PuzzleAnchorPack, PuzzleAnchorStatus, empty_anchor_pack}; use platform_llm::LlmClient; use serde::{Deserialize, Serialize}; -use serde_json::{Value as JsonValue, json}; -use spacetime_client::{ - PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, PuzzleAgentSessionRecord, -}; +use serde_json::Value as JsonValue; +use spacetime_client::{PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentSessionRecord}; -use crate::creation_agent_anchor_templates::{ - get_creation_agent_anchor_template, render_anchor_question_block, -}; -use crate::creation_agent_chat::render_quick_fill_extra_rules; use crate::creation_agent_llm_turn::{ CreationAgentLlmTurnErrorMessages, stream_creation_agent_json_turn, }; +use crate::prompt::puzzle::agent_chat::{ + PUZZLE_AGENT_JSON_TURN_USER_PROMPT, PUZZLE_AGENT_SYSTEM_PROMPT, build_puzzle_agent_prompt, + serialize_puzzle_record_anchor_pack, +}; #[derive(Clone, Debug)] pub(crate) struct PuzzleAgentTurnRequest<'a> { @@ -60,63 +58,6 @@ struct PuzzleAgentModelOutput { next_anchor_pack: PuzzleAnchorPack, } -const PUZZLE_AGENT_SYSTEM_PROMPT: &str = r#"你是一个负责和陶泥主共创拼图画面的中文创意策划。 - -你要帮助用户把一句灵感逐步收束成可以发布成拼图关卡的视觉方案。 - -你必须同时输出: -1. 一段直接发给用户的中文回复 replyText -2. 当前进度 progressPercent -3. 下一轮完整可用的 nextAnchorPack - -硬约束: -1. 只能输出 JSON,不能输出代码块或解释 -2. nextAnchorPack 必须是完整对象,不能只输出 patch -3. replyText 必须是自然中文,不能提“字段”“锚点”“结构”“JSON”等内部词 -4. replyText 一次最多推进一个最关键问题 -5. 如果用户已经给出明确方向,就优先吸收和收束,不要机械反问 -6. progressPercent 范围只能是 0 到 100 -7. status 只能使用 missing / inferred / confirmed / locked -"#; - -const PUZZLE_AGENT_OUTPUT_CONTRACT: &str = r#"请严格按以下 JSON 输出,不要输出其他文字: -{ - "replyText": "", - "progressPercent": 0, - "nextAnchorPack": { - "themePromise": { - "key": "themePromise", - "label": "题材承诺", - "value": "", - "status": "missing" - }, - "visualSubject": { - "key": "visualSubject", - "label": "画面主体", - "value": "", - "status": "missing" - }, - "visualMood": { - "key": "visualMood", - "label": "视觉气质", - "value": "", - "status": "missing" - }, - "compositionHooks": { - "key": "compositionHooks", - "label": "拼图记忆点", - "value": "", - "status": "missing" - }, - "tagsAndForbidden": { - "key": "tagsAndForbidden", - "label": "标签与禁忌", - "value": "", - "status": "missing" - } - } -}"#; - pub(crate) async fn run_puzzle_agent_turn( request: PuzzleAgentTurnRequest<'_>, on_reply_update: F, @@ -128,7 +69,7 @@ where let turn_output = stream_creation_agent_json_turn( request.llm_client, format!("{PUZZLE_AGENT_SYSTEM_PROMPT}\n\n{prompt}"), - "请按约定输出这一轮的 JSON。", + PUZZLE_AGENT_JSON_TURN_USER_PROMPT, request.enable_web_search, CreationAgentLlmTurnErrorMessages { model_unavailable: "当前模型不可用,请稍后重试。", @@ -185,10 +126,6 @@ pub(crate) fn build_failed_finalize_record_input( error_message: String, updated_at_micros: i64, ) -> PuzzleAgentMessageFinalizeRecordInput { - let anchor_pack_json = serde_json::to_string(&map_record_anchor_pack(&session.anchor_pack)) - .unwrap_or_else(|_| { - serde_json::to_string(&empty_anchor_pack()).unwrap_or_else(|_| "{}".to_string()) - }); PuzzleAgentMessageFinalizeRecordInput { session_id, owner_user_id, @@ -196,61 +133,12 @@ pub(crate) fn build_failed_finalize_record_input( assistant_reply_text: None, stage: session.stage.clone(), progress_percent: session.progress_percent, - anchor_pack_json, + anchor_pack_json: serialize_puzzle_record_anchor_pack(&session.anchor_pack), error_message: Some(error_message), updated_at_micros, } } -fn build_puzzle_agent_prompt( - session: &PuzzleAgentSessionRecord, - quick_fill_requested: bool, -) -> String { - let anchor_question_block = get_creation_agent_anchor_template("puzzle") - .map(render_anchor_question_block) - .unwrap_or_else(|| "模板目标:收束成可以发布为拼图关卡的视觉方案。".to_string()); - let quick_fill_rules = if quick_fill_requested { - format!( - "\n\n{}", - render_quick_fill_extra_rules( - "当前题材方向里的拼图关键词", - "不要要求用户再提供素材、风格或禁忌", - "输出完整 nextAnchorPack,直接补齐 value 为空或 status 为 missing 的项", - "生成结果页", - ) - ) - } else { - String::new() - }; - format!( - "{anchor_question_block}{quick_fill_rules}\n\n当前是第 {turn} 轮,当前进度 {progress}% 。\n\n是否要求自动补充剩余关键字:{quick_fill_requested_text}\n\n当前 anchor pack:\n{anchor_pack}\n\n最近聊天记录:\n{chat_history}\n\n{contract}", - anchor_question_block = anchor_question_block, - quick_fill_rules = quick_fill_rules, - turn = session.current_turn.saturating_add(1), - progress = session.progress_percent, - quick_fill_requested_text = if quick_fill_requested { "是" } else { "否" }, - anchor_pack = serde_json::to_string_pretty(&map_record_anchor_pack(&session.anchor_pack)) - .unwrap_or_else(|_| "{}".to_string()), - chat_history = - serde_json::to_string_pretty(&build_chat_history(session.messages.as_slice())) - .unwrap_or_else(|_| "[]".to_string()), - contract = PUZZLE_AGENT_OUTPUT_CONTRACT, - ) -} - -fn build_chat_history(messages: &[PuzzleAgentMessageRecord]) -> Vec { - messages - .iter() - .map(|message| { - json!({ - "role": message.role, - "kind": message.kind, - "content": message.text, - }) - }) - .collect() -} - fn parse_model_output(parsed: &JsonValue) -> Result { let reply_text = parsed .get("replyText") @@ -348,27 +236,6 @@ fn resolve_puzzle_agent_stage(progress_percent: u32) -> PuzzleAgentStage { } } -fn map_record_anchor_pack(record: &spacetime_client::PuzzleAnchorPackRecord) -> PuzzleAnchorPack { - PuzzleAnchorPack { - theme_promise: map_record_anchor_item(&record.theme_promise), - visual_subject: map_record_anchor_item(&record.visual_subject), - visual_mood: map_record_anchor_item(&record.visual_mood), - composition_hooks: map_record_anchor_item(&record.composition_hooks), - tags_and_forbidden: map_record_anchor_item(&record.tags_and_forbidden), - } -} - -fn map_record_anchor_item( - record: &spacetime_client::PuzzleAnchorItemRecord, -) -> module_puzzle::PuzzleAnchorItem { - module_puzzle::PuzzleAnchorItem { - key: record.key.clone(), - label: record.label.clone(), - value: record.value.clone(), - status: parse_anchor_status(record.status.as_str()), - } -} - fn parse_anchor_status(value: &str) -> PuzzleAnchorStatus { match value { "confirmed" => PuzzleAnchorStatus::Confirmed, @@ -383,57 +250,9 @@ mod tests { use module_puzzle::PuzzleAnchorStatus; use serde_json::json; - use super::{build_puzzle_agent_prompt, parse_model_output}; + use super::parse_model_output; use crate::creation_agent_llm_turn::extract_reply_text_from_partial_json; - fn empty_session_record() -> spacetime_client::PuzzleAgentSessionRecord { - spacetime_client::PuzzleAgentSessionRecord { - session_id: "puzzle-session-test".to_string(), - current_turn: 2, - progress_percent: 60, - stage: "collecting_anchors".to_string(), - anchor_pack: spacetime_client::PuzzleAnchorPackRecord { - theme_promise: spacetime_client::PuzzleAnchorItemRecord { - key: "themePromise".to_string(), - label: "题材承诺".to_string(), - value: "雨夜猫咪遗迹".to_string(), - status: "confirmed".to_string(), - }, - visual_subject: spacetime_client::PuzzleAnchorItemRecord { - key: "visualSubject".to_string(), - label: "画面主体".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - visual_mood: spacetime_client::PuzzleAnchorItemRecord { - key: "visualMood".to_string(), - label: "视觉气质".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - composition_hooks: spacetime_client::PuzzleAnchorItemRecord { - key: "compositionHooks".to_string(), - label: "拼图记忆点".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - tags_and_forbidden: spacetime_client::PuzzleAnchorItemRecord { - key: "tagsAndForbidden".to_string(), - label: "标签与禁忌".to_string(), - value: String::new(), - status: "missing".to_string(), - }, - }, - draft: None, - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - suggested_actions: Vec::new(), - result_preview: None, - updated_at: "2026-04-24T10:00:00.000Z".to_string(), - } - } - #[test] fn extract_reply_text_from_partial_json_preserves_chinese_characters() { let partial_json = r#"{"replyText":"夜雨猫咪遗迹","progressPercent":42"#; @@ -498,13 +317,4 @@ mod tests { "雨夜、猫咪、神庙遗迹;禁止文字水印" ); } - - #[test] - fn quick_fill_prompt_forbids_follow_up_questions() { - let prompt = build_puzzle_agent_prompt(&empty_session_record(), true); - - assert!(prompt.contains("用户刚刚主动要求你自动补充剩余关键字")); - assert!(prompt.contains("不要再继续提问")); - assert!(prompt.contains("progressPercent 直接输出为 100")); - } } diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index aa418b6d..20fed91b 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -22,12 +22,13 @@ use module_runtime_story_compat::{ use crate::{ auth::AuthenticatedAccessToken, http_error::AppError, + llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::{ NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT, NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT, NpcChatTurnPromptInput, build_deterministic_chat_suggestions, - build_deterministic_npc_reply, build_fallback_function_suggestions, - build_fallback_npc_chat_suggestions, build_npc_chat_turn_reply_prompt, - build_npc_chat_turn_suggestion_prompt, + build_deterministic_hostile_breakoff_reply, build_deterministic_npc_reply, + build_fallback_function_suggestions, build_fallback_npc_chat_suggestions, + build_npc_chat_turn_reply_prompt, build_npc_chat_turn_suggestion_prompt, }, request_context::RequestContext, state::AppState, @@ -136,16 +137,26 @@ pub async fn stream_runtime_npc_chat_turn( let (npc_reply, suggestions, function_suggestions, force_exit) = match llm_result { Some(result) => result, None => { - let npc_reply = build_deterministic_npc_reply( - npc_name.as_str(), - player_message.as_str(), - payload.npc_initiates_conversation, - ); - let force_exit = should_force_chat_exit(payload.chat_directive.as_ref()) - || should_hostile_chat_breakoff_deterministically( + let deterministic_hostile_breakoff = + should_hostile_chat_breakoff_deterministically( player_message.as_str(), payload.chat_directive.as_ref(), + Some(&payload.npc_state), ); + let force_exit = should_force_chat_exit(payload.chat_directive.as_ref()) + || deterministic_hostile_breakoff; + let npc_reply = if deterministic_hostile_breakoff { + build_deterministic_hostile_breakoff_reply( + npc_name.as_str(), + player_message.as_str(), + ) + } else { + build_deterministic_npc_reply( + npc_name.as_str(), + player_message.as_str(), + payload.npc_initiates_conversation, + ) + }; let suggestions = if force_exit { Vec::new() } else { @@ -227,6 +238,7 @@ where ]); reply_request.max_tokens = Some(700); reply_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + reply_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let reply_response = llm_client .stream_text(reply_request, |delta| { @@ -254,6 +266,7 @@ where ]); suggestion_request.max_tokens = Some(200); suggestion_request.enable_web_search = state.config.rpg_llm_web_search_enabled; + suggestion_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let suggestion_text = llm_client .request_text(suggestion_request) .await @@ -269,6 +282,7 @@ where || should_hostile_chat_breakoff_deterministically( payload.player_message.as_str(), payload.chat_directive.as_ref(), + Some(&payload.npc_state), ); if force_exit { @@ -615,6 +629,7 @@ fn is_hostile_model_chat(chat_directive: Option<&Value>) -> bool { fn should_hostile_chat_breakoff_deterministically( player_message: &str, chat_directive: Option<&Value>, + npc_state: Option<&Value>, ) -> bool { if !is_hostile_model_chat(chat_directive) { return false; @@ -628,6 +643,14 @@ fn should_hostile_chat_breakoff_deterministically( return true; } + // 中文注释:模型建议不可用时,后端兜底仍按敌对聊天口径避免负面挑衅被拖成闲聊。 + if npc_state + .and_then(|state| read_number_field(state, "chattedCount")) + .is_some_and(|chatted_count| chatted_count >= 4.0) + { + return true; + } + let hostile_break_words = [ "动手", "开战", @@ -637,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically( "闭嘴", "少废话", "别挡路", + "废话", + "威胁", + "找死", + "送死", + "住口", + "让开", + "滚开", + "不退", + "不会退", + "别装", + "骗子", + "叛徒", ]; count_keyword_matches(player_message, &hostile_break_words) > 0 } @@ -809,6 +844,51 @@ mod tests { ); } + #[test] + fn hostile_chat_breakoff_fallback_triggers_on_negative_words() { + let chat_directive = json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + }); + let npc_state = json!({ "chattedCount": 1 }); + + assert!(should_hostile_chat_breakoff_deterministically( + "少废话,让开,不然现在就动手。", + Some(&chat_directive), + Some(&npc_state), + )); + } + + #[test] + fn hostile_chat_breakoff_fallback_triggers_after_four_turns() { + let chat_directive = json!({ + "terminationMode": "hostile_model", + "isHostileChat": true, + }); + let npc_state = json!({ "chattedCount": 4 }); + + assert!(should_hostile_chat_breakoff_deterministically( + "我还想再问一个问题。", + Some(&chat_directive), + Some(&npc_state), + )); + } + + #[test] + fn hostile_chat_breakoff_fallback_ignores_non_hostile_chat() { + let chat_directive = json!({ + "terminationMode": "none", + "isHostileChat": false, + }); + let npc_state = json!({ "chattedCount": 6 }); + + assert!(!should_hostile_chat_breakoff_deterministically( + "少废话,让开。", + Some(&chat_directive), + Some(&npc_state), + )); + } + #[tokio::test] async fn npc_chat_turn_prefers_request_snapshot_over_persisted_session() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/runtime_chat_plain.rs b/server-rs/crates/api-server/src/runtime_chat_plain.rs index cbd79513..775bf92a 100644 --- a/server-rs/crates/api-server/src/runtime_chat_plain.rs +++ b/server-rs/crates/api-server/src/runtime_chat_plain.rs @@ -15,7 +15,8 @@ use std::convert::Infallible; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, - prompt::runtime_chat::*, request_context::RequestContext, state::AppState, + llm_model_routing::RPG_STORY_LLM_MODEL, prompt::runtime_chat::*, + request_context::RequestContext, state::AppState, }; use module_runtime_story_compat::{ RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context, current_world_type, @@ -587,6 +588,7 @@ async fn request_runtime_plain_text( ]); request.max_tokens = Some(400); request.enable_web_search = state.config.rpg_llm_web_search_enabled; + request.model = Some(RPG_STORY_LLM_MODEL.to_string()); llm_client .request_text(request) @@ -618,6 +620,7 @@ fn stream_plain_text_response<'a>( ]); request.max_tokens = Some(700); request.enable_web_search = enable_web_search; + request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let response = llm_client .stream_text(request, |_| {}) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 648539c5..a14a550b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -21,6 +21,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, @@ -126,6 +127,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD } + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + } } } @@ -605,7 +609,7 @@ mod tests { use crate::{app::build_router, config::AppConfig, state::AppState}; #[test] - fn profile_wallet_ledger_source_type_formats_asset_operation_values() { + fn profile_wallet_ledger_source_type_formats_backend_values() { assert_eq!( format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::AssetOperationConsume @@ -618,6 +622,12 @@ mod tests { ), shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND ); + assert_eq!( + format_profile_wallet_ledger_source_type( + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim + ), + shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + ); } #[tokio::test] diff --git a/server-rs/crates/api-server/src/runtime_story/compat/ai.rs b/server-rs/crates/api-server/src/runtime_story/compat/ai.rs index 91694879..9d96252b 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/ai.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/ai.rs @@ -1,4 +1,5 @@ use super::*; +use crate::llm_model_routing::RPG_STORY_LLM_MODEL; use crate::prompt::runtime_chat::{ RuntimeNpcDialoguePromptParams, RuntimeReasonedStoryPromptParams, RuntimeStoryTextPromptParams, build_runtime_npc_dialogue_user_prompt, build_runtime_reasoned_story_user_prompt, @@ -100,6 +101,7 @@ pub(super) async fn generate_action_story_payload( fn apply_rpg_web_search(state: &AppState, request: &mut LlmTextRequest) { request.enable_web_search = state.config.rpg_llm_web_search_enabled; + request.model = Some(RPG_STORY_LLM_MODEL.to_string()); } pub(super) async fn generate_npc_dialogue_payload( @@ -144,6 +146,7 @@ pub(super) async fn generate_npc_dialogue_payload( ]); llm_request.max_tokens = Some(700); llm_request.enable_web_search = enable_web_search; + llm_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let dialogue_text = llm_client .request_text(llm_request) @@ -195,6 +198,7 @@ pub(super) async fn generate_reasoned_story_payload( ]); llm_request.max_tokens = Some(700); llm_request.enable_web_search = enable_web_search; + llm_request.model = Some(RPG_STORY_LLM_MODEL.to_string()); let story_text = llm_client .request_text(llm_request) diff --git a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs index 413c0bea..2631617a 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs @@ -271,11 +271,18 @@ pub(super) fn build_active_npc_runtime_story_options( game_state: &Value, npc_id: &str, ) -> Vec { + if read_current_npc_affinity(game_state) < 0 { + return vec![build_npc_runtime_story_option( + "npc_chat", + "继续交谈", + npc_id, + "chat", + )]; + } + let mut options = vec![ build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"), build_npc_help_runtime_story_option(game_state, npc_id), - build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"), - build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"), ]; if current_npc_inventory_items(game_state) @@ -332,12 +339,6 @@ pub(super) fn build_active_npc_runtime_story_options( )); } - options.push(build_npc_runtime_story_option( - "npc_leave", - "离开当前角色", - npc_id, - "leave", - )); options } diff --git a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs index a54e2645..9d40d4a0 100644 --- a/server-rs/crates/api-server/src/runtime_story/compat/tests.rs +++ b/server-rs/crates/api-server/src/runtime_story/compat/tests.rs @@ -1112,12 +1112,9 @@ fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_he vec![ "npc_chat", "npc_help", - "npc_spar", - "npc_fight", "npc_trade", "npc_gift", - "npc_quest_accept", - "npc_leave" + "npc_quest_accept" ] ); assert_eq!( @@ -1129,11 +1126,11 @@ fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_he Some("当前 NPC 的一次性援手已经用完了。") ); assert!(matches!( - response.view_model.available_options[4].interaction, + response.view_model.available_options[2].interaction, Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "trade" )); assert!(matches!( - response.view_model.available_options[5].interaction, + response.view_model.available_options[3].interaction, Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "gift" )); let npc_interaction = response @@ -1154,6 +1151,35 @@ fn runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_he ); } +#[test] +fn runtime_story_state_compiler_limits_negative_affinity_active_npc_to_chat() { + let mut game_state = build_runtime_story_boundary_game_state_fixture(); + write_bool_field(&mut game_state, "npcInteractionActive", true); + write_current_npc_state_i32_field(&mut game_state, "affinity", -8); + let response = build_runtime_story_state_response( + "runtime-main", + Some(0), + RuntimeStorySnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story: None, + }, + ); + + let function_ids = response + .view_model + .available_options + .iter() + .map(|option| option.function_id.as_str()) + .collect::>(); + assert_eq!(function_ids, vec!["npc_chat"]); + assert!(matches!( + response.view_model.available_options[0].interaction, + Some(RuntimeStoryOptionInteraction::Npc { ref action, .. }) if action == "chat" + )); +} + #[test] fn runtime_story_equipment_equip_updates_loadout_and_build_toast() { let request = RuntimeStoryActionRequest { diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs new file mode 100644 index 00000000..e45ebfdd --- /dev/null +++ b/server-rs/crates/api-server/src/work_author.rs @@ -0,0 +1,54 @@ +use module_auth::AuthUser; + +use crate::state::AppState; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkAuthorSummary { + pub display_name: String, + pub public_user_code: Option, +} + +/// 中文注释:作品作者的真相源是 owner_user_id;历史昵称字段只作为账号资料不可读时的兼容回退。 +pub fn resolve_work_author_by_user_id( + state: &AppState, + owner_user_id: &str, + fallback_display_name: Option<&str>, + fallback_public_user_code: Option<&str>, +) -> WorkAuthorSummary { + let fallback_display_name = + normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string()); + let fallback_public_user_code = normalize_optional_text(fallback_public_user_code); + + let Some(owner_user_id) = normalize_optional_text(Some(owner_user_id)) else { + return WorkAuthorSummary { + display_name: fallback_display_name, + public_user_code: fallback_public_user_code, + }; + }; + + match state.auth_user_service().get_user_by_id(&owner_user_id) { + Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name), + Ok(None) | Err(_) => WorkAuthorSummary { + display_name: fallback_display_name, + public_user_code: fallback_public_user_code, + }, + } +} + +fn map_auth_user_to_work_author_summary( + user: AuthUser, + fallback_display_name: String, +) -> WorkAuthorSummary { + WorkAuthorSummary { + display_name: normalize_optional_text(Some(user.display_name.as_str())) + .unwrap_or(fallback_display_name), + public_user_code: normalize_optional_text(Some(user.public_user_code.as_str())), + } +} + +fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} diff --git a/server-rs/crates/module-big-fish/src/lib.rs b/server-rs/crates/module-big-fish/src/lib.rs index ab155e36..5aa71e47 100644 --- a/server-rs/crates/module-big-fish/src/lib.rs +++ b/server-rs/crates/module-big-fish/src/lib.rs @@ -347,6 +347,14 @@ pub struct BigFishPlayRecordInput { pub played_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BigFishWorkLikeRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum BigFishFieldError { MissingSessionId, diff --git a/server-rs/crates/module-custom-world/src/lib.rs b/server-rs/crates/module-custom-world/src/lib.rs index d09af952..b71c4ef5 100644 --- a/server-rs/crates/module-custom-world/src/lib.rs +++ b/server-rs/crates/module-custom-world/src/lib.rs @@ -510,6 +510,15 @@ pub struct CustomWorldProfilePlayRecordInput { pub played_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CustomWorldProfileLikeRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CustomWorldAgentSessionCreateInput { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 5b0256fd..f86e19b5 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -16,8 +16,20 @@ pub const PUZZLE_RUN_ID_PREFIX: &str = "puzzle-run-"; pub const PUZZLE_MIN_TAG_COUNT: usize = 3; pub const PUZZLE_MAX_TAG_COUNT: usize = 6; pub const PUZZLE_FREEZE_TIME_DURATION_MS: u64 = 10_000; +pub const PUZZLE_EXTEND_TIME_DURATION_MS: u64 = 60_000; +pub const PUZZLE_NEXT_LEVEL_MODE_SAME_WORK: &str = "sameWork"; +pub const PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS: &str = "similarWorks"; +pub const PUZZLE_NEXT_LEVEL_MODE_NONE: &str = "none"; +pub const PUZZLE_SUPPORTED_GRID_SIZES: [u32; 5] = [3, 4, 5, 6, 7]; const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; +// 中文注释:拼图难度只从关卡序号解析,避免切割规格和倒计时在不同入口各写一套。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PuzzleLevelConfig { + pub grid_size: u32, + pub time_limit_ms: u64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum PuzzleAgentStage { @@ -113,9 +125,26 @@ pub struct PuzzleGeneratedImageCandidate { pub selected: bool, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleDraftLevel { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleResultDraft { + #[serde(default)] + pub work_title: String, + #[serde(default)] + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -127,6 +156,18 @@ pub struct PuzzleResultDraft { pub cover_image_src: Option, pub cover_asset_id: Option, pub generation_status: String, + #[serde(default)] + pub levels: Vec, + #[serde(default)] + pub form_draft: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleFormDraft { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -202,11 +243,17 @@ pub struct PuzzleWorkProfile { pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, + #[serde(default)] + pub work_title: String, + #[serde(default)] + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + #[serde(default)] + pub levels: Vec, pub publication_status: PuzzlePublicationStatus, pub updated_at_micros: i64, pub published_at_micros: Option, @@ -218,6 +265,10 @@ pub struct PuzzleWorkProfile { pub like_count: u32, #[serde(default)] pub recent_play_count_7d: u32, + #[serde(default)] + pub point_incentive_total_half_points: u64, + #[serde(default)] + pub point_incentive_claimed_points: u64, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPack, } @@ -275,6 +326,8 @@ pub struct PuzzleBoardSnapshot { pub struct PuzzleRuntimeLevelSnapshot { pub run_id: String, pub level_index: u32, + #[serde(default)] + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -308,7 +361,7 @@ pub struct PuzzleRuntimeLevelSnapshot { } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PuzzleRunSnapshot { pub run_id: String, pub entry_profile_id: String, @@ -319,10 +372,33 @@ pub struct PuzzleRunSnapshot { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + #[serde(default = "default_puzzle_next_level_mode")] + pub next_level_mode: String, + #[serde(default)] + pub next_level_profile_id: Option, + #[serde(default)] + pub next_level_id: Option, + #[serde(default)] + pub recommended_next_works: Vec, #[serde(default)] pub leaderboard_entries: Vec, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct PuzzleRecommendedNextWork { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub similarity_score: f32, +} + +fn default_puzzle_next_level_mode() -> String { + PUZZLE_NEXT_LEVEL_MODE_NONE.to_string() +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionCreateInput { @@ -334,6 +410,15 @@ pub struct PuzzleAgentSessionCreateInput { pub created_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleFormDraftSaveInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleAgentSessionGetInput { @@ -378,6 +463,8 @@ pub struct PuzzleDraftCompileInput { pub struct PuzzleGeneratedImagesSaveInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, } @@ -387,6 +474,7 @@ pub struct PuzzleGeneratedImagesSaveInput { pub struct PuzzleSelectCoverImageInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, pub candidate_id: String, pub selected_at_micros: i64, } @@ -399,9 +487,12 @@ pub struct PuzzlePublishInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, + pub levels_json: Option, pub published_at_micros: i64, } @@ -429,11 +520,14 @@ pub struct PuzzleWorkDeleteInput { pub struct PuzzleWorkUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: Option, pub updated_at_micros: i64, } @@ -450,12 +544,29 @@ pub struct PuzzleWorkRemixInput { pub remixed_at_micros: i64, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkLikeRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PuzzleWorkPointIncentiveClaimInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct PuzzleRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option, pub started_at_micros: i64, } @@ -511,6 +622,8 @@ pub struct PuzzleRunPropInput { pub owner_user_id: String, pub prop_kind: String, pub used_at_micros: i64, + #[serde(default)] + pub spent_points: u64, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -690,10 +803,17 @@ pub fn empty_anchor_pack() -> PuzzleAnchorPack { pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> PuzzleAnchorPack { let source = normalize_required_string(latest_message.unwrap_or(seed_text)) - .or_else(|| normalize_required_string(seed_text)) - .unwrap_or_else(|| "童话森林里的发光猫咪遗迹".to_string()); - if let Some((title, picture_description)) = parse_form_seed_text(&source) { - return build_form_anchor_pack(title.as_str(), picture_description.as_str()); + .or_else(|| normalize_required_string(seed_text)); + let Some(source) = source else { + return empty_anchor_pack(); + }; + if let Some(form_seed) = parse_form_seed_text(&source) { + if form_seed.has_any_value() { + return build_form_anchor_pack( + form_seed.work_title.as_deref().unwrap_or(""), + form_seed.picture_description.as_deref().unwrap_or(""), + ); + } } let mut pack = empty_anchor_pack(); @@ -711,22 +831,26 @@ pub fn infer_anchor_pack(seed_text: &str, latest_message: Option<&str>) -> Puzzl } pub fn build_form_anchor_pack(title: &str, picture_description: &str) -> PuzzleAnchorPack { - let normalized_title = - normalize_required_string(title).unwrap_or_else(|| "奇景拼图".to_string()); - let normalized_description = - normalize_required_string(picture_description).unwrap_or_else(|| normalized_title.clone()); + let normalized_title = normalize_required_string(title); + let normalized_description = normalize_required_string(picture_description); let mut pack = empty_anchor_pack(); - pack.theme_promise.value = normalized_title.clone(); - pack.theme_promise.status = PuzzleAnchorStatus::Locked; - pack.visual_subject.value = normalized_description.clone(); - pack.visual_subject.status = PuzzleAnchorStatus::Locked; + if let Some(title) = normalized_title.as_ref() { + pack.theme_promise.value = title.clone(); + pack.theme_promise.status = PuzzleAnchorStatus::Locked; + } + if let Some(description) = normalized_description.as_ref() { + pack.visual_subject.value = description.clone(); + pack.visual_subject.status = PuzzleAnchorStatus::Locked; + } pack.visual_mood.value = "清晰、适合拼图切块".to_string(); pack.visual_mood.status = PuzzleAnchorStatus::Inferred; pack.composition_hooks.value = "主体轮廓、色块分区、局部细节".to_string(); pack.composition_hooks.status = PuzzleAnchorStatus::Inferred; - pack.tags_and_forbidden.value = - build_form_tags_and_forbidden(normalized_title.as_str(), normalized_description.as_str()); + pack.tags_and_forbidden.value = build_form_tags_and_forbidden( + normalized_title.as_deref().unwrap_or(""), + normalized_description.as_deref().unwrap_or(""), + ); pack.tags_and_forbidden.status = PuzzleAnchorStatus::Inferred; pack @@ -766,13 +890,37 @@ pub fn build_creator_intent( pub fn compile_result_draft( anchor_pack: &PuzzleAnchorPack, messages: &[PuzzleAgentMessageSnapshot], +) -> PuzzleResultDraft { + compile_result_draft_from_seed(anchor_pack, messages, None) +} + +pub fn compile_result_draft_from_seed( + anchor_pack: &PuzzleAnchorPack, + messages: &[PuzzleAgentMessageSnapshot], + seed_text: Option<&str>, ) -> PuzzleResultDraft { let creator_intent = build_creator_intent(anchor_pack, messages); let normalized_tags = normalize_theme_tags(creator_intent.theme_tags.clone()); - let level_name = build_level_name(anchor_pack, &normalized_tags); + let work_title = build_work_title(anchor_pack); + let work_description = resolve_work_description(seed_text, anchor_pack); + let picture_description = fallback_text(&anchor_pack.visual_subject.value, "画面主体"); + let level_name = + build_level_name_from_picture(picture_description.as_str(), &normalized_tags, 1); + let level = PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: level_name.clone(), + picture_description, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }; PuzzleResultDraft { + work_title, + work_description: work_description.clone(), level_name, - summary: build_result_summary(anchor_pack), + summary: work_description, theme_tags: normalized_tags, forbidden_directives: creator_intent.forbidden_directives.clone(), creator_intent: Some(creator_intent), @@ -782,6 +930,79 @@ pub fn compile_result_draft( cover_image_src: None, cover_asset_id: None, generation_status: "idle".to_string(), + levels: vec![level], + form_draft: None, + } +} + +pub fn build_form_draft_from_seed( + anchor_pack: &PuzzleAnchorPack, + seed_text: Option<&str>, +) -> PuzzleResultDraft { + let form_seed = seed_text.and_then(parse_form_seed_text); + build_form_draft_from_parts( + anchor_pack, + form_seed.as_ref().and_then(|seed| seed.work_title.clone()), + form_seed + .as_ref() + .and_then(|seed| seed.work_description.clone()), + form_seed.and_then(|seed| seed.picture_description), + ) +} + +pub fn build_form_draft_from_parts( + anchor_pack: &PuzzleAnchorPack, + work_title: Option, + work_description: Option, + picture_description: Option, +) -> PuzzleResultDraft { + let work_title = work_title.and_then(|value| normalize_required_string(&value)); + let work_description = work_description.and_then(|value| normalize_required_string(&value)); + let picture_description = + picture_description.and_then(|value| normalize_required_string(&value)); + let title_for_tags = work_title.as_deref().unwrap_or(""); + let picture_for_tags = picture_description.as_deref().unwrap_or(""); + let mut tags = normalize_theme_tags(derive_form_theme_tags(title_for_tags, picture_for_tags)); + if tags.is_empty() { + tags = vec![ + "拼图".to_string(), + "插画".to_string(), + "清晰构图".to_string(), + ]; + } + let summary = work_description.clone().unwrap_or_default(); + let level = PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: String::new(), + picture_description: picture_description.clone().unwrap_or_default(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }; + + // 中文注释:这是生成前的表单草稿,只用于创作中心恢复和表单回填,不进入发布就绪判断。 + PuzzleResultDraft { + work_title: work_title.clone().unwrap_or_default(), + work_description: summary.clone(), + level_name: String::new(), + summary, + theme_tags: tags, + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: anchor_pack.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + levels: vec![level], + form_draft: Some(PuzzleFormDraft { + work_title, + work_description, + picture_description, + }), } } @@ -849,13 +1070,155 @@ pub fn apply_selected_candidate( Ok(draft) } +pub fn normalize_puzzle_draft(mut draft: PuzzleResultDraft) -> PuzzleResultDraft { + if draft.work_title.trim().is_empty() { + draft.work_title = fallback_text(&draft.anchor_pack.theme_promise.value, &draft.level_name); + } + if draft.work_description.trim().is_empty() { + draft.work_description = draft.summary.clone(); + } + if draft.levels.is_empty() { + draft.levels = vec![PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: draft.level_name.clone(), + picture_description: fallback_text( + &draft.anchor_pack.visual_subject.value, + &draft.summary, + ), + candidates: draft.candidates.clone(), + selected_candidate_id: draft.selected_candidate_id.clone(), + cover_image_src: draft.cover_image_src.clone(), + cover_asset_id: draft.cover_asset_id.clone(), + generation_status: draft.generation_status.clone(), + }]; + } + sync_primary_level_fields(&mut draft); + draft +} + +pub fn sync_primary_level_fields(draft: &mut PuzzleResultDraft) { + if let Some(primary_level) = draft.levels.first() { + draft.level_name = primary_level.level_name.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + draft.generation_status = primary_level.generation_status.clone(); + } + if draft.work_description.trim().is_empty() { + draft.work_description = draft.summary.clone(); + } + draft.summary = draft.work_description.clone(); + if draft.form_draft.is_some() { + draft.form_draft = Some(PuzzleFormDraft { + work_title: normalize_required_string(&draft.work_title), + work_description: normalize_required_string(&draft.work_description), + picture_description: draft + .levels + .first() + .and_then(|level| normalize_required_string(&level.picture_description)), + }); + } +} + +pub fn selected_puzzle_level( + draft: &PuzzleResultDraft, + level_id: Option<&str>, +) -> Option { + let normalized = normalize_puzzle_draft(draft.clone()); + let requested_level_id = level_id.and_then(normalize_required_string); + requested_level_id + .as_deref() + .and_then(|target_id| { + normalized + .levels + .iter() + .find(|level| level.level_id == target_id) + .cloned() + }) + .or_else(|| normalized.levels.first().cloned()) +} + +pub fn replace_puzzle_level( + draft: &PuzzleResultDraft, + level: PuzzleDraftLevel, +) -> Result { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + let Some(index) = next_draft + .levels + .iter() + .position(|entry| entry.level_id == level.level_id) + else { + return Err(PuzzleFieldError::InvalidOperation); + }; + next_draft.levels[index] = level; + sync_primary_level_fields(&mut next_draft); + Ok(next_draft) +} + +pub fn append_blank_puzzle_level(draft: &PuzzleResultDraft) -> PuzzleResultDraft { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + let next_index = next_draft.levels.len() + 1; + let picture_description = next_draft + .levels + .first() + .map(|level| level.picture_description.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| fallback_text(&next_draft.anchor_pack.visual_subject.value, "画面主体")); + next_draft.levels.push(PuzzleDraftLevel { + level_id: format!("puzzle-level-{next_index}"), + level_name: build_level_name_from_picture( + picture_description.as_str(), + &next_draft.theme_tags, + next_index, + ), + picture_description, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }); + sync_primary_level_fields(&mut next_draft); + next_draft +} + +pub fn remove_puzzle_level( + draft: &PuzzleResultDraft, + level_id: &str, +) -> Result { + let mut next_draft = normalize_puzzle_draft(draft.clone()); + if next_draft.levels.len() <= 1 { + return Err(PuzzleFieldError::InvalidOperation); + } + let normalized_level_id = + normalize_required_string(level_id).ok_or(PuzzleFieldError::InvalidOperation)?; + next_draft + .levels + .retain(|level| level.level_id != normalized_level_id); + if next_draft.levels.is_empty() { + return Err(PuzzleFieldError::InvalidOperation); + } + sync_primary_level_fields(&mut next_draft); + Ok(next_draft) +} + pub fn build_result_preview( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> PuzzleResultPreviewEnvelope { - let blockers = validate_publish_requirements(draft, author_display_name); + let normalized_draft = normalize_puzzle_draft(draft.clone()); + if normalized_draft.form_draft.is_some() { + return PuzzleResultPreviewEnvelope { + draft: normalized_draft, + blockers: Vec::new(), + quality_findings: Vec::new(), + publish_ready: false, + }; + } + let blockers = validate_publish_requirements(&normalized_draft, author_display_name); PuzzleResultPreviewEnvelope { - draft: draft.clone(), + draft: normalized_draft, blockers, quality_findings: Vec::new(), publish_ready: validate_publish_requirements(draft, author_display_name).is_empty(), @@ -866,27 +1229,44 @@ pub fn validate_publish_requirements( draft: &PuzzleResultDraft, author_display_name: Option<&str>, ) -> Vec { + let draft = normalize_puzzle_draft(draft.clone()); let mut blockers = Vec::new(); - if normalize_required_string(&draft.level_name).is_none() { + if normalize_required_string(&draft.work_title).is_none() { blockers.push(PuzzleResultPreviewBlocker { - id: "missing-level-name".to_string(), - code: "MISSING_LEVEL_NAME".to_string(), - message: "关卡名不能为空".to_string(), + id: "missing-work-title".to_string(), + code: "MISSING_WORK_TITLE".to_string(), + message: "作品名称不能为空".to_string(), }); } - if draft - .cover_image_src - .as_deref() - .map(str::trim) - .unwrap_or("") - .is_empty() - { + if normalize_required_string(&draft.work_description).is_none() { blockers.push(PuzzleResultPreviewBlocker { - id: "missing-cover-image".to_string(), - code: "MISSING_COVER_IMAGE".to_string(), - message: "正式拼图图片尚未确定".to_string(), + id: "missing-work-description".to_string(), + code: "MISSING_WORK_DESCRIPTION".to_string(), + message: "作品描述不能为空".to_string(), }); } + for level in &draft.levels { + if normalize_required_string(&level.level_name).is_none() { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-level-name-{}", level.level_id), + code: "MISSING_LEVEL_NAME".to_string(), + message: "关卡名不能为空".to_string(), + }); + } + if level + .cover_image_src + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + blockers.push(PuzzleResultPreviewBlocker { + id: format!("missing-cover-image-{}", level.level_id), + code: "MISSING_COVER_IMAGE".to_string(), + message: "正式拼图图片尚未确定".to_string(), + }); + } + } if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT || draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT { @@ -917,18 +1297,22 @@ pub fn create_work_profile( ) -> Result { let author_display_name = normalize_required_string(author_display_name) .ok_or(PuzzleFieldError::MissingAuthorDisplayName)?; - let preview = build_result_preview(draft, Some(&author_display_name)); + let draft = normalize_puzzle_draft(draft.clone()); + let preview = build_result_preview(&draft, Some(&author_display_name)); Ok(PuzzleWorkProfile { work_id, profile_id, owner_user_id, source_session_id, author_display_name, + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), level_name: draft.level_name.clone(), summary: draft.summary.clone(), theme_tags: normalize_theme_tags(draft.theme_tags.clone()), cover_image_src: draft.cover_image_src.clone(), cover_asset_id: draft.cover_asset_id.clone(), + levels: draft.levels.clone(), publication_status: PuzzlePublicationStatus::Draft, updated_at_micros, published_at_micros: None, @@ -936,6 +1320,8 @@ pub fn create_work_profile( remix_count: 0, like_count: 0, recent_play_count_7d: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: preview.publish_ready, anchor_pack: draft.anchor_pack.clone(), }) @@ -946,14 +1332,18 @@ pub fn publish_work_profile( draft: &PuzzleResultDraft, published_at_micros: i64, ) -> Result { - if !validate_publish_requirements(draft, Some(&profile.author_display_name)).is_empty() { + let draft = normalize_puzzle_draft(draft.clone()); + if !validate_publish_requirements(&draft, Some(&profile.author_display_name)).is_empty() { return Err(PuzzleFieldError::InvalidOperation); } + profile.work_title = draft.work_title.clone(); + profile.work_description = draft.work_description.clone(); profile.level_name = draft.level_name.clone(); profile.summary = draft.summary.clone(); profile.theme_tags = normalize_theme_tags(draft.theme_tags.clone()); profile.cover_image_src = draft.cover_image_src.clone(); profile.cover_asset_id = draft.cover_asset_id.clone(); + profile.levels = draft.levels.clone(); profile.publication_status = PuzzlePublicationStatus::Published; profile.publish_ready = true; profile.updated_at_micros = published_at_micros; @@ -965,22 +1355,39 @@ pub fn publish_work_profile( /// 这里只允许覆盖 PRD 明确要求的关卡名、摘要与标签,不额外扩到更多结果页元数据。 pub fn apply_publish_overrides_to_draft( draft: &PuzzleResultDraft, + work_title: Option, + work_description: Option, level_name: Option, summary: Option, theme_tags: Option>, + levels: Option>, ) -> Result { - let mut next_draft = draft.clone(); + let mut next_draft = normalize_puzzle_draft(draft.clone()); + + if let Some(next_work_title) = work_title + && let Some(normalized_work_title) = normalize_required_string(&next_work_title) + { + next_draft.work_title = normalized_work_title; + } + + if let Some(next_work_description) = work_description + && let Some(normalized_work_description) = normalize_required_string(&next_work_description) + { + next_draft.work_description = normalized_work_description; + } if let Some(next_level_name) = level_name && let Some(normalized_level_name) = normalize_required_string(&next_level_name) { - next_draft.level_name = normalized_level_name; + if let Some(primary_level) = next_draft.levels.first_mut() { + primary_level.level_name = normalized_level_name; + } } if let Some(next_summary) = summary && let Some(normalized_summary) = normalize_required_string(&next_summary) { - next_draft.summary = normalized_summary; + next_draft.work_description = normalized_summary; } if let Some(next_theme_tags) = theme_tags { @@ -993,23 +1400,116 @@ pub fn apply_publish_overrides_to_draft( next_draft.theme_tags = normalized_theme_tags; } + if let Some(next_levels) = levels { + let normalized_levels = normalize_puzzle_levels(next_levels, &next_draft.theme_tags)?; + next_draft.levels = normalized_levels; + } + + sync_primary_level_fields(&mut next_draft); Ok(next_draft) } +pub fn normalize_puzzle_levels( + levels: Vec, + theme_tags: &[String], +) -> Result, PuzzleFieldError> { + let mut normalized_levels = Vec::new(); + for (index, mut level) in levels.into_iter().enumerate() { + let level_id = normalize_required_string(&level.level_id) + .unwrap_or_else(|| format!("puzzle-level-{}", index + 1)); + let picture_description = normalize_required_string(&level.picture_description) + .unwrap_or_else(|| format!("第{}关画面", index + 1)); + let level_name = normalize_required_string(&level.level_name).unwrap_or_else(|| { + build_level_name_from_picture(picture_description.as_str(), theme_tags, index + 1) + }); + level.level_id = level_id; + level.level_name = level_name; + level.picture_description = picture_description; + level.generation_status = normalize_required_string(&level.generation_status) + .unwrap_or_else(|| "idle".to_string()); + normalized_levels.push(level); + } + if normalized_levels.is_empty() { + return Err(PuzzleFieldError::InvalidOperation); + } + Ok(normalized_levels) +} + +pub fn is_supported_puzzle_grid_size(grid_size: u32) -> bool { + PUZZLE_SUPPORTED_GRID_SIZES.contains(&grid_size) +} + +pub fn resolve_puzzle_level_config(level_index: u32) -> PuzzleLevelConfig { + let level_index = level_index.max(1); + match level_index { + 1 => PuzzleLevelConfig { + grid_size: 3, + time_limit_ms: 300_000, + }, + 2 => PuzzleLevelConfig { + grid_size: 4, + time_limit_ms: 300_000, + }, + 3 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 300_000, + }, + 4 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + _ => { + let loop_index = (level_index.saturating_sub(5) % 6) + 5; + match loop_index { + 5 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + 6 => PuzzleLevelConfig { + grid_size: 6, + time_limit_ms: 240_000, + }, + 7 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 210_000, + }, + 8 => PuzzleLevelConfig { + grid_size: 7, + time_limit_ms: 270_000, + }, + 9 => PuzzleLevelConfig { + grid_size: 5, + time_limit_ms: 240_000, + }, + _ => PuzzleLevelConfig { + grid_size: 7, + time_limit_ms: 270_000, + }, + } + } + } +} + pub fn resolve_puzzle_grid_size(cleared_level_count: u32) -> u32 { - if cleared_level_count >= 3 { 4 } else { 3 } + resolve_puzzle_level_config(cleared_level_count + 1).grid_size +} + +pub fn resolve_puzzle_level_time_limit_ms_by_index(level_index: u32) -> u64 { + resolve_puzzle_level_config(level_index.max(1)).time_limit_ms } pub fn resolve_puzzle_level_time_limit_ms(grid_size: u32) -> u64 { match grid_size { - 4 => 300_000, - _ => 180_000, + 3 | 4 | 5 => 300_000, + 6 => 240_000, + 7 => 270_000, + _ => 300_000, } } pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { let time_limit_ms = if level.time_limit_ms == 0 { - resolve_puzzle_level_time_limit_ms(level.grid_size) + resolve_puzzle_level_time_limit_ms_by_index(level.level_index) } else { level.time_limit_ms }; @@ -1021,7 +1521,7 @@ fn normalize_timer_fields(level: &mut PuzzleRuntimeLevelSnapshot, now_ms: u64) { level.started_at_ms = now_ms; } if level.time_limit_ms == 0 { - level.time_limit_ms = resolve_puzzle_level_time_limit_ms(level.grid_size); + level.time_limit_ms = resolve_puzzle_level_time_limit_ms_by_index(level.level_index); } if level.remaining_ms == 0 && level.status == PuzzleRuntimeLevelStatus::Playing { level.remaining_ms = level.time_limit_ms; @@ -1153,6 +1653,42 @@ pub fn apply_puzzle_freeze_time( apply_puzzle_freeze_time_at(run, current_unix_ms()) } +pub fn extend_failed_puzzle_time_at( + run: &PuzzleRunSnapshot, + now_ms: u64, +) -> Result { + let mut next_run = resolve_puzzle_run_timer_at(run.clone(), now_ms); + let current_level = next_run + .current_level + .as_mut() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Failed { + return Err(PuzzleFieldError::InvalidOperation); + } + + let total_consumed_before_extend = current_level + .time_limit_ms + .saturating_sub(PUZZLE_EXTEND_TIME_DURATION_MS); + current_level.status = PuzzleRuntimeLevelStatus::Playing; + current_level.elapsed_ms = None; + current_level.cleared_at_ms = None; + current_level.remaining_ms = PUZZLE_EXTEND_TIME_DURATION_MS; + current_level.started_at_ms = now_ms.saturating_sub(total_consumed_before_extend); + current_level.paused_accumulated_ms = 0; + current_level.pause_started_at_ms = None; + current_level.freeze_accumulated_ms = 0; + current_level.freeze_started_at_ms = None; + current_level.freeze_until_ms = None; + + Ok(next_run) +} + +pub fn extend_failed_puzzle_time( + run: &PuzzleRunSnapshot, +) -> Result { + extend_failed_puzzle_time_at(run, current_unix_ms()) +} + pub fn build_initial_board(grid_size: u32) -> Result { build_initial_board_with_seed(grid_size, 0) } @@ -1161,7 +1697,7 @@ pub fn build_initial_board_with_seed( grid_size: u32, shuffle_seed: u64, ) -> Result { - if !matches!(grid_size, 3 | 4) { + if !is_supported_puzzle_grid_size(grid_size) { return Err(PuzzleFieldError::InvalidGridSize); } @@ -1227,19 +1763,25 @@ pub fn start_run_with_shuffle_seed_at( shuffle_seed: u64, started_at_ms: u64, ) -> Result { - let grid_size = resolve_puzzle_grid_size(cleared_level_count); + let level_index = cleared_level_count + 1; + let level_config = resolve_puzzle_level_config(level_index); + let grid_size = level_config.grid_size; let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), cleared_level_count, - current_level_index: cleared_level_count + 1, + current_level_index: level_index, current_grid_size: grid_size, played_profile_ids: vec![entry_profile.profile_id.clone()], previous_level_tags: entry_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id, - level_index: cleared_level_count + 1, + level_index, + level_id: entry_profile + .levels + .first() + .map(|level| level.level_id.clone()), grid_size, profile_id: entry_profile.profile_id.clone(), level_name: entry_profile.level_name.clone(), @@ -1251,8 +1793,8 @@ pub fn start_run_with_shuffle_seed_at( started_at_ms, cleared_at_ms: None, elapsed_ms: None, - time_limit_ms: resolve_puzzle_level_time_limit_ms(grid_size), - remaining_ms: resolve_puzzle_level_time_limit_ms(grid_size), + time_limit_ms: level_config.time_limit_ms, + remaining_ms: level_config.time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, @@ -1261,6 +1803,10 @@ pub fn start_run_with_shuffle_seed_at( leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } @@ -1479,11 +2025,13 @@ pub fn advance_next_level_at( } let next_cleared_count = run.cleared_level_count; - let next_grid_size = resolve_puzzle_grid_size(next_cleared_count); + let next_level_index = run.current_level_index + 1; + let next_level_config = resolve_puzzle_level_config(next_level_index); + let next_grid_size = next_level_config.grid_size; let shuffle_seed = puzzle_shuffle_seed( &run.run_id, &next_profile.profile_id, - run.current_level_index + 1, + next_level_index, next_grid_size, ); let next_board = build_initial_board_with_seed(next_grid_size, shuffle_seed)?; @@ -1494,13 +2042,17 @@ pub fn advance_next_level_at( run_id: run.run_id.clone(), entry_profile_id: run.entry_profile_id.clone(), cleared_level_count: next_cleared_count, - current_level_index: run.current_level_index + 1, + current_level_index: next_level_index, current_grid_size: next_grid_size, played_profile_ids, previous_level_tags: next_profile.theme_tags.clone(), current_level: Some(PuzzleRuntimeLevelSnapshot { run_id: run.run_id.clone(), - level_index: run.current_level_index + 1, + level_index: next_level_index, + level_id: next_profile + .levels + .first() + .map(|level| level.level_id.clone()), grid_size: next_grid_size, profile_id: next_profile.profile_id.clone(), level_name: next_profile.level_name.clone(), @@ -1512,8 +2064,8 @@ pub fn advance_next_level_at( started_at_ms, cleared_at_ms: None, elapsed_ms: None, - time_limit_ms: resolve_puzzle_level_time_limit_ms(next_grid_size), - remaining_ms: resolve_puzzle_level_time_limit_ms(next_grid_size), + time_limit_ms: next_level_config.time_limit_ms, + remaining_ms: next_level_config.time_limit_ms, paused_accumulated_ms: 0, pause_started_at_ms: None, freeze_accumulated_ms: 0, @@ -1522,15 +2074,176 @@ pub fn advance_next_level_at( leaderboard_entries: Vec::new(), }), recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }) } +pub fn advance_to_new_work_first_level_at( + run: &PuzzleRunSnapshot, + next_profile: &PuzzleWorkProfile, + started_at_ms: u64, +) -> Result { + let current_level = run + .current_level + .clone() + .ok_or(PuzzleFieldError::InvalidOperation)?; + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + return Err(PuzzleFieldError::InvalidOperation); + } + + // 中文注释:跨作品代表进入一个新作品,关卡序号、切割规格和倒计时都从第 1 关重新开始。 + let next_level_index = 1; + let level_config = resolve_puzzle_level_config(next_level_index); + let grid_size = level_config.grid_size; + let shuffle_seed = puzzle_shuffle_seed( + &run.run_id, + &next_profile.profile_id, + next_level_index, + grid_size, + ); + let next_board = build_initial_board_with_seed(grid_size, shuffle_seed)?; + let mut played_profile_ids = run.played_profile_ids.clone(); + if !played_profile_ids.contains(&next_profile.profile_id) { + played_profile_ids.push(next_profile.profile_id.clone()); + } + + Ok(PuzzleRunSnapshot { + run_id: run.run_id.clone(), + entry_profile_id: next_profile.profile_id.clone(), + cleared_level_count: 0, + current_level_index: next_level_index, + current_grid_size: grid_size, + played_profile_ids, + previous_level_tags: next_profile.theme_tags.clone(), + current_level: Some(PuzzleRuntimeLevelSnapshot { + run_id: run.run_id.clone(), + level_index: next_level_index, + level_id: next_profile + .levels + .first() + .map(|level| level.level_id.clone()), + grid_size, + profile_id: next_profile.profile_id.clone(), + level_name: next_profile.level_name.clone(), + author_display_name: next_profile.author_display_name.clone(), + theme_tags: next_profile.theme_tags.clone(), + cover_image_src: next_profile.cover_image_src.clone(), + board: next_board, + status: PuzzleRuntimeLevelStatus::Playing, + started_at_ms, + cleared_at_ms: None, + elapsed_ms: None, + time_limit_ms: level_config.time_limit_ms, + remaining_ms: level_config.time_limit_ms, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, + leaderboard_entries: Vec::new(), + }), + recommended_next_profile_id: None, + next_level_mode: default_puzzle_next_level_mode(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), + leaderboard_entries: Vec::new(), + }) +} + +pub fn selected_profile_level_after_index( + profile: &PuzzleWorkProfile, + current_level_index: u32, +) -> Option { + if current_level_index == 0 { + return None; + } + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + normalized_levels.get(current_level_index as usize).cloned() +} + +pub fn selected_profile_level_after_runtime_level( + profile: &PuzzleWorkProfile, + current_level: &PuzzleRuntimeLevelSnapshot, +) -> Option { + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + if normalized_levels.len() <= 1 { + return None; + } + + let matched_index = current_level + .level_id + .as_ref() + .and_then(|level_id| { + normalized_levels + .iter() + .position(|level| level.level_id == *level_id) + }) + .or_else(|| { + current_level + .cover_image_src + .as_ref() + .and_then(|cover_image_src| { + normalized_levels.iter().position(|level| { + level.cover_image_src.as_ref() == Some(cover_image_src) + && level.level_name == current_level.level_name + }) + }) + }) + .or_else(|| { + normalized_levels.iter().position(|level| { + level.level_name == current_level.level_name + && level.cover_image_src == current_level.cover_image_src + }) + }) + .or_else(|| { + current_level.level_index.checked_sub(1).and_then(|index| { + ((index as usize) < normalized_levels.len()).then_some(index as usize) + }) + })?; + + normalized_levels.get(matched_index + 1).cloned() +} + +pub fn selected_profile_level_index(profile: &PuzzleWorkProfile, level_id: &str) -> Option { + let target_level_id = normalize_required_string(level_id)?; + let normalized_levels = normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()); + normalized_levels + .iter() + .position(|level| level.level_id == target_level_id) +} + +pub fn resolve_restart_cleared_level_count(profile: &PuzzleWorkProfile, level_id: &str) -> u32 { + // 中文注释:失败重开指定的是当前关 levelId;start_run_at 用“已通关数 + 1”计算当前关,所以这里返回关卡下标。 + selected_profile_level_index(profile, level_id).unwrap_or(0) as u32 +} + pub fn select_next_profile<'a>( current_profile: &PuzzleWorkProfile, played_profile_ids: &[String], candidates: &'a [PuzzleWorkProfile], ) -> Option<&'a PuzzleWorkProfile> { + select_next_profiles(current_profile, played_profile_ids, candidates, 1) + .into_iter() + .next() +} + +pub fn select_next_profiles<'a>( + current_profile: &PuzzleWorkProfile, + played_profile_ids: &[String], + candidates: &'a [PuzzleWorkProfile], + limit: usize, +) -> Vec<&'a PuzzleWorkProfile> { + if limit == 0 { + return Vec::new(); + } let mut available = candidates .iter() .filter(|candidate| { @@ -1551,23 +2264,25 @@ pub fn select_next_profile<'a>( available.retain(|candidate| candidate.profile_id != *last_played); } - available.into_iter().max_by(|left, right| { + available.sort_by(|left, right| { let left_score = recommendation_score(current_profile, left); let right_score = recommendation_score(current_profile, right); - left_score - .partial_cmp(&right_score) + right_score + .partial_cmp(&left_score) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| { - tag_similarity_score(¤t_profile.theme_tags, &left.theme_tags) + tag_similarity_score(¤t_profile.theme_tags, &right.theme_tags) .partial_cmp(&tag_similarity_score( ¤t_profile.theme_tags, - &right.theme_tags, + &left.theme_tags, )) .unwrap_or(std::cmp::Ordering::Equal) }) - .then_with(|| right.play_count.cmp(&left.play_count)) - .then_with(|| left.updated_at_micros.cmp(&right.updated_at_micros)) - }) + .then_with(|| left.play_count.cmp(&right.play_count)) + .then_with(|| right.updated_at_micros.cmp(&left.updated_at_micros)) + }); + available.truncate(limit); + available } pub fn recommendation_score( @@ -1598,10 +2313,169 @@ pub fn tag_similarity_score(left_tags: &[String], right_tags: &[String]) -> f32 if union <= f32::EPSILON { 0.0 } else { - intersection / union + let lexical_score = intersection / union; + // 中文注释:优先复用 RPG build 标签的属性亲和度语义模型;拼图自有标签未命中时保留 Jaccard 兜底。 + rpg_build_tag_set_similarity(&left_set, &right_set) + .map(|semantic_score| semantic_score.max(lexical_score)) + .unwrap_or(lexical_score) } } +#[derive(Clone, Copy)] +struct RpgBuildTagSemanticDefinition { + category: &'static str, + affinity: [f32; 6], +} + +fn rpg_affinity(strength: f32, agility: f32, intelligence: f32, spirit: f32) -> [f32; 6] { + [ + strength * 0.72 + spirit * 0.28, + agility * 0.88 + intelligence * 0.12, + intelligence * 0.78 + agility * 0.22, + strength * 0.62 + agility * 0.18 + intelligence * 0.2, + spirit * 0.72 + intelligence * 0.28, + spirit * 0.74 + strength * 0.26, + ] +} + +fn resolve_rpg_build_tag_semantic(tag: &str) -> Option { + let normalized = tag.trim().to_lowercase(); + let value = normalized.as_str(); + let definition = match value { + "quickblade" | "快剑" | "快刀" | "决斗者" => { + ("style", rpg_affinity(0.35, 1.0, 0.1, 0.05)) + } + "combo" | "连段" | "连击" | "连锁" => ("style", rpg_affinity(0.3, 0.92, 0.18, 0.08)), + "dash" | "突进" | "冲锋" => ("style", rpg_affinity(0.45, 0.95, 0.0, 0.0)), + "pursuit" | "追击" => ("style", rpg_affinity(0.38, 0.88, 0.08, 0.02)), + "swiftstrike" | "快袭" | "刺袭" | "伏击" => { + ("style", rpg_affinity(0.22, 0.98, 0.12, 0.04)) + } + "ranged" | "远射" | "射击" | "箭矢" => { + ("style", rpg_affinity(0.18, 0.82, 0.34, 0.08)) + } + "guerrilla" | "游击" | "骚扰" => ("style", rpg_affinity(0.24, 0.9, 0.28, 0.12)), + "mobility" | "机动" | "敏捷" | "灵活" => { + ("style", rpg_affinity(0.18, 1.0, 0.08, 0.08)) + } + "windrun" | "风行" | "疾行" => ("style", rpg_affinity(0.08, 1.0, 0.1, 0.1)), + "heavyhit" | "重击" => ("style", rpg_affinity(1.0, 0.28, 0.02, 0.04)), + "burst" | "爆发" => ("style", rpg_affinity(0.72, 0.58, 0.36, 0.08)), + "armorbreak" | "破甲" => ("style", rpg_affinity(0.92, 0.28, 0.08, 0.02)), + "pressure" | "压制" => ("style", rpg_affinity(0.62, 0.64, 0.1, 0.08)), + "bloodrush" | "压血" => ("resource", rpg_affinity(0.84, 0.54, 0.04, 0.18)), + "guard" | "守御" | "守卫" | "防御" => { + ("defense", rpg_affinity(0.7, 0.18, 0.04, 0.72)) + } + "barrier" | "护体" | "护罩" | "护盾" => { + ("defense", rpg_affinity(0.48, 0.08, 0.2, 0.92)) + } + "heavyarmor" | "重甲" => ("defense", rpg_affinity(0.88, 0.04, 0.02, 0.54)), + "counter" | "反击" | "回击" => ("defense", rpg_affinity(0.66, 0.46, 0.14, 0.36)), + "banish" | "镇邪" => ("defense", rpg_affinity(0.24, 0.06, 0.54, 0.88)), + "caster" | "法修" | "法师" => ("element", rpg_affinity(0.0, 0.1, 1.0, 0.6)), + "mana" | "法力" => ("resource", rpg_affinity(0.02, 0.08, 0.94, 0.74)), + "thunder" | "雷法" => ("element", rpg_affinity(0.06, 0.24, 0.96, 0.42)), + "formation" | "符阵" | "法阵" => ("element", rpg_affinity(0.08, 0.12, 0.82, 0.96)), + "control" | "控场" | "控制" => ("style", rpg_affinity(0.12, 0.34, 0.78, 0.72)), + "overload" | "过载" => ("resource", rpg_affinity(0.14, 0.18, 0.92, 0.38)), + "heal" | "回复" | "治疗" => ("resource", rpg_affinity(0.02, 0.08, 0.56, 1.0)), + "support" | "护持" | "支援" | "祝福" => { + ("resource", rpg_affinity(0.14, 0.14, 0.58, 0.98)) + } + "sustain" | "续战" => ("resource", rpg_affinity(0.34, 0.18, 0.22, 0.9)), + "fate" | "命纹" => ("flow", rpg_affinity(0.08, 0.22, 0.72, 0.84)), + "fortune" | "机缘" => ("flow", rpg_affinity(0.06, 0.34, 0.7, 0.78)), + "cooldown" | "冷却" => ("resource", rpg_affinity(0.04, 0.46, 0.82, 0.4)), + "command" | "统御" => ("flow", rpg_affinity(0.38, 0.26, 0.72, 0.82)), + "balanced" | "均衡" | "平衡" | "全能" => { + ("flow", rpg_affinity(0.58, 0.58, 0.58, 0.58)) + } + "craft" | "工巧" | "工艺" => ("craft", rpg_affinity(0.24, 0.16, 0.74, 0.5)), + "alchemy" | "炼药" | "药剂" => ("craft", rpg_affinity(0.08, 0.16, 0.84, 0.76)), + "vanguard" | "先锋" => ("flow", rpg_affinity(0.82, 0.44, 0.08, 0.34)), + "berserk" | "狂战" => ("flow", rpg_affinity(0.98, 0.42, 0.0, 0.22)), + "spellblade" | "法剑" => ("flow", rpg_affinity(0.42, 0.42, 0.88, 0.38)), + "paladin" | "圣佑" | "圣骑士" => ("flow", rpg_affinity(0.58, 0.12, 0.42, 0.96)), + "fortress" | "堡垒" => ("flow", rpg_affinity(0.94, 0.04, 0.08, 0.82)), + "starter" | "起手" => ("flow", rpg_affinity(0.42, 0.42, 0.42, 0.42)), + _ => return None, + }; + Some(RpgBuildTagSemanticDefinition { + category: definition.0, + affinity: definition.1, + }) +} + +fn normalized_affinity_dot(left: [f32; 6], right: [f32; 6]) -> f32 { + let left_magnitude = left.iter().map(|value| value * value).sum::().sqrt(); + let right_magnitude = right.iter().map(|value| value * value).sum::().sqrt(); + if left_magnitude <= 0.0001 || right_magnitude <= 0.0001 { + return 0.0; + } + left.iter() + .zip(right.iter()) + .map(|(left_value, right_value)| { + (left_value / left_magnitude) * (right_value / right_magnitude) + }) + .sum::() +} + +fn rpg_build_tag_similarity( + left: RpgBuildTagSemanticDefinition, + right: RpgBuildTagSemanticDefinition, +) -> f32 { + let category_bonus = if left.category == right.category { + 0.08 + } else { + 0.0 + }; + (normalized_affinity_dot(left.affinity, right.affinity) + category_bonus).min(1.0) +} + +fn rpg_build_tag_directional_similarity( + left: &[RpgBuildTagSemanticDefinition], + right: &[RpgBuildTagSemanticDefinition], +) -> f32 { + if left.is_empty() || right.is_empty() { + return 0.0; + } + let total = left + .iter() + .map(|left_definition| { + right + .iter() + .map(|right_definition| { + rpg_build_tag_similarity(*left_definition, *right_definition) + }) + .fold(0.0_f32, f32::max) + }) + .sum::(); + total / left.len() as f32 +} + +fn rpg_build_tag_set_similarity( + left_tags: &BTreeSet, + right_tags: &BTreeSet, +) -> Option { + let left_definitions = left_tags + .iter() + .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) + .collect::>(); + let right_definitions = right_tags + .iter() + .filter_map(|tag| resolve_rpg_build_tag_semantic(tag)) + .collect::>(); + if left_definitions.is_empty() || right_definitions.is_empty() { + return None; + } + Some( + (rpg_build_tag_directional_similarity(&left_definitions, &right_definitions) + + rpg_build_tag_directional_similarity(&right_definitions, &left_definitions)) + / 2.0, + ) +} + pub fn normalize_theme_tags(tags: Vec) -> Vec { let alias_map = BTreeMap::from([ ("蒸汽", "蒸汽城市"), @@ -1683,25 +2557,54 @@ fn infer_tags_and_forbidden(source: &str) -> String { } } -fn parse_form_seed_text(source: &str) -> Option<(String, String)> { +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct PuzzleFormSeedParts { + work_title: Option, + work_description: Option, + picture_description: Option, +} + +impl PuzzleFormSeedParts { + fn has_any_value(&self) -> bool { + self.work_title.is_some() + || self.work_description.is_some() + || self.picture_description.is_some() + } +} + +fn parse_form_seed_text(source: &str) -> Option { let normalized_source = source.trim(); - let title_marker = "拼图标题:"; - let description_marker = "画面描述:"; - let title_start = normalized_source.find(title_marker)? + title_marker.len(); - let description_start = normalized_source.find(description_marker)?; - if description_start <= title_start { + if normalized_source.is_empty() { return None; } - let title = normalize_required_string(&normalized_source[title_start..description_start]); - let picture_description = normalize_required_string( - &normalized_source[description_start + description_marker.len()..], - ); + let title_marker = if normalized_source.contains("作品名称:") { + "作品名称:" + } else { + "拼图标题:" + }; + let parts = PuzzleFormSeedParts { + work_title: extract_form_seed_value(normalized_source, title_marker), + work_description: extract_form_seed_value(normalized_source, "作品描述:"), + picture_description: extract_form_seed_value(normalized_source, "画面描述:"), + }; - match (title, picture_description) { - (Some(title), Some(picture_description)) => Some((title, picture_description)), - _ => None, - } + parts.has_any_value().then_some(parts) +} + +fn extract_form_seed_value(source: &str, marker: &str) -> Option { + let value_start = source.find(marker)? + marker.len(); + let value_end = ["作品名称:", "拼图标题:", "作品描述:", "画面描述:"] + .into_iter() + .filter(|next_marker| *next_marker != marker) + .filter_map(|next_marker| { + source[value_start..] + .find(next_marker) + .map(|index| value_start + index) + }) + .min() + .unwrap_or(source.len()); + normalize_required_string(&source[value_start..value_end]) } fn build_form_tags_and_forbidden(title: &str, picture_description: &str) -> String { @@ -1758,7 +2661,7 @@ fn derive_form_theme_tags(title: &str, picture_description: &str) -> Vec fn is_form_anchor_pack(anchor_pack: &PuzzleAnchorPack) -> bool { matches!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked) - && matches!( + || matches!( anchor_pack.visual_subject.status, PuzzleAnchorStatus::Locked ) @@ -1777,6 +2680,22 @@ fn build_result_summary(anchor_pack: &PuzzleAnchorPack) -> String { ) } +fn resolve_work_description(seed_text: Option<&str>, anchor_pack: &PuzzleAnchorPack) -> String { + seed_text + .and_then(parse_form_seed_text) + .and_then(|parts| { + parts + .work_description + .or(parts.picture_description) + .or(parts.work_title) + }) + .unwrap_or_else(|| build_result_summary(anchor_pack)) +} + +fn build_work_title(anchor_pack: &PuzzleAnchorPack) -> String { + fallback_text(&anchor_pack.theme_promise.value, "奇景拼图") +} + fn extract_forbidden_directive(source: &str) -> String { if let Some((_, tail)) = source.split_once(';') { return normalize_required_string(tail).unwrap_or_else(|| "禁止标题字".to_string()); @@ -1784,20 +2703,24 @@ fn extract_forbidden_directive(source: &str) -> String { "禁止标题字".to_string() } -fn build_level_name(anchor_pack: &PuzzleAnchorPack, normalized_tags: &[String]) -> String { - if is_form_anchor_pack(anchor_pack) - && let Some(title) = normalize_required_string(&anchor_pack.theme_promise.value) - { - return title; +fn build_level_name_from_picture( + picture_description: &str, + normalized_tags: &[String], + level_index: usize, +) -> String { + let source = normalize_required_string(picture_description).unwrap_or_default(); + for keyword in [ + "猫", "狗", "神庙", "遗迹", "森林", "雨夜", "城市", "机械", "海", "花", "雪", "龙", "灯", + "塔", + ] { + if source.contains(keyword) { + return format!("{keyword}画面"); + } } - if let Some(tag) = normalized_tags.first() { - return format!("{tag}拼图"); + return format!("{tag}第{level_index}关"); } - if let Some(subject) = normalize_required_string(&anchor_pack.visual_subject.value) { - return subject.chars().take(8).collect::(); - } - "奇景拼图".to_string() + format!("第{level_index}关") } fn fallback_text(value: &str, fallback: &str) -> String { @@ -1862,7 +2785,8 @@ fn build_initial_pieces_without_correct_neighbors( } // 随机尝试耗尽后使用确定性约束搜索兜底,保证开局没有任意一对原图相邻块互相贴边。 - let fallback_pieces = build_original_neighbor_free_pieces(grid_size, shuffle_seed) + let fallback_pieces = build_deterministic_neighbor_free_pieces(grid_size, shuffle_seed) + .or_else(|| build_original_neighbor_free_pieces(grid_size, shuffle_seed)) .unwrap_or_else(|| build_pieces_from_positions(grid_size, &base_positions)); debug_assert!(!has_any_original_neighbor_pair(&fallback_pieces)); fallback_pieces @@ -1930,6 +2854,124 @@ fn are_original_neighbors(left: &PuzzlePieceState, right: &PuzzlePieceState) -> left.correct_row.abs_diff(right.correct_row) + left.correct_col.abs_diff(right.correct_col) == 1 } +fn build_deterministic_neighbor_free_pieces( + grid_size: u32, + shuffle_seed: u64, +) -> Option> { + // 中文注释:大棋盘随机命中“无原图相邻贴边”的概率较低,失败后用确定性排列兜底保证稳定开局。 + let positions = match grid_size { + 3 => build_seeded_3x3_neighbor_free_positions(shuffle_seed), + 4 | 6 => build_affine_neighbor_free_positions(grid_size, 1, 1, 2, 1, shuffle_seed), + 5 | 7 => { + build_affine_neighbor_free_positions(grid_size, 0, 1, 2, grid_size - 1, shuffle_seed) + } + _ => return None, + }; + let pieces = build_pieces_from_positions(grid_size, &positions); + (!has_any_original_neighbor_pair(&pieces)).then_some(pieces) +} + +fn build_seeded_3x3_neighbor_free_positions(shuffle_seed: u64) -> Vec { + const LAYOUTS: [[(u32, u32); 9]; 6] = [ + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (0, 2), + (2, 1), + (1, 1), + (2, 2), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (0, 2), + (2, 1), + (2, 2), + (1, 1), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 0), + (2, 2), + (0, 0), + (1, 1), + (0, 2), + (2, 1), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 1), + (0, 2), + (2, 0), + (0, 0), + (2, 2), + (1, 1), + ], + [ + (0, 1), + (1, 0), + (1, 2), + (2, 2), + (0, 2), + (2, 1), + (1, 1), + (2, 0), + (0, 0), + ], + [ + (0, 1), + (1, 0), + (2, 1), + (2, 0), + (2, 2), + (0, 2), + (1, 2), + (0, 0), + (1, 1), + ], + ]; + let layout = &LAYOUTS[(shuffle_seed as usize) % LAYOUTS.len()]; + layout + .into_iter() + .map(|(row, col)| PuzzleCellPosition { + row: *row, + col: *col, + }) + .collect() +} + +fn build_affine_neighbor_free_positions( + grid_size: u32, + row_from_row: u32, + row_from_col: u32, + col_from_row: u32, + col_from_col: u32, + shuffle_seed: u64, +) -> Vec { + let row_offset = (shuffle_seed % u64::from(grid_size)) as u32; + let col_offset = ((shuffle_seed / u64::from(grid_size)) % u64::from(grid_size)) as u32; + (0..(grid_size * grid_size)) + .map(|index| { + let row = index / grid_size; + let col = index % grid_size; + PuzzleCellPosition { + row: (row_from_row * row + row_from_col * col + row_offset) % grid_size, + col: (col_from_row * row + col_from_col * col + col_offset) % grid_size, + } + }) + .collect() +} + fn build_original_neighbor_free_pieces( grid_size: u32, shuffle_seed: u64, @@ -2417,6 +3459,19 @@ pub fn current_puzzle_unix_micros() -> i64 { (current_unix_ms() as i64).saturating_mul(1_000) } +pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_points: u64) -> u64 { + total_half_points + .saturating_div(2) + .saturating_sub(claimed_points) +} + +pub fn puzzle_point_incentive_total_after_spend( + total_half_points: u64, + spent_points: u64, +) -> u64 { + total_half_points.saturating_add(spent_points) +} + #[cfg(test)] mod tests { use super::*; @@ -2432,17 +3487,32 @@ mod tests { owner_user_id: owner_user_id.to_string(), source_session_id: None, author_display_name: "作者".to_string(), + work_title: format!("{profile_id} 作品"), + work_description: "summary".to_string(), level_name: format!("{profile_id} 关"), summary: "summary".to_string(), theme_tags: tags.into_iter().map(|value| value.to_string()).collect(), cover_image_src: Some("/cover.png".to_string()), cover_asset_id: Some("asset-1".to_string()), + levels: vec![PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: format!("{profile_id} 关"), + picture_description: "summary".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/cover.png".to_string()), + cover_asset_id: Some("asset-1".to_string()), + generation_status: "ready".to_string(), + }], publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 100, published_at_micros: Some(100), play_count: 0, + recent_play_count_7d: 0, remix_count: 0, like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), } @@ -2451,8 +3521,51 @@ mod tests { #[test] fn resolve_grid_size_matches_prd() { assert_eq!(resolve_puzzle_grid_size(0), 3); - assert_eq!(resolve_puzzle_grid_size(2), 3); - assert_eq!(resolve_puzzle_grid_size(3), 4); + assert_eq!(resolve_puzzle_grid_size(1), 4); + assert_eq!(resolve_puzzle_grid_size(2), 5); + assert_eq!(resolve_puzzle_grid_size(3), 5); + assert_eq!(resolve_puzzle_grid_size(4), 5); + assert_eq!(resolve_puzzle_grid_size(5), 6); + assert_eq!(resolve_puzzle_grid_size(6), 5); + assert_eq!(resolve_puzzle_grid_size(7), 7); + assert_eq!(resolve_puzzle_grid_size(8), 5); + assert_eq!(resolve_puzzle_grid_size(9), 7); + assert_eq!(resolve_puzzle_grid_size(10), 5); + assert_eq!(resolve_puzzle_grid_size(15), 7); + } + + #[test] + fn resolve_level_time_limit_matches_prd() { + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(1), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(2), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(3), 300_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(4), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(5), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(6), 240_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(7), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(8), 270_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(9), 240_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(10), 270_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(11), 210_000); + assert_eq!(resolve_puzzle_level_time_limit_ms_by_index(16), 270_000); + } + + #[test] + fn form_draft_preserves_partial_initial_fields() { + let seed_text = "作品名称:月台拼图\n作品描述:"; + let anchor_pack = infer_anchor_pack(seed_text, Some(seed_text)); + let draft = build_form_draft_from_seed(&anchor_pack, Some(seed_text)); + let form_draft = draft.form_draft.expect("form draft should exist"); + + assert_eq!(form_draft.work_title.as_deref(), Some("月台拼图")); + assert_eq!(form_draft.work_description, None); + assert_eq!(form_draft.picture_description, None); + assert_eq!(draft.work_title, "月台拼图"); + assert_eq!(draft.work_description, ""); + assert_eq!(draft.level_name, ""); + assert_eq!(draft.levels[0].level_name, ""); + assert_eq!(draft.anchor_pack.theme_promise.value, "月台拼图"); + assert_eq!(draft.anchor_pack.visual_subject.value, ""); } #[test] @@ -2488,10 +3601,16 @@ mod tests { #[test] fn form_seed_locks_title_and_picture_description_as_primary_anchors() { let anchor_pack = infer_anchor_pack( - "拼图标题:暖灯猫街\n画面描述:一只猫在雨夜灯牌下回头。", + "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", None, ); - let draft = compile_result_draft(&anchor_pack, &[]); + let draft = compile_result_draft_from_seed( + &anchor_pack, + &[], + Some( + "作品名称:暖灯猫街\n作品描述:一套雨夜猫街主题拼图。\n画面描述:一只猫在雨夜灯牌下回头。", + ), + ); assert_eq!(anchor_pack.theme_promise.value, "暖灯猫街"); assert_eq!(anchor_pack.theme_promise.status, PuzzleAnchorStatus::Locked); @@ -2500,8 +3619,14 @@ mod tests { anchor_pack.visual_subject.status, PuzzleAnchorStatus::Locked ); - assert_eq!(draft.level_name, "暖灯猫街"); - assert_eq!(draft.summary, "一只猫在雨夜灯牌下回头。"); + assert_eq!(draft.work_title, "暖灯猫街"); + assert_eq!(draft.work_description, "一套雨夜猫街主题拼图。"); + assert_eq!(draft.summary, "一套雨夜猫街主题拼图。"); + assert_eq!(draft.level_name, "猫画面"); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。" + ); assert_eq!( draft .creator_intent @@ -2525,15 +3650,16 @@ mod tests { "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" ); assert_eq!( - draft.summary, + draft.levels[0].picture_description, "一只猫在雨夜灯牌下回头。\n远处有暖色霓虹和玻璃雨滴。" ); + assert_eq!(draft.summary, draft.work_description); assert!(draft.theme_tags.iter().any(|tag| tag == "猫咪")); assert!(draft.theme_tags.iter().any(|tag| tag == "雨夜")); } #[test] - fn tag_similarity_score_uses_jaccard() { + fn tag_similarity_score_uses_jaccard_fallback() { let score = tag_similarity_score( &["蒸汽城市".to_string(), "雨夜".to_string()], &["蒸汽城市".to_string(), "猫咪".to_string()], @@ -2541,6 +3667,13 @@ mod tests { assert!((score - 0.3333).abs() < 0.01); } + #[test] + fn tag_similarity_score_prefers_rpg_build_semantic_affinity() { + let score = tag_similarity_score(&["快剑".to_string()], &["连击".to_string()]); + + assert!(score > 0.75); + } + #[test] fn select_next_profile_prefers_same_tags_and_author() { let current = build_published_profile("a", "owner-a", vec!["蒸汽城市", "雨夜"]); @@ -2553,6 +3686,66 @@ mod tests { assert_eq!(selected.profile_id, "b"); } + #[test] + fn restart_cleared_count_uses_selected_level_index() { + let mut profile = build_published_profile("entry", "owner-a", vec!["机关"]); + profile.levels = vec![ + PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: "第一关".to_string(), + picture_description: "第一关画面".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-1.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }, + PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画面".to_string(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-2.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }, + ]; + + assert_eq!( + resolve_restart_cleared_level_count(&profile, "puzzle-level-2"), + 1 + ); + assert_eq!( + resolve_restart_cleared_level_count(&profile, "missing-level"), + 0 + ); + } + + #[test] + fn advance_to_new_work_first_level_restarts_level_progress() { + let first_profile = build_published_profile("entry", "owner-a", vec!["奇幻", "遗迹"]); + let next_profile = build_published_profile("next", "owner-b", vec!["奇幻", "魔法"]); + let mut run = start_run("run-cross-work".to_string(), &first_profile, 2).expect("run"); + run.cleared_level_count = run.current_level_index; + let current_level = run.current_level.as_mut().expect("level"); + current_level.status = PuzzleRuntimeLevelStatus::Cleared; + current_level.cleared_at_ms = Some(2_000); + current_level.elapsed_ms = Some(1_000); + + let next_run = + advance_to_new_work_first_level_at(&run, &next_profile, 3_000).expect("next run"); + + assert_eq!(next_run.entry_profile_id, "next"); + assert_eq!(next_run.cleared_level_count, 0); + assert_eq!(next_run.current_level_index, 1); + let next_level = next_run.current_level.expect("next level"); + assert_eq!(next_level.profile_id, "next"); + assert_eq!(next_level.level_index, 1); + assert_eq!(next_level.grid_size, 3); + assert_eq!(next_level.time_limit_ms, 300_000); + } + #[test] fn swap_pieces_marks_cleared_when_back_to_origin() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); @@ -2599,9 +3792,19 @@ mod tests { assert_ne!(first_positions, second_positions); } + #[test] + fn puzzle_point_incentive_uses_half_points_and_floor_claimable() { + // 中文注释:累计单位是 half point,消耗 1 个陶泥币只让作者获得 0.5 个待结算陶泥币。 + assert_eq!(puzzle_point_incentive_total_after_spend(0, 1), 1); + assert_eq!(puzzle_point_incentive_claimable_points(1, 0), 0); + assert_eq!(puzzle_point_incentive_claimable_points(2, 0), 1); + assert_eq!(puzzle_point_incentive_claimable_points(5, 1), 1); + assert_eq!(puzzle_point_incentive_claimable_points(5, 2), 0); + } + #[test] fn initial_board_has_no_original_neighbor_pairs() { - for grid_size in [3, 4] { + for grid_size in PUZZLE_SUPPORTED_GRID_SIZES { for shuffle_seed in 0..128 { let board = build_initial_board_with_seed(grid_size, shuffle_seed).expect("board"); @@ -2865,6 +4068,28 @@ mod tests { assert_eq!(timed_level.elapsed_ms, Some(timed_level.time_limit_ms)); } + #[test] + fn failed_level_can_extend_one_minute() { + let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); + let now_ms = current_unix_ms(); + let mut run = + start_run_with_shuffle_seed("run-extend".to_string(), &profile, 0, 14).expect("run"); + let level = run.current_level.as_mut().expect("level"); + level.started_at_ms = now_ms.saturating_sub(level.time_limit_ms + 1_000); + + let failed_run = resolve_puzzle_run_timer_at(run, now_ms); + let extended_run = extend_failed_puzzle_time_at(&failed_run, now_ms + 5_000) + .expect("extend should succeed"); + let extended_level = extended_run.current_level.as_ref().expect("level"); + + assert_eq!(extended_level.status, PuzzleRuntimeLevelStatus::Playing); + assert_eq!(extended_level.remaining_ms, PUZZLE_EXTEND_TIME_DURATION_MS); + assert_eq!(extended_level.elapsed_ms, None); + assert_eq!(extended_level.cleared_at_ms, None); + assert_eq!(extended_level.pause_started_at_ms, None); + assert_eq!(extended_level.freeze_until_ms, None); + } + #[test] fn pause_and_freeze_are_excluded_from_effective_timer() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); @@ -2909,6 +4134,8 @@ mod tests { let updated = apply_publish_overrides_to_draft( &draft, + Some("雨夜猫塔作品".to_string()), + Some("作品描述。".to_string()), Some("雨夜猫塔".to_string()), Some("一张更聚焦猫咪塔楼的夜景拼图。".to_string()), Some(vec![ @@ -2916,10 +4143,12 @@ mod tests { "猫咪".to_string(), "遗迹".to_string(), ]), + None, ) .expect("publish overrides should succeed"); assert_eq!(updated.level_name, "雨夜猫塔"); + assert_eq!(updated.work_title, "雨夜猫塔作品"); assert_eq!(updated.summary, "一张更聚焦猫咪塔楼的夜景拼图。"); assert_eq!( updated.theme_tags, @@ -2935,9 +4164,16 @@ mod tests { fn apply_publish_overrides_rejects_invalid_tag_count() { let anchor_pack = infer_anchor_pack("蒸汽城市", Some("蒸汽城市")); let draft = compile_result_draft(&anchor_pack, &[]); - let error = - apply_publish_overrides_to_draft(&draft, None, None, Some(vec!["蒸汽".to_string()])) - .expect_err("invalid tag count should fail"); + let error = apply_publish_overrides_to_draft( + &draft, + None, + None, + None, + None, + Some(vec!["蒸汽".to_string()]), + None, + ) + .expect_err("invalid tag count should fail"); assert_eq!(error, PuzzleFieldError::InvalidTagCount); } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index a029f313..127e2051 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -264,6 +264,7 @@ pub enum RuntimeProfileWalletLedgerSourceType { AssetOperationConsume, AssetOperationRefund, RedeemCodeReward, + PuzzleAuthorIncentiveClaim, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -1783,6 +1784,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::AssetOperationConsume => "asset_operation_consume", Self::AssetOperationRefund => "asset_operation_refund", Self::RedeemCodeReward => "redeem_code_reward", + Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim", } } } @@ -2364,6 +2366,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(), "asset_operation_refund" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim.as_str(), + "puzzle_author_incentive_claim" + ); } #[test] diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 32f263b2..c390470d 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -18,6 +18,7 @@ pub const DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; pub const DEFAULT_MAX_RETRIES: u32 = 1; pub const DEFAULT_RETRY_BACKOFF_MS: u64 = 500; pub const CHAT_COMPLETIONS_PATH: &str = "/chat/completions"; +pub const RESPONSES_PATH: &str = "/responses"; const DEFAULT_LLM_RAW_LOG_DIR: &str = "logs/llm-raw"; static LLM_RAW_LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1); @@ -66,6 +67,14 @@ pub struct LlmTextRequest { pub messages: Vec, pub max_tokens: Option, pub enable_web_search: bool, + pub protocol: LlmTextProtocol, +} + +// 文本协议必须由业务请求显式选择,避免全局默认模型把不同场景混到同一上游形态。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LlmTextProtocol { + ChatCompletions, + Responses, } // 上层在流式消费时拿到的是“累计文本 + 当前增量”,避免每层重新自己拼接。 @@ -117,9 +126,16 @@ pub struct LlmClient { } #[derive(Serialize)] -struct ChatCompletionsRequestBody<'a> { - model: &'a str, - messages: &'a [LlmMessage], +#[serde(untagged)] +enum LlmRequestBody { + ChatCompletions(ChatCompletionsRequestBody), + Responses(ResponsesRequestBody), +} + +#[derive(Serialize)] +struct ChatCompletionsRequestBody { + model: String, + messages: Vec, stream: bool, #[serde(skip_serializing_if = "Option::is_none")] max_tokens: Option, @@ -130,10 +146,42 @@ struct ChatCompletionsRequestBody<'a> { #[derive(Serialize)] struct ChatCompletionsWebSearchOptions {} +#[derive(Serialize)] +struct ResponsesRequestBody { + model: String, + stream: bool, + input: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, +} + +#[derive(Serialize)] +struct ResponsesInputMessage { + role: &'static str, + content: Vec, +} + +#[derive(Serialize)] +struct ResponsesInputContentPart { + #[serde(rename = "type")] + part_type: &'static str, + text: String, +} + +#[derive(Serialize)] +struct ResponsesWebSearchTool { + #[serde(rename = "type")] + tool_type: &'static str, + max_keyword: u8, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LlmRawFailureInputLog<'a> { provider: &'static str, + protocol: &'static str, model: &'a str, stream: bool, attempt: u32, @@ -181,10 +229,48 @@ struct ChatCompletionsContentPart { text: Option, } -#[derive(Default)] +#[derive(Deserialize)] +struct ResponsesResponseEnvelope { + id: Option, + model: Option, + #[serde(default)] + output_text: Option, + #[serde(default)] + output: Vec, + #[serde(default)] + status: Option, + usage: Option, +} + +#[derive(Deserialize)] +struct ResponsesOutputItem { + #[serde(default)] + content: Vec, +} + +#[derive(Deserialize)] +struct ResponsesOutputContentPart { + #[serde(rename = "type")] + #[allow(dead_code)] + part_type: Option, + #[serde(default)] + text: Option, +} + +#[derive(Deserialize)] +struct ResponsesUsage { + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + total_tokens: u64, +} + struct OpenAiCompatibleSseParser { buffer: String, raw_text: String, + protocol: LlmTextProtocol, } #[derive(Debug)] @@ -282,6 +368,14 @@ impl LlmConfig { CHAT_COMPLETIONS_PATH.trim_start_matches('/') ) } + + pub fn responses_url(&self) -> String { + format!( + "{}/{}", + self.base_url.trim_end_matches('/'), + RESPONSES_PATH.trim_start_matches('/') + ) + } } impl LlmMessage { @@ -312,6 +406,7 @@ impl LlmTextRequest { messages, max_tokens: None, enable_web_search: false, + protocol: LlmTextProtocol::ChatCompletions, } } @@ -337,6 +432,11 @@ impl LlmTextRequest { self } + pub fn with_responses_api(mut self) -> Self { + self.protocol = LlmTextProtocol::Responses; + self + } + fn validate(&self) -> Result<(), LlmError> { if self.messages.is_empty() { return Err(LlmError::InvalidRequest( @@ -372,6 +472,15 @@ impl LlmTextRequest { } } +impl LlmTextProtocol { + fn as_str(self) -> &'static str { + match self { + Self::ChatCompletions => "chat_completions", + Self::Responses => "responses", + } + } +} + impl fmt::Display for LlmError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -430,18 +539,23 @@ impl LlmClient { llm_error })?; - parse_chat_completions_response(self.config.provider(), &resolved_model, raw_text.as_str()) - .map_err(|error| { - log_llm_raw_failure( - &self.config, - &request, - false, - 1, - "parse_response_failed", - raw_text.as_str(), - ); - error - }) + parse_text_response( + request.protocol, + self.config.provider(), + &resolved_model, + raw_text.as_str(), + ) + .map_err(|error| { + log_llm_raw_failure( + &self.config, + &request, + false, + 1, + "parse_response_failed", + raw_text.as_str(), + ); + error + }) } pub async fn request_single_message_text( @@ -470,7 +584,7 @@ impl LlmClient { .and_then(|value| value.to_str().ok()) .map(str::to_string); - let mut parser = OpenAiCompatibleSseParser::default(); + let mut parser = OpenAiCompatibleSseParser::new(request.protocol); let mut accumulated_text = String::new(); let mut finish_reason = None; let mut undecoded_chunk_bytes = Vec::new(); @@ -658,29 +772,27 @@ impl LlmClient { request: &LlmTextRequest, stream: bool, ) -> Result { - let request_body = ChatCompletionsRequestBody { - model: request.resolved_model(self.config.model()), - messages: request.messages.as_slice(), - stream, - max_tokens: request.max_tokens, - web_search_options: request - .enable_web_search - .then_some(ChatCompletionsWebSearchOptions {}), + let request_body = build_request_body(request, self.config.model(), stream); + let model = request.resolved_model(self.config.model()); + let url = match request.protocol { + LlmTextProtocol::ChatCompletions => self.config.chat_completions_url(), + LlmTextProtocol::Responses => self.config.responses_url(), }; let max_attempts = self.config.max_retries().saturating_add(1); for attempt in 1..=max_attempts { debug!( - "platform-llm request started: provider={}, stream={}, attempt={}, model={}", + "platform-llm request started: provider={}, protocol={}, stream={}, attempt={}, model={}", self.config.provider().as_str(), + request.protocol.as_str(), stream, attempt, - request_body.model + model ); let send_result = self .http_client - .post(self.config.chat_completions_url()) + .post(url.as_str()) .bearer_auth(self.config.api_key()) .json(&request_body) .timeout(Duration::from_millis(self.config.request_timeout_ms())) @@ -690,8 +802,9 @@ impl LlmClient { match send_result { Ok(response) if response.status().is_success() => { debug!( - "platform-llm request succeeded: provider={}, stream={}, attempt={}, status={}", + "platform-llm request succeeded: provider={}, protocol={}, stream={}, attempt={}, status={}", self.config.provider().as_str(), + request.protocol.as_str(), stream, attempt, response.status().as_u16() @@ -705,8 +818,9 @@ impl LlmClient { if should_retry_status(status) && attempt < max_attempts { warn!( - "platform-llm request retrying after upstream status: provider={}, attempt={}, status={}, message={}", + "platform-llm request retrying after upstream status: provider={}, protocol={}, attempt={}, status={}, message={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt, status.as_u16(), message @@ -731,8 +845,9 @@ impl LlmClient { Err(error) if error.is_timeout() => { if attempt < max_attempts { warn!( - "platform-llm request retrying after timeout: provider={}, attempt={}", + "platform-llm request retrying after timeout: provider={}, protocol={}, attempt={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt ); self.sleep_before_retry(attempt).await; @@ -754,8 +869,9 @@ impl LlmClient { let message = error.to_string(); if attempt < max_attempts { warn!( - "platform-llm request retrying after connectivity failure: provider={}, attempt={}, error={}", + "platform-llm request retrying after connectivity failure: provider={}, protocol={}, attempt={}, error={}", self.config.provider().as_str(), + request.protocol.as_str(), attempt, message ); @@ -810,6 +926,14 @@ impl LlmClient { } impl OpenAiCompatibleSseParser { + fn new(protocol: LlmTextProtocol) -> Self { + Self { + buffer: String::new(), + raw_text: String::new(), + protocol, + } + } + fn push_chunk(&mut self, chunk: &str) -> Result, LlmError> { self.raw_text.push_str(chunk); self.buffer.push_str(chunk); @@ -837,7 +961,7 @@ impl OpenAiCompatibleSseParser { let block = self.buffer[..boundary].to_string(); self.buffer = self.buffer[(boundary + 2)..].to_string(); - if let Some(event) = parse_sse_event_block(block.as_str())? { + if let Some(event) = parse_sse_event_block(self.protocol, block.as_str())? { events.push(event); } } @@ -855,6 +979,55 @@ fn normalize_non_empty(value: String, error_message: &str) -> Result LlmRequestBody { + match request.protocol { + LlmTextProtocol::ChatCompletions => { + LlmRequestBody::ChatCompletions(ChatCompletionsRequestBody { + model: request.resolved_model(fallback_model).to_string(), + messages: request.messages.clone(), + stream, + max_tokens: request.max_tokens, + web_search_options: request + .enable_web_search + .then_some(ChatCompletionsWebSearchOptions {}), + }) + } + LlmTextProtocol::Responses => LlmRequestBody::Responses(ResponsesRequestBody { + model: request.resolved_model(fallback_model).to_string(), + stream, + input: map_responses_input_messages(request.messages.as_slice()), + max_output_tokens: request.max_tokens, + tools: request.enable_web_search.then(|| { + vec![ResponsesWebSearchTool { + tool_type: "web_search", + max_keyword: 3, + }] + }), + }), + } +} + +fn map_responses_input_messages(messages: &[LlmMessage]) -> Vec { + messages + .iter() + .map(|message| ResponsesInputMessage { + role: match message.role { + LlmMessageRole::System => "system", + LlmMessageRole::User => "user", + LlmMessageRole::Assistant => "assistant", + }, + content: vec![ResponsesInputContentPart { + part_type: "input_text", + text: message.content.clone(), + }], + }) + .collect() +} + fn log_llm_raw_failure( config: &LlmConfig, request: &LlmTextRequest, @@ -890,6 +1063,7 @@ fn write_llm_raw_failure( let model = request.resolved_model(config.model()); let input_log = LlmRawFailureInputLog { provider: config.provider().as_str(), + protocol: request.protocol.as_str(), model, stream, attempt, @@ -936,6 +1110,20 @@ fn sanitize_log_file_segment(value: &str) -> String { } } +fn parse_text_response( + protocol: LlmTextProtocol, + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + match protocol { + LlmTextProtocol::ChatCompletions => { + parse_chat_completions_response(provider, fallback_model, raw_text) + } + LlmTextProtocol::Responses => parse_responses_response(provider, fallback_model, raw_text), + } +} + fn parse_chat_completions_response( provider: LlmProvider, fallback_model: &str, @@ -967,6 +1155,56 @@ fn parse_chat_completions_response( }) } +fn parse_responses_response( + provider: LlmProvider, + fallback_model: &str, + raw_text: &str, +) -> Result { + let parsed: ResponsesResponseEnvelope = serde_json::from_str(raw_text).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}")) + })?; + let content = extract_responses_text(&parsed) + .ok_or(LlmError::EmptyResponse)? + .trim() + .to_string(); + + if content.is_empty() { + return Err(LlmError::EmptyResponse); + } + + Ok(LlmTextResponse { + provider, + model: parsed.model.unwrap_or_else(|| fallback_model.to_string()), + content, + finish_reason: parsed.status, + response_id: parsed.id, + usage: parsed.usage.map(|usage| LlmTokenUsage { + prompt_tokens: usage.input_tokens, + completion_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + }), + }) +} + +fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option { + parsed + .output_text + .as_deref() + .map(str::to_string) + .filter(|text| !text.is_empty()) + .or_else(|| { + let text = parsed + .output + .iter() + .flat_map(|item| item.content.iter()) + .filter_map(|part| part.text.as_deref()) + .collect::>() + .join(""); + + if text.is_empty() { None } else { Some(text) } + }) +} + fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { choice .message @@ -1016,7 +1254,10 @@ fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec), LlmError> } } -fn parse_sse_event_block(block: &str) -> Result, LlmError> { +fn parse_sse_event_block( + protocol: LlmTextProtocol, + block: &str, +) -> Result, LlmError> { let data_lines = block .lines() .filter_map(|line| line.trim().strip_prefix("data:")) @@ -1032,6 +1273,10 @@ fn parse_sse_event_block(block: &str) -> Result, LlmEr return Ok(None); } + if protocol == LlmTextProtocol::Responses { + return parse_responses_sse_event(data.as_str()); + } + let parsed: ChatCompletionsResponseEnvelope = serde_json::from_str(data.as_str()) .map_err(|error| LlmError::Deserialize(format!("解析 LLM SSE 事件失败:{error}")))?; let first_choice = parsed @@ -1045,6 +1290,44 @@ fn parse_sse_event_block(block: &str) -> Result, LlmEr })) } +fn parse_responses_sse_event(data: &str) -> Result, LlmError> { + let parsed: serde_json::Value = serde_json::from_str(data).map_err(|error| { + LlmError::Deserialize(format!("解析 LLM Responses SSE 事件失败:{error}")) + })?; + let event_type = parsed + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + + match event_type { + "response.output_text.delta" => Ok(Some(ParsedStreamEvent { + delta_text: parsed + .get("delta") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + finish_reason: None, + })), + "response.completed" => Ok(Some(ParsedStreamEvent { + delta_text: None, + finish_reason: Some("completed".to_string()), + })), + "response.failed" | "error" => { + let message = parsed + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .or_else(|| parsed.get("message").and_then(serde_json::Value::as_str)) + .unwrap_or("LLM Responses SSE 返回失败事件") + .to_string(); + Err(LlmError::Upstream { + status_code: 502, + message, + }) + } + _ => Ok(None), + } +} + fn should_retry_status(status: StatusCode) -> bool { status == StatusCode::REQUEST_TIMEOUT || status == StatusCode::TOO_MANY_REQUESTS @@ -1151,11 +1434,12 @@ mod tests { config.chat_completions_url(), "https://example.com/base/chat/completions" ); + assert_eq!(config.responses_url(), "https://example.com/base/responses"); } #[test] fn sse_parser_handles_split_chunks_and_done_marker() { - let mut parser = OpenAiCompatibleSseParser::default(); + let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::ChatCompletions); let events_a = parser .push_chunk("data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\r\n\r\n") .expect("first chunk should parse"); @@ -1170,6 +1454,24 @@ mod tests { assert_eq!(events_b[0].finish_reason.as_deref(), Some("stop")); } + #[test] + fn responses_sse_parser_only_emits_output_text_delta() { + let mut parser = OpenAiCompatibleSseParser::new(LlmTextProtocol::Responses); + let events = parser + .push_chunk(concat!( + "data: {\"type\":\"response.created\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n", + )) + .expect("responses stream should parse"); + + assert_eq!(events.len(), 3); + assert_eq!(events[0].delta_text.as_deref(), Some("你")); + assert_eq!(events[1].delta_text.as_deref(), Some("好")); + assert_eq!(events[2].finish_reason.as_deref(), Some("completed")); + } + #[test] fn decode_utf8_stream_chunk_preserves_incomplete_multibyte_suffix() { let full_bytes = "你好".as_bytes(); @@ -1284,6 +1586,72 @@ mod tests { assert_eq!(request_json["web_search_options"], serde_json::json!({})); } + #[tokio::test] + async fn request_text_sends_responses_body_with_web_search_tool() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_responses","model":"deepseek-v3-2-251201","output_text":"Responses 成功","status":"completed","usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}"#.to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .request_text( + LlmTextRequest::single_turn("系统", "用户") + .with_model("deepseek-v3-2-251201") + .with_responses_api() + .with_web_search(true) + .with_max_tokens(128), + ) + .await + .expect("responses request_text should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_line = request_text.lines().next().unwrap_or_default(); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert!(request_line.contains("POST /responses HTTP/1.1")); + assert_eq!(response.content, "Responses 成功"); + assert_eq!(response.model, "deepseek-v3-2-251201"); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 9, + completion_tokens: 4, + total_tokens: 13, + }) + ); + assert_eq!( + request_json["model"], + serde_json::json!("deepseek-v3-2-251201") + ); + assert_eq!(request_json["stream"], serde_json::json!(false)); + assert_eq!( + request_json["tools"], + serde_json::json!([{ "type": "web_search", "max_keyword": 3 }]) + ); + assert_eq!( + request_json["input"][0]["content"][0], + serde_json::json!({ "type": "input_text", "text": "系统" }) + ); + } + #[tokio::test] async fn stream_text_accumulates_sse_response() { let server_url = spawn_mock_server(vec![MockResponse { @@ -1314,6 +1682,41 @@ mod tests { assert_eq!(response.response_id.as_deref(), Some("req_stream_01")); } + #[tokio::test] + async fn stream_text_accumulates_responses_sse_response() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"好\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n" + ) + .to_string(), + extra_headers: vec![("x-request-id", "req_responses_stream_01")], + }]); + + let client = build_test_client(server_url, 0); + let mut updates = Vec::new(); + let response = client + .stream_text( + LlmTextRequest::single_turn("系统", "用户").with_responses_api(), + |delta| { + updates.push(delta.accumulated_text.clone()); + }, + ) + .await + .expect("responses stream_text should succeed"); + + assert_eq!(updates, vec!["你".to_string(), "你好".to_string()]); + assert_eq!(response.content, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("completed")); + assert_eq!( + response.response_id.as_deref(), + Some("req_responses_stream_01") + ); + } + #[tokio::test] async fn request_text_writes_raw_failure_logs_after_parse_error() { let log_dir = std::env::temp_dir().join(format!( diff --git a/server-rs/crates/shared-contracts/src/big_fish_works.rs b/server-rs/crates/shared-contracts/src/big_fish_works.rs index 58c6a743..255c87ed 100644 --- a/server-rs/crates/shared-contracts/src/big_fish_works.rs +++ b/server-rs/crates/shared-contracts/src/big_fish_works.rs @@ -6,6 +6,7 @@ pub struct BigFishWorkSummaryResponse { pub work_id: String, pub source_session_id: String, pub owner_user_id: String, + pub author_display_name: String, pub title: String, pub subtitle: String, pub summary: String, diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index 6e009956..d46d2e29 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -6,6 +6,10 @@ pub struct CreatePuzzleAgentSessionRequest { #[serde(default)] pub seed_text: Option, #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] pub picture_description: Option, #[serde(default)] pub reference_image_src: Option, @@ -33,11 +37,32 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub candidate_id: Option, #[serde(default)] + pub level_id: Option, + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, + #[serde(default)] pub level_name: Option, #[serde(default)] pub summary: Option, #[serde(default)] pub theme_tags: Option>, + #[serde(default)] + pub levels_json: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleFormDraftResponse { + #[serde(default)] + pub work_title: Option, + #[serde(default)] + pub work_description: Option, + #[serde(default)] + pub picture_description: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -88,6 +113,8 @@ pub struct PuzzleCreatorIntentResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleResultDraftResponse { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -103,6 +130,25 @@ pub struct PuzzleResultDraftResponse { #[serde(default)] pub cover_asset_id: Option, pub generation_status: String, + pub levels: Vec, + #[serde(default)] + pub form_draft: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleDraftLevelResponse { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + #[serde(default)] + pub selected_candidate_id: Option, + #[serde(default)] + pub cover_image_src: Option, + #[serde(default)] + pub cover_asset_id: Option, + pub generation_status: String, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -153,6 +199,7 @@ pub struct PuzzleResultPreviewEnvelopeResponse { #[serde(rename_all = "camelCase")] pub struct PuzzleAgentSessionSnapshotResponse { pub session_id: String, + pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: String, diff --git a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs index 57e73b04..daed2603 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_gallery.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_gallery.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::puzzle_works::PuzzleWorkSummaryResponse; +use crate::puzzle_works::{PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -11,5 +11,5 @@ pub struct PuzzleGalleryResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleGalleryDetailResponse { - pub item: PuzzleWorkSummaryResponse, + pub item: PuzzleWorkProfileResponse, } diff --git a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs index 0b577158..07f6d457 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub struct StartPuzzleRunRequest { pub profile_id: String, + #[serde(default)] + pub level_id: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -104,6 +106,8 @@ pub struct PuzzleBoardSnapshotResponse { pub struct PuzzleRuntimeLevelSnapshotResponse { pub run_id: String, pub level_index: u32, + #[serde(default)] + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -137,6 +141,18 @@ pub struct PuzzleRuntimeLevelSnapshotResponse { pub leaderboard_entries: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PuzzleRecommendedNextWorkResponse { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + #[serde(default)] + pub cover_image_src: Option, + pub similarity_score: f32, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PuzzleRunSnapshotResponse { @@ -152,6 +168,14 @@ pub struct PuzzleRunSnapshotResponse { #[serde(default)] pub recommended_next_profile_id: Option, #[serde(default)] + pub next_level_mode: String, + #[serde(default)] + pub next_level_profile_id: Option, + #[serde(default)] + pub next_level_id: Option, + #[serde(default)] + pub recommended_next_works: Vec, + #[serde(default)] pub leaderboard_entries: Vec, } diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index 8ec7319b..cdc14bb3 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -1,10 +1,12 @@ use serde::{Deserialize, Serialize}; -use crate::puzzle_agent::PuzzleAnchorPackResponse; +use crate::puzzle_agent::{PuzzleAnchorPackResponse, PuzzleDraftLevelResponse}; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PutPuzzleWorkRequest { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -12,6 +14,8 @@ pub struct PutPuzzleWorkRequest { pub cover_image_src: Option, #[serde(default)] pub cover_asset_id: Option, + #[serde(default)] + pub levels: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -23,6 +27,8 @@ pub struct PuzzleWorkSummaryResponse { #[serde(default)] pub source_session_id: Option, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -41,7 +47,59 @@ pub struct PuzzleWorkSummaryResponse { pub like_count: u32, #[serde(default)] pub recent_play_count_7d: u32, + #[serde(default)] + pub point_incentive_total_half_points: u64, + #[serde(default)] + pub point_incentive_claimed_points: u64, + #[serde(default)] + pub point_incentive_total_points: f64, + #[serde(default)] + pub point_incentive_claimable_points: u64, pub publish_ready: bool, + #[serde(default)] + pub levels: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn puzzle_work_summary_response_uses_point_incentive_fields() { + let payload = serde_json::to_value(PuzzleWorkSummaryResponse { + work_id: "work-1".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: None, + author_display_name: "作者".to_string(), + work_title: "作品".to_string(), + work_description: "描述".to_string(), + level_name: "第一关".to_string(), + summary: "画面".to_string(), + theme_tags: vec!["拼图".to_string(), "夜色".to_string(), "灯光".to_string()], + cover_image_src: None, + cover_asset_id: None, + publication_status: "published".to_string(), + updated_at: "2026-05-01T00:00:00Z".to_string(), + published_at: Some("2026-05-01T00:00:00Z".to_string()), + play_count: 1, + remix_count: 0, + like_count: 0, + recent_play_count_7d: 1, + point_incentive_total_half_points: 3, + point_incentive_claimed_points: 1, + point_incentive_total_points: 1.5, + point_incentive_claimable_points: 0, + publish_ready: true, + levels: Vec::new(), + }) + .expect("payload should serialize"); + + assert_eq!(payload["pointIncentiveTotalHalfPoints"], 3); + assert_eq!(payload["pointIncentiveClaimedPoints"], 1); + assert_eq!(payload["pointIncentiveTotalPoints"], 1.5); + assert_eq!(payload["pointIncentiveClaimablePoints"], 0); + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index f5f343ab..087142ce 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -11,6 +11,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = "asset_operation_consume"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asset_operation_refund"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str = + "puzzle_author_incentive_claim"; pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial"; pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane"; pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina"; @@ -928,6 +930,14 @@ mod tests { .to_string(), created_at: "2026-04-22T10:05:00Z".to_string(), }, + ProfileWalletLedgerEntryResponse { + id: "ledger-7".to_string(), + amount_delta: 2, + balance_after: 202, + source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM + .to_string(), + created_at: "2026-04-22T10:06:00Z".to_string(), + }, ], }) .expect("payload should serialize"); @@ -958,6 +968,10 @@ mod tests { payload["entries"][5]["sourceType"], json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND) ); + assert_eq!( + payload["entries"][6]["sourceType"], + json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM) + ); assert_eq!( payload["entries"][0]["createdAt"], json!("2026-04-22T10:00:00Z") diff --git a/server-rs/crates/spacetime-client/src/big_fish.rs b/server-rs/crates/spacetime-client/src/big_fish.rs index 29e5e6ea..353d21c3 100644 --- a/server-rs/crates/spacetime-client/src/big_fish.rs +++ b/server-rs/crates/spacetime-client/src/big_fish.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work; +use crate::module_bindings::record_big_fish_like_procedure::record_big_fish_like; use crate::module_bindings::record_big_fish_play_procedure::record_big_fish_play; use crate::module_bindings::remix_big_fish_work_procedure::remix_big_fish_work; use module_big_fish::PUBLIC_BIG_FISH_GALLERY_OWNER_USER_ID; @@ -294,6 +295,29 @@ impl SpacetimeClient { .await } + pub async fn record_big_fish_like( + &self, + input: BigFishLikeReportRecordInput, + ) -> Result, SpacetimeClientError> { + let procedure_input = BigFishWorkLikeRecordInput { + session_id: input.session_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_big_fish_like_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(|result| map_big_fish_works_procedure_result(result, None)); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn remix_big_fish_work( &self, input: BigFishWorkRemixRecordInput, diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index d1c16ff5..bad91024 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -1,6 +1,7 @@ use super::*; use crate::mapper::*; use crate::module_bindings::delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; +use crate::module_bindings::record_custom_world_profile_like_procedure::record_custom_world_profile_like; use crate::module_bindings::record_custom_world_profile_play_procedure::record_custom_world_profile_play; use crate::module_bindings::remix_custom_world_profile_procedure::remix_custom_world_profile; @@ -261,6 +262,30 @@ impl SpacetimeClient { .await } + pub async fn record_custom_world_profile_like( + &self, + input: CustomWorldProfileLikeReportRecordInput, + ) -> Result { + let procedure_input = CustomWorldProfileLikeRecordInput { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .record_custom_world_profile_like_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_library_mutation_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn publish_custom_world_world( &self, input: CustomWorldPublishWorldRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 3ade7c47..2b65e760 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -3,50 +3,53 @@ pub mod module_bindings; mod mapper; -pub(crate) use mapper::*; +use mapper::*; pub use mapper::{ AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, - BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput, - BigFishMessageSubmitRecordInput, BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, - BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishWorkRemixRecordInput, - BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord, - CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, - CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord, - CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationProgressRecordInput, - CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, - CustomWorldAgentSessionRecord, CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, + BigFishGameDraftRecord, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, + BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, + BigFishPlayReportRecordInput, BigFishRuntimeParamsRecord, BigFishSessionCreateRecordInput, + BigFishSessionRecord, BigFishWorkRemixRecordInput, BigFishWorkSummaryRecord, + CustomWorldAgentActionExecuteRecord, CustomWorldAgentActionExecuteRecordInput, + CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageFinalizeRecordInput, + CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, + CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord, - CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, - CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, - CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, - CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, - CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, - Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, - Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, - Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, - Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord, - Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput, - Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, - Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, - Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, NpcBattleInteractionRecord, - NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput, - PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, - PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, - PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, - PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, + CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, + CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, + CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, + CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, + CustomWorldWorkSummaryRecord, Match3DAgentMessageFinalizeRecordInput, + Match3DAgentMessageRecord, Match3DAgentMessageSubmitRecordInput, + Match3DAgentSessionCreateRecordInput, Match3DAgentSessionRecord, Match3DAnchorItemRecord, + Match3DAnchorPackRecord, Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, + Match3DCreatorConfigRecord, Match3DItemSnapshotRecord, Match3DResultDraftRecord, + Match3DRunClickRecordInput, Match3DRunRecord, Match3DRunRestartRecordInput, + Match3DRunStartRecordInput, Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, + Match3DTraySlotRecord, Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, + NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, + PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, + PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, + PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, + PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, + PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, - PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, - ResolveNpcBattleInteractionInput, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, + PuzzleWorkPointIncentiveClaimRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, + PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, }; pub mod ai; @@ -117,7 +120,7 @@ use module_puzzle::{ PuzzleAnchorItem as DomainPuzzleAnchorItem, PuzzleAnchorPack as DomainPuzzleAnchorPack, PuzzleBoardSnapshot as DomainPuzzleBoardSnapshot, PuzzleCellPosition as DomainPuzzleCellPosition, - PuzzleCreatorIntent as DomainPuzzleCreatorIntent, + PuzzleCreatorIntent as DomainPuzzleCreatorIntent, PuzzleDraftLevel as DomainPuzzleDraftLevel, PuzzleGeneratedImageCandidate as DomainPuzzleGeneratedImageCandidate, PuzzleMergedGroupState as DomainPuzzleMergedGroupState, PuzzlePieceState as DomainPuzzlePieceState, PuzzleResultDraft as DomainPuzzleResultDraft, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index a374694a..7b111d4a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2394,6 +2394,7 @@ pub(crate) fn map_puzzle_agent_session_snapshot( ) -> PuzzleAgentSessionRecord { PuzzleAgentSessionRecord { session_id: snapshot.session_id, + seed_text: snapshot.seed_text, current_turn: snapshot.current_turn, progress_percent: snapshot.progress_percent, stage: snapshot.stage.as_str().to_string(), @@ -2439,6 +2440,8 @@ pub(crate) fn map_puzzle_result_draft( snapshot: DomainPuzzleResultDraft, ) -> PuzzleResultDraftRecord { PuzzleResultDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, level_name: snapshot.level_name, summary: snapshot.summary, theme_tags: snapshot.theme_tags, @@ -2454,6 +2457,39 @@ pub(crate) fn map_puzzle_result_draft( cover_image_src: snapshot.cover_image_src, cover_asset_id: snapshot.cover_asset_id, generation_status: snapshot.generation_status, + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), + form_draft: snapshot.form_draft.map(map_puzzle_form_draft), + } +} + +pub(crate) fn map_puzzle_form_draft( + snapshot: module_puzzle::PuzzleFormDraft, +) -> PuzzleFormDraftRecord { + PuzzleFormDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, + picture_description: snapshot.picture_description, + } +} + +pub(crate) fn map_puzzle_draft_level(snapshot: DomainPuzzleDraftLevel) -> PuzzleDraftLevelRecord { + PuzzleDraftLevelRecord { + level_id: snapshot.level_id, + level_name: snapshot.level_name, + picture_description: snapshot.picture_description, + candidates: snapshot + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate) + .collect(), + selected_candidate_id: snapshot.selected_candidate_id, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + generation_status: snapshot.generation_status, } } @@ -2787,6 +2823,8 @@ pub(crate) fn map_puzzle_work_profile( owner_user_id: snapshot.owner_user_id, source_session_id: snapshot.source_session_id, author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, level_name: snapshot.level_name, summary: snapshot.summary, theme_tags: snapshot.theme_tags, @@ -2799,8 +2837,15 @@ pub(crate) fn map_puzzle_work_profile( remix_count: snapshot.remix_count, like_count: snapshot.like_count, recent_play_count_7d: snapshot.recent_play_count_7d, + point_incentive_total_half_points: snapshot.point_incentive_total_half_points, + point_incentive_claimed_points: snapshot.point_incentive_claimed_points, publish_ready: snapshot.publish_ready, anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), } } @@ -2817,6 +2862,14 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz .current_level .map(map_puzzle_runtime_level_snapshot), recommended_next_profile_id: snapshot.recommended_next_profile_id, + next_level_mode: snapshot.next_level_mode, + next_level_profile_id: snapshot.next_level_profile_id, + next_level_id: snapshot.next_level_id, + recommended_next_works: snapshot + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work) + .collect(), leaderboard_entries: snapshot .leaderboard_entries .into_iter() @@ -2825,6 +2878,19 @@ pub(crate) fn map_puzzle_run_snapshot(snapshot: DomainPuzzleRunSnapshot) -> Puzz } } +fn map_puzzle_recommended_next_work( + snapshot: module_puzzle::PuzzleRecommendedNextWork, +) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: snapshot.profile_id, + level_name: snapshot.level_name, + author_display_name: snapshot.author_display_name, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + similarity_score: snapshot.similarity_score, + } +} + pub(crate) fn map_puzzle_runtime_level_snapshot( snapshot: DomainPuzzleRuntimeLevelSnapshot, ) -> PuzzleRuntimeLevelRecord { @@ -2838,6 +2904,7 @@ pub(crate) fn map_puzzle_runtime_level_snapshot( PuzzleRuntimeLevelRecord { run_id: snapshot.run_id, level_index: snapshot.level_index, + level_id: snapshot.level_id, grid_size: snapshot.grid_size, profile_id: snapshot.profile_id, level_name: snapshot.level_name, @@ -3835,6 +3902,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { + module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim + } } } @@ -4630,6 +4700,14 @@ pub struct CustomWorldProfilePlayReportRecordInput { pub played_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileLikeReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldPublishWorldRecordInput { pub session_id: String, @@ -4729,6 +4807,14 @@ pub struct PuzzleAgentSessionCreateRecordInput { pub created_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentMessageSubmitRecordInput { pub session_id: String, @@ -4755,6 +4841,8 @@ pub struct PuzzleAgentMessageFinalizeRecordInput { pub struct PuzzleGeneratedImagesSaveRecordInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, pub candidates_json: String, pub saved_at_micros: i64, } @@ -4763,6 +4851,7 @@ pub struct PuzzleGeneratedImagesSaveRecordInput { pub struct PuzzleSelectCoverImageRecordInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option, pub candidate_id: String, pub selected_at_micros: i64, } @@ -4774,9 +4863,12 @@ pub struct PuzzlePublishRecordInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, pub level_name: Option, pub summary: Option, pub theme_tags: Option>, + pub levels_json: Option, pub published_at_micros: i64, } @@ -4784,11 +4876,14 @@ pub struct PuzzlePublishRecordInput { pub struct PuzzleWorkUpsertRecordInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, pub cover_image_src: Option, pub cover_asset_id: Option, + pub levels_json: Option, pub updated_at_micros: i64, } @@ -4804,11 +4899,19 @@ pub struct PuzzleWorkRemixRecordInput { pub remixed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkLikeReportRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleRunStartRecordInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option, pub started_at_micros: i64, } @@ -4852,6 +4955,7 @@ pub struct PuzzleRunPropRecordInput { pub owner_user_id: String, pub prop_kind: String, pub used_at_micros: i64, + pub spent_points: u64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -4862,6 +4966,13 @@ pub struct BigFishPlayReportRecordInput { pub reported_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishLikeReportRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct BigFishWorkRemixRecordInput { pub source_session_id: String, @@ -5274,6 +5385,8 @@ pub struct PuzzleGeneratedImageCandidateRecord { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleResultDraftRecord { + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -5285,6 +5398,27 @@ pub struct PuzzleResultDraftRecord { pub cover_image_src: Option, pub cover_asset_id: Option, pub generation_status: String, + pub levels: Vec, + pub form_draft: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftRecord { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleDraftLevelRecord { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -5329,6 +5463,7 @@ pub struct PuzzleResultPreviewRecord { #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAgentSessionRecord { pub session_id: String, + pub seed_text: String, pub current_turn: u32, pub progress_percent: u32, pub stage: String, @@ -5349,6 +5484,8 @@ pub struct PuzzleWorkProfileRecord { pub owner_user_id: String, pub source_session_id: Option, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec, @@ -5361,8 +5498,18 @@ pub struct PuzzleWorkProfileRecord { pub remix_count: u32, pub like_count: u32, pub recent_play_count_7d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, pub publish_ready: bool, pub anchor_pack: PuzzleAnchorPackRecord, + pub levels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkPointIncentiveClaimRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -5406,10 +5553,21 @@ pub struct PuzzleBoardRecord { pub all_tiles_resolved: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct PuzzleRecommendedNextWorkRecord { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub similarity_score: f32, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleRuntimeLevelRecord { pub run_id: String, pub level_index: u32, + pub level_id: Option, pub grid_size: u32, pub profile_id: String, pub level_name: String, @@ -5431,7 +5589,7 @@ pub struct PuzzleRuntimeLevelRecord { pub leaderboard_entries: Vec, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub struct PuzzleRunRecord { pub run_id: String, pub entry_profile_id: String, @@ -5442,6 +5600,10 @@ pub struct PuzzleRunRecord { pub previous_level_tags: Vec, pub current_level: Option, pub recommended_next_profile_id: Option, + pub next_level_mode: String, + pub next_level_profile_id: Option, + pub next_level_id: Option, + pub recommended_next_works: Vec, pub leaderboard_entries: Vec, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs index 3601be97..64105c40 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs @@ -2,17 +2,23 @@ // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. #![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; -use super::runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; use super::runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput; +use super::runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -struct AdminUpsertProfileInviteCodeArgs { + struct AdminUpsertProfileInviteCodeArgs { pub input: RuntimeProfileInviteCodeAdminUpsertInput, } + impl __sdk::InModule for AdminUpsertProfileInviteCodeArgs { type Module = super::RemoteModule; } @@ -22,19 +28,16 @@ impl __sdk::InModule for AdminUpsertProfileInviteCodeArgs { /// /// Implemented for [`super::RemoteProcedures`]. pub trait admin_upsert_profile_invite_code { - fn admin_upsert_profile_invite_code(&self, input: RuntimeProfileInviteCodeAdminUpsertInput) { - self.admin_upsert_profile_invite_code_then(input, |_, _| {}); + fn admin_upsert_profile_invite_code(&self, input: RuntimeProfileInviteCodeAdminUpsertInput, +) { + self.admin_upsert_profile_invite_code_then(input, |_, _| {}); } fn admin_upsert_profile_invite_code_then( &self, input: RuntimeProfileInviteCodeAdminUpsertInput, - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, ); } @@ -43,17 +46,12 @@ impl admin_upsert_profile_invite_code for super::RemoteProcedures { &self, input: RuntimeProfileInviteCodeAdminUpsertInput, - __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, ) { - self.imp - .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( - "admin_upsert_profile_invite_code", - AdminUpsertProfileInviteCodeArgs { input }, - __callback, - ); + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( + "admin_upsert_profile_invite_code", + AdminUpsertProfileInviteCodeArgs { input, }, + __callback, + ); } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs new file mode 100644 index 00000000..61ec041e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/big_fish_work_like_record_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BigFishWorkLikeRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + + +impl __sdk::InModule for BigFishWorkLikeRecordInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs new file mode 100644 index 00000000..e045794c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/claim_puzzle_work_point_incentive_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveClaimInput; +use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ClaimPuzzleWorkPointIncentiveArgs { + pub input: PuzzleWorkPointIncentiveClaimInput, +} + + +impl __sdk::InModule for ClaimPuzzleWorkPointIncentiveArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `claim_puzzle_work_point_incentive`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait claim_puzzle_work_point_incentive { + fn claim_puzzle_work_point_incentive(&self, input: PuzzleWorkPointIncentiveClaimInput, +) { + self.claim_puzzle_work_point_incentive_then(input, |_, _| {}); + } + + fn claim_puzzle_work_point_incentive_then( + &self, + input: PuzzleWorkPointIncentiveClaimInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl claim_puzzle_work_point_incentive for super::RemoteProcedures { + fn claim_puzzle_work_point_incentive_then( + &self, + input: PuzzleWorkPointIncentiveClaimInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>( + "claim_puzzle_work_point_incentive", + ClaimPuzzleWorkPointIncentiveArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs new file mode 100644 index 00000000..ce1b13cc --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/custom_world_profile_like_record_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct CustomWorldProfileLikeRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + + +impl __sdk::InModule for CustomWorldProfileLikeRecordInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs index 0c78ac7c..db131f8d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_gallery_detail_procedure.rs @@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{ __ws, }; -use super::puzzle_work_get_input_type::PuzzleWorkGetInput; use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; +use super::puzzle_work_get_input_type::PuzzleWorkGetInput; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs index c21d1048..107d90cb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_puzzle_work_detail_procedure.rs @@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{ __ws, }; -use super::puzzle_work_get_input_type::PuzzleWorkGetInput; use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; +use super::puzzle_work_get_input_type::PuzzleWorkGetInput; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index dff7022f..67d0bf21 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -11,7 +11,6 @@ use spacetimedb_sdk::__codegen::{ __ws, }; -pub mod admin_upsert_profile_invite_code_procedure; pub mod ai_result_reference_type; pub mod ai_result_reference_input_type; pub mod ai_result_reference_kind_type; @@ -93,6 +92,7 @@ pub mod big_fish_session_get_input_type; pub mod big_fish_session_procedure_result_type; pub mod big_fish_session_snapshot_type; pub mod big_fish_work_delete_input_type; +pub mod big_fish_work_like_record_input_type; pub mod big_fish_work_remix_input_type; pub mod big_fish_works_list_input_type; pub mod big_fish_works_procedure_result_type; @@ -137,6 +137,7 @@ pub mod custom_world_library_detail_input_type; pub mod custom_world_library_mutation_result_type; pub mod custom_world_profile_type; pub mod custom_world_profile_delete_input_type; +pub mod custom_world_profile_like_record_input_type; pub mod custom_world_profile_list_input_type; pub mod custom_world_profile_list_result_type; pub mod custom_world_profile_play_record_input_type; @@ -237,6 +238,7 @@ pub mod profile_redeem_code_usage_type; pub mod profile_referral_relation_type; pub mod profile_save_archive_type; pub mod profile_wallet_ledger_type; +pub mod public_work_like_type; pub mod public_work_play_daily_stat_type; pub mod puzzle_agent_message_finalize_input_type; pub mod puzzle_agent_message_kind_type; @@ -249,6 +251,7 @@ pub mod puzzle_agent_session_procedure_result_type; pub mod puzzle_agent_session_row_type; pub mod puzzle_agent_stage_type; pub mod puzzle_draft_compile_input_type; +pub mod puzzle_form_draft_save_input_type; pub mod puzzle_generated_images_save_input_type; pub mod puzzle_leaderboard_entry_row_type; pub mod puzzle_leaderboard_submit_input_type; @@ -266,6 +269,8 @@ pub mod puzzle_runtime_run_row_type; pub mod puzzle_select_cover_image_input_type; pub mod puzzle_work_delete_input_type; pub mod puzzle_work_get_input_type; +pub mod puzzle_work_like_record_input_type; +pub mod puzzle_work_point_incentive_claim_input_type; pub mod puzzle_work_procedure_result_type; pub mod puzzle_work_profile_row_type; pub mod puzzle_work_remix_input_type; @@ -429,6 +434,7 @@ pub mod upsert_custom_world_profile_reducer; pub mod upsert_npc_state_reducer; pub mod custom_world_gallery_entry_table; pub mod admin_disable_profile_redeem_code_procedure; +pub mod admin_upsert_profile_invite_code_procedure; pub mod admin_upsert_profile_redeem_code_procedure; pub mod advance_puzzle_next_level_procedure; pub mod append_ai_text_chunk_and_return_procedure; @@ -438,6 +444,7 @@ pub mod authorize_database_migration_operator_procedure; pub mod begin_story_session_and_return_procedure; pub mod bind_asset_object_to_entity_and_return_procedure; pub mod cancel_ai_task_and_return_procedure; +pub mod claim_puzzle_work_point_incentive_procedure; pub mod clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; pub mod click_match_3_d_item_procedure; @@ -523,8 +530,11 @@ pub mod publish_custom_world_world_procedure; pub mod publish_match_3_d_work_procedure; pub mod publish_puzzle_work_procedure; pub mod put_database_migration_import_chunk_procedure; +pub mod record_big_fish_like_procedure; pub mod record_big_fish_play_procedure; +pub mod record_custom_world_profile_like_procedure; pub mod record_custom_world_profile_play_procedure; +pub mod record_puzzle_work_like_procedure; pub mod redeem_profile_referral_invite_code_procedure; pub mod redeem_profile_reward_code_procedure; pub mod refund_profile_wallet_points_and_return_procedure; @@ -539,6 +549,7 @@ pub mod resolve_treasure_interaction_and_return_procedure; pub mod restart_match_3_d_run_procedure; pub mod resume_profile_save_archive_and_return_procedure; pub mod revoke_database_migration_operator_procedure; +pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_generated_images_procedure; pub mod select_puzzle_cover_image_procedure; pub mod start_match_3_d_run_procedure; @@ -564,7 +575,6 @@ pub mod upsert_runtime_setting_and_return_procedure; pub mod upsert_runtime_snapshot_and_return_procedure; pub mod use_puzzle_runtime_prop_procedure; -pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; pub use ai_result_reference_type::AiResultReference; pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_kind_type::AiResultReferenceKind; @@ -646,6 +656,7 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput; pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult; pub use big_fish_session_snapshot_type::BigFishSessionSnapshot; pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput; +pub use big_fish_work_like_record_input_type::BigFishWorkLikeRecordInput; pub use big_fish_work_remix_input_type::BigFishWorkRemixInput; pub use big_fish_works_list_input_type::BigFishWorksListInput; pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult; @@ -690,6 +701,7 @@ pub use custom_world_library_detail_input_type::CustomWorldLibraryDetailInput; pub use custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; pub use custom_world_profile_type::CustomWorldProfile; pub use custom_world_profile_delete_input_type::CustomWorldProfileDeleteInput; +pub use custom_world_profile_like_record_input_type::CustomWorldProfileLikeRecordInput; pub use custom_world_profile_list_input_type::CustomWorldProfileListInput; pub use custom_world_profile_list_result_type::CustomWorldProfileListResult; pub use custom_world_profile_play_record_input_type::CustomWorldProfilePlayRecordInput; @@ -790,6 +802,7 @@ pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage; pub use profile_referral_relation_type::ProfileReferralRelation; pub use profile_save_archive_type::ProfileSaveArchive; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use public_work_like_type::PublicWorkLike; pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat; pub use puzzle_agent_message_finalize_input_type::PuzzleAgentMessageFinalizeInput; pub use puzzle_agent_message_kind_type::PuzzleAgentMessageKind; @@ -802,6 +815,7 @@ pub use puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureR pub use puzzle_agent_session_row_type::PuzzleAgentSessionRow; pub use puzzle_agent_stage_type::PuzzleAgentStage; pub use puzzle_draft_compile_input_type::PuzzleDraftCompileInput; +pub use puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; pub use puzzle_generated_images_save_input_type::PuzzleGeneratedImagesSaveInput; pub use puzzle_leaderboard_entry_row_type::PuzzleLeaderboardEntryRow; pub use puzzle_leaderboard_submit_input_type::PuzzleLeaderboardSubmitInput; @@ -819,6 +833,8 @@ pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow; pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput; pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput; pub use puzzle_work_get_input_type::PuzzleWorkGetInput; +pub use puzzle_work_like_record_input_type::PuzzleWorkLikeRecordInput; +pub use puzzle_work_point_incentive_claim_input_type::PuzzleWorkPointIncentiveClaimInput; pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow; pub use puzzle_work_remix_input_type::PuzzleWorkRemixInput; @@ -982,6 +998,7 @@ pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; pub use upsert_npc_state_reducer::upsert_npc_state; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; +pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code; pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; @@ -991,6 +1008,7 @@ pub use authorize_database_migration_operator_procedure::authorize_database_migr pub use begin_story_session_and_return_procedure::begin_story_session_and_return; pub use bind_asset_object_to_entity_and_return_procedure::bind_asset_object_to_entity_and_return; pub use cancel_ai_task_and_return_procedure::cancel_ai_task_and_return; +pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks; pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return; pub use click_match_3_d_item_procedure::click_match_3_d_item; @@ -1076,8 +1094,11 @@ pub use publish_custom_world_world_procedure::publish_custom_world_world; pub use publish_match_3_d_work_procedure::publish_match_3_d_work; pub use publish_puzzle_work_procedure::publish_puzzle_work; pub use put_database_migration_import_chunk_procedure::put_database_migration_import_chunk; +pub use record_big_fish_like_procedure::record_big_fish_like; pub use record_big_fish_play_procedure::record_big_fish_play; +pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like; pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; +pub use record_puzzle_work_like_procedure::record_puzzle_work_like; pub use redeem_profile_referral_invite_code_procedure::redeem_profile_referral_invite_code; pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; @@ -1092,6 +1113,7 @@ pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_inte pub use restart_match_3_d_run_procedure::restart_match_3_d_run; pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_archive_and_return; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; +pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image; pub use start_match_3_d_run_procedure::start_match_3_d_run; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs new file mode 100644 index 00000000..9fd67b49 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_like_type.rs @@ -0,0 +1,77 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkLike { + pub like_id: String, + pub source_type: String, + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at: __sdk::Timestamp, +} + + +impl __sdk::InModule for PublicWorkLike { + type Module = super::RemoteModule; +} + + +/// Column accessor struct for the table `PublicWorkLike`. +/// +/// Provides typed access to columns for query building. +pub struct PublicWorkLikeCols { + pub like_id: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub liked_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for PublicWorkLike { + type Cols = PublicWorkLikeCols; + fn cols(table_name: &'static str) -> Self::Cols { + PublicWorkLikeCols { + like_id: __sdk::__query_builder::Col::new(table_name, "like_id"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + liked_at: __sdk::__query_builder::Col::new(table_name, "liked_at"), + + } + } +} + +/// Indexed column accessor struct for the table `PublicWorkLike`. +/// +/// Provides typed access to indexed columns for query building. +pub struct PublicWorkLikeIxCols { + pub like_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for PublicWorkLike { + type IxCols = PublicWorkLikeIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + PublicWorkLikeIxCols { + like_id: __sdk::__query_builder::IxCol::new(table_name, "like_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for PublicWorkLike {} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs new file mode 100644 index 00000000..e51852a9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_form_draft_save_input_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleFormDraftSaveInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + + +impl __sdk::InModule for PuzzleFormDraftSaveInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs index 0c2196b4..e88d3c91 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_generated_images_save_input_type.rs @@ -15,6 +15,8 @@ use spacetimedb_sdk::__codegen::{ pub struct PuzzleGeneratedImagesSaveInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option::, + pub levels_json: Option::, pub candidates_json: String, pub saved_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs index d3a5d26d..bdfb5538 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_publish_input_type.rs @@ -18,9 +18,12 @@ pub struct PuzzlePublishInput { pub work_id: String, pub profile_id: String, pub author_display_name: String, + pub work_title: Option::, + pub work_description: Option::, pub level_name: Option::, pub summary: Option::, pub theme_tags: Option::>, + pub levels_json: Option::, pub published_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs index c1d70bb5..c910145d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_prop_input_type.rs @@ -17,6 +17,7 @@ pub struct PuzzleRunPropInput { pub owner_user_id: String, pub prop_kind: String, pub used_at_micros: i64, + pub spent_points: u64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs index 55e0a93e..c178a7d5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_run_start_input_type.rs @@ -16,6 +16,7 @@ pub struct PuzzleRunStartInput { pub run_id: String, pub owner_user_id: String, pub profile_id: String, + pub level_id: Option::, pub started_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs index 847ac3a4..7789636b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_select_cover_image_input_type.rs @@ -15,6 +15,7 @@ use spacetimedb_sdk::__codegen::{ pub struct PuzzleSelectCoverImageInput { pub session_id: String, pub owner_user_id: String, + pub level_id: Option::, pub candidate_id: String, pub selected_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs new file mode 100644 index 00000000..19bcab7e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_like_record_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkLikeRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + + +impl __sdk::InModule for PuzzleWorkLikeRecordInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs new file mode 100644 index 00000000..fd375cad --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_point_incentive_claim_input_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PuzzleWorkPointIncentiveClaimInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + + +impl __sdk::InModule for PuzzleWorkPointIncentiveClaimInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs index c69ef38d..750603e3 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_profile_row_type.rs @@ -19,11 +19,14 @@ pub struct PuzzleWorkProfileRow { pub owner_user_id: String, pub source_session_id: Option::, pub author_display_name: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags_json: String, pub cover_image_src: Option::, pub cover_asset_id: Option::, + pub levels_json: String, pub publication_status: PuzzlePublicationStatus, pub play_count: u32, pub anchor_pack_json: String, @@ -33,6 +36,8 @@ pub struct PuzzleWorkProfileRow { pub published_at: Option::<__sdk::Timestamp>, pub remix_count: u32, pub like_count: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, } @@ -50,11 +55,14 @@ pub struct PuzzleWorkProfileRowCols { pub owner_user_id: __sdk::__query_builder::Col, pub source_session_id: __sdk::__query_builder::Col>, pub author_display_name: __sdk::__query_builder::Col, + pub work_title: __sdk::__query_builder::Col, + pub work_description: __sdk::__query_builder::Col, pub level_name: __sdk::__query_builder::Col, pub summary: __sdk::__query_builder::Col, pub theme_tags_json: __sdk::__query_builder::Col, pub cover_image_src: __sdk::__query_builder::Col>, pub cover_asset_id: __sdk::__query_builder::Col>, + pub levels_json: __sdk::__query_builder::Col, pub publication_status: __sdk::__query_builder::Col, pub play_count: __sdk::__query_builder::Col, pub anchor_pack_json: __sdk::__query_builder::Col, @@ -64,6 +72,8 @@ pub struct PuzzleWorkProfileRowCols { pub published_at: __sdk::__query_builder::Col>, pub remix_count: __sdk::__query_builder::Col, pub like_count: __sdk::__query_builder::Col, + pub point_incentive_total_half_points: __sdk::__query_builder::Col, + pub point_incentive_claimed_points: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { @@ -75,11 +85,14 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), source_session_id: __sdk::__query_builder::Col::new(table_name, "source_session_id"), author_display_name: __sdk::__query_builder::Col::new(table_name, "author_display_name"), + work_title: __sdk::__query_builder::Col::new(table_name, "work_title"), + work_description: __sdk::__query_builder::Col::new(table_name, "work_description"), level_name: __sdk::__query_builder::Col::new(table_name, "level_name"), summary: __sdk::__query_builder::Col::new(table_name, "summary"), theme_tags_json: __sdk::__query_builder::Col::new(table_name, "theme_tags_json"), cover_image_src: __sdk::__query_builder::Col::new(table_name, "cover_image_src"), cover_asset_id: __sdk::__query_builder::Col::new(table_name, "cover_asset_id"), + levels_json: __sdk::__query_builder::Col::new(table_name, "levels_json"), publication_status: __sdk::__query_builder::Col::new(table_name, "publication_status"), play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), anchor_pack_json: __sdk::__query_builder::Col::new(table_name, "anchor_pack_json"), @@ -89,6 +102,8 @@ impl __sdk::__query_builder::HasCols for PuzzleWorkProfileRow { published_at: __sdk::__query_builder::Col::new(table_name, "published_at"), remix_count: __sdk::__query_builder::Col::new(table_name, "remix_count"), like_count: __sdk::__query_builder::Col::new(table_name, "like_count"), + point_incentive_total_half_points: __sdk::__query_builder::Col::new(table_name, "point_incentive_total_half_points"), + point_incentive_claimed_points: __sdk::__query_builder::Col::new(table_name, "point_incentive_claimed_points"), } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs index ae412d68..1f87db51 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/puzzle_work_upsert_input_type.rs @@ -15,11 +15,14 @@ use spacetimedb_sdk::__codegen::{ pub struct PuzzleWorkUpsertInput { pub profile_id: String, pub owner_user_id: String, + pub work_title: String, + pub work_description: String, pub level_name: String, pub summary: String, pub theme_tags: Vec::, pub cover_image_src: Option::, pub cover_asset_id: Option::, + pub levels_json: Option::, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs new file mode 100644 index 00000000..6df452b0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_big_fish_like_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult; +use super::big_fish_work_like_record_input_type::BigFishWorkLikeRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct RecordBigFishLikeArgs { + pub input: BigFishWorkLikeRecordInput, +} + + +impl __sdk::InModule for RecordBigFishLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_big_fish_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_big_fish_like { + fn record_big_fish_like(&self, input: BigFishWorkLikeRecordInput, +) { + self.record_big_fish_like_then(input, |_, _| {}); + } + + fn record_big_fish_like_then( + &self, + input: BigFishWorkLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl record_big_fish_like for super::RemoteProcedures { + fn record_big_fish_like_then( + &self, + input: BigFishWorkLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>( + "record_big_fish_like", + RecordBigFishLikeArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs new file mode 100644 index 00000000..f9d69dfe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_custom_world_profile_like_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::custom_world_library_mutation_result_type::CustomWorldLibraryMutationResult; +use super::custom_world_profile_like_record_input_type::CustomWorldProfileLikeRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct RecordCustomWorldProfileLikeArgs { + pub input: CustomWorldProfileLikeRecordInput, +} + + +impl __sdk::InModule for RecordCustomWorldProfileLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_custom_world_profile_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_custom_world_profile_like { + fn record_custom_world_profile_like(&self, input: CustomWorldProfileLikeRecordInput, +) { + self.record_custom_world_profile_like_then(input, |_, _| {}); + } + + fn record_custom_world_profile_like_then( + &self, + input: CustomWorldProfileLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl record_custom_world_profile_like for super::RemoteProcedures { + fn record_custom_world_profile_like_then( + &self, + input: CustomWorldProfileLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, CustomWorldLibraryMutationResult>( + "record_custom_world_profile_like", + RecordCustomWorldProfileLikeArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs new file mode 100644 index 00000000..282fbe68 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_puzzle_work_like_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_work_procedure_result_type::PuzzleWorkProcedureResult; +use super::puzzle_work_like_record_input_type::PuzzleWorkLikeRecordInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct RecordPuzzleWorkLikeArgs { + pub input: PuzzleWorkLikeRecordInput, +} + + +impl __sdk::InModule for RecordPuzzleWorkLikeArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_puzzle_work_like`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_puzzle_work_like { + fn record_puzzle_work_like(&self, input: PuzzleWorkLikeRecordInput, +) { + self.record_puzzle_work_like_then(input, |_, _| {}); + } + + fn record_puzzle_work_like_then( + &self, + input: PuzzleWorkLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl record_puzzle_work_like for super::RemoteProcedures { + fn record_puzzle_work_like_then( + &self, + input: PuzzleWorkLikeRecordInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, PuzzleWorkProcedureResult>( + "record_puzzle_work_like", + RecordPuzzleWorkLikeArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs index 70f54300..afd29a72 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -2,7 +2,12 @@ // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. #![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot; @@ -10,10 +15,11 @@ use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSn #[sats(crate = __lib)] pub struct RuntimeProfileInviteCodeAdminProcedureResult { pub ok: bool, - pub record: Option, - pub error_message: Option, + pub record: Option::, + pub error_message: Option::, } + impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs index 77daf412..9b394d43 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -2,7 +2,13 @@ // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. #![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -13,6 +19,7 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput { pub updated_at_micros: i64, } + impl __sdk::InModule for RuntimeProfileInviteCodeAdminUpsertInput { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs index 36ea09ee..66d378f0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -2,7 +2,13 @@ // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. #![allow(unused, clippy::all)] -use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] @@ -14,6 +20,7 @@ pub struct RuntimeProfileInviteCodeSnapshot { pub updated_at_micros: i64, } + impl __sdk::InModule for RuntimeProfileInviteCodeSnapshot { type Module = super::RemoteModule; } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index 8e091481..1d8ea31d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -27,6 +27,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { RedeemCodeReward, + PuzzleAuthorIncentiveClaim, + } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs new file mode 100644 index 00000000..c5e7312c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_puzzle_form_draft_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{ + self as __sdk, + __lib, + __sats, + __ws, +}; + +use super::puzzle_agent_session_procedure_result_type::PuzzleAgentSessionProcedureResult; +use super::puzzle_form_draft_save_input_type::PuzzleFormDraftSaveInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct SavePuzzleFormDraftArgs { + pub input: PuzzleFormDraftSaveInput, +} + + +impl __sdk::InModule for SavePuzzleFormDraftArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `save_puzzle_form_draft`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait save_puzzle_form_draft { + fn save_puzzle_form_draft(&self, input: PuzzleFormDraftSaveInput, +) { + self.save_puzzle_form_draft_then(input, |_, _| {}); + } + + fn save_puzzle_form_draft_then( + &self, + input: PuzzleFormDraftSaveInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl save_puzzle_form_draft for super::RemoteProcedures { + fn save_puzzle_form_draft_then( + &self, + input: PuzzleFormDraftSaveInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, PuzzleAgentSessionProcedureResult>( + "save_puzzle_form_draft", + SavePuzzleFormDraftArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index d7e36907..07130f84 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -1,6 +1,8 @@ use super::*; use crate::mapper::*; +use crate::module_bindings::claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive; use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work; +use crate::module_bindings::record_puzzle_work_like_procedure::record_puzzle_work_like; use crate::module_bindings::remix_puzzle_work_procedure::remix_puzzle_work; impl SpacetimeClient { @@ -55,6 +57,31 @@ impl SpacetimeClient { .await } + pub async fn save_puzzle_form_draft( + &self, + input: PuzzleFormDraftSaveRecordInput, + ) -> Result { + let procedure_input = PuzzleFormDraftSaveInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + seed_text: input.seed_text, + saved_at_micros: input.saved_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().save_puzzle_form_draft_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_agent_session_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn submit_puzzle_agent_message( &self, input: PuzzleAgentMessageSubmitRecordInput, @@ -143,6 +170,8 @@ impl SpacetimeClient { let procedure_input = PuzzleGeneratedImagesSaveInput { session_id: input.session_id, owner_user_id: input.owner_user_id, + level_id: input.level_id, + levels_json: input.levels_json, candidates_json: input.candidates_json, saved_at_micros: input.saved_at_micros, }; @@ -168,6 +197,7 @@ impl SpacetimeClient { let procedure_input = PuzzleSelectCoverImageInput { session_id: input.session_id, owner_user_id: input.owner_user_id, + level_id: input.level_id, candidate_id: input.candidate_id, selected_at_micros: input.selected_at_micros, }; @@ -196,9 +226,12 @@ impl SpacetimeClient { work_id: input.work_id, profile_id: input.profile_id, author_display_name: input.author_display_name, + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags: input.theme_tags, + levels_json: input.levels_json, published_at_micros: input.published_at_micros, }; @@ -261,11 +294,14 @@ impl SpacetimeClient { let procedure_input = PuzzleWorkUpsertInput { profile_id: input.profile_id, owner_user_id: input.owner_user_id, + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags: input.theme_tags, cover_image_src: input.cover_image_src, cover_asset_id: input.cover_asset_id, + levels_json: input.levels_json, updated_at_micros: input.updated_at_micros, }; @@ -305,6 +341,29 @@ impl SpacetimeClient { .await } + pub async fn claim_puzzle_work_point_incentive( + &self, + input: PuzzleWorkPointIncentiveClaimRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkPointIncentiveClaimInput { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + claimed_at_micros: input.claimed_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .claim_puzzle_work_point_incentive_then(procedure_input, move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }); + }) + .await + } + pub async fn list_puzzle_gallery( &self, ) -> Result, SpacetimeClientError> { @@ -341,6 +400,30 @@ impl SpacetimeClient { .await } + pub async fn record_puzzle_work_like( + &self, + input: PuzzleWorkLikeReportRecordInput, + ) -> Result { + let procedure_input = PuzzleWorkLikeRecordInput { + profile_id: input.profile_id, + user_id: input.user_id, + liked_at_micros: input.liked_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection.procedures().record_puzzle_work_like_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_puzzle_work_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn remix_puzzle_work( &self, input: PuzzleWorkRemixRecordInput, @@ -377,6 +460,7 @@ impl SpacetimeClient { run_id: input.run_id, owner_user_id: input.owner_user_id, profile_id: input.profile_id, + level_id: input.level_id, started_at_micros: input.started_at_micros, }; @@ -526,6 +610,7 @@ impl SpacetimeClient { owner_user_id: input.owner_user_id, prop_kind: input.prop_kind, used_at_micros: input.used_at_micros, + spent_points: input.spent_points, }; self.call_after_connect(move |connection, sender| { diff --git a/server-rs/crates/spacetime-module/src/big_fish/session.rs b/server-rs/crates/spacetime-module/src/big_fish/session.rs index 42159924..280be68e 100644 --- a/server-rs/crates/spacetime-module/src/big_fish/session.rs +++ b/server-rs/crates/spacetime-module/src/big_fish/session.rs @@ -1,7 +1,8 @@ use crate::big_fish::tables::{big_fish_agent_message, big_fish_creation_session}; use crate::runtime::{ - ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, - count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, + add_profile_observed_play_time, count_recent_public_work_plays, record_public_work_like, + record_public_work_play, upsert_profile_played_work, }; use crate::*; @@ -123,6 +124,32 @@ pub fn record_big_fish_play( } } +#[spacetimedb::procedure] +pub fn record_big_fish_like( + ctx: &mut ProcedureContext, + input: BigFishWorkLikeRecordInput, +) -> BigFishWorksProcedureResult { + match ctx.try_with_tx(|tx| record_big_fish_like_tx(tx, input.clone())) { + Ok(items) => match serde_json::to_string(&items) { + Ok(items_json) => BigFishWorksProcedureResult { + ok: true, + items_json: Some(items_json), + error_message: None, + }, + Err(error) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(error.to_string()), + }, + }, + Err(message) => BigFishWorksProcedureResult { + ok: false, + items_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn remix_big_fish_work( ctx: &mut ProcedureContext, @@ -712,6 +739,60 @@ pub(crate) fn record_big_fish_play_tx( list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input()) } +pub(crate) fn record_big_fish_like_tx( + ctx: &ReducerContext, + input: BigFishWorkLikeRecordInput, +) -> Result, String> { + let session_id = input.session_id.trim(); + let user_id = input.user_id.trim(); + if session_id.is_empty() || user_id.is_empty() { + return Err("big_fish like 参数不能为空".to_string()); + } + let session = ctx + .db + .big_fish_creation_session() + .session_id() + .find(&session_id.to_string()) + .filter(|row| row.stage == BigFishCreationStage::Published) + .ok_or_else(|| "big_fish 已发布作品不存在,无法点赞".to_string())?; + let inserted_like = record_public_work_like( + ctx, + PublicWorkLikeRecordInput { + source_type: "big-fish".to_string(), + owner_user_id: session.owner_user_id.clone(), + profile_id: session.session_id.clone(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + if inserted_like { + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + let next_session = BigFishCreationSession { + session_id: session.session_id.clone(), + owner_user_id: session.owner_user_id.clone(), + seed_text: session.seed_text.clone(), + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack_json: session.anchor_pack_json.clone(), + draft_json: session.draft_json.clone(), + asset_coverage_json: session.asset_coverage_json.clone(), + last_assistant_reply: session.last_assistant_reply.clone(), + publish_ready: session.publish_ready, + play_count: session.play_count, + remix_count: session.remix_count, + like_count: session.like_count.saturating_add(1), + published_at: session.published_at, + created_at: session.created_at, + updated_at: liked_at, + }; + replace_big_fish_session(ctx, &session, next_session); + } + + list_big_fish_works_tx(ctx, build_public_big_fish_gallery_list_input()) +} + fn remix_big_fish_work_tx( ctx: &ReducerContext, input: BigFishWorkRemixInput, diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index fe5874c8..4a965ea0 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -2219,6 +2219,27 @@ pub fn record_custom_world_profile_play( } } +#[spacetimedb::procedure] +pub fn record_custom_world_profile_like( + ctx: &mut ProcedureContext, + input: module_custom_world::CustomWorldProfileLikeRecordInput, +) -> CustomWorldLibraryMutationResult { + match ctx.try_with_tx(|tx| record_custom_world_profile_like_record(tx, input.clone())) { + Ok((entry, gallery_entry)) => CustomWorldLibraryMutationResult { + ok: true, + entry: Some(entry), + gallery_entry: Some(gallery_entry), + error_message: None, + }, + Err(message) => CustomWorldLibraryMutationResult { + ok: false, + entry: None, + gallery_entry: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn list_custom_world_works( ctx: &mut ProcedureContext, @@ -3260,6 +3281,92 @@ fn record_custom_world_profile_play_record( )) } +fn record_custom_world_profile_like_record( + ctx: &ReducerContext, + input: module_custom_world::CustomWorldProfileLikeRecordInput, +) -> Result<(CustomWorldProfileSnapshot, CustomWorldGalleryEntrySnapshot), String> { + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if owner_user_id.is_empty() || profile_id.is_empty() || user_id.is_empty() { + return Err("custom_world like 参数不能为空".to_string()); + } + let existing = ctx + .db + .custom_world_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.owner_user_id == owner_user_id) + .filter(|row| { + row.publication_status == CustomWorldPublicationStatus::Published + && row.deleted_at.is_none() + && row.published_at.is_some() + }) + .ok_or_else(|| "custom_world 已发布作品不存在,无法点赞".to_string())?; + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + let inserted_like = record_public_work_like( + ctx, + crate::runtime::PublicWorkLikeRecordInput { + source_type: "custom-world".to_string(), + owner_user_id: existing.owner_user_id.clone(), + profile_id: existing.profile_id.clone(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + if !inserted_like { + let gallery_entry = ctx + .db + .custom_world_gallery_entry() + .profile_id() + .find(&existing.profile_id) + .filter(|row| row.owner_user_id == existing.owner_user_id) + .map(|row| build_custom_world_gallery_entry_snapshot(ctx, &row)) + .ok_or_else(|| "custom_world gallery_entry 不存在".to_string())?; + return Ok(( + build_custom_world_profile_snapshot(&existing), + gallery_entry, + )); + } + + // 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。 + let next_row = CustomWorldProfile { + profile_id: existing.profile_id.clone(), + owner_user_id: existing.owner_user_id.clone(), + public_work_code: existing.public_work_code.clone(), + author_public_user_code: existing.author_public_user_code.clone(), + source_agent_session_id: existing.source_agent_session_id.clone(), + publication_status: existing.publication_status, + world_name: existing.world_name.clone(), + subtitle: existing.subtitle.clone(), + summary_text: existing.summary_text.clone(), + theme_mode: existing.theme_mode, + cover_image_src: existing.cover_image_src.clone(), + profile_payload_json: existing.profile_payload_json.clone(), + playable_npc_count: existing.playable_npc_count, + landmark_count: existing.landmark_count, + play_count: existing.play_count, + remix_count: existing.remix_count, + like_count: existing.like_count.saturating_add(1), + author_display_name: existing.author_display_name.clone(), + published_at: existing.published_at, + deleted_at: existing.deleted_at, + created_at: existing.created_at, + updated_at: liked_at, + }; + ctx.db + .custom_world_profile() + .profile_id() + .delete(&existing.profile_id); + let inserted = ctx.db.custom_world_profile().insert(next_row); + let gallery_entry = sync_custom_world_gallery_entry_from_profile(ctx, &inserted)?; + Ok(( + build_custom_world_profile_snapshot(&inserted), + gallery_entry, + )) +} + fn list_custom_world_work_snapshots( ctx: &ReducerContext, input: CustomWorldWorksListInput, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index b9f283f7..93af5b00 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -164,6 +164,7 @@ macro_rules! migration_tables { profile_referral_relation, profile_played_world, public_work_play_daily_stat, + public_work_like, profile_membership, profile_recharge_order, profile_save_archive, @@ -1152,6 +1153,31 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde object .entry("like_count".to_string()) .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("point_incentive_total_half_points".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("point_incentive_claimed_points".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + // 中文注释:拼图多关卡字段晚于旧作品表加入,旧迁移包留空并由读取层补出首关。 + object + .entry("levels_json".to_string()) + .or_insert_with(|| serde_json::Value::from("")); + // 中文注释:作品名称/描述从旧关卡名/画面摘要拆出,旧行保留旧值做兼容回填。 + let fallback_title = object + .get("level_name") + .cloned() + .unwrap_or_else(|| serde_json::Value::from("")); + object + .entry("work_title".to_string()) + .or_insert(fallback_title); + let fallback_description = object + .get("summary") + .cloned() + .unwrap_or_else(|| serde_json::Value::from("")); + object + .entry("work_description".to_string()) + .or_insert(fallback_description); } } next_value diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 38be4b6c..caa00342 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,27 +1,38 @@ use crate::runtime::{ - ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, - count_recent_public_work_plays, record_public_work_play, upsert_profile_played_work, + ProfilePlayedWorkUpsertInput, ProfileSaveArchiveUpsertInput, PublicWorkLikeRecordInput, + PublicWorkPlayRecordInput, add_profile_observed_play_time, count_recent_public_work_plays, + grant_profile_wallet_points, record_public_work_like, record_public_work_play, + upsert_profile_played_work, upsert_profile_save_archive, }; use module_puzzle::{ - PUZZLE_MAX_TAG_COUNT, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, + PUZZLE_MAX_TAG_COUNT, PUZZLE_NEXT_LEVEL_MODE_NONE, PUZZLE_NEXT_LEVEL_MODE_SAME_WORK, + PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS, PuzzleAgentMessageFinalizeInput, PuzzleAgentMessageKind, PuzzleAgentMessageRole, PuzzleAgentMessageSnapshot, PuzzleAgentSessionCreateInput, PuzzleAgentSessionGetInput, PuzzleAgentSessionProcedureResult, PuzzleAgentSessionSnapshot, - PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleGeneratedImageCandidate, - PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, PuzzleLeaderboardSubmitInput, - PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, - PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, - PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, - PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, - PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult, - apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview, - compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags, - publish_work_profile, resolve_puzzle_grid_size, select_next_profile, + PuzzleAgentStage, PuzzleAnchorPack, PuzzleDraftCompileInput, PuzzleFormDraftSaveInput, + PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, + PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, + PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, + PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, + PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, + PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, + PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, PuzzleWorkPointIncentiveClaimInput, + PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, + PuzzleWorksListInput, PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, + apply_selected_candidate, build_form_draft_from_seed, build_result_preview, + compile_result_draft_from_seed, create_work_profile, infer_anchor_pack, normalize_puzzle_draft, + normalize_puzzle_levels, normalize_theme_tags, publish_work_profile, replace_puzzle_level, + select_next_profiles, selected_profile_level_after_runtime_level, selected_puzzle_level, + tag_similarity_score, }; +use module_runtime::RuntimeProfileWalletLedgerSourceType; use serde_json::from_str as json_from_str; +use serde_json::json; use serde_json::to_string as json_to_string; use spacetimedb::{ProcedureContext, Table, Timestamp, TxContext}; +const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; + /// 拼图 Agent session 真相表。 /// 当前只保存结构化字段与 JSON 草稿,不提前拆出更多编辑态子表。 #[spacetimedb::table( @@ -72,11 +83,14 @@ pub struct PuzzleWorkProfileRow { owner_user_id: String, source_session_id: Option, author_display_name: String, + work_title: String, + work_description: String, level_name: String, summary: String, theme_tags_json: String, cover_image_src: Option, cover_asset_id: Option, + levels_json: String, publication_status: PuzzlePublicationStatus, play_count: u32, anchor_pack_json: String, @@ -88,6 +102,10 @@ pub struct PuzzleWorkProfileRow { remix_count: u32, #[default(0)] like_count: u32, + #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] + point_incentive_total_half_points: u64, + #[default(PUZZLE_POINT_INCENTIVE_DEFAULT_U64)] + point_incentive_claimed_points: u64, } /// 运行态 run 快照表。 @@ -225,6 +243,27 @@ pub fn compile_puzzle_agent_draft( } } +/// 保存拼图入口表单草稿。 +/// 中文注释:该 procedure 只更新 session 与创作中心草稿卡,不触发图片生成或发布校验。 +#[spacetimedb::procedure] +pub fn save_puzzle_form_draft( + ctx: &mut ProcedureContext, + input: PuzzleFormDraftSaveInput, +) -> PuzzleAgentSessionProcedureResult { + match ctx.try_with_tx(|tx| save_puzzle_form_draft_tx(tx, input.clone())) { + Ok(session) => PuzzleAgentSessionProcedureResult { + ok: true, + session_json: Some(serialize_json(&session)), + error_message: None, + }, + Err(message) => PuzzleAgentSessionProcedureResult { + ok: false, + session_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn save_puzzle_generated_images( ctx: &mut ProcedureContext, @@ -393,6 +432,25 @@ pub fn get_puzzle_gallery_detail( } } +#[spacetimedb::procedure] +pub fn record_puzzle_work_like( + ctx: &mut ProcedureContext, + input: PuzzleWorkLikeInput, +) -> PuzzleWorkProcedureResult { + match ctx.try_with_tx(|tx| record_puzzle_work_like_tx(tx, input.clone())) { + Ok(item) => PuzzleWorkProcedureResult { + ok: true, + item_json: Some(serialize_json(&item)), + error_message: None, + }, + Err(message) => PuzzleWorkProcedureResult { + ok: false, + item_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn remix_puzzle_work( ctx: &mut ProcedureContext, @@ -545,6 +603,25 @@ pub fn use_puzzle_runtime_prop( } } +#[spacetimedb::procedure] +pub fn claim_puzzle_work_point_incentive( + ctx: &mut ProcedureContext, + input: PuzzleWorkPointIncentiveClaimInput, +) -> PuzzleWorkProcedureResult { + match ctx.try_with_tx(|tx| claim_puzzle_work_point_incentive_tx(tx, input.clone())) { + Ok(item) => PuzzleWorkProcedureResult { + ok: true, + item_json: Some(serialize_json(&item)), + error_message: None, + }, + Err(message) => PuzzleWorkProcedureResult { + ok: false, + item_json: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn submit_puzzle_leaderboard_entry( ctx: &mut ProcedureContext, @@ -572,6 +649,7 @@ fn create_puzzle_agent_session_tx( ensure_message_missing(ctx, &input.welcome_message_id)?; let created_at = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); + let initial_form_draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { session_id: input.session_id.clone(), owner_user_id: input.owner_user_id.clone(), @@ -581,7 +659,7 @@ fn create_puzzle_agent_session_tx( progress_percent: 0, stage: PuzzleAgentStage::CollectingAnchors, anchor_pack_json: serialize_json(&anchor_pack), - draft_json: None, + draft_json: Some(serialize_json(&initial_form_draft)), last_assistant_reply: Some(input.welcome_message_text.clone()), published_profile_id: None, created_at, @@ -595,6 +673,13 @@ fn create_puzzle_agent_session_tx( text: input.welcome_message_text, created_at, }); + upsert_puzzle_draft_work_profile( + ctx, + &input.session_id, + &input.owner_user_id, + &initial_form_draft, + input.created_at_micros, + )?; get_puzzle_agent_session_tx( ctx, PuzzleAgentSessionGetInput { @@ -730,9 +815,12 @@ fn compile_puzzle_agent_draft_tx( input: PuzzleDraftCompileInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; - let anchor_pack = deserialize_anchor_pack(&row.anchor_pack_json)?; + if row.seed_text.trim().is_empty() { + return Err("请先填写拼图作品信息".to_string()); + } + let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text)); let messages = list_session_messages(ctx, &row.session_id); - let draft = compile_result_draft(&anchor_pack, &messages); + let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)); // 创作中心的拼图草稿卡只是 Agent session 的列表投影, // 每次编译结果页时同步 upsert,保证后续能按 source_session_id 恢复聊天。 upsert_puzzle_draft_work_profile( @@ -772,29 +860,91 @@ fn compile_puzzle_agent_draft_tx( ) } +fn save_puzzle_form_draft_tx( + ctx: &TxContext, + input: PuzzleFormDraftSaveInput, +) -> Result { + let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; + if row.stage != PuzzleAgentStage::CollectingAnchors { + return get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ); + } + + let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); + let anchor_pack = infer_anchor_pack(&input.seed_text, Some(&input.seed_text)); + let draft = build_form_draft_from_seed(&anchor_pack, Some(&input.seed_text)); + upsert_puzzle_draft_work_profile( + ctx, + &input.session_id, + &input.owner_user_id, + &draft, + input.saved_at_micros, + )?; + replace_puzzle_agent_session( + ctx, + &row, + PuzzleAgentSessionRow { + session_id: row.session_id.clone(), + owner_user_id: row.owner_user_id.clone(), + seed_text: input.seed_text, + current_turn: row.current_turn, + progress_percent: 0, + stage: PuzzleAgentStage::CollectingAnchors, + anchor_pack_json: serialize_json(&anchor_pack), + draft_json: Some(serialize_json(&draft)), + last_assistant_reply: row.last_assistant_reply.clone(), + published_profile_id: row.published_profile_id.clone(), + created_at: row.created_at, + updated_at: saved_at, + }, + ); + + get_puzzle_agent_session_tx( + ctx, + PuzzleAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn save_puzzle_generated_images_tx( ctx: &TxContext, input: PuzzleGeneratedImagesSaveInput, ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let mut draft = deserialize_draft_required(&row.draft_json)?; + if let Some(levels) = deserialize_optional_levels_input(input.levels_json.as_deref())? { + // 中文注释:结果页新增关卡可能还没等到自动保存,生成图时以本次 action 携带的关卡快照作为写回目标。 + draft.levels = levels; + module_puzzle::sync_primary_level_fields(&mut draft); + } let candidates: Vec = json_from_str(&input.candidates_json) .map_err(|error| format!("拼图候选图 JSON 非法: {error}"))?; if candidates.is_empty() { return Err("拼图候选图不能为空".to_string()); } - replace_generated_candidate(&mut draft, candidates); - draft.generation_status = "ready".to_string(); - if let Some(selected) = draft + let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) + .ok_or_else(|| "拼图关卡不存在".to_string())?; + let mut next_level = target_level; + replace_generated_candidate(&mut next_level.candidates, candidates); + next_level.generation_status = "ready".to_string(); + if let Some(selected) = next_level .candidates .iter() .find(|entry| entry.selected) .cloned() { - draft.selected_candidate_id = Some(selected.candidate_id); - draft.cover_image_src = Some(selected.image_src); - draft.cover_asset_id = Some(selected.asset_id); + next_level.selected_candidate_id = Some(selected.candidate_id); + next_level.cover_image_src = Some(selected.image_src); + next_level.cover_asset_id = Some(selected.asset_id); } + draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready { @@ -843,8 +993,38 @@ fn select_puzzle_cover_image_tx( ) -> Result { let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?; let draft = deserialize_draft_required(&row.draft_json)?; - let draft = - apply_selected_candidate(draft, &input.candidate_id).map_err(|error| error.to_string())?; + let target_level = selected_puzzle_level(&draft, input.level_id.as_deref()) + .ok_or_else(|| "拼图关卡不存在".to_string())?; + let level_draft = PuzzleResultDraft { + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + level_name: target_level.level_name.clone(), + summary: draft.summary.clone(), + theme_tags: draft.theme_tags.clone(), + forbidden_directives: draft.forbidden_directives.clone(), + creator_intent: draft.creator_intent.clone(), + anchor_pack: draft.anchor_pack.clone(), + candidates: target_level.candidates.clone(), + selected_candidate_id: target_level.selected_candidate_id.clone(), + cover_image_src: target_level.cover_image_src.clone(), + cover_asset_id: target_level.cover_asset_id.clone(), + generation_status: target_level.generation_status.clone(), + levels: vec![target_level.clone()], + form_draft: None, + }; + let selected_level_draft = apply_selected_candidate(level_draft, &input.candidate_id) + .map_err(|error| error.to_string())?; + let next_level = module_puzzle::PuzzleDraftLevel { + level_id: target_level.level_id, + level_name: target_level.level_name, + picture_description: target_level.picture_description, + candidates: selected_level_draft.candidates, + selected_candidate_id: selected_level_draft.selected_candidate_id, + cover_image_src: selected_level_draft.cover_image_src, + cover_asset_id: selected_level_draft.cover_asset_id, + generation_status: selected_level_draft.generation_status, + }; + let draft = replace_puzzle_level(&draft, next_level).map_err(|error| error.to_string())?; let selected_at = Timestamp::from_micros_since_unix_epoch(input.selected_at_micros); let next_stage = if build_result_preview(&draft, Some("陶泥主")).publish_ready { PuzzleAgentStage::ReadyToPublish @@ -894,9 +1074,12 @@ fn publish_puzzle_work_tx( let draft = deserialize_draft_required(&row.draft_json)?; let draft = apply_publish_overrides_to_draft( &draft, + input.work_title.clone(), + input.work_description.clone(), input.level_name.clone(), input.summary.clone(), input.theme_tags.clone(), + deserialize_optional_levels_input(input.levels_json.as_deref())?, ) .map_err(|error| error.to_string())?; let (work_id, profile_id) = build_puzzle_work_ids_from_session_id(&input.session_id); @@ -981,23 +1164,60 @@ fn update_puzzle_work_tx( if theme_tags.is_empty() || theme_tags.len() > PUZZLE_MAX_TAG_COUNT { return Err("拼图标签数量不合法".to_string()); } + let levels = deserialize_optional_levels_input(input.levels_json.as_deref())? + .map(|levels| { + normalize_puzzle_levels(levels, &theme_tags).map_err(|error| error.to_string()) + }) + .transpose()? + .unwrap_or_else(|| build_profile_levels_from_row(&row).unwrap_or_default()); + let preview_draft = PuzzleResultDraft { + work_title: input.work_title.clone(), + work_description: input.work_description.clone(), + level_name: input.level_name.clone(), + summary: input.summary.clone(), + theme_tags: theme_tags.clone(), + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, + candidates: levels + .first() + .map(|level| level.candidates.clone()) + .unwrap_or_default(), + selected_candidate_id: levels + .first() + .and_then(|level| level.selected_candidate_id.clone()), + cover_image_src: input.cover_image_src.clone(), + cover_asset_id: input.cover_asset_id.clone(), + generation_status: levels + .first() + .map(|level| level.generation_status.clone()) + .unwrap_or_else(|| "idle".to_string()), + levels: levels.clone(), + form_draft: None, + }; let next_row = PuzzleWorkProfileRow { profile_id: row.profile_id.clone(), work_id: row.work_id.clone(), owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: input.work_title, + work_description: input.work_description, level_name: input.level_name, summary: input.summary, theme_tags_json: serialize_json(&theme_tags), cover_image_src: input.cover_image_src, cover_asset_id: input.cover_asset_id, + levels_json: serialize_json(&levels), publication_status: row.publication_status, play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), - publish_ready: row.publish_ready, + publish_ready: build_result_preview(&preview_draft, Some(&row.author_display_name)) + .publish_ready, created_at: row.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), published_at: row.published_at, @@ -1103,6 +1323,78 @@ fn get_puzzle_gallery_detail_tx( ) } +fn record_puzzle_work_like_tx( + ctx: &TxContext, + input: PuzzleWorkLikeInput, +) -> Result { + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if profile_id.is_empty() || user_id.is_empty() { + return Err("拼图 like 参数不能为空".to_string()); + } + let row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .filter(|row| row.publication_status == PuzzlePublicationStatus::Published) + .ok_or_else(|| "拼图已发布作品不存在,无法点赞".to_string())?; + let inserted_like = record_public_work_like( + ctx, + PublicWorkLikeRecordInput { + source_type: "puzzle".to_string(), + owner_user_id: row.owner_user_id.clone(), + profile_id: row.profile_id.clone(), + user_id: user_id.to_string(), + liked_at_micros: input.liked_at_micros, + }, + )?; + + let current_row = if inserted_like { + let liked_at = Timestamp::from_micros_since_unix_epoch(input.liked_at_micros); + let next_row = PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count.saturating_add(1), + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: liked_at, + published_at: row.published_at, + }; + replace_puzzle_work_profile(ctx, &row, next_row); + ctx.db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图点赞更新失败".to_string())? + } else { + row + }; + + build_puzzle_work_profile_from_row_with_recent_count( + ctx, + ¤t_row, + ctx.timestamp.to_micros_since_unix_epoch(), + ) +} + fn remix_puzzle_work_tx( ctx: &TxContext, input: PuzzleWorkRemixInput, @@ -1154,15 +1446,20 @@ fn remix_puzzle_work_tx( owner_user_id: source.owner_user_id.clone(), source_session_id: source.source_session_id.clone(), author_display_name: source.author_display_name.clone(), + work_title: source.work_title.clone(), + work_description: source.work_description.clone(), level_name: source.level_name.clone(), summary: source.summary.clone(), theme_tags_json: source.theme_tags_json.clone(), cover_image_src: source.cover_image_src.clone(), cover_asset_id: source.cover_asset_id.clone(), + levels_json: source.levels_json.clone(), publication_status: source.publication_status, play_count: source.play_count, remix_count: source.remix_count.saturating_add(1), like_count: source.like_count, + point_incentive_total_half_points: source.point_incentive_total_half_points, + point_incentive_claimed_points: source.point_incentive_claimed_points, anchor_pack_json: source.anchor_pack_json.clone(), publish_ready: source.publish_ready, created_at: source.created_at, @@ -1172,6 +1469,8 @@ fn remix_puzzle_work_tx( ); let draft = PuzzleResultDraft { + work_title: source_profile.work_title.clone(), + work_description: source_profile.work_description.clone(), level_name: source_profile.level_name.clone(), summary: source_profile.summary.clone(), theme_tags: source_profile.theme_tags.clone(), @@ -1183,6 +1482,8 @@ fn remix_puzzle_work_tx( cover_image_src: source_profile.cover_image_src.clone(), cover_asset_id: source_profile.cover_asset_id.clone(), generation_status: "ready".to_string(), + levels: source_profile.levels.clone(), + form_draft: None, }; ctx.db.puzzle_agent_session().insert(PuzzleAgentSessionRow { session_id: target_session_id.to_string(), @@ -1212,15 +1513,20 @@ fn remix_puzzle_work_tx( owner_user_id: target_owner_user_id.to_string(), source_session_id: Some(target_session_id.to_string()), author_display_name: input.author_display_name.trim().to_string(), + work_title: source_profile.work_title, + work_description: source_profile.work_description, level_name: source_profile.level_name, summary: source_profile.summary, theme_tags_json: serialize_json(&source_profile.theme_tags), cover_image_src: source_profile.cover_image_src, cover_asset_id: source_profile.cover_asset_id, + levels_json: serialize_json(&source_profile.levels), publication_status: PuzzlePublicationStatus::Draft, play_count: 0, remix_count: 0, like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, anchor_pack_json: serialize_json(&source_profile.anchor_pack), publish_ready: true, created_at: remixed_at, @@ -1259,11 +1565,21 @@ fn start_puzzle_run_tx( if entry_profile_row.publication_status != PuzzlePublicationStatus::Published { return Err("入口拼图作品未发布".to_string()); } - let entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; + let mut entry_profile = build_puzzle_work_profile_from_row(&entry_profile_row)?; + let mut cleared_level_count = 0; + if let Some(level) = selected_profile_level(&entry_profile, input.level_id.as_deref())? { + cleared_level_count = + module_puzzle::resolve_restart_cleared_level_count(&entry_profile, &level.level_id); + entry_profile = profile_for_single_level(&entry_profile, &level); + } let started_at_ms = micros_to_millis(input.started_at_micros); - let mut run = - module_puzzle::start_run_at(input.run_id.clone(), &entry_profile, 0, started_at_ms) - .map_err(|error| error.to_string())?; + let mut run = module_puzzle::start_run_at( + input.run_id.clone(), + &entry_profile, + cleared_level_count, + started_at_ms, + ) + .map_err(|error| error.to_string())?; let current_grid_size = run.current_grid_size; let current_profile_id = entry_profile.profile_id.clone(); hydrate_puzzle_leaderboard_entries( @@ -1273,12 +1589,7 @@ fn start_puzzle_run_tx( current_profile_id.as_str(), current_grid_size, ); - run.recommended_next_profile_id = select_next_profile( - &entry_profile, - &run.played_profile_ids, - &list_published_puzzle_profiles(ctx)?, - ) - .map(|value| value.profile_id.clone()); + refresh_next_level_handoff(ctx, &mut run)?; record_public_work_play( ctx, @@ -1310,6 +1621,7 @@ fn get_puzzle_run_tx( deserialize_run(&row.snapshot_json)?, micros_to_millis(now_micros), ); + refresh_next_level_handoff(ctx, &mut run)?; if serialize_json(&run) != row.snapshot_json { replace_puzzle_runtime_run(ctx, &row, &run, now_micros); } @@ -1342,7 +1654,7 @@ fn swap_puzzle_pieces_tx( micros_to_millis(input.swapped_at_micros), ) .map_err(|error| error.to_string())?; - refresh_next_profile_recommendation(ctx, &mut next_run)?; + refresh_next_level_handoff(ctx, &mut next_run)?; if let Some((profile_id, grid_size)) = next_run .current_level .as_ref() @@ -1374,7 +1686,7 @@ fn drag_puzzle_piece_or_group_tx( micros_to_millis(input.dragged_at_micros), ) .map_err(|error| error.to_string())?; - refresh_next_profile_recommendation(ctx, &mut next_run)?; + refresh_next_level_handoff(ctx, &mut next_run)?; if let Some((profile_id, grid_size)) = next_run .current_level .as_ref() @@ -1405,26 +1717,47 @@ fn advance_puzzle_next_level_tx( if current_level.status != PuzzleRuntimeLevelStatus::Cleared { return Err("当前关卡尚未通关".to_string()); } - let current_profile = build_puzzle_work_profile_from_row( - &ctx.db - .puzzle_work_profile() - .profile_id() - .find(¤t_level.profile_id) - .ok_or_else(|| "当前拼图作品不存在".to_string())?, - )?; - let candidates = list_published_puzzle_profiles(ctx)?; - let next_profile = select_next_profile( - ¤t_profile, - ¤t_run.played_profile_ids, - &candidates, - ) - .ok_or_else(|| "没有可用的下一关候选".to_string())? - .clone(); - let mut next_run = module_puzzle::advance_next_level_at( - ¤t_run, - &next_profile, - micros_to_millis(input.advanced_at_micros), - ) + let current_profile_row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(¤t_level.profile_id) + .ok_or_else(|| "当前拼图作品不存在".to_string())?; + let current_profile = build_puzzle_work_profile_from_row(¤t_profile_row)?; + let same_work_next_profile = + selected_profile_level_after_runtime_level(¤t_profile, current_level) + .map(|level| profile_for_single_level(¤t_profile, &level)); + let similar_work_next_profile = if same_work_next_profile.is_none() { + let candidates = list_published_puzzle_profiles(ctx)?; + select_next_profiles( + ¤t_profile, + ¤t_run.played_profile_ids, + &candidates, + 1, + ) + .into_iter() + .next() + .cloned() + } else { + None + }; + let next_profile = same_work_next_profile + .as_ref() + .or(similar_work_next_profile.as_ref()) + .ok_or_else(|| "没有可用的下一关候选".to_string())?; + let mut next_run = if same_work_next_profile.is_some() { + module_puzzle::advance_next_level_at( + ¤t_run, + next_profile, + micros_to_millis(input.advanced_at_micros), + ) + } else { + module_puzzle::advance_to_new_work_first_level_at( + ¤t_run, + next_profile, + micros_to_millis(input.advanced_at_micros), + ) + } .map_err(|error| error.to_string())?; let next_grid_size = next_run.current_grid_size; let next_profile_id = next_profile.profile_id.clone(); @@ -1435,9 +1768,7 @@ fn advance_puzzle_next_level_tx( &next_profile_id, next_grid_size, ); - next_run.recommended_next_profile_id = - select_next_profile(&next_profile, &next_run.played_profile_ids, &candidates) - .map(|value| value.profile_id.clone()); + refresh_next_level_handoff(ctx, &mut next_run)?; if let Some(next_profile_row) = ctx .db @@ -1478,8 +1809,9 @@ fn update_puzzle_run_pause_tx( micros_to_millis(input.updated_at_micros), ) .map_err(|error| error.to_string())?; - replace_puzzle_runtime_run(ctx, &row, &next_run, input.updated_at_micros); let mut hydrated_run = next_run; + refresh_next_level_handoff(ctx, &mut hydrated_run)?; + replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.updated_at_micros); if let Some((profile_id, grid_size)) = hydrated_run .current_level .as_ref() @@ -1508,6 +1840,11 @@ fn use_puzzle_runtime_prop_tx( micros_to_millis(input.used_at_micros), ) .map_err(|error| error.to_string())?, + "extendTime" | "extend_time" => module_puzzle::extend_failed_puzzle_time_at( + ¤t_run, + micros_to_millis(input.used_at_micros), + ) + .map_err(|error| error.to_string())?, "hint" => module_puzzle::set_puzzle_run_paused_at( ¤t_run, false, @@ -1522,8 +1859,22 @@ fn use_puzzle_runtime_prop_tx( .map_err(|error| error.to_string())?, _ => return Err("未知拼图道具".to_string()), }; - replace_puzzle_runtime_run(ctx, &row, &next_run, input.used_at_micros); let mut hydrated_run = next_run; + refresh_next_level_handoff(ctx, &mut hydrated_run)?; + if let Some(profile_id) = hydrated_run + .current_level + .as_ref() + .map(|level| level.profile_id.clone()) + { + accrue_puzzle_point_incentive( + ctx, + &profile_id, + &input.owner_user_id, + input.spent_points, + input.used_at_micros, + )?; + } + replace_puzzle_runtime_run(ctx, &row, &hydrated_run, input.used_at_micros); if let Some((profile_id, grid_size)) = hydrated_run .current_level .as_ref() @@ -1540,6 +1891,86 @@ fn use_puzzle_runtime_prop_tx( Ok(hydrated_run) } +fn claim_puzzle_work_point_incentive_tx( + ctx: &TxContext, + input: PuzzleWorkPointIncentiveClaimInput, +) -> Result { + let profile_id = input.profile_id.trim(); + let owner_user_id = input.owner_user_id.trim(); + if profile_id.is_empty() || owner_user_id.is_empty() { + return Err("拼图积分激励参数不能为空".to_string()); + } + + let row = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图作品不存在".to_string())?; + if row.owner_user_id != owner_user_id { + return Err("无权领取该作品的积分激励".to_string()); + } + + let claimable_points = module_puzzle::puzzle_point_incentive_claimable_points( + row.point_incentive_total_half_points, + row.point_incentive_claimed_points, + ); + if claimable_points == 0 { + return Err("暂无可领取积分激励".to_string()); + } + + let claimed_at = Timestamp::from_micros_since_unix_epoch(input.claimed_at_micros); + let next_row = PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row + .point_incentive_claimed_points + .saturating_add(claimable_points), + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: claimed_at, + published_at: row.published_at, + }; + replace_puzzle_work_profile(ctx, &row, next_row); + + grant_profile_wallet_points( + ctx, + owner_user_id, + claimable_points, + RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim, + &format!( + "puzzle_author_incentive_claim:{}:{}:{}", + profile_id, owner_user_id, input.claimed_at_micros + ), + claimed_at, + )?; + + let updated = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .ok_or_else(|| "拼图积分激励领取更新失败".to_string())?; + build_puzzle_work_profile_from_row(&updated) +} + fn submit_puzzle_leaderboard_entry_tx( ctx: &TxContext, input: PuzzleLeaderboardSubmitInput, @@ -1550,12 +1981,20 @@ fn submit_puzzle_leaderboard_entry_tx( .current_level .as_ref() .ok_or_else(|| "拼图关卡不存在".to_string())?; - if current_level.profile_id != input.profile_id { - return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); + if input.profile_id.trim().is_empty() { + return Err("提交成绩的拼图作品不能为空".to_string()); } - if current_level.grid_size != input.grid_size { + if !module_puzzle::is_supported_puzzle_grid_size(input.grid_size) { + return Err("提交成绩的网格规格无效".to_string()); + } + let matches_service_level = + current_level.profile_id == input.profile_id && current_level.grid_size == input.grid_size; + if current_level.profile_id == input.profile_id && current_level.grid_size != input.grid_size { return Err("提交成绩的网格规格与当前关卡不匹配".to_string()); } + if !matches_service_level && !is_frontend_puzzle_level_candidate(&run, &input.profile_id) { + return Err("提交成绩的拼图作品与当前关卡不匹配".to_string()); + } let nickname = input.nickname.trim(); if nickname.is_empty() { @@ -1587,20 +2026,51 @@ fn submit_puzzle_leaderboard_entry_tx( &input.owner_user_id, 10, ); - if let Some(level) = run.current_level.as_mut() { + if matches_service_level { + if let Some(level) = run.current_level.as_mut() { + // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 + // 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。 + level.status = PuzzleRuntimeLevelStatus::Cleared; + level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros)); + level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); + level.leaderboard_entries = leaderboard_entries.clone(); + } + run.cleared_level_count = run.cleared_level_count.max(run.current_level_index); + } else { // 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。 - // 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。 - level.status = PuzzleRuntimeLevelStatus::Cleared; - level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros)); - level.elapsed_ms = Some(input.elapsed_ms.max(1_000)); - level.leaderboard_entries = leaderboard_entries.clone(); + // 前端通过 local-next-level 推进到第二关后,服务端旧 run 可能仍停在上一关。 + // 此时只返回真实榜单,前端会把榜单合并回当前本地关卡,不能用旧棋盘覆盖前端状态。 + log::info!( + "puzzle leaderboard submitted for frontend-only level: run_id={}, service_profile_id={}, submitted_profile_id={}", + input.run_id, + current_level.profile_id, + input.profile_id + ); } - run.cleared_level_count = run.cleared_level_count.max(run.current_level_index); run.leaderboard_entries = leaderboard_entries; + refresh_next_level_handoff(ctx, &mut run)?; replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros); Ok(run) } +fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) -> bool { + run.recommended_next_profile_id + .as_ref() + .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) + || run + .next_level_profile_id + .as_ref() + .is_some_and(|candidate_profile_id| candidate_profile_id == profile_id) + || run + .recommended_next_works + .iter() + .any(|candidate| candidate.profile_id == profile_id) + || run + .played_profile_ids + .iter() + .any(|played_profile_id| played_profile_id == profile_id) +} + fn build_puzzle_agent_session_snapshot( ctx: &TxContext, row: &PuzzleAgentSessionRow, @@ -1657,11 +2127,22 @@ fn build_puzzle_work_profile_from_row_without_recent_count( owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: if row.work_title.trim().is_empty() { + row.level_name.clone() + } else { + row.work_title.clone() + }, + work_description: if row.work_description.trim().is_empty() { + row.summary.clone() + } else { + row.work_description.clone() + }, level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags: deserialize_theme_tags(&row.theme_tags_json)?, cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), + levels: build_profile_levels_from_row(row)?, publication_status: row.publication_status, updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), published_at_micros: row @@ -1670,12 +2151,72 @@ fn build_puzzle_work_profile_from_row_without_recent_count( play_count: row.play_count, remix_count: row.remix_count, like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, recent_play_count_7d: 0, publish_ready: row.publish_ready, anchor_pack: deserialize_anchor_pack(&row.anchor_pack_json)?, }) } +fn build_profile_levels_from_row( + row: &PuzzleWorkProfileRow, +) -> Result, String> { + let levels = deserialize_levels_json(&row.levels_json)?; + if !levels.is_empty() { + return Ok(levels); + } + Ok(vec![module_puzzle::PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: row.level_name.clone(), + picture_description: row.summary.clone(), + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + generation_status: if row.cover_image_src.is_some() { + "ready".to_string() + } else { + "idle".to_string() + }, + }]) +} + +fn selected_profile_level( + profile: &PuzzleWorkProfile, + level_id: Option<&str>, +) -> Result, String> { + let Some(level_id) = level_id.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) else { + return Ok(None); + }; + profile + .levels + .iter() + .find(|level| level.level_id == level_id) + .cloned() + .map(Some) + .ok_or_else(|| "入口拼图关卡不存在".to_string()) +} + +fn profile_for_single_level( + profile: &PuzzleWorkProfile, + level: &module_puzzle::PuzzleDraftLevel, +) -> PuzzleWorkProfile { + let mut next_profile = profile.clone(); + next_profile.level_name = level.level_name.clone(); + next_profile.cover_image_src = level.cover_image_src.clone(); + next_profile.cover_asset_id = level.cover_asset_id.clone(); + next_profile.levels = vec![level.clone()]; + next_profile +} + fn build_puzzle_work_ids_from_session_id(session_id: &str) -> (String, String) { let stable_suffix = session_id .strip_prefix("puzzle-session-") @@ -1705,6 +2246,22 @@ fn upsert_puzzle_draft_work_profile( if existing.publication_status == PuzzlePublicationStatus::Published { return Ok(()); } + let mut profile = create_work_profile( + work_id, + profile_id, + owner_user_id.to_string(), + Some(session_id.to_string()), + existing.author_display_name.clone(), + draft, + updated_at_micros, + ) + .map_err(|error| error.to_string())?; + profile.play_count = existing.play_count; + profile.remix_count = existing.remix_count; + profile.like_count = existing.like_count; + profile.point_incentive_total_half_points = existing.point_incentive_total_half_points; + profile.point_incentive_claimed_points = existing.point_incentive_claimed_points; + return upsert_puzzle_work_profile(ctx, profile); } let profile = create_work_profile( work_id, @@ -1868,17 +2425,26 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, + work_title: profile.work_title, + work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, + levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, // 二次编辑发布同一个 profile 时,作品内容可以覆盖,但历史游玩数属于 // 广场消费数据,不能因为重新发布被清零。 play_count: existing.play_count.max(profile.play_count), remix_count: existing.remix_count.max(profile.remix_count), like_count: existing.like_count.max(profile.like_count), + point_incentive_total_half_points: existing + .point_incentive_total_half_points + .max(profile.point_incentive_total_half_points), + point_incentive_claimed_points: existing + .point_incentive_claimed_points + .max(profile.point_incentive_claimed_points), anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: existing.created_at, @@ -1897,15 +2463,20 @@ fn upsert_puzzle_work_profile(ctx: &TxContext, profile: PuzzleWorkProfile) -> Re owner_user_id: profile.owner_user_id, source_session_id: profile.source_session_id, author_display_name: profile.author_display_name, + work_title: profile.work_title, + work_description: profile.work_description, level_name: profile.level_name, summary: profile.summary, theme_tags_json: serialize_json(&profile.theme_tags), cover_image_src: profile.cover_image_src, cover_asset_id: profile.cover_asset_id, + levels_json: serialize_json(&profile.levels), publication_status: profile.publication_status, play_count: profile.play_count, remix_count: profile.remix_count, like_count: profile.like_count, + point_incentive_total_half_points: profile.point_incentive_total_half_points, + point_incentive_claimed_points: profile.point_incentive_claimed_points, anchor_pack_json: serialize_json(&profile.anchor_pack), publish_ready: profile.publish_ready, created_at: Timestamp::from_micros_since_unix_epoch(profile.updated_at_micros), @@ -1943,6 +2514,7 @@ fn insert_puzzle_runtime_run( created_at: timestamp, updated_at: timestamp, }); + upsert_puzzle_profile_save_archive(ctx, run, owner_user_id, created_at_micros)?; Ok(()) } @@ -1964,13 +2536,228 @@ fn replace_puzzle_runtime_run( .unwrap_or_else(|| current.current_profile_id.clone()), cleared_level_count: run.cleared_level_count, current_level_index: run.current_level_index, - current_grid_size: resolve_puzzle_grid_size(run.cleared_level_count), + current_grid_size: run.current_grid_size, played_profile_ids_json: serialize_json(&run.played_profile_ids), previous_level_tags_json: serialize_json(&run.previous_level_tags), snapshot_json: serialize_json(run), created_at: current.created_at, updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), }); + if let Err(error) = + upsert_puzzle_profile_save_archive(ctx, run, ¤t.owner_user_id, updated_at_micros) + { + log::warn!("拼图存档投影同步失败: {}", error); + } +} + +fn upsert_puzzle_profile_save_archive( + ctx: &TxContext, + run: &PuzzleRunSnapshot, + user_id: &str, + saved_at_micros: i64, +) -> Result<(), String> { + let user_id = user_id.trim(); + if user_id.is_empty() { + return Ok(()); + } + let Some(current_level) = run.current_level.as_ref() else { + return Ok(()); + }; + let world_key = format!("puzzle:{}", run.entry_profile_id); + let target = resolve_puzzle_archive_target(ctx, run, current_level)?; + + // 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。 + let game_state_json = json_to_string(&json!({ + "runtimeKind": "puzzle", + "runId": run.run_id, + "entryProfileId": run.entry_profile_id, + "currentProfileId": target.profile_id.clone(), + "currentLevelIndex": target.level_index, + "currentLevelId": target.level_id.clone(), + "status": target.status.as_str(), + })) + .unwrap_or_else(|_| "{}".to_string()); + + upsert_profile_save_archive( + ctx, + ProfileSaveArchiveUpsertInput { + user_id: user_id.to_string(), + world_key, + owner_user_id: target.owner_user_id, + profile_id: Some(run.entry_profile_id.clone()), + world_type: Some("PUZZLE".to_string()), + world_name: target.level_name, + subtitle: format!("第 {} 关", target.level_index), + summary_text: puzzle_archive_summary_text(target.status), + cover_image_src: target.cover_image_src, + bottom_tab: "puzzle".to_string(), + game_state_json, + current_story_json: None, + saved_at_micros, + }, + ) +} + +struct PuzzleArchiveTarget { + profile_id: String, + level_index: u32, + level_id: Option, + level_name: String, + status: PuzzleRuntimeLevelStatus, + cover_image_src: Option, + owner_user_id: Option, +} + +fn resolve_puzzle_archive_target( + ctx: &TxContext, + run: &PuzzleRunSnapshot, + current_level: &module_puzzle::PuzzleRuntimeLevelSnapshot, +) -> Result { + // 中文注释:通关后若已经算出同作品下一关,存档页直接投影到下一关入口; + // 跨作品候选需要玩家选择,不能在存档里提前替玩家切换作品。 + let owner_user_id = resolve_puzzle_current_owner_user_id(ctx, ¤t_level.profile_id); + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + } + + let Some(next_level_id) = run + .next_level_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + }; + if run.next_level_profile_id.as_deref() != Some(current_level.profile_id.as_str()) + || run.next_level_mode != PUZZLE_NEXT_LEVEL_MODE_SAME_WORK + { + return Ok(PuzzleArchiveTarget { + profile_id: current_level.profile_id.clone(), + level_index: current_level.level_index, + level_id: current_level.level_id.clone(), + level_name: current_level.level_name.clone(), + status: current_level.status, + cover_image_src: current_level.cover_image_src.clone(), + owner_user_id, + }); + } + + let current_profile = build_puzzle_work_profile_from_row( + &ctx.db + .puzzle_work_profile() + .profile_id() + .find(¤t_level.profile_id) + .ok_or_else(|| "当前拼图作品不存在".to_string())?, + )?; + let next_level = current_profile + .levels + .iter() + .find(|level| level.level_id == next_level_id) + .cloned() + .ok_or_else(|| "下一关拼图关卡不存在".to_string())?; + + Ok(PuzzleArchiveTarget { + profile_id: current_profile.profile_id, + level_index: current_level.level_index.saturating_add(1), + level_id: Some(next_level.level_id), + level_name: next_level.level_name, + status: PuzzleRuntimeLevelStatus::Playing, + cover_image_src: next_level.cover_image_src, + owner_user_id, + }) +} + +fn resolve_puzzle_current_owner_user_id(ctx: &TxContext, profile_id: &str) -> Option { + ctx.db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + .map(|row| row.owner_user_id) +} + +fn puzzle_archive_summary_text(status: PuzzleRuntimeLevelStatus) -> String { + match status { + PuzzleRuntimeLevelStatus::Cleared => "关卡已完成", + PuzzleRuntimeLevelStatus::Failed => "关卡失败", + PuzzleRuntimeLevelStatus::Playing => "拼图进行中", + } + .to_string() +} + +fn accrue_puzzle_point_incentive( + ctx: &TxContext, + profile_id: &str, + player_user_id: &str, + spent_points: u64, + updated_at_micros: i64, +) -> Result<(), String> { + if spent_points == 0 { + return Ok(()); + } + + let Some(row) = ctx + .db + .puzzle_work_profile() + .profile_id() + .find(&profile_id.to_string()) + else { + return Ok(()); + }; + if row.publication_status != PuzzlePublicationStatus::Published + || row.owner_user_id == player_user_id + { + return Ok(()); + } + + replace_puzzle_work_profile( + ctx, + &row, + PuzzleWorkProfileRow { + profile_id: row.profile_id.clone(), + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_session_id: row.source_session_id.clone(), + author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), + level_name: row.level_name.clone(), + summary: row.summary.clone(), + theme_tags_json: row.theme_tags_json.clone(), + cover_image_src: row.cover_image_src.clone(), + cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), + publication_status: row.publication_status, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + point_incentive_total_half_points: module_puzzle::puzzle_point_incentive_total_after_spend( + row.point_incentive_total_half_points, + spent_points, + ), + point_incentive_claimed_points: row.point_incentive_claimed_points, + anchor_pack_json: row.anchor_pack_json.clone(), + publish_ready: row.publish_ready, + created_at: row.created_at, + updated_at: Timestamp::from_micros_since_unix_epoch(updated_at_micros), + published_at: row.published_at, + }, + ); + Ok(()) } fn increment_puzzle_profile_play_count( @@ -1987,15 +2774,20 @@ fn increment_puzzle_profile_play_count( owner_user_id: row.owner_user_id.clone(), source_session_id: row.source_session_id.clone(), author_display_name: row.author_display_name.clone(), + work_title: row.work_title.clone(), + work_description: row.work_description.clone(), level_name: row.level_name.clone(), summary: row.summary.clone(), theme_tags_json: row.theme_tags_json.clone(), cover_image_src: row.cover_image_src.clone(), cover_asset_id: row.cover_asset_id.clone(), + levels_json: row.levels_json.clone(), publication_status: row.publication_status, play_count: row.play_count.saturating_add(1), remix_count: row.remix_count, like_count: row.like_count, + point_incentive_total_half_points: row.point_incentive_total_half_points, + point_incentive_claimed_points: row.point_incentive_claimed_points, anchor_pack_json: row.anchor_pack_json.clone(), publish_ready: row.publish_ready, created_at: row.created_at, @@ -2028,11 +2820,11 @@ fn upsert_puzzle_profile_played_work( } fn replace_generated_candidate( - draft: &mut PuzzleResultDraft, + candidates_slot: &mut Vec, candidates: Vec, ) { // 结果页生图采用单图替换:每次只保留最新图片,并立即作为正式图。 - draft.candidates = candidates + *candidates_slot = candidates .into_iter() .take(1) .map(|mut candidate| { @@ -2051,14 +2843,33 @@ fn list_published_puzzle_profiles(ctx: &TxContext) -> Result Result<(), String> { +fn reset_next_level_handoff(run: &mut PuzzleRunSnapshot) { + run.recommended_next_profile_id = None; + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(); + run.next_level_profile_id = None; + run.next_level_id = None; + run.recommended_next_works = Vec::new(); +} + +fn build_recommended_next_work( + current_profile: &PuzzleWorkProfile, + candidate: &PuzzleWorkProfile, +) -> PuzzleRecommendedNextWork { + PuzzleRecommendedNextWork { + profile_id: candidate.profile_id.clone(), + level_name: candidate.level_name.clone(), + author_display_name: candidate.author_display_name.clone(), + theme_tags: candidate.theme_tags.clone(), + cover_image_src: candidate.cover_image_src.clone(), + similarity_score: tag_similarity_score(¤t_profile.theme_tags, &candidate.theme_tags), + } +} + +fn refresh_next_level_handoff(ctx: &TxContext, run: &mut PuzzleRunSnapshot) -> Result<(), String> { let current_level = match run.current_level.as_ref() { Some(value) => value, None => { - run.recommended_next_profile_id = None; + reset_next_level_handoff(run); return Ok(()); } }; @@ -2069,12 +2880,41 @@ fn refresh_next_profile_recommendation( .find(¤t_level.profile_id) .ok_or_else(|| "当前拼图作品不存在".to_string())?, )?; - run.recommended_next_profile_id = select_next_profile( - ¤t_profile, - &run.played_profile_ids, - &list_published_puzzle_profiles(ctx)?, - ) - .map(|value| value.profile_id.clone()); + if current_level.status != PuzzleRuntimeLevelStatus::Cleared { + reset_next_level_handoff(run); + return Ok(()); + } + + if let Some(next_level) = + selected_profile_level_after_runtime_level(¤t_profile, current_level) + { + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SAME_WORK.to_string(); + run.next_level_profile_id = Some(current_profile.profile_id.clone()); + run.next_level_id = Some(next_level.level_id); + run.recommended_next_profile_id = Some(current_profile.profile_id.clone()); + run.recommended_next_works = Vec::new(); + return Ok(()); + } + + let candidates = list_published_puzzle_profiles(ctx)?; + let recommended_next_works = + select_next_profiles(¤t_profile, &run.played_profile_ids, &candidates, 3) + .into_iter() + .map(|candidate| build_recommended_next_work(¤t_profile, candidate)) + .collect::>(); + + if recommended_next_works.is_empty() { + reset_next_level_handoff(run); + return Ok(()); + } + + run.next_level_mode = PUZZLE_NEXT_LEVEL_MODE_SIMILAR_WORKS.to_string(); + run.next_level_profile_id = recommended_next_works + .first() + .map(|candidate| candidate.profile_id.clone()); + run.next_level_id = None; + run.recommended_next_profile_id = run.next_level_profile_id.clone(); + run.recommended_next_works = recommended_next_works; Ok(()) } @@ -2196,7 +3036,11 @@ fn deserialize_anchor_pack(value: &str) -> Result { fn deserialize_optional_draft(value: &Option) -> Result, String> { value .as_ref() - .map(|raw| json_from_str(raw).map_err(|error| format!("拼图 draft JSON 非法: {error}"))) + .map(|raw| { + json_from_str(raw) + .map(normalize_puzzle_draft) + .map_err(|error| format!("拼图 draft JSON 非法: {error}")) + }) .transpose() } @@ -2208,6 +3052,22 @@ fn deserialize_theme_tags(value: &str) -> Result, String> { json_from_str(value).map_err(|error| format!("拼图 theme_tags JSON 非法: {error}")) } +fn deserialize_levels_json(value: &str) -> Result, String> { + if value.trim().is_empty() { + return Ok(Vec::new()); + } + json_from_str(value).map_err(|error| format!("拼图 levels JSON 非法: {error}")) +} + +fn deserialize_optional_levels_input( + value: Option<&str>, +) -> Result>, String> { + value + .map(|raw| deserialize_levels_json(raw)) + .transpose() + .map(|levels| levels.filter(|items| !items.is_empty())) +} + fn deserialize_run(value: &str) -> Result { json_from_str(value).map_err(|error| format!("拼图 run snapshot JSON 非法: {error}")) } @@ -2216,8 +3076,8 @@ fn deserialize_run(value: &str) -> Result { mod tests { use super::*; use module_puzzle::{ - PuzzleLeaderboardEntry, build_generated_candidates, empty_anchor_pack, - recommendation_score, tag_similarity_score, + PuzzleLeaderboardEntry, build_generated_candidates, compile_result_draft, + empty_anchor_pack, recommendation_score, tag_similarity_score, }; #[test] @@ -2232,6 +3092,10 @@ mod tests { previous_level_tags: vec!["蒸汽城市".to_string()], current_level: None, recommended_next_profile_id: None, + next_level_mode: PUZZLE_NEXT_LEVEL_MODE_NONE.to_string(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), leaderboard_entries: Vec::new(), }; let serialized = serialize_json(&snapshot); @@ -2247,6 +3111,20 @@ mod tests { .expect("candidates should build"); let draft = apply_selected_candidate( PuzzleResultDraft { + levels: vec![module_puzzle::PuzzleDraftLevel { + level_id: "puzzle-level-1".to_string(), + level_name: draft.level_name.clone(), + picture_description: draft + .levels + .first() + .map(|level| level.picture_description.clone()) + .unwrap_or_default(), + candidates: candidates.clone(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }], candidates, ..draft }, @@ -2272,7 +3150,7 @@ mod tests { }]; replace_generated_candidate( - &mut draft, + &mut draft.candidates, vec![PuzzleGeneratedImageCandidate { candidate_id: "session-1-candidate-2".to_string(), image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(), @@ -2297,11 +3175,14 @@ mod tests { owner_user_id: "owner-a".to_string(), source_session_id: None, author_display_name: "作者".to_string(), + work_title: "A".to_string(), + work_description: String::new(), level_name: "A".to_string(), summary: String::new(), theme_tags: vec!["雨夜".to_string(), "猫咪".to_string()], cover_image_src: Some("/a.png".to_string()), cover_asset_id: Some("asset-a".to_string()), + levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 1, published_at_micros: Some(1), @@ -2309,6 +3190,8 @@ mod tests { recent_play_count_7d: 0, remix_count: 0, like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), }; @@ -2316,10 +3199,13 @@ mod tests { owner_user_id: "owner-a".to_string(), profile_id: "profile-b".to_string(), work_id: "work-b".to_string(), + work_title: "B".to_string(), + work_description: String::new(), level_name: "B".to_string(), theme_tags: vec!["雨夜".to_string(), "蒸汽城市".to_string()], cover_image_src: Some("/b.png".to_string()), cover_asset_id: Some("asset-b".to_string()), + levels: Vec::new(), publication_status: PuzzlePublicationStatus::Published, updated_at_micros: 2, published_at_micros: Some(2), @@ -2327,6 +3213,8 @@ mod tests { recent_play_count_7d: 0, remix_count: 0, like_count: 0, + point_incentive_total_half_points: 0, + point_incentive_claimed_points: 0, publish_ready: true, anchor_pack: empty_anchor_pack(), source_session_id: None, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 9d5d2db4..834b7778 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -140,6 +140,22 @@ pub struct PublicWorkPlayDailyStat { pub(crate) updated_at: Timestamp, } +#[spacetimedb::table( + accessor = public_work_like, + index(accessor = by_public_work_like_work, btree(columns = [source_type, profile_id])), + index(accessor = by_public_work_like_user, btree(columns = [user_id])) +)] +pub struct PublicWorkLike { + #[primary_key] + pub(crate) like_id: String, + // 中文注释:source_type 与 play 统计保持同一套作品类型命名,确保跨玩法 profile_id 不会互相冲突。 + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) user_id: String, + pub(crate) liked_at: Timestamp, +} + pub(crate) struct ProfilePlayedWorkUpsertInput { pub(crate) user_id: String, pub(crate) world_key: String, @@ -158,6 +174,30 @@ pub(crate) struct PublicWorkPlayRecordInput { pub(crate) played_at_micros: i64, } +pub(crate) struct PublicWorkLikeRecordInput { + pub(crate) source_type: String, + pub(crate) owner_user_id: String, + pub(crate) profile_id: String, + pub(crate) user_id: String, + pub(crate) liked_at_micros: i64, +} + +pub(crate) struct ProfileSaveArchiveUpsertInput { + pub(crate) user_id: String, + pub(crate) world_key: String, + pub(crate) owner_user_id: Option, + pub(crate) profile_id: Option, + pub(crate) world_type: Option, + pub(crate) world_name: String, + pub(crate) subtitle: String, + pub(crate) summary_text: String, + pub(crate) cover_image_src: Option, + pub(crate) bottom_tab: String, + pub(crate) game_state_json: String, + pub(crate) current_story_json: Option, + pub(crate) saved_at_micros: i64, +} + #[spacetimedb::table(accessor = profile_membership)] pub struct ProfileMembership { #[primary_key] @@ -755,6 +795,53 @@ pub(crate) fn add_profile_observed_play_time( Ok(()) } +pub(crate) fn upsert_profile_save_archive( + ctx: &ReducerContext, + input: ProfileSaveArchiveUpsertInput, +) -> Result<(), String> { + let user_id = input.user_id.trim(); + let world_key = input.world_key.trim(); + if user_id.is_empty() || world_key.is_empty() { + return Err("profile_save_archive 参数不能为空".to_string()); + } + + let saved_at = Timestamp::from_micros_since_unix_epoch(input.saved_at_micros); + let archive_id = format!("{user_id}:{world_key}"); + let existing = ctx.db.profile_save_archive().archive_id().find(&archive_id); + let created_at = existing + .as_ref() + .map(|row| row.created_at) + .unwrap_or(saved_at); + + if let Some(existing) = existing { + ctx.db + .profile_save_archive() + .archive_id() + .delete(&existing.archive_id); + } + + ctx.db.profile_save_archive().insert(ProfileSaveArchive { + archive_id, + user_id: user_id.to_string(), + world_key: world_key.to_string(), + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_type: input.world_type, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + cover_image_src: input.cover_image_src, + saved_at, + bottom_tab: input.bottom_tab, + game_state_json: input.game_state_json, + current_story_json: input.current_story_json, + created_at, + updated_at: saved_at, + }); + + Ok(()) +} + pub(crate) fn record_public_work_play( ctx: &ReducerContext, input: PublicWorkPlayRecordInput, @@ -798,6 +885,39 @@ pub(crate) fn record_public_work_play( Ok(()) } +pub(crate) fn record_public_work_like( + ctx: &ReducerContext, + input: PublicWorkLikeRecordInput, +) -> Result { + let source_type = input.source_type.trim(); + let owner_user_id = input.owner_user_id.trim(); + let profile_id = input.profile_id.trim(); + let user_id = input.user_id.trim(); + if source_type.is_empty() + || owner_user_id.is_empty() + || profile_id.is_empty() + || user_id.is_empty() + { + return Err("public_work_like 参数不能为空".to_string()); + } + + let like_id = build_public_work_like_id(source_type, profile_id, user_id); + if ctx.db.public_work_like().like_id().find(&like_id).is_some() { + return Ok(false); + } + + ctx.db.public_work_like().insert(PublicWorkLike { + like_id, + source_type: source_type.to_string(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + user_id: user_id.to_string(), + liked_at: Timestamp::from_micros_since_unix_epoch(input.liked_at_micros), + }); + + Ok(true) +} + pub(crate) fn count_recent_public_work_plays( ctx: &ReducerContext, source_type: &str, @@ -837,6 +957,10 @@ fn build_public_work_play_daily_stat_id( format!("{source_type}:{profile_id}:{played_day}") } +fn build_public_work_like_id(source_type: &str, profile_id: &str, user_id: &str) -> String { + format!("{source_type}:{profile_id}:{user_id}") +} + fn ensure_profile_dashboard_state(ctx: &ReducerContext, user_id: &str, updated_at: Timestamp) { if ctx .db @@ -2079,6 +2203,24 @@ fn apply_profile_wallet_delta( ) } +pub(crate) fn grant_profile_wallet_points( + ctx: &ReducerContext, + user_id: &str, + amount_delta: u64, + source_type: RuntimeProfileWalletLedgerSourceType, + ledger_id: &str, + created_at: Timestamp, +) -> Result { + apply_profile_wallet_delta( + ctx, + user_id, + amount_delta, + source_type, + ledger_id, + created_at, + ) +} + fn apply_profile_wallet_adjustment( ctx: &ReducerContext, input: RuntimeProfileWalletAdjustmentInput, diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index a1a3d61a..08717ec0 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -88,13 +88,34 @@ vi.mock('./rpg-runtime-shell', () => ({ session, chrome, }: { - session: { gameState: { currentScenePreset?: { name?: string } | null } }; + session: { + gameState: { + currentScenePreset?: { id?: string; name?: string } | null; + playerCharacter?: { name?: string } | null; + runtimeSessionId?: string | null; + runtimeMode?: string; + runtimePersistenceDisabled?: boolean; + }; + currentStory?: { text?: string } | null; + }; chrome?: { hidePlayerLevelBadge?: boolean }; }) => (
幕预览运行时
{chrome?.hidePlayerLevelBadge ?
隐藏等级徽标
: null}
{session.gameState.currentScenePreset?.name ?? '未进入场景'}
+
{session.gameState.currentScenePreset?.id ?? '未进入场景ID'}
+
+ {session.gameState.playerCharacter ? '已选择预览角色' : '未选择角色'} +
+
{session.gameState.runtimeSessionId ?? '未设置预览会话'}
+
{session.gameState.runtimeMode ?? '未设置运行模式'}
+
+ {session.gameState.runtimePersistenceDisabled + ? '预览禁用持久化' + : '预览允许持久化'} +
+
{session.currentStory?.text ?? '未生成当前故事'}
), })); @@ -102,6 +123,30 @@ vi.mock('./rpg-runtime-shell', () => ({ vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({ fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }), saveCharacterWorkflowCache: vi.fn().mockResolvedValue(undefined), + resolveCharacterRoleAssetWorkflow: vi.fn(({ role }) => + Promise.resolve({ + ok: true, + cache: null, + workflow: { + role, + defaultPromptBundle: { + visualPromptText: '', + animationPromptText: '', + scenePromptText: '', + }, + visualPromptText: '', + animationPromptText: '', + animationPromptTextByKey: {}, + visualDrafts: [], + selectedVisualDraftId: '', + selectedAnimation: 'idle', + }, + }), + ), + putCharacterRoleAssetWorkflow: vi.fn().mockResolvedValue({ + ok: true, + cache: null, + }), generateCharacterVisualCandidates: vi.fn(), publishCharacterVisualAsset: vi.fn(), generateCharacterAnimationDraft: vi.fn(), @@ -1312,6 +1357,13 @@ test('场景幕预览会打开当前幕运行时面板', async () => { expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0); expect(screen.getByText('隐藏等级徽标')).toBeTruthy(); + expect(screen.getByText('已选择预览角色')).toBeTruthy(); + expect(screen.getByText('runtime-scene-act-preview')).toBeTruthy(); + expect(screen.getByText('landmark-1')).toBeTruthy(); + expect(screen.getByText('play')).toBeTruthy(); + expect(screen.getByText('预览禁用持久化')).toBeTruthy(); + expect(screen.getByText(/顾潮音已经在沉钟栈桥等你/u)).toBeTruthy(); + expect(screen.queryByText('正在载入这一幕的游戏流程...')).toBeNull(); await user.click(screen.getByRole('button', { name: '结束预览' })); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index ec1d162a..e884bb95 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, expect, test, vi } from 'vitest'; @@ -104,10 +104,15 @@ test('creation hub reflects updated draft title summary and counts after rerende expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy(); expect(screen.queryByText('角色 3')).toBeNull(); expect(screen.queryByText('地点 4')).toBeNull(); + const rpgButton = screen.getByRole('button', { name: /角色扮演/u }); + const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u }); expect( - screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }), + puzzleButton.compareDocumentPosition(rpgButton) & + Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); - expect(screen.getByRole('button', { name: /拼图.*创意礼物/u })).toBeTruthy(); + expect((rpgButton as HTMLButtonElement).disabled).toBe(true); + expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0); + expect(puzzleButton).toBeTruthy(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); rerender( @@ -185,6 +190,59 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to expect(screen.queryByText('我的拼图作品')).toBeNull(); }); +test('creation hub shows puzzle point incentive and claims without opening card', async () => { + const user = userEvent.setup(); + const onClaimPuzzlePointIncentive = vi.fn(); + const onOpenPuzzleDetail = vi.fn(); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + onClaimPuzzlePointIncentive={onClaimPuzzlePointIncentive} + />, + ); + + expect(screen.getByLabelText('积分激励总数 2.5 陶泥币')).toBeTruthy(); + expect(screen.getByLabelText('待领取积分 1 陶泥币')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '领取积分' })); + + expect(onClaimPuzzlePointIncentive).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'puzzle-profile-incentive' }), + ); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); +}); + test('creation hub shows RPG public work code from published library entry', () => { render( { expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('角色扮演'); - expect(html).toContain('剧情演绎,冒险成长'); + expect(html).toContain('敬请期待'); expect(html).toContain('拼图'); expect(html).toContain('创意礼物,生活分享'); expect(html).not.toContain('大鱼吃小鱼'); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 9d66627d..4db45f2a 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -47,6 +47,8 @@ type CustomWorldCreationHubProps = { puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null; + onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null; + claimingPuzzleProfileId?: string | null; }; function EmptyState({ title }: { title: string }) { @@ -131,6 +133,8 @@ export function CustomWorldCreationHub({ puzzleItems = [], onOpenPuzzleDetail, onDeletePuzzle = null, + onClaimPuzzlePointIncentive = null, + claimingPuzzleProfileId = null, }: CustomWorldCreationHubProps) { const [activeFilter, setActiveFilter] = useState('all'); @@ -222,6 +226,17 @@ export function CustomWorldCreationHub({ } } + function buildPointIncentiveAction(item: CreationWorkShelfItem) { + if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) { + return null; + } + + const sourceItem = item.source.item; + return () => { + onClaimPuzzlePointIncentive(sourceItem); + }; + } + return (
@@ -281,6 +296,11 @@ export function CustomWorldCreationHub({ onOpen={() => handleOpenShelfItem(item)} onDelete={buildDeleteAction(item)} deleteBusy={deletingWorkId === item.id} + onClaimPointIncentive={buildPointIncentiveAction(item)} + pointIncentiveBusy={ + item.source.kind === 'puzzle' && + claimingPuzzleProfileId === item.source.item.profileId + } /> ))}
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 41ecdf64..4b3036e9 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -13,6 +13,7 @@ import { type CreationWorkShelfMetric, type CreationWorkShelfMetricId, formatCreationMetricCount, + formatCreationPointIncentiveTotal, } from './creationWorkShelf'; type CustomWorldWorkCardProps = { @@ -21,6 +22,8 @@ type CustomWorldWorkCardProps = { onOpen: () => void; onDelete?: (() => void) | null; deleteBusy?: boolean; + onClaimPointIncentive?: (() => void) | null; + pointIncentiveBusy?: boolean; }; const BADGE_TONE_CLASS: Record = { @@ -189,12 +192,17 @@ export function CustomWorldWorkCard({ onOpen, onDelete = null, deleteBusy = false, + onClaimPointIncentive = null, + pointIncentiveBusy = false, }: CustomWorldWorkCardProps) { const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); const shareResetTimerRef = useRef(null); const isPublished = item.status === 'published'; + const canClaimPointIncentive = + Boolean(onClaimPointIncentive) && + (item.pointIncentive?.claimablePoints ?? 0) > 0; const displayTitle = formatPlatformWorkDisplayName(item.title); const { cardRef, deltas, displayValues, showGrowth } = usePublishedMetricAnimation( @@ -346,34 +354,81 @@ export function CustomWorldWorkCard({
{isPublished ? ( -
- {item.metrics.map((metric) => ( -
- - {metric.label} - - - - {formatCreationMetricCount( - displayValues[metric.id] ?? metric.value, +
+ {item.pointIncentive ? ( +
+
+ + 积分激励 + + + {formatCreationPointIncentiveTotal( + item.pointIncentive.totalPoints, )} - - {metric.unit} +
+
+ + 待领取 - - {showGrowth && deltas[metric.id] > 0 ? ( - - - {formatCreationMetricCount(deltas[metric.id])} + + {formatCreationMetricCount( + item.pointIncentive.claimablePoints, + )} - ) : null} +
+
- ))} + ) : null} + +
+ {item.metrics.map((metric) => ( +
+ + {metric.label} + + + + {formatCreationMetricCount( + displayValues[metric.id] ?? metric.value, + )} + + + {metric.unit} + + + {showGrowth && deltas[metric.id] > 0 ? ( + + + {formatCreationMetricCount(deltas[metric.id])} + + ) : null} +
+ ))} +
) : null}
diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 4a936ff2..3be8e6db 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -35,6 +35,12 @@ export type CreationWorkShelfMetric = { tone: CreationWorkShelfMetricTone; }; +export type CreationWorkShelfPointIncentive = { + totalHalfPoints: number; + totalPoints: number; + claimablePoints: number; +}; + export type CreationWorkShelfSource = | { kind: 'rpg'; @@ -66,6 +72,7 @@ export type CreationWorkShelfItem = { canShare: boolean; badges: CreationWorkShelfBadge[]; metrics: CreationWorkShelfMetric[]; + pointIncentive?: CreationWorkShelfPointIncentive; source: CreationWorkShelfSource; }; @@ -208,8 +215,11 @@ function mapPuzzleWorkToShelfItem( id: item.workId, kind: 'puzzle', status, - title: item.levelName, - summary: item.summary, + title: item.workTitle?.trim() || item.levelName.trim() || '未命名拼图', + summary: + item.workDescription?.trim() || + item.summary.trim() || + (status === 'draft' ? '未填写作品描述' : ''), updatedAt: item.updatedAt, coverImageSrc: item.coverImageSrc ?? null, coverRenderMode: 'image', @@ -235,6 +245,21 @@ function mapPuzzleWorkToShelfItem( likeCount: item.likeCount, }) : [], + pointIncentive: + status === 'published' + ? { + totalHalfPoints: normalizeMetricCount( + item.pointIncentiveTotalHalfPoints, + ), + totalPoints: normalizePointIncentiveTotal( + item.pointIncentiveTotalPoints, + item.pointIncentiveTotalHalfPoints, + ), + claimablePoints: normalizeMetricCount( + item.pointIncentiveClaimablePoints, + ), + } + : undefined, source: { kind: 'puzzle', item }, }; } @@ -283,6 +308,24 @@ export function formatCreationMetricCount(value?: number | null) { return `${normalized}`; } +export function formatCreationPointIncentiveTotal(value?: number | null) { + const normalized = Math.max(0, value ?? 0); + return Number.isInteger(normalized) + ? normalized.toFixed(0) + : normalized.toFixed(1); +} + +function normalizePointIncentiveTotal( + totalPoints?: number | null, + totalHalfPoints?: number | null, +) { + if (Number.isFinite(totalPoints)) { + return Math.max(0, totalPoints ?? 0); + } + + return normalizeMetricCount(totalHalfPoints) / 2; +} + function buildStatusBadge( status: CreationWorkShelfStatus, ): CreationWorkShelfBadge { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index b8a07043..9534bfa2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -39,6 +39,7 @@ import type { } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import type { PuzzleRunSnapshot, + PuzzleRuntimePropKind, SubmitPuzzleLeaderboardRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; @@ -47,6 +48,8 @@ import type { CustomWorldLibraryEntry, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveResumeResponse, + ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { @@ -64,6 +67,7 @@ import { streamBigFishCreationMessage, } from '../../services/big-fish-creation'; import { + likeBigFishGalleryWork, listBigFishGallery, remixBigFishGalleryWork, } from '../../services/big-fish-gallery'; @@ -103,11 +107,13 @@ import { } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail, + likePuzzleGalleryWork, listPuzzleGallery, remixPuzzleGalleryWork, } from '../../services/puzzle-gallery'; import { advanceLocalPuzzleNextLevel, + advancePuzzleNextLevel, getPuzzleRun, startPuzzleRun, submitPuzzleLeaderboard, @@ -117,23 +123,32 @@ import { import { applyLocalPuzzleFreezeTime, dragLocalPuzzlePiece, + extendLocalPuzzleTime, isLocalPuzzleRun, refreshLocalPuzzleTimer, + resolvePuzzleRestartLevelId, + restartLocalPuzzleLevel, setLocalPuzzlePaused, startLocalPuzzleRun, submitLocalPuzzleLeaderboard, swapLocalPuzzlePieces, } from '../../services/puzzle-runtime/puzzleLocalRuntime'; -import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works'; +import { + claimPuzzleWorkPointIncentive, + deletePuzzleWork, + listPuzzleWorks, +} from '../../services/puzzle-works'; import { deleteRpgCreationAgentSession } from '../../services/rpg-creation'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import { deleteRpgEntryWorldProfile, getRpgEntryWorldGalleryDetailByCode, + likeRpgEntryWorldGallery, recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; +import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { @@ -183,6 +198,23 @@ type PuzzleRuntimeReturnStage = type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; +type PuzzleSaveArchiveState = { + runtimeKind?: unknown; + entryProfileId?: unknown; + currentProfileId?: unknown; + currentLevelId?: unknown; +}; + +async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) { + return requestRpgRuntimeJson< + ProfileSaveArchiveResumeResponse + >( + `/profile/save-archives/${encodeURIComponent(worldKey)}`, + { method: 'POST' }, + '恢复拼图存档失败', + ); +} + type AgentResultBlockerView = { code?: string; message: string; @@ -211,6 +243,16 @@ function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) { return `${kind}:${entry.ownerUserId}:${entry.profileId}`; } +function isSamePlatformPublicGalleryEntry( + left: PlatformPublicGalleryCard, + right: PlatformPublicGalleryCard, +) { + return ( + getPlatformPublicGalleryEntryKey(left) === + getPlatformPublicGalleryEntryKey(right) + ); +} + function mergePlatformPublicGalleryEntries( rpgEntries: CustomWorldGalleryCard[], puzzleEntries: PlatformPublicGalleryCard[], @@ -269,7 +311,22 @@ function mapPublicWorkDetailToPuzzleWork( playCount: entry.playCount ?? 0, remixCount: entry.remixCount ?? 0, likeCount: entry.likeCount ?? 0, + pointIncentiveTotalHalfPoints: 0, + pointIncentiveClaimedPoints: 0, + pointIncentiveTotalPoints: 0, + pointIncentiveClaimablePoints: 0, publishReady: true, + levels: + entry.coverSlides?.map((slide, index) => ({ + levelId: slide.id || `puzzle-level-${index + 1}`, + levelName: slide.label, + pictureDescription: entry.summaryText, + candidates: [], + selectedCandidateId: null, + coverImageSrc: slide.imageSrc, + coverAssetId: null, + generationStatus: 'ready' as const, + })) ?? [], }; } @@ -290,6 +347,7 @@ function mapPublicWorkDetailToBigFishWork( workId: entry.workId, sourceSessionId: entry.profileId, ownerUserId: entry.ownerUserId, + authorDisplayName: entry.authorDisplayName, title: entry.worldName, subtitle: entry.subtitle, summary: entry.summaryText, @@ -308,6 +366,22 @@ function mapPublicWorkDetailToBigFishWork( }; } +function mergePuzzleWorkSummary( + current: PuzzleWorkSummary, + updated: PuzzleWorkSummary, +): PuzzleWorkSummary { + return current.profileId === updated.profileId ? updated : current; +} + +function mergeBigFishWorkSummary( + current: BigFishWorkSummary, + updated: BigFishWorkSummary, +): BigFishWorkSummary { + return current.sourceSessionId === updated.sourceSessionId + ? updated + : current; +} + async function resolvePublicWorkAuthorSummary( entry: PlatformPublicGalleryCard, ): Promise { @@ -466,15 +540,101 @@ function buildPuzzleResultProfileId(sessionId: string | null | undefined) { function buildPuzzleCompileActionFromFormPayload( payload: CreatePuzzleAgentSessionRequest | null, ): PuzzleAgentActionRequest { + const workTitle = payload?.workTitle?.trim() || payload?.seedText?.trim(); + const workDescription = payload?.workDescription?.trim(); + const pictureDescription = payload?.pictureDescription?.trim(); + return { action: 'compile_puzzle_draft', - promptText: - payload?.pictureDescription?.trim() || payload?.seedText?.trim(), + promptText: pictureDescription || workTitle, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), + ...(pictureDescription ? { pictureDescription } : {}), referenceImageSrc: payload?.referenceImageSrc || null, candidateCount: 1, }; } +function buildPuzzleFormPayloadFromSession( + session: PuzzleAgentSessionSnapshot, +): CreatePuzzleAgentSessionRequest { + const formDraft = session.draft?.formDraft; + const workTitle = + formDraft?.workTitle?.trim() || + session.draft?.workTitle?.trim() || + session.draft?.levelName?.trim() || + session.anchorPack.themePromise.value.trim() || + session.seedText?.trim() || + ''; + const workDescription = + formDraft?.workDescription?.trim() || + session.draft?.workDescription?.trim() || + session.draft?.summary?.trim() || + ''; + const pictureDescription = + formDraft?.pictureDescription?.trim() || + session.draft?.levels?.[0]?.pictureDescription?.trim() || + session.anchorPack.visualSubject.value.trim() || + ''; + + return { + seedText: workTitle, + workTitle, + workDescription, + pictureDescription, + referenceImageSrc: null, + }; +} + +function buildPuzzleFormPayloadFromAction( + payload: PuzzleAgentActionRequest, +): CreatePuzzleAgentSessionRequest | null { + if ( + payload.action !== 'compile_puzzle_draft' && + payload.action !== 'save_puzzle_form_draft' + ) { + return null; + } + + const workTitle = payload.workTitle?.trim() ?? ''; + const workDescription = payload.workDescription?.trim() ?? ''; + const pictureDescription = + payload.pictureDescription?.trim() || payload.promptText?.trim() || ''; + + return { + seedText: workTitle, + workTitle, + workDescription, + pictureDescription, + referenceImageSrc: + payload.action === 'compile_puzzle_draft' + ? (payload.referenceImageSrc ?? null) + : null, + }; +} + +function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) { + return Boolean( + session?.stage === 'collecting_anchors' && session.draft?.formDraft, + ); +} + +function isEmptyPuzzleFormOnlyDraft( + session: PuzzleAgentSessionSnapshot | null, +) { + if (!isPuzzleFormOnlyDraft(session)) { + return false; + } + + const formDraft = session?.draft?.formDraft; + return !( + session?.seedText?.trim() || + formDraft?.workTitle?.trim() || + formDraft?.workDescription?.trim() || + formDraft?.pictureDescription?.trim() + ); +} + const CustomWorldGenerationView = lazy(async () => { const module = await import('../CustomWorldGenerationView'); return { @@ -587,6 +747,24 @@ function mergePuzzleServiceRuntimeState( } const serviceLevel = serviceRun.currentLevel; + if ( + currentRun.currentLevel.status === 'cleared' && + serviceLevel.status !== 'cleared' + ) { + return { + ...currentRun, + recommendedNextProfileId: serviceRun.recommendedNextProfileId, + nextLevelMode: serviceRun.nextLevelMode, + nextLevelProfileId: serviceRun.nextLevelProfileId, + nextLevelId: serviceRun.nextLevelId, + recommendedNextWorks: serviceRun.recommendedNextWorks, + leaderboardEntries: + currentRun.currentLevel.leaderboardEntries.length > 0 + ? currentRun.currentLevel.leaderboardEntries + : currentRun.leaderboardEntries, + }; + } + const leaderboardEntries = serviceLevel.leaderboardEntries.length > 0 ? serviceLevel.leaderboardEntries @@ -594,9 +772,18 @@ function mergePuzzleServiceRuntimeState( return { ...currentRun, + recommendedNextProfileId: serviceRun.recommendedNextProfileId, + nextLevelMode: serviceRun.nextLevelMode, + nextLevelProfileId: serviceRun.nextLevelProfileId, + nextLevelId: serviceRun.nextLevelId, + recommendedNextWorks: serviceRun.recommendedNextWorks, leaderboardEntries, currentLevel: { ...currentRun.currentLevel, + status: serviceLevel.status, + startedAtMs: serviceLevel.startedAtMs, + clearedAtMs: serviceLevel.clearedAtMs, + elapsedMs: serviceLevel.elapsedMs, timeLimitMs: serviceLevel.timeLimitMs, remainingMs: serviceLevel.remainingMs, pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs, @@ -685,6 +872,8 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] = + useState(null); const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const [profilePlayStats, setProfilePlayStats] = useState(null); @@ -1191,6 +1380,10 @@ export function PlatformEntryFlowShellImpl({ onActionComplete: async ({ payload, response, setSession }) => { setPuzzleOperation(response.operation); setSession(response.session); + const formPayload = buildPuzzleFormPayloadFromAction(payload); + if (formPayload) { + setPuzzleFormDraftPayload(formPayload); + } if (payload.action === 'publish_puzzle_work') { await Promise.allSettled([ @@ -1233,6 +1426,11 @@ export function PlatformEntryFlowShellImpl({ } }, beforeExecuteAction: ({ payload }) => { + const formPayload = buildPuzzleFormPayloadFromAction(payload); + if (formPayload) { + setPuzzleFormDraftPayload(formPayload); + } + if (payload.action !== 'compile_puzzle_draft') { return; } @@ -1314,19 +1512,19 @@ export function PlatformEntryFlowShellImpl({ setPuzzleOperation(null); setPuzzleGenerationState(null); setPuzzleFormDraftPayload(null); - puzzleFlow.setSession(null); - puzzleFlow.setError(null); - puzzleFlow.setStreamingReplyText(''); - puzzleFlow.setIsStreamingReply(false); - enterCreateTab(); - setShowCreationTypeModal(false); - setSelectionStage('puzzle-agent-workspace'); - }, [enterCreateTab, puzzleFlow, setSelectionStage]); + const nextSession = await puzzleFlow.openWorkspace({}); + if (nextSession) { + void refreshPuzzleShelf(); + } + }, [puzzleFlow, refreshPuzzleShelf]); const createPuzzleDraftFromForm = useCallback( async (payload: CreatePuzzleAgentSessionRequest) => { setPuzzleFormDraftPayload(payload); - const nextSession = await puzzleFlow.openWorkspace(payload); + const nextSession = + puzzleFlow.session && !isEmptyPuzzleFormOnlyDraft(puzzleFlow.session) + ? puzzleFlow.session + : await puzzleFlow.openWorkspace(payload); if (!nextSession) { return; } @@ -1339,6 +1537,36 @@ export function PlatformEntryFlowShellImpl({ [puzzleFlow], ); + const savePuzzleFormDraft = useCallback( + async (payload: CreatePuzzleAgentSessionRequest) => { + const session = puzzleFlow.session; + if (!session || session.stage !== 'collecting_anchors') { + return; + } + + setPuzzleFormDraftPayload(payload); + + try { + const response = await executePuzzleAgentAction(session.sessionId, { + action: 'save_puzzle_form_draft', + promptText: payload.pictureDescription ?? null, + workTitle: payload.workTitle ?? payload.seedText ?? '', + workDescription: payload.workDescription ?? '', + pictureDescription: payload.pictureDescription ?? '', + }); + setPuzzleOperation(response.operation); + puzzleFlow.setSession(response.session); + setPuzzleError(null); + void refreshPuzzleShelf(); + } catch (error) { + setPuzzleError( + resolvePuzzleErrorMessage(error, '保存拼图表单草稿失败。'), + ); + } + }, + [puzzleFlow, refreshPuzzleShelf, resolvePuzzleErrorMessage, setPuzzleError], + ); + useEffect(() => { if (platformBootstrap.canReadProtectedData) { hadReadableProtectedDataRef.current = true; @@ -1379,6 +1607,7 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleNextLevelGenerating(false); setPuzzleError(null); setDeletingCreationWorkId(null); + setClaimingPuzzlePointIncentiveProfileId(null); setProfilePlayStats(null); setProfilePlayStatsError(null); setIsProfilePlayStatsOpen(false); @@ -1416,7 +1645,7 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if (type === 'airp' || type === 'visual-novel') { + if (type === 'rpg' || type === 'airp' || type === 'visual-novel') { return; } @@ -1424,13 +1653,6 @@ export function PlatformEntryFlowShellImpl({ return; } - if (type === 'rpg') { - runProtectedAction(() => { - void sessionController.openRpgAgentWorkspace(); - }); - return; - } - if (type === 'big-fish') { runProtectedAction(() => { void openBigFishAgentWorkspace(); @@ -1457,7 +1679,6 @@ export function PlatformEntryFlowShellImpl({ openPuzzleAgentWorkspace, prepareCreationLaunch, runProtectedAction, - sessionController, ], ); @@ -1494,6 +1715,35 @@ export function PlatformEntryFlowShellImpl({ const executePuzzleAction = puzzleFlow.executeAction; + const retryPuzzleDraftGeneration = useCallback(() => { + if (puzzleFormDraftPayload) { + void createPuzzleDraftFromForm(puzzleFormDraftPayload); + return; + } + + void executePuzzleAction( + buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload), + ); + }, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]); + + const executePuzzleWorkspaceAction = useCallback( + (payload: PuzzleAgentActionRequest) => { + if ( + payload.action === 'compile_puzzle_draft' && + isEmptyPuzzleFormOnlyDraft(puzzleFlow.session) + ) { + const formPayload = buildPuzzleFormPayloadFromAction(payload); + if (formPayload) { + void createPuzzleDraftFromForm(formPayload); + return; + } + } + + void executePuzzleAction(payload); + }, + [createPuzzleDraftFromForm, executePuzzleAction, puzzleFlow.session], + ); + useEffect(() => { if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) { setSelectionStage( @@ -1581,6 +1831,7 @@ export function PlatformEntryFlowShellImpl({ returnStage: PuzzleRuntimeReturnStage = 'work-detail', detailItem?: PuzzleWorkSummary, mirrorErrorToPublicDetail = false, + levelId?: string | null, ) => { if (isPuzzleBusy) { return; @@ -1592,11 +1843,15 @@ export function PlatformEntryFlowShellImpl({ try { const item = detailItem ?? (await getPuzzleGalleryDetail(profileId)).item; - const { run } = await startPuzzleRun({ profileId: item.profileId }); + const { run } = await startPuzzleRun({ + profileId: item.profileId, + levelId: levelId ?? null, + }); setSelectedPuzzleDetail(item); setPuzzleRun(run); setPuzzleRuntimeReturnStage(returnStage); setSelectionStage('puzzle-runtime'); + void platformBootstrap.refreshSaveArchives(); pushAppHistoryPath( buildPublicWorkStagePath( 'puzzle-runtime', @@ -1615,6 +1870,7 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, + platformBootstrap, resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, @@ -1635,6 +1891,8 @@ export function PlatformEntryFlowShellImpl({ ownerUserId: authUi?.user?.id ?? 'current-user', sourceSessionId: puzzleSession?.sessionId ?? null, authorDisplayName: authUi?.user?.displayName ?? '玩家', + workTitle: draft.workTitle || draft.levelName, + workDescription: draft.workDescription || draft.summary, levelName: draft.levelName, summary: draft.summary, themeTags: draft.themeTags, @@ -1646,7 +1904,12 @@ export function PlatformEntryFlowShellImpl({ playCount: 0, remixCount: 0, likeCount: 0, + pointIncentiveTotalHalfPoints: 0, + pointIncentiveClaimedPoints: 0, + pointIncentiveTotalPoints: 0, + pointIncentiveClaimablePoints: 0, publishReady: Boolean(puzzleSession?.resultPreview?.publishReady), + levels: draft.levels, } satisfies PuzzleWorkSummary; }, [ @@ -1745,9 +2008,7 @@ export function PlatformEntryFlowShellImpl({ } const timerId = window.setInterval(() => { - if (!isLocalPuzzleRun(puzzleRun)) { - return; - } + // 中文注释:正式 run 的棋盘交互也在前端即时裁决,倒计时展示同样走本地时钟;超时落库仍由 onTimeExpired 拉取后端快照完成。 setPuzzleRun((currentRun) => currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, ); @@ -1774,7 +2035,9 @@ export function PlatformEntryFlowShellImpl({ paused, }); setPuzzleRun((currentRun) => - currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + currentRun + ? mergePuzzleServiceRuntimeState(currentRun, run) + : currentRun, ); void platformBootstrap.refreshProfileDashboard(); } catch (error) { @@ -1789,7 +2052,7 @@ export function PlatformEntryFlowShellImpl({ const syncPuzzleRuntimeTimeout = useCallback(async () => { if ( !puzzleRun?.currentLevel || - puzzleRun.currentLevel.status !== 'playing' + puzzleRun.currentLevel.status === 'cleared' ) { return; } @@ -1804,21 +2067,28 @@ export function PlatformEntryFlowShellImpl({ try { const { run } = await getPuzzleRun(puzzleRun.runId); setPuzzleRun((currentRun) => - currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + currentRun + ? mergePuzzleServiceRuntimeState(currentRun, run) + : currentRun, ); + void platformBootstrap.refreshSaveArchives(); } catch (error) { setPuzzleError( resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'), ); } - }, [puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]); + }, [platformBootstrap, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]); const usePuzzleProp = useCallback( - async (propKind: 'hint' | 'reference' | 'freezeTime') => { - if ( - !puzzleRun?.currentLevel || - puzzleRun.currentLevel.status !== 'playing' - ) { + async (propKind: PuzzleRuntimePropKind) => { + if (!puzzleRun?.currentLevel) { + return null; + } + const canUseProp = + propKind === 'extendTime' + ? puzzleRun.currentLevel.status !== 'cleared' + : puzzleRun.currentLevel.status === 'playing'; + if (!canUseProp) { return null; } @@ -1828,7 +2098,9 @@ export function PlatformEntryFlowShellImpl({ return null; } const nextRun = - propKind === 'freezeTime' + propKind === 'extendTime' + ? extendLocalPuzzleTime(currentRun) + : propKind === 'freezeTime' ? applyLocalPuzzleFreezeTime(currentRun) : setLocalPuzzlePaused(currentRun, propKind === 'reference'); puzzleRunRef.current = nextRun; @@ -1846,11 +2118,107 @@ export function PlatformEntryFlowShellImpl({ puzzleRunRef.current = nextRun; setPuzzleRun(nextRun); void platformBootstrap.refreshProfileDashboard(); + void platformBootstrap.refreshSaveArchives(); return nextRun; }, [platformBootstrap, puzzleRun], ); + const restartPuzzleCurrentLevel = useCallback(async () => { + const currentLevel = puzzleRun?.currentLevel ?? null; + if (!puzzleRun || !currentLevel || isPuzzleBusy) { + return; + } + + setPuzzleError(null); + const restartLevelId = resolvePuzzleRestartLevelId( + puzzleRun, + selectedPuzzleDetail, + ); + if (isLocalPuzzleRun(puzzleRun)) { + const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun); + puzzleRunRef.current = nextRun; + setPuzzleRun(nextRun); + return; + } + + await startPuzzleRunFromProfile( + currentLevel.profileId, + puzzleRuntimeReturnStage, + selectedPuzzleDetail?.profileId === currentLevel.profileId + ? selectedPuzzleDetail + : undefined, + false, + restartLevelId, + ); + }, [ + isPuzzleBusy, + puzzleRun, + puzzleRuntimeReturnStage, + selectedPuzzleDetail, + setPuzzleError, + startPuzzleRunFromProfile, + ]); + + const resumePuzzleSaveArchive = useCallback( + async (entry: ProfileSaveArchiveSummary) => { + if (isPuzzleBusy) { + return; + } + + setIsPuzzleBusy(true); + setPuzzleError(null); + platformBootstrap.setSaveError(null); + + try { + const resumedArchive = await resumePuzzleProfileSaveArchiveRaw( + entry.worldKey, + ); + platformBootstrap.setSaveEntries((currentEntries) => + currentEntries.map((currentEntry) => + currentEntry.worldKey === resumedArchive.entry.worldKey + ? resumedArchive.entry + : currentEntry, + ), + ); + const gameState = resumedArchive.snapshot.gameState; + const profileId = + typeof gameState.currentProfileId === 'string' && + gameState.currentProfileId.trim() + ? gameState.currentProfileId + : typeof gameState.entryProfileId === 'string' && + gameState.entryProfileId.trim() + ? gameState.entryProfileId + : (entry.profileId ?? entry.worldKey.replace(/^puzzle:/u, '')); + const levelId = + typeof gameState.currentLevelId === 'string' && + gameState.currentLevelId.trim() + ? gameState.currentLevelId + : null; + await startPuzzleRunFromProfile( + profileId, + 'platform', + undefined, + false, + levelId, + ); + } catch (error) { + platformBootstrap.setSaveError( + resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'), + ); + } finally { + setIsPuzzleBusy(false); + } + }, + [ + isPuzzleBusy, + platformBootstrap, + resolvePuzzleErrorMessage, + setPuzzleError, + startPuzzleRunFromProfile, + ], + ); + useEffect(() => { const currentLevel = puzzleRun?.currentLevel ?? null; if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') { @@ -1879,7 +2247,27 @@ export function PlatformEntryFlowShellImpl({ if (isLocalPuzzleRun(puzzleRun)) { setPuzzleRun(submitLocalPuzzleLeaderboard(puzzleRun, payload.nickname)); - setIsPuzzleLeaderboardBusy(false); + void advanceLocalPuzzleNextLevel({ + run: puzzleRun, + sourceSessionId: + selectedPuzzleDetail?.sourceSessionId ?? + puzzleSession?.sessionId ?? + null, + }) + .then(({ run }) => { + setPuzzleRun((currentRun) => { + if (!currentRun) { + return currentRun; + } + return mergePuzzleServiceRuntimeState(currentRun, run); + }); + }) + .catch(() => { + // 中文注释:本地试玩缺少后端候选时保留本地排行榜和既有下一关入口,避免结算被探测请求打断。 + }) + .finally(() => { + setIsPuzzleLeaderboardBusy(false); + }); return; } @@ -1891,6 +2279,7 @@ export function PlatformEntryFlowShellImpl({ } return mergePuzzleServiceRuntimeState(currentRun, run); }); + void platformBootstrap.refreshSaveArchives(); }) .catch((error) => { submittedPuzzleLeaderboardKeysRef.current.delete(submitKey); @@ -1903,13 +2292,19 @@ export function PlatformEntryFlowShellImpl({ }); }, [ authUi?.user?.displayName, + platformBootstrap, puzzleRun, + puzzleSession, resolvePuzzleErrorMessage, + selectedPuzzleDetail, setPuzzleError, ]); - const advancePuzzleLevel = useCallback(async () => { - if (!puzzleRun || isPuzzleBusy) { + const advancePuzzleLevel = useCallback(async (target?: { + profileId?: string; + levelId?: string | null; + }) => { + if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { return; } @@ -1923,14 +2318,34 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { - const { run } = await advanceLocalPuzzleNextLevel({ - run: puzzleRun, - sourceSessionId: - selectedPuzzleDetail?.sourceSessionId ?? - puzzleSession?.sessionId ?? + const targetProfileId = target?.profileId?.trim(); + if ( + targetProfileId && + targetProfileId !== currentLevel.profileId && + puzzleRun.nextLevelMode === 'similarWorks' + ) { + await startPuzzleRunFromProfile( + targetProfileId, + 'puzzle-gallery-detail', + undefined, + false, null, - }); + ); + return; + } + const { run } = isLocalPuzzleRun(puzzleRun) + ? await advanceLocalPuzzleNextLevel({ + run: puzzleRun, + sourceSessionId: + selectedPuzzleDetail?.sourceSessionId ?? + puzzleSession?.sessionId ?? + null, + }) + : await advancePuzzleNextLevel(puzzleRun.runId); setPuzzleRun(run); + if (!isLocalPuzzleRun(puzzleRun)) { + void platformBootstrap.refreshSaveArchives(); + } } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); } finally { @@ -1939,10 +2354,13 @@ export function PlatformEntryFlowShellImpl({ } }, [ isPuzzleBusy, + isPuzzleLeaderboardBusy, + platformBootstrap, puzzleRun, puzzleSession, resolvePuzzleErrorMessage, selectedPuzzleDetail, + startPuzzleRunFromProfile, ]); const leaveAgentWorkspace = useCallback(() => { @@ -2144,14 +2562,17 @@ export function PlatformEntryFlowShellImpl({ } runProtectedAction(() => { + const displayName = + work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; const confirmed = window.confirm( - `确认删除作品《${work.levelName}》吗?删除后会从你的作品列表和公开广场中移除。`, + `确认删除作品《${displayName}》吗?删除后会从你的作品列表和公开广场中移除。`, ); if (!confirmed) { return; } setDeletingCreationWorkId(work.workId); + setPuzzleFormDraftPayload(null); setPuzzleError(null); void deletePuzzleWork(work.profileId) @@ -2217,6 +2638,187 @@ export function PlatformEntryFlowShellImpl({ [setSelectionStage], ); + const syncUpdatedPublicWorkDetail = useCallback( + (updatedEntry: PlatformPublicGalleryCard) => { + setSelectedPublicWorkDetail((current) => + current && isSamePlatformPublicGalleryEntry(current, updatedEntry) + ? updatedEntry + : current, + ); + }, + [], + ); + + const handleClaimPuzzlePointIncentive = useCallback( + (work: PuzzleWorkSummary) => { + if (claimingPuzzlePointIncentiveProfileId) { + return; + } + + runProtectedAction(() => { + setClaimingPuzzlePointIncentiveProfileId(work.profileId); + setPuzzleError(null); + + void claimPuzzleWorkPointIncentive(work.profileId) + .then((response) => { + const updatedWork = response.item; + setPuzzleWorks((current) => + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), + ); + setPuzzleGalleryEntries((current) => + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), + ); + setSelectedPuzzleDetail((current) => + current ? mergePuzzleWorkSummary(current, updatedWork) : current, + ); + syncUpdatedPublicWorkDetail( + mapPuzzleWorkToPublicWorkDetail(updatedWork), + ); + void platformBootstrap.refreshProfileDashboard(); + }) + .catch((error) => { + setPuzzleError( + resolvePuzzleErrorMessage(error, '领取拼图积分激励失败。'), + ); + }) + .finally(() => { + setClaimingPuzzlePointIncentiveProfileId(null); + }); + }); + }, + [ + claimingPuzzlePointIncentiveProfileId, + platformBootstrap, + resolvePuzzleErrorMessage, + runProtectedAction, + setPuzzleError, + syncUpdatedPublicWorkDetail, + ], + ); + + const likePublicWork = useCallback( + (entry: PlatformPublicGalleryCard) => { + if (isPublicWorkDetailBusy) { + return; + } + + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + setPublicWorkDetailError(null); + + if (isBigFishGalleryEntry(entry)) { + void likeBigFishGalleryWork(entry.profileId) + .then((response) => { + const updatedWork = response.items.find( + (item) => item.sourceSessionId === entry.profileId, + ); + if (!updatedWork) { + return; + } + setBigFishGalleryEntries((current) => + current.map((item) => + mergeBigFishWorkSummary(item, updatedWork), + ), + ); + setBigFishWorks((current) => + current.map((item) => + mergeBigFishWorkSummary(item, updatedWork), + ), + ); + syncUpdatedPublicWorkDetail( + mapBigFishWorkToPublicWorkDetail(updatedWork), + ); + setBigFishRuntimeWork((current) => + current + ? mergeBigFishWorkSummary(current, updatedWork) + : current, + ); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveBigFishErrorMessage(error, '点赞大鱼吃小鱼作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + return; + } + + if (isPuzzleGalleryEntry(entry)) { + void likePuzzleGalleryWork(entry.profileId) + .then((response) => { + const updatedWork = response.item; + setPuzzleGalleryEntries((current) => + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), + ); + setPuzzleWorks((current) => + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), + ); + setSelectedPuzzleDetail((current) => + current + ? mergePuzzleWorkSummary(current, updatedWork) + : current, + ); + syncUpdatedPublicWorkDetail( + mapPuzzleWorkToPublicWorkDetail(updatedWork), + ); + }) + .catch((error) => { + setPublicWorkDetailError( + resolvePuzzleErrorMessage(error, '点赞拼图作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + return; + } + + void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) + .then((updatedEntry) => { + setSelectedDetailEntry((current) => + current?.profileId === updatedEntry.profileId + ? updatedEntry + : current, + ); + platformBootstrap.setPublishedGalleryEntries((current) => + current.map((item) => + item.profileId === updatedEntry.profileId ? updatedEntry : item, + ), + ); + syncUpdatedPublicWorkDetail( + mapRpgGalleryCardToPublicWorkDetail(updatedEntry), + ); + }) + .catch((error) => { + setPublicWorkDetailError( + resolveRpgCreationErrorMessage(error, '点赞 RPG 作品失败。'), + ); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + }); + }); + }, + [ + isPublicWorkDetailBusy, + platformBootstrap, + resolveBigFishErrorMessage, + resolvePuzzleErrorMessage, + runProtectedAction, + syncUpdatedPublicWorkDetail, + ], + ); + useEffect(() => { const detailEntry = selectionStage === 'work-detail' @@ -2366,9 +2968,25 @@ export function PlatformEntryFlowShellImpl({ ); if (!restoredSession) { await refreshPuzzleShelf().catch(() => undefined); + return; + } + + if (isPuzzleFormOnlyDraft(restoredSession)) { + setPuzzleFormDraftPayload( + buildPuzzleFormPayloadFromSession(restoredSession), + ); + setSelectionStage('puzzle-agent-workspace'); + } else { + setPuzzleFormDraftPayload(null); } }, - [openPuzzleDetail, puzzleFlow, refreshPuzzleShelf, setPuzzleError], + [ + openPuzzleDetail, + puzzleFlow, + refreshPuzzleShelf, + setPuzzleError, + setSelectionStage, + ], ); const startBigFishRunFromWork = useCallback( @@ -2771,6 +3389,7 @@ export function PlatformEntryFlowShellImpl({ workId: `big-fish:${sessionId}`, sourceSessionId: sessionId, ownerUserId: work.ownerUserId ?? '', + authorDisplayName: work.worldSubtitle || '玩家', title: work.worldTitle, subtitle: work.worldSubtitle, summary: work.worldSubtitle, @@ -2996,6 +3615,10 @@ export function PlatformEntryFlowShellImpl({ onDeletePuzzle={(item) => { handleDeletePuzzleWork(item); }} + onClaimPuzzlePointIncentive={(item) => { + handleClaimPuzzlePointIncentive(item); + }} + claimingPuzzleProfileId={claimingPuzzlePointIncentiveProfileId} /> ); @@ -3040,6 +3663,13 @@ export function PlatformEntryFlowShellImpl({ createTabContent={creationHubContent} onContinueGame={handleContinueGame} onResumeSave={(entry) => { + if ( + (entry.worldType ?? '').toLowerCase() === 'puzzle' || + entry.worldKey.startsWith('puzzle:') + ) { + void resumePuzzleSaveArchive(entry); + return; + } void platformBootstrap.handleResumeSaveEntry(entry); }} onOpenCreateWorld={openCreationTypePicker} @@ -3105,6 +3735,7 @@ export function PlatformEntryFlowShellImpl({ { + likePublicWork(selectedPublicWorkDetail); + }} onStart={startSelectedPublicWork} onRemix={remixSelectedPublicWork} /> @@ -3141,6 +3775,9 @@ export function PlatformEntryFlowShellImpl({ { @@ -3148,6 +3785,11 @@ export function PlatformEntryFlowShellImpl({ clearSelectedPublicWorkAuthor(); entryNavigation.backToPlatformHome(); }} + onLike={() => { + likePublicWork( + mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry), + ); + }} onStart={handleStartSelectedWorld} onRemix={() => { remixPublicWork( @@ -3469,12 +4111,15 @@ export function PlatformEntryFlowShellImpl({ void submitPuzzleMessage(payload); }} onExecuteAction={(payload) => { - void executePuzzleAction(payload); + executePuzzleWorkspaceAction(payload); }} initialFormPayload={puzzleFormDraftPayload} onCreateFromForm={(payload) => { void createPuzzleDraftFromForm(payload); }} + onAutoSaveForm={(payload) => { + void savePuzzleFormDraft(payload); + }} /> @@ -3497,6 +4142,7 @@ export function PlatformEntryFlowShellImpl({ } anchorEntries={buildPuzzleGenerationAnchorEntries( puzzleSession, + puzzleFormDraftPayload, )} progress={buildMiniGameDraftGenerationProgress( puzzleGenerationState, @@ -3507,13 +4153,7 @@ export function PlatformEntryFlowShellImpl({ onEditSetting={() => { setSelectionStage('puzzle-agent-workspace'); }} - onRetry={() => { - void executePuzzleAction( - buildPuzzleCompileActionFromFormPayload( - puzzleFormDraftPayload, - ), - ); - }} + onRetry={retryPuzzleDraftGeneration} onInterrupt={undefined} backLabel="返回创作中心" settingActionLabel={null} @@ -3529,36 +4169,38 @@ export function PlatformEntryFlowShellImpl({ )} - {selectionStage === 'puzzle-result' && puzzleSession?.draft && ( - - } + {selectionStage === 'puzzle-result' && + puzzleSession?.draft && + !isPuzzleFormOnlyDraft(puzzleSession) && ( + - { - setSelectionStage('puzzle-agent-workspace'); - }} - onExecuteAction={(payload) => { - void executePuzzleAction(payload); - }} - onStartTestRun={startPuzzleTestRunFromDraft} - /> - - - )} + } + > + { + setSelectionStage('puzzle-agent-workspace'); + }} + onExecuteAction={(payload) => { + void executePuzzleAction(payload); + }} + onStartTestRun={startPuzzleTestRunFromDraft} + /> + + + )} {selectionStage === 'puzzle-gallery-detail' && selectedPuzzleDetail && ( { void dragPuzzlePiece(payload); }} - onAdvanceNextLevel={() => { - void advancePuzzleLevel(); + onAdvanceNextLevel={(target) => { + void advancePuzzleLevel(target); + }} + onRestartLevel={() => { + void restartPuzzleCurrentLevel(); }} onPauseChange={setPuzzleRuntimePaused} onUseProp={usePuzzleProp} @@ -3907,9 +4552,7 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(false); }} onSelectRpg={() => { - runProtectedAction(() => { - void sessionController.openRpgAgentWorkspace(); - }); + // RPG 创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。 }} onSelectBigFish={() => { runProtectedAction(() => { diff --git a/src/components/platform-entry/PlatformWorkDetailView.test.tsx b/src/components/platform-entry/PlatformWorkDetailView.test.tsx index c82bcb3f..22bf57d5 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.test.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.test.tsx @@ -1,12 +1,34 @@ /* @vitest-environment jsdom */ -import { render, screen } from '@testing-library/react'; -import { expect, test, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { act } from 'react'; +import { afterEach, expect, test, vi } from 'vitest'; -import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; +import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation'; import { PlatformWorkDetailView } from './PlatformWorkDetailView'; -function createPuzzleEntry(): PlatformPublicGalleryCard { +vi.mock('../ResolvedAssetImage', () => ({ + ResolvedAssetImage: ({ + src, + alt, + className, + 'aria-hidden': ariaHidden, + }: { + src?: string | null; + alt?: string; + className?: string; + 'aria-hidden'?: boolean | 'true' | 'false'; + }) => ( + {alt + ), +})); + +function createPuzzleEntry(): PlatformPuzzleGalleryCard { return { sourceType: 'puzzle', workId: 'work-1', @@ -18,6 +40,7 @@ function createPuzzleEntry(): PlatformPublicGalleryCard { subtitle: '拼图关卡', summaryText: '适合公开游玩的拼图作品。', coverImageSrc: null, + coverSlides: [], themeTags: ['拼图'], playCount: 12, remixCount: 3, @@ -29,13 +52,18 @@ function createPuzzleEntry(): PlatformPublicGalleryCard { }; } -test('PlatformWorkDetailView renders compact stats and recent update time', () => { +afterEach(() => { + vi.useRealTimers(); +}); + +test('PlatformWorkDetailView renders compact stats and date time', () => { render( , @@ -43,12 +71,101 @@ test('PlatformWorkDetailView renders compact stats and recent update time', () = expect(screen.getByText('改造')).toBeTruthy(); expect(screen.getByText('游玩')).toBeTruthy(); - expect(screen.getByText('点赞')).toBeTruthy(); - expect(screen.getByText('最近更新')).toBeTruthy(); + expect(screen.getAllByText('点赞').length).toBeGreaterThanOrEqual(2); + expect(screen.getByText('日期')).toBeTruthy(); expect(screen.queryByText('改造次数')).toBeNull(); expect(screen.queryByText('游玩次数')).toBeNull(); expect(screen.queryByText('上线日期')).toBeNull(); + expect(screen.queryByText('最近更新')).toBeNull(); expect(screen.getByText('2026-04-25')).toBeTruthy(); expect(screen.getAllByText('次')).toHaveLength(2); expect(screen.getByText('赞')).toBeTruthy(); + expect(screen.getByRole('button', { name: '点赞 4赞' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '作品改造' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '启动' })).toBeTruthy(); +}); + +test('PlatformWorkDetailView prefers resolved public user display name', () => { + render( + , + ); + + expect(screen.getByText('新的作者昵称')).toBeTruthy(); + expect(screen.queryByText('137****6613')).toBeNull(); +}); + +test('PlatformWorkDetailView calls like handler', () => { + const onLike = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '点赞 4赞' })); + + expect(onLike).toHaveBeenCalledTimes(1); +}); + +test('PlatformWorkDetailView cycles puzzle level cover slides', () => { + vi.useFakeTimers(); + render( + , + ); + + expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe( + '/level-1.png', + ); + + fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' })); + + expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe( + '/level-2.png', + ); + + act(() => { + vi.advanceTimersByTime(4200); + }); + + expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe( + '/level-1.png', + ); }); diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index da850839..0ff44823 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -1,5 +1,7 @@ import { ArrowLeft, + ChevronLeft, + ChevronRight, Clock3, Copy, Gamepad2, @@ -8,7 +10,7 @@ import { Play, Share2, } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import { copyTextToClipboard } from '../../services/clipboard'; @@ -20,16 +22,18 @@ import { formatPlatformWorldTime, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, - resolvePlatformWorldCoverImage, + resolvePlatformWorldCoverSlides, resolvePlatformWorldStats, } from '../rpg-entry/rpgEntryWorldPresentation'; export interface PlatformWorkDetailViewProps { entry: PlatformPublicGalleryCard; authorAvatarUrl?: string | null; + authorDisplayName?: string | null; isBusy: boolean; error: string | null; onBack: () => void; + onLike: () => void; onStart: () => void; onRemix: () => void; } @@ -56,18 +60,32 @@ function getAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '作')[0] ?? '作'; } +const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; + export function PlatformWorkDetailView({ entry, authorAvatarUrl, + authorDisplayName, isBusy, error, onBack, + onLike, onStart, onRemix, }: PlatformWorkDetailViewProps) { - const coverImage = resolvePlatformWorldCoverImage(entry); + const coverSlides = useMemo( + () => resolvePlatformWorldCoverSlides(entry), + [entry], + ); + const [activeCoverIndex, setActiveCoverIndex] = useState(0); + const activeCoverSlide = + coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null; + const coverImage = activeCoverSlide?.imageSrc ?? ''; + const hasCoverCarousel = coverSlides.length > 1; const publicWorkCode = resolvePlatformPublicWorkCode(entry); const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? ''; + const resolvedAuthorDisplayName = + authorDisplayName?.trim() || entry.authorDisplayName; const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( 'idle', ); @@ -85,13 +103,6 @@ export function PlatformWorkDetailView({ ); const stats = resolvePlatformWorldStats(entry); const statItems = [ - { - label: '改造', - value: formatCompactCount(stats.remixCount), - unit: '次', - icon: GitFork, - tone: 'remix', - }, { label: '游玩', value: formatCompactCount(stats.playCount), @@ -99,6 +110,13 @@ export function PlatformWorkDetailView({ icon: Gamepad2, tone: 'play', }, + { + label: '改造', + value: formatCompactCount(stats.remixCount), + unit: '次', + icon: GitFork, + tone: 'remix', + }, { label: '点赞', value: formatCompactCount(stats.likeCount), @@ -107,7 +125,7 @@ export function PlatformWorkDetailView({ tone: 'like', }, { - label: '最近更新', + label: '日期', value: formatPlatformWorldTime(stats.updatedAt ?? stats.publishedAt), icon: Clock3, tone: 'time', @@ -115,6 +133,46 @@ export function PlatformWorkDetailView({ }, ]; + useEffect(() => { + setActiveCoverIndex(0); + }, [entry.profileId, coverSlides.length]); + + useEffect(() => { + setActiveCoverIndex((current) => + coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0, + ); + }, [coverSlides.length]); + + useEffect(() => { + if (!hasCoverCarousel) { + return undefined; + } + + const timerId = window.setInterval(() => { + setActiveCoverIndex((current) => (current + 1) % coverSlides.length); + }, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS); + + return () => { + window.clearInterval(timerId); + }; + }, [coverSlides.length, hasCoverCarousel]); + + const showPreviousCover = () => { + if (!hasCoverCarousel) { + return; + } + setActiveCoverIndex( + (current) => (current - 1 + coverSlides.length) % coverSlides.length, + ); + }; + + const showNextCover = () => { + if (!hasCoverCarousel) { + return; + } + setActiveCoverIndex((current) => (current + 1) % coverSlides.length); + }; + const copyPublicWorkCode = () => { if (!publicWorkCode) { return; @@ -178,6 +236,46 @@ export function PlatformWorkDetailView({ alt={entry.worldName} className="platform-work-detail__cover-image" /> + {hasCoverCarousel ? ( + <> + + +
+ {coverSlides.map((slide, index) => ( +
+ + ) : null} ) : (
@@ -199,9 +297,7 @@ export function PlatformWorkDetailView({ )}
-
- {displayName} -
+
{displayName}
{normalizedAuthorAvatarUrl ? ( @@ -213,23 +309,25 @@ export function PlatformWorkDetailView({ /> ) : ( - {getAuthorAvatarLabel(entry.authorDisplayName)} + {getAuthorAvatarLabel(resolvedAuthorDisplayName)} )} - {entry.authorDisplayName} + {resolvedAuthorDisplayName}
@@ -300,6 +398,15 @@ export function PlatformWorkDetailView({
+