From fe02603ba11f0d639fa4410adc3ddcf05f0a2666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 1 May 2026 00:33:39 +0800 Subject: [PATCH] 1 --- ...TION_NEW_WORK_COMPACT_LAYOUT_2026-04-24.md | 1 + ...PLATFORM_BIG_FISH_ENTRY_HIDE_2026-04-28.md | 2 +- ...WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md | 20 +- ...ADER_AND_CLEAR_EFFECT_DESIGN_2026-04-27.md | 8 + ..._AND_INLINE_FUNCTION_OPTIONS_2026-04-25.md | 33 +- .../MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md | 26 +- ...TOP_HOME_MODULE_CONTENT_SYNC_2026-04-30.md | 22 + ...E_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md | 95 +++ .../PUZZLE_FORM_CREATION_FLOW_2026-04-29.md | 28 +- ...VEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md | 74 +++ ...LE_RUNTIME_DRAG_RESPONSE_FIX_2026-04-27.md | 10 + docs/technical/README.md | 3 + .../ROUTE_IMAGE_READY_GATE_2026-04-25.md | 2 + ...TION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md | 9 +- ...EPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md | 88 +++ .../src/contracts/puzzleAgentActions.ts | 1 + .../src/contracts/puzzleRuntimeSession.ts | 22 +- .../shared/src/contracts/puzzleWorkSummary.ts | 2 +- scripts/dev-rust-stack.sh | 24 + server-rs/crates/api-server/src/assets.rs | 60 +- .../api-server/src/prompt/puzzle/draft.rs | 86 +++ .../api-server/src/prompt/puzzle/image.rs | 15 +- .../api-server/src/prompt/puzzle/mod.rs | 1 + .../api-server/src/prompt/rpg/runtime_chat.rs | 95 ++- server-rs/crates/api-server/src/puzzle.rs | 599 +++++++++++++++--- .../crates/api-server/src/runtime_chat.rs | 97 ++- server-rs/crates/module-puzzle/src/lib.rs | 389 +++++++++++- .../shared-contracts/src/puzzle_gallery.rs | 4 +- .../shared-contracts/src/puzzle_runtime.rs | 22 + .../shared-contracts/src/puzzle_works.rs | 3 +- server-rs/crates/spacetime-client/src/lib.rs | 15 +- .../crates/spacetime-client/src/mapper.rs | 40 +- ...abase_migration_import_chunks_procedure.rs | 58 ++ ...abase_migration_import_chunk_input_type.rs | 26 + .../database_migration_import_chunk_type.rs | 80 +++ ...igration_import_chunks_clear_input_type.rs | 23 + ...base_migration_import_chunks_input_type.rs | 26 + ...rt_database_migration_to_file_procedure.rs | 2 +- ...atabase_migration_from_chunks_procedure.rs | 58 ++ ...ation_incremental_from_chunks_procedure.rs | 58 ++ .../src/module_bindings/mod.rs | 16 + ...tabase_migration_import_chunk_procedure.rs | 58 ++ ...puzzle_generated_images_save_input_type.rs | 1 + .../crates/spacetime-client/src/puzzle.rs | 1 + .../spacetime-module/src/big_fish/session.rs | 6 +- server-rs/crates/spacetime-module/src/lib.rs | 5 +- .../crates/spacetime-module/src/puzzle.rs | 251 ++++++-- .../spacetime-module/src/runtime/profile.rs | 63 ++ ...ustomWorldCreationHub.interaction.test.tsx | 7 +- .../PlatformEntryFlowShellImpl.tsx | 356 +++++++++-- .../PlatformWorkDetailView.test.tsx | 81 ++- .../platform-entry/PlatformWorkDetailView.tsx | 98 ++- .../platformEntryCreationTypes.ts | 10 +- .../puzzle-agent/PuzzleAgentWorkspace.tsx | 7 +- .../PuzzleGalleryDetailView.test.tsx | 78 ++- .../PuzzleGalleryDetailView.tsx | 119 +++- .../puzzle-result/PuzzleResultView.test.tsx | 70 +- .../puzzle-result/PuzzleResultView.tsx | 316 +++++---- .../PuzzleRuntimeShell.test.tsx | 233 ++++++- .../puzzle-runtime/PuzzleRuntimeShell.tsx | 301 ++++++--- .../RpgEntryHomeView.recharge.test.tsx | 206 +++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 474 +++++++++----- .../rpgEntryWorldPresentation.test.ts | 70 ++ .../rpg-entry/rpgEntryWorldPresentation.ts | 92 +++ src/index.css | 53 ++ src/routing/RouteImageReadyGate.test.ts | 27 +- src/routing/RouteLoadingScreen.tsx | 23 +- .../puzzle-runtime/puzzleLocalRuntime.ts | 85 ++- 68 files changed, 4586 insertions(+), 748 deletions(-) create mode 100644 docs/technical/PLATFORM_DESKTOP_HOME_MODULE_CONTENT_SYNC_2026-04-30.md create mode 100644 docs/technical/PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md create mode 100644 docs/technical/PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md create mode 100644 docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md create mode 100644 server-rs/crates/api-server/src/prompt/puzzle/draft.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs 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_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md b/docs/design/PLATFORM_WORK_DETAIL_AND_REMIX_DESIGN_2026-04-28.md index fb0abe70..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,6 +1,6 @@ # 平台统一作品详情页与 Remix 数据链路设计 -更新时间:`2026-04-29` +更新时间:`2026-05-01` ## 1. 本次目标 @@ -15,21 +15,22 @@ 统一详情页只做作品展示与动作入口,不承担规则说明。 1. 顶部导航:返回按钮、标题“详情”、更多按钮占位;不展示“统计 / 详情 / 评价 / 论坛”Tab。 -2. 封面区:固定 `16:9` 比例,使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。 -3. 基础信息区: +2. 封面区:固定 `16:9` 比例,默认使用作品封面图 `cover` 填满整块主视觉;背景可用同图弱化铺底;缺图时只显示平台主题底,不新增说明文字。拼图作品详情页若详情数据包含多个关卡图,则顶部封面区优先按关卡正式图轮播展示,每张图对应一个关卡;无可用关卡图时再回退作品封面图。 +3. 移动端首页“推荐”和“今日游戏”列表中,只有最接近屏幕垂直中心的作品卡片进入封面轮播态;若该拼图作品有多张关卡封面,则按详情页同源封面序列自动轮换。用户滚动后,离开中心的旧卡片必须立即恢复首张封面,新中心卡片再开始轮播;“游戏分类”、排行、桌面端列表不启用该自动轮播。 +4. 基础信息区: - 左侧作品图标使用作品封面或首图。 - 中间展示作品名、作者头像、作者名、玩法类型;作者头像读取公开用户资料 `avatarUrl`,缺失时使用作者昵称首字占位。 - 右侧原 TapTap 评分位置替换为 `点赞` 按钮;点击后调用后端点赞接口,由后端记录当前登录用户对该公开作品的点赞关系并返回更新后的真实 `likeCount` 读模型,前端不伪造点赞增长。 -4. 统计区固定四项: +5. 统计区固定四项: - 游玩:`playCount`,显示为“数字 + 次”,单位放在数字后方。 - 改造:`remixCount`,显示为“数字 + 次”,单位放在数字后方。 - 点赞:`likeCount`,显示为“数字 + 赞”,单位放在数字后方。 - 日期:优先展示 `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. 数据真相源 @@ -99,4 +100,5 @@ 4. Remix 后原作品改造次数增加,新草稿归当前用户所有,且不会继承源作品统计。 5. 点赞公开作品会走对应后端记录入口,首次点赞后刷新仍能看到递增后的点赞次数,重复点赞不会继续增加。 6. 启动公开作品会走对应后端记录入口,刷新后仍能看到递增后的游玩次数。 -7. 修改后运行编码检查、SpacetimeDB 绑定生成、Rust 检查和必要前端测试。 +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/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md b/docs/prd/MY_TAB_DATA_DASHBOARD_PRD_2026-04-16.md index 3f5b4c51..9122a871 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,17 +82,17 @@ 1. 陶泥币卡 - 打开资产流水抽屉 -2. 总游戏时长卡 +2. 游戏时长卡 - 打开游玩统计抽屉 -3. 玩过作品卡 - - 打开玩过作品列表 +3. 玩过卡 + - 打开玩过列表 如果本期不做明细页,点击可先无动作,但必须预留可扩展事件位。 ## 4.2 展示规则 1. 数字过大时做单位缩略展示 -2. “总游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天 +2. “游戏时长”卡固定以小时为单位展示,短时长不切换成分钟,长时长不切换成天 3. 进入页面先展示骨架屏 4. 数据请求失败时展示降级文案,不展示假数字 @@ -131,7 +131,7 @@ 返回: - 游玩时长分布 -- 玩过作品列表摘要 +- 玩过列表摘要 --- @@ -139,7 +139,7 @@ 1. 钱包余额从后端钱包台账聚合 2. 游戏时长从运行时会话日志或快照汇总 -3. 玩过作品数从有效游玩记录去重计算 +3. 玩过数从有效游玩记录去重计算 禁止继续采用: @@ -153,4 +153,4 @@ 2. 切换设备后看板数据一致 3. 没有存档时也能正常展示账号级数据 4. 数据加载失败时页面表现可控 -5. “总游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时` +5. “游戏时长”卡展示值始终带 `小时` 单位,例如 `0小时`、`1.5小时`、`36小时` 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..ce2b0621 --- /dev/null +++ b/docs/technical/PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md @@ -0,0 +1,95 @@ +# 拼图失败续时与存档投影设计 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` + +## 写入时机 + +SpacetimeDB 拼图运行态每次持久化 run 时同步刷新存档: + +1. `start_puzzle_run`:创建 run 后立即写入拼图存档。 +2. `advance_puzzle_next_level`:进入下一关后更新同一条存档。 +3. `use_puzzle_runtime_prop(extendTime)`:续时成功后更新状态。 +4. `get_puzzle_run` 导致失败态落库时,也同步更新为失败存档。 + +排行榜提交只负责成绩与通关态,不新增存档规则;如果它把 run 状态更新为通关,也跟随 run 持久化刷新存档。 + +## 验收 + +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 aa54d5fe..7504983e 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -10,10 +10,13 @@ 1. 玩家在创作页点击“拼图”入口时,前端必须立即创建一个新的拼图 Agent session,并同步生成一条 `publicationStatus = draft` 的拼图作品卡;此时不触发 `compile_puzzle_draft`,不生成图片,不进入生成进度页。 2. 新 session 的 `seedText` 允许为空;SpacetimeDB 侧用空锚点和空表单草稿初始化,不得把默认题材文案写入玩家草稿字段。 -3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;参考图只保存在当前前端会话内,不落入 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` 真相源。 @@ -51,7 +54,7 @@ 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 = 表单画面描述`,首图生成后直接写入该关卡。 +5. 首次草稿生成时必须创建一个默认关卡,`levelId = puzzle-level-1`,`pictureDescription = 表单画面描述`,草稿设置阶段 `levelName` 为空;首图生成后可由后端根据画面描述和图片语义生成关卡名称并写入该关卡。 6. 关卡名称由后端基于画面描述和图片语义输入生成;无可用语义时按题材标签与序号兜底,禁止继续直接使用作品名称作为关卡名称。 7. 旧草稿或旧作品缺少 `levels` 时,读取层必须由旧 `levelName`、`summary`、`coverImageSrc`、`candidates` 补出一个兼容关卡,避免历史草稿无法打开。 @@ -69,14 +72,31 @@ 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: -1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、重新生成画面,并支持单独体验该关卡。 +1. 拼图关卡列表:默认展示草稿生成出的第一关。列表项参考 RPG 草稿卡片样式,显示画面图、关卡名称和轻量状态。支持新增关卡、删除关卡。点击列表项进入独立关卡详情页,不在列表项下方展开。关卡详情页可编辑关卡名称、画面描述、生成或重新生成画面,并在已有正式图后支持关卡测试。 2. 作品信息:展示并编辑作品名称、作品描述、作品标签。 +### 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 编辑,作品详情页不得再回退使用画面描述。 ## 验收 @@ -85,5 +105,5 @@ 2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。 3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。 4. 结果页包含“拼图关卡”和“作品信息”两个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。 -5. 关卡详情页支持重新生成画面和单独体验该关卡。 +5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。 6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。 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..98091234 --- /dev/null +++ b/docs/technical/PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md @@ -0,0 +1,74 @@ +# 拼图下一关与相似作品接续设计 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` 时默认加载候选第一项,并从该作品第 1 关重新开始。 +5. `local-next-level` 兼容接口同样优先找同作品下一关;没有时才返回相似作品候选或旧草稿兜底。 + +## 前端规则 + +1. 结算弹窗: + - `sameWork`:主按钮显示“下一关”,直接触发默认推进。 + - `similarWorks`:展示最多 3 个作品候选卡;用户点击卡片进入候选作品。 + - `none`:禁用下一关入口。 +2. 底部通关后入口: + - `sameWork` 保留“下一关”。 + - `similarWorks` 显示“换个作品”,点击后打开结算弹窗供选择。 +3. 所有正式相似度计算只信任后端返回,不在 UI 里重新算。 + +## 验收 + +1. 当前作品有下一关时,点击“下一关”进入当前作品下一关。 +2. 当前作品没有下一关时,通关弹窗显示最多 3 个相似作品。 +3. 点击相似作品后进入该作品第 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/README.md b/docs/technical/README.md index 56aa9945..9ae04c50 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,7 @@ ## 文档列表 - [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`。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 @@ -19,6 +20,8 @@ - [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):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。 - [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` 仅作为兼容回退。 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/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md b/docs/technical/SERVER_RS_GENERATION_PROMPT_SCRIPT_MIGRATION_2026-04-25.md index c2ad7f7f..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 @@ -64,7 +64,8 @@ 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/image.rs` 承接拼图图片生成正式提示词与默认反向提示词。 -4. `puzzle_agent_turn.rs` 只保留 LLM 调用、结果解析、阶段判断和 SpacetimeDB 写回输入构造,不再内联拼图聊天提示词正文。 -5. `puzzle.rs` 只保留拼图路由、计费、DashScope、OSS、候选图持久化和运行态编排,不再内联拼图图片提示词正文。 -6. 后续调整拼图共创问法、输出契约、图片画面约束或反向提示词时,优先修改 `prompt/puzzle/`,不要在 `puzzle.rs` 或 `puzzle_agent_turn.rs` 中新增提示词正文。 +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/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 83a25119..5d96445c 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -62,6 +62,7 @@ export type PuzzleAgentActionRequest = promptText?: string | null; referenceImageSrc?: string | null; candidateCount?: number; + levelsJson?: string; } | { action: 'select_puzzle_image'; diff --git a/packages/shared/src/contracts/puzzleRuntimeSession.ts b/packages/shared/src/contracts/puzzleRuntimeSession.ts index 234dd18d..a240b3b8 100644 --- a/packages/shared/src/contracts/puzzleRuntimeSession.ts +++ b/packages/shared/src/contracts/puzzleRuntimeSession.ts @@ -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,6 +90,10 @@ export interface PuzzleRunSnapshot { previousLevelTags: string[]; currentLevel: PuzzleRuntimeLevelSnapshot | null; recommendedNextProfileId: string | null; + nextLevelMode?: PuzzleNextLevelMode; + nextLevelProfileId?: string | null; + nextLevelId?: string | null; + recommendedNextWorks?: PuzzleRecommendedNextWork[]; leaderboardEntries: PuzzleLeaderboardEntry[]; } diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index cede4091..0fbeb7c8 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -24,11 +24,11 @@ export interface PuzzleWorkSummary { likeCount?: number; recentPlayCount7d?: number; publishReady: boolean; + levels?: PuzzleDraftLevel[]; } export interface PuzzleWorkProfile extends PuzzleWorkSummary { anchorPack: PuzzleAnchorPack; - levels?: PuzzleDraftLevel[]; metadata?: JsonObject | null; } 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/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/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 index 53fe1ae6..f4370549 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/image.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/image.rs @@ -38,17 +38,15 @@ pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> Strin image_prompt } -fn build_puzzle_image_prompt_text(level_name: &str, prompt: &str) -> String { +fn build_puzzle_image_prompt_text(_level_name: &str, prompt: &str) -> String { format!( concat!( "请生成一张高清插画。", - "关卡名:{level_name}。", "画面主体:{prompt}。", - "画面要求:1:1 正方形拼图关卡,适配 3x3 或 4x4 拼图切块,", + "画面要求:1:1", "主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,", "避免文字、水印、边框和 UI 元素。" ), - level_name = level_name, prompt = prompt, ) } @@ -78,10 +76,9 @@ mod tests { 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("1:1")); + assert!(prompt.contains("主体要清晰集中")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } @@ -93,8 +90,8 @@ mod tests { 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("3x3 或 4x4")); + assert!(prompt.contains("1:1")); + assert!(prompt.contains("主体要清晰集中")); assert!(prompt.contains("避免文字、水印、边框和 UI 元素")); } diff --git a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs index b7f3051f..c579b9c0 100644 --- a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs +++ b/server-rs/crates/api-server/src/prompt/puzzle/mod.rs @@ -1,2 +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/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 59e187dc..32588b50 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -38,9 +38,10 @@ use shared_contracts::{ 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, @@ -56,12 +57,12 @@ use spacetime_client::{ PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, - PuzzlePublishRecordInput, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, - PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - SpacetimeClientError, + PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord, + PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, SpacetimeClientError, }; use std::convert::Infallible; use tokio::time::sleep; @@ -72,7 +73,13 @@ 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, @@ -472,7 +479,7 @@ pub async fn execute_puzzle_agent_action( .map(str::trim) .filter(|value| !value.is_empty()) .or_else(|| payload.prompt_text.as_deref()); - if let Err(response) = save_puzzle_form_payload_before_compile( + let compile_session_id = match save_puzzle_form_payload_before_compile( &state, &request_context, &session_id, @@ -482,8 +489,9 @@ pub async fn execute_puzzle_agent_action( ) .await { - return Err(response); - } + Ok(next_session_id) => next_session_id, + Err(response) => return Err(response), + }; let session = execute_billable_asset_operation( &state, &owner_user_id, @@ -492,7 +500,7 @@ 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(), prompt_text, payload.reference_image_src.as_deref(), @@ -522,7 +530,7 @@ pub async fn execute_puzzle_agent_action( .as_deref() .or(payload.prompt_text.as_deref()), ); - let session = state + let save_result = state .spacetime_client() .save_puzzle_form_draft(PuzzleFormDraftSaveRecordInput { session_id: session_id.clone(), @@ -530,14 +538,36 @@ pub async fn execute_puzzle_agent_action( seed_text, saved_at_micros: now, }) - .await - .map_err(|error| { - puzzle_error_response( - &request_context, - PUZZLE_AGENT_API_BASE_PROVIDER, - map_puzzle_client_error(error), - ) - }); + .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", "表单草稿保存", @@ -547,30 +577,42 @@ pub async fn execute_puzzle_agent_action( } "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": "拼图结果页草稿尚未生成", })) })?; + 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 = payload - .prompt_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| target_level.picture_description.clone()); + 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 = target_level.candidates.len(); @@ -609,6 +651,7 @@ pub async fn execute_puzzle_agent_action( 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, }) @@ -977,7 +1020,7 @@ pub async fn get_puzzle_gallery_detail( Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { - item: map_puzzle_work_summary_response(&state, item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -1014,7 +1057,7 @@ pub async fn record_puzzle_gallery_like( Ok(json_success_body( Some(&request_context), PuzzleGalleryDetailResponse { - item: map_puzzle_work_summary_response(&state, item), + item: map_puzzle_work_profile_response(&state, item), }, )) } @@ -1303,6 +1346,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, @@ -1646,6 +1690,7 @@ fn map_puzzle_work_summary_response( like_count: item.like_count, recent_play_count_7d: item.recent_play_count_7d, publish_ready: item.publish_ready, + levels: Vec::new(), } } @@ -1653,14 +1698,16 @@ 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(state, item.clone()), + summary, anchor_pack: map_puzzle_anchor_pack_response(item.anchor_pack), - levels: item - .levels - .into_iter() - .map(map_puzzle_draft_level_response) - .collect(), } } @@ -1675,6 +1722,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() @@ -1683,6 +1738,19 @@ 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, @@ -1717,6 +1785,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() @@ -1725,12 +1801,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, @@ -1823,6 +1913,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, @@ -1933,14 +2024,14 @@ fn build_puzzle_welcome_text(seed_text: &str) -> String { } fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { - build_puzzle_form_seed_text_from_parts( - payload + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title: payload .work_title .as_deref() .or(payload.seed_text.as_deref()), - payload.work_description.as_deref(), - payload.picture_description.as_deref(), - ) + work_description: payload.work_description.as_deref(), + picture_description: payload.picture_description.as_deref(), + }) } fn build_puzzle_form_seed_text_from_parts( @@ -1948,20 +2039,11 @@ fn build_puzzle_form_seed_text_from_parts( work_description: Option<&str>, picture_description: Option<&str>, ) -> String { - let title = title.unwrap_or_default().trim(); - let work_description = work_description.unwrap_or_default().trim(); - let picture_description = picture_description.unwrap_or_default().trim(); - - [ - ("作品名称", title), - ("作品描述", work_description), - ("画面描述", picture_description), - ] - .into_iter() - .filter(|(_, value)| !value.is_empty()) - .map(|(label, value)| format!("{label}:{value}")) - .collect::>() - .join("\n") + build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { + title, + work_description, + picture_description, + }) } async fn save_puzzle_form_payload_before_compile( @@ -1971,7 +2053,7 @@ async fn save_puzzle_form_payload_before_compile( owner_user_id: &str, payload: &ExecutePuzzleAgentActionRequest, now: i64, -) -> Result<(), Response> { +) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( payload.work_title.as_deref(), payload.work_description.as_deref(), @@ -1981,26 +2063,101 @@ async fn save_puzzle_form_payload_before_compile( .or(payload.prompt_text.as_deref()), ); if seed_text.trim().is_empty() { - return Ok(()); + return Ok(session_id.to_string()); } - state + 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: seed_text.clone(), saved_at_micros: now, }) .await - .map(|_| ()) + .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( @@ -2008,15 +2165,20 @@ fn select_puzzle_level_for_api( level_id: Option<&str>, ) -> Result { let normalized_level_id = level_id.map(str::trim).filter(|value| !value.is_empty()); - let level = normalized_level_id - .and_then(|target_id| { - draft - .levels - .iter() - .find(|level| level.level_id == target_id) - .cloned() - }) - .or_else(|| draft.levels.first().cloned()); + 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, @@ -2025,6 +2187,43 @@ fn select_puzzle_level_for_api( }) } +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], @@ -2138,22 +2337,18 @@ async fn compile_puzzle_draft_with_initial_cover( .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 = prompt_text - .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| { - Some(target_level.picture_description.as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - }) - .unwrap_or(draft.summary.as_str()); + 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, &target_level.level_name, - image_prompt, + &image_prompt, reference_image_src, 1, target_level.candidates.len(), @@ -2179,6 +2374,7 @@ async fn compile_puzzle_draft_with_initial_cover( 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(), }) @@ -2252,6 +2448,15 @@ 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") { @@ -2484,11 +2689,18 @@ async fn build_local_next_puzzle_run( ); } + 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); + } + if let Some(gallery_item) = resolve_gallery_next_puzzle_work(state, &run).await? { return Ok(build_next_run_from_puzzle_work(state, run, gallery_item)); } - 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!({ @@ -2551,6 +2763,7 @@ async fn build_local_next_puzzle_run( 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(), }) @@ -2578,6 +2791,101 @@ async fn build_local_next_puzzle_run( )) } +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_next_puzzle_work( state: &AppState, run: &PuzzleRunRecord, @@ -2609,6 +2917,76 @@ fn pick_unused_puzzle_candidate<'a>( }) } +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_puzzle_work( state: &AppState, run: PuzzleRunRecord, @@ -2654,6 +3032,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, @@ -2661,11 +3067,32 @@ 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 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()); } @@ -2681,8 +3108,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, @@ -2702,6 +3130,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(), } } diff --git a/server-rs/crates/api-server/src/runtime_chat.rs b/server-rs/crates/api-server/src/runtime_chat.rs index d247c857..20fed91b 100644 --- a/server-rs/crates/api-server/src/runtime_chat.rs +++ b/server-rs/crates/api-server/src/runtime_chat.rs @@ -26,9 +26,9 @@ use crate::{ 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, @@ -137,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 { @@ -272,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 { @@ -618,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; @@ -631,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 = [ "动手", "开战", @@ -640,6 +660,18 @@ fn should_hostile_chat_breakoff_deterministically( "闭嘴", "少废话", "别挡路", + "废话", + "威胁", + "找死", + "送死", + "住口", + "让开", + "滚开", + "不退", + "不会退", + "别装", + "骗子", + "叛徒", ]; count_keyword_matches(player_message, &hostile_break_words) > 0 } @@ -812,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/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index ccdf0cc8..73b73168 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -16,6 +16,10 @@ 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"; const PUZZLE_INITIAL_SHUFFLE_ATTEMPTS: u64 = 64; #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -310,6 +314,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, @@ -343,7 +349,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, @@ -354,10 +360,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 { @@ -423,6 +452,7 @@ 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, } @@ -906,22 +936,22 @@ pub fn build_form_draft_from_parts( ) -> 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 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()]; + tags = vec![ + "拼图".to_string(), + "插画".to_string(), + "清晰构图".to_string(), + ]; } - let level_name = picture_description - .as_deref() - .map(|value| build_level_name_from_picture(value, &tags, 1)) - .or_else(|| work_title.clone()) - .unwrap_or_else(|| "未命名拼图".to_string()); let summary = work_description.clone().unwrap_or_default(); let level = PuzzleDraftLevel { level_id: "puzzle-level-1".to_string(), - level_name: level_name.clone(), + level_name: String::new(), picture_description: picture_description.clone().unwrap_or_default(), candidates: Vec::new(), selected_candidate_id: None, @@ -934,7 +964,7 @@ pub fn build_form_draft_from_parts( PuzzleResultDraft { work_title: work_title.clone().unwrap_or_default(), work_description: summary.clone(), - level_name, + level_name: String::new(), summary, theme_tags: tags, forbidden_directives: Vec::new(), @@ -1538,6 +1568,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) } @@ -1625,6 +1691,10 @@ pub fn start_run_with_shuffle_seed_at( current_level: Some(PuzzleRuntimeLevelSnapshot { run_id, level_index: cleared_level_count + 1, + 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(), @@ -1646,6 +1716,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(), }) } @@ -1886,6 +1960,10 @@ pub fn advance_next_level_at( current_level: Some(PuzzleRuntimeLevelSnapshot { run_id: run.run_id.clone(), level_index: run.current_level_index + 1, + 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(), @@ -1907,15 +1985,98 @@ 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 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 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| { @@ -1936,23 +2097,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( @@ -1983,10 +2146,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([ ("蒸汽", "蒸汽城市"), @@ -2172,7 +2494,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 ) @@ -2902,6 +3224,24 @@ mod tests { assert_eq!(resolve_puzzle_grid_size(3), 4); } + #[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] fn normalize_theme_tags_dedups_aliases() { assert_eq!( @@ -2993,7 +3333,7 @@ mod tests { } #[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()], @@ -3001,6 +3341,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!["蒸汽城市", "雨夜"]); 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 3e62eb79..07f6d457 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_runtime.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_runtime.rs @@ -106,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, @@ -139,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 { @@ -154,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 cf594d5e..086f3c08 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -48,6 +48,8 @@ pub struct PuzzleWorkSummaryResponse { #[serde(default)] pub recent_play_count_7d: u32, pub publish_ready: bool, + #[serde(default)] + pub levels: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -56,7 +58,6 @@ pub struct PuzzleWorkProfileResponse { #[serde(flatten)] pub summary: PuzzleWorkSummaryResponse, pub anchor_pack: PuzzleAnchorPackResponse, - pub levels: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index e1686086..67736666 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -34,13 +34,14 @@ pub use mapper::{ PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, - PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord, - PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, - PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, - PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, - PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, - PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, - ResolveCombatActionRecord, ResolveNpcBattleInteractionInput, + PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, + PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, + PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, + PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, + PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, + PuzzleSelectCoverImageRecordInput, PuzzleWorkLikeReportRecordInput, PuzzleWorkProfileRecord, + PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, ResolveCombatActionRecord, + ResolveNpcBattleInteractionInput, }; pub mod ai; diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 1e489c0e..40db1037 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2459,6 +2459,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() @@ -2467,12 +2475,26 @@ 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 { 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, @@ -4400,6 +4422,7 @@ 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, } @@ -4739,10 +4762,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, @@ -4764,7 +4798,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, @@ -4775,6 +4809,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/clear_database_migration_import_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_procedure.rs new file mode 100644 index 00000000..8777fe6c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/clear_database_migration_import_chunks_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::database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput; +use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ClearDatabaseMigrationImportChunksArgs { + pub input: DatabaseMigrationImportChunksClearInput, +} + + +impl __sdk::InModule for ClearDatabaseMigrationImportChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `clear_database_migration_import_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait clear_database_migration_import_chunks { + fn clear_database_migration_import_chunks(&self, input: DatabaseMigrationImportChunksClearInput, +) { + self.clear_database_migration_import_chunks_then(input, |_, _| {}); + } + + fn clear_database_migration_import_chunks_then( + &self, + input: DatabaseMigrationImportChunksClearInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl clear_database_migration_import_chunks for super::RemoteProcedures { + fn clear_database_migration_import_chunks_then( + &self, + input: DatabaseMigrationImportChunksClearInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "clear_database_migration_import_chunks", + ClearDatabaseMigrationImportChunksArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_input_type.rs new file mode 100644 index 00000000..5d5d3c9c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_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 DatabaseMigrationImportChunkInput { + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub chunk: String, +} + + +impl __sdk::InModule for DatabaseMigrationImportChunkInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs new file mode 100644 index 00000000..a3647da9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunk_type.rs @@ -0,0 +1,80 @@ +// 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 DatabaseMigrationImportChunk { + pub chunk_key: String, + pub upload_id: String, + pub chunk_index: u32, + pub chunk_count: u32, + pub operator_identity: __sdk::Identity, + pub created_at: __sdk::Timestamp, + pub chunk: String, +} + + +impl __sdk::InModule for DatabaseMigrationImportChunk { + type Module = super::RemoteModule; +} + + +/// Column accessor struct for the table `DatabaseMigrationImportChunk`. +/// +/// Provides typed access to columns for query building. +pub struct DatabaseMigrationImportChunkCols { + pub chunk_key: __sdk::__query_builder::Col, + pub upload_id: __sdk::__query_builder::Col, + pub chunk_index: __sdk::__query_builder::Col, + pub chunk_count: __sdk::__query_builder::Col, + pub operator_identity: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub chunk: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for DatabaseMigrationImportChunk { + type Cols = DatabaseMigrationImportChunkCols; + fn cols(table_name: &'static str) -> Self::Cols { + DatabaseMigrationImportChunkCols { + chunk_key: __sdk::__query_builder::Col::new(table_name, "chunk_key"), + upload_id: __sdk::__query_builder::Col::new(table_name, "upload_id"), + chunk_index: __sdk::__query_builder::Col::new(table_name, "chunk_index"), + chunk_count: __sdk::__query_builder::Col::new(table_name, "chunk_count"), + operator_identity: __sdk::__query_builder::Col::new(table_name, "operator_identity"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + chunk: __sdk::__query_builder::Col::new(table_name, "chunk"), + + } + } +} + +/// Indexed column accessor struct for the table `DatabaseMigrationImportChunk`. +/// +/// Provides typed access to indexed columns for query building. +pub struct DatabaseMigrationImportChunkIxCols { + pub chunk_key: __sdk::__query_builder::IxCol, + pub upload_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for DatabaseMigrationImportChunk { + type IxCols = DatabaseMigrationImportChunkIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + DatabaseMigrationImportChunkIxCols { + chunk_key: __sdk::__query_builder::IxCol::new(table_name, "chunk_key"), + upload_id: __sdk::__query_builder::IxCol::new(table_name, "upload_id"), + + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for DatabaseMigrationImportChunk {} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs new file mode 100644 index 00000000..e21d4ea3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_clear_input_type.rs @@ -0,0 +1,23 @@ +// 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 DatabaseMigrationImportChunksClearInput { + pub upload_id: String, +} + + +impl __sdk::InModule for DatabaseMigrationImportChunksClearInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_input_type.rs new file mode 100644 index 00000000..2bd708b6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/database_migration_import_chunks_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 DatabaseMigrationImportChunksInput { + pub upload_id: String, + pub include_tables: Vec::, + pub replace_existing: bool, + pub dry_run: bool, +} + + +impl __sdk::InModule for DatabaseMigrationImportChunksInput { + type Module = super::RemoteModule; +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs index 096b48dd..1de238d2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/export_database_migration_to_file_procedure.rs @@ -9,8 +9,8 @@ use spacetimedb_sdk::__codegen::{ __ws, }; -use super::database_migration_export_input_type::DatabaseMigrationExportInput; use super::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; +use super::database_migration_export_input_type::DatabaseMigrationExportInput; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_procedure.rs new file mode 100644 index 00000000..5d48c850 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_from_chunks_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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; +use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ImportDatabaseMigrationFromChunksArgs { + pub input: DatabaseMigrationImportChunksInput, +} + + +impl __sdk::InModule for ImportDatabaseMigrationFromChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `import_database_migration_from_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait import_database_migration_from_chunks { + fn import_database_migration_from_chunks(&self, input: DatabaseMigrationImportChunksInput, +) { + self.import_database_migration_from_chunks_then(input, |_, _| {}); + } + + fn import_database_migration_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl import_database_migration_from_chunks for super::RemoteProcedures { + fn import_database_migration_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "import_database_migration_from_chunks", + ImportDatabaseMigrationFromChunksArgs { input, }, + __callback, + ); + } +} + diff --git a/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_procedure.rs new file mode 100644 index 00000000..195a1b10 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/import_database_migration_incremental_from_chunks_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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; +use super::database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct ImportDatabaseMigrationIncrementalFromChunksArgs { + pub input: DatabaseMigrationImportChunksInput, +} + + +impl __sdk::InModule for ImportDatabaseMigrationIncrementalFromChunksArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `import_database_migration_incremental_from_chunks`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait import_database_migration_incremental_from_chunks { + fn import_database_migration_incremental_from_chunks(&self, input: DatabaseMigrationImportChunksInput, +) { + self.import_database_migration_incremental_from_chunks_then(input, |_, _| {}); + } + + fn import_database_migration_incremental_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl import_database_migration_incremental_from_chunks for super::RemoteProcedures { + fn import_database_migration_incremental_from_chunks_then( + &self, + input: DatabaseMigrationImportChunksInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "import_database_migration_incremental_from_chunks", + ImportDatabaseMigrationIncrementalFromChunksArgs { input, }, + __callback, + ); + } +} + 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 51a82a99..bb236a52 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -161,6 +161,10 @@ pub mod custom_world_works_list_input_type; pub mod custom_world_works_list_result_type; pub mod database_migration_authorize_operator_input_type; pub mod database_migration_export_input_type; +pub mod database_migration_import_chunk_type; +pub mod database_migration_import_chunk_input_type; +pub mod database_migration_import_chunks_clear_input_type; +pub mod database_migration_import_chunks_input_type; pub mod database_migration_import_input_type; pub mod database_migration_operator_type; pub mod database_migration_operator_procedure_result_type; @@ -410,6 +414,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 clear_database_migration_import_chunks_procedure; pub mod clear_platform_browse_history_and_return_procedure; pub mod compile_big_fish_draft_procedure; pub mod compile_custom_world_published_profile_procedure; @@ -464,7 +469,9 @@ pub mod get_runtime_snapshot_procedure; pub mod get_story_session_state_procedure; pub mod grant_player_progression_experience_and_return_procedure; pub mod import_auth_store_snapshot_procedure; +pub mod import_database_migration_from_chunks_procedure; pub mod import_database_migration_from_file_procedure; +pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; pub mod list_asset_history_and_return_procedure; pub mod list_big_fish_works_procedure; @@ -480,6 +487,7 @@ pub mod publish_big_fish_game_procedure; pub mod publish_custom_world_profile_and_return_procedure; pub mod publish_custom_world_world_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; @@ -670,6 +678,10 @@ pub use custom_world_works_list_input_type::CustomWorldWorksListInput; pub use custom_world_works_list_result_type::CustomWorldWorksListResult; pub use database_migration_authorize_operator_input_type::DatabaseMigrationAuthorizeOperatorInput; pub use database_migration_export_input_type::DatabaseMigrationExportInput; +pub use database_migration_import_chunk_type::DatabaseMigrationImportChunk; +pub use database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; +pub use database_migration_import_chunks_clear_input_type::DatabaseMigrationImportChunksClearInput; +pub use database_migration_import_chunks_input_type::DatabaseMigrationImportChunksInput; pub use database_migration_import_input_type::DatabaseMigrationImportInput; pub use database_migration_operator_type::DatabaseMigrationOperator; pub use database_migration_operator_procedure_result_type::DatabaseMigrationOperatorProcedureResult; @@ -919,6 +931,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 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 compile_big_fish_draft_procedure::compile_big_fish_draft; pub use compile_custom_world_published_profile_procedure::compile_custom_world_published_profile; @@ -973,7 +986,9 @@ pub use get_runtime_snapshot_procedure::get_runtime_snapshot; pub use get_story_session_state_procedure::get_story_session_state; pub use grant_player_progression_experience_and_return_procedure::grant_player_progression_experience_and_return; pub use import_auth_store_snapshot_procedure::import_auth_store_snapshot; +pub use import_database_migration_from_chunks_procedure::import_database_migration_from_chunks; pub use import_database_migration_from_file_procedure::import_database_migration_from_file; +pub use import_database_migration_incremental_from_chunks_procedure::import_database_migration_incremental_from_chunks; pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; pub use list_big_fish_works_procedure::list_big_fish_works; @@ -989,6 +1004,7 @@ pub use publish_big_fish_game_procedure::publish_big_fish_game; pub use publish_custom_world_profile_and_return_procedure::publish_custom_world_profile_and_return; pub use publish_custom_world_world_procedure::publish_custom_world_world; 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; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_procedure.rs new file mode 100644 index 00000000..a639ab61 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/put_database_migration_import_chunk_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::database_migration_procedure_result_type::DatabaseMigrationProcedureResult; +use super::database_migration_import_chunk_input_type::DatabaseMigrationImportChunkInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] + struct PutDatabaseMigrationImportChunkArgs { + pub input: DatabaseMigrationImportChunkInput, +} + + +impl __sdk::InModule for PutDatabaseMigrationImportChunkArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `put_database_migration_import_chunk`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait put_database_migration_import_chunk { + fn put_database_migration_import_chunk(&self, input: DatabaseMigrationImportChunkInput, +) { + self.put_database_migration_import_chunk_then(input, |_, _| {}); + } + + fn put_database_migration_import_chunk_then( + &self, + input: DatabaseMigrationImportChunkInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ); +} + +impl put_database_migration_import_chunk for super::RemoteProcedures { + fn put_database_migration_import_chunk_then( + &self, + input: DatabaseMigrationImportChunkInput, + + __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, DatabaseMigrationProcedureResult>( + "put_database_migration_import_chunk", + PutDatabaseMigrationImportChunkArgs { input, }, + __callback, + ); + } +} + 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 069b7cd3..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 @@ -16,6 +16,7 @@ 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/puzzle.rs b/server-rs/crates/spacetime-client/src/puzzle.rs index 4a4042a8..96cfd166 100644 --- a/server-rs/crates/spacetime-client/src/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/puzzle.rs @@ -170,6 +170,7 @@ impl SpacetimeClient { 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, }; 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 f8c2c07a..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,8 +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_like, record_public_work_play, - upsert_profile_played_work, PublicWorkLikeRecordInput, + 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::*; diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index e3c437f7..17bef5d9 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -3322,7 +3322,10 @@ fn record_custom_world_profile_like_record( .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)); + return Ok(( + build_custom_world_profile_snapshot(&existing), + gallery_entry, + )); } // 中文注释:点赞关系表先保证一人一作品一次,再递增公开作品计数,避免前端重复点击造成热度膨胀。 diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index f89c1cb8..943d7034 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -1,28 +1,31 @@ use crate::runtime::{ - ProfilePlayedWorkUpsertInput, PublicWorkPlayRecordInput, add_profile_observed_play_time, - count_recent_public_work_plays, record_public_work_like, record_public_work_play, - upsert_profile_played_work, PublicWorkLikeRecordInput, + ProfilePlayedWorkUpsertInput, PublicWorkLikeRecordInput, PublicWorkPlayRecordInput, + ProfileSaveArchiveUpsertInput, + add_profile_observed_play_time, count_recent_public_work_plays, 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, PuzzleFormDraftSaveInput, PuzzleGeneratedImageCandidate, PuzzleGeneratedImagesSaveInput, PuzzleLeaderboardEntry, - PuzzleLeaderboardSubmitInput, - PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft, PuzzleRunDragInput, - PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, - PuzzleRunPropInput, PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, - PuzzleRuntimeLevelStatus, PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, - PuzzleWorkGetInput, PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, - PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkRemixInput, PuzzleWorkUpsertInput, - PuzzleWorksListInput, PuzzleWorksProcedureResult, + PuzzleLeaderboardSubmitInput, PuzzlePublicationStatus, PuzzlePublishInput, + PuzzleRecommendedNextWork, PuzzleResultDraft, PuzzleRunDragInput, PuzzleRunGetInput, + PuzzleRunNextLevelInput, PuzzleRunPauseInput, PuzzleRunProcedureResult, PuzzleRunPropInput, + PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus, + PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput, + PuzzleWorkLikeRecordInput as PuzzleWorkLikeInput, 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, resolve_puzzle_grid_size, select_next_profile, selected_puzzle_level, + replace_puzzle_level, resolve_puzzle_grid_size, select_next_profiles, + selected_profile_level_after_runtime_level, selected_puzzle_level, tag_similarity_score, }; 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}; @@ -889,6 +892,11 @@ fn save_puzzle_generated_images_tx( ) -> 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() { @@ -1539,12 +1547,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, @@ -1576,6 +1579,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); } @@ -1608,7 +1612,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() @@ -1640,7 +1644,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() @@ -1671,21 +1675,28 @@ 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 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 next_profile = selected_profile_level_after_runtime_level(¤t_profile, current_level) + .map(|level| profile_for_single_level(¤t_profile, &level)) + .or_else(|| { + let candidates = list_published_puzzle_profiles(ctx).ok()?; + select_next_profiles( + ¤t_profile, + ¤t_run.played_profile_ids, + &candidates, + 1, + ) + .into_iter() + .next() + .cloned() + }) + .ok_or_else(|| "没有可用的下一关候选".to_string())?; let mut next_run = module_puzzle::advance_next_level_at( ¤t_run, &next_profile, @@ -1701,9 +1712,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 @@ -1744,8 +1753,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() @@ -1774,6 +1784,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, @@ -1788,8 +1803,9 @@ 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)?; + 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() @@ -1883,6 +1899,7 @@ fn submit_puzzle_leaderboard_entry_tx( ); } 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) } @@ -1891,6 +1908,14 @@ fn is_frontend_puzzle_level_candidate(run: &PuzzleRunSnapshot, profile_id: &str) 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() @@ -2328,6 +2353,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(()) } @@ -2356,6 +2382,75 @@ fn replace_puzzle_runtime_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); + + // 中文注释:拼图存档只保存恢复入口所需的最小运行态索引,棋盘真相继续放在 puzzle_runtime_run。 + let game_state_json = json_to_string(&json!({ + "runtimeKind": "puzzle", + "runId": run.run_id, + "entryProfileId": run.entry_profile_id, + "currentProfileId": current_level.profile_id, + "currentLevelIndex": current_level.level_index, + "currentLevelId": current_level.level_id, + "status": current_level.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: resolve_puzzle_current_owner_user_id(ctx, ¤t_level.profile_id), + profile_id: Some(run.entry_profile_id.clone()), + world_type: Some("PUZZLE".to_string()), + world_name: current_level.level_name.clone(), + subtitle: format!("第 {} 关", current_level.level_index), + summary_text: puzzle_archive_summary_text(current_level.status), + cover_image_src: current_level.cover_image_src.clone(), + bottom_tab: "puzzle".to_string(), + game_state_json, + current_story_json: None, + saved_at_micros, + }, + ) +} + +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 increment_puzzle_profile_play_count( @@ -2439,14 +2534,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(()); } }; @@ -2457,12 +2571,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(()) } @@ -2640,6 +2783,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); diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index d82d454f..261c3040 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -181,6 +181,22 @@ pub(crate) struct PublicWorkLikeRecordInput { 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] @@ -759,6 +775,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, diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 10f36509..304aa08f 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -105,9 +105,14 @@ test('creation hub reflects updated draft title summary and counts after rerende 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( + puzzleButton.compareDocumentPosition(rpgButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); expect((rpgButton as HTMLButtonElement).disabled).toBe(true); expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0); - expect(screen.getByRole('button', { name: /拼图.*创意礼物/u })).toBeTruthy(); + expect(puzzleButton).toBeTruthy(); expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull(); rerender( diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 504e05db..f333d757 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -31,6 +31,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'; @@ -39,6 +40,7 @@ import type { CustomWorldLibraryEntry, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, + ProfileSaveArchiveSummary, } from '../../../packages/shared/src/contracts/runtime'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; import { @@ -111,8 +113,10 @@ import { import { applyLocalPuzzleFreezeTime, dragLocalPuzzlePiece, + extendLocalPuzzleTime, isLocalPuzzleRun, refreshLocalPuzzleTimer, + restartLocalPuzzleLevel, setLocalPuzzlePaused, startLocalPuzzleRun, submitLocalPuzzleLeaderboard, @@ -128,7 +132,10 @@ import { recordRpgEntryWorldGalleryPlay, remixRpgEntryWorldGallery, } from '../../services/rpg-entry/rpgEntryLibraryClient'; -import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient'; +import { + getRpgProfilePlayStats, + resumeRpgProfileSaveArchive, +} from '../../services/rpg-entry/rpgProfileClient'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; import { @@ -178,6 +185,13 @@ type PuzzleRuntimeReturnStage = type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform'; +type PuzzleSaveArchiveState = { + runtimeKind?: unknown; + entryProfileId?: unknown; + currentProfileId?: unknown; + currentLevelId?: unknown; +}; + type AgentResultBlockerView = { code?: string; message: string; @@ -210,7 +224,10 @@ function isSamePlatformPublicGalleryEntry( left: PlatformPublicGalleryCard, right: PlatformPublicGalleryCard, ) { - return getPlatformPublicGalleryEntryKey(left) === getPlatformPublicGalleryEntryKey(right); + return ( + getPlatformPublicGalleryEntryKey(left) === + getPlatformPublicGalleryEntryKey(right) + ); } function mergePlatformPublicGalleryEntries( @@ -272,6 +289,17 @@ function mapPublicWorkDetailToPuzzleWork( remixCount: entry.remixCount ?? 0, likeCount: entry.likeCount ?? 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, + })) ?? [], }; } @@ -322,7 +350,9 @@ function mergeBigFishWorkSummary( current: BigFishWorkSummary, updated: BigFishWorkSummary, ): BigFishWorkSummary { - return current.sourceSessionId === updated.sourceSessionId ? updated : current; + return current.sourceSessionId === updated.sourceSessionId + ? updated + : current; } async function resolvePublicWorkAuthorSummary( @@ -562,6 +592,22 @@ function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) { ); } +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 { @@ -665,11 +711,20 @@ function mergePuzzleServiceRuntimeState( ? serviceLevel.leaderboardEntries : serviceRun.leaderboardEntries; - return { - ...currentRun, - leaderboardEntries, - currentLevel: { + 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, @@ -1338,7 +1393,10 @@ export function PlatformEntryFlowShellImpl({ const createPuzzleDraftFromForm = useCallback( async (payload: CreatePuzzleAgentSessionRequest) => { setPuzzleFormDraftPayload(payload); - const nextSession = puzzleFlow.session ?? (await puzzleFlow.openWorkspace(payload)); + const nextSession = + puzzleFlow.session && !isEmptyPuzzleFormOnlyDraft(puzzleFlow.session) + ? puzzleFlow.session + : await puzzleFlow.openWorkspace(payload); if (!nextSession) { return; } @@ -1504,6 +1562,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( @@ -1655,6 +1742,7 @@ export function PlatformEntryFlowShellImpl({ remixCount: 0, likeCount: 0, publishReady: Boolean(puzzleSession?.resultPreview?.publishReady), + levels: draft.levels, } satisfies PuzzleWorkSummary; }, [ @@ -1782,7 +1870,9 @@ export function PlatformEntryFlowShellImpl({ paused, }); setPuzzleRun((currentRun) => - currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + currentRun + ? mergePuzzleServiceRuntimeState(currentRun, run) + : currentRun, ); void platformBootstrap.refreshProfileDashboard(); } catch (error) { @@ -1812,7 +1902,9 @@ export function PlatformEntryFlowShellImpl({ try { const { run } = await getPuzzleRun(puzzleRun.runId); setPuzzleRun((currentRun) => - currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun, + currentRun + ? mergePuzzleServiceRuntimeState(currentRun, run) + : currentRun, ); } catch (error) { setPuzzleError( @@ -1822,11 +1914,13 @@ export function PlatformEntryFlowShellImpl({ }, [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 expectedStatus = + propKind === 'extendTime' ? 'failed' : 'playing'; + if (puzzleRun.currentLevel.status !== expectedStatus) { return null; } @@ -1836,7 +1930,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; @@ -1859,6 +1955,92 @@ export function PlatformEntryFlowShellImpl({ [platformBootstrap, puzzleRun], ); + const restartPuzzleCurrentLevel = useCallback(async () => { + const currentLevel = puzzleRun?.currentLevel ?? null; + if (!puzzleRun || !currentLevel || isPuzzleBusy) { + return; + } + + setPuzzleError(null); + 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, + currentLevel.levelId ?? null, + ); + }, [ + 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 resumeRpgProfileSaveArchive( + entry.worldKey, + ); + platformBootstrap.setSaveEntries((currentEntries) => + currentEntries.map((currentEntry) => + currentEntry.worldKey === resumedArchive.entry.worldKey + ? resumedArchive.entry + : currentEntry, + ), + ); + const gameState = resumedArchive.snapshot + .gameState as PuzzleSaveArchiveState; + 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') { @@ -1916,7 +2098,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError, ]); - const advancePuzzleLevel = useCallback(async () => { + const advancePuzzleLevel = useCallback(async (target?: { + profileId?: string; + levelId?: string | null; + }) => { if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { return; } @@ -1931,6 +2116,21 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { + 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, @@ -1954,6 +2154,7 @@ export function PlatformEntryFlowShellImpl({ puzzleSession, resolvePuzzleErrorMessage, selectedPuzzleDetail, + startPuzzleRunFromProfile, ]); const leaveAgentWorkspace = useCallback(() => { @@ -2262,24 +2463,27 @@ export function PlatformEntryFlowShellImpl({ return; } setBigFishGalleryEntries((current) => - current.map((item) => mergeBigFishWorkSummary(item, updatedWork)), + current.map((item) => + mergeBigFishWorkSummary(item, updatedWork), + ), ); setBigFishWorks((current) => - current.map((item) => mergeBigFishWorkSummary(item, updatedWork)), + current.map((item) => + mergeBigFishWorkSummary(item, updatedWork), + ), ); syncUpdatedPublicWorkDetail( mapBigFishWorkToPublicWorkDetail(updatedWork), ); setBigFishRuntimeWork((current) => - current ? mergeBigFishWorkSummary(current, updatedWork) : current, + current + ? mergeBigFishWorkSummary(current, updatedWork) + : current, ); }) .catch((error) => { setPublicWorkDetailError( - resolveBigFishErrorMessage( - error, - '点赞大鱼吃小鱼作品失败。', - ), + resolveBigFishErrorMessage(error, '点赞大鱼吃小鱼作品失败。'), ); }) .finally(() => { @@ -2293,13 +2497,19 @@ export function PlatformEntryFlowShellImpl({ .then((response) => { const updatedWork = response.item; setPuzzleGalleryEntries((current) => - current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), ); setPuzzleWorks((current) => - current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), + current.map((item) => + mergePuzzleWorkSummary(item, updatedWork), + ), ); setSelectedPuzzleDetail((current) => - current ? mergePuzzleWorkSummary(current, updatedWork) : current, + current + ? mergePuzzleWorkSummary(current, updatedWork) + : current, ); syncUpdatedPublicWorkDetail( mapPuzzleWorkToPublicWorkDetail(updatedWork), @@ -2319,13 +2529,13 @@ export function PlatformEntryFlowShellImpl({ void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId) .then((updatedEntry) => { setSelectedDetailEntry((current) => - current?.profileId === updatedEntry.profileId ? updatedEntry : current, + current?.profileId === updatedEntry.profileId + ? updatedEntry + : current, ); platformBootstrap.setPublishedGalleryEntries((current) => current.map((item) => - item.profileId === updatedEntry.profileId - ? mapRpgGalleryCardToPublicWorkDetail(updatedEntry) - : item, + item.profileId === updatedEntry.profileId ? updatedEntry : item, ), ); syncUpdatedPublicWorkDetail( @@ -3186,6 +3396,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} @@ -3286,7 +3503,9 @@ export function PlatformEntryFlowShellImpl({ { @@ -3568,7 +3787,7 @@ export function PlatformEntryFlowShellImpl({ void submitPuzzleMessage(payload); }} onExecuteAction={(payload) => { - void executePuzzleAction(payload); + executePuzzleWorkspaceAction(payload); }} initialFormPayload={puzzleFormDraftPayload} onCreateFromForm={(payload) => { @@ -3610,13 +3829,7 @@ export function PlatformEntryFlowShellImpl({ onEditSetting={() => { setSelectionStage('puzzle-agent-workspace'); }} - onRetry={() => { - void executePuzzleAction( - buildPuzzleCompileActionFromFormPayload( - puzzleFormDraftPayload, - ), - ); - }} + onRetry={retryPuzzleDraftGeneration} onInterrupt={undefined} backLabel="返回创作中心" settingActionLabel={null} @@ -3635,35 +3848,35 @@ export function PlatformEntryFlowShellImpl({ {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} diff --git a/src/components/platform-entry/PlatformWorkDetailView.test.tsx b/src/components/platform-entry/PlatformWorkDetailView.test.tsx index 03c693e8..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 { fireEvent, render, screen } from '@testing-library/react'; -import { expect, test, vi } from 'vitest'; +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,6 +52,10 @@ function createPuzzleEntry(): PlatformPublicGalleryCard { }; } +afterEach(() => { + vi.useRealTimers(); +}); + test('PlatformWorkDetailView renders compact stats and date time', () => { render( { 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 94f6dbbd..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,7 +22,7 @@ import { formatPlatformWorldTime, type PlatformPublicGalleryCard, resolvePlatformPublicWorkCode, - resolvePlatformWorldCoverImage, + resolvePlatformWorldCoverSlides, resolvePlatformWorldStats, } from '../rpg-entry/rpgEntryWorldPresentation'; @@ -58,6 +60,8 @@ function getAuthorAvatarLabel(authorDisplayName: string) { return Array.from(authorDisplayName.trim() || '作')[0] ?? '作'; } +const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200; + export function PlatformWorkDetailView({ entry, authorAvatarUrl, @@ -69,7 +73,15 @@ export function PlatformWorkDetailView({ 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 = @@ -121,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; @@ -184,6 +236,46 @@ export function PlatformWorkDetailView({ alt={entry.worldName} className="platform-work-detail__cover-image" /> + {hasCoverCarousel ? ( + <> + + +
+ {coverSlides.map((slide, index) => ( +
+ + ) : null} ) : (
diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 1ecea91b..581d1b14 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -19,7 +19,15 @@ export type PlatformCreationTypeCard = { * 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。 */ export function getVisiblePlatformCreationTypes() { - return PLATFORM_CREATION_TYPES.filter((item) => !item.hidden); + const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter( + (item) => !item.hidden, + ); + + // 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。 + return [ + ...visibleCreationTypes.filter((item) => !item.locked), + ...visibleCreationTypes.filter((item) => item.locked), + ]; } /** diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index fdc1f8f1..be164543 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -110,7 +110,9 @@ export function PuzzleAgentWorkspace({ const [referenceImageError, setReferenceImageError] = useState( null, ); - const previousSessionIdRef = useRef(session?.sessionId ?? null); + const previousSessionIdRef = useRef( + session?.sessionId ?? null, + ); const appliedInitialFormKeyRef = useRef(null); useEffect(() => { @@ -118,7 +120,8 @@ export function PuzzleAgentWorkspace({ if ( currentSessionId && previousSessionIdRef.current === null && - appliedInitialFormKeyRef.current === JSON.stringify(initialFormPayload ?? null) + appliedInitialFormKeyRef.current === + JSON.stringify(initialFormPayload ?? null) ) { previousSessionIdRef.current = currentSessionId; return; diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx index 5d4d4d4b..917da52e 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.test.tsx @@ -2,13 +2,22 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { act } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView'; vi.mock('../ResolvedAssetImage', () => ({ - ResolvedAssetImage: () => null, + ResolvedAssetImage: ({ + src, + alt, + className, + }: { + src?: string | null; + alt?: string; + className?: string; + }) => {alt, })); const originalClipboard = navigator.clipboard; @@ -33,6 +42,7 @@ const detailItem = { } satisfies PuzzleWorkSummary; afterEach(() => { + vi.useRealTimers(); vi.clearAllMocks(); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -40,6 +50,72 @@ afterEach(() => { }); }); +test('cycles every level image on puzzle detail cover', async () => { + vi.useFakeTimers(); + + render( + , + ); + + expect(screen.getByAltText('第一关').getAttribute('src')).toBe( + '/level-1.png', + ); + + act(() => { + screen.getByRole('button', { name: '下一张关卡图' }).click(); + }); + + expect(screen.getByAltText('第二关').getAttribute('src')).toBe( + '/level-2.png', + ); + + act(() => { + vi.advanceTimersByTime(4200); + }); + + expect(screen.getByAltText('第一关').getAttribute('src')).toBe( + '/level-1.png', + ); +}); + test('shows and copies puzzle public work code in detail view', async () => { const user = userEvent.setup(); const writeText = vi.fn(async () => undefined); diff --git a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx index 3e81eb18..11b93a66 100644 --- a/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx +++ b/src/components/puzzle-gallery/PuzzleGalleryDetailView.tsx @@ -1,5 +1,14 @@ -import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react'; -import { useState } from 'react'; +import { + ArrowLeft, + ChevronLeft, + ChevronRight, + Copy, + Pencil, + Play, + Share2, + UserRound, +} from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; @@ -7,6 +16,7 @@ import { copyTextToClipboard } from '../../services/clipboard'; import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { + buildPuzzleWorkCoverSlides, formatPlatformWorkDisplayName, formatPlatformWorkDisplayTags, } from '../rpg-entry/rpgEntryWorldPresentation'; @@ -20,6 +30,8 @@ type PuzzleGalleryDetailViewProps = { onStartGame: () => void; }; +const PUZZLE_DETAIL_COVER_CAROUSEL_INTERVAL_MS = 4200; + /** * 拼图广场详情页。 * 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。 @@ -41,6 +53,53 @@ export function PuzzleGalleryDetailView({ ); const displayName = formatPlatformWorkDisplayName(item.levelName); const displayTags = formatPlatformWorkDisplayTags(item.themeTags); + const coverSlides = useMemo(() => buildPuzzleWorkCoverSlides(item), [item]); + const [activeCoverIndex, setActiveCoverIndex] = useState(0); + const activeCoverSlide = + coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null; + const coverImageSrc = activeCoverSlide?.imageSrc ?? ''; + const hasCoverCarousel = coverSlides.length > 1; + + useEffect(() => { + setActiveCoverIndex(0); + }, [item.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); + }, PUZZLE_DETAIL_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 = () => { void copyTextToClipboard(publicWorkCode).then((copied) => { setCopyState(copied ? 'copied' : 'failed'); @@ -151,13 +210,55 @@ export function PuzzleGalleryDetailView({
-
- {item.coverImageSrc ? ( - +
+ {coverImageSrc ? ( + <> + + {hasCoverCarousel ? ( + <> + + +
+ {coverSlides.map((slide, index) => ( +
+ + ) : null} + ) : (
暂无封面 diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 5962cd95..af1af2be 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -239,9 +239,30 @@ describe('PuzzleResultView', () => { promptText: '一只猫在雨夜灯牌下回头。', referenceImageSrc: undefined, candidateCount: 1, + levelsJson: expect.any(String), }); + const generatePayload = onExecuteAction.mock.calls[0]![0]; + expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([ + expect.objectContaining({ + levelId: 'puzzle-level-1', + levelName: '暖灯猫街', + pictureDescription: '一只猫在雨夜灯牌下回头。', + }), + ]); - fireEvent.click(within(dialog).getByRole('button', { name: /体验该关/u })); + const levelNameInput = within(dialog).getByLabelText('关卡名称'); + const formalImageTitle = within(dialog).getByText('画面图'); + const pictureDescriptionInput = within(dialog).getByLabelText('画面描述'); + expect( + levelNameInput.compareDocumentPosition(formalImageTitle) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + formalImageTitle.compareDocumentPosition(pictureDescriptionInput) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: /关卡测试/u })); expect(onStartTestRun).toHaveBeenCalledWith( expect.objectContaining({ levelName: '暖灯猫街', @@ -272,7 +293,10 @@ describe('PuzzleResultView', () => { ); fireEvent.click(screen.getByRole('button', { name: /新增关卡/u })); - expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy(); + const dialog = screen.getByRole('dialog', { name: '关卡详情' }); + expect(within(dialog).getByRole('button', { name: /生成画面/u })).toBeTruthy(); + expect(within(dialog).queryByText('画面图')).toBeNull(); + expect(within(dialog).queryByRole('button', { name: /关卡测试/u })).toBeNull(); fireEvent.click(screen.getByLabelText('关闭')); expect(screen.getAllByText('第2关').length).toBeGreaterThan(0); @@ -285,7 +309,7 @@ describe('PuzzleResultView', () => { expect.objectContaining({ levels: expect.arrayContaining([ expect.objectContaining({ levelId: 'puzzle-level-1' }), - expect.objectContaining({ levelName: '第2关' }), + expect.objectContaining({ levelName: '' }), ]), }), ); @@ -309,6 +333,45 @@ describe('PuzzleResultView', () => { ); }); + test('generates image for a newly added level with the current levels snapshot', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000); + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: /新增关卡/u })); + const dialog = screen.getByRole('dialog', { name: '关卡详情' }); + fireEvent.change(within(dialog).getByLabelText('画面描述'), { + target: { value: '新关卡里有一座发光钟楼。' }, + }); + fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u })); + + expect(onExecuteAction).toHaveBeenCalledWith({ + action: 'generate_puzzle_images', + levelId: 'puzzle-level-1775000000000-2', + promptText: '新关卡里有一座发光钟楼。', + referenceImageSrc: undefined, + candidateCount: 1, + levelsJson: expect.any(String), + }); + + const payload = onExecuteAction.mock.calls[0]![0]; + expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([ + expect.objectContaining({ levelId: 'puzzle-level-1' }), + expect.objectContaining({ + levelId: 'puzzle-level-1775000000000-2', + levelName: '', + pictureDescription: '新关卡里有一座发光钟楼。', + }), + ]); + }); + test('publishes with work info and serialized levels', () => { const onExecuteAction = vi.fn(); @@ -394,6 +457,7 @@ describe('PuzzleResultView', () => { promptText: '屋檐下的猫与暖灯街角。', referenceImageSrc: '/generated-puzzle-assets/history/image.png', candidateCount: 1, + levelsJson: expect.any(String), }); }); }); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index fcc56c14..e720cf5d 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -16,7 +16,6 @@ import { createPortal } from 'react-dom'; import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions'; import type { PuzzleDraftLevel, - PuzzleGeneratedImageCandidate, PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; @@ -84,7 +83,7 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) { function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel { return { levelId: 'puzzle-level-1', - levelName: draft.levelName || draft.workTitle || '第一关', + levelName: draft.levelName || '', pictureDescription: draft.summary, candidates: draft.candidates, selectedCandidateId: draft.selectedCandidateId, @@ -103,7 +102,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) { return sourceLevels.map((level, index) => ({ ...level, levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`, - levelName: level.levelName?.trim() || `第${index + 1}关`, + levelName: level.levelName?.trim() || '', pictureDescription: level.pictureDescription?.trim() || draft.summary, candidates: level.candidates ?? [], selectedCandidateId: level.selectedCandidateId ?? null, @@ -148,7 +147,7 @@ function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraft const nextIndex = existingLevels.length + 1; return { levelId: `puzzle-level-${Date.now()}-${nextIndex}`, - levelName: `第${nextIndex}关`, + levelName: '', pictureDescription: '', candidates: [], selectedCandidateId: null, @@ -585,6 +584,7 @@ function PuzzleLevelDetailDialog({ const [referenceImageError, setReferenceImageError] = useState(null); const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false); const formalImageSrc = resolveLevelFormalImageSrc(level); + const hasFormalImage = Boolean(formalImageSrc); const handleReferenceImageChange = async ( event: ChangeEvent, @@ -626,7 +626,7 @@ function PuzzleLevelDetailDialog({ role="dialog" aria-modal="true" aria-label="关卡详情" - className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]" + className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-2xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]" onClick={(event) => event.stopPropagation()} >
@@ -644,167 +644,159 @@ function PuzzleLevelDetailDialog({
-
-
-
-
- 关卡名称 -
- - onLevelChange({ ...level, levelName: event.target.value }) - } - className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none" - aria-label="关卡名称" - /> -
+
+
+
+ 关卡名称 +
+ + onLevelChange({ ...level, levelName: event.target.value }) + } + className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none" + aria-label="关卡名称" + /> +
-
-
- 画面描述 -
-
-