Compare commits
9 Commits
d5627c536d
...
35958d5942
| Author | SHA1 | Date | |
|---|---|---|---|
| 35958d5942 | |||
| 7e49c3a3c6 | |||
| 6700e99dc8 | |||
| 6e4c941601 | |||
| 468b88b105 | |||
| 81e59f90ce | |||
| fc6519a7b7 | |||
| 2fe0a9083d | |||
| 4c8ba535e4 |
@@ -17,6 +17,13 @@ VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image"
|
||||
NODE_SERVER_ADDR=":8081"
|
||||
NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
||||
|
||||
# M7 backend cutover switch for local/gray dev proxy.
|
||||
# Keep `node` by default. Set to `rust` to point Vite dev proxy at the Rust Axum server.
|
||||
GENARRATIVE_BACKEND_STACK="node"
|
||||
RUST_SERVER_TARGET="http://127.0.0.1:3000"
|
||||
# Optional hard override. When set, it wins over GENARRATIVE_BACKEND_STACK/NODE_SERVER_TARGET/RUST_SERVER_TARGET.
|
||||
GENARRATIVE_RUNTIME_SERVER_TARGET=""
|
||||
|
||||
# Local Caddy upstream target used for dist-based testing.
|
||||
CADDY_API_UPSTREAM="http://127.0.0.1:8081"
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。
|
||||
6. 已新增 `docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `battle_state` 与 `resolve_combat_action` 的首版字段与规则口径。
|
||||
7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。
|
||||
8. 已冻结 `treasure_record` 的首版领域类型、完整奖励物品快照和字段校验规则。
|
||||
9. 已在 `server-rs/crates/spacetime-module` 中新增 `treasure_record` 表。
|
||||
10. 已新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure,并把宝箱奖励同步写入 `inventory_slot`。
|
||||
8. 已冻结 runtime item 侧奖励快照与物品写回基线,为后续奖励链并入 inventory / quest / combat 提供统一底层能力。
|
||||
9. 已在 `server-rs/crates/spacetime-module` 中补齐 runtime item / inventory / quest / combat 所需的奖励落表与回写依赖。
|
||||
10. 当前 M4 runtime story compat bridge 已明确移除旧 `treasure_*` 遭遇动作概念,不再把宝箱遭遇视作本阶段 runtime story 主链目标。
|
||||
11. 已新增 `docs/technical/M4_RPG_RUNTIME_INVENTORY_SPACETIMEDB_BASELINE_2026-04-21.md`,冻结 `inventory_slot` 与 `apply_inventory_mutation` 的首版字段与规则口径。
|
||||
12. 已新增 `server-rs/crates/module-inventory` 真实 crate。
|
||||
13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。
|
||||
@@ -104,44 +104,86 @@
|
||||
- `battle_guard_break`
|
||||
- `battle_probe_pressure`
|
||||
- `battle_recover_breath`
|
||||
- `treasure_secure`
|
||||
- `treasure_inspect`
|
||||
- `treasure_leave`
|
||||
- `inventory_use`
|
||||
- `equipment_equip`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
47. `actions/resolve` 已补 `clientVersion` 与 `gameState.runtimeActionVersion` 的冲突校验、动作后版本自增、`storyHistory` 追加和 snapshot 回写。
|
||||
48. `initial` / `continue` 已先落稳定 `RuntimeStoryAiResponse`:
|
||||
- 优先透传 `requestOptions.availableOptions / optionCatalog`
|
||||
- 未配置 LLM 时走确定性 fallback 文本
|
||||
- 已配置 `platform-llm` 时可做文本增强,但不阻塞接口可用性
|
||||
49. 已执行 `cargo test -p shared-contracts`、`cargo check -p api-server`、`cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。
|
||||
50. 已补 Rust 侧 route boundary 回归:
|
||||
49. `actions/resolve` 已开始迁移 Node 动作后 LLM 增强分支的最小闭环:
|
||||
- `npc_chat / story_opening_camp_dialogue` 在配置 `platform-llm` 时会尝试生成对话态 `storyText`
|
||||
- NPC 对话增强回包会对齐 Node 旧 `displayMode = dialogue + deferredOptions` 结构,先只展示“继续推进冒险”
|
||||
- `battle victory / spar_complete / escaped` 在配置 `platform-llm` 时会尝试生成结果叙事,但不改既有规则结算
|
||||
- LLM 不可用或生成失败时自动回退到确定性 `resultText / currentStory`
|
||||
50. 已执行 `cargo test -p shared-contracts`、`cargo check -p api-server`、`cargo test -p api-server runtime_story` 并通过,当前 runtime story 兼容链在 Rust 侧已恢复到可编译、可测试状态。
|
||||
51. 已补 Rust 侧 route boundary 回归:
|
||||
- `runtime_story_routes_resolve_through_rust_route_boundary`
|
||||
- `runtime_story_action_resolve_rejects_client_version_conflict`
|
||||
- `runtime_story_npc_help_is_one_shot_and_restores_resources`
|
||||
- `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full`
|
||||
51. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链:
|
||||
52. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链:
|
||||
- `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52`
|
||||
- `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4`
|
||||
- `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId`
|
||||
52. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。
|
||||
53. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥:
|
||||
53. 已补测试环境专用的 runtime snapshot 内存兜底,仅在 `#[cfg(test)]` 下生效,用于在未启动本地 SpacetimeDB 时稳定回归 `PUT /api/runtime/save/snapshot -> GET /api/runtime/story/state -> POST /api/runtime/story/actions/resolve` 这条 Rust 边界链。
|
||||
54. 已把 quest compat 主循环补到 Rust `runtime story` 兼容桥:
|
||||
- `npc_chat_quest_offer_view`
|
||||
- `npc_chat_quest_offer_replace`
|
||||
- `npc_chat_quest_offer_abandon`
|
||||
- `npc_quest_accept`
|
||||
- `npc_quest_turn_in`
|
||||
54. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。
|
||||
55. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler:
|
||||
55. 已把 quest offer 对话态的 `currentStory.npcChatState.pendingQuestOffer` 与前端面板依赖的 `runtimePayload.npcChatQuestOfferAction` 一并回填到 Rust compat 回包,保证现有 quest 面板入口不回退。
|
||||
56. 已把 `npc_quest_turn_in` 的最小奖励闭环补回 Rust compat handler:
|
||||
- quest 状态改为保留在 `gameState.quests` 中的 `turned_in`
|
||||
- 同步写回 `playerCurrency`
|
||||
- 同步写回 `playerInventory`
|
||||
- 同步写回 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource`
|
||||
- 同步写回 NPC `affinity`
|
||||
56. 已新增 quest compat Rust 回归:
|
||||
57. 已新增 quest compat Rust 回归:
|
||||
- `runtime_story_quest_offer_replace_updates_pending_offer_and_payload`
|
||||
- `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options`
|
||||
- `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story`
|
||||
- `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity`
|
||||
57. 已再次执行 `cargo test -p api-server runtime_story`、`cargo check -p api-server` 与 `node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。
|
||||
58. 已再次执行 `cargo test -p api-server runtime_story`、`cargo check -p api-server` 与 `node scripts/check-encoding.mjs` 并通过,当前 quest compat 已恢复到可编译、可回归状态。
|
||||
59. 已继续把 Task6 旧 inventory / NPC inventory compat 主链补回 Rust `runtime story` 兼容桥:
|
||||
- `equipment_equip`
|
||||
- `equipment_unequip`
|
||||
- `forge_craft`
|
||||
- `forge_dismantle`
|
||||
- `forge_reforge`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
60. 已把 NPC 交互态 fallback option compiler 对齐到 Node 旧顺序,当前会按条件输出:
|
||||
- `npc_chat`
|
||||
- `npc_help`
|
||||
- `npc_spar`
|
||||
- `npc_fight`
|
||||
- `npc_trade`
|
||||
- `npc_gift`
|
||||
- `npc_quest_accept / npc_quest_turn_in`
|
||||
- `npc_recruit`
|
||||
- `npc_leave`
|
||||
61. 已新增 Rust compat 回归:
|
||||
- `runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock`
|
||||
- `runtime_story_equipment_equip_updates_loadout_and_build_toast`
|
||||
- `runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout`
|
||||
- `runtime_story_forge_craft_consumes_materials_and_currency`
|
||||
- `runtime_story_forge_dismantle_replaces_item_with_material_outputs`
|
||||
- `runtime_story_forge_reforge_upgrades_item_and_consumes_cost`
|
||||
- `runtime_story_npc_trade_buy_updates_currency_inventory_and_stock`
|
||||
- `runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc`
|
||||
- `runtime_story_npc_trade_buy_bootstraps_missing_npc_state`
|
||||
- `runtime_story_npc_gift_updates_affinity_inventory_and_patch`
|
||||
- `runtime_story_route_boundary_persists_equipment_equip_snapshot_updates`
|
||||
62. 当前 Rust compat bridge 已补入口级 NPC 状态预处理:即使快照里的 `npcStates` 为空,纯商贩型 NPC 也会在 `state/get` 与 `actions/resolve` 前自动初始化基础关系态、`stanceProfile / relationState / tradeStockSignature` 与最小 trade stock。
|
||||
63. 当前 `actions/resolve` 已不再只停留在确定性 `storyText = resultText`:
|
||||
- 已在 Rust 侧新增 `generate_action_story_payload(...)`
|
||||
- 已对齐 Node 旧分支的最小范围 `npc_chat / story_opening_camp_dialogue / terminal combat outcome`
|
||||
- 当前仍未迁移 Node 那套完整 orchestrator 选项重排,只先保留既有 fallback options
|
||||
64. 当前 `cargo test -p api-server runtime_story` 已提升到 30 条回归通过。
|
||||
|
||||
当前验证边界补充:
|
||||
|
||||
@@ -150,9 +192,9 @@
|
||||
3. 当前可以确认的是:
|
||||
- `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通
|
||||
- Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖
|
||||
- 真正的 `resolve_story_action / sync_runtime_snapshot_projection` 真相链仍未完成
|
||||
- Rust `actions/resolve` 已开始承接 Node 动作后 LLM 文本增强,但完整 orchestrator / 真相链仍未完成
|
||||
|
||||
当前这轮仍未扩到真正的 SpacetimeDB `resolve_story_action` / `sync_runtime_snapshot_projection` 真相 reducer,也还没有完成前端默认切流到 Rust `api-server`。当前已完成的是“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧的快照桥与确定性动作闭环”,后续 `M4` 继续推进真相态替换与前端切换。
|
||||
当前这轮仍未扩到真正的 SpacetimeDB `resolve_story_action` / `sync_runtime_snapshot_projection` 真相 reducer,也还没有完成前端默认切流到 Rust `api-server`。当前已完成的是“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧的快照桥 + 确定性动作闭环 + 最小动作后 LLM 文本增强”,后续 `M4` 继续推进真相态替换与前端切换。
|
||||
|
||||
## 1. SpacetimeDB gameplay 表
|
||||
|
||||
@@ -161,7 +203,7 @@
|
||||
- [x] 设计 `npc_state`
|
||||
- [x] 设计 `quest_record`
|
||||
- [x] 设计 `inventory_slot`
|
||||
- [x] 设计 `treasure_record`
|
||||
- [x] 设计 runtime item 奖励快照基线
|
||||
- [x] 设计 `battle_state`
|
||||
- [x] 设计 `player_progression`
|
||||
- [x] 设计 `chapter_progression`
|
||||
@@ -175,7 +217,7 @@
|
||||
- [x] 设计 `apply_quest_signal`
|
||||
- [x] 设计 `apply_inventory_mutation`
|
||||
- [x] 设计 `resolve_npc_interaction`
|
||||
- [x] 设计 `resolve_treasure_interaction`
|
||||
- [x] 设计 runtime item 奖励回写基线
|
||||
- [x] 设计 `resolve_combat_action`
|
||||
- [x] 设计 `update_progression_state`
|
||||
|
||||
@@ -231,7 +273,7 @@
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] 当前前端 story 选项点击后可走新后端闭环
|
||||
- [ ] NPC / quest / treasure / combat 主循环行为不回退
|
||||
- [ ] NPC / quest / combat 主循环行为不回退
|
||||
- [x] `story state` 恢复链可用
|
||||
- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
|
||||
- [x] 旧 Node 版 story route 回归用例完成平移
|
||||
@@ -250,11 +292,12 @@
|
||||
- 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary`
|
||||
- 已补 `clientVersion` 冲突回归
|
||||
- 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler
|
||||
4. `NPC / quest / treasure / combat 主循环行为不回退` 仍不能勾选:
|
||||
- 虽然 `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / treasure_* / battle_*` 已有确定性兼容闭环
|
||||
- 且 quest 交付奖励、quest offer 对话态与 quest follow-up 选项已补到 Rust compat handler
|
||||
- 但 treasure / combat 的更大范围 Node 回归还没全部平移
|
||||
- 真相态 reducer 仍未替换 compat bridge
|
||||
4. `NPC / quest / combat 主循环行为不回退` 仍不能勾选:
|
||||
- 当前 runtime story compat bridge 已明确移除 `treasure_*` 遭遇动作,不再把 treasure 视作本阶段 runtime story 主循环的一部分。
|
||||
- `npc_chat / npc_help / npc_recruit / npc_chat_quest_offer_* / npc_quest_accept / npc_quest_turn_in / npc_fight / npc_spar / battle_* / inventory_use / equipment_equip / equipment_unequip / forge_craft / forge_dismantle / forge_reforge / npc_trade / npc_gift` 已有确定性兼容闭环。
|
||||
- 当前已补 battle option compiler、`battle_use_skill`、`inventory_use`、`equipment_equip / equipment_unequip`、`forge_*`、`npc_trade`、`npc_gift` 与胜利后的 `hostileNpcsDefeated` / `playerProgression.lastGrantedSource = hostile_npc` 写回。
|
||||
- 当前已补 NPC 交互态入口预处理:纯商贩型 NPC 即使没有预填 `npcStates.*.inventory`,也会在 compat bridge 内自动恢复可交易库存与基础关系态,不再依赖 Node 侧预热。
|
||||
- 但 combat 更大范围 Node 回归仍未全部平移,真相态 reducer 也仍未替换 compat bridge。
|
||||
5. `后端边界与当前 rpgEntry -> ...` 仍不能勾选:
|
||||
- 前端真实调用链已对齐 `/api/runtime/story/*`
|
||||
- 但“默认走 Rust server”的联调证据仍未冻结
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
- [x] 设计 public / private 对象访问策略
|
||||
- [x] 设计签名 URL 输出策略
|
||||
- [x] 设计 `x-oss-meta-*` 元数据规范
|
||||
- [ ] 设计内容 hash / 版本字段规范
|
||||
- [x] 设计内容 hash / 版本字段规范(Stage 1 明确为 `asset_object.content_hash: Option<String>` + `version = 1`,后续强 hash 单独阶段再扩)
|
||||
|
||||
## 2. 上传与对象确认
|
||||
|
||||
@@ -44,13 +44,13 @@
|
||||
|
||||
## 3. 资产任务系统
|
||||
|
||||
- [ ] 设计 `asset_job`
|
||||
- [x] 设计 `asset_job`(Stage 1 明确不新增重复表,AI 资产任务先复用 `AiTaskService / ai_task` 口径)
|
||||
- [x] 设计 `asset_object`
|
||||
- [ ] 设计 `asset_manifest`
|
||||
- [ ] 设计 `character_visual_asset`
|
||||
- [ ] 设计 `character_animation_asset`
|
||||
- [ ] 设计 `scene_image_asset`
|
||||
- [ ] 设计 `sprite_sheet_asset`
|
||||
- [x] 设计 `asset_manifest`(Stage 1 使用 OSS JSON manifest + `asset_object` 表达集合对象,不新增结构化表)
|
||||
- [x] 设计 `character_visual_asset`(Stage 1 使用 `asset_entity_binding: character / primary_visual`,强业务表延后)
|
||||
- [x] 设计 `character_animation_asset`(Stage 1 使用 `asset_entity_binding: character / animation_set` 绑定总 manifest,强业务表延后)
|
||||
- [x] 设计 `scene_image_asset`(Stage 1 使用 `asset_entity_binding: custom_world_landmark / scene_image`,强业务表延后)
|
||||
- [x] 设计 `sprite_sheet_asset`(Qwen 独立工具已清理,Stage 1 仅保留历史 `/generated-qwen-sprites/*` 读取兼容)
|
||||
|
||||
补充说明:
|
||||
|
||||
@@ -63,60 +63,91 @@
|
||||
- [../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](../docs/technical/ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
- [../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](../docs/technical/M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
3. 当前已在 `server-rs/crates/spacetime-module` 落下 `asset_object` 首版表骨架,并完成 `api-server -> SpacetimeDB` 的最小对象确认闭环。
|
||||
4. 元数据、版本、manifest 与强业务资产表边界见:
|
||||
- [../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](../docs/technical/M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md)
|
||||
|
||||
## 4. 资产生成链路
|
||||
|
||||
- [ ] 迁移角色主形象生成
|
||||
- [ ] 迁移角色动作生成
|
||||
- [ ] 迁移动作模板查询
|
||||
- [ ] 迁移视频导入
|
||||
- [ ] 迁移工作流缓存
|
||||
- [ ] 迁移 Qwen 主图生成
|
||||
- [ ] 迁移 Qwen 整表生成
|
||||
- [ ] 迁移 Qwen 修帧
|
||||
- [ ] 迁移 Qwen 保存
|
||||
- [ ] 迁移场景图生成
|
||||
- [ ] 迁移封面图上传
|
||||
- [x] 迁移角色主形象生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前仍为 SVG 占位生成,不代表真实 DashScope 图片模型已迁完)
|
||||
- [x] 迁移角色动作生成(Stage 1 已接通 Rust `generate / jobs / publish` 最小 OSS 主链,当前 `image-sequence` 为 SVG 占位帧,视频类策略优先复用参考视频或仓库占位预览,不代表真实视频模型已迁完)
|
||||
- [x] 迁移动作模板查询(Stage 1 已接通 Rust 内置模板列表兼容接口)
|
||||
- [x] 迁移视频导入(Stage 1 已接通 Data URL 视频导入到 OSS 草稿区,不再写本地 `public/`)
|
||||
- [x] 迁移工作流缓存(Stage 1 已接通 Rust `GET/POST character-workflow-cache` 到 OSS JSON 草稿对象,不再写本地 `public/`)
|
||||
- [x] 迁移场景图生成(已完成 Stage 2:custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`)
|
||||
- [x] 迁移封面图上传(已完成 Stage 2:custom world `cover-image / cover-upload` 已补齐真实 DashScope 生成与 `cropRect + 16:9 + WebP 压缩`)
|
||||
- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`)
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 本次收口只解决 custom world 兼容图片入口的正式资产真相链,不代表 DashScope 图片生成、任务状态、封面裁剪压缩能力已全量迁完。
|
||||
1. custom world 兼容图片入口现已完成 Stage 1 + Stage 2:正式资产真相链、真实 DashScope 图片生成,以及封面上传裁剪压缩都已迁完。
|
||||
2. 详细边界见:
|
||||
- [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 角色动作模板与视频导入第一批已新增独立设计文档,当前只迁移:
|
||||
- `GET /api/assets/character-animation/templates`
|
||||
- `POST /api/assets/character-animation/import-video`
|
||||
- [../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
4. 角色资产工作流缓存第一批已新增独立设计文档,当前把旧本地 `workflow-cache.json` 改为 OSS JSON 草稿对象:
|
||||
- `GET /api/assets/character-workflow-cache/:characterId`
|
||||
- `POST /api/assets/character-workflow-cache`
|
||||
- [../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md)
|
||||
5. `2026-04-22` 复核确认:旧独立 `qwen-sprite-tool + qwenSpriteRoutes.ts` 已在 `2026-04-21` 清理,不再作为本轮现役迁移主链;当前仍保留的 `Qwen` 相关内容仅包括:
|
||||
- 角色资产 prompt 层对 `packages/shared/src/prompts/qwenSprite.ts` 的复用
|
||||
- 历史资源前缀 `/generated-qwen-sprites/*` 的读取兼容
|
||||
6. custom world 图片链 Stage 2 已完成:
|
||||
- `scene-image / cover-image` 已替换为真实 DashScope 图片生成
|
||||
- `cover-upload` 已补回 Node 旧链路中的 `cropRect + 16:9 + WebP 压缩`
|
||||
- 详细口径与验证结果见 [../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](../docs/technical/M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md)
|
||||
|
||||
## 5. 路径兼容
|
||||
|
||||
- [ ] 兼容 `/generated-character-drafts/*`
|
||||
- [ ] 兼容 `/generated-characters/*`
|
||||
- [ ] 兼容 `/generated-custom-world-scenes/*`
|
||||
- [ ] 兼容 `/generated-qwen-sprites/*`
|
||||
- [x] 兼容 `/generated-character-drafts/*`
|
||||
- [x] 兼容 `/generated-characters/*`
|
||||
- [x] 兼容 `/generated-animations/*`
|
||||
- [x] 兼容 `/generated-custom-world-scenes/*`
|
||||
- [x] 兼容 `/generated-custom-world-covers/*`
|
||||
- [x] 兼容 `/generated-qwen-sprites/*`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 第一批路径兼容由 Rust `api-server` 同源代理到私有 OSS 短期读签名,不回退本地 `public/`,详细边界见:
|
||||
- [../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](../docs/technical/M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md)
|
||||
2. 当前 Stage 1 先全量代理对象内容,不实现视频 Range 分片;若后续真实视频体积变大,再按播放器需求补 Range。
|
||||
|
||||
## 6. 兼容接口
|
||||
|
||||
- [ ] 兼容 `/api/assets/character-visual/generate`
|
||||
- [ ] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||
- [ ] 兼容 `/api/assets/character-visual/publish`
|
||||
- [ ] 兼容 `/api/assets/character-animation/generate`
|
||||
- [ ] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||
- [ ] 兼容 `/api/assets/character-animation/publish`
|
||||
- [ ] 兼容 `/api/assets/character-animation/import-video`
|
||||
- [ ] 兼容 `/api/assets/character-animation/templates`
|
||||
- [ ] 兼容 `/api/assets/character-workflow-cache`
|
||||
- [ ] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/master`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/sheet`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/frame-repair`
|
||||
- [ ] 兼容 `/api/assets/qwen-sprite/save`
|
||||
- [x] 兼容 `/api/assets/character-visual/generate`
|
||||
- [x] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-visual/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/generate`
|
||||
- [x] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||
- [x] 兼容 `/api/assets/character-animation/publish`
|
||||
- [x] 兼容 `/api/assets/character-animation/import-video`
|
||||
- [x] 兼容 `/api/assets/character-animation/templates`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache`
|
||||
- [x] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
||||
## 7. 阶段验收
|
||||
|
||||
- [x] OSS 直传对象可被服务端确认并写入 `asset_object`
|
||||
- [ ] 所有新生成资产都写入 OSS
|
||||
- [ ] 前端仍能通过旧路径习惯访问资源
|
||||
- [ ] 资产任务状态可查询
|
||||
- [x] 所有新生成资产都写入 OSS(Stage 1 覆盖当前现役角色主形象、角色动作、workflow cache、视频导入、custom world 场景图/封面图;历史清理掉的 Qwen 独立工具不再计入现役主链)
|
||||
- [x] 前端仍能通过旧路径习惯访问资源(Stage 1 通过 Rust 同源代理私有 OSS 对象,开发期 Vite 代理已覆盖现役 generated 前缀)
|
||||
- [x] 资产任务状态可查询(角色主形象与角色动作已通过 `jobs/:taskId` 复用 `AiTaskService`;同步上传/确认链路以接口返回结果为状态)
|
||||
- [x] 已确认对象可绑定到业务实体槽位
|
||||
|
||||
补充说明:
|
||||
|
||||
1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。
|
||||
2. `所有新生成资产都写入 OSS` 与 `前端仍能通过旧路径习惯访问资源` 仍需继续把角色主形象、动画、Qwen 精灵与其余历史渲染入口一并收口后再整体勾选。
|
||||
2. 角色主形象第一批已新增独立设计文档与 Rust 最小闭环:
|
||||
- [../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](../docs/technical/M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. 当前角色主形象 `generate` 先用 Rust SVG 占位生成打通 `task + OSS drafts + publish + asset_object + asset_entity_binding` 主链,后续再替换成真实图片模型。
|
||||
4. 角色动作模板与视频导入第一批已接入 Rust:
|
||||
- `templates` 返回旧内置模板 contract。
|
||||
- `import-video` 当前只接受 `data:video/*;base64,...`,并写入 OSS `generated-character-drafts/*` 草稿区。
|
||||
5. 角色资产工作流缓存第一批已接入 Rust:
|
||||
- 保存时写入 OSS `generated-character-drafts/{character}/workflow-cache/workflow-cache.json`。
|
||||
- 读取时未命中返回 `cache: null`,保持旧前端 contract。
|
||||
6. 角色动作第一批已接入 Rust:
|
||||
- `generate` 直接写入 OSS `generated-character-drafts/*`。
|
||||
- `jobs/:taskId` 从 `AiTaskService` 派生旧任务状态 contract。
|
||||
- `publish` 会把动作帧与总 manifest 写入 OSS `generated-animations/*`,并确认 `asset_object + asset_entity_binding`。
|
||||
7. custom world 场景图、封面图、封面上传已在 `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md` + `M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md` 范围内完成正式 `OSS + asset_object + asset_entity_binding` 主链、真实 DashScope 图片生成和封面上传裁剪压缩。
|
||||
8. `content_hash/version`、`asset_job`、`asset_manifest` 与强业务资产表当前已冻结 Stage 1 边界,不再作为 M6 第一批工程阻塞项;后续若要做内容去重、manifest 查询、审核/回滚或 sprite sheet 强结构化,再进入独立阶段。
|
||||
|
||||
@@ -2,45 +2,45 @@
|
||||
|
||||
## 1. 测试体系
|
||||
|
||||
- [ ] 为 Axum handler 补接口测试
|
||||
- [ ] 为 SpacetimeDB reducer 补规则测试
|
||||
- [ ] 为 view / projection 补数据一致性测试
|
||||
- [ ] 为 auth 主链补集成测试
|
||||
- [ ] 为 runtime snapshot 主链补集成测试
|
||||
- [ ] 为 story action 主链补集成测试
|
||||
- [ ] 为 custom world / agent 主链补集成测试
|
||||
- [ ] 为 assets / OSS 主链补集成测试
|
||||
- [ ] 为兼容 contract 补回归测试
|
||||
- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + M7 preflight 固化;新增接口测试继续按主链补齐)
|
||||
- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接)
|
||||
- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁)
|
||||
- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 M7 preflight 入口)
|
||||
- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 M7 preflight 入口)
|
||||
- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 M7 preflight 扩展验证)
|
||||
- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 M7 preflight;真实 LLM/OSS 环境联调继续由 smoke 承接)
|
||||
- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,M7 preflight 固化基础门禁)
|
||||
- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 M7 preflight)
|
||||
|
||||
## 2. 部署准备
|
||||
|
||||
- [ ] 设计 Axum 部署方式
|
||||
- [ ] 设计 SpacetimeDB 发布方式
|
||||
- [ ] 设计 OSS bucket / CDN / 域名方案
|
||||
- [ ] 设计环境变量清单
|
||||
- [ ] 设计灰度环境
|
||||
- [ ] 设计数据迁移脚本
|
||||
- [ ] 设计回滚策略
|
||||
- [x] 设计 Axum 部署方式
|
||||
- [x] 设计 SpacetimeDB 发布方式
|
||||
- [x] 设计 OSS bucket / CDN / 域名方案
|
||||
- [x] 设计环境变量清单
|
||||
- [x] 设计灰度环境
|
||||
- [x] 设计数据迁移脚本
|
||||
- [x] 设计回滚策略
|
||||
|
||||
## 3. 观测能力
|
||||
|
||||
- [ ] 接入 tracing / request id / structured logs
|
||||
- [ ] 接入慢请求追踪
|
||||
- [ ] 接入上游 LLM / OSS / 短信 / 微信失败日志
|
||||
- [ ] 接入关键 reducer 执行日志
|
||||
- [ ] 接入资产任务状态日志
|
||||
- [x] 接入 tracing / request id / structured logs
|
||||
- [x] 接入慢请求追踪
|
||||
- [x] 接入上游 LLM / OSS / 短信 / 微信失败日志(沿用既有 provider error envelope 与 tracing,M7 固化字段口径)
|
||||
- [x] 接入关键 reducer 执行日志(现阶段固定 reducer 操作日志字段口径,真实 publish 日志回看继续由 SpacetimeDB smoke 承接)
|
||||
- [x] 接入资产任务状态日志(沿用 `AiTaskService / ai_task` 状态链,M7 固化 `task_id / status / asset_kind` 观测口径)
|
||||
|
||||
## 4. 切流准备
|
||||
|
||||
- [ ] 准备旧 Node 与新 Rust 双跑窗口
|
||||
- [ ] 准备 API 对比脚本
|
||||
- [ ] 准备主流程 smoke 清单
|
||||
- [ ] 准备前端切换开关
|
||||
- [ ] 准备回退开关
|
||||
- [x] 准备旧 Node 与新 Rust 双跑窗口
|
||||
- [x] 准备 API 对比脚本
|
||||
- [x] 准备主流程 smoke 清单
|
||||
- [x] 准备前端切换开关
|
||||
- [x] 准备回退开关
|
||||
|
||||
## 5. 主工程结构收口
|
||||
|
||||
- [ ] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口
|
||||
- [x] 拆分 `server-rs/crates/spacetime-module/src/lib.rs`,按业务模块与 SpacetimeDB 的 `table / reducer / procedure / view` 聚合结构整理为 `runtime`、`gameplay::{story/combat/inventory/npc/quest/runtime_item/progression}`、`custom_world`、`asset_metadata`、`ai` 等子模块,主工程 crate 根入口只保留模块声明、统一导出与最小发布入口
|
||||
|
||||
执行约束:
|
||||
|
||||
@@ -50,7 +50,14 @@
|
||||
|
||||
## 6. 阶段验收
|
||||
|
||||
- [x] 本地切流前预检通过(`server-rs/scripts/m7-preflight.ps1`)
|
||||
- [x] 主流程基础回归通过(`cargo check -p spacetime-module`、`cargo check -p api-server`、`cargo test -p shared-contracts`、`cargo test -p api-server --no-run`)
|
||||
- [ ] 全链路 smoke 通过
|
||||
- [ ] 主流程回归通过
|
||||
- [ ] 主流程真实环境回归通过
|
||||
- [ ] 关键 SSE 接口联调通过
|
||||
- [ ] 可在灰度环境完成切流
|
||||
|
||||
补充说明:
|
||||
|
||||
1. M7 已新增 [../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](../docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md),冻结本地预检、部署、灰度、双跑、回滚与结构收口口径。
|
||||
2. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||
|
||||
@@ -4,33 +4,39 @@
|
||||
|
||||
### Contract 与前端兼容
|
||||
|
||||
- [ ] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射
|
||||
- [ ] 设计 Rust 侧 contract 生成或手写策略
|
||||
- [ ] 保持当前字段名、枚举值、响应结构稳定
|
||||
- [ ] 为 breaking change 建立显式变更流程
|
||||
- [x] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射
|
||||
- [x] 设计 Rust 侧 contract 生成或手写策略
|
||||
- [x] 保持当前字段名、枚举值、响应结构稳定
|
||||
- [x] 为 breaking change 建立显式变更流程
|
||||
|
||||
### SpacetimeDB schema 演进治理
|
||||
|
||||
- [ ] 约定 stable reducer 命名规则
|
||||
- [ ] 约定 stable table 命名规则
|
||||
- [ ] 约定列追加式演进规则
|
||||
- [ ] 约定软删除而不是直接删表删列的场景
|
||||
- [ ] 约定事件表与投影表拆分规则
|
||||
- [x] 约定 stable reducer 命名规则
|
||||
- [x] 约定 stable table 命名规则
|
||||
- [x] 约定列追加式演进规则
|
||||
- [x] 约定软删除而不是直接删表删列的场景
|
||||
- [x] 约定事件表与投影表拆分规则
|
||||
|
||||
### 大对象与缓存治理
|
||||
|
||||
- [ ] 明确哪些内容入 OSS
|
||||
- [ ] 明确哪些内容只存 SpacetimeDB 元数据
|
||||
- [ ] 明确哪些内容允许短期本地缓存
|
||||
- [ ] 明确 workflow cache 生命周期
|
||||
- [x] 明确哪些内容入 OSS
|
||||
- [x] 明确哪些内容只存 SpacetimeDB 元数据
|
||||
- [x] 明确哪些内容允许短期本地缓存
|
||||
- [x] 明确 workflow cache 生命周期
|
||||
|
||||
### 文档维护
|
||||
|
||||
- [ ] 每个阶段完成后同步更新设计文档
|
||||
- [ ] 每个阶段完成后补一份落地记录
|
||||
- [ ] 完成接口迁移后更新新的模块与 API 索引文档
|
||||
- [x] 每个阶段完成后同步更新设计文档
|
||||
- [x] 每个阶段完成后补一份落地记录
|
||||
- [x] 完成接口迁移后更新新的模块与 API 索引文档
|
||||
- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
||||
- [ ] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
||||
- [x] `M5` 结构变更同步对齐 `docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
||||
|
||||
补充说明:
|
||||
|
||||
1. 横向治理规则已冻结在 [../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](../docs/technical/BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md)。
|
||||
2. Rust 侧 96 条 Axum 路由索引已冻结在 [../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](../docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。
|
||||
3. `M4` 当前仍存在 `runtime_story` 独立 crate 拆分工作区,结构文档对齐需等该拆分收口后再勾选。
|
||||
|
||||
## 2. 第一优先级建议执行顺序
|
||||
|
||||
@@ -44,13 +50,13 @@
|
||||
|
||||
## 3. 最终验收清单
|
||||
|
||||
- [ ] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
||||
- [x] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
||||
- [ ] 当前 `6` 个挂载面已全部迁移
|
||||
- [ ] 当前 `12` 个内部模块已完成新架构落位
|
||||
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
|
||||
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
|
||||
- [ ] 阿里云 OSS 已成为唯一资产对象仓
|
||||
- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致
|
||||
- [ ] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
|
||||
- [x] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
|
||||
- [ ] 前端主流程在不大改 UI 的前提下可跑通
|
||||
- [ ] 能完成灰度切流,并保留可回退能力
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# 后端重写横向治理规则(2026-04-22)
|
||||
|
||||
更新时间:`2026-04-22`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文件冻结 `SpacetimeDB + Axum + OSS` 后端重写收口阶段的横向规则,覆盖:
|
||||
|
||||
1. 前端 TypeScript contract 与 Rust DTO 的映射策略。
|
||||
2. SpacetimeDB table / reducer / procedure 的演进规则。
|
||||
3. 大对象、manifest、workflow cache 的存储边界。
|
||||
4. 阶段文档与 API 索引的维护规则。
|
||||
|
||||
这些规则用于减少 M4/M5/M6/M7 后续并行推进时的 contract 漂移。
|
||||
|
||||
## 2. Contract 与前端兼容
|
||||
|
||||
### 2.1 映射原则
|
||||
|
||||
1. `packages/shared/src/contracts/*` 是前端消费 contract 的现有事实来源。
|
||||
2. `server-rs/crates/shared-contracts/src/*.rs` 是 Rust `api-server` 返回 DTO 的事实来源。
|
||||
3. 两侧字段名必须继续使用当前前端已消费的 JSON 命名,不因 Rust 字段命名风格改变外部 shape。
|
||||
4. Rust DTO 必须通过 `serde(rename_all = "camelCase")`、显式 `rename` 或兼容枚举值保持旧 contract。
|
||||
5. 临时兼容字段只能标记为 optional,不能在没有迁移说明和测试前直接删除。
|
||||
|
||||
### 2.2 当前映射面
|
||||
|
||||
| 前端 contract | Rust DTO 模块 | 当前用途 |
|
||||
| --- | --- | --- |
|
||||
| `packages/shared/src/contracts/auth.ts` | `shared-contracts::auth` | 登录方式、用户信息、会话、审计、验证码与微信登录 |
|
||||
| `packages/shared/src/contracts/runtime.ts` | `shared-contracts::runtime` | profile dashboard、play stats、wallet ledger、browse history、settings、inventory |
|
||||
| `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` | `shared-contracts::runtime_story` | runtime story action request / response、state resolve、view model |
|
||||
| `packages/shared/src/contracts/rpgRuntimeStoryState.ts` | `shared-contracts::runtime_story` | runtime story state / presentation 兼容 |
|
||||
| `packages/shared/src/contracts/rpgAgent*.ts` | `shared-contracts::runtime` 与 `custom_world` 相关 DTO | custom world agent session、message、operation、action |
|
||||
| `packages/shared/src/contracts/rpgCreation*.ts` | `shared-contracts::runtime` 与 `custom_world` 相关 DTO | result preview、works、library、published profile |
|
||||
| `packages/shared/src/contracts/common.ts` | `shared-contracts::api` | 统一 success / error envelope |
|
||||
|
||||
### 2.3 变更流程
|
||||
|
||||
1. 扩字段:先加 Rust optional 字段和 contract test,再接前端消费。
|
||||
2. 改字段语义:必须新增技术方案说明旧语义、新语义、迁移期兼容逻辑和回退方式。
|
||||
3. 删字段或删枚举:必须先证明前端调用、Node 兼容层、历史 fixture 和测试都不再消费。
|
||||
4. breaking change 必须在任务清单和设计文档中显式标注,不允许只靠 PR diff 表达。
|
||||
5. 所有 shared contract 变更至少运行 `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`。
|
||||
|
||||
## 3. SpacetimeDB Schema 演进治理
|
||||
|
||||
本节按 SpacetimeDB 约束执行:
|
||||
|
||||
1. reducer 是事务性写入口,不依赖 reducer 返回值读取数据。
|
||||
2. reducer 必须确定性执行,不做网络、文件系统、外部随机数或时间副作用。
|
||||
3. 客户端读取依赖 table / subscription / procedure 返回的显式 DTO,不把 Axum 进程内缓存当真相。
|
||||
4. 用户身份以后续接入 SpacetimeDB 直连时的 `ctx.sender()` 为准,不信任客户端传入 owner 字段。
|
||||
|
||||
### 3.1 命名规则
|
||||
|
||||
1. table 使用稳定单数 snake_case 名称,例如 `story_session`、`asset_object`、`custom_world_agent_session`。
|
||||
2. reducer 使用动作动词 + 领域对象,例如 `upsert_runtime_snapshot`、`confirm_asset_object`、`turn_in_quest`。
|
||||
3. 需要同步返回 DTO 的 procedure 统一使用 `_and_return` 或 `get_ / list_ / compile_` 语义。
|
||||
4. public table 只暴露客户端确实需要订阅或查询的状态;内部审计、token、风控等默认不 public。
|
||||
5. event table 只用于事件广播,不替代持久状态表。
|
||||
|
||||
### 3.2 列演进规则
|
||||
|
||||
1. 优先追加 optional 字段,不直接改名、改类型或删除列。
|
||||
2. 必须删除语义时,先软废弃字段并让读模型停止依赖,再在独立迁移窗口清理。
|
||||
3. 状态类枚举新增值时,前端必须有 unknown / fallback 处理。
|
||||
4. 需要唯一约束或索引时,先补设计文档说明查询路径,再改 schema。
|
||||
5. 大规模重排表结构必须拆成新表 + 双写 / 读模型迁移,不在原表上做破坏性变更。
|
||||
|
||||
### 3.3 软删除规则
|
||||
|
||||
1. 用户可见业务实体优先使用 `status`、`deleted_at`、`archived_at` 表达生命周期。
|
||||
2. 会话、作品、资产绑定、审计和任务记录默认不物理删除。
|
||||
3. 物理删除只用于临时草稿、过期验证码、过期 OAuth state 等明确可丢弃数据。
|
||||
4. 删除 reducer 必须写清是否幂等,重复调用不能造成不可恢复错误。
|
||||
|
||||
## 4. 大对象与缓存治理
|
||||
|
||||
### 4.1 OSS 存储边界
|
||||
|
||||
必须进入 OSS:
|
||||
|
||||
1. 图片、视频、动作帧、封面图、场景图。
|
||||
2. 大型 JSON manifest。
|
||||
3. 角色工作流缓存 JSON。
|
||||
4. 导入视频和生成过程草稿资源。
|
||||
|
||||
只进入 SpacetimeDB 元数据:
|
||||
|
||||
1. `bucket`、`object_key`、`asset_kind`、`content_type`、`content_length`、`content_hash`、`version`。
|
||||
2. `asset_entity_binding` 的业务实体、槽位、owner 和 profile 绑定关系。
|
||||
3. AI task、asset task、publish gate 等状态字段。
|
||||
4. 可用于列表和权限判断的轻量 summary。
|
||||
|
||||
### 4.2 本地缓存边界
|
||||
|
||||
1. 生产主链不得把仓库 `public/generated-*` 作为资产真相。
|
||||
2. 旧 `/generated-*` 仅作为同源代理兼容路径,读取私有 OSS 对象。
|
||||
3. 测试环境允许使用 `#[cfg(test)]` 内存兜底,但必须在文档中注明不进入生产链。
|
||||
4. workflow cache 当前真相是 OSS JSON 草稿对象,不落本地文件。
|
||||
5. 临时生成文件如需存在,必须限制在进程临时目录,并在任务完成后清理。
|
||||
|
||||
### 4.3 Manifest 与版本
|
||||
|
||||
1. 多文件资产集合使用 OSS manifest 表达,不重复新增结构化表,除非已证明查询需求需要。
|
||||
2. `asset_object.version` 当前默认 `1`,版本升级必须说明兼容读取规则。
|
||||
3. `content_hash` 可为空,但一旦用于去重,必须先补冲突处理和重算策略。
|
||||
4. 强业务资产表只有在需要领域查询、审核、回滚或权限策略时再新增。
|
||||
|
||||
## 5. 文档维护规则
|
||||
|
||||
1. 工程修改必须同步对应阶段任务清单。
|
||||
2. 新增或改变接口时,同步更新 [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md)。
|
||||
3. 仍存在 Node 旧能力差异时,同步更新 [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md) 的过期说明或新增 Rust 侧补充索引。
|
||||
4. M4 结构变更同步维护 RPG runtime 链路文档。
|
||||
5. M5 结构变更同步维护 creation flow 链路文档。
|
||||
6. M6 资产链路变更同步维护 OSS / asset_object / generated path 文档。
|
||||
7. M7 切流相关变更同步维护部署、预检、smoke 与回滚文档。
|
||||
|
||||
## 6. 验收门禁
|
||||
|
||||
横向治理完成不等价于真实切流完成。当前可本地验收的门禁是:
|
||||
|
||||
1. `cargo check -p api-server --manifest-path server-rs/Cargo.toml`
|
||||
2. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`
|
||||
3. `cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`
|
||||
4. `cargo test -p api-server --manifest-path server-rs/Cargo.toml --no-run`
|
||||
5. `node scripts/check-encoding.mjs ...`
|
||||
|
||||
真实切流前仍必须单独完成:
|
||||
|
||||
1. OSS 真实读写 smoke。
|
||||
2. LLM / DashScope 真实生成 smoke。
|
||||
3. 关键 SSE 接口联调。
|
||||
4. SpacetimeDB publish / rollback 演练。
|
||||
5. 灰度环境双跑对比。
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
|
||||
### 4.2.2 `actions/resolve` 首版策略
|
||||
|
||||
当前 Rust compat handler 已按“确定性兼容动作 + snapshot 回写”落地,目标是先覆盖前端实际点击主链,而不是一步到位复刻 Node 全部 story domain。
|
||||
当前 Rust compat handler 已按“确定性兼容动作 + snapshot 回写 + 最小动作后 LLM 文本增强”落地,目标是先覆盖前端实际点击主链,而不是一步到位复刻 Node 全部 story domain。
|
||||
|
||||
当前已覆盖动作:
|
||||
|
||||
@@ -213,9 +213,14 @@
|
||||
22. `battle_guard_break`
|
||||
23. `battle_probe_pressure`
|
||||
24. `battle_recover_breath`
|
||||
25. `treasure_secure`
|
||||
26. `treasure_inspect`
|
||||
27. `treasure_leave`
|
||||
25. `inventory_use`
|
||||
26. `equipment_equip`
|
||||
27. `equipment_unequip`
|
||||
28. `forge_craft`
|
||||
29. `forge_dismantle`
|
||||
30. `forge_reforge`
|
||||
31. `npc_trade`
|
||||
32. `npc_gift`
|
||||
|
||||
统一规则:
|
||||
|
||||
@@ -224,6 +229,7 @@
|
||||
3. `clientVersion` 与 `gameState.runtimeActionVersion` 不一致时返回 `409`
|
||||
4. 动作成功后递增 `runtimeActionVersion`
|
||||
5. 追加 `storyHistory`,并把新的 `currentStory` / `viewModel` / `presentation` / `patches` 回写到 snapshot
|
||||
6. 若已配置 `platform-llm`,允许在动作规则结算完成后尝试生成增强版 `storyText / currentStory`;生成失败时自动回退确定性结果
|
||||
|
||||
当前已额外对齐的 Node 旧主链细节:
|
||||
|
||||
@@ -248,8 +254,36 @@
|
||||
- quest 不再被直接从快照中移除,而是保留为 `status = turned_in`
|
||||
- 当前最小奖励闭环已写回 `playerCurrency / playerInventory / playerProgression / npc affinity`
|
||||
- `playerProgression` 当前仍走 compat 侧确定性经验累计,不等价于最终 SpacetimeDB 真相成长链
|
||||
6. combat compat
|
||||
- battle 状态查询已补 `inventory_use` 与多条 `battle_use_skill` 选项编译
|
||||
- 技能选项会继续输出 `runtimePayload.skillId`、`disabled` 与 `reason`
|
||||
- 战斗物品会继续输出 `runtimePayload.itemId`
|
||||
- `battle_use_skill` 已补 `playerSkillCooldowns` 与 `activeBuildBuffs` 写回
|
||||
- `inventory_use` 已补 `playerInventory` 扣减、`itemsUsed`、冷却缩减与 `activeBuildBuffs` 写回
|
||||
- hostile 战斗胜利后已补 `runtimeStats.hostileNpcsDefeated += 1`
|
||||
- hostile 战斗胜利后已补 `playerProgression.totalXp / level / xpToNextLevel / lastGrantedSource = hostile_npc`
|
||||
7. Task6 inventory / NPC inventory compat
|
||||
- `equipment_equip` 已补最小装备换装、`playerEquipment` 写回、`playerInventory` 扣减、`playerMaxHp / playerMaxMana` 回算与 Build toast
|
||||
- `equipment_unequip` 已补槽位归一化、卸装回包、`playerEquipment` 置空、`playerInventory` 回收与 Build toast 回算
|
||||
- `forge_craft / forge_dismantle / forge_reforge` 已补最小工坊主链:材料消耗、产物生成、货币扣减、重铸属性提升与结果文本
|
||||
- `npc_trade` 已补买入 / 卖出结算所需的 `playerCurrency`、`playerInventory` 与 `npcStates.*.inventory` 写回
|
||||
- `npc_gift` 已补 `playerInventory` 扣减、NPC 背包收礼、`affinity`、`giftsGiven` 与 `npc_affinity_changed` patch
|
||||
- NPC 交互态 fallback option compiler 已按 Node 旧顺序补 `npc_trade / npc_gift / npc_quest_* / npc_recruit / npc_leave`
|
||||
- 已补 compat bridge 入口级 NPC state bootstrap:当 `npcStates` 为空且遭遇为纯商贩型 NPC 时,`state/get` 与 `actions/resolve` 会自动初始化 `relationState / stanceProfile / tradeStockSignature / inventory`
|
||||
8. 动作后 LLM 文本增强
|
||||
- `npc_chat / story_opening_camp_dialogue` 已在 Rust 侧补最小 `generate_action_story_payload(...)` 分支
|
||||
- 当 `platform-llm` 可用时,会尝试生成中文 NPC 对话文本,并把 `currentStory` 保存为 `displayMode = dialogue`
|
||||
- 该对话态当前保持与 Node 旧结构一致:`options` 只保留“继续推进冒险”,真实下一步入口先压到 `deferredOptions`
|
||||
- `battle victory / spar_complete / escaped` 已支持生成结果叙事,但当前仍沿用既有 fallback options,不提前迁移完整 orchestrator 选项重排
|
||||
- 所有动作后 LLM 增强都只改写展示文本,不改变已完成的数值结算、patch 与 snapshot 写回顺序
|
||||
|
||||
### 4.2.3 `initial` / `continue` 首版策略
|
||||
### 4.2.3 当前明确移除的旧概念
|
||||
|
||||
`treasure_*` 旧 runtime story 遭遇动作已经从当前 Rust compat bridge 中移除,不再属于本阶段 M4 runtime story 主链覆盖范围。
|
||||
|
||||
当前保留的仅是 quest 目标语义里历史遗留的 `inspect_treasure` 字段口径,后续会在 quest / 叙事任务链单独收口,不在这份 compat bridge 文档里继续把 treasure 视作动作主循环。
|
||||
|
||||
### 4.2.4 `initial` / `continue` 首版策略
|
||||
|
||||
当前 Rust compat handler 已提供稳定 `RuntimeStoryAiResponse`:
|
||||
|
||||
@@ -321,6 +355,18 @@
|
||||
- `runtime_story_quest_offer_abandon_clears_pending_offer_and_restores_chat_options`
|
||||
- `runtime_story_quest_accept_writes_quest_runtime_stats_and_followup_story`
|
||||
- `runtime_story_quest_turn_in_marks_quest_rewards_and_affinity`
|
||||
12. 继续补 Task6 inventory / NPC inventory compat 回归:
|
||||
- `runtime_story_state_compiler_builds_active_npc_options_with_trade_gift_and_help_lock`
|
||||
- `runtime_story_equipment_equip_updates_loadout_and_build_toast`
|
||||
- `runtime_story_equipment_unequip_returns_item_to_inventory_and_resets_loadout`
|
||||
- `runtime_story_forge_craft_consumes_materials_and_currency`
|
||||
- `runtime_story_forge_dismantle_replaces_item_with_material_outputs`
|
||||
- `runtime_story_forge_reforge_upgrades_item_and_consumes_cost`
|
||||
- `runtime_story_npc_trade_buy_updates_currency_inventory_and_stock`
|
||||
- `runtime_story_state_compiler_bootstraps_trade_inventory_for_role_npc`
|
||||
- `runtime_story_npc_trade_buy_bootstraps_missing_npc_state`
|
||||
- `runtime_story_npc_gift_updates_affinity_inventory_and_patch`
|
||||
- `runtime_story_route_boundary_persists_equipment_equip_snapshot_updates`
|
||||
|
||||
---
|
||||
|
||||
@@ -356,7 +402,7 @@
|
||||
- `POST /api/runtime/story/continue`
|
||||
5. `cargo test -p shared-contracts` 通过
|
||||
6. `cargo check -p api-server` 通过
|
||||
7. `cargo test -p api-server runtime_story` 通过
|
||||
7. `cargo test -p api-server runtime_story` 通过,当前 Rust `runtime_story` 兼容桥回归为 30 条
|
||||
8. `node scripts/check-encoding.mjs` 通过
|
||||
|
||||
补充边界:
|
||||
@@ -364,4 +410,4 @@
|
||||
1. 当前测试里为 `runtime_snapshot` 加了 `#[cfg(test)]` 下的内存兜底,只用于在未启动本地 SpacetimeDB 时稳定验证 Rust route boundary。
|
||||
2. 该测试兜底不进入生产链路,不改变真实 `runtime_save -> spacetime-client -> SpacetimeDB procedure` 的运行时实现。
|
||||
|
||||
达到以上条件后,兼容桥这一段已不再停留在 DTO / 空壳状态;下一轮重点转向“compat bridge 替换成真相态 reducer / projection”。
|
||||
达到以上条件后,兼容桥这一段已不再停留在 DTO / 空壳状态;下一轮重点转向“继续迁移 Node 剩余编排分支,并最终用真相态 reducer / projection 替换 compat bridge”。
|
||||
|
||||
299
docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md
Normal file
299
docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# M4 Runtime Story Rust 文件拆分方案(2026-04-22)
|
||||
|
||||
更新时间:`2026-04-22`
|
||||
|
||||
## 0. 文档目标
|
||||
|
||||
本文件只解决一个工程问题:
|
||||
|
||||
**把 `server-rs/crates/api-server/src/runtime_story.rs` 从当前超大单文件拆成可维护的 Rust 子模块,同时不改变既有 M4 compat bridge 的行为边界。**
|
||||
|
||||
当前 `runtime_story.rs` 已超过 `7000` 行,内部同时混杂了:
|
||||
|
||||
1. Axum route handler
|
||||
2. snapshot 持久化与 DTO 组装
|
||||
3. runtime story compat 动作结算
|
||||
4. runtime option compiler / currentStory builder
|
||||
5. LLM 文本增强
|
||||
6. test fixture 与 route boundary 回归
|
||||
|
||||
这已经超出单文件可维护范围,也会直接拖慢后续继续迁移 Node compat 分支的速度。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本轮拆分原则
|
||||
|
||||
本轮拆分坚持以下边界:
|
||||
|
||||
1. **先拆“展示编译层”和“AI 增强层”,不先重写规则结算层。**
|
||||
2. **不改变 `app.rs` 里的路由绑定函数名。**
|
||||
3. **不改变 `RuntimeStoryActionResponse / RuntimeStoryAiResponse` contract。**
|
||||
4. **不改变现有 compat bridge 的动作规则、patch、snapshot 写回顺序。**
|
||||
5. **优先做可验证的文件拆分,不把这轮演变成架构重写。**
|
||||
|
||||
原因:
|
||||
|
||||
1. 当前 `resolve_runtime_story_choice_action(...)` 仍在持续迁移 Node compat 行为,短期内继续集中在主文件更利于快速补链。
|
||||
2. `presentation / option compiler / dialogue currentStory / AI payload` 对外依赖相对单纯,更适合先抽走。
|
||||
3. test module 独立后,可以明显降低主文件噪音,后续再继续拆规则层也更安全。
|
||||
|
||||
---
|
||||
|
||||
## 2. 首轮拆分目标
|
||||
|
||||
首轮只拆以下 3 块:
|
||||
|
||||
### 2.1 `runtime_story/presentation.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. `viewModel` 编译
|
||||
2. `availableOptions` 编译
|
||||
3. `currentStory` builder
|
||||
4. `dialogue / pendingQuestOffer` 的 story shape helper
|
||||
5. `story option` 与 `interaction` 的组装
|
||||
|
||||
这块包含但不限于:
|
||||
|
||||
1. `build_runtime_story_view_model`
|
||||
2. `build_runtime_story_options`
|
||||
3. `build_fallback_runtime_story_options`
|
||||
4. `build_dialogue_current_story`
|
||||
5. `build_pending_quest_offer_story`
|
||||
6. `build_story_option_from_runtime_option`
|
||||
|
||||
### 2.2 `runtime_story/ai.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. `initial / continue` 的 `RuntimeStoryAiResponse`
|
||||
2. `actions/resolve` 后的最小 LLM 文本增强
|
||||
3. 对话 turn 解析
|
||||
4. AI prompt payload 构造
|
||||
|
||||
这块包含但不限于:
|
||||
|
||||
1. `build_runtime_story_ai_response`
|
||||
2. `generate_ai_story_text`
|
||||
3. `generate_action_story_payload`
|
||||
4. `generate_npc_dialogue_payload`
|
||||
5. `generate_reasoned_story_payload`
|
||||
6. `parse_dialogue_turns`
|
||||
|
||||
### 2.3 `runtime_story/tests.rs`
|
||||
|
||||
职责:
|
||||
|
||||
1. route boundary test
|
||||
2. 纯函数回归
|
||||
3. fixture builder
|
||||
4. 鉴权 token helper
|
||||
|
||||
---
|
||||
|
||||
## 3. 拆分后目录形态
|
||||
|
||||
首轮目标目录:
|
||||
|
||||
```text
|
||||
server-rs/crates/api-server/src/
|
||||
├─ runtime_story.rs
|
||||
└─ runtime_story/
|
||||
├─ ai.rs
|
||||
├─ presentation.rs
|
||||
└─ tests.rs
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
1. `runtime_story.rs` 保留为外层入口模块
|
||||
2. 子模块通过 `mod ai; mod presentation; #[cfg(test)] mod tests;` 组织
|
||||
3. `runtime_story.rs` 继续暴露原有 5 个 route handler:
|
||||
- `resolve_runtime_story_state`
|
||||
- `get_runtime_story_state`
|
||||
- `resolve_runtime_story_action`
|
||||
- `generate_runtime_story_initial`
|
||||
- `generate_runtime_story_continue`
|
||||
|
||||
---
|
||||
|
||||
## 4. Rust 侧实现策略
|
||||
|
||||
## 4.1 不做新的共享 crate
|
||||
|
||||
本轮不把 helper 再抽成新的 crate 或全局 util module。
|
||||
|
||||
原因:
|
||||
|
||||
1. 当前拆分目标是降低单文件复杂度,不是扩展跨模块复用面。
|
||||
2. `presentation / ai / tests` 仍强依赖 `runtime_story` 内部 helper。
|
||||
3. 如果过早抽到 crate 级共享层,会额外引入新的 API 稳定面和更大改动范围。
|
||||
|
||||
## 4.2 子模块通过 `super::*` 复用内部 helper
|
||||
|
||||
首轮允许子模块继续通过 `use super::*;` 访问现有内部函数、结构体和常量。
|
||||
|
||||
这是刻意的折中:
|
||||
|
||||
1. 优先完成物理拆分
|
||||
2. 暂不要求所有 helper 立即彻底分层
|
||||
3. 后续再在第二轮继续把规则层和 state helper 往下切
|
||||
|
||||
## 4.3 第二轮候选拆分
|
||||
|
||||
本轮完成后,下一轮可继续评估:
|
||||
|
||||
1. `runtime_story/actions.rs`
|
||||
2. `runtime_story/battle.rs`
|
||||
3. `runtime_story/inventory.rs`
|
||||
4. `runtime_story/npc_state.rs`
|
||||
5. `runtime_story/json_state.rs`
|
||||
|
||||
但这些都不属于本次提交的必达范围。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证要求
|
||||
|
||||
拆分后至少必须通过:
|
||||
|
||||
1. `cargo test -p api-server runtime_story --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
|
||||
2. `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`
|
||||
3. `node D:\\Genarrative\\scripts\\check-encoding.mjs`
|
||||
|
||||
若以上任一失败,则本轮拆分不算完成。
|
||||
|
||||
---
|
||||
|
||||
## 6. 本轮明确不做
|
||||
|
||||
1. 不改 compat bridge 业务规则
|
||||
2. 不新增或删除 runtime functionId
|
||||
3. 不顺手把 quest 里的历史 `inspect_treasure` 字段一并清理
|
||||
4. 不提前把 `resolve_story_action / sync_runtime_snapshot_projection` 真相 reducer 并入本轮
|
||||
5. 不修改前端调用边界
|
||||
|
||||
---
|
||||
|
||||
## 7. 完成标记
|
||||
|
||||
本轮拆分完成的判定标准:
|
||||
|
||||
1. `runtime_story.rs` 明显缩短,至少不再携带 tests 与 AI/presentation 全量实现
|
||||
2. `runtime_story/ai.rs`、`runtime_story/presentation.rs`、`runtime_story/tests.rs` 已落地
|
||||
3. route handler 对外签名不变
|
||||
4. 定向回归全部通过
|
||||
|
||||
达到以上条件后,再继续进入下一轮“规则层进一步拆分”。
|
||||
|
||||
---
|
||||
|
||||
## 8. 2026-04-22 实际落地进度
|
||||
|
||||
截至 `2026-04-22` 当前工作区,首轮物理拆分已经进入可继续演进状态:
|
||||
|
||||
1. 外层入口 [runtime_story.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story.rs) 已缩成薄壳,只保留原有 5 个 route handler 的导出。
|
||||
2. 兼容实现主体已迁入 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs),并继续保留规则结算主链。
|
||||
3. `tests` 已外置到 [tests.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/tests.rs),避免继续堆在主文件内。
|
||||
4. 本轮进一步把 `compat` 内部再拆成:
|
||||
- [ai.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/ai.rs)
|
||||
- [presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs)
|
||||
5. 当前拆分策略仍然维持 `compat` 内部模块,通过 `use super::*;` 复用共享 helper,不提前抽独立 crate。
|
||||
6. quest replace / fixture 中原本残留的 `inspect_treasure` mock 已同步替换为更中性的 `talk_to_npc`,避免把已废弃的 treasure 概念继续固化进新模块。
|
||||
|
||||
下一步不再是继续把文件塞回去,而是沿着当前目录继续把“无 HTTP / 无 AppState”的纯规则与编译逻辑收敛出来,为后续独立 crate 做第二阶段准备。
|
||||
|
||||
## 9. 第二阶段收敛边界
|
||||
|
||||
第二阶段不新增对外入口,只继续整理 `compat` 内部依赖面:
|
||||
|
||||
1. 继续保留 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 作为 route handler、快照持久化与 compat action orchestration 的主入口。
|
||||
2. 优先把“只依赖 `serde_json::Value` / 共享 contract / 纯函数 helper”的部分抽到内部纯模块。
|
||||
3. 当前最适合先抽的块不是 battle route,而是:
|
||||
- NPC 状态补齐
|
||||
- encounter / inventory / equipment 读写
|
||||
- quest / trade / recruit 等会复用的 `game_state` 纯变换 helper
|
||||
4. 这一步的目标不是立刻独立 crate,而是先在 `api-server` 内形成清晰的“HTTP 外壳”与“纯状态编译层”分界。
|
||||
|
||||
如果第二阶段完成后 `compat` 内已经能明显区分:
|
||||
|
||||
1. `AppState / RequestContext / Axum` 相关边界
|
||||
2. `Value -> Value / DTO` 的纯规则层
|
||||
|
||||
那么第三阶段再把后者抽成独立 crate,风险会显著低于现在直接新建 crate。
|
||||
|
||||
## 10. 第二阶段 battle 收敛进度
|
||||
|
||||
截至 `2026-04-22` 当前工作区,第二阶段已经继续向“纯规则内聚”推进一块 battle 逻辑:
|
||||
|
||||
1. `compat` 新增 [battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs),专门承接 battle 兼容桥里的纯规则与展示编译 helper。
|
||||
2. 已迁入 `battle.rs` 的内容包括:
|
||||
- 战斗数值写回 helper
|
||||
- 技能 / 物品的 battle action plan 生成
|
||||
- 战斗技能冷却读写
|
||||
- battle 选项与推荐物品编译
|
||||
- battle 胜利经验奖励计算
|
||||
3. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 当前继续只保留 `resolve_battle_action(...)` 这种动作编排入口,不再堆放大段 battle 纯 helper。
|
||||
4. [core.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/core.rs) 中原本只服务 battle 链的 skill / inventory 读取与 cooldown helper 已同步移出,避免“纯规则仍散落在多个模块”。
|
||||
5. 这一步仍然没有改变:
|
||||
- Axum route handler 签名
|
||||
- `AppState / RequestContext` 边界
|
||||
- `RuntimeStoryActionResponse` / patch / snapshot 的写回顺序
|
||||
|
||||
这说明第二阶段已经不只是在“补状态 helper”,而是开始把 compat 内最独立的一类规则块真正收束成内部纯模块。下一步可以继续沿同样方法处理 `forge`,以及 `trade / gift / companion` 这类不依赖 HTTP 的 helper 群。
|
||||
|
||||
同日进一步推进后,这条路线已经从 battle 扩展到 forge:
|
||||
|
||||
1. `compat` 新增 [forge.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge.rs),把锻造配方、重铸成本、材料消耗、运行时物品生成、拆解产物和重铸产物构造统一收口。
|
||||
2. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 当前对 forge 只保留:
|
||||
- `resolve_forge_craft_action(...)`
|
||||
- `resolve_forge_dismantle_action(...)`
|
||||
- `resolve_forge_reforge_action(...)`
|
||||
这些动作编排入口。
|
||||
3. [game_state.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs) 里的 NPC trade bootstrapping 继续直接复用 `forge.rs` 中的运行时物品构造 helper,避免 trade stock 与工坊产物出现两套生成规则。
|
||||
4. 这意味着第二阶段已经形成一个更清楚的内部形态:
|
||||
- `battle.rs`:战斗纯规则与战斗选项编译
|
||||
- `forge.rs`:工坊纯规则与运行时锻造物品生成
|
||||
- `game_state.rs`:快照态读写与 NPC / inventory / equipment 状态桥
|
||||
|
||||
后续再继续迁 `trade / gift / companion` 时,目标就不再是单纯减少行数,而是把 compat bridge 逐步收束成“动作编排壳 + 多个纯规则模块”的明确结构。
|
||||
|
||||
在此基础上,同日又继续把 NPC 交互侧的一批纯 helper 收到独立模块:
|
||||
|
||||
1. `compat` 新增 [npc_support.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/npc_support.rs)。
|
||||
2. 已迁入的内容包括:
|
||||
- 赠礼好感收益与赠礼结果文本
|
||||
- 交易价格、折扣档位、货币文本、数量后缀
|
||||
- 队伍招募与满员换队 helper
|
||||
3. [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 现在对 `npc_trade / npc_gift / npc_recruit` 仍只保留动作编排,不再承担底层价格计算和队伍变换逻辑。
|
||||
4. 到这一步,`compat.rs` 的主要职责已经更接近:
|
||||
- route handler / snapshot bridge
|
||||
- action orchestration
|
||||
- 少量尚未迁出的共享 glue code
|
||||
|
||||
这为后续把“无 HTTP / 无 `AppState`”的剩余 glue code 再往下收,提供了更明确的拆分方向。
|
||||
|
||||
第二阶段继续推进到 action resolver 编排后,当前又新增动作编排模块:
|
||||
|
||||
1. [battle_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle_actions.rs)。
|
||||
2. [equipment_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/equipment_actions.rs)。
|
||||
3. [forge_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge_actions.rs)。
|
||||
4. [npc_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/npc_actions.rs)。
|
||||
5. [quest_actions.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/quest_actions.rs)。
|
||||
|
||||
已迁入的内容包括:
|
||||
|
||||
1. `battle_*`
|
||||
2. `equipment_equip / equipment_unequip`
|
||||
3. `forge_craft / forge_dismantle / forge_reforge`
|
||||
4. `npc_preview_talk / npc_chat / npc_help / npc_fight / npc_spar`
|
||||
5. `npc_trade / npc_gift / npc_recruit`
|
||||
6. `npc_chat_quest_offer_view`
|
||||
7. `npc_chat_quest_offer_replace`
|
||||
8. `npc_chat_quest_offer_abandon`
|
||||
9. `npc_quest_accept`
|
||||
10. `npc_quest_turn_in`
|
||||
|
||||
这组 resolver 虽然仍是 action orchestration,但已经不依赖 HTTP / `AppState`,只依赖快照 `Value`、当前故事 `currentStory`、共享 DTO 与内部 helper,因此适合先作为 `api-server` 内部模块沉淀。
|
||||
|
||||
迁移后 [compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 对这些动作只保留 functionId 分发、快照桥接与少量共享 glue code,不再承载 battle / equipment / forge / NPC / quest 的具体结算细节。
|
||||
@@ -0,0 +1,142 @@
|
||||
# M6 资产元数据、版本与专用表边界设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于把 `M6` 清单中剩余的以下项收口到可执行边界:
|
||||
|
||||
1. 内容 hash / 版本字段规范
|
||||
2. `asset_job`
|
||||
3. `asset_manifest`
|
||||
4. `character_visual_asset`
|
||||
5. `character_animation_asset`
|
||||
6. `scene_image_asset`
|
||||
7. `sprite_sheet_asset`
|
||||
|
||||
当前 `M6` 第一批已经落地的真实主链是:
|
||||
|
||||
1. OSS 私有对象持有二进制内容
|
||||
2. `asset_object` 记录 `bucket + object_key` 和基础元数据
|
||||
3. `asset_entity_binding` 记录业务实体槽位绑定
|
||||
4. 角色动作发布通过 OSS `manifest.json` 表达动作集合
|
||||
5. 角色主形象、角色动作、custom world 场景图/封面图都先通过通用绑定闭环
|
||||
|
||||
因此本阶段不继续堆新表,而是冻结“哪些内容已经由现有主链承担,哪些等真实访问模式稳定后再拆强业务表”。
|
||||
|
||||
## 2. 内容 hash 与版本规范
|
||||
|
||||
### 2.1 当前 Stage 1 规范
|
||||
|
||||
`asset_object` 当前字段已经包含:
|
||||
|
||||
1. `content_hash: Option<String>`
|
||||
2. `version: u32`
|
||||
|
||||
本阶段规范如下:
|
||||
|
||||
1. `version` 固定从 `1` 起步。
|
||||
2. 同一 `bucket + object_key` 被重新确认时,保留原 `created_at`,更新 `updated_at`,版本仍按当前 `INITIAL_ASSET_OBJECT_VERSION = 1` 处理。
|
||||
3. `content_hash` 当前优先使用 OSS `ETag` 或调用方明确传入的 hash。
|
||||
4. 不在 `api-server` 对大文件做强制全量 SHA-256 计算,避免图片/视频代理链路和服务端上传链路被额外 CPU 与内存占用放大。
|
||||
5. 后续若需要强一致内容去重,再新增独立 `content_digest` 计算策略,不复用当前可空 `content_hash` 做强制约束。
|
||||
|
||||
### 2.2 不做强制 hash 的原因
|
||||
|
||||
1. OSS `ETag` 在不同上传方式下不一定等价于单纯 MD5。
|
||||
2. 当前第一批主要目标是把本地 `public/` 真相迁到 OSS 与 SpacetimeDB 元数据。
|
||||
3. 角色动作视频、帧序列和 custom world 图片都已经能通过 `content_length + object_key + asset_kind + binding` 完成首批追踪。
|
||||
4. 强制 hash 需要统一 multipart、服务端上传、浏览器直传和迁移脚本的计算口径,适合后续单独阶段。
|
||||
|
||||
## 3. `asset_job` 边界
|
||||
|
||||
当前不新增 `asset_job` 表。
|
||||
|
||||
理由:
|
||||
|
||||
1. `M4` 已引入 `module-ai::AiTaskService` 和对应 `ai_task` 设计。
|
||||
2. 角色主形象与角色动作的 Stage 1 已复用 `AiTaskService` 输出旧 `jobs/:taskId` contract。
|
||||
3. custom world 场景图/封面图当前仍是同步兼容接口,不需要单独资产任务态。
|
||||
|
||||
当前任务状态统一口径:
|
||||
|
||||
1. AI 生成相关:使用 `ai_task` / `AiTaskService`。
|
||||
2. 纯上传确认相关:使用 `asset_object` 与 `asset_entity_binding` 的返回结果。
|
||||
3. 后续若出现非 AI 的长时资产处理任务,再重新评估是否拆 `asset_job`。
|
||||
|
||||
## 4. `asset_manifest` 边界
|
||||
|
||||
当前不新增 SpacetimeDB `asset_manifest` 表。
|
||||
|
||||
Stage 1 的 manifest 口径如下:
|
||||
|
||||
1. manifest 是一个 OSS JSON 对象。
|
||||
2. 角色动作整套 manifest 会被确认成 `asset_object`。
|
||||
3. `asset_entity_binding` 绑定的是整套 manifest 对象,而不是每个单帧对象。
|
||||
4. 前端仍通过旧 `animationMap` contract 消费动作帧路径。
|
||||
|
||||
后续只有满足以下条件之一时,才新增 `asset_manifest` 表:
|
||||
|
||||
1. 需要在 SpacetimeDB 中按 manifest 内部动作、帧、依赖对象做查询。
|
||||
2. 需要对 manifest 做版本 diff、审核、回滚。
|
||||
3. 需要把 manifest 作为跨 profile、跨角色复用的结构化资产集合。
|
||||
|
||||
## 5. 强业务资产表边界
|
||||
|
||||
当前不新增以下强业务表:
|
||||
|
||||
1. `character_visual_asset`
|
||||
2. `character_animation_asset`
|
||||
3. `scene_image_asset`
|
||||
4. `sprite_sheet_asset`
|
||||
|
||||
当前由以下组合承担业务绑定:
|
||||
|
||||
1. `asset_object.asset_kind`
|
||||
2. `asset_entity_binding.entity_kind`
|
||||
3. `asset_entity_binding.entity_id`
|
||||
4. `asset_entity_binding.slot`
|
||||
|
||||
当前已冻结槽位:
|
||||
|
||||
| 业务 | `entity_kind` | `slot` | `asset_kind` |
|
||||
| --- | --- | --- | --- |
|
||||
| 角色主形象 | `character` | `primary_visual` | `character_visual` |
|
||||
| 角色动作集 | `character` | `animation_set` | `character_animation` |
|
||||
| custom world 场景图 | `custom_world_landmark` | `scene_image` | `scene_image` |
|
||||
| custom world 封面 | `custom_world_profile` | `cover` | `custom_world_cover` |
|
||||
|
||||
后续拆强业务表的条件:
|
||||
|
||||
1. 需要对角色主形象候选、审核状态、模型参数做结构化查询。
|
||||
2. 需要对动作集逐动作授权、复用、差分发布。
|
||||
3. 需要对场景图、封面图做多版本历史、审核流或推荐流。
|
||||
4. 需要对 sprite sheet 做切片、修帧、atlas 元数据查询。
|
||||
|
||||
## 6. `sprite_sheet_asset` 与 Qwen 边界
|
||||
|
||||
当前 `Qwen sprite` 独立工具链已经清理,不再作为本轮现役迁移主链。
|
||||
|
||||
本阶段只保留:
|
||||
|
||||
1. 历史 `/generated-qwen-sprites/*` 路径读取兼容。
|
||||
2. `platform-oss::LegacyAssetPrefix::QwenSprites` 对象键支持。
|
||||
|
||||
因此 `sprite_sheet_asset` 当前只保留后续能力位,不在 `M6` Stage 1 新增表或接口。
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本阶段 M6 元数据与专用表边界视为完成:
|
||||
|
||||
1. `content_hash/version` 在文档中明确为 `asset_object` 现有可空 hash + 初始版本口径。
|
||||
2. `asset_job` 明确由 `AiTaskService` 暂代,不新增重复任务表。
|
||||
3. `asset_manifest` 明确由 OSS JSON manifest + `asset_object` 暂代。
|
||||
4. 强业务资产表明确延后到访问模式稳定后拆分。
|
||||
5. `05_M6_ASSETS_OSS_EDITOR.md` 不再把这些后续能力位误标为当前 Stage 1 未完成阻塞项。
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_TABLE_DESIGN_2026-04-21.md)
|
||||
2. [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
|
||||
3. [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
4. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
@@ -0,0 +1,219 @@
|
||||
# M6 角色动作资产接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色动作生成 + 任务查询 + 正式发布”的真实落地口径。
|
||||
|
||||
本批只解决以下三条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `POST /api/assets/character-animation/generate`
|
||||
2. `GET /api/assets/character-animation/jobs/:taskId`
|
||||
3. `POST /api/assets/character-animation/publish`
|
||||
|
||||
目标不是一次性接入 DashScope / Ark 视频模型,而是先把角色动作资产从旧 Node 本地 `public/generated-*` 真相切到:
|
||||
|
||||
1. `OSS` 草稿对象
|
||||
2. `AI task` 任务态
|
||||
3. `OSS` 正式动作对象
|
||||
4. `asset_object`
|
||||
5. `asset_entity_binding`
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::sign_get_object_url`
|
||||
3. `asset_object`
|
||||
4. `asset_entity_binding`
|
||||
5. `module-ai` 进程内 `AiTaskService`
|
||||
6. 角色主形象已完成 `generate / jobs / publish` 的第一批 OSS 主链
|
||||
7. 角色动作模板、视频导入、workflow cache 已完成第一批 Rust 兼容入口
|
||||
|
||||
因此本批复用现有 OSS、资产对象确认、业务实体绑定和 `AiTaskService`,不新增独立 `asset_job` 表。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容角色动作草稿生成接口
|
||||
2. 兼容角色动作任务查询接口
|
||||
3. 兼容角色动作正式发布接口
|
||||
4. `image-sequence` 草稿帧写入 OSS `generated-character-drafts/*`
|
||||
5. 视频类策略草稿预览对象写入 OSS `generated-character-drafts/*`
|
||||
6. 正式动作帧写入 OSS `generated-animations/*`
|
||||
7. 正式动作 manifest 写入 OSS `generated-animations/*`
|
||||
8. 正式动作 manifest 确认为 `asset_object`
|
||||
9. 正式动作 manifest 绑定到角色实体动作槽位
|
||||
10. 返回字段继续保持旧前端可消费 contract
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不接真实 DashScope 图片序列帧模型
|
||||
2. 不接真实 Ark 图生视频模型
|
||||
3. 不接真实动作迁移模型
|
||||
4. 不落 `character_animation_asset` 强业务表
|
||||
5. 不回写 `src/data/characterOverrides.json`
|
||||
6. 不迁移历史本地 `public/generated-animations`
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `POST /api/assets/character-animation/generate`
|
||||
|
||||
请求结构继续保持前端当前字段:
|
||||
|
||||
1. `characterId`
|
||||
2. `strategy`
|
||||
3. `animation`
|
||||
4. `promptText`
|
||||
5. `characterBriefText`
|
||||
6. `actionTemplateId`
|
||||
7. `visualSource`
|
||||
8. `referenceImageDataUrls`
|
||||
9. `referenceVideoDataUrls`
|
||||
10. `lastFrameImageDataUrl`
|
||||
11. `frameCount`
|
||||
12. `fps`
|
||||
13. `durationSeconds`
|
||||
14. `loop`
|
||||
15. `useChromaKey`
|
||||
16. `resolution`
|
||||
17. `ratio`
|
||||
18. `imageSequenceModel`
|
||||
19. `videoModel`
|
||||
20. `referenceVideoModel`
|
||||
21. `motionTransferModel`
|
||||
|
||||
`image-sequence` 返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `strategy`
|
||||
4. `model`
|
||||
5. `prompt`
|
||||
6. `imageSources`
|
||||
|
||||
视频类策略返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `strategy`
|
||||
4. `model`
|
||||
5. `prompt`
|
||||
6. `previewVideoPath`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. Stage 1 的 `image-sequence` 先生成 SVG 占位帧。
|
||||
2. Stage 1 的视频类策略若提供 `referenceVideoDataUrls[0]`,则把该视频作为草稿预览写入 OSS。
|
||||
3. Stage 1 的视频类策略若没有参考视频,则写入占位预览对象以保持接口 contract,后续真实视频模型替换该产物。
|
||||
|
||||
### 4.2 `GET /api/assets/character-animation/jobs/:taskId`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `taskId`
|
||||
2. `kind`
|
||||
3. `status`
|
||||
4. `characterId`
|
||||
5. `animation`
|
||||
6. `strategy`
|
||||
7. `model`
|
||||
8. `prompt`
|
||||
9. `createdAt`
|
||||
10. `updatedAt`
|
||||
11. `result`
|
||||
12. `errorMessage`
|
||||
|
||||
当前阶段直接复用 `AiTaskService` 内存态任务快照派生。
|
||||
|
||||
### 4.3 `POST /api/assets/character-animation/publish`
|
||||
|
||||
请求结构继续保持:
|
||||
|
||||
1. `characterId`
|
||||
2. `visualAssetId`
|
||||
3. `animations`
|
||||
4. `updateCharacterOverride`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `animationSetId`
|
||||
3. `overrideMap`
|
||||
4. `animationMap`
|
||||
5. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 每个动作的帧写入 `generated-animations/*`
|
||||
2. 每个动作生成 `manifest.json`
|
||||
3. 整套动作生成总 `manifest.json`
|
||||
4. 总 manifest 确认为 `asset_object`
|
||||
5. 总 manifest 绑定到角色实体槽位
|
||||
6. `overrideMap` 当前返回 `{}`,Rust 后端不再写本地角色覆盖文件
|
||||
|
||||
## 5. 业务实体与槽位约定
|
||||
|
||||
本批统一复用通用 `asset_entity_binding`。
|
||||
|
||||
### 5.1 角色动作正式对象
|
||||
|
||||
| 字段 | 取值 |
|
||||
| --- | --- |
|
||||
| `entity_kind` | `character` |
|
||||
| `entity_id` | `characterId` |
|
||||
| `slot` | `animation_set` |
|
||||
| `asset_kind` | `character_animation` |
|
||||
|
||||
说明:
|
||||
|
||||
1. 正式绑定对象是整套动作总 manifest。
|
||||
2. 单帧对象不单独绑定。
|
||||
3. 后续若落 `character_animation_asset` 强业务表,再把动作级索引迁到专用表。
|
||||
|
||||
## 6. OSS 对象键规划
|
||||
|
||||
### 6.1 草稿序列帧
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{taskId}/frame-{index}.svg`
|
||||
|
||||
### 6.2 草稿预览视频
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{taskId}/preview.{extension}`
|
||||
|
||||
### 6.3 正式动作帧
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/{actionSegment}/frame{index}.{extension}`
|
||||
|
||||
### 6.4 正式动作 manifest
|
||||
|
||||
动作级 manifest:
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/{actionSegment}/manifest.json`
|
||||
|
||||
整套 manifest:
|
||||
|
||||
`generated-animations/{characterSegment}/{animationSetId}/manifest.json`
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-animation/generate`
|
||||
2. Rust 已兼容 `character-animation/jobs/:taskId`
|
||||
3. Rust 已兼容 `character-animation/publish`
|
||||
4. 草稿动作产物写入 OSS
|
||||
5. 正式动作产物写入 OSS
|
||||
6. 正式总 manifest 形成 `asset_object`
|
||||
7. 正式总 manifest 形成 `asset_entity_binding`
|
||||
8. 前端仍能继续消费 `imageSources / previewVideoPath / animationMap` 旧 contract
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
3. [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md)
|
||||
4. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
@@ -0,0 +1,147 @@
|
||||
# M6 角色动作模板与视频导入接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色动作模板查询 + 视频导入”的真实落地口径。
|
||||
|
||||
本批只解决以下两条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `GET /api/assets/character-animation/templates`
|
||||
2. `POST /api/assets/character-animation/import-video`
|
||||
|
||||
目标不是一次性迁移角色动作生成、发布和真实视频模型,而是先把资产工坊当前可独立收口的动作模板与参考视频导入从旧 Node 本地 `public/generated-character-drafts` 写盘,切到 OSS 草稿对象。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `generated-character-drafts/*` 兼容对象键前缀
|
||||
3. `shared-contracts::assets` 角色主形象兼容 DTO
|
||||
4. `api-server` 已接入角色主形象 `generate / jobs / publish`
|
||||
|
||||
因此本批复用既有 OSS 服务端上传 helper,不新增 SpacetimeDB 表。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容动作模板列表接口
|
||||
2. 兼容参考视频导入接口
|
||||
3. 导入视频对象写入 OSS `generated-character-drafts/*`
|
||||
4. 返回字段继续保持旧前端可消费 contract
|
||||
5. 不再把导入视频写入本地 `public/`
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不迁移 `character-animation/generate`
|
||||
2. 不迁移 `character-animation/jobs/:taskId`
|
||||
3. 不迁移 `character-animation/publish`
|
||||
4. 不落 `character_animation_asset` 强业务表
|
||||
5. 不为导入草稿创建 `asset_object`
|
||||
6. 不为导入草稿创建 `asset_entity_binding`
|
||||
7. 不读取旧本地 `public/` 路径作为导入源
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `GET /api/assets/character-animation/templates`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `templates`
|
||||
|
||||
每个模板继续包含:
|
||||
|
||||
1. `id`
|
||||
2. `label`
|
||||
3. `animation`
|
||||
4. `promptSuffix`
|
||||
5. `notes`
|
||||
|
||||
当前模板列表固定为内置四项:
|
||||
|
||||
1. `idle_loop`
|
||||
2. `run_side`
|
||||
3. `attack_slash`
|
||||
4. `die_fall`
|
||||
|
||||
### 4.2 `POST /api/assets/character-animation/import-video`
|
||||
|
||||
请求结构继续保持:
|
||||
|
||||
1. `characterId`
|
||||
2. `animation`
|
||||
3. `videoSource`
|
||||
4. `sourceLabel`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `importedVideoPath`
|
||||
3. `draftId`
|
||||
4. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `videoSource` 当前阶段只接受 `data:video/*;base64,...`
|
||||
2. `importedVideoPath` 继续返回旧前端习惯的 `/generated-character-drafts/*`
|
||||
3. 底层对象真相在 OSS,不再写本地 `public/`
|
||||
4. `saveMessage` 明确说明当前是“已导入 OSS 草稿区”
|
||||
|
||||
## 5. OSS 对象键规划
|
||||
|
||||
导入视频固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/animation/{animationSegment}/{draftId}/{sourceLabel}.{extension}`
|
||||
|
||||
其中:
|
||||
|
||||
1. `characterSegment` 来自 `characterId` 的安全路径片段
|
||||
2. `animationSegment` 来自 `animation` 的安全路径片段
|
||||
3. `draftId` 固定为 `animation-import-{unixMillis}`
|
||||
4. `extension` 从 Data URL MIME 类型派生
|
||||
|
||||
## 6. 元数据规范
|
||||
|
||||
导入视频对象写入以下 `x-oss-meta-*` 元数据:
|
||||
|
||||
1. `asset_kind = character_animation_reference_video`
|
||||
2. `owner_user_id = asset-tool`
|
||||
3. `entity_kind = character`
|
||||
4. `entity_id = characterId`
|
||||
5. `slot = animation_reference_video`
|
||||
6. `animation = animation`
|
||||
|
||||
说明:
|
||||
|
||||
1. 旧资产工坊接口没有显式 Bearer,第一批继续使用 `asset-tool` 作为兼容归属。
|
||||
2. 草稿导入视频只是后续动作生成的参考输入,不是正式发布资产,因此本批不确认 `asset_object`。
|
||||
|
||||
## 7. 数据源边界
|
||||
|
||||
Rust 第一批只接受 `data:video/*;base64,...`。
|
||||
|
||||
暂不接受旧本地 public 路径,原因是:
|
||||
|
||||
1. Rust 迁移目标是不再依赖本地 `public/` 作为资产真相。
|
||||
2. 若为了兼容旧路径再读取本地文件,会延长旧写盘链路生命周期。
|
||||
3. 前端导入入口当前可直接传视频 Data URL,足以满足本批最小闭环。
|
||||
|
||||
## 8. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-animation/templates`
|
||||
2. Rust 已兼容 `character-animation/import-video`
|
||||
3. 导入视频写入 OSS `generated-character-drafts/*`
|
||||
4. 接口返回 `importedVideoPath / draftId` 旧 contract
|
||||
5. 不再产生本地 `public/generated-character-drafts/*` 导入文件
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
3. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
@@ -0,0 +1,242 @@
|
||||
# M6 角色主形象资产接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色主形象资产链”的真实落地口径。
|
||||
|
||||
本批只解决以下三条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `POST /api/assets/character-visual/generate`
|
||||
2. `GET /api/assets/character-visual/jobs/:taskId`
|
||||
3. `POST /api/assets/character-visual/publish`
|
||||
|
||||
目标不是一次性把整套资产系统迁完,而是先把“角色主形象候选生成 + 查询 + 正式发布”从旧 Node 的本地 `public/generated-*` 真相,切到:
|
||||
|
||||
1. `OSS`
|
||||
2. `asset_object`
|
||||
3. `asset_entity_binding`
|
||||
4. `AI task` 任务态
|
||||
|
||||
形成第一批正式主链。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::head_object`
|
||||
3. `asset_object`
|
||||
4. `asset_entity_binding`
|
||||
5. `module-ai` 进程内 `AiTaskService`
|
||||
6. `platform-llm` OpenAI 兼容文本模型网关
|
||||
7. `custom world` 图片兼容入口已经完成一版 `OSS + asset_object + asset_entity_binding` 落地
|
||||
|
||||
因此本批不重新设计一套新资产基础设施,而是复用:
|
||||
|
||||
1. 既有 `OSS` 上传与确认链
|
||||
2. 既有 `asset_object / asset_entity_binding`
|
||||
3. 既有 `AiTaskService`
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容角色主形象候选生成接口
|
||||
2. 兼容角色主形象任务状态查询接口
|
||||
3. 兼容角色主形象正式发布接口
|
||||
4. 候选草稿对象写入 OSS `generated-character-drafts/*`
|
||||
5. 正式主图对象写入 OSS `generated-characters/*`
|
||||
6. 正式发布结果写入 `asset_object`
|
||||
7. 正式发布结果绑定到角色实体槽位
|
||||
8. 返回字段继续保持旧前端可消费 contract
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不落 `asset_job` 正式 SpacetimeDB 表
|
||||
2. 不落 `character_visual_asset` 强业务表
|
||||
3. 不落 `character-workflow-cache`
|
||||
4. 不落 `character-animation` 全链路
|
||||
5. 不回写 `src/data/characterOverrides.json`
|
||||
6. 不要求前端改成新的对象读取协议
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `POST /api/assets/character-visual/generate`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `taskId`
|
||||
3. `model`
|
||||
4. `prompt`
|
||||
5. `drafts`
|
||||
|
||||
其中每个 `draft` 继续包含:
|
||||
|
||||
1. `id`
|
||||
2. `label`
|
||||
3. `imageSrc`
|
||||
4. `width`
|
||||
5. `height`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `imageSrc` 继续返回旧前端习惯的 `/generated-character-drafts/*`
|
||||
2. 草稿对象底层不再写本地 `public/`
|
||||
3. 草稿对象真相仅在 OSS
|
||||
|
||||
### 4.2 `GET /api/assets/character-visual/jobs/:taskId`
|
||||
|
||||
返回结构继续保持旧前端读取方式:
|
||||
|
||||
1. `taskId`
|
||||
2. `kind`
|
||||
3. `status`
|
||||
4. `characterId`
|
||||
5. `model`
|
||||
6. `prompt`
|
||||
7. `createdAt`
|
||||
8. `updatedAt`
|
||||
9. `result`
|
||||
10. `errorMessage`
|
||||
|
||||
当前阶段直接复用 `AiTaskService` 内存态任务快照派生,不要求前端改字段名。
|
||||
|
||||
### 4.3 `POST /api/assets/character-visual/publish`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `assetId`
|
||||
3. `portraitPath`
|
||||
4. `overrideMap`
|
||||
5. `saveMessage`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. `portraitPath` 固定返回 `/generated-characters/*`
|
||||
2. 当前 `overrideMap` 先返回空对象 `{}`,只做 contract 兼容,不再在 Rust 后端写本地覆盖文件
|
||||
3. `saveMessage` 明确说明当前是“已写入 OSS 并绑定业务实体”
|
||||
|
||||
## 5. 业务实体与槽位约定
|
||||
|
||||
本批统一复用通用 `asset_entity_binding`。
|
||||
|
||||
### 5.1 角色主形象正式对象
|
||||
|
||||
| 字段 | 取值 |
|
||||
| --- | --- |
|
||||
| `entity_kind` | `character` |
|
||||
| `entity_id` | `characterId` |
|
||||
| `slot` | `primary_visual` |
|
||||
| `asset_kind` | `character_visual` |
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 同一角色重复发布时,允许覆盖到最新对象
|
||||
2. 候选草稿对象不创建业务绑定
|
||||
3. 业务引用真相以 `asset_entity_binding` 为准
|
||||
|
||||
## 6. OSS 对象键规划
|
||||
|
||||
### 6.1 候选草稿
|
||||
|
||||
候选草稿固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/visual/{taskId}/candidate-{index}.svg`
|
||||
|
||||
### 6.2 正式主图
|
||||
|
||||
正式主图固定写入:
|
||||
|
||||
`generated-characters/{characterSegment}/visual/{assetId}/master.svg`
|
||||
|
||||
## 7. 任务状态口径
|
||||
|
||||
当前阶段不新增独立 `asset_job` 表,统一复用 `module-ai` 的内存态 `AiTaskService`。
|
||||
|
||||
### 7.1 任务种类
|
||||
|
||||
`task_kind` 统一使用:
|
||||
|
||||
`custom_world_generation`
|
||||
|
||||
说明:
|
||||
|
||||
1. 这是当前 `module-ai` 已冻结的可用任务类型之一
|
||||
2. 本批只把它当作“生成类资产任务”的最小任务容器
|
||||
3. 后续 `asset_job` 表落地后,再把角色主形象任务迁到正式资产任务模型
|
||||
|
||||
### 7.2 阶段映射
|
||||
|
||||
当前固定使用以下阶段:
|
||||
|
||||
1. `prepare_prompt`
|
||||
2. `request_model`
|
||||
3. `normalize_result`
|
||||
4. `persist_result`
|
||||
|
||||
其中:
|
||||
|
||||
1. `generate` 成功后,任务直接进入 `completed`
|
||||
2. `publish` 不额外创建新任务,只消费已有候选路径
|
||||
|
||||
## 8. Rust 第一批生成策略
|
||||
|
||||
本批生成策略固定为:
|
||||
|
||||
1. 若已配置 `platform-llm`,则用文本模型生成一个结构化占位结果
|
||||
2. 服务端把结果渲染成 SVG 占位图
|
||||
3. 占位图写入 OSS 草稿路径
|
||||
|
||||
说明:
|
||||
|
||||
1. 这不是最终的 DashScope 图片模型正式链
|
||||
2. 但它可以先把“接口 contract + 任务状态 + OSS 真相 + 正式发布绑定”全部打通
|
||||
3. 后续替换成真实图片模型时,不需要再改动主链结构
|
||||
|
||||
## 9. 服务端执行顺序
|
||||
|
||||
### 9.1 生成
|
||||
|
||||
每次调用 `generate` 固定执行:
|
||||
|
||||
1. 创建 `AiTask`
|
||||
2. 生成最终 prompt
|
||||
3. 产出候选 SVG 字节
|
||||
4. 每个候选对象上传 OSS
|
||||
5. 回写任务结果
|
||||
6. 返回 `/generated-character-drafts/*`
|
||||
|
||||
### 9.2 发布
|
||||
|
||||
每次调用 `publish` 固定执行:
|
||||
|
||||
1. 校验 `selectedPreviewSource`
|
||||
2. 解析旧 `/generated-*` 路径为 `object_key`
|
||||
3. 调 OSS `HEAD Object` 确认候选对象存在
|
||||
4. 读取候选对象内容
|
||||
5. 上传正式主图对象到 `generated-characters/*`
|
||||
6. 对正式对象执行 `asset_object` 确认
|
||||
7. 对正式对象执行 `asset_entity_binding`
|
||||
8. 返回 `/generated-characters/*`
|
||||
|
||||
## 10. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `character-visual generate / jobs / publish`
|
||||
2. 候选草稿不再写本地 `public/generated-character-drafts`
|
||||
3. 正式主图不再写本地 `public/generated-characters`
|
||||
4. 发布成功后能形成 `asset_object`
|
||||
5. 发布成功后能形成 `asset_entity_binding`
|
||||
6. 前端仍能继续消费 `taskId / drafts / portraitPath` 旧 contract
|
||||
|
||||
## 11. 关联文档
|
||||
|
||||
1. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md)
|
||||
3. [SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md](./SPACETIMEDB_ASSET_OBJECT_STORAGE_DESIGN_2026-04-21.md)
|
||||
4. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
@@ -0,0 +1,138 @@
|
||||
# M6 角色资产工作流缓存接入 OSS 第一批设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 `M6` 第一批“角色资产工作流缓存”的真实落地口径。
|
||||
|
||||
本批只解决以下两条旧接口的 Rust 重写入口:
|
||||
|
||||
1. `GET /api/assets/character-workflow-cache/:characterId`
|
||||
2. `POST /api/assets/character-workflow-cache`
|
||||
|
||||
目标是把旧 Node 写入本地 `public/generated-character-drafts/*/workflow-cache.json` 的临时缓存,切到 OSS JSON 草稿对象,继续保持前端当前可消费 contract。
|
||||
|
||||
## 2. 当前前提
|
||||
|
||||
当前仓库已经具备以下能力:
|
||||
|
||||
1. `platform-oss::OssClient::put_object`
|
||||
2. `platform-oss::OssClient::sign_get_object_url`
|
||||
3. `generated-character-drafts/*` 兼容对象键前缀
|
||||
4. 角色主形象与动作导入已经开始把草稿对象写入 OSS
|
||||
|
||||
因此本批不新增数据库表,也不引入本地 JSON 文件。
|
||||
|
||||
## 3. 本批范围
|
||||
|
||||
### 3.1 要完成的内容
|
||||
|
||||
1. 兼容工作流缓存读取接口
|
||||
2. 兼容工作流缓存保存接口
|
||||
3. 缓存 JSON 写入 OSS `generated-character-drafts/*`
|
||||
4. 返回字段继续保持旧前端可消费 contract
|
||||
5. 不再把缓存写入本地 `public/`
|
||||
|
||||
### 3.2 本批不解决的内容
|
||||
|
||||
1. 不落 `asset_object`
|
||||
2. 不落 `asset_manifest`
|
||||
3. 不落 `character_visual_asset`
|
||||
4. 不落 `character_animation_asset`
|
||||
5. 不做跨设备强一致合并
|
||||
6. 不迁移历史本地缓存文件
|
||||
|
||||
## 4. 旧接口兼容 contract
|
||||
|
||||
### 4.1 `GET /api/assets/character-workflow-cache/:characterId`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `cache`
|
||||
|
||||
补充口径:
|
||||
|
||||
1. 未找到 OSS 缓存对象时返回 `cache: null`
|
||||
2. 找到对象但 `characterId` 不匹配时返回 `cache: null`
|
||||
3. 返回的 `cache` 字段保持前端 `CharacterAssetWorkflowCache` 结构
|
||||
|
||||
### 4.2 `POST /api/assets/character-workflow-cache`
|
||||
|
||||
请求结构继续保持前端当前字段:
|
||||
|
||||
1. `characterId`
|
||||
2. `visualPromptText`
|
||||
3. `animationPromptText`
|
||||
4. `visualDrafts`
|
||||
5. `selectedVisualDraftId`
|
||||
6. `selectedAnimation`
|
||||
7. `imageSrc`
|
||||
8. `generatedVisualAssetId`
|
||||
9. `generatedAnimationSetId`
|
||||
10. `animationMap`
|
||||
|
||||
返回结构继续保持:
|
||||
|
||||
1. `ok`
|
||||
2. `cache`
|
||||
3. `saveMessage`
|
||||
|
||||
## 5. OSS 对象键规划
|
||||
|
||||
缓存 JSON 固定写入:
|
||||
|
||||
`generated-character-drafts/{characterSegment}/workflow-cache/workflow-cache.json`
|
||||
|
||||
其中:
|
||||
|
||||
1. `characterSegment` 来自 `characterId` 的安全路径片段
|
||||
2. 文件名固定为 `workflow-cache.json`
|
||||
3. content type 固定为 `application/json; charset=utf-8`
|
||||
|
||||
## 6. 字段归一化规则
|
||||
|
||||
保存接口固定执行以下归一化:
|
||||
|
||||
1. `characterId` 必填,trim 后不能为空
|
||||
2. `visualPromptText` 最长保留 280 字
|
||||
3. `animationPromptText` 最长保留 280 字
|
||||
4. `visualDrafts` 只保留有 `imageSrc` 的候选
|
||||
5. `visualDrafts[].width` 默认 `1024`
|
||||
6. `visualDrafts[].height` 默认 `1536`
|
||||
7. `selectedAnimation` 默认 `idle`
|
||||
8. 空 `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化
|
||||
9. 非对象 `animationMap` 归一化为 `null`
|
||||
10. `updatedAt` 由 Rust 服务端生成 UTC 时间
|
||||
|
||||
## 7. 元数据规范
|
||||
|
||||
缓存 JSON 对象写入以下 `x-oss-meta-*` 元数据:
|
||||
|
||||
1. `asset_kind = character_workflow_cache`
|
||||
2. `owner_user_id = asset-tool`
|
||||
3. `entity_kind = character`
|
||||
4. `entity_id = characterId`
|
||||
5. `slot = workflow_cache`
|
||||
|
||||
说明:
|
||||
|
||||
1. 旧资产工坊接口没有显式 Bearer,第一批继续使用 `asset-tool` 作为兼容归属。
|
||||
2. workflow cache 是工作流草稿状态,不是正式可发布资产,因此本批不确认 `asset_object`。
|
||||
|
||||
## 8. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. Rust 已兼容 `GET /api/assets/character-workflow-cache/:characterId`
|
||||
2. Rust 已兼容 `POST /api/assets/character-workflow-cache`
|
||||
3. 缓存 JSON 写入 OSS `generated-character-drafts/*`
|
||||
4. 未命中时返回 `cache: null`
|
||||
5. 前端仍能继续消费 `cache / saveMessage` 旧 contract
|
||||
|
||||
## 9. 关联文档
|
||||
|
||||
1. [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md)
|
||||
3. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||
@@ -0,0 +1,228 @@
|
||||
# M6 custom world 场景图 / 封面图 Stage 2 设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档用于冻结 custom world 图片链在 `Stage 1` 之后的第二批迁移口径。
|
||||
|
||||
`Stage 1` 已完成:
|
||||
|
||||
1. `scene-image / cover-image / cover-upload` 不再写仓库 `public/`
|
||||
2. 图片对象统一写入 `OSS`
|
||||
3. 写入后统一形成 `asset_object + asset_entity_binding`
|
||||
|
||||
但当前仍有两段能力没有迁完:
|
||||
|
||||
1. `scene-image / cover-image` 仍使用 Rust SVG 占位图,而不是 Node 旧链路里的真实 DashScope 图片生成
|
||||
2. `cover-upload` 仍未迁移 Node 旧链路里的 `cropRect + 16:9 裁剪 + WebP 压缩`
|
||||
|
||||
本批目标就是把这两段缺失能力补齐,同时继续保持 `Stage 1` 已冻结的 OSS 真相链。
|
||||
|
||||
## 1.1 当前落地结果
|
||||
|
||||
`2026-04-22` 已按本文口径完成 Rust `api-server` Stage 2 落地:
|
||||
|
||||
1. `POST /api/custom-world/scene-image` 已切到真实 DashScope 图片生成
|
||||
2. `POST /api/custom-world/cover-image` 已切到真实 DashScope 图片生成
|
||||
3. `POST /api/custom-world/cover-upload` 已补齐 `cropRect + 16:9 + 1600x900 + WebP + 1.5 MB`
|
||||
4. 三条链路继续统一写入 `OSS + asset_object + asset_entity_binding`
|
||||
5. `/generated-custom-world-scenes/*` 与 `/generated-custom-world-covers/*` 旧读取路径兼容口径保持不变
|
||||
|
||||
本次同时补齐的兼容细节:
|
||||
|
||||
1. `scene-image` 新增兼容读取 `negativePrompt / referenceImageSrc / userPrompt / profile / landmark`
|
||||
2. `cover-image` 新增兼容读取 `referenceImageSrc / characterRoleIds`
|
||||
3. `cover-upload` 新增兼容读取 `cropRect`
|
||||
4. 参考图输入在 Rust 端兼容两种来源:
|
||||
- `data:image/*;base64,...`
|
||||
- 现有 `/generated-*` 旧路径,通过 OSS 短签名回读后转为 Data URL
|
||||
|
||||
本批验证结果:
|
||||
|
||||
1. `cargo check -p api-server` 通过
|
||||
2. `cargo test -p api-server custom_world_ai` 通过
|
||||
3. `npm run check:encoding` 通过
|
||||
|
||||
## 2. 本批范围
|
||||
|
||||
### 2.1 要完成的内容
|
||||
|
||||
1. `POST /api/custom-world/scene-image` 接入真实 DashScope 图片生成
|
||||
2. `POST /api/custom-world/cover-image` 接入真实 DashScope 图片生成
|
||||
3. `POST /api/custom-world/cover-upload` 接入裁剪、缩放、压缩
|
||||
4. 生成后的图片仍统一写入 `OSS`
|
||||
5. 每次成功写图仍统一形成 `asset_object + asset_entity_binding`
|
||||
6. 路由响应继续保持旧前端字段形状
|
||||
|
||||
### 2.2 本批不解决的内容
|
||||
|
||||
1. 不引入新的 custom world 图片任务表
|
||||
2. 不引入 `signedUrl` 直返业务字段
|
||||
3. 不在本批补视频 Range、分片传输或前端编辑器新交互
|
||||
4. 不在本批迁移更多 custom world 非图片媒体链路
|
||||
|
||||
## 3. 旧 Node 口径对齐
|
||||
|
||||
### 3.1 场景图生成
|
||||
|
||||
Node 旧链路区分两种模式:
|
||||
|
||||
1. 无参考图:走 DashScope `text2image`
|
||||
2. 有参考图:走 DashScope `multimodal-generation`
|
||||
|
||||
本批 Rust 继续保持同口径:
|
||||
|
||||
1. `referenceImageSrc` 为空时:
|
||||
- 模型默认 `wan2.2-t2i-flash`
|
||||
- 路径:`/services/aigc/text2image/image-synthesis`
|
||||
- 异步创建任务后轮询 `/tasks/{taskId}`
|
||||
2. `referenceImageSrc` 非空时:
|
||||
- 模型默认 `qwen-image-2.0`
|
||||
- 路径:`/services/aigc/multimodal-generation/generation`
|
||||
- 直接取返回中的第一张图
|
||||
|
||||
### 3.2 封面图生成
|
||||
|
||||
Node 旧链路也区分两种模式:
|
||||
|
||||
1. 无参考图:`wan2.2-t2i-flash`
|
||||
2. 有参考图:`qwen-image-2.0`
|
||||
|
||||
Rust 本批保持一致,并继续沿用:
|
||||
|
||||
1. `profile + opening act + selected roles + landmarks` 作为 prompt 上下文
|
||||
2. 最多 6 张参考图
|
||||
3. 返回 `sourceType = generated`
|
||||
|
||||
### 3.3 封面上传
|
||||
|
||||
Node 旧链路对上传封面有明确处理:
|
||||
|
||||
1. 请求必须提供 `cropRect`
|
||||
2. `cropRect` 必须保持 `16:9`
|
||||
3. 输出固定缩放为 `1600x900`
|
||||
4. 输出格式固定为 `webp`
|
||||
5. 输出体积上限 `1.5 MB`
|
||||
6. 原图体积上限 `10 MB`
|
||||
|
||||
Rust 本批必须保持这组兼容约束。
|
||||
|
||||
## 4. 请求与响应 contract
|
||||
|
||||
### 4.1 `POST /api/custom-world/scene-image`
|
||||
|
||||
在 `Stage 1` 字段基础上,Rust 本批补齐兼容读取:
|
||||
|
||||
1. `negativePrompt`
|
||||
2. `referenceImageSrc`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `model`
|
||||
4. `size`
|
||||
5. `taskId`
|
||||
6. `prompt`
|
||||
7. `actualPrompt`
|
||||
|
||||
### 4.2 `POST /api/custom-world/cover-image`
|
||||
|
||||
继续兼容:
|
||||
|
||||
1. `profile`
|
||||
2. `userPrompt`
|
||||
3. `referenceImageSrc`
|
||||
4. `characterRoleIds`
|
||||
5. `size`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `sourceType = generated`
|
||||
4. `model`
|
||||
5. `size`
|
||||
6. `taskId`
|
||||
7. `prompt`
|
||||
8. `actualPrompt`
|
||||
|
||||
### 4.3 `POST /api/custom-world/cover-upload`
|
||||
|
||||
继续兼容:
|
||||
|
||||
1. `profileId`
|
||||
2. `worldName`
|
||||
3. `imageDataUrl`
|
||||
4. `cropRect`
|
||||
|
||||
返回仍为:
|
||||
|
||||
1. `imageSrc`
|
||||
2. `assetId`
|
||||
3. `sourceType = uploaded`
|
||||
|
||||
## 5. 服务端执行顺序
|
||||
|
||||
### 5.1 场景图 / 封面图生成
|
||||
|
||||
统一执行:
|
||||
|
||||
1. 归一 prompt 与模型选择
|
||||
2. 向 DashScope 发起生成请求
|
||||
3. 下载生成结果图片二进制
|
||||
4. `put_object`
|
||||
5. `HEAD Object`
|
||||
6. `confirm asset_object`
|
||||
7. `bind asset_entity_binding`
|
||||
8. 返回 `legacyPublicPath`
|
||||
|
||||
### 5.2 封面上传
|
||||
|
||||
统一执行:
|
||||
|
||||
1. 解析 `imageDataUrl`
|
||||
2. 校验原图体积
|
||||
3. 解码图片
|
||||
4. 按 `cropRect` 裁剪
|
||||
5. 校验裁剪区域 `16:9`
|
||||
6. 缩放到 `1600x900`
|
||||
7. 编码为 `webp`
|
||||
8. 若超过 `1.5 MB`,逐档降低质量重试
|
||||
9. `put_object`
|
||||
10. `HEAD Object`
|
||||
11. `confirm asset_object`
|
||||
12. `bind asset_entity_binding`
|
||||
13. 返回 `legacyPublicPath`
|
||||
|
||||
## 6. 环境变量与模型口径
|
||||
|
||||
本批继续复用现有 DashScope 环境变量,不新增另一套命名:
|
||||
|
||||
1. `DASHSCOPE_BASE_URL`
|
||||
2. `DASHSCOPE_API_KEY`
|
||||
3. `DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS`
|
||||
|
||||
模型默认值固定为:
|
||||
|
||||
1. 场景图文生图:`wan2.2-t2i-flash`
|
||||
2. 场景图参考图模式:`qwen-image-2.0`
|
||||
3. 封面文生图:`wan2.2-t2i-flash`
|
||||
4. 封面参考图模式:`qwen-image-2.0`
|
||||
|
||||
## 7. 完成定义
|
||||
|
||||
当以下条件满足时,本批视为完成:
|
||||
|
||||
1. `scene-image` 不再返回 Rust SVG 占位图
|
||||
2. `cover-image` 不再返回 Rust SVG 占位图
|
||||
3. `cover-upload` 已执行 `cropRect + 16:9 + webp + 1.5MB`
|
||||
4. 三条链路仍统一落到 `OSS + asset_object + asset_entity_binding`
|
||||
5. 前端无需改 contract 即可继续消费
|
||||
|
||||
## 8. 关联文档
|
||||
|
||||
1. [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||
2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||
3. [M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](./M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md)
|
||||
@@ -0,0 +1,75 @@
|
||||
# M6 旧 generated 路径 OSS 读取兼容设计
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
这份文档冻结 `M6` 第一批 OSS 化之后,旧前端继续访问 `/generated-*` 路径的 Rust 后端兼容口径。
|
||||
|
||||
当前角色主形象、角色动作、custom world 场景图和封面图已经把新生成资产写入私有 OSS。旧前端仍会把以下路径当作图片、视频或动作帧地址直接交给 `<img>`、`<video>`、canvas 抽帧或 `CharacterAnimator`:
|
||||
|
||||
1. `/generated-character-drafts/*`
|
||||
2. `/generated-characters/*`
|
||||
3. `/generated-animations/*`
|
||||
4. `/generated-custom-world-scenes/*`
|
||||
5. `/generated-custom-world-covers/*`
|
||||
6. `/generated-qwen-sprites/*`
|
||||
|
||||
如果只提供 `/api/assets/read-url`,旧 UI 中直接消费资源路径的位置会继续失败。因此本批补一个同源读取兼容层。
|
||||
|
||||
## 2. 本批范围
|
||||
|
||||
### 2.1 要完成的内容
|
||||
|
||||
1. Rust `api-server` 挂接上述六类 `GET /generated-*/*` 路由。
|
||||
2. 路由把 legacy path 转成 OSS `object_key`。
|
||||
3. 路由使用服务端 OSS 主凭证生成短期私有读签名。
|
||||
4. 路由由服务端拉取 OSS 对象并同源返回二进制内容。
|
||||
5. 返回保留 OSS 的 `content-type`,补充 `cache-control`,让图片、视频、SVG、JSON manifest 都能被旧前端直接消费。
|
||||
6. Vite 本地开发代理补齐 `/generated-animations` 与 `/generated-custom-world-covers`,避免新 OSS 路径在开发期落回本地 `public/`。
|
||||
|
||||
### 2.2 本批不解决的内容
|
||||
|
||||
1. 不把私有 OSS 对象改成公开读。
|
||||
2. 不引入 CDN。
|
||||
3. 不把对象缓存到本地 `public/`。
|
||||
4. 不迁移历史本地文件。
|
||||
5. 不实现 Range 分片视频流;Stage 1 先全量代理对象,后续如视频体积变大再补 Range。
|
||||
|
||||
## 3. 路由契约
|
||||
|
||||
每条旧路径均返回原始资源内容:
|
||||
|
||||
1. 成功:`200`,body 为 OSS 对象二进制内容。
|
||||
2. OSS 对象不存在:`404`。
|
||||
3. OSS 配置缺失:`503`。
|
||||
4. object key 不在受支持 `generated-*` 前缀:`400`。
|
||||
5. OSS 请求失败:`502`。
|
||||
|
||||
响应头:
|
||||
|
||||
1. `content-type`:优先使用 OSS 响应头。
|
||||
2. `cache-control`:`private, max-age=60`。
|
||||
3. `x-genarrative-asset-object-key`:回写解析后的 OSS object key,方便调试。
|
||||
|
||||
## 4. 对象键约定
|
||||
|
||||
旧路径去掉开头 `/` 后就是 OSS `object_key`。
|
||||
|
||||
示例:
|
||||
|
||||
`/generated-animations/hero/animation-set-1/idle/frame01.png`
|
||||
|
||||
对应:
|
||||
|
||||
`generated-animations/hero/animation-set-1/idle/frame01.png`
|
||||
|
||||
## 5. 完成定义
|
||||
|
||||
当以下条件满足时,本批路径兼容视为完成:
|
||||
|
||||
1. Rust 已挂接六类 `/generated-*` 路由。
|
||||
2. 路由能通过 OSS 私有读签名同源代理对象内容。
|
||||
3. `cargo check -p api-server` 通过。
|
||||
4. `scripts/check-encoding.mjs` 覆盖本轮新增文档和相关代码。
|
||||
5. `05_M6_ASSETS_OSS_EDITOR.md` 中路径兼容项完成勾选。
|
||||
@@ -0,0 +1,133 @@
|
||||
# M7 联调、回归、部署与切流执行方案
|
||||
|
||||
日期:`2026-04-22`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
这份文档把 `M7:联调、回归、部署与切流任务清单` 从高层勾选项细化为可直接执行的工程方案。
|
||||
|
||||
M7 的目标不是新增玩法功能,而是在 `M0 ~ M6` 已迁移的 Rust 后端基础上完成切流前收口:
|
||||
|
||||
1. 固定本地、灰度、切流前的检查命令。
|
||||
2. 固定 `Axum + SpacetimeDB + OSS` 的部署与回滚口径。
|
||||
3. 固定观测字段、慢请求、上游失败日志与资产任务日志。
|
||||
4. 固定旧 `server-node` 与新 `server-rs` 的双跑和 API 对比方式。
|
||||
5. 等价拆分 `server-rs/crates/spacetime-module/src/lib.rs`,避免 SpacetimeDB 主工程继续退化为单大文件。
|
||||
|
||||
## 2. 执行约束
|
||||
|
||||
1. 不改变现有 HTTP contract、SSE contract、SpacetimeDB 表名、reducer 名、procedure 名和对象键前缀。
|
||||
2. 不把 LLM、OSS、短信、微信等外部副作用移入 SpacetimeDB reducer。
|
||||
3. `spacetime-module` 拆分只做物理结构收口,不做 schema 重命名、字段删除、字段重排或 reducer/procedure 改名。
|
||||
4. 迁移期保留 `server-node` 作为回退锚点,M7 不删除旧后端。
|
||||
5. 前端切换默认仍指向 Node;只有显式设置 `GENARRATIVE_BACKEND_STACK=rust` 或 `GENARRATIVE_RUNTIME_SERVER_TARGET` 时才切到 Rust。
|
||||
|
||||
## 3. 测试体系
|
||||
|
||||
M7 固定四层测试入口:
|
||||
|
||||
1. Rust crate 级别:`cargo check/test` 覆盖 `api-server`、`spacetime-module`、`shared-contracts` 与模块 crate。
|
||||
2. Axum handler 级别:继续复用 `api-server` 内已有 `build_router + tower::ServiceExt` 测试,重点覆盖 `healthz/auth/runtime/assets/custom-world/story` 的兼容响应。
|
||||
3. SpacetimeDB 模块级别:`cargo check -p spacetime-module` 作为 schema/reducer/procedure 的最低门禁;需要真实数据库行为时使用 `spacetime publish --server local --yes` 后再跑 smoke。
|
||||
4. 端到端主流程:`server-rs/scripts/smoke.ps1` 与 `server-rs/scripts/oss-smoke.ps1` 分别覆盖基础 HTTP contract 与真实 OSS 链路。
|
||||
|
||||
推荐本地顺序:
|
||||
|
||||
```powershell
|
||||
.\server-rs\scripts\m7-preflight.ps1
|
||||
.\server-rs\scripts\smoke.ps1
|
||||
node scripts\run-tsx.cjs scripts\m7-api-compare.ts
|
||||
```
|
||||
|
||||
## 4. 部署准备
|
||||
|
||||
Axum 部署方式:
|
||||
|
||||
1. `cargo build -p api-server --release` 生成发布二进制。
|
||||
2. 进程环境显式配置 `GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_API_LOG`。
|
||||
3. 反向代理继续保留 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto`、`X-Request-Id`。
|
||||
4. SSE 路由必须禁用代理缓冲。
|
||||
|
||||
SpacetimeDB 发布方式:
|
||||
|
||||
1. 本地开发先执行 `server-rs/scripts/spacetime-dev.ps1` 启动 standalone。
|
||||
2. 发布模块使用 `spacetime publish genarrative-dev --server local --yes --module-path server-rs/crates/spacetime-module`。
|
||||
3. 若需要重置开发库,必须显式加 `--clear-database --yes`,不得默认清库。
|
||||
4. 生成绑定时使用仓库根目录 `spacetime.json` 中的 `typescript` 与 `rust` 输出目录。
|
||||
|
||||
OSS / CDN / 域名方案:
|
||||
|
||||
1. 正式对象真相仍为 `bucket + object_key`。
|
||||
2. bucket 默认私有读写,浏览器不直接匿名读取。
|
||||
3. `/generated-*` 旧路径由 Axum 同源代理或 CDN 边缘回源到 Rust API。
|
||||
4. CDN 只缓存可公开缓存的派生读结果,不把私有签名 URL 写入业务表。
|
||||
|
||||
环境变量最小清单:
|
||||
|
||||
1. `GENARRATIVE_API_HOST`、`GENARRATIVE_API_PORT`、`GENARRATIVE_API_LOG`
|
||||
2. `GENARRATIVE_JWT_ISSUER`、`GENARRATIVE_JWT_SECRET`
|
||||
3. `GENARRATIVE_SPACETIME_SERVER_URL`、`GENARRATIVE_SPACETIME_DATABASE`、`GENARRATIVE_SPACETIME_TOKEN`
|
||||
4. `ALIYUN_OSS_BUCKET`、`ALIYUN_OSS_ENDPOINT`、`ALIYUN_OSS_ACCESS_KEY_ID`、`ALIYUN_OSS_ACCESS_KEY_SECRET`
|
||||
5. `GENARRATIVE_LLM_PROVIDER`、`GENARRATIVE_LLM_BASE_URL`、`GENARRATIVE_LLM_API_KEY`
|
||||
6. `DASHSCOPE_BASE_URL`、`DASHSCOPE_API_KEY`
|
||||
7. `SMS_AUTH_ENABLED` 与短信供应商变量
|
||||
8. `WECHAT_AUTH_ENABLED` 与微信 OAuth 变量
|
||||
9. `GENARRATIVE_BACKEND_STACK`、`NODE_SERVER_TARGET`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET`
|
||||
|
||||
## 5. 灰度与切流
|
||||
|
||||
灰度环境固定为三段:
|
||||
|
||||
1. `shadow`:Node 继续承接用户流量,Rust 只由脚本和内部账号请求。
|
||||
2. `dual-run`:同一组 smoke/API compare 同时打 Node 与 Rust,差异必须登记。
|
||||
3. `rust-primary`:反向代理或 Vite dev proxy 指向 Rust,Node 进程保留但不作为主入口。
|
||||
|
||||
前端切换方式:
|
||||
|
||||
1. 默认 `GENARRATIVE_BACKEND_STACK=node`。
|
||||
2. 本地或灰度切 Rust 设置 `GENARRATIVE_BACKEND_STACK=rust`,并配置 `RUST_SERVER_TARGET`。
|
||||
3. 紧急回退设置 `GENARRATIVE_BACKEND_STACK=node` 或直接覆盖 `GENARRATIVE_RUNTIME_SERVER_TARGET` 指回 Node。
|
||||
|
||||
## 6. API 对比
|
||||
|
||||
`scripts/m7-api-compare.ts` 负责对比 Node 与 Rust 的基础 contract:
|
||||
|
||||
1. 默认对比 `/healthz` 与 `/api/auth/login-options`。
|
||||
2. 可通过 `M7_COMPARE_PATHS` 扩展只读路径清单。
|
||||
3. 对比时会固定传入 `x-request-id`,并归一化 `requestId / timestamp / latencyMs` 等波动字段。
|
||||
4. 默认严格模式下发现差异直接返回非零退出码。
|
||||
|
||||
该脚本只承担“无状态 GET contract”对比;带登录、写入、OSS 或 SSE 的主流程仍由专门 smoke 脚本负责。
|
||||
|
||||
## 7. 观测能力
|
||||
|
||||
M7 观测字段固定为:
|
||||
|
||||
1. HTTP 访问日志:`method`、`uri`、`status`、`latency_ms`、`slow_request`、`request_id`
|
||||
2. 错误日志:`request_id`、`status`、`error_code`
|
||||
3. 上游失败:`provider`、`operation`、`request_id`、`status/code`、`message`
|
||||
4. 关键 reducer:操作名、主实体 ID、结果状态
|
||||
5. 资产任务:`task_id`、`character_id/entity_id`、`asset_kind`、`status`
|
||||
|
||||
慢请求阈值默认 `1000ms`,可通过 `GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS` 覆盖。
|
||||
|
||||
## 8. 数据迁移与回滚
|
||||
|
||||
当前 M7 不做一次性“Node PostgreSQL 全量导入 SpacetimeDB”的危险迁移,采用双跑验证与按主链确认的渐进策略:
|
||||
|
||||
1. 已迁移主链以 SpacetimeDB 为真相源。
|
||||
2. 未迁移或灰度失败主链继续回退到 Node。
|
||||
3. 资产二进制以 OSS 为真相,不回滚到本地 `public/generated-*` 写盘。
|
||||
4. 若 SpacetimeDB schema 需要清库重发,只允许在开发库或明确灰度库执行 `--clear-database`。
|
||||
5. 生产回滚优先切反向代理目标,不优先改代码。
|
||||
|
||||
## 9. 验收定义
|
||||
|
||||
M7 完成时必须满足:
|
||||
|
||||
1. M7 文档、脚本、任务清单均同步。
|
||||
2. `api-server` 和 `spacetime-module` 至少通过 `cargo check`。
|
||||
3. 基础 smoke 脚本可执行,并覆盖 `healthz + envelope + request id`。
|
||||
4. Node/Rust API 对比脚本可执行。
|
||||
5. Vite dev proxy 已具备 Node/Rust 切换与回退开关。
|
||||
6. `spacetime-module` 已从单 `lib.rs` 拆为按 `runtime / gameplay / custom_world / asset_metadata / ai` 组织的文件结构。
|
||||
@@ -3,6 +3,8 @@
|
||||
> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。
|
||||
> 生成命令:`npm run server-node:manifest:backend`
|
||||
> 生成时间:`2026-04-20T14:26:38.663Z`
|
||||
>
|
||||
> 过期说明:该索引生成于 `2026-04-20`,其中 `createQwenSpriteRoutes` 与 `/api/assets/qwen-sprite/*` 相关描述已在 `2026-04-21` 后失效。当前 Node 现役资产挂载面仅保留 `createCharacterAssetRoutes`;`Qwen` 仅剩 prompt 模板复用与 `/generated-qwen-sprites/*` 历史路径兼容,不再存在独立路由主链。
|
||||
|
||||
## 总览
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
## 文档列表
|
||||
|
||||
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 96 条 Axum 路由,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
|
||||
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
|
||||
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md):`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
|
||||
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md):`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。
|
||||
- [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md):冻结手机号验证码登录第一阶段的真实落地边界,明确游客兜底默认关闭、公开请求不污染登录态,以及 smoke 必须覆盖短信登录主链。
|
||||
@@ -37,6 +39,8 @@
|
||||
- [SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_AUTH_USER_ACCOUNT_TABLE_DESIGN_2026-04-21.md):`M2` 第一张身份主表 `user_account` 的职责边界、字段、唯一约束、状态迁移、旧 `users` 映射与落地约束。
|
||||
- [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md):基于当前 Node 后端能力清单,设计用 `SpacetimeDB + Axum + 阿里云 OSS` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
||||
- [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。
|
||||
- [M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md](./M6_ASSET_METADATA_HASH_VERSION_AND_SPECIALIZED_TABLE_BOUNDARY_2026-04-22.md):冻结 M6 第一批内容 hash、版本、manifest、asset job 与强业务资产表的 Stage 1 边界,明确当前使用 `asset_object + asset_entity_binding + OSS manifest + AiTaskService` 闭合,不重复新增表。
|
||||
- [M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md](./M6_LEGACY_GENERATED_PATH_OSS_READ_COMPAT_2026-04-22.md):冻结 M6 旧 `/generated-*` 路径到 OSS 私有读同源代理的兼容口径,保证旧前端仍能直接消费图片、视频、动作帧与 manifest。
|
||||
- [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。
|
||||
- [M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_RUNTIME_SETTINGS_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 首批 `runtime settings` 纵向切片的表字段、默认值、procedure、Axum facade、错误 contract 与测试策略。
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_AGENT_SESSION_STAGE6_DESIGN_2026-04-22.md):冻结 `M5` Agent session create / snapshot 的最小 SpacetimeDB 与 Axum facade 闭环,明确本轮不迁移 LLM、SSE、卡片更新和完整 action registry。
|
||||
@@ -46,6 +50,12 @@
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_LIBRARY_DETAIL_STAGE5_EXTENSION_DESIGN_2026-04-22.md):补齐 `M5` Stage 5 遗漏的 owner-only `GET /api/runtime/custom-world-library/:profileId` 设计,冻结单条 profile detail 的 SpacetimeDB procedure、client facade、404 语义与 Axum 路由扩展方式。
|
||||
- [SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md](./SPACETIMEDB_CUSTOM_WORLD_WORKS_AND_AGENT_EXTENSION_STAGE9_DESIGN_2026-04-22.md):冻结 `M5` 剩余主链的 works、card detail、publish gate、supportedActions、action registry 与 AI/OSS 兼容路由边界,作为 Stage 9 到收口阶段的统一落地依据。
|
||||
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批 custom world 场景图、封面图、封面上传从本地 `public/` 临时落地切到 `OSS + asset_object + asset_entity_binding` 正式真相链的边界与槽位约定。
|
||||
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE2_2026-04-22.md):冻结 `M6` 第二批 custom world 图片链迁移口径,明确把 `scene-image / cover-image` 从 Rust SVG 占位切到真实 DashScope 图片生成,并补回 `cover-upload` 的 `cropRect + 16:9 + WebP 压缩`。
|
||||
- [M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。
|
||||
- [M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md](./M6_CHARACTER_ANIMATION_IMPORT_AND_TEMPLATE_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色动作模板查询与参考视频导入从旧 Node 本地草稿写盘切到 Rust `OSS` 草稿对象的接口 contract、对象键规划与暂不确认 `asset_object` 的边界。
|
||||
- [M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md](./M6_CHARACTER_WORKFLOW_CACHE_OSS_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色资产工作流缓存从旧 Node 本地 `workflow-cache.json` 切到 Rust `OSS` JSON 草稿对象的读写 contract、字段归一化与暂不落正式资产表的边界。
|
||||
- [M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CHARACTER_VISUAL_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md):冻结 `M6` 第一批角色主形象 `generate / jobs / publish` 接口从旧本地 `public/generated-*` 真相切到 `OSS + asset_object + asset_entity_binding + AI task` 的最小闭环与兼容 contract。
|
||||
- [M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md](./M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md):冻结 `M7` 联调、回归、部署、观测、双跑对比、灰度切流、回滚和 `spacetime-module` 结构收口的可执行方案。
|
||||
- [M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md](./M3_BROWSE_HISTORY_AXUM_SPACETIMEDB_DESIGN_2026-04-21.md):冻结 `M3` 第二批 `browse history` 纵向切片的 `user_browse_history` 表、双路径 facade、宽松归一化、去重排序规则与测试策略。
|
||||
- [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。
|
||||
- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。
|
||||
@@ -73,6 +83,7 @@
|
||||
- [M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_RPG_RUNTIME_STORY_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `server-rs` 侧 `M4` 首轮已落地的 `story_session / story_event` SpacetimeDB 基座、`begin_story_session / continue_story` reducer、同步返回快照的 story procedure、`spacetime-client` facade 与新的 `/api/story/sessions*` Axum 接口,以及当前尚未兼容旧 `runtime story` 路由的边界。
|
||||
- [M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md](./M4_RPG_RUNTIME_STORY_SESSION_STATE_QUERY_DESIGN_2026-04-22.md):冻结 `GET /api/story/sessions/:storySessionId/state` 这条最小 story state 查询切片,明确当前只返回 `storySession + storyEvents`,不等价于旧 `runtime story state` 兼容完成。
|
||||
- [M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md](./M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md):冻结旧 `POST /api/runtime/story/state/resolve` 兼容桥的首版边界,明确先补 `RuntimeStoryActionResponse` DTO 与状态桥,再继续进入 Rust `actions/resolve` 与正式 snapshot projection。
|
||||
- [M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md](./M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md):冻结 `runtime_story.rs` 从超大单文件拆到 `compat/ai/presentation/tests/battle/core/game_state/forge/npc_support/*_actions` 子模块的收口策略、验证要求与下一阶段纯规则下沉边界。
|
||||
- [M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md](./M4_MODULE_AI_BASELINE_DESIGN_2026-04-21.md):冻结 `module-ai` 首版的任务/阶段/流式片段/结果引用领域模型、最小内存服务与后续 `platform-llm` / `api-server` / `spacetime-module` 的边界。
|
||||
- [M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md](./M4_MODULE_AI_SPACETIMEDB_BASELINE_2026-04-21.md):记录 `module-ai` 在 `spacetime-module` 中首轮已落地的 `ai_task / ai_task_stage / ai_text_chunk / ai_result_reference` 真相表、最小 reducer/procedure 与当前仍未扩到真实模型调用和 Axum facade 的边界。
|
||||
- [M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md](./M4_MODULE_AI_AXUM_FACADE_DESIGN_2026-04-22.md):冻结 `module-ai` 从 `shared-contracts`、`spacetime-client` 到 `api-server` 的最小 AI task mutation facade,明确 `start` 路由当前只返回 `202 Accepted`。
|
||||
|
||||
159
docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
Normal file
159
docs/technical/RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Rust API Server 路由索引(2026-04-22)
|
||||
|
||||
更新时间:`2026-04-22`
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文件记录当前 `server-rs/crates/api-server/src/app.rs` 中已挂载的 Rust Axum 路由面,用于对照 Node 后端 `96` 条路由能力基线。
|
||||
|
||||
本文件只做路由索引,不替代单个阶段的设计文档;接口字段、权限、错误模型仍以各阶段技术方案和 `shared-contracts` 为准。
|
||||
|
||||
## 2. 当前统计
|
||||
|
||||
当前 Rust `api-server` 从 `app.rs` 可抽取到 `96` 条路由:
|
||||
|
||||
1. 内部鉴权调试接口:`2` 条。
|
||||
2. AI task 接口:`9` 条。
|
||||
3. assets / OSS 接口:`15` 条。
|
||||
4. auth 接口:`12` 条。
|
||||
5. custom world / agent 接口:`23` 条。
|
||||
6. llm proxy 接口:`1` 条。
|
||||
7. profile / runtime profile 接口:`12` 条。
|
||||
8. runtime story / story gameplay 接口:`15` 条。
|
||||
9. legacy generated 静态路径兼容:`6` 条。
|
||||
10. health check:`1` 条。
|
||||
|
||||
## 3. 路由清单
|
||||
|
||||
### 3.1 内部鉴权调试
|
||||
|
||||
1. `GET /_internal/auth/claims`
|
||||
2. `GET /_internal/auth/refresh-cookie`
|
||||
|
||||
### 3.2 AI Task
|
||||
|
||||
1. `POST /api/ai/tasks`
|
||||
2. `POST /api/ai/tasks/{task_id}/start`
|
||||
3. `POST /api/ai/tasks/{task_id}/cancel`
|
||||
4. `POST /api/ai/tasks/{task_id}/complete`
|
||||
5. `POST /api/ai/tasks/{task_id}/fail`
|
||||
6. `POST /api/ai/tasks/{task_id}/chunks`
|
||||
7. `POST /api/ai/tasks/{task_id}/references`
|
||||
8. `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/start`
|
||||
9. `POST /api/ai/tasks/{task_id}/stages/{stage_kind}/complete`
|
||||
|
||||
### 3.3 Assets / OSS
|
||||
|
||||
1. `POST /api/assets/direct-upload-tickets`
|
||||
2. `POST /api/assets/sts-upload-credentials`
|
||||
3. `POST /api/assets/objects/confirm`
|
||||
4. `POST /api/assets/objects/bind`
|
||||
5. `GET /api/assets/read-url`
|
||||
6. `POST /api/assets/character-visual/generate`
|
||||
7. `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
8. `POST /api/assets/character-visual/publish`
|
||||
9. `POST /api/assets/character-animation/generate`
|
||||
10. `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
11. `POST /api/assets/character-animation/publish`
|
||||
12. `POST /api/assets/character-animation/import-video`
|
||||
13. `GET /api/assets/character-animation/templates`
|
||||
14. `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
15. `GET / POST /api/assets/character-workflow-cache`
|
||||
|
||||
### 3.4 Auth
|
||||
|
||||
1. `GET /api/auth/login-options`
|
||||
2. `GET /api/auth/me`
|
||||
3. `POST /api/auth/logout`
|
||||
4. `POST /api/auth/logout-all`
|
||||
5. `GET /api/auth/sessions`
|
||||
6. `POST /api/auth/refresh`
|
||||
7. `POST /api/auth/phone/send-code`
|
||||
8. `POST /api/auth/phone/login`
|
||||
9. `GET /api/auth/wechat/start`
|
||||
10. `GET /api/auth/wechat/callback`
|
||||
11. `POST /api/auth/wechat/bind-phone`
|
||||
12. `POST /api/auth/entry`
|
||||
|
||||
### 3.5 Custom World / Agent
|
||||
|
||||
1. `GET /api/runtime/custom-world-library`
|
||||
2. `GET /api/runtime/custom-world-library/{profile_id}`
|
||||
3. `POST /api/runtime/custom-world-library/{profile_id}/publish`
|
||||
4. `POST /api/runtime/custom-world-library/{profile_id}/unpublish`
|
||||
5. `GET /api/runtime/custom-world-gallery`
|
||||
6. `GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}`
|
||||
7. `GET /api/runtime/custom-world/works`
|
||||
8. `POST /api/runtime/custom-world/agent/sessions`
|
||||
9. `GET /api/runtime/custom-world/agent/sessions/{session_id}`
|
||||
10. `POST /api/runtime/custom-world/agent/sessions/{session_id}/messages`
|
||||
11. `GET /api/runtime/custom-world/agent/sessions/{session_id}/messages/stream`
|
||||
12. `GET /api/runtime/custom-world/agent/sessions/{session_id}/operations/{operation_id}`
|
||||
13. `GET /api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}`
|
||||
14. `POST /api/runtime/custom-world/agent/sessions/{session_id}/actions`
|
||||
15. `POST /api/custom-world/entity`
|
||||
16. `POST /api/runtime/custom-world/entity`
|
||||
17. `POST /api/custom-world/scene-npc`
|
||||
18. `POST /api/runtime/custom-world/scene-npc`
|
||||
19. `POST /api/custom-world/scene-image`
|
||||
20. `POST /api/custom-world/cover-image`
|
||||
21. `POST /api/custom-world/cover-upload`
|
||||
22. `POST /api/runtime/custom-world/cover-image`
|
||||
23. `POST /api/runtime/custom-world/cover-upload`
|
||||
|
||||
### 3.6 LLM Proxy
|
||||
|
||||
1. `POST /api/llm/chat/completions`
|
||||
|
||||
### 3.7 Profile / Runtime Profile
|
||||
|
||||
1. `GET /api/profile/dashboard`
|
||||
2. `GET /api/runtime/profile/dashboard`
|
||||
3. `GET /api/profile/play-stats`
|
||||
4. `GET /api/runtime/profile/play-stats`
|
||||
5. `GET /api/profile/wallet-ledger`
|
||||
6. `GET /api/runtime/profile/wallet-ledger`
|
||||
7. `GET /api/profile/browse-history`
|
||||
8. `GET /api/runtime/profile/browse-history`
|
||||
9. `GET /api/profile/save-archives`
|
||||
10. `GET /api/runtime/profile/save-archives`
|
||||
11. `POST /api/profile/save-archives/{world_key}`
|
||||
12. `POST /api/runtime/profile/save-archives/{world_key}`
|
||||
|
||||
### 3.8 Runtime Story / Gameplay
|
||||
|
||||
1. `POST /api/runtime/save/snapshot`
|
||||
2. `GET /api/runtime/settings`
|
||||
3. `GET /api/runtime/story/state/{session_id}`
|
||||
4. `POST /api/runtime/story/state/resolve`
|
||||
5. `POST /api/runtime/story/actions/resolve`
|
||||
6. `POST /api/runtime/story/initial`
|
||||
7. `POST /api/runtime/story/continue`
|
||||
8. `POST /api/story/sessions`
|
||||
9. `POST /api/story/sessions/continue`
|
||||
10. `GET /api/story/sessions/{story_session_id}/state`
|
||||
11. `POST /api/story/battles`
|
||||
12. `POST /api/story/battles/resolve`
|
||||
13. `GET /api/story/battles/{battle_state_id}`
|
||||
14. `POST /api/story/npc/battle`
|
||||
15. `GET /api/runtime/sessions/{runtime_session_id}/inventory`
|
||||
|
||||
### 3.9 Legacy Generated 路径
|
||||
|
||||
1. `GET /generated-character-drafts/{*path}`
|
||||
2. `GET /generated-characters/{*path}`
|
||||
3. `GET /generated-animations/{*path}`
|
||||
4. `GET /generated-custom-world-scenes/{*path}`
|
||||
5. `GET /generated-custom-world-covers/{*path}`
|
||||
6. `GET /generated-qwen-sprites/{*path}`
|
||||
|
||||
### 3.10 Health
|
||||
|
||||
1. `GET /healthz`
|
||||
|
||||
## 4. 维护规则
|
||||
|
||||
1. 新增、删除或改名 Rust 路由时,必须同步更新本索引。
|
||||
2. 如果 Node 后端 `NODE_BACKEND_MODULE_AND_API_INDEX.md` 的现役能力面发生变化,必须同时更新本索引与对应阶段任务清单。
|
||||
3. 任何 breaking route change 都必须先更新阶段设计文档,再改代码。
|
||||
4. 真实切流前,必须用本索引对照代理层、前端调用面和 smoke 清单,避免只完成编译而遗漏外部可访问路径。
|
||||
@@ -17,6 +17,8 @@
|
||||
"server-node:smoke": "npx tsx scripts/smoke-server-node.ts",
|
||||
"server-node:smoke:proxy": "npx tsx scripts/smoke-same-origin-stack.ts",
|
||||
"server-node:check:deploy": "npm run check:encoding && npm run server-node:test && npm run server-node:smoke && npm run server-node:build && npm run build && npm run server-node:smoke:proxy",
|
||||
"server-rs:m7:preflight": "powershell -ExecutionPolicy Bypass -File server-rs/scripts/m7-preflight.ps1",
|
||||
"m7:api-compare": "node scripts/run-tsx.cjs scripts/m7-api-compare.ts",
|
||||
"build": "node scripts/build-gate.mjs",
|
||||
"build:raw": "node scripts/vite-cli.mjs build",
|
||||
"preview": "node scripts/vite-cli.mjs preview",
|
||||
|
||||
@@ -77,9 +77,6 @@ export const TASK6_RUNTIME_FUNCTION_IDS = [
|
||||
'npc_quest_accept',
|
||||
'npc_quest_turn_in',
|
||||
'npc_trade',
|
||||
'treasure_inspect',
|
||||
'treasure_leave',
|
||||
'treasure_secure',
|
||||
] as const;
|
||||
export type Task6RuntimeFunctionId =
|
||||
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
|
||||
@@ -121,10 +118,6 @@ export type RuntimeStoryOptionInteraction =
|
||||
| 'quest_accept'
|
||||
| 'quest_turn_in';
|
||||
questId?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'treasure';
|
||||
action: 'inspect' | 'leave' | 'secure';
|
||||
};
|
||||
|
||||
export type RuntimeStoryChoiceAction = RuntimeAction<
|
||||
|
||||
170
scripts/m7-api-compare.ts
Normal file
170
scripts/m7-api-compare.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
type HttpMethod = 'GET';
|
||||
|
||||
interface CompareCase {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface CompareResult {
|
||||
path: string;
|
||||
nodeStatus: number;
|
||||
rustStatus: number;
|
||||
matched: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NODE_BASE_URL = 'http://127.0.0.1:8081';
|
||||
const DEFAULT_RUST_BASE_URL = 'http://127.0.0.1:3000';
|
||||
|
||||
function readEnv(name: string, fallback: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : fallback;
|
||||
}
|
||||
|
||||
function buildCases(): CompareCase[] {
|
||||
const rawPaths = process.env.M7_COMPARE_PATHS?.trim();
|
||||
const paths = rawPaths
|
||||
? rawPaths.split(',').map((value) => value.trim()).filter(Boolean)
|
||||
: ['/healthz', '/api/auth/login-options'];
|
||||
|
||||
return paths.map((path) => ({
|
||||
method: 'GET',
|
||||
path: path.startsWith('/') ? path : `/${path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchJson(baseUrl: string, testCase: CompareCase, requestId: string) {
|
||||
const url = new URL(testCase.path, baseUrl);
|
||||
const response = await fetch(url, {
|
||||
method: testCase.method,
|
||||
headers: {
|
||||
'x-request-id': requestId,
|
||||
'x-genarrative-response-envelope': '1',
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
const json = text ? JSON.parse(text) : null;
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
json: normalizeVolatileJson(json),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVolatileJson(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeVolatileJson);
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
if (['requestId', 'timestamp', 'latencyMs'].includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[key] = normalizeVolatileJson(child);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(stableStringify).join(',')}]`;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => `${JSON.stringify(key)}:${stableStringify(child)}`);
|
||||
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
async function compareCase(
|
||||
nodeBaseUrl: string,
|
||||
rustBaseUrl: string,
|
||||
testCase: CompareCase,
|
||||
): Promise<CompareResult> {
|
||||
const requestId = `m7-api-compare-${testCase.path.replaceAll('/', '-')}`;
|
||||
const [nodeResponse, rustResponse] = await Promise.all([
|
||||
fetchJson(nodeBaseUrl, testCase, requestId),
|
||||
fetchJson(rustBaseUrl, testCase, requestId),
|
||||
]);
|
||||
|
||||
if (nodeResponse.status !== rustResponse.status) {
|
||||
return {
|
||||
path: testCase.path,
|
||||
nodeStatus: nodeResponse.status,
|
||||
rustStatus: rustResponse.status,
|
||||
matched: false,
|
||||
reason: 'status 不一致',
|
||||
};
|
||||
}
|
||||
|
||||
const nodeBody = stableStringify(nodeResponse.json);
|
||||
const rustBody = stableStringify(rustResponse.json);
|
||||
if (nodeBody !== rustBody) {
|
||||
return {
|
||||
path: testCase.path,
|
||||
nodeStatus: nodeResponse.status,
|
||||
rustStatus: rustResponse.status,
|
||||
matched: false,
|
||||
reason: `body 不一致\nnode=${nodeBody}\nrust=${rustBody}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: testCase.path,
|
||||
nodeStatus: nodeResponse.status,
|
||||
rustStatus: rustResponse.status,
|
||||
matched: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const nodeBaseUrl = readEnv('M7_NODE_BASE_URL', DEFAULT_NODE_BASE_URL);
|
||||
const rustBaseUrl = readEnv('M7_RUST_BASE_URL', DEFAULT_RUST_BASE_URL);
|
||||
const strict = process.env.M7_COMPARE_STRICT?.trim() !== 'false';
|
||||
const cases = buildCases();
|
||||
|
||||
console.log(`[m7:api-compare] node=${nodeBaseUrl}`);
|
||||
console.log(`[m7:api-compare] rust=${rustBaseUrl}`);
|
||||
console.log(`[m7:api-compare] cases=${cases.map((item) => item.path).join(', ')}`);
|
||||
|
||||
const results = await Promise.all(
|
||||
cases.map((testCase) => compareCase(nodeBaseUrl, rustBaseUrl, testCase)),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
const label = result.matched ? 'OK' : 'DIFF';
|
||||
console.log(
|
||||
`[m7:api-compare] ${label} ${result.path} node=${result.nodeStatus} rust=${result.rustStatus}`,
|
||||
);
|
||||
if (result.reason) {
|
||||
console.log(result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
const failures = results.filter((result) => !result.matched);
|
||||
if (strict) {
|
||||
assert.equal(failures.length, 0, '存在 Node/Rust API contract 差异');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[m7:api-compare] failed');
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
131
server-rs/Cargo.lock
generated
131
server-rs/Cargo.lock
generated
@@ -76,6 +76,7 @@ dependencies = [
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"httpdate",
|
||||
"image",
|
||||
"module-ai",
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
@@ -106,6 +107,7 @@ dependencies = [
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"webp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -294,6 +296,12 @@ version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -326,6 +334,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -654,6 +664,15 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -850,6 +869,12 @@ dependencies = [
|
||||
"wasip3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1169,6 +1194,32 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1239,6 +1290,16 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.95"
|
||||
@@ -1304,6 +1365,16 @@ version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libwebp-sys"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"glob",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -1512,6 +1583,16 @@ dependencies = [
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
@@ -1748,6 +1829,19 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1826,6 +1920,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -3508,6 +3614,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webp"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"libwebp-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.7"
|
||||
@@ -3884,3 +4000,18 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## 2. 当前阶段说明
|
||||
|
||||
当前目录已经完成以下三十五项初始化:
|
||||
当前目录已经完成以下三十九项初始化:
|
||||
|
||||
1. 为新后端预留正式目录并把路径固定到仓库结构中。
|
||||
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
|
||||
@@ -52,6 +52,9 @@
|
||||
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
|
||||
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
|
||||
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
|
||||
37. 创建 `scripts/m7-preflight.ps1`,固定 M7 切流前 Rust 后端预检入口。
|
||||
38. 创建根目录 `scripts/m7-api-compare.ts`,固定旧 Node 与新 Rust 的无状态 API contract 对比入口。
|
||||
39. 固定 Vite dev proxy 的 `GENARRATIVE_BACKEND_STACK` / `GENARRATIVE_RUNTIME_SERVER_TARGET` 切流和回退开关。
|
||||
|
||||
后续任务会继续在本目录内按顺序补齐:
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
dotenvy = "0.15"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
webp = "0.3"
|
||||
module-ai = { path = "../module-ai" }
|
||||
module-assets = { path = "../module-assets" }
|
||||
module-auth = { path = "../module-auth" }
|
||||
@@ -28,7 +31,7 @@ shared-contracts = { path = "../shared-contracts" }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
shared-logging = { path = "../shared-logging" }
|
||||
spacetime-client = { path = "../spacetime-client" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] }
|
||||
tokio-stream = "0.1"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
tower-http = { version = "0.6", features = ["trace"] }
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
- `POST /api/runtime/story/actions/resolve`
|
||||
- `POST /api/runtime/story/initial`
|
||||
- `POST /api/runtime/story/continue`
|
||||
26. 接入 `POST /api/assets/character-visual/generate`
|
||||
27. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
28. 接入 `POST /api/assets/character-visual/publish`
|
||||
29. 接入 `GET /api/assets/character-animation/templates`
|
||||
30. 接入 `POST /api/assets/character-animation/import-video`
|
||||
31. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
32. 接入 `POST /api/assets/character-workflow-cache`
|
||||
33. 接入 `POST /api/assets/character-animation/generate`
|
||||
34. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
35. 接入 `POST /api/assets/character-animation/publish`
|
||||
36. 接入旧 `/generated-character-drafts/*`、`/generated-characters/*`、`/generated-animations/*`、`/generated-custom-world-scenes/*`、`/generated-custom-world-covers/*`、`/generated-qwen-sprites/*` 到 OSS 私有读代理
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -75,6 +86,11 @@
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 接入旧 `runtime story` compat facade
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 接入旧 `/generated-*` 路径到 OSS 私有读同源代理
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -144,3 +160,9 @@
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 已在 Rust 侧补齐 compat handler,但内部仍是 `runtime_snapshot` 驱动的兼容桥与确定性动作编排,不应误判为真正的 SpacetimeDB `resolve_story_action` 真相链已完成。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 读取兼容层只代理受支持 generated 前缀到 OSS 私有读签名,不回退仓库 `public/`,Stage 1 不支持视频 Range 分片。
|
||||
|
||||
@@ -6,8 +6,11 @@ use axum::{
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
||||
use tracing::{Level, info_span};
|
||||
use tower_http::{
|
||||
classify::ServerErrorsFailureClass,
|
||||
trace::{DefaultOnRequest, TraceLayer},
|
||||
};
|
||||
use tracing::{Level, Span, error, info, info_span, warn};
|
||||
|
||||
use crate::{
|
||||
ai_tasks::{
|
||||
@@ -24,6 +27,14 @@ use crate::{
|
||||
},
|
||||
auth_me::auth_me,
|
||||
auth_sessions::auth_sessions,
|
||||
character_animation_assets::{
|
||||
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
|
||||
import_character_animation_video, list_character_animation_templates,
|
||||
publish_character_animation, save_character_workflow_cache,
|
||||
},
|
||||
character_visual_assets::{
|
||||
generate_character_visual, get_character_visual_job, publish_character_visual,
|
||||
},
|
||||
custom_world::{
|
||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||
@@ -40,6 +51,11 @@ use crate::{
|
||||
},
|
||||
error_middleware::normalize_error_response,
|
||||
health::health_check,
|
||||
legacy_generated_assets::{
|
||||
proxy_generated_animations, proxy_generated_character_drafts, proxy_generated_characters,
|
||||
proxy_generated_custom_world_covers, proxy_generated_custom_world_scenes,
|
||||
proxy_generated_qwen_sprites,
|
||||
},
|
||||
llm::proxy_llm_chat_completions,
|
||||
login_options::auth_login_options,
|
||||
logout::logout,
|
||||
@@ -73,6 +89,8 @@ use crate::{
|
||||
|
||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||
pub fn build_router(state: AppState) -> Router {
|
||||
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||
|
||||
Router::new()
|
||||
.route(
|
||||
"/healthz",
|
||||
@@ -95,6 +113,30 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)),
|
||||
)
|
||||
.route("/api/auth/login-options", get(auth_login_options))
|
||||
.route(
|
||||
"/generated-character-drafts/{*path}",
|
||||
get(proxy_generated_character_drafts),
|
||||
)
|
||||
.route(
|
||||
"/generated-characters/{*path}",
|
||||
get(proxy_generated_characters),
|
||||
)
|
||||
.route(
|
||||
"/generated-animations/{*path}",
|
||||
get(proxy_generated_animations),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-scenes/{*path}",
|
||||
get(proxy_generated_custom_world_scenes),
|
||||
)
|
||||
.route(
|
||||
"/generated-custom-world-covers/{*path}",
|
||||
get(proxy_generated_custom_world_covers),
|
||||
)
|
||||
.route(
|
||||
"/generated-qwen-sprites/{*path}",
|
||||
get(proxy_generated_qwen_sprites),
|
||||
)
|
||||
.route(
|
||||
"/api/auth/me",
|
||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||
@@ -234,6 +276,46 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/assets/objects/bind",
|
||||
post(bind_asset_object_to_entity),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/generate",
|
||||
post(generate_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/jobs/{task_id}",
|
||||
get(get_character_visual_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-visual/publish",
|
||||
post(publish_character_visual),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/generate",
|
||||
post(generate_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/jobs/{task_id}",
|
||||
get(get_character_animation_job),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/publish",
|
||||
post(publish_character_animation),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/import-video",
|
||||
post(import_character_animation_video),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-animation/templates",
|
||||
get(list_character_animation_templates),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache",
|
||||
post(save_character_workflow_cache),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/character-workflow-cache/{character_id}",
|
||||
get(get_character_workflow_cache),
|
||||
)
|
||||
.route("/api/assets/read-url", get(get_asset_read_url))
|
||||
.route(
|
||||
"/api/runtime/settings",
|
||||
@@ -611,8 +693,47 @@ pub fn build_router(state: AppState) -> Router {
|
||||
)
|
||||
})
|
||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||
.on_response(DefaultOnResponse::new().level(Level::INFO))
|
||||
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
|
||||
.on_response(
|
||||
move |response: &axum::response::Response,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
let status = response.status().as_u16();
|
||||
let slow_request = latency_ms >= slow_request_threshold_ms;
|
||||
span.record("status", status);
|
||||
span.record("latency_ms", latency_ms);
|
||||
if slow_request {
|
||||
warn!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = true,
|
||||
"http request completed slowly"
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
parent: span,
|
||||
status,
|
||||
latency_ms,
|
||||
slow_request = false,
|
||||
"http request completed"
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.on_failure(
|
||||
|failure: ServerErrorsFailureClass,
|
||||
latency: std::time::Duration,
|
||||
span: &Span| {
|
||||
let latency_ms = latency.as_millis().min(u64::MAX as u128) as u64;
|
||||
error!(
|
||||
parent: span,
|
||||
latency_ms,
|
||||
failure = %failure,
|
||||
"http request failed"
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
// request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。
|
||||
.layer(middleware::from_fn(attach_request_context))
|
||||
|
||||
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
1930
server-rs/crates/api-server/src/character_animation_assets.rs
Normal file
File diff suppressed because it is too large
Load Diff
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
938
server-rs/crates/api-server/src/character_visual_assets.rs
Normal file
@@ -0,0 +1,938 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_ai::{
|
||||
AiResultReferenceKind, AiStageCompletionInput, AiTaskCreateInput, AiTaskKind,
|
||||
AiTaskServiceError, AiTaskSnapshot, AiTaskStageKind, AiTaskStatus, generate_ai_task_id,
|
||||
};
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input,
|
||||
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
|
||||
};
|
||||
use platform_llm::{LlmMessage, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::{
|
||||
CharacterAssetJobStatusPayload, CharacterAssetJobStatusText, CharacterVisualDraftPayload,
|
||||
CharacterVisualGenerateRequest, CharacterVisualGenerateResponse, CharacterVisualPublishRequest,
|
||||
CharacterVisualPublishResponse,
|
||||
};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const CHARACTER_VISUAL_MODEL: &str = "rust-svg-character-visual";
|
||||
const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual";
|
||||
const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
|
||||
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
|
||||
|
||||
pub async fn generate_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let task_id = generate_ai_task_id(current_utc_micros());
|
||||
let prompt = build_character_visual_prompt(
|
||||
payload.prompt_text.as_str(),
|
||||
payload.character_brief_text.as_deref(),
|
||||
);
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
|
||||
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
|
||||
let candidate_count = payload.candidate_count.clamp(1, 4);
|
||||
|
||||
let created = create_visual_task(
|
||||
&state,
|
||||
&task_id,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&model,
|
||||
&prompt,
|
||||
)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
let result = async {
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::PreparePrompt,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PreparePrompt,
|
||||
text_output: Some(prompt.clone()),
|
||||
structured_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"sourceMode": payload.source_mode,
|
||||
"size": size,
|
||||
"referenceImageCount": payload.reference_image_data_urls.len(),
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let visual_seed = generate_visual_seed_with_llm(&state, &prompt, &character_id).await;
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::RequestModel,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::RequestModel,
|
||||
text_output: Some(visual_seed.clone()),
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
let drafts = persist_visual_drafts(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
&task_id,
|
||||
&visual_seed,
|
||||
&size,
|
||||
candidate_count,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let result_payload = json!({
|
||||
"drafts": drafts,
|
||||
"draftRelativeDir": format!(
|
||||
"generated-character-drafts/{}/visual/{}",
|
||||
sanitize_storage_segment(character_id.as_str(), "character"),
|
||||
task_id
|
||||
),
|
||||
});
|
||||
|
||||
state
|
||||
.ai_task_service()
|
||||
.start_stage(
|
||||
task_id.as_str(),
|
||||
AiTaskStageKind::NormalizeResult,
|
||||
current_utc_micros(),
|
||||
)
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::NormalizeResult,
|
||||
text_output: None,
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_stage(AiStageCompletionInput {
|
||||
task_id: task_id.clone(),
|
||||
stage_kind: AiTaskStageKind::PersistResult,
|
||||
text_output: Some("角色主形象候选草稿已写入 OSS。".to_string()),
|
||||
structured_payload_json: Some(result_payload.to_string()),
|
||||
warning_messages: Vec::new(),
|
||||
completed_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)?;
|
||||
state
|
||||
.ai_task_service()
|
||||
.complete_task(task_id.as_str(), current_utc_micros())
|
||||
.map_err(map_ai_task_error)?;
|
||||
|
||||
Ok::<_, AppError>(drafts)
|
||||
}
|
||||
.await;
|
||||
|
||||
let drafts = match result {
|
||||
Ok(drafts) => drafts,
|
||||
Err(error) => {
|
||||
let _ = state.ai_task_service().fail_task(
|
||||
created.task_id.as_str(),
|
||||
error.message().to_string(),
|
||||
current_utc_micros(),
|
||||
);
|
||||
return Err(character_visual_error_response(&request_context, error));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id,
|
||||
model,
|
||||
prompt,
|
||||
drafts,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_character_visual_job(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Path(task_id): Path<String>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let task = state
|
||||
.ai_task_service()
|
||||
.get_task(task_id.as_str())
|
||||
.map_err(map_ai_task_error)
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_character_visual_job_payload(task),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn publish_character_visual(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<CharacterVisualPublishRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 旧资产工坊接口没有显式 Bearer 头,Rust 兼容层先使用工具用户归属,避免破坏现有前端调用。
|
||||
let owner_user_id = "asset-tool".to_string();
|
||||
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
|
||||
if payload.selected_preview_source.trim().is_empty() {
|
||||
return Err(character_visual_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let asset_id = format!("visual-{}", current_utc_millis());
|
||||
let published = persist_published_visual(
|
||||
&state,
|
||||
&owner_user_id,
|
||||
&character_id,
|
||||
asset_id.as_str(),
|
||||
payload.selected_preview_source.as_str(),
|
||||
payload.prompt_text.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| character_visual_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
CharacterVisualPublishResponse {
|
||||
ok: true,
|
||||
asset_id,
|
||||
portrait_path: published,
|
||||
override_map: json!({}),
|
||||
save_message: if payload.update_character_override == Some(false) {
|
||||
"主形象已写入 OSS 并绑定当前角色,可直接写回当前自定义世界角色。".to_string()
|
||||
} else {
|
||||
"主形象已写入 OSS 并绑定当前角色;Rust 后端不再写本地角色覆盖文件。".to_string()
|
||||
},
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn create_visual_task(
|
||||
state: &AppState,
|
||||
task_id: &str,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
model: &str,
|
||||
prompt: &str,
|
||||
) -> Result<AiTaskSnapshot, AppError> {
|
||||
state
|
||||
.ai_task_service()
|
||||
.create_task(AiTaskCreateInput {
|
||||
task_id: task_id.to_string(),
|
||||
task_kind: AiTaskKind::CustomWorldGeneration,
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
request_label: "生成角色主形象".to_string(),
|
||||
source_module: "assets.character_visual".to_string(),
|
||||
source_entity_id: Some(character_id.to_string()),
|
||||
request_payload_json: Some(
|
||||
json!({
|
||||
"characterId": character_id,
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
stages: AiTaskKind::CustomWorldGeneration.default_stage_blueprints(),
|
||||
created_at_micros: current_utc_micros(),
|
||||
})
|
||||
.map_err(map_ai_task_error)
|
||||
}
|
||||
|
||||
async fn generate_visual_seed_with_llm(
|
||||
state: &AppState,
|
||||
prompt: &str,
|
||||
character_id: &str,
|
||||
) -> String {
|
||||
let fallback = format!("{character_id}:{prompt}");
|
||||
let Some(llm_client) = state.llm_client() else {
|
||||
return fallback;
|
||||
};
|
||||
|
||||
let request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏角色主形象草稿描述器。只输出一句中文视觉摘要,不要输出 Markdown。",
|
||||
),
|
||||
LlmMessage::user(
|
||||
json!({
|
||||
"task": "summarize_character_visual_seed",
|
||||
"characterId": character_id,
|
||||
"prompt": prompt,
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
])
|
||||
.with_max_tokens(96);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
async fn persist_visual_drafts(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
task_id: &str,
|
||||
visual_seed: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
) -> Result<Vec<CharacterVisualDraftPayload>, AppError> {
|
||||
let mut drafts = Vec::with_capacity(candidate_count as usize);
|
||||
for index in 0..candidate_count {
|
||||
let file_name = format!("candidate-{:02}.svg", index + 1);
|
||||
let body =
|
||||
build_character_visual_svg(size, visual_seed, format!("候选 {}", index + 1).as_str())
|
||||
.into_bytes();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::CharacterDrafts,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
task_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
"image/svg+xml".to_string(),
|
||||
body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
"draft",
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
|
||||
drafts.push(CharacterVisualDraftPayload {
|
||||
id: format!("candidate-{}", index + 1),
|
||||
label: format!("候选 {}", index + 1),
|
||||
image_src: put_result.legacy_public_path,
|
||||
width: parse_size(size).0,
|
||||
height: parse_size(size).1,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
async fn persist_published_visual(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_id: &str,
|
||||
selected_preview_source: &str,
|
||||
prompt_text: Option<&str>,
|
||||
) -> Result<String, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let source_object_key = resolve_object_key_from_legacy_path(selected_preview_source)?;
|
||||
let head = oss_client
|
||||
.head_object(
|
||||
&http_client,
|
||||
OssHeadObjectRequest {
|
||||
object_key: source_object_key.clone(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: source_object_key,
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let source_body = http_client
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取候选主形象内容失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.to_vec();
|
||||
|
||||
let content_type = head
|
||||
.content_type
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/svg+xml".to_string());
|
||||
let file_name = match content_type.as_str() {
|
||||
"image/png" => "master.png",
|
||||
"image/jpeg" => "master.jpg",
|
||||
"image/webp" => "master.webp",
|
||||
_ => "master.svg",
|
||||
}
|
||||
.to_string();
|
||||
let put_result = put_character_visual_object(
|
||||
state,
|
||||
LegacyAssetPrefix::Characters,
|
||||
vec![
|
||||
sanitize_storage_segment(character_id, "character"),
|
||||
"visual".to_string(),
|
||||
asset_id.to_string(),
|
||||
],
|
||||
file_name,
|
||||
content_type.clone(),
|
||||
source_body,
|
||||
build_asset_metadata(
|
||||
CHARACTER_VISUAL_ASSET_KIND,
|
||||
owner_user_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND,
|
||||
character_id,
|
||||
CHARACTER_VISUAL_SLOT,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let confirmed = confirm_character_visual_asset_object(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
asset_id,
|
||||
put_result.object_key.clone(),
|
||||
content_type,
|
||||
prompt_text.map(str::to_string),
|
||||
)
|
||||
.await?;
|
||||
bind_character_visual_asset(
|
||||
state,
|
||||
owner_user_id,
|
||||
character_id,
|
||||
confirmed.record.asset_object_id,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(put_result.legacy_public_path)
|
||||
}
|
||||
|
||||
async fn put_character_visual_object(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: Vec<String>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
oss_client
|
||||
.put_object(
|
||||
&reqwest::Client::new(),
|
||||
OssPutObjectRequest {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_name,
|
||||
content_type: Some(content_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)
|
||||
}
|
||||
|
||||
async fn confirm_character_visual_asset_object(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
source_job_id: &str,
|
||||
object_key: String,
|
||||
content_type: String,
|
||||
prompt_text: Option<String>,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let head = oss_client
|
||||
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
|
||||
.await
|
||||
.map_err(map_character_visual_oss_error)?;
|
||||
let now_micros = current_utc_micros();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(content_type)),
|
||||
head.content_length,
|
||||
prompt_text.or(head.etag),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(source_job_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
Some(character_id.to_string()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
let _ = state.ai_task_service().attach_result_reference(
|
||||
source_job_id,
|
||||
AiResultReferenceKind::AssetObject,
|
||||
record.asset_object_id.clone(),
|
||||
Some("角色主形象正式对象".to_string()),
|
||||
now_micros,
|
||||
);
|
||||
Ok(module_assets::ConfirmAssetObjectResult { record })
|
||||
}
|
||||
|
||||
async fn bind_character_visual_asset(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
character_id: &str,
|
||||
asset_object_id: String,
|
||||
) -> Result<(), AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object_id,
|
||||
CHARACTER_VISUAL_ENTITY_KIND.to_string(),
|
||||
character_id.to_string(),
|
||||
CHARACTER_VISUAL_SLOT.to_string(),
|
||||
CHARACTER_VISUAL_ASSET_KIND.to_string(),
|
||||
Some(owner_user_id.to_string()),
|
||||
None,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_binding_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_visual_spacetime_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
|
||||
let request_payload = task
|
||||
.request_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok())
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let result = task
|
||||
.latest_structured_payload_json
|
||||
.as_deref()
|
||||
.and_then(|value| serde_json::from_str::<Value>(value).ok());
|
||||
|
||||
CharacterAssetJobStatusPayload {
|
||||
task_id: task.task_id,
|
||||
kind: "visual".to_string(),
|
||||
status: match task.status {
|
||||
AiTaskStatus::Pending => CharacterAssetJobStatusText::Queued,
|
||||
AiTaskStatus::Running => CharacterAssetJobStatusText::Running,
|
||||
AiTaskStatus::Completed => CharacterAssetJobStatusText::Completed,
|
||||
AiTaskStatus::Failed | AiTaskStatus::Cancelled => CharacterAssetJobStatusText::Failed,
|
||||
},
|
||||
character_id: request_payload
|
||||
.get("characterId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
animation: None,
|
||||
strategy: None,
|
||||
model: request_payload
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(CHARACTER_VISUAL_MODEL)
|
||||
.to_string(),
|
||||
prompt: request_payload
|
||||
.get("prompt")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
created_at: format_utc_micros(task.created_at_micros),
|
||||
updated_at: format_utc_micros(task.updated_at_micros),
|
||||
result,
|
||||
error_message: task.failure_message,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_character_visual_prompt(prompt_text: &str, character_brief_text: Option<&str>) -> String {
|
||||
let merged = [character_brief_text.unwrap_or_default(), prompt_text]
|
||||
.into_iter()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"{}\n单人全身,右向斜侧身,3 到 4 头身,像素动作角色,纯绿色背景,服装完整,轮廓清晰,不要复杂背景。",
|
||||
if merged.is_empty() {
|
||||
"自定义世界角色,服装完整,姿态自然。"
|
||||
} else {
|
||||
merged.as_str()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn build_character_visual_svg(size: &str, label: &str, candidate_label: &str) -> String {
|
||||
let (width, height) = parse_size(size);
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
|
||||
<rect width="100%" height="100%" fill="#00ff00"/>
|
||||
<ellipse cx="{shadow_x}" cy="{shadow_y}" rx="{shadow_rx}" ry="{shadow_ry}" fill="rgba(0,0,0,0.18)"/>
|
||||
<path d="M {body_x} {body_y} C {body_c1x} {body_c1y}, {body_c2x} {body_c2y}, {body_x2} {body_y2} L {leg_x} {leg_y} L {leg2_x} {leg_y} Z" fill="#1f2937"/>
|
||||
<circle cx="{head_x}" cy="{head_y}" r="{head_r}" fill="#f8d7b0"/>
|
||||
<path d="M {weapon_x} {weapon_y} L {weapon_x2} {weapon_y2}" stroke="#e5e7eb" stroke-width="{weapon_w}" stroke-linecap="round"/>
|
||||
<text x="50%" y="{text_y}" text-anchor="middle" fill="#0f172a" font-size="{font_main}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{title}</text>
|
||||
<text x="50%" y="{sub_y}" text-anchor="middle" fill="#0f172a" font-size="{font_sub}" font-family="Microsoft YaHei, PingFang SC, sans-serif">{candidate}</text>
|
||||
</svg>"##,
|
||||
width = width,
|
||||
height = height,
|
||||
shadow_x = width / 2,
|
||||
shadow_y = height * 5 / 6,
|
||||
shadow_rx = width / 5,
|
||||
shadow_ry = height / 28,
|
||||
body_x = width * 45 / 100,
|
||||
body_y = height * 34 / 100,
|
||||
body_c1x = width * 34 / 100,
|
||||
body_c1y = height * 50 / 100,
|
||||
body_c2x = width * 43 / 100,
|
||||
body_c2y = height * 72 / 100,
|
||||
body_x2 = width * 56 / 100,
|
||||
body_y2 = height * 72 / 100,
|
||||
leg_x = width * 48 / 100,
|
||||
leg_y = height * 84 / 100,
|
||||
leg2_x = width * 62 / 100,
|
||||
head_x = width * 53 / 100,
|
||||
head_y = height * 25 / 100,
|
||||
head_r = (width.min(height) / 12).max(18),
|
||||
weapon_x = width * 57 / 100,
|
||||
weapon_y = height * 42 / 100,
|
||||
weapon_x2 = width * 76 / 100,
|
||||
weapon_y2 = height * 34 / 100,
|
||||
weapon_w = (width.min(height) / 90).max(4),
|
||||
text_y = height * 91 / 100,
|
||||
sub_y = height * 96 / 100,
|
||||
font_main = (width.min(height) / 28).max(14),
|
||||
font_sub = (width.min(height) / 36).max(11),
|
||||
title = escape_svg_text(label),
|
||||
candidate = escape_svg_text(candidate_label),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_object_key_from_legacy_path(value: &str) -> Result<String, AppError> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "selectedPreviewSource is required.",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if trimmed.starts_with("data:") {
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "character-visual",
|
||||
"message": "Rust 版 publish 当前要求 selectedPreviewSource 为已写入 OSS 的 /generated-* 路径。",
|
||||
})));
|
||||
}
|
||||
Ok(trimmed.trim_start_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn build_asset_metadata(
|
||||
asset_kind: &str,
|
||||
owner_user_id: &str,
|
||||
entity_kind: &str,
|
||||
entity_id: &str,
|
||||
slot: &str,
|
||||
) -> BTreeMap<String, String> {
|
||||
BTreeMap::from([
|
||||
("asset_kind".to_string(), asset_kind.to_string()),
|
||||
("owner_user_id".to_string(), owner_user_id.to_string()),
|
||||
("entity_kind".to_string(), entity_kind.to_string()),
|
||||
("entity_id".to_string(), entity_id.to_string()),
|
||||
("slot".to_string(), slot.to_string()),
|
||||
])
|
||||
}
|
||||
|
||||
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
|
||||
state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_required_text(value: &str, fallback: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.chars()
|
||||
.take(180)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string()
|
||||
.if_empty_then(fallback)
|
||||
}
|
||||
|
||||
fn sanitize_storage_segment(value: &str, fallback: &str) -> String {
|
||||
let normalized = value
|
||||
.trim()
|
||||
.chars()
|
||||
.map(|character| match character {
|
||||
'a'..='z' | '0'..='9' | '-' | '_' => character,
|
||||
'A'..='Z' => character.to_ascii_lowercase(),
|
||||
_ => '-',
|
||||
})
|
||||
.collect::<String>();
|
||||
let normalized = collapse_dashes(&normalized);
|
||||
if normalized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_dashes(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.fold(
|
||||
(String::new(), false),
|
||||
|(mut output, last_is_dash), character| {
|
||||
let is_dash = character == '-';
|
||||
if is_dash && last_is_dash {
|
||||
return (output, true);
|
||||
}
|
||||
output.push(character);
|
||||
(output, is_dash)
|
||||
},
|
||||
)
|
||||
.0
|
||||
.trim_matches('-')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn parse_size(size: &str) -> (u32, u32) {
|
||||
let mut parts = size.split('*');
|
||||
let width = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
let height = parts
|
||||
.next()
|
||||
.and_then(|value| value.trim().parse::<u32>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(1024);
|
||||
(width, height)
|
||||
}
|
||||
|
||||
fn escape_svg_text(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
fn format_utc_micros(micros: i64) -> String {
|
||||
module_runtime::format_utc_micros(micros)
|
||||
}
|
||||
|
||||
fn current_utc_millis() -> i64 {
|
||||
current_utc_micros() / 1_000
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
let duration = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch");
|
||||
i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64")
|
||||
}
|
||||
|
||||
fn map_ai_task_error(error: AiTaskServiceError) -> AppError {
|
||||
let status = match error {
|
||||
AiTaskServiceError::TaskNotFound => StatusCode::NOT_FOUND,
|
||||
AiTaskServiceError::TaskAlreadyExists => StatusCode::CONFLICT,
|
||||
AiTaskServiceError::Field(_) | AiTaskServiceError::StageNotFound => StatusCode::BAD_REQUEST,
|
||||
AiTaskServiceError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "ai-task",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_object_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_asset_binding_prepare_error(error: AssetObjectFieldError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-entity-binding",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_spacetime_error(error: SpacetimeClientError) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn character_visual_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
trait EmptyFallback {
|
||||
fn if_empty_then(self, fallback: &str) -> String;
|
||||
}
|
||||
|
||||
impl EmptyFallback for String {
|
||||
fn if_empty_then(self, fallback: &str) -> String {
|
||||
if self.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_character_visual_prompt_keeps_generation_constraints() {
|
||||
let prompt = build_character_visual_prompt("潮雾港向导", Some("旧港守望者"));
|
||||
|
||||
assert!(prompt.contains("潮雾港向导"));
|
||||
assert!(prompt.contains("右向斜侧身"));
|
||||
assert!(prompt.contains("纯绿色背景"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_storage_segment_keeps_legacy_safe_shape() {
|
||||
assert_eq!(
|
||||
sanitize_storage_segment("Harbor Guide/潮雾", "character"),
|
||||
"harbor-guide"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ pub struct AppConfig {
|
||||
pub llm_request_timeout_ms: u64,
|
||||
pub llm_max_retries: u32,
|
||||
pub llm_retry_backoff_ms: u64,
|
||||
pub dashscope_base_url: String,
|
||||
pub dashscope_api_key: Option<String>,
|
||||
pub dashscope_image_request_timeout_ms: u64,
|
||||
pub slow_request_threshold_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -104,6 +108,10 @@ impl Default for AppConfig {
|
||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||||
dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(),
|
||||
dashscope_api_key: None,
|
||||
dashscope_image_request_timeout_ms: 150_000,
|
||||
slow_request_threshold_ms: 1_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,6 +313,24 @@ impl AppConfig {
|
||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
||||
}
|
||||
|
||||
if let Some(dashscope_base_url) = read_first_non_empty_env(&["DASHSCOPE_BASE_URL"]) {
|
||||
config.dashscope_base_url = dashscope_base_url;
|
||||
}
|
||||
|
||||
config.dashscope_api_key = read_first_non_empty_env(&["DASHSCOPE_API_KEY"]);
|
||||
|
||||
if let Some(dashscope_image_request_timeout_ms) =
|
||||
read_first_positive_u64_env(&["DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS"])
|
||||
{
|
||||
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
|
||||
}
|
||||
|
||||
if let Some(slow_request_threshold_ms) =
|
||||
read_first_positive_u64_env(&["GENARRATIVE_SLOW_REQUEST_THRESHOLD_MS"])
|
||||
{
|
||||
config.slow_request_threshold_ms = slow_request_threshold_ms;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
209
server-rs/crates/api-server/src/legacy_generated_assets.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::{HeaderName, HeaderValue, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{http_error::AppError, state::AppState};
|
||||
|
||||
const CACHE_CONTROL_VALUE: &str = "private, max-age=60";
|
||||
const ASSET_OBJECT_KEY_HEADER: &str = "x-genarrative-asset-object-key";
|
||||
|
||||
pub async fn proxy_generated_character_drafts(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CharacterDrafts, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_characters(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Characters, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_animations(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::Animations, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_scenes(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldScenes, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_custom_world_covers(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::CustomWorldCovers, path).await
|
||||
}
|
||||
|
||||
pub async fn proxy_generated_qwen_sprites(
|
||||
State(state): State<AppState>,
|
||||
Path(path): Path<String>,
|
||||
) -> Response {
|
||||
proxy_legacy_generated_asset(state, LegacyAssetPrefix::QwenSprites, path).await
|
||||
}
|
||||
|
||||
async fn proxy_legacy_generated_asset(
|
||||
state: AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Response {
|
||||
match read_legacy_generated_asset(&state, prefix, path).await {
|
||||
Ok(response) => response,
|
||||
Err(error) => error.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_legacy_generated_asset(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path: String,
|
||||
) -> Result<Response, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
let object_key = build_generated_object_key(prefix, path.as_str())?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_legacy_generated_oss_error)?;
|
||||
let upstream_response = reqwest::Client::new()
|
||||
.get(signed.signed_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
|
||||
if upstream_response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"objectKey": object_key,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
let status = upstream_response.status();
|
||||
let content_type = upstream_response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.cloned();
|
||||
let bytes = upstream_response
|
||||
.error_for_status()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源失败:{error}"),
|
||||
}))
|
||||
})?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取 OSS 旧 generated 资源内容失败:{error}"),
|
||||
}))
|
||||
})?;
|
||||
let mut response = Response::builder()
|
||||
.status(status)
|
||||
.header(header::CACHE_CONTROL, CACHE_CONTROL_VALUE)
|
||||
.header(
|
||||
HeaderName::from_static(ASSET_OBJECT_KEY_HEADER),
|
||||
HeaderValue::from_str(object_key.as_str()).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应头失败:{error}"),
|
||||
}))
|
||||
})?,
|
||||
);
|
||||
if let Some(content_type) = content_type {
|
||||
response = response.header(header::CONTENT_TYPE, content_type);
|
||||
}
|
||||
|
||||
response.body(Body::from(bytes)).map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": format!("构造资源响应失败:{error}"),
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_generated_object_key(prefix: LegacyAssetPrefix, path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty() || path.split('/').any(is_invalid_path_segment) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "legacy-generated-assets",
|
||||
"message": "generated 资源路径不合法。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(format!("{}/{}", prefix.as_str(), path))
|
||||
}
|
||||
|
||||
fn is_invalid_path_segment(segment: &str) -> bool {
|
||||
segment.is_empty() || segment == "." || segment == ".." || segment.contains('\\')
|
||||
}
|
||||
|
||||
fn map_legacy_generated_oss_error(error: platform_oss::OssError) -> AppError {
|
||||
let status = match error {
|
||||
platform_oss::OssError::InvalidConfig(_) | platform_oss::OssError::InvalidRequest(_) => {
|
||||
StatusCode::BAD_REQUEST
|
||||
}
|
||||
platform_oss::OssError::ObjectNotFound(_) => StatusCode::NOT_FOUND,
|
||||
platform_oss::OssError::Request(_)
|
||||
| platform_oss::OssError::SerializePolicy(_)
|
||||
| platform_oss::OssError::Sign(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_keeps_supported_prefix() {
|
||||
let object_key = build_generated_object_key(
|
||||
LegacyAssetPrefix::Animations,
|
||||
"hero/animation-set-1/idle/frame01.png",
|
||||
)
|
||||
.expect("object key should build");
|
||||
|
||||
assert_eq!(
|
||||
object_key,
|
||||
"generated-animations/hero/animation-set-1/idle/frame01.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_generated_object_key_rejects_parent_segment() {
|
||||
assert!(
|
||||
build_generated_object_key(LegacyAssetPrefix::Characters, "../secret.png").is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ mod auth;
|
||||
mod auth_me;
|
||||
mod auth_session;
|
||||
mod auth_sessions;
|
||||
mod character_animation_assets;
|
||||
mod character_visual_assets;
|
||||
mod config;
|
||||
mod custom_world;
|
||||
mod custom_world_ai;
|
||||
mod error_middleware;
|
||||
mod health;
|
||||
mod http_error;
|
||||
mod legacy_generated_assets;
|
||||
mod llm;
|
||||
mod login_options;
|
||||
mod logout;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
710
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
710
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
@@ -0,0 +1,710 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, Path, State},
|
||||
http::StatusCode,
|
||||
response::Response,
|
||||
};
|
||||
use module_npc::{
|
||||
NpcRelationStance, build_initial_stance_profile as build_module_npc_initial_stance_profile,
|
||||
build_relation_state as build_module_npc_relation_state,
|
||||
};
|
||||
use module_runtime::RuntimeSnapshotRecord;
|
||||
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::runtime_story::{
|
||||
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||||
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryCompanionViewModel,
|
||||
RuntimeStoryEncounterViewModel, RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch, RuntimeStoryPlayerViewModel, RuntimeStoryPresentation,
|
||||
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest, RuntimeStoryStatusViewModel,
|
||||
RuntimeStoryViewModel,
|
||||
};
|
||||
use shared_kernel::{format_rfc3339, offset_datetime_to_unix_micros, parse_rfc3339};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||
request_context::RequestContext, state::AppState,
|
||||
};
|
||||
|
||||
#[path = "compat/ai.rs"]
|
||||
mod ai;
|
||||
#[path = "compat/battle.rs"]
|
||||
mod battle;
|
||||
#[path = "compat/battle_actions.rs"]
|
||||
mod battle_actions;
|
||||
#[path = "compat/core.rs"]
|
||||
mod core;
|
||||
#[path = "compat/equipment_actions.rs"]
|
||||
mod equipment_actions;
|
||||
#[path = "compat/forge.rs"]
|
||||
mod forge;
|
||||
#[path = "compat/forge_actions.rs"]
|
||||
mod forge_actions;
|
||||
#[path = "compat/game_state.rs"]
|
||||
mod game_state;
|
||||
#[path = "compat/npc_support.rs"]
|
||||
mod npc_support;
|
||||
#[path = "compat/npc_actions.rs"]
|
||||
mod npc_actions;
|
||||
#[path = "compat/presentation.rs"]
|
||||
mod presentation;
|
||||
#[path = "compat/quest_actions.rs"]
|
||||
mod quest_actions;
|
||||
|
||||
use self::{
|
||||
ai::*, battle::*, battle_actions::*, core::*, equipment_actions::*, forge::*,
|
||||
forge_actions::*, game_state::*, npc_actions::*, npc_support::*, presentation::*,
|
||||
quest_actions::*,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compat/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||||
const MAX_TASK5_COMPANIONS: usize = 2;
|
||||
|
||||
struct StoryResolution {
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
story_text: Option<String>,
|
||||
presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||
saved_current_story: Option<Value>,
|
||||
patches: Vec<RuntimeStoryPatch>,
|
||||
battle: Option<RuntimeBattlePresentation>,
|
||||
toast: Option<String>,
|
||||
}
|
||||
|
||||
struct GeneratedStoryPayload {
|
||||
story_text: String,
|
||||
history_result_text: String,
|
||||
presentation_options: Vec<RuntimeStoryOptionView>,
|
||||
saved_current_story: Value,
|
||||
}
|
||||
|
||||
struct CurrentEncounterNpcQuestContext {
|
||||
npc_id: String,
|
||||
npc_name: String,
|
||||
}
|
||||
|
||||
struct PendingQuestOfferContext {
|
||||
dialogue: Vec<Value>,
|
||||
turn_count: i32,
|
||||
custom_input_placeholder: String,
|
||||
quest: Value,
|
||||
quest_id: String,
|
||||
intro_text: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn resolve_runtime_story_state(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryStateResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload.snapshot,
|
||||
)
|
||||
.await?;
|
||||
|
||||
validate_client_version(
|
||||
&request_context,
|
||||
payload.client_version,
|
||||
&snapshot.game_state,
|
||||
"运行时版本已变化,请先同步最新快照后再读取状态",
|
||||
)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, payload.client_version, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_runtime_story_state(
|
||||
State(state): State<AppState>,
|
||||
Path(session_id): Path<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let session_id = normalize_required_string(session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_state_response(&session_id, None, snapshot),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn resolve_runtime_story_action(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryActionRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let requested_session_id =
|
||||
normalize_required_string(payload.session_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "sessionId",
|
||||
"message": "sessionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
let function_id =
|
||||
normalize_required_string(payload.action.function_id.as_str()).ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "action.functionId",
|
||||
"message": "functionId 不能为空",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
if payload.action.action_type.trim() != "story_choice" {
|
||||
return Err(runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "action.type",
|
||||
"message": "runtime story 当前只支持 story_choice 动作",
|
||||
})),
|
||||
));
|
||||
}
|
||||
|
||||
let mut snapshot = resolve_snapshot_for_request(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
payload.snapshot.clone(),
|
||||
)
|
||||
.await?;
|
||||
validate_client_version(
|
||||
&request_context,
|
||||
payload.client_version,
|
||||
&snapshot.game_state,
|
||||
"运行时版本已变化,请先同步最新快照后再提交动作",
|
||||
)?;
|
||||
|
||||
let current_story_before = snapshot.current_story.clone();
|
||||
let mut game_state = snapshot.game_state.clone();
|
||||
let mut resolution = resolve_runtime_story_choice_action(
|
||||
&mut game_state,
|
||||
current_story_before.as_ref(),
|
||||
&payload,
|
||||
&function_id,
|
||||
)
|
||||
.map_err(|message| {
|
||||
runtime_story_error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let server_version = read_u32_field(&game_state, "runtimeActionVersion")
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
write_u32_field(&mut game_state, "runtimeActionVersion", server_version);
|
||||
write_string_field(
|
||||
&mut game_state,
|
||||
"runtimeSessionId",
|
||||
requested_session_id.as_str(),
|
||||
);
|
||||
|
||||
let mut options = resolution
|
||||
.presentation_options
|
||||
.take()
|
||||
.unwrap_or_else(|| build_fallback_runtime_story_options(&game_state));
|
||||
if options.is_empty() {
|
||||
options = build_fallback_runtime_story_options(&game_state);
|
||||
}
|
||||
|
||||
let mut story_text = resolution
|
||||
.story_text
|
||||
.clone()
|
||||
.unwrap_or_else(|| resolution.result_text.clone());
|
||||
let mut history_result_text = resolution.result_text.clone();
|
||||
let mut saved_current_story = resolution
|
||||
.saved_current_story
|
||||
.take()
|
||||
.unwrap_or_else(|| build_legacy_current_story(story_text.as_str(), &options));
|
||||
if let Some(generated_payload) = generate_action_story_payload(
|
||||
&state,
|
||||
&game_state,
|
||||
&payload,
|
||||
&function_id,
|
||||
resolution.action_text.as_str(),
|
||||
resolution.result_text.as_str(),
|
||||
&options,
|
||||
resolution.battle.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
story_text = generated_payload.story_text;
|
||||
history_result_text = generated_payload.history_result_text;
|
||||
options = generated_payload.presentation_options;
|
||||
saved_current_story = generated_payload.saved_current_story;
|
||||
}
|
||||
append_story_history(
|
||||
&mut game_state,
|
||||
resolution.action_text.as_str(),
|
||||
history_result_text.as_str(),
|
||||
);
|
||||
|
||||
let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend {
|
||||
action_text: resolution.action_text.clone(),
|
||||
result_text: history_result_text,
|
||||
}];
|
||||
patches.extend(resolution.patches);
|
||||
|
||||
snapshot.saved_at = Some(format_now_rfc3339());
|
||||
snapshot.game_state = game_state;
|
||||
snapshot.current_story = Some(saved_current_story);
|
||||
let persisted = persist_runtime_story_snapshot(
|
||||
&state,
|
||||
&request_context,
|
||||
authenticated.claims().user_id().to_string(),
|
||||
snapshot,
|
||||
)
|
||||
.await?;
|
||||
let persisted_snapshot = runtime_snapshot_payload_from_record(&persisted);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||||
requested_session_id,
|
||||
server_version,
|
||||
snapshot: persisted_snapshot,
|
||||
action_text: resolution.action_text,
|
||||
result_text: resolution.result_text,
|
||||
story_text,
|
||||
options,
|
||||
patches,
|
||||
toast: resolution.toast,
|
||||
battle: resolution.battle,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_initial(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_ai_response(&state, payload, true).await,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn generate_runtime_story_continue(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<RuntimeStoryAiRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
build_runtime_story_ai_response(&state, payload, false).await,
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_snapshot_for_request(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||
) -> Result<RuntimeStorySnapshotPayload, Response> {
|
||||
if let Some(snapshot) = snapshot {
|
||||
let record =
|
||||
persist_runtime_story_snapshot(state, request_context, user_id, snapshot).await?;
|
||||
return Ok(runtime_snapshot_payload_from_record(&record));
|
||||
}
|
||||
|
||||
let record = state
|
||||
.get_runtime_snapshot_record(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": "运行时快照不存在,请先初始化并保存一次游戏",
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(runtime_snapshot_payload_from_record(&record))
|
||||
}
|
||||
|
||||
async fn persist_runtime_story_snapshot(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
user_id: String,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> Result<RuntimeSnapshotRecord, Response> {
|
||||
validate_snapshot_payload(&snapshot).map_err(|message| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let saved_at = snapshot
|
||||
.saved_at
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_required_string(value))
|
||||
.map(|value| parse_rfc3339(value.as_str()))
|
||||
.transpose()
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"field": "snapshot.savedAt",
|
||||
"message": format!("savedAt 非法: {error}"),
|
||||
})),
|
||||
)
|
||||
})?
|
||||
.unwrap_or(now);
|
||||
|
||||
state
|
||||
.put_runtime_snapshot_record(
|
||||
user_id,
|
||||
offset_datetime_to_unix_micros(saved_at),
|
||||
snapshot.bottom_tab,
|
||||
snapshot.game_state,
|
||||
snapshot.current_story,
|
||||
offset_datetime_to_unix_micros(now),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
runtime_story_error_response(request_context, map_runtime_story_client_error(error))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_snapshot_payload(snapshot: &RuntimeStorySnapshotPayload) -> Result<(), String> {
|
||||
if normalize_required_string(snapshot.bottom_tab.as_str()).is_none() {
|
||||
return Err("snapshot.bottomTab 不能为空".to_string());
|
||||
}
|
||||
if !snapshot.game_state.is_object() {
|
||||
return Err("snapshot.gameState 必须是 JSON object".to_string());
|
||||
}
|
||||
if snapshot
|
||||
.current_story
|
||||
.as_ref()
|
||||
.is_some_and(|current_story| !current_story.is_object())
|
||||
{
|
||||
return Err("snapshot.currentStory 必须是 JSON object 或 null".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn runtime_snapshot_payload_from_record(
|
||||
record: &RuntimeSnapshotRecord,
|
||||
) -> RuntimeStorySnapshotPayload {
|
||||
RuntimeStorySnapshotPayload {
|
||||
saved_at: Some(record.saved_at.clone()),
|
||||
bottom_tab: record.bottom_tab.clone(),
|
||||
game_state: record.game_state.clone(),
|
||||
current_story: record.current_story.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_client_version(
|
||||
request_context: &RequestContext,
|
||||
client_version: Option<u32>,
|
||||
game_state: &Value,
|
||||
message: &str,
|
||||
) -> Result<(), Response> {
|
||||
let Some(client_version) = client_version else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(server_version) = read_u32_field(game_state, "runtimeActionVersion") else {
|
||||
return Ok(());
|
||||
};
|
||||
if client_version == server_version {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(runtime_story_error_response(
|
||||
request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "runtime-story",
|
||||
"message": message,
|
||||
"clientVersion": client_version,
|
||||
"serverVersion": server_version,
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
struct RuntimeStoryActionResponseParts {
|
||||
requested_session_id: String,
|
||||
server_version: u32,
|
||||
snapshot: RuntimeStorySnapshotPayload,
|
||||
action_text: String,
|
||||
result_text: String,
|
||||
story_text: String,
|
||||
options: Vec<RuntimeStoryOptionView>,
|
||||
patches: Vec<RuntimeStoryPatch>,
|
||||
toast: Option<String>,
|
||||
battle: Option<RuntimeBattlePresentation>,
|
||||
}
|
||||
|
||||
fn resolve_runtime_story_choice_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_runtime_story_bridge_state(game_state);
|
||||
match function_id {
|
||||
CONTINUE_ADVENTURE_FUNCTION_ID => resolve_continue_adventure_action(current_story),
|
||||
"story_opening_camp_dialogue" => resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
"交换开场判断",
|
||||
2,
|
||||
"你把眼前局势先讲清楚,对方终于愿意把第一轮判断说出口。",
|
||||
),
|
||||
"camp_travel_home_scene" => {
|
||||
clear_encounter_state(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("返回营地", request),
|
||||
result_text: "你主动结束了当前遭遇,把节奏带回了更安全的营地。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"idle_call_out" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("主动出声试探", request),
|
||||
"你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。",
|
||||
)),
|
||||
"idle_explore_forward" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("继续向前探索", request),
|
||||
"你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。",
|
||||
)),
|
||||
"idle_observe_signs" => Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("观察周围迹象", request),
|
||||
"你先压住动作,把风向、脚印和气味这些细节重新读了一遍。",
|
||||
)),
|
||||
"idle_rest_focus" => {
|
||||
restore_player_resource(game_state, 8, 6);
|
||||
Ok(simple_story_resolution(
|
||||
game_state,
|
||||
resolve_action_text("原地调息", request),
|
||||
"你把呼吸慢下来重新稳住节奏,生命和灵力都回上来一点。",
|
||||
))
|
||||
}
|
||||
"idle_travel_next_scene" => {
|
||||
clear_encounter_state(game_state);
|
||||
increment_runtime_stat(game_state, "scenesTraveled", 1);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("前往相邻场景", request),
|
||||
result_text: "你收束了这一段遭遇,顺着路线把故事推进到新的场景段落。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"npc_preview_talk" => resolve_npc_preview_action(game_state, request),
|
||||
"npc_chat" => resolve_npc_chat_action(game_state, request),
|
||||
"npc_help" => resolve_npc_help_action(game_state, request),
|
||||
"npc_chat_quest_offer_view" => {
|
||||
resolve_pending_quest_offer_view_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_chat_quest_offer_replace" => {
|
||||
resolve_pending_quest_offer_replace_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_chat_quest_offer_abandon" => {
|
||||
resolve_pending_quest_offer_abandon_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_quest_accept" => {
|
||||
resolve_pending_quest_accept_action(game_state, current_story, request)
|
||||
}
|
||||
"npc_quest_turn_in" => resolve_pending_quest_turn_in_action(game_state, request),
|
||||
"npc_leave" => {
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
clear_encounter_state(game_state);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("离开当前角色", request),
|
||||
result_text: format!("你结束了与 {npc_name} 的这一轮接触,把注意力重新放回旅途。"),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
"npc_fight" | "npc_spar" => {
|
||||
resolve_npc_battle_entry_action(game_state, request, function_id)
|
||||
}
|
||||
"npc_trade" => resolve_npc_trade_action(game_state, request),
|
||||
"npc_gift" => resolve_npc_gift_action(game_state, request),
|
||||
"npc_recruit" => resolve_npc_recruit_action(game_state, request),
|
||||
"equipment_equip" => resolve_equipment_equip_action(game_state, request),
|
||||
"equipment_unequip" => resolve_equipment_unequip_action(game_state, request),
|
||||
"forge_craft" => resolve_forge_craft_action(game_state, request),
|
||||
"forge_dismantle" => resolve_forge_dismantle_action(game_state, request),
|
||||
"forge_reforge" => resolve_forge_reforge_action(game_state, request),
|
||||
"battle_attack_basic"
|
||||
| "battle_use_skill"
|
||||
| "battle_all_in_crush"
|
||||
| "battle_escape_breakout"
|
||||
| "battle_feint_step"
|
||||
| "battle_finisher_window"
|
||||
| "battle_guard_break"
|
||||
| "battle_probe_pressure"
|
||||
| "battle_recover_breath"
|
||||
| "inventory_use" => resolve_battle_action(game_state, request, function_id),
|
||||
_ => Err(format!("暂不支持的 runtime action:{function_id}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_continue_adventure_action(
|
||||
current_story: Option<&Value>,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let deferred_options = current_story
|
||||
.map(|story| {
|
||||
read_array_field(story, "deferredOptions")
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let options = (!deferred_options.is_empty()).then_some(deferred_options);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: "继续推进冒险".to_string(),
|
||||
result_text: "你没有把节奏停下来,而是顺着当前局势继续向前推进了这一段故事。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: options,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn simple_story_resolution(
|
||||
game_state: &Value,
|
||||
action_text: String,
|
||||
result_text: &str,
|
||||
) -> StoryResolution {
|
||||
StoryResolution {
|
||||
action_text,
|
||||
result_text: result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_action_text(default_text: &str, request: &RuntimeStoryActionRequest) -> String {
|
||||
request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "optionText"))
|
||||
.unwrap_or_else(|| default_text.to_string())
|
||||
}
|
||||
|
||||
fn build_status_patch(game_state: &Value) -> RuntimeStoryPatch {
|
||||
RuntimeStoryPatch::StatusChanged {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_world_type(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "worldType")
|
||||
}
|
||||
|
||||
fn map_runtime_story_client_error(error: SpacetimeClientError) -> AppError {
|
||||
let (status, provider) = match error {
|
||||
SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-story"),
|
||||
_ => (StatusCode::BAD_GATEWAY, "spacetimedb"),
|
||||
};
|
||||
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": provider,
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
||||
error.into_response_with_context(Some(request_context))
|
||||
}
|
||||
358
server-rs/crates/api-server/src/runtime_story/compat/ai.rs
Normal file
358
server-rs/crates/api-server/src/runtime_story/compat/ai.rs
Normal file
@@ -0,0 +1,358 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn build_runtime_story_ai_response(
|
||||
state: &AppState,
|
||||
payload: RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> RuntimeStoryAiResponse {
|
||||
let options = build_ai_response_options(&payload);
|
||||
let fallback = build_ai_fallback_story_text(&payload, initial);
|
||||
let story_text = generate_ai_story_text(state, &payload, initial)
|
||||
.await
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.unwrap_or(fallback);
|
||||
|
||||
RuntimeStoryAiResponse {
|
||||
story_text,
|
||||
options,
|
||||
encounter: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn generate_ai_story_text(
|
||||
state: &AppState,
|
||||
payload: &RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> Option<String> {
|
||||
let llm_client = state.llm_client()?;
|
||||
let system_prompt = if initial {
|
||||
"你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。"
|
||||
} else {
|
||||
"你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。"
|
||||
};
|
||||
let user_prompt = json!({
|
||||
"worldType": payload.world_type,
|
||||
"character": payload.character,
|
||||
"monsters": payload.monsters,
|
||||
"history": payload.history,
|
||||
"choice": payload.choice,
|
||||
"context": payload.context,
|
||||
"availableOptions": payload.request_options.available_options,
|
||||
})
|
||||
.to_string();
|
||||
let mut request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(system_prompt),
|
||||
LlmMessage::user(user_prompt),
|
||||
]);
|
||||
request.max_tokens = Some(700);
|
||||
|
||||
llm_client
|
||||
.request_text(request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
pub(super) async fn generate_action_story_payload(
|
||||
state: &AppState,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let llm_client = state.llm_client()?;
|
||||
// 动作结算仍由确定性规则完成;LLM 只负责把已结算结果改写为可展示文本,失败时不影响主链。
|
||||
if function_id == "npc_chat" || function_id == "story_opening_camp_dialogue" {
|
||||
return generate_npc_dialogue_payload(
|
||||
llm_client,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if should_generate_reasoned_combat_story(battle) {
|
||||
return generate_reasoned_story_payload(
|
||||
llm_client,
|
||||
game_state,
|
||||
request,
|
||||
action_text,
|
||||
result_text,
|
||||
options,
|
||||
battle,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(super) async fn generate_npc_dialogue_payload(
|
||||
llm_client: &LlmClient,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
if read_required_string_field(encounter, "kind").as_deref() != Some("npc") {
|
||||
return None;
|
||||
}
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "对方".to_string());
|
||||
let user_prompt = json!({
|
||||
"worldType": world_type,
|
||||
"character": character,
|
||||
"encounter": encounter,
|
||||
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
|
||||
"history": build_action_story_history(game_state, action_text, result_text),
|
||||
"context": build_action_story_prompt_context(game_state, None),
|
||||
"topic": action_text,
|
||||
"resultSummary": result_text,
|
||||
"requestedOption": request.action.payload,
|
||||
"availableOptions": build_action_prompt_options(deferred_options),
|
||||
})
|
||||
.to_string();
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。",
|
||||
),
|
||||
LlmMessage::user(format!(
|
||||
"请基于以下运行时状态,把玩家这一轮选择改写成 2 到 5 行可直接展示的 NPC 对话。可以使用“你:”和“{npc_name}:”格式,必须保留既有结算含义。\n{user_prompt}"
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
|
||||
let dialogue_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
let presentation_options = vec![build_continue_adventure_runtime_story_option()];
|
||||
let saved_current_story =
|
||||
build_dialogue_current_story(npc_name.as_str(), dialogue_text.as_str(), deferred_options);
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: dialogue_text.clone(),
|
||||
history_result_text: dialogue_text,
|
||||
presentation_options,
|
||||
saved_current_story,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn generate_reasoned_story_payload(
|
||||
llm_client: &LlmClient,
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Option<GeneratedStoryPayload> {
|
||||
let world_type = current_world_type(game_state)?;
|
||||
let character = read_object_field(game_state, "playerCharacter")?.clone();
|
||||
let user_prompt = json!({
|
||||
"worldType": world_type,
|
||||
"character": character,
|
||||
"monsters": read_array_field(game_state, "sceneHostileNpcs").into_iter().cloned().collect::<Vec<_>>(),
|
||||
"history": build_action_story_history(game_state, action_text, result_text),
|
||||
"context": build_action_story_prompt_context(game_state, battle),
|
||||
"choice": action_text,
|
||||
"resultSummary": result_text,
|
||||
"requestedOption": request.action.payload,
|
||||
"availableOptions": build_action_prompt_options(options),
|
||||
})
|
||||
.to_string();
|
||||
let mut llm_request = LlmTextRequest::new(vec![
|
||||
LlmMessage::system(
|
||||
"你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。",
|
||||
),
|
||||
LlmMessage::user(format!(
|
||||
"请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{user_prompt}"
|
||||
)),
|
||||
]);
|
||||
llm_request.max_tokens = Some(700);
|
||||
|
||||
let story_text = llm_client
|
||||
.request_text(llm_request)
|
||||
.await
|
||||
.ok()
|
||||
.map(|response| response.content.trim().to_string())
|
||||
.filter(|text| !text.is_empty())?;
|
||||
|
||||
Some(GeneratedStoryPayload {
|
||||
story_text: story_text.clone(),
|
||||
history_result_text: story_text.clone(),
|
||||
presentation_options: options.to_vec(),
|
||||
saved_current_story: build_legacy_current_story(story_text.as_str(), options),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn should_generate_reasoned_combat_story(
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> bool {
|
||||
battle
|
||||
.and_then(|presentation| presentation.outcome.as_deref())
|
||||
.is_some_and(|outcome| matches!(outcome, "victory" | "spar_complete" | "escaped"))
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_history(
|
||||
game_state: &Value,
|
||||
action_text: &str,
|
||||
result_text: &str,
|
||||
) -> Vec<Value> {
|
||||
let mut history = read_array_field(game_state, "storyHistory")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let text = read_optional_string_field(entry, "text")?;
|
||||
let history_role = read_optional_string_field(entry, "historyRole")
|
||||
.unwrap_or_else(|| "result".to_string());
|
||||
Some(json!({
|
||||
"text": text,
|
||||
"historyRole": history_role,
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
history.push(json!({
|
||||
"text": action_text,
|
||||
"historyRole": "action",
|
||||
}));
|
||||
history.push(json!({
|
||||
"text": result_text,
|
||||
"historyRole": "result",
|
||||
}));
|
||||
let keep_from = history.len().saturating_sub(12);
|
||||
history.into_iter().skip(keep_from).collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_action_story_prompt_context(
|
||||
game_state: &Value,
|
||||
battle: Option<&RuntimeBattlePresentation>,
|
||||
) -> Value {
|
||||
let scene_preset = read_object_field(game_state, "currentScenePreset");
|
||||
let battle_value = battle
|
||||
.and_then(|presentation| serde_json::to_value(presentation).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
|
||||
json!({
|
||||
"sceneName": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "name"))
|
||||
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||
.unwrap_or_else(|| "当前区域".to_string()),
|
||||
"sceneDescription": scene_preset
|
||||
.and_then(|scene| read_optional_string_field(scene, "description"))
|
||||
.or_else(|| read_optional_string_field(game_state, "sceneDescription"))
|
||||
.unwrap_or_else(|| "周围气氛仍在继续变化。".to_string()),
|
||||
"encounterName": read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
}),
|
||||
"encounterId": current_encounter_id(game_state),
|
||||
"playerHp": read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||||
"playerMaxHp": read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||||
"playerMana": read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||||
"playerMaxMana": read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||||
"inBattle": read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
"currentNpcBattleOutcome": read_optional_string_field(game_state, "currentNpcBattleOutcome"),
|
||||
"battle": battle_value,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_action_prompt_options(options: &[RuntimeStoryOptionView]) -> Vec<Value> {
|
||||
options
|
||||
.iter()
|
||||
.filter(|option| !option.disabled.unwrap_or(false))
|
||||
.map(|option| {
|
||||
json!({
|
||||
"functionId": option.function_id,
|
||||
"actionText": option.action_text,
|
||||
"text": option.action_text,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_response_options(payload: &RuntimeStoryAiRequest) -> Vec<Value> {
|
||||
let source = if payload.request_options.available_options.is_empty() {
|
||||
&payload.request_options.option_catalog
|
||||
} else {
|
||||
&payload.request_options.available_options
|
||||
};
|
||||
let options = source
|
||||
.iter()
|
||||
.filter_map(normalize_ai_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
if !options.is_empty() {
|
||||
return options;
|
||||
}
|
||||
|
||||
vec![
|
||||
build_ai_story_option_value("idle_observe_signs", "观察周围迹象"),
|
||||
build_ai_story_option_value("idle_explore_forward", "继续向前探索"),
|
||||
build_ai_story_option_value("idle_rest_focus", "原地调息"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn normalize_ai_story_option(value: &Value) -> Option<Value> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
let mut option = value.as_object()?.clone();
|
||||
option.insert("functionId".to_string(), Value::String(function_id));
|
||||
option.insert("actionText".to_string(), Value::String(action_text.clone()));
|
||||
option
|
||||
.entry("text".to_string())
|
||||
.or_insert_with(|| Value::String(action_text));
|
||||
|
||||
Some(Value::Object(option))
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_story_option_value(function_id: &str, action_text: &str) -> Value {
|
||||
json!({
|
||||
"functionId": function_id,
|
||||
"actionText": action_text,
|
||||
"text": action_text,
|
||||
"visuals": {
|
||||
"playerAnimation": "idle",
|
||||
"playerMoveMeters": 0,
|
||||
"playerOffsetY": 0,
|
||||
"playerFacing": "right",
|
||||
"scrollWorld": false,
|
||||
"monsterChanges": []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_ai_fallback_story_text(
|
||||
payload: &RuntimeStoryAiRequest,
|
||||
initial: bool,
|
||||
) -> String {
|
||||
let character_name =
|
||||
read_optional_string_field(&payload.character, "name").unwrap_or_else(|| "你".to_string());
|
||||
let scene_name = read_optional_string_field(&payload.context, "sceneName")
|
||||
.or_else(|| read_optional_string_field(&payload.context, "scene"))
|
||||
.unwrap_or_else(|| "当前区域".to_string());
|
||||
if initial {
|
||||
return format!(
|
||||
"{character_name} 在 {scene_name} 稳住脚步,周围的气息正在变化,第一轮选择已经摆到眼前。"
|
||||
);
|
||||
}
|
||||
|
||||
let choice = normalize_required_string(payload.choice.as_str())
|
||||
.unwrap_or_else(|| "继续推进".to_string());
|
||||
format!("{character_name} 选择了「{choice}」,{scene_name} 的局势随之向下一步展开。")
|
||||
}
|
||||
616
server-rs/crates/api-server/src/runtime_story/compat/battle.rs
Normal file
616
server-rs/crates/api-server/src/runtime_story/compat/battle.rs
Normal file
@@ -0,0 +1,616 @@
|
||||
use super::*;
|
||||
|
||||
/// 兼容桥里的战斗动作仍走快照态,因此把每回合需要写回的字段先收口到这里,
|
||||
/// 避免技能、物品、旧 battle_* 分支继续把状态更新散落在各处。
|
||||
pub(super) struct BattleActionPlan {
|
||||
pub(super) action_text: String,
|
||||
pub(super) result_text: String,
|
||||
pub(super) damage_dealt: i32,
|
||||
pub(super) damage_taken: i32,
|
||||
pub(super) heal: i32,
|
||||
pub(super) mana_restore: i32,
|
||||
pub(super) mana_cost: i32,
|
||||
pub(super) cooldown_tick_turns: i32,
|
||||
pub(super) cooldown_bonus_turns: i32,
|
||||
pub(super) applied_skill_cooldown: Option<(String, i32)>,
|
||||
pub(super) build_buffs: Vec<Value>,
|
||||
pub(super) consumed_item_id: Option<String>,
|
||||
}
|
||||
|
||||
struct BattleSkillView {
|
||||
id: String,
|
||||
name: String,
|
||||
damage: i32,
|
||||
mana_cost: i32,
|
||||
cooldown_turns: i32,
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
struct BattleInventoryUseProfile {
|
||||
hp_restore: i32,
|
||||
mana_restore: i32,
|
||||
cooldown_reduction: i32,
|
||||
build_buffs: Vec<Value>,
|
||||
}
|
||||
|
||||
struct BattleInventoryItemView {
|
||||
id: String,
|
||||
name: String,
|
||||
quantity: i32,
|
||||
use_profile: Option<BattleInventoryUseProfile>,
|
||||
}
|
||||
|
||||
pub(super) fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) {
|
||||
let max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp);
|
||||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana);
|
||||
write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp));
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerMana",
|
||||
(mana + mana_restore).clamp(0, max_mana),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
|
||||
if mana_cost <= 0 {
|
||||
return;
|
||||
}
|
||||
let mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
|
||||
}
|
||||
|
||||
pub(super) fn apply_player_damage(game_state: &mut Value, damage: i32) {
|
||||
if damage <= 0 {
|
||||
return;
|
||||
}
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1);
|
||||
write_i32_field(game_state, "playerHp", (hp - damage).max(1));
|
||||
}
|
||||
|
||||
pub(super) fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
|
||||
let target_hp = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| {
|
||||
read_i32_field(encounter, "hp")
|
||||
.or_else(|| read_i32_field(encounter, "currentHp"))
|
||||
.or_else(|| read_i32_field(encounter, "targetHp"))
|
||||
})
|
||||
.or_else(|| {
|
||||
read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.and_then(|target| read_i32_field(target, "hp"))
|
||||
})
|
||||
.unwrap_or(24);
|
||||
let next_hp = target_hp - damage.max(0);
|
||||
write_current_encounter_i32_field(game_state, "hp", next_hp);
|
||||
write_first_hostile_npc_i32_field(game_state, "hp", next_hp);
|
||||
|
||||
next_hp
|
||||
}
|
||||
|
||||
fn read_player_skills(game_state: &Value) -> Vec<BattleSkillView> {
|
||||
read_field(game_state, "playerCharacter")
|
||||
.map(|character| read_array_field(character, "skills"))
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let id = read_optional_string_field(entry, "id")?;
|
||||
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
|
||||
Some(BattleSkillView {
|
||||
id,
|
||||
name,
|
||||
damage: read_i32_field(entry, "damage").unwrap_or(14).max(0),
|
||||
mana_cost: read_i32_field(entry, "manaCost").unwrap_or(0).max(0),
|
||||
cooldown_turns: read_i32_field(entry, "cooldownTurns").unwrap_or(0).max(0),
|
||||
build_buffs: read_array_field(entry, "buildBuffs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_player_skill_by_id(game_state: &Value, skill_id: &str) -> Option<BattleSkillView> {
|
||||
read_player_skills(game_state)
|
||||
.into_iter()
|
||||
.find(|skill| skill.id == skill_id)
|
||||
}
|
||||
|
||||
pub(super) fn read_player_skill_cooldowns(
|
||||
game_state: &Value,
|
||||
) -> std::collections::BTreeMap<String, i32> {
|
||||
read_object_field(game_state, "playerSkillCooldowns")
|
||||
.and_then(Value::as_object)
|
||||
.map(|cooldowns| {
|
||||
cooldowns
|
||||
.iter()
|
||||
.map(|(skill_id, turns)| {
|
||||
(
|
||||
skill_id.clone(),
|
||||
turns
|
||||
.as_i64()
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||
if turns <= 0 {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let cooldowns = root
|
||||
.entry("playerSkillCooldowns".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !cooldowns.is_object() {
|
||||
*cooldowns = Value::Object(Map::new());
|
||||
}
|
||||
let cooldowns = cooldowns
|
||||
.as_object_mut()
|
||||
.expect("playerSkillCooldowns should be object");
|
||||
for value in cooldowns.values_mut() {
|
||||
let current = value
|
||||
.as_i64()
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
.unwrap_or(0);
|
||||
*value = json!((current - turns).max(0));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||
if turns <= 0 {
|
||||
return;
|
||||
}
|
||||
tick_player_skill_cooldowns(game_state, turns);
|
||||
}
|
||||
|
||||
pub(super) fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let cooldowns = root
|
||||
.entry("playerSkillCooldowns".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !cooldowns.is_object() {
|
||||
*cooldowns = Value::Object(Map::new());
|
||||
}
|
||||
cooldowns
|
||||
.as_object_mut()
|
||||
.expect("playerSkillCooldowns should be object")
|
||||
.insert(skill_id.to_string(), json!(turns.max(0)));
|
||||
}
|
||||
|
||||
fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemView> {
|
||||
read_array_field(game_state, "playerInventory")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let id = read_optional_string_field(entry, "id")?;
|
||||
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
|
||||
let use_profile =
|
||||
read_field(entry, "useProfile").map(|profile| BattleInventoryUseProfile {
|
||||
hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0),
|
||||
mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0),
|
||||
cooldown_reduction: read_i32_field(profile, "cooldownReduction")
|
||||
.unwrap_or(0)
|
||||
.max(0),
|
||||
build_buffs: read_array_field(profile, "buildBuffs")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
});
|
||||
Some(BattleInventoryItemView {
|
||||
id,
|
||||
name,
|
||||
quantity: read_i32_field(entry, "quantity").unwrap_or(0).max(0),
|
||||
use_profile,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn find_player_inventory_item(game_state: &Value, item_id: &str) -> Option<BattleInventoryItemView> {
|
||||
read_player_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.find(|item| item.id == item_id)
|
||||
}
|
||||
|
||||
pub(super) fn battle_victory_experience_reward(game_state: &Value) -> i32 {
|
||||
let hostile = read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| read_field(game_state, "currentEncounter"));
|
||||
let explicit_reward = hostile
|
||||
.and_then(|entry| read_i32_field(entry, "experienceReward"))
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
if explicit_reward > 0 {
|
||||
return explicit_reward;
|
||||
}
|
||||
let level = hostile
|
||||
.and_then(|entry| read_field(entry, "levelProfile"))
|
||||
.and_then(|profile| read_i32_field(profile, "level"))
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
12 + 6 * (level - 1)
|
||||
}
|
||||
|
||||
fn battle_action_numbers(
|
||||
function_id: &str,
|
||||
) -> (i32, i32, i32, i32, i32, &'static str, &'static str) {
|
||||
match function_id {
|
||||
"battle_recover_breath" => (
|
||||
0,
|
||||
0,
|
||||
8,
|
||||
6,
|
||||
0,
|
||||
"恢复",
|
||||
"你先稳住呼吸,把状态从危险边缘拉回一点。",
|
||||
),
|
||||
"battle_use_skill" => (
|
||||
14,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
4,
|
||||
"施放技能",
|
||||
"你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。",
|
||||
),
|
||||
"battle_all_in_crush" => (
|
||||
22,
|
||||
8,
|
||||
0,
|
||||
0,
|
||||
6,
|
||||
"全力压制",
|
||||
"你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。",
|
||||
),
|
||||
"battle_feint_step" => (
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"佯攻换位",
|
||||
"你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。",
|
||||
),
|
||||
"battle_finisher_window" => (
|
||||
18,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
3,
|
||||
"抓住终结窗口",
|
||||
"你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。",
|
||||
),
|
||||
"battle_guard_break" => (
|
||||
12,
|
||||
5,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
"破开防守",
|
||||
"你顶住压力破开对方防守,为后续行动争到更直接的窗口。",
|
||||
),
|
||||
"battle_probe_pressure" => (
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"试探压迫",
|
||||
"你没有贸然压上,而是用轻攻测试对方反应。",
|
||||
),
|
||||
_ => (
|
||||
10,
|
||||
4,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
"普通攻击",
|
||||
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
if function_id == "battle_use_skill" {
|
||||
return build_skill_battle_action_plan(game_state, request);
|
||||
}
|
||||
if function_id == "inventory_use" {
|
||||
return build_inventory_use_battle_action_plan(game_state, request);
|
||||
}
|
||||
|
||||
let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) =
|
||||
battle_action_numbers(function_id);
|
||||
Ok(BattleActionPlan {
|
||||
action_text: action_text.to_string(),
|
||||
result_text: result_text.to_string(),
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
heal,
|
||||
mana_restore,
|
||||
mana_cost,
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: 0,
|
||||
applied_skill_cooldown: None,
|
||||
build_buffs: Vec::new(),
|
||||
consumed_item_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_skill_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
let payload = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
|
||||
let skill_id = read_optional_string_field(payload, "skillId")
|
||||
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
|
||||
let skill = find_player_skill_by_id(game_state, skill_id.as_str())
|
||||
.ok_or_else(|| format!("未找到技能:{skill_id}"))?;
|
||||
let cooldowns = read_player_skill_cooldowns(game_state);
|
||||
if cooldowns.get(skill_id.as_str()).copied().unwrap_or(0) > 0 {
|
||||
return Err(format!("{} 仍在冷却中", skill.name));
|
||||
}
|
||||
if skill.mana_cost > read_i32_field(game_state, "playerMana").unwrap_or(0) {
|
||||
return Err("当前灵力不足,无法执行这个战斗动作".to_string());
|
||||
}
|
||||
|
||||
Ok(BattleActionPlan {
|
||||
action_text: skill.name.clone(),
|
||||
result_text: format!("{} 命中了敌人,这一轮技能效果已经直接结算。", skill.name),
|
||||
damage_dealt: skill.damage.max(1),
|
||||
damage_taken: 4,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
mana_cost: skill.mana_cost.max(0),
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: 0,
|
||||
applied_skill_cooldown: Some((skill.id, skill.cooldown_turns.max(0))),
|
||||
build_buffs: skill.build_buffs,
|
||||
consumed_item_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_inventory_use_battle_action_plan(
|
||||
game_state: &Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<BattleActionPlan, String> {
|
||||
let payload = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
|
||||
let item_id = read_optional_string_field(payload, "itemId")
|
||||
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_item(game_state, item_id.as_str())
|
||||
.ok_or_else(|| "未找到可用于战斗结算的物品".to_string())?;
|
||||
if item.quantity <= 0 {
|
||||
return Err("未找到可用于战斗结算的物品".to_string());
|
||||
}
|
||||
if item.use_profile.is_none() {
|
||||
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
|
||||
}
|
||||
let effect = item.use_profile.expect("use_profile should exist");
|
||||
if effect.hp_restore <= 0
|
||||
&& effect.mana_restore <= 0
|
||||
&& effect.cooldown_reduction <= 0
|
||||
&& effect.build_buffs.is_empty()
|
||||
{
|
||||
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
|
||||
}
|
||||
|
||||
Ok(BattleActionPlan {
|
||||
action_text: format!("使用{}", item.name),
|
||||
result_text: format!("你立刻用下{},当前回合的物品效果已经生效。", item.name),
|
||||
damage_dealt: 0,
|
||||
damage_taken: 8,
|
||||
heal: effect.hp_restore.max(0),
|
||||
mana_restore: effect.mana_restore.max(0),
|
||||
mana_cost: 0,
|
||||
cooldown_tick_turns: 1,
|
||||
cooldown_bonus_turns: effect.cooldown_reduction.max(0),
|
||||
applied_skill_cooldown: None,
|
||||
build_buffs: effect.build_buffs,
|
||||
consumed_item_id: Some(item.id),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn battle_action_toast(
|
||||
function_id: &str,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Option<String> {
|
||||
if function_id != "inventory_use" {
|
||||
return None;
|
||||
}
|
||||
let item_name = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"));
|
||||
item_name.map(|_| "Build 增益已写回当前快照".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn build_battle_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let mut options = vec![
|
||||
RuntimeStoryOptionView {
|
||||
detail_text: Some(build_basic_attack_detail_text(game_state)),
|
||||
..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat")
|
||||
},
|
||||
RuntimeStoryOptionView {
|
||||
detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()),
|
||||
..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat")
|
||||
},
|
||||
];
|
||||
|
||||
let preferred_item = pick_preferred_battle_inventory_item(game_state);
|
||||
if let Some(item) = preferred_item {
|
||||
let effect = item
|
||||
.use_profile
|
||||
.expect("preferred battle item must have use profile");
|
||||
options.push(build_runtime_story_option_with_payload(
|
||||
"inventory_use",
|
||||
&format!("使用物品:{}", item.name),
|
||||
"combat",
|
||||
Some(build_battle_item_summary(&effect)),
|
||||
json!({
|
||||
"itemId": item.id
|
||||
}),
|
||||
));
|
||||
} else {
|
||||
options.push(build_disabled_runtime_story_option(
|
||||
"inventory_use",
|
||||
"使用物品",
|
||||
"combat",
|
||||
Some("当前没有可直接结算的战斗消耗品".to_string()),
|
||||
"暂无可用物品",
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
options.extend(build_battle_skill_runtime_story_options(game_state));
|
||||
options.push(build_static_runtime_story_option(
|
||||
"battle_escape_breakout",
|
||||
"强行脱离战斗",
|
||||
"combat",
|
||||
));
|
||||
options
|
||||
}
|
||||
|
||||
fn build_basic_attack_detail_text(game_state: &Value) -> String {
|
||||
let strength = read_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_field(character, "attributes"))
|
||||
.and_then(|attributes| read_i32_field(attributes, "strength"))
|
||||
.unwrap_or(8);
|
||||
let agility = read_field(game_state, "playerCharacter")
|
||||
.and_then(|character| read_field(character, "attributes"))
|
||||
.and_then(|attributes| read_i32_field(attributes, "agility"))
|
||||
.unwrap_or(0);
|
||||
let preview_damage = ((strength * 85 + agility * 45) / 100).max(8);
|
||||
format!("不耗蓝 / 伤害 {preview_damage}")
|
||||
}
|
||||
|
||||
fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
||||
let cooldowns = read_player_skill_cooldowns(game_state);
|
||||
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
read_player_skills(game_state)
|
||||
.into_iter()
|
||||
.map(|skill| {
|
||||
let detail_text = Some(format!(
|
||||
"耗蓝 {} / 伤害 {} / 冷却 {}",
|
||||
skill.mana_cost.max(0),
|
||||
skill.damage.max(0),
|
||||
skill.cooldown_turns.max(0)
|
||||
));
|
||||
let payload = Some(json!({
|
||||
"skillId": skill.id
|
||||
}));
|
||||
let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0);
|
||||
if remaining_cooldown > 0 {
|
||||
return build_disabled_runtime_story_option(
|
||||
"battle_use_skill",
|
||||
&skill.name,
|
||||
"combat",
|
||||
detail_text,
|
||||
format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(),
|
||||
payload,
|
||||
);
|
||||
}
|
||||
if skill.mana_cost > player_mana {
|
||||
return build_disabled_runtime_story_option(
|
||||
"battle_use_skill",
|
||||
&skill.name,
|
||||
"combat",
|
||||
detail_text,
|
||||
"灵力不足",
|
||||
payload,
|
||||
);
|
||||
}
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload,
|
||||
..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
|
||||
fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option<BattleInventoryItemView> {
|
||||
let has_cooling_skill = read_player_skill_cooldowns(game_state)
|
||||
.values()
|
||||
.any(|remaining| *remaining > 0);
|
||||
let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0);
|
||||
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
|
||||
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
let hp_low = player_hp * 100 <= player_max_hp * 45;
|
||||
let mana_low = player_mana * 100 <= player_max_mana * 45;
|
||||
|
||||
read_player_inventory_items(game_state)
|
||||
.into_iter()
|
||||
.filter(|item| item.quantity > 0 && item.use_profile.is_some())
|
||||
.filter_map(|item| {
|
||||
let effect = item.use_profile.as_ref()?;
|
||||
let mut score = effect.build_buffs.len() as i32 * 8;
|
||||
score += effect.hp_restore * if hp_low { 3 } else { 1 };
|
||||
score += effect.mana_restore * if mana_low { 2 } else { 1 };
|
||||
score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 };
|
||||
Some((score, item))
|
||||
})
|
||||
.max_by(|left, right| {
|
||||
left.0
|
||||
.cmp(&right.0)
|
||||
.then_with(|| left.1.name.cmp(&right.1.name).reverse())
|
||||
})
|
||||
.map(|(_, item)| item)
|
||||
}
|
||||
|
||||
fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if effect.hp_restore > 0 {
|
||||
parts.push(format!("回血 {}", effect.hp_restore));
|
||||
}
|
||||
if effect.mana_restore > 0 {
|
||||
parts.push(format!("回蓝 {}", effect.mana_restore));
|
||||
}
|
||||
if effect.cooldown_reduction > 0 {
|
||||
parts.push(format!("冷却 -{}", effect.cooldown_reduction));
|
||||
}
|
||||
if !effect.build_buffs.is_empty() {
|
||||
let buff_names = effect
|
||||
.build_buffs
|
||||
.iter()
|
||||
.filter_map(|buff| read_optional_string_field(buff, "name"))
|
||||
.collect::<Vec<_>>();
|
||||
if !buff_names.is_empty() {
|
||||
parts.push(format!("增益 {}", buff_names.join("、")));
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"立即结算一次物品效果".to_string()
|
||||
} else {
|
||||
parts.join(" / ")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_battle_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let target_id = current_encounter_id(game_state)
|
||||
.or_else(|| first_hostile_npc_string_field(game_state, "id"))
|
||||
.unwrap_or_else(|| "battle_target".to_string());
|
||||
let target_name = current_encounter_name_from_battle(game_state);
|
||||
let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode")
|
||||
.unwrap_or_else(|| "fight".to_string());
|
||||
|
||||
if function_id == "battle_escape_breakout" {
|
||||
clear_encounter_state(game_state);
|
||||
return Ok(StoryResolution {
|
||||
action_text: resolve_action_text("强行脱离战斗", request),
|
||||
result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![
|
||||
RuntimeStoryPatch::BattleResolved {
|
||||
function_id: function_id.to_string(),
|
||||
target_id: Some(target_id.clone()),
|
||||
damage_dealt: Some(0),
|
||||
damage_taken: Some(0),
|
||||
outcome: "escaped".to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
|
||||
],
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(target_id),
|
||||
target_name: Some(target_name),
|
||||
damage_dealt: Some(0),
|
||||
damage_taken: Some(0),
|
||||
outcome: Some("escaped".to_string()),
|
||||
}),
|
||||
toast: Some("已脱离战斗".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let plan = build_battle_action_plan(game_state, request, function_id)?;
|
||||
spend_player_mana(game_state, plan.mana_cost);
|
||||
restore_player_resource(game_state, plan.heal, plan.mana_restore);
|
||||
tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns);
|
||||
reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns);
|
||||
if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() {
|
||||
set_player_skill_cooldown(game_state, skill_id.as_str(), *turns);
|
||||
}
|
||||
if !plan.build_buffs.is_empty() {
|
||||
append_active_build_buffs(game_state, plan.build_buffs.clone());
|
||||
}
|
||||
if let Some(item_id) = plan.consumed_item_id.as_ref() {
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
increment_runtime_stat(game_state, "itemsUsed", 1);
|
||||
}
|
||||
|
||||
apply_player_damage(game_state, plan.damage_taken);
|
||||
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
|
||||
let outcome = if target_hp <= 0 {
|
||||
if battle_mode == "spar" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"victory"
|
||||
}
|
||||
} else {
|
||||
"ongoing"
|
||||
};
|
||||
|
||||
let victory_experience = if outcome == "victory" {
|
||||
battle_victory_experience_reward(game_state)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if outcome != "ongoing" {
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
if outcome == "spar_complete" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"fight_victory"
|
||||
},
|
||||
);
|
||||
if outcome == "victory" {
|
||||
clear_encounter_only(game_state);
|
||||
increment_runtime_stat(game_state, "hostileNpcsDefeated", 1);
|
||||
if victory_experience > 0 {
|
||||
grant_player_progression_experience(game_state, victory_experience, "hostile_npc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut patches = vec![
|
||||
RuntimeStoryPatch::BattleResolved {
|
||||
function_id: function_id.to_string(),
|
||||
target_id: Some(target_id.clone()),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: outcome.to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
];
|
||||
if outcome == "victory" {
|
||||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||||
}
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(plan.action_text.as_str(), request),
|
||||
result_text: if outcome == "ongoing" {
|
||||
plan.result_text
|
||||
} else if outcome == "spar_complete" {
|
||||
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
|
||||
} else {
|
||||
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
|
||||
},
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(target_id),
|
||||
target_name: Some(target_name),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: Some(outcome.to_string()),
|
||||
}),
|
||||
toast: battle_action_toast(function_id, request),
|
||||
})
|
||||
}
|
||||
321
server-rs/crates/api-server/src/runtime_story/compat/core.rs
Normal file
321
server-rs/crates/api-server/src/runtime_story/compat/core.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn clear_encounter_state(game_state: &mut Value) {
|
||||
clear_encounter_only(game_state);
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
}
|
||||
|
||||
pub(super) fn clear_encounter_only(game_state: &mut Value) {
|
||||
write_null_field(game_state, "currentEncounter");
|
||||
let root = ensure_json_object(game_state);
|
||||
root.insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new()));
|
||||
}
|
||||
|
||||
pub(super) fn append_story_history(game_state: &mut Value, action_text: &str, result_text: &str) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let story_history = root
|
||||
.entry("storyHistory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !story_history.is_array() {
|
||||
*story_history = Value::Array(Vec::new());
|
||||
}
|
||||
let entries = story_history
|
||||
.as_array_mut()
|
||||
.expect("storyHistory should be array");
|
||||
entries.push(json!({
|
||||
"text": action_text,
|
||||
"historyRole": "action",
|
||||
}));
|
||||
entries.push(json!({
|
||||
"text": result_text,
|
||||
"historyRole": "result",
|
||||
}));
|
||||
}
|
||||
|
||||
pub(super) fn increment_runtime_stat(game_state: &mut Value, key: &str, delta: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let stats = root
|
||||
.entry("runtimeStats".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !stats.is_object() {
|
||||
*stats = Value::Object(Map::new());
|
||||
}
|
||||
let stats = stats
|
||||
.as_object_mut()
|
||||
.expect("runtimeStats should be object");
|
||||
let previous = stats
|
||||
.get(key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0);
|
||||
stats.insert(key.to_string(), json!((previous + delta).max(0)));
|
||||
}
|
||||
|
||||
pub(super) fn add_player_currency(game_state: &mut Value, delta: i32) {
|
||||
let previous = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
previous.saturating_add(delta.max(0)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn add_player_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let root = ensure_json_object(game_state);
|
||||
let inventory = root
|
||||
.entry("playerInventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
let items = inventory
|
||||
.as_array_mut()
|
||||
.expect("playerInventory should be array");
|
||||
items.extend(additions);
|
||||
}
|
||||
|
||||
pub(super) fn grant_player_progression_experience(
|
||||
game_state: &mut Value,
|
||||
amount: i32,
|
||||
source: &str,
|
||||
) {
|
||||
if amount <= 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let root = ensure_json_object(game_state);
|
||||
let progression = root
|
||||
.entry("playerProgression".to_string())
|
||||
.or_insert_with(|| Value::Object(Map::new()));
|
||||
if !progression.is_object() {
|
||||
*progression = Value::Object(Map::new());
|
||||
}
|
||||
let progression = progression
|
||||
.as_object_mut()
|
||||
.expect("playerProgression should be object");
|
||||
let previous_total_xp = progression
|
||||
.get("totalXp")
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_total_xp = previous_total_xp.saturating_add(amount);
|
||||
let level = resolve_progression_level(next_total_xp);
|
||||
let current_level_xp = next_total_xp.saturating_sub(cumulative_xp_required(level));
|
||||
let xp_to_next_level = if level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
xp_to_next_level_for(level)
|
||||
};
|
||||
|
||||
progression.insert("level".to_string(), json!(level));
|
||||
progression.insert("currentLevelXp".to_string(), json!(current_level_xp.max(0)));
|
||||
progression.insert("totalXp".to_string(), json!(next_total_xp));
|
||||
progression.insert("xpToNextLevel".to_string(), json!(xp_to_next_level.max(0)));
|
||||
progression.insert("pendingLevelUps".to_string(), json!(0));
|
||||
progression.insert(
|
||||
"lastGrantedSource".to_string(),
|
||||
Value::String(source.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) const MAX_PLAYER_LEVEL: i32 = 20;
|
||||
|
||||
pub(super) fn xp_to_next_level_for(level: i32) -> i32 {
|
||||
if level >= MAX_PLAYER_LEVEL {
|
||||
0
|
||||
} else {
|
||||
let scale = (level - 1).max(0);
|
||||
60 + 20 * scale + 8 * scale * scale
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cumulative_xp_required(level: i32) -> i32 {
|
||||
let mut total = 0;
|
||||
let capped_level = level.clamp(1, MAX_PLAYER_LEVEL);
|
||||
for current_level in 1..capped_level {
|
||||
total += xp_to_next_level_for(current_level);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
pub(super) fn resolve_progression_level(total_xp: i32) -> i32 {
|
||||
let normalized_total_xp = total_xp.max(0);
|
||||
let mut resolved_level = 1;
|
||||
for level in 2..=MAX_PLAYER_LEVEL {
|
||||
if normalized_total_xp < cumulative_xp_required(level) {
|
||||
break;
|
||||
}
|
||||
resolved_level = level;
|
||||
}
|
||||
resolved_level
|
||||
}
|
||||
|
||||
pub(super) fn append_active_build_buffs(game_state: &mut Value, additions: Vec<Value>) {
|
||||
if additions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let buffs = root
|
||||
.entry("activeBuildBuffs".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !buffs.is_array() {
|
||||
*buffs = Value::Array(Vec::new());
|
||||
}
|
||||
buffs
|
||||
.as_array_mut()
|
||||
.expect("activeBuildBuffs should be array")
|
||||
.extend(additions);
|
||||
}
|
||||
|
||||
pub(super) fn remove_player_inventory_item(game_state: &mut Value, item_id: &str, quantity: i32) {
|
||||
if quantity <= 0 {
|
||||
return;
|
||||
}
|
||||
let root = ensure_json_object(game_state);
|
||||
let inventory = root
|
||||
.entry("playerInventory".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !inventory.is_array() {
|
||||
*inventory = Value::Array(Vec::new());
|
||||
}
|
||||
let items = inventory
|
||||
.as_array_mut()
|
||||
.expect("playerInventory should be array");
|
||||
let Some(index) = items
|
||||
.iter()
|
||||
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let current_quantity = read_i32_field(&items[index], "quantity")
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let next_quantity = current_quantity - quantity;
|
||||
if next_quantity <= 0 {
|
||||
items.remove(index);
|
||||
return;
|
||||
}
|
||||
if let Some(item) = items[index].as_object_mut() {
|
||||
item.insert("quantity".to_string(), json!(next_quantity));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_current_encounter_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let Some(encounter) = root.get_mut("currentEncounter") else {
|
||||
return;
|
||||
};
|
||||
if let Some(encounter) = encounter.as_object_mut() {
|
||||
encounter.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_first_hostile_npc_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let Some(hostiles) = root.get_mut("sceneHostileNpcs") else {
|
||||
return;
|
||||
};
|
||||
let Some(first) = hostiles.as_array_mut().and_then(|items| items.first_mut()) else {
|
||||
return;
|
||||
};
|
||||
if let Some(first) = first.as_object_mut() {
|
||||
first.insert(key.to_string(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn first_hostile_npc_string_field(game_state: &Value, key: &str) -> Option<String> {
|
||||
read_array_field(game_state, "sceneHostileNpcs")
|
||||
.first()
|
||||
.and_then(|target| read_optional_string_field(target, key))
|
||||
}
|
||||
|
||||
pub(super) fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||
read_optional_string_field(game_state, "runtimeSessionId")
|
||||
}
|
||||
|
||||
pub(super) fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
value.as_object()?.get(key)
|
||||
}
|
||||
|
||||
pub(super) fn read_object_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
let field = read_field(value, key)?;
|
||||
field.is_object().then_some(field)
|
||||
}
|
||||
|
||||
pub(super) fn read_array_field<'a>(value: &'a Value, key: &str) -> Vec<&'a Value> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(super) fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||
}
|
||||
|
||||
pub(super) fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||
}
|
||||
|
||||
pub(super) fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||
read_field(value, key).and_then(Value::as_bool)
|
||||
}
|
||||
|
||||
pub(super) fn read_i32_field(value: &Value, key: &str) -> Option<i32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_i64)
|
||||
.and_then(|number| i32::try_from(number).ok())
|
||||
}
|
||||
|
||||
pub(super) fn read_u32_field(value: &Value, key: &str) -> Option<u32> {
|
||||
read_field(value, key)
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|number| u32::try_from(number).ok())
|
||||
}
|
||||
|
||||
pub(super) fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub(super) fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
|
||||
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||
}
|
||||
|
||||
pub(super) fn write_bool_field(value: &mut Value, key: &str, field_value: bool) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::Bool(field_value));
|
||||
}
|
||||
|
||||
pub(super) fn write_string_field(value: &mut Value, key: &str, field_value: &str) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::String(field_value.to_string()));
|
||||
}
|
||||
|
||||
pub(super) fn write_null_field(value: &mut Value, key: &str) {
|
||||
ensure_json_object(value).insert(key.to_string(), Value::Null);
|
||||
}
|
||||
|
||||
pub(super) fn ensure_json_object(value: &mut Value) -> &mut Map<String, Value> {
|
||||
if !value.is_object() {
|
||||
*value = Value::Object(Map::new());
|
||||
}
|
||||
value.as_object_mut().expect("value should be object")
|
||||
}
|
||||
|
||||
pub(super) fn normalize_required_string(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
pub(super) fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||
value.and_then(normalize_required_string)
|
||||
}
|
||||
|
||||
pub(super) fn format_now_rfc3339() -> String {
|
||||
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use super::*;
|
||||
|
||||
/// 对齐 Node 旧 inventory compat,先按装备位把物品从背包切到 playerEquipment,
|
||||
/// 再把基础面板属性回算到快照上。
|
||||
pub(super) fn resolve_equipment_equip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
if read_field(game_state, "playerCharacter").is_none() {
|
||||
return Err("缺少玩家角色,无法调整装备。".to_string());
|
||||
}
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return Err("战斗中无法调整装备。".to_string());
|
||||
}
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_equip 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件装备。".to_string())?;
|
||||
let slot_id = resolve_equipment_slot_for_item(&item)
|
||||
.ok_or_else(|| format!("{} 不是可装备物品。", read_inventory_item_name(&item)))?;
|
||||
let previous_equipment = read_player_equipment_item(game_state, slot_id);
|
||||
let next_equipment_item = normalize_equipped_item(&item);
|
||||
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
add_player_inventory_items(game_state, vec![previous_equipment.clone()]);
|
||||
}
|
||||
write_player_equipment_item(game_state, slot_id, Some(next_equipment_item));
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&item);
|
||||
let result_text = if let Some(previous_equipment) = previous_equipment.as_ref() {
|
||||
format!(
|
||||
"你将{}从{}位上换下,改为装备{}。",
|
||||
read_inventory_item_name(previous_equipment),
|
||||
equipment_slot_label(slot_id),
|
||||
item_name
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"你将{}装备在{}位上。",
|
||||
item_name,
|
||||
equipment_slot_label(slot_id)
|
||||
)
|
||||
};
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("装备{}", item_name), request),
|
||||
result_text,
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_equipment_unequip_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法卸下装备。",
|
||||
"战斗中无法卸下装备。",
|
||||
)?;
|
||||
let slot_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "slotId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let slot_id = normalize_equipment_slot_id(slot_id.as_str())
|
||||
.ok_or_else(|| "equipment_unequip 缺少合法 slotId".to_string())?;
|
||||
let equipped_item = read_player_equipment_item(game_state, slot_id)
|
||||
.ok_or_else(|| format!("{}位当前没有装备。", equipment_slot_label(slot_id)))?;
|
||||
|
||||
write_player_equipment_item(game_state, slot_id, None);
|
||||
add_player_inventory_items(game_state, vec![equipped_item.clone()]);
|
||||
apply_equipment_loadout_to_state(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("卸下{}", read_inventory_item_name(&equipped_item)),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"你卸下了{},暂时收回背包。",
|
||||
read_inventory_item_name(&equipped_item)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
409
server-rs/crates/api-server/src/runtime_story/compat/forge.rs
Normal file
409
server-rs/crates/api-server/src/runtime_story/compat/forge.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
use super::*;
|
||||
|
||||
/// 这批定义只服务 compat bridge 的确定性锻造规则,
|
||||
/// 先在 `api-server` 内收口,后续再评估是否值得独立 crate。
|
||||
pub(super) struct ForgeRequirementDefinition {
|
||||
pub(super) quantity: i32,
|
||||
pub(super) matcher: ForgeRequirementMatcher,
|
||||
}
|
||||
|
||||
pub(super) enum ForgeRequirementMatcher {
|
||||
Named(&'static str),
|
||||
AnyMaterial,
|
||||
}
|
||||
|
||||
pub(super) struct ForgeRecipeDefinition {
|
||||
pub(super) id: &'static str,
|
||||
pub(super) name: &'static str,
|
||||
pub(super) currency_cost: i32,
|
||||
pub(super) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(super) struct ReforgeCostDefinition {
|
||||
pub(super) currency_cost: i32,
|
||||
pub(super) requirements: Vec<ForgeRequirementDefinition>,
|
||||
}
|
||||
|
||||
pub(super) fn forge_recipe_definition(recipe_id: &str) -> Option<ForgeRecipeDefinition> {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => Some(ForgeRecipeDefinition {
|
||||
id: "synthesis-refined-ingot",
|
||||
name: "压炼锭材",
|
||||
currency_cost: 18,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 3,
|
||||
matcher: ForgeRequirementMatcher::AnyMaterial,
|
||||
}],
|
||||
}),
|
||||
"forge-duelist-blade" => Some(ForgeRecipeDefinition {
|
||||
id: "forge-duelist-blade",
|
||||
name: "锻造 百炼追风剑",
|
||||
currency_cost: 72,
|
||||
requirements: vec![
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 2,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
},
|
||||
ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("快剑精粹"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reforge_cost_definition(slot_id: Option<&str>) -> ReforgeCostDefinition {
|
||||
if slot_id == Some("relic") {
|
||||
return ReforgeCostDefinition {
|
||||
currency_cost: 52,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("凝光纱"),
|
||||
}],
|
||||
};
|
||||
}
|
||||
ReforgeCostDefinition {
|
||||
currency_cost: 46,
|
||||
requirements: vec![ForgeRequirementDefinition {
|
||||
quantity: 1,
|
||||
matcher: ForgeRequirementMatcher::Named("精炼锭材"),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn forge_requirement_matches(item: &Value, requirement: &ForgeRequirementDefinition) -> bool {
|
||||
match requirement.matcher {
|
||||
ForgeRequirementMatcher::Named(name) => {
|
||||
read_optional_string_field(item, "name").as_deref() == Some(name)
|
||||
}
|
||||
ForgeRequirementMatcher::AnyMaterial => {
|
||||
read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|tag| tag == "material")
|
||||
|| read_optional_string_field(item, "category")
|
||||
.is_some_and(|category| category.contains("材料"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_forge_requirements_if_possible(
|
||||
inventory: &[Value],
|
||||
requirements: &[ForgeRequirementDefinition],
|
||||
) -> Option<Vec<Value>> {
|
||||
let mut next_inventory = inventory.to_vec();
|
||||
for requirement in requirements {
|
||||
let mut remaining = requirement.quantity.max(0);
|
||||
let snapshot = next_inventory.clone();
|
||||
for item in snapshot {
|
||||
if remaining <= 0 {
|
||||
break;
|
||||
}
|
||||
if !forge_requirement_matches(&item, requirement) {
|
||||
continue;
|
||||
}
|
||||
let item_id = read_optional_string_field(&item, "id")?;
|
||||
let item_quantity = read_i32_field(&item, "quantity").unwrap_or(0).max(0);
|
||||
let consumed = remaining.min(item_quantity);
|
||||
next_inventory =
|
||||
remove_inventory_item_from_list(next_inventory, item_id.as_str(), consumed);
|
||||
remaining -= consumed;
|
||||
}
|
||||
if remaining > 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(next_inventory)
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_material_item(
|
||||
game_state: &Value,
|
||||
name: &str,
|
||||
quantity: i32,
|
||||
tags: &[&str],
|
||||
rarity: &str,
|
||||
) -> Value {
|
||||
let mut all_tags = vec!["material".to_string()];
|
||||
all_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
|
||||
json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("forge-material:{name}").as_str()),
|
||||
"category": "材料",
|
||||
"name": name,
|
||||
"quantity": quantity.max(1),
|
||||
"rarity": rarity,
|
||||
"tags": all_tags,
|
||||
"buildProfile": {
|
||||
"role": "工巧",
|
||||
"tags": tags,
|
||||
"synergy": tags,
|
||||
"forgeRank": 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_equipment_item(
|
||||
game_state: &Value,
|
||||
name: &str,
|
||||
slot_id: &str,
|
||||
rarity: &str,
|
||||
description: &str,
|
||||
role: &str,
|
||||
tags: &[&str],
|
||||
synergy: &[&str],
|
||||
stat_profile: Value,
|
||||
) -> Value {
|
||||
let slot_tag = match slot_id {
|
||||
"weapon" => "weapon",
|
||||
"armor" => "armor",
|
||||
_ => "relic",
|
||||
};
|
||||
let mut next_tags = vec![slot_tag.to_string()];
|
||||
next_tags.extend(tags.iter().map(|tag| (*tag).to_string()));
|
||||
json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("forge-equip:{name}").as_str()),
|
||||
"category": equipment_slot_label(slot_id),
|
||||
"name": name,
|
||||
"description": description,
|
||||
"quantity": 1,
|
||||
"rarity": rarity,
|
||||
"tags": next_tags,
|
||||
"equipmentSlotId": slot_id,
|
||||
"statProfile": stat_profile,
|
||||
"buildProfile": {
|
||||
"role": role,
|
||||
"tags": tags,
|
||||
"synergy": synergy,
|
||||
"forgeRank": 1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_forge_recipe_result_item(
|
||||
game_state: &Value,
|
||||
recipe_id: &str,
|
||||
_world_type: Option<&str>,
|
||||
) -> Value {
|
||||
match recipe_id {
|
||||
"synthesis-refined-ingot" => {
|
||||
build_runtime_material_item(game_state, "精炼锭材", 1, &["工巧", "守御"], "rare")
|
||||
}
|
||||
"forge-duelist-blade" => build_runtime_equipment_item(
|
||||
game_state,
|
||||
"百炼追风剑",
|
||||
"weapon",
|
||||
"epic",
|
||||
"为快剑与追身构筑准备的锻造兵刃。",
|
||||
"快剑",
|
||||
&["快剑", "突进", "追击"],
|
||||
&["快剑", "突进", "追击"],
|
||||
json!({
|
||||
"maxManaBonus": 10,
|
||||
"outgoingDamageBonus": 0.20
|
||||
}),
|
||||
),
|
||||
_ => build_runtime_material_item(game_state, "临时锻造产物", 1, &["工巧"], "common"),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tag_essence_item(game_state: &Value, tag: &str) -> Value {
|
||||
build_runtime_material_item(
|
||||
game_state,
|
||||
format!("{tag}精粹").as_str(),
|
||||
1,
|
||||
&[tag, "工巧"],
|
||||
"rare",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_dismantle_outputs(game_state: &Value, item: &Value) -> Option<Vec<Value>> {
|
||||
let slot_id = resolve_equipment_slot_for_item(item);
|
||||
if slot_id.is_none() && read_field(item, "buildProfile").is_none() {
|
||||
return None;
|
||||
}
|
||||
let rarity_scale = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 5,
|
||||
"epic" => 4,
|
||||
"rare" => 3,
|
||||
"uncommon" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let mut outputs = Vec::new();
|
||||
match slot_id {
|
||||
Some("weapon") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"武器残片",
|
||||
rarity_scale,
|
||||
&["工巧", "重击"],
|
||||
"uncommon",
|
||||
)),
|
||||
Some("armor") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"甲片",
|
||||
rarity_scale,
|
||||
&["工巧", "守御"],
|
||||
"uncommon",
|
||||
)),
|
||||
Some("relic") => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"灵饰碎片",
|
||||
rarity_scale,
|
||||
&["工巧", "法力"],
|
||||
"uncommon",
|
||||
)),
|
||||
_ => outputs.push(build_runtime_material_item(
|
||||
game_state,
|
||||
"零散材料",
|
||||
((rarity_scale + 1) / 2).max(1),
|
||||
&["工巧"],
|
||||
"uncommon",
|
||||
)),
|
||||
}
|
||||
|
||||
let mut build_tags = read_field(item, "buildProfile")
|
||||
.map(|profile| {
|
||||
let mut tags = read_array_field(profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if let Some(role) = read_optional_string_field(profile, "role") {
|
||||
tags.push(role);
|
||||
}
|
||||
tags
|
||||
})
|
||||
.unwrap_or_default();
|
||||
build_tags.sort();
|
||||
build_tags.dedup();
|
||||
let tag_limit = if item_rarity_key(item) == "legendary" {
|
||||
3
|
||||
} else {
|
||||
2
|
||||
};
|
||||
for tag in build_tags.into_iter().take(tag_limit) {
|
||||
outputs.push(build_tag_essence_item(game_state, tag.as_str()));
|
||||
}
|
||||
Some(outputs)
|
||||
}
|
||||
|
||||
pub(super) fn build_reforged_item(game_state: &Value, item: &Value) -> Option<Value> {
|
||||
let slot_id = resolve_equipment_slot_for_item(item)?;
|
||||
let build_profile = read_field(item, "buildProfile")?;
|
||||
let mut next_tags = read_array_field(build_profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let extra_tag = match slot_id {
|
||||
"weapon" => "追击",
|
||||
"armor" => "护体",
|
||||
_ => "法力",
|
||||
};
|
||||
next_tags.push(extra_tag.to_string());
|
||||
next_tags.sort();
|
||||
next_tags.dedup();
|
||||
next_tags.truncate(3);
|
||||
|
||||
let source_name = read_inventory_item_name(item);
|
||||
let next_name = if source_name.contains('·') && source_name.contains("重铸") {
|
||||
source_name.clone()
|
||||
} else {
|
||||
format!("{source_name}·重铸")
|
||||
};
|
||||
let stat_profile = read_field(item, "statProfile");
|
||||
let outgoing_damage_bonus = stat_profile
|
||||
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(0.0);
|
||||
let incoming_damage_multiplier = stat_profile
|
||||
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
|
||||
.and_then(Value::as_f64);
|
||||
let current_forge_rank = read_i32_field(build_profile, "forgeRank").unwrap_or(0);
|
||||
let mut tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
tags.sort();
|
||||
tags.dedup();
|
||||
|
||||
Some(json!({
|
||||
"id": generate_runtime_item_id(game_state, format!("reforge:{source_name}").as_str()),
|
||||
"category": read_optional_string_field(item, "category").unwrap_or_else(|| equipment_slot_label(slot_id).to_string()),
|
||||
"name": next_name,
|
||||
"description": read_optional_string_field(item, "description"),
|
||||
"quantity": 1,
|
||||
"rarity": item_rarity_key(item),
|
||||
"tags": tags,
|
||||
"equipmentSlotId": slot_id,
|
||||
"statProfile": {
|
||||
"maxHpBonus": stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
|
||||
.unwrap_or(0) + if slot_id == "armor" { 10 } else { 4 },
|
||||
"maxManaBonus": stat_profile
|
||||
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
|
||||
.unwrap_or(0) + if slot_id == "relic" { 10 } else { 4 },
|
||||
"outgoingDamageBonus": ((outgoing_damage_bonus + 0.03) * 1000.0).round() / 1000.0,
|
||||
"incomingDamageMultiplier": if let Some(multiplier) = incoming_damage_multiplier {
|
||||
(((multiplier - 0.03).max(0.72)) * 1000.0).round() / 1000.0
|
||||
} else if slot_id == "armor" {
|
||||
0.94
|
||||
} else {
|
||||
0.97
|
||||
}
|
||||
},
|
||||
"buildProfile": {
|
||||
"role": read_optional_string_field(build_profile, "role"),
|
||||
"tags": next_tags,
|
||||
"synergy": read_array_field(build_profile, "tags")
|
||||
.into_iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.chain(std::iter::once(extra_tag.to_string()))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>(),
|
||||
"forgeRank": current_forge_rank + 1
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_forge_success_text(
|
||||
action: &str,
|
||||
recipe_name: Option<&str>,
|
||||
source_item_name: Option<&str>,
|
||||
created_item_name: Option<&str>,
|
||||
output_names: &[String],
|
||||
currency_text: Option<String>,
|
||||
) -> String {
|
||||
match action {
|
||||
"craft" => format!(
|
||||
"你在工坊中完成了{},获得了{}{}。",
|
||||
recipe_name.unwrap_or("目标配方"),
|
||||
created_item_name.unwrap_or("目标物品"),
|
||||
currency_text
|
||||
.map(|text| format!(",并支付了{text}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
"reforge" => format!(
|
||||
"你消耗材料重新淬炼了{},最终得到{}{}。",
|
||||
source_item_name.unwrap_or("目标物品"),
|
||||
created_item_name.unwrap_or("重铸产物"),
|
||||
currency_text
|
||||
.map(|text| format!(",并支付了{text}"))
|
||||
.unwrap_or_default()
|
||||
),
|
||||
_ => format!(
|
||||
"你拆解了{},回收出{}。",
|
||||
source_item_name.unwrap_or("目标物品"),
|
||||
output_names.join("、")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_runtime_item_id(game_state: &Value, prefix: &str) -> String {
|
||||
let version = read_u32_field(game_state, "runtimeActionVersion").unwrap_or(0);
|
||||
let inventory_len = read_array_field(game_state, "playerInventory").len();
|
||||
format!("{prefix}:{version}:{inventory_len}")
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_forge_craft_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行锻造配方。",
|
||||
"战斗中无法使用工坊。",
|
||||
)?;
|
||||
let recipe_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "recipeId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_craft 缺少 recipeId".to_string())?;
|
||||
let recipe = forge_recipe_definition(recipe_id.as_str())
|
||||
.ok_or_else(|| "未找到目标锻造配方。".to_string())?;
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < recipe.currency_cost {
|
||||
return Err(format!("{} 当前材料或货币不足。", recipe.name));
|
||||
}
|
||||
let current_inventory = read_player_inventory_values(game_state);
|
||||
let consumed_inventory = apply_forge_requirements_if_possible(
|
||||
current_inventory.as_slice(),
|
||||
recipe.requirements.as_slice(),
|
||||
)
|
||||
.ok_or_else(|| format!("{} 当前材料或货币不足。", recipe.name))?;
|
||||
let created_item = build_forge_recipe_result_item(
|
||||
game_state,
|
||||
recipe.id,
|
||||
current_world_type(game_state).as_deref(),
|
||||
);
|
||||
let next_inventory =
|
||||
add_inventory_items_to_list(consumed_inventory, vec![created_item.clone()]);
|
||||
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_sub(recipe.currency_cost),
|
||||
);
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("制作{}", read_inventory_item_name(&created_item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"craft",
|
||||
Some(recipe.name),
|
||||
None,
|
||||
Some(read_inventory_item_name(&created_item).as_str()),
|
||||
&[],
|
||||
Some(format_currency_text(
|
||||
recipe.currency_cost,
|
||||
current_world_type(game_state).as_deref(),
|
||||
)),
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_forge_dismantle_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行拆解。",
|
||||
"战斗中无法执行拆解。",
|
||||
)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_dismantle 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "未找到可拆解的物品。".to_string())?;
|
||||
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("未找到可拆解的物品。".to_string());
|
||||
}
|
||||
let outputs = build_dismantle_outputs(game_state, &item)
|
||||
.ok_or_else(|| format!("{} 当前不支持拆解。", read_inventory_item_name(&item)))?;
|
||||
let mut next_inventory = read_player_inventory_values(game_state);
|
||||
next_inventory = remove_inventory_item_from_list(next_inventory, item_id.as_str(), 1);
|
||||
next_inventory = add_inventory_items_to_list(next_inventory, outputs.clone());
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
let output_names = outputs
|
||||
.iter()
|
||||
.map(read_inventory_item_name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("拆解{}", read_inventory_item_name(&item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"dismantle",
|
||||
None,
|
||||
Some(read_inventory_item_name(&item).as_str()),
|
||||
None,
|
||||
output_names.as_slice(),
|
||||
None,
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_forge_reforge_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
ensure_inventory_action_available(
|
||||
game_state,
|
||||
"缺少玩家角色,无法执行重铸。",
|
||||
"战斗中无法执行重铸。",
|
||||
)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "forge_reforge 缺少 itemId".to_string())?;
|
||||
let item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "未找到可重铸的物品。".to_string())?;
|
||||
if read_i32_field(&item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("未找到可重铸的物品。".to_string());
|
||||
}
|
||||
let slot_id = resolve_equipment_slot_for_item(&item);
|
||||
let reforge_cost = reforge_cost_definition(slot_id);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < reforge_cost.currency_cost {
|
||||
return Err(format!(
|
||||
"{} 当前不满足重铸条件。",
|
||||
read_inventory_item_name(&item)
|
||||
));
|
||||
}
|
||||
let reforged_item = build_reforged_item(game_state, &item)
|
||||
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
|
||||
let base_inventory = remove_inventory_item_from_list(
|
||||
read_player_inventory_values(game_state),
|
||||
item_id.as_str(),
|
||||
1,
|
||||
);
|
||||
let consumed_inventory = apply_forge_requirements_if_possible(
|
||||
base_inventory.as_slice(),
|
||||
reforge_cost.requirements.as_slice(),
|
||||
)
|
||||
.ok_or_else(|| format!("{} 当前不满足重铸条件。", read_inventory_item_name(&item)))?;
|
||||
let next_inventory =
|
||||
add_inventory_items_to_list(consumed_inventory, vec![reforged_item.clone()]);
|
||||
write_player_inventory_values(game_state, next_inventory);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_sub(reforge_cost.currency_cost),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("重铸{}", read_inventory_item_name(&item)),
|
||||
request,
|
||||
),
|
||||
result_text: build_forge_success_text(
|
||||
"reforge",
|
||||
None,
|
||||
Some(read_inventory_item_name(&item).as_str()),
|
||||
Some(read_inventory_item_name(&reforged_item).as_str()),
|
||||
&[],
|
||||
Some(format_currency_text(
|
||||
reforge_cost.currency_cost,
|
||||
current_world_type(game_state).as_deref(),
|
||||
)),
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: Some(build_current_build_toast(game_state)),
|
||||
})
|
||||
}
|
||||
1115
server-rs/crates/api-server/src/runtime_story/compat/game_state.rs
Normal file
1115
server-rs/crates/api-server/src/runtime_story/compat/game_state.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,398 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_npc_preview_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
write_bool_field(game_state, "npcInteractionActive", true);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text("转向眼前角色", request),
|
||||
result_text: format!("{npc_name} 注意到了你的靠近,正在等你先把话说出来。"),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_affinity_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
default_action_text: &str,
|
||||
affinity_delta: i32,
|
||||
fallback_result_text: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
write_bool_field(game_state, "npcInteractionActive", true);
|
||||
let affinity_patch = adjust_current_npc_affinity(game_state, affinity_delta).map(
|
||||
|(npc_id, previous_affinity, next_affinity)| RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
},
|
||||
);
|
||||
let mut patches = Vec::new();
|
||||
if let Some(patch) = affinity_patch {
|
||||
patches.push(patch);
|
||||
}
|
||||
patches.push(build_status_patch(game_state));
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(default_action_text, request),
|
||||
result_text: fallback_result_text.to_string(),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_chat_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let chatted_count = read_current_npc_state_i32_field(game_state, "chattedCount").unwrap_or(0);
|
||||
let affinity_gain = (6 - chatted_count).max(2);
|
||||
let result_text = format!(
|
||||
"{} 愿意把话接下去,态度比刚才明显松动了一些。当前关系推进了 {} 点。",
|
||||
current_encounter_name(game_state),
|
||||
affinity_gain
|
||||
);
|
||||
let mut resolution = resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
"继续交谈",
|
||||
affinity_gain,
|
||||
result_text.as_str(),
|
||||
)?;
|
||||
write_current_npc_state_i32_field(game_state, "chattedCount", chatted_count.saturating_add(1));
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
resolution.action_text = format!("继续和{}交谈", current_encounter_name(game_state));
|
||||
Ok(resolution)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_help_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||||
return Err("当前 NPC 的一次性援手已经用完了".to_string());
|
||||
}
|
||||
|
||||
restore_player_resource(game_state, 10, 8);
|
||||
write_current_npc_state_bool_field(game_state, "helpUsed", true);
|
||||
resolve_npc_affinity_action(
|
||||
game_state,
|
||||
request,
|
||||
&format!("向{}请求援手", current_encounter_name(game_state)),
|
||||
4,
|
||||
&format!(
|
||||
"{} 给了你一次及时支援,你的状态暂时稳住了,关系也顺势拉近了一点。",
|
||||
current_encounter_name(game_state)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_battle_entry_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
function_id: &str,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let battle_mode = if function_id == "npc_spar" {
|
||||
"spar"
|
||||
} else {
|
||||
"fight"
|
||||
};
|
||||
write_bool_field(game_state, "inBattle", true);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_string_field(game_state, "currentBattleNpcId", npc_id.as_str());
|
||||
write_string_field(game_state, "currentNpcBattleMode", battle_mode);
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
if battle_mode == "spar" {
|
||||
"点到为止切磋"
|
||||
} else {
|
||||
"与对方战斗"
|
||||
},
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{npc_name} 已经进入{}节奏,下一步必须按战斗动作结算。",
|
||||
battle_mode_text(battle_mode)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![build_status_patch(game_state)],
|
||||
battle: Some(RuntimeBattlePresentation {
|
||||
target_id: Some(npc_id),
|
||||
target_name: Some(npc_name),
|
||||
damage_dealt: None,
|
||||
damage_taken: None,
|
||||
outcome: Some("ongoing".to_string()),
|
||||
}),
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_recruit_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let npc_id = current_encounter_id(game_state).unwrap_or_else(|| "npc_current".to_string());
|
||||
let npc_name = current_encounter_name(game_state);
|
||||
let current_affinity = read_current_npc_affinity(game_state);
|
||||
if read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false) {
|
||||
return Err("当前 NPC 已经处于已招募状态".to_string());
|
||||
}
|
||||
if current_affinity < 60 {
|
||||
return Err("当前关系还没达到招募阈值,暂时不能邀请入队".to_string());
|
||||
}
|
||||
|
||||
let release_npc_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "releaseNpcId"));
|
||||
let released_companion_name = recruit_companion_to_party(
|
||||
game_state,
|
||||
npc_id.as_str(),
|
||||
npc_name.as_str(),
|
||||
release_npc_id.as_deref(),
|
||||
)?;
|
||||
let affinity_patch =
|
||||
set_current_npc_recruited(game_state, true).map(|(previous_affinity, next_affinity)| {
|
||||
RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id: npc_id.clone(),
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}
|
||||
});
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
clear_encounter_only(game_state);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_null_field(game_state, "currentNpcBattleOutcome");
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
|
||||
let mut patches = Vec::new();
|
||||
if let Some(patch) = affinity_patch {
|
||||
patches.push(patch);
|
||||
}
|
||||
patches.push(build_status_patch(game_state));
|
||||
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("邀请{npc_name}加入队伍"), request),
|
||||
result_text: match released_companion_name {
|
||||
Some(released_name) => format!(
|
||||
"{npc_name} 接受了你的邀请,你先让 {released_name} 暂时离队,把位置腾给了新的同行者。"
|
||||
),
|
||||
None => format!("{npc_name} 接受了你的邀请,正式进入了同行队伍。"),
|
||||
},
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches,
|
||||
battle: None,
|
||||
toast: Some(format!("{npc_name} 已加入队伍")),
|
||||
})
|
||||
}
|
||||
|
||||
/// 先按 NPC 当前遭遇态结算简化版买卖逻辑,保持与 Node compat 一致的字段写回,
|
||||
/// 后续再由真相态 inventory / runtime-item reducer 接管。
|
||||
pub(super) fn resolve_npc_trade_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (_npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let payload = request.action.payload.as_ref();
|
||||
let mode = payload
|
||||
.and_then(|value| read_optional_string_field(value, "mode"))
|
||||
.ok_or_else(|| "npc_trade 缺少合法 mode,需为 buy 或 sell".to_string())?;
|
||||
if mode != "buy" && mode != "sell" {
|
||||
return Err("npc_trade 缺少合法 mode,需为 buy 或 sell".to_string());
|
||||
}
|
||||
let item_id = payload
|
||||
.and_then(|value| {
|
||||
read_optional_string_field(value, "itemId")
|
||||
.or_else(|| read_optional_string_field(value, "selectedNpcItemId"))
|
||||
.or_else(|| read_optional_string_field(value, "selectedPlayerItemId"))
|
||||
})
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_trade 缺少 itemId".to_string())?;
|
||||
let quantity = payload
|
||||
.and_then(|value| read_i32_field(value, "quantity"))
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
if mode == "buy" {
|
||||
let npc_item = read_current_npc_inventory_item(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "目标商品不存在或库存不足。".to_string())?;
|
||||
let available_quantity = read_i32_field(&npc_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("目标商品不存在或库存不足。".to_string());
|
||||
}
|
||||
let total_price = npc_purchase_price(&npc_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
if player_currency < total_price {
|
||||
return Err("当前钱币不足,无法完成购买。".to_string());
|
||||
}
|
||||
|
||||
write_i32_field(game_state, "playerCurrency", player_currency - total_price);
|
||||
add_player_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&npc_item, quantity)],
|
||||
);
|
||||
remove_current_npc_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&npc_item);
|
||||
return Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"从{}手里买下{}{}",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{},把{}{}卖给了你。",
|
||||
npc_name,
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
),
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
});
|
||||
}
|
||||
|
||||
let player_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有足够数量的目标物品。".to_string())?;
|
||||
let available_quantity = read_i32_field(&player_item, "quantity").unwrap_or(0).max(0);
|
||||
if available_quantity < quantity {
|
||||
return Err("背包里没有足够数量的目标物品。".to_string());
|
||||
}
|
||||
let total_price = npc_buyback_price(&player_item, read_current_npc_affinity(game_state))
|
||||
.saturating_mul(quantity);
|
||||
let player_currency = read_i32_field(game_state, "playerCurrency").unwrap_or(0);
|
||||
write_i32_field(
|
||||
game_state,
|
||||
"playerCurrency",
|
||||
player_currency.saturating_add(total_price),
|
||||
);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), quantity);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&player_item, quantity)],
|
||||
);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
let item_name = read_inventory_item_name(&player_item);
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!(
|
||||
"把{}{}卖给{}",
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
npc_name
|
||||
),
|
||||
request,
|
||||
),
|
||||
result_text: format!(
|
||||
"{}收下了{}{},付给你{}。",
|
||||
npc_name,
|
||||
item_name,
|
||||
trade_quantity_suffix(quantity),
|
||||
format_currency_text(
|
||||
total_price,
|
||||
read_optional_string_field(game_state, "worldType").as_deref()
|
||||
)
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: Vec::new(),
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_npc_gift_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let (npc_id, npc_name) = current_npc_trade_context(game_state)?;
|
||||
let item_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.ok_or_else(|| "npc_gift 缺少 itemId".to_string())?;
|
||||
let gift_item = find_player_inventory_entry(game_state, item_id.as_str())
|
||||
.cloned()
|
||||
.ok_or_else(|| "背包里没有这件可赠送的物品。".to_string())?;
|
||||
if read_i32_field(&gift_item, "quantity").unwrap_or(0) <= 0 {
|
||||
return Err("背包里没有这件可赠送的物品。".to_string());
|
||||
}
|
||||
|
||||
let previous_affinity = read_current_npc_affinity(game_state);
|
||||
let affinity_gain = resolve_npc_gift_affinity_gain(&gift_item);
|
||||
let next_affinity = (previous_affinity + affinity_gain).clamp(-100, 100);
|
||||
remove_player_inventory_item(game_state, item_id.as_str(), 1);
|
||||
add_current_npc_inventory_items(
|
||||
game_state,
|
||||
vec![clone_inventory_item_with_quantity(&gift_item, 1)],
|
||||
);
|
||||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||||
let next_gifts_given =
|
||||
read_current_npc_state_i32_field(game_state, "giftsGiven").unwrap_or(0) + 1;
|
||||
write_current_npc_state_i32_field(game_state, "giftsGiven", next_gifts_given);
|
||||
mark_current_npc_first_meaningful_contact_resolved(game_state);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(
|
||||
&format!("把{}赠给{}", read_inventory_item_name(&gift_item), npc_name),
|
||||
request,
|
||||
),
|
||||
result_text: build_npc_gift_result_text(
|
||||
npc_name.as_str(),
|
||||
&gift_item,
|
||||
affinity_gain,
|
||||
next_affinity,
|
||||
),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_npc_gift_affinity_gain(item: &Value) -> i32 {
|
||||
let rarity_score = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 5,
|
||||
"epic" => 4,
|
||||
"rare" => 3,
|
||||
"uncommon" => 2,
|
||||
_ => 1,
|
||||
};
|
||||
let tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
let mana_bonus = if tags.iter().any(|tag| tag == "mana") {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let healing_bonus = if tags.iter().any(|tag| tag == "healing") {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(4 + rarity_score * 3 + mana_bonus + healing_bonus).min(24)
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_gift_result_text(
|
||||
npc_name: &str,
|
||||
item: &Value,
|
||||
affinity_gain: i32,
|
||||
next_affinity: i32,
|
||||
) -> String {
|
||||
let shift_text = if affinity_gain >= 12 {
|
||||
"态度一下子软化了许多"
|
||||
} else if affinity_gain >= 8 {
|
||||
"态度明显和缓下来"
|
||||
} else if affinity_gain >= 5 {
|
||||
"态度比先前亲近了一些"
|
||||
} else {
|
||||
"态度略微放松了些"
|
||||
};
|
||||
let affinity_text = if next_affinity >= 90 {
|
||||
"对你高度信赖,言谈间明显亲近,几乎已经把你当成自己人。"
|
||||
} else if next_affinity >= 60 {
|
||||
"对你已经建立起稳固信任,愿意进一步合作。"
|
||||
} else if next_affinity >= 30 {
|
||||
"对你的态度明显友善了许多,也更愿意正常交流。"
|
||||
} else if next_affinity >= 15 {
|
||||
"戒备开始松动,愿意试探性地配合你的节奏。"
|
||||
} else if next_affinity >= 0 {
|
||||
"仍保持明显距离,只会给出谨慎而有限的回应。"
|
||||
} else {
|
||||
"关系已经降到冰点,对你几乎不再保留善意。"
|
||||
};
|
||||
format!(
|
||||
"{}收下了{},{}。{}",
|
||||
npc_name,
|
||||
read_inventory_item_name(item),
|
||||
shift_text,
|
||||
affinity_text
|
||||
)
|
||||
}
|
||||
|
||||
fn inventory_item_value(item: &Value) -> i32 {
|
||||
if let Some(explicit_value) = read_i32_field(item, "value") {
|
||||
return explicit_value.max(8);
|
||||
}
|
||||
let rarity_base = match item_rarity_key(item).as_str() {
|
||||
"legendary" => 168,
|
||||
"epic" => 92,
|
||||
"rare" => 48,
|
||||
"uncommon" => 24,
|
||||
_ => 12,
|
||||
};
|
||||
let category = read_optional_string_field(item, "category").unwrap_or_default();
|
||||
let tags = read_array_field(item, "tags")
|
||||
.into_iter()
|
||||
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||
.collect::<Vec<_>>();
|
||||
let mut value = rarity_base;
|
||||
if tags.iter().any(|tag| tag == "weapon") {
|
||||
value += 14;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "armor") {
|
||||
value += 12;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "relic") {
|
||||
value += 16;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "mana") {
|
||||
value += 8;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "healing") {
|
||||
value += 8;
|
||||
}
|
||||
if tags.iter().any(|tag| tag == "material") {
|
||||
value += 4;
|
||||
}
|
||||
if category.contains("专属") {
|
||||
value += 10;
|
||||
}
|
||||
value.max(8)
|
||||
}
|
||||
|
||||
fn discount_tier_for_affinity(affinity: i32) -> i32 {
|
||||
if affinity >= 90 {
|
||||
3
|
||||
} else if affinity >= 60 {
|
||||
2
|
||||
} else if affinity >= 30 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn npc_purchase_price(item: &Value, affinity: i32) -> i32 {
|
||||
let discount_multiplier = 1.0 - f64::from(discount_tier_for_affinity(affinity)) * 0.08;
|
||||
(f64::from(inventory_item_value(item)) * discount_multiplier)
|
||||
.round()
|
||||
.max(6.0) as i32
|
||||
}
|
||||
|
||||
pub(super) fn npc_buyback_price(item: &Value, affinity: i32) -> i32 {
|
||||
let buyback_multiplier = 0.4 + f64::from(discount_tier_for_affinity(affinity)) * 0.06;
|
||||
(f64::from(inventory_item_value(item)) * buyback_multiplier)
|
||||
.round()
|
||||
.max(4.0) as i32
|
||||
}
|
||||
|
||||
pub(super) fn trade_quantity_suffix(quantity: i32) -> String {
|
||||
if quantity > 1 {
|
||||
format!(" x{quantity}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn format_currency_text(value: i32, world_type: Option<&str>) -> String {
|
||||
let currency_name = match world_type {
|
||||
Some("XIANXIA") => "灵石",
|
||||
Some("WUXIA") => "铜钱",
|
||||
_ => "钱币",
|
||||
};
|
||||
format!("{value} {currency_name}")
|
||||
}
|
||||
|
||||
fn add_companion_if_absent(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
character_id: Option<String>,
|
||||
joined_at_affinity: i32,
|
||||
) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let companions = root
|
||||
.entry("companions".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !companions.is_array() {
|
||||
*companions = Value::Array(Vec::new());
|
||||
}
|
||||
let items = companions
|
||||
.as_array_mut()
|
||||
.expect("companions should be array");
|
||||
if items
|
||||
.iter()
|
||||
.any(|item| read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
items.push(json!({
|
||||
"npcId": npc_id,
|
||||
"characterId": character_id,
|
||||
"joinedAtAffinity": joined_at_affinity,
|
||||
}));
|
||||
}
|
||||
|
||||
fn remove_companion_by_npc_id(game_state: &mut Value, npc_id: &str) -> Option<Value> {
|
||||
let root = ensure_json_object(game_state);
|
||||
let companions = root
|
||||
.entry("companions".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !companions.is_array() {
|
||||
*companions = Value::Array(Vec::new());
|
||||
}
|
||||
let items = companions
|
||||
.as_array_mut()
|
||||
.expect("companions should be array");
|
||||
let index = items.iter().position(|item| {
|
||||
read_optional_string_field(item, "npcId").is_some_and(|value| value == npc_id)
|
||||
})?;
|
||||
Some(items.remove(index))
|
||||
}
|
||||
|
||||
/// compat bridge 先只维护一个轻量队伍名单,继续复用旧前端的满员换队语义。
|
||||
pub(super) fn recruit_companion_to_party(
|
||||
game_state: &mut Value,
|
||||
npc_id: &str,
|
||||
_npc_name: &str,
|
||||
release_npc_id: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let companion_count = read_array_field(game_state, "companions").len();
|
||||
if companion_count < MAX_TASK5_COMPANIONS {
|
||||
add_companion_if_absent(
|
||||
game_state,
|
||||
npc_id,
|
||||
None,
|
||||
read_current_npc_affinity(game_state),
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(release_npc_id) = release_npc_id.and_then(normalize_required_string) else {
|
||||
return Err("队伍已满时必须明确指定一名离队同伴".to_string());
|
||||
};
|
||||
|
||||
let released_companion = remove_companion_by_npc_id(game_state, release_npc_id.as_str())
|
||||
.ok_or_else(|| "指定的离队同伴不存在,无法完成换队招募".to_string())?;
|
||||
let released_name = read_optional_string_field(&released_companion, "displayName")
|
||||
.or_else(|| read_optional_string_field(&released_companion, "name"))
|
||||
.or_else(|| read_optional_string_field(&released_companion, "npcName"))
|
||||
.unwrap_or_else(|| release_npc_id.clone());
|
||||
add_companion_if_absent(
|
||||
game_state,
|
||||
npc_id,
|
||||
None,
|
||||
read_current_npc_affinity(game_state),
|
||||
);
|
||||
Ok(Some(released_name))
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build_runtime_story_state_response(
|
||||
requested_session_id: &str,
|
||||
client_version: Option<u32>,
|
||||
mut snapshot: RuntimeStorySnapshotPayload,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
ensure_runtime_story_bridge_state(&mut snapshot.game_state);
|
||||
let session_id = read_runtime_session_id(&snapshot.game_state)
|
||||
.unwrap_or_else(|| requested_session_id.to_string());
|
||||
let options =
|
||||
build_runtime_story_options(snapshot.current_story.as_ref(), &snapshot.game_state);
|
||||
let story_text = read_story_text(snapshot.current_story.as_ref())
|
||||
.unwrap_or_else(|| build_fallback_story_text(&snapshot.game_state));
|
||||
let server_version = read_u32_field(&snapshot.game_state, "runtimeActionVersion")
|
||||
.or(client_version)
|
||||
.unwrap_or(0);
|
||||
|
||||
build_runtime_story_action_response(RuntimeStoryActionResponseParts {
|
||||
requested_session_id: session_id,
|
||||
server_version,
|
||||
snapshot,
|
||||
action_text: String::new(),
|
||||
result_text: String::new(),
|
||||
story_text,
|
||||
options,
|
||||
patches: Vec::new(),
|
||||
toast: None,
|
||||
battle: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_action_response(
|
||||
parts: RuntimeStoryActionResponseParts,
|
||||
) -> RuntimeStoryActionResponse {
|
||||
let session_id = read_runtime_session_id(&parts.snapshot.game_state)
|
||||
.unwrap_or_else(|| parts.requested_session_id);
|
||||
|
||||
RuntimeStoryActionResponse {
|
||||
session_id,
|
||||
server_version: parts.server_version,
|
||||
view_model: build_runtime_story_view_model(&parts.snapshot.game_state, &parts.options),
|
||||
presentation: RuntimeStoryPresentation {
|
||||
action_text: parts.action_text,
|
||||
result_text: parts.result_text,
|
||||
story_text: parts.story_text,
|
||||
options: parts.options,
|
||||
toast: parts.toast,
|
||||
battle: parts.battle,
|
||||
},
|
||||
patches: parts.patches,
|
||||
snapshot: parts.snapshot,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_view_model(
|
||||
game_state: &Value,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> RuntimeStoryViewModel {
|
||||
RuntimeStoryViewModel {
|
||||
player: RuntimeStoryPlayerViewModel {
|
||||
hp: read_i32_field(game_state, "playerHp").unwrap_or(0),
|
||||
max_hp: read_i32_field(game_state, "playerMaxHp").unwrap_or(1),
|
||||
mana: read_i32_field(game_state, "playerMana").unwrap_or(0),
|
||||
max_mana: read_i32_field(game_state, "playerMaxMana").unwrap_or(1),
|
||||
},
|
||||
encounter: build_runtime_story_encounter(game_state),
|
||||
companions: build_runtime_story_companions(game_state),
|
||||
available_options: options.to_vec(),
|
||||
status: RuntimeStoryStatusViewModel {
|
||||
in_battle: read_bool_field(game_state, "inBattle").unwrap_or(false),
|
||||
npc_interaction_active: read_bool_field(game_state, "npcInteractionActive")
|
||||
.unwrap_or(false),
|
||||
current_npc_battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
current_npc_battle_outcome: read_optional_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_dialogue_current_story(
|
||||
npc_name: &str,
|
||||
text: &str,
|
||||
deferred_options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
let continue_option = build_continue_adventure_runtime_story_option();
|
||||
// 对齐 Node 旧 currentStory:先展示单轮对话,只把真实下一步选项压到 deferredOptions。
|
||||
json!({
|
||||
"text": text,
|
||||
"options": vec![build_story_option_from_runtime_option(&continue_option)],
|
||||
"displayMode": "dialogue",
|
||||
"dialogue": parse_dialogue_turns(text, npc_name),
|
||||
"streaming": false,
|
||||
"deferredOptions": deferred_options
|
||||
.iter()
|
||||
.map(build_story_option_from_runtime_option)
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_continue_adventure_runtime_story_option() -> RuntimeStoryOptionView {
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story")
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_turns(text: &str, npc_name: &str) -> Vec<Value> {
|
||||
let mut turns = Vec::new();
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(turn) = parse_dialogue_line(line, npc_name) {
|
||||
turns.push(turn);
|
||||
}
|
||||
}
|
||||
|
||||
if turns.is_empty() && !text.trim().is_empty() {
|
||||
turns.push(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": text.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
turns
|
||||
}
|
||||
|
||||
pub(super) fn parse_dialogue_line(line: &str, npc_name: &str) -> Option<Value> {
|
||||
let delimiter_index = line.find(':').or_else(|| line.find(':'))?;
|
||||
let speaker_name = line[..delimiter_index].trim();
|
||||
let content_start = delimiter_index + line[delimiter_index..].chars().next()?.len_utf8();
|
||||
let content = line[content_start..].trim();
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if speaker_name == "你" {
|
||||
return Some(json!({
|
||||
"speaker": "player",
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
if speaker_name == npc_name {
|
||||
return Some(json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": npc_name,
|
||||
"text": content,
|
||||
}));
|
||||
}
|
||||
|
||||
Some(json!({
|
||||
"speaker": "companion",
|
||||
"speakerName": speaker_name,
|
||||
"text": content,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_companions(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryCompanionViewModel> {
|
||||
read_array_field(game_state, "companions")
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let npc_id = read_required_string_field(entry, "npcId")?;
|
||||
Some(RuntimeStoryCompanionViewModel {
|
||||
npc_id,
|
||||
character_id: read_optional_string_field(entry, "characterId"),
|
||||
joined_at_affinity: read_i32_field(entry, "joinedAtAffinity").unwrap_or(0),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_encounter(
|
||||
game_state: &Value,
|
||||
) -> Option<RuntimeStoryEncounterViewModel> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")?;
|
||||
let npc_name = read_required_string_field(encounter, "npcName")
|
||||
.or_else(|| read_required_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "当前遭遇".to_string());
|
||||
let encounter_id =
|
||||
read_required_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
let npc_state = resolve_current_encounter_npc_state(game_state, &encounter_id, &npc_name);
|
||||
|
||||
Some(RuntimeStoryEncounterViewModel {
|
||||
id: encounter_id,
|
||||
kind: read_required_string_field(encounter, "kind").unwrap_or_else(|| "npc".to_string()),
|
||||
npc_name,
|
||||
hostile: read_bool_field(encounter, "hostile").unwrap_or(false),
|
||||
affinity: npc_state.and_then(|state| read_i32_field(state, "affinity")),
|
||||
recruited: npc_state.and_then(|state| read_bool_field(state, "recruited")),
|
||||
interaction_active: read_bool_field(game_state, "npcInteractionActive").unwrap_or(false),
|
||||
battle_mode: read_optional_string_field(game_state, "currentNpcBattleMode"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_current_encounter_npc_state<'a>(
|
||||
game_state: &'a Value,
|
||||
encounter_id: &str,
|
||||
npc_name: &str,
|
||||
) -> Option<&'a Value> {
|
||||
let npc_states = read_object_field(game_state, "npcStates")?;
|
||||
|
||||
npc_states
|
||||
.get(encounter_id)
|
||||
.or_else(|| npc_states.get(npc_name))
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_options(
|
||||
current_story: Option<&Value>,
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if let Some(story) = current_story {
|
||||
let prefers_deferred = read_required_string_field(story, "displayMode")
|
||||
.is_some_and(|value| value == "dialogue")
|
||||
&& !read_array_field(story, "deferredOptions").is_empty();
|
||||
|
||||
let source = if prefers_deferred {
|
||||
read_array_field(story, "deferredOptions")
|
||||
} else {
|
||||
read_array_field(story, "options")
|
||||
};
|
||||
|
||||
let compiled = source
|
||||
.into_iter()
|
||||
.filter_map(build_runtime_story_option_from_story_option)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if !compiled.is_empty() {
|
||||
return compiled;
|
||||
}
|
||||
}
|
||||
|
||||
build_fallback_runtime_story_options(game_state)
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_option_from_story_option(
|
||||
value: &Value,
|
||||
) -> Option<RuntimeStoryOptionView> {
|
||||
let function_id = read_required_string_field(value, "functionId")?;
|
||||
let action_text = read_required_string_field(value, "actionText")
|
||||
.or_else(|| read_required_string_field(value, "text"))
|
||||
.unwrap_or_else(|| function_id.clone());
|
||||
|
||||
Some(RuntimeStoryOptionView {
|
||||
scope: infer_option_scope(function_id.as_str()).to_string(),
|
||||
detail_text: read_optional_string_field(value, "detailText"),
|
||||
interaction: build_runtime_story_option_interaction(read_field(value, "interaction")),
|
||||
payload: read_field(value, "runtimePayload")
|
||||
.or_else(|| read_field(value, "payload"))
|
||||
.cloned(),
|
||||
disabled: read_bool_field(value, "disabled"),
|
||||
reason: read_optional_string_field(value, "disabledReason")
|
||||
.or_else(|| read_optional_string_field(value, "reason")),
|
||||
function_id,
|
||||
action_text,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_option_interaction(
|
||||
value: Option<&Value>,
|
||||
) -> Option<RuntimeStoryOptionInteraction> {
|
||||
let interaction = value?;
|
||||
match read_required_string_field(interaction, "kind")?.as_str() {
|
||||
"npc" => Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: read_required_string_field(interaction, "npcId")?,
|
||||
action: read_required_string_field(interaction, "action")?,
|
||||
quest_id: read_optional_string_field(interaction, "questId"),
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_runtime_story_options(
|
||||
game_state: &Value,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
return build_battle_runtime_story_options(game_state);
|
||||
}
|
||||
|
||||
let encounter = read_object_field(game_state, "currentEncounter");
|
||||
if let Some(encounter) = encounter {
|
||||
if matches!(
|
||||
read_required_string_field(encounter, "kind").as_deref(),
|
||||
Some("npc")
|
||||
) {
|
||||
let interaction_active =
|
||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
||||
let npc_id = read_required_string_field(encounter, "id")
|
||||
.unwrap_or_else(|| "npc_current".to_string());
|
||||
if let Some(active_quest) = find_active_quest_for_issuer(game_state, npc_id.as_str()) {
|
||||
if read_optional_string_field(active_quest, "status")
|
||||
.is_some_and(|status| status == "completed")
|
||||
{
|
||||
return vec![
|
||||
build_npc_runtime_story_option_with_quest(
|
||||
"npc_quest_turn_in",
|
||||
&format!("向{}交付委托", current_encounter_name(game_state)),
|
||||
&npc_id,
|
||||
"quest_turn_in",
|
||||
read_optional_string_field(active_quest, "id"),
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
&npc_id,
|
||||
"leave",
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if interaction_active {
|
||||
return build_active_npc_runtime_story_options(game_state, npc_id.as_str());
|
||||
}
|
||||
|
||||
return vec![
|
||||
build_npc_runtime_story_option("npc_preview_talk", "转向眼前角色", &npc_id, "chat"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", &npc_id, "fight"),
|
||||
build_npc_runtime_story_option("npc_leave", "离开当前角色", &npc_id, "leave"),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
vec![
|
||||
build_static_runtime_story_option("idle_observe_signs", "观察周围迹象", "story"),
|
||||
build_static_runtime_story_option("idle_call_out", "主动出声试探", "story"),
|
||||
build_static_runtime_story_option("idle_rest_focus", "原地调息", "story"),
|
||||
build_static_runtime_story_option("idle_explore_forward", "继续向前探索", "story"),
|
||||
build_static_runtime_story_option("idle_travel_next_scene", "前往相邻场景", "story"),
|
||||
build_static_runtime_story_option(CONTINUE_ADVENTURE_FUNCTION_ID, "继续推进冒险", "story"),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_static_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
function_id: function_id.to_string(),
|
||||
action_text: action_text.to_string(),
|
||||
detail_text: None,
|
||||
scope: scope.to_string(),
|
||||
interaction: None,
|
||||
payload: None,
|
||||
disabled: None,
|
||||
reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_runtime_story_option_with_payload(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
payload: Value,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload: Some(payload),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_disabled_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
scope: &str,
|
||||
detail_text: Option<String>,
|
||||
reason: &str,
|
||||
payload: Option<Value>,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
detail_text,
|
||||
payload,
|
||||
disabled: Some(true),
|
||||
reason: Some(reason.to_string()),
|
||||
..build_static_runtime_story_option(function_id, action_text, scope)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: npc_id.to_string(),
|
||||
action: action.to_string(),
|
||||
quest_id: None,
|
||||
}),
|
||||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option_with_payload(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
payload: Value,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
payload: Some(payload),
|
||||
..build_npc_runtime_story_option(function_id, action_text, npc_id, action)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_runtime_story_option_with_quest(
|
||||
function_id: &str,
|
||||
action_text: &str,
|
||||
npc_id: &str,
|
||||
action: &str,
|
||||
quest_id: Option<String>,
|
||||
) -> RuntimeStoryOptionView {
|
||||
RuntimeStoryOptionView {
|
||||
interaction: Some(RuntimeStoryOptionInteraction::Npc {
|
||||
npc_id: npc_id.to_string(),
|
||||
action: action.to_string(),
|
||||
quest_id,
|
||||
}),
|
||||
..build_static_runtime_story_option(function_id, action_text, "npc")
|
||||
}
|
||||
}
|
||||
|
||||
/// 对齐 Node 旧 compat 入口顺序,在 NPC 交互态下统一补齐交易、赠礼、委托与招募入口。
|
||||
pub(super) fn build_active_npc_runtime_story_options(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> Vec<RuntimeStoryOptionView> {
|
||||
let mut options = vec![
|
||||
build_npc_runtime_story_option("npc_chat", "继续交谈", npc_id, "chat"),
|
||||
build_npc_help_runtime_story_option(game_state, npc_id),
|
||||
build_npc_runtime_story_option("npc_spar", "点到为止切磋", npc_id, "spar"),
|
||||
build_npc_runtime_story_option("npc_fight", "与对方战斗", npc_id, "fight"),
|
||||
];
|
||||
|
||||
if current_npc_inventory_items(game_state)
|
||||
.iter()
|
||||
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_trade",
|
||||
"交易",
|
||||
npc_id,
|
||||
"trade",
|
||||
));
|
||||
}
|
||||
|
||||
if has_giftable_player_inventory(game_state) {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_gift",
|
||||
"赠送礼物",
|
||||
npc_id,
|
||||
"gift",
|
||||
));
|
||||
}
|
||||
|
||||
let active_quest = find_active_quest_for_issuer(game_state, npc_id);
|
||||
if let Some(active_quest) = active_quest {
|
||||
let can_turn_in = read_optional_string_field(active_quest, "status")
|
||||
.is_some_and(|status| status == "completed" || status == "ready_to_turn_in");
|
||||
if can_turn_in {
|
||||
options.push(build_npc_runtime_story_option_with_quest(
|
||||
"npc_quest_turn_in",
|
||||
&format!("向{}交付委托", current_encounter_name(game_state)),
|
||||
npc_id,
|
||||
"quest_turn_in",
|
||||
read_optional_string_field(active_quest, "id"),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_quest_accept",
|
||||
"接下委托",
|
||||
npc_id,
|
||||
"quest_accept",
|
||||
));
|
||||
}
|
||||
|
||||
if read_current_npc_affinity(game_state) >= 60
|
||||
&& !read_current_npc_state_bool_field(game_state, "recruited").unwrap_or(false)
|
||||
{
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_recruit",
|
||||
"邀请同行",
|
||||
npc_id,
|
||||
"recruit",
|
||||
));
|
||||
}
|
||||
|
||||
options.push(build_npc_runtime_story_option(
|
||||
"npc_leave",
|
||||
"离开当前角色",
|
||||
npc_id,
|
||||
"leave",
|
||||
));
|
||||
options
|
||||
}
|
||||
|
||||
pub(super) fn build_npc_help_runtime_story_option(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
) -> RuntimeStoryOptionView {
|
||||
if read_current_npc_state_bool_field(game_state, "helpUsed").unwrap_or(false) {
|
||||
return build_disabled_runtime_story_option(
|
||||
"npc_help",
|
||||
"请求援手",
|
||||
"npc",
|
||||
None,
|
||||
"当前 NPC 的一次性援手已经用完了。",
|
||||
None,
|
||||
);
|
||||
}
|
||||
build_npc_runtime_story_option("npc_help", "请求援手", npc_id, "help")
|
||||
}
|
||||
|
||||
pub(super) fn current_encounter_npc_quest_context(
|
||||
game_state: &Value,
|
||||
) -> Result<CurrentEncounterNpcQuestContext, String> {
|
||||
let encounter = read_object_field(game_state, "currentEncounter")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||||
let kind = read_required_string_field(encounter, "kind")
|
||||
.ok_or_else(|| "当前不在可结算的 NPC 委托态。".to_string())?;
|
||||
if kind != "npc" {
|
||||
return Err("当前不在可结算的 NPC 委托态。".to_string());
|
||||
}
|
||||
|
||||
let npc_name = read_optional_string_field(encounter, "npcName")
|
||||
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||
.unwrap_or_else(|| "当前角色".to_string());
|
||||
let npc_id = read_optional_string_field(encounter, "id").unwrap_or_else(|| npc_name.clone());
|
||||
|
||||
if resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str()).is_none()
|
||||
{
|
||||
return Err("当前 NPC 状态不存在,无法处理委托。".to_string());
|
||||
}
|
||||
|
||||
Ok(CurrentEncounterNpcQuestContext { npc_id, npc_name })
|
||||
}
|
||||
|
||||
pub(super) fn read_pending_quest_offer_context(
|
||||
current_story: Option<&Value>,
|
||||
npc_key: &str,
|
||||
) -> Option<PendingQuestOfferContext> {
|
||||
let current_story = current_story?;
|
||||
let npc_chat_state = read_object_field(current_story, "npcChatState")?;
|
||||
let pending_offer = read_object_field(npc_chat_state, "pendingQuestOffer")?;
|
||||
let quest = read_object_field(pending_offer, "quest")?.clone();
|
||||
let quest_id = read_optional_string_field(&quest, "id")?;
|
||||
let pending_npc_id = read_optional_string_field(npc_chat_state, "npcId");
|
||||
let issuer_npc_id = read_optional_string_field(&quest, "issuerNpcId");
|
||||
if pending_npc_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != npc_key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if issuer_npc_id
|
||||
.as_deref()
|
||||
.is_some_and(|value| value != npc_key)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(PendingQuestOfferContext {
|
||||
dialogue: read_array_field(current_story, "dialogue")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect(),
|
||||
turn_count: read_i32_field(npc_chat_state, "turnCount").unwrap_or(0),
|
||||
custom_input_placeholder: read_optional_string_field(
|
||||
npc_chat_state,
|
||||
"customInputPlaceholder",
|
||||
)
|
||||
.unwrap_or_else(|| "输入你想对 TA 说的话".to_string()),
|
||||
quest,
|
||||
quest_id,
|
||||
intro_text: read_optional_string_field(pending_offer, "introText"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_offer_dialogue_text(npc_name: &str, quest: &Value) -> String {
|
||||
let summary_text = read_optional_string_field(quest, "summary")
|
||||
.or_else(|| read_optional_string_field(quest, "description"))
|
||||
.unwrap_or_default();
|
||||
if summary_text.is_empty() {
|
||||
return format!(
|
||||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把眼前这件事正式交给你。"
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"{npc_name}沉吟了片刻,像是终于把真正想托付的事说了出来。如果你愿意,我想把这件事正式交给你:{summary_text}"
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn append_dialogue_turns(existing: &[Value], additions: Vec<Value>) -> Vec<Value> {
|
||||
let mut dialogue = existing.to_vec();
|
||||
dialogue.extend(additions);
|
||||
dialogue
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_quest_offer_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_view",
|
||||
"查看任务",
|
||||
npc_id,
|
||||
"quest_offer_view",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "view"
|
||||
}),
|
||||
),
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_replace",
|
||||
"更换任务",
|
||||
npc_id,
|
||||
"quest_offer_replace",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "replace"
|
||||
}),
|
||||
),
|
||||
build_npc_runtime_story_option_with_payload(
|
||||
"npc_chat_quest_offer_abandon",
|
||||
"放弃任务",
|
||||
npc_id,
|
||||
"quest_offer_abandon",
|
||||
json!({
|
||||
"npcChatQuestOfferAction": "abandon"
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_post_quest_offer_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"那先继续聊聊你刚才没说完的部分",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"除了委托,你对眼前局势还有什么判断",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"先把这附近真正危险的地方说清楚",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_post_quest_accept_chat_options(npc_id: &str) -> Vec<RuntimeStoryOptionView> {
|
||||
vec![
|
||||
build_npc_runtime_story_option("npc_chat", "这件事里你最担心哪一步", npc_id, "chat"),
|
||||
build_npc_runtime_story_option("npc_chat", "我回来时你最想先知道什么", npc_id, "chat"),
|
||||
build_npc_runtime_story_option(
|
||||
"npc_chat",
|
||||
"除了这份委托,你还想提醒我什么",
|
||||
npc_id,
|
||||
"chat",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn build_pending_quest_offer_story(
|
||||
dialogue: Vec<Value>,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
turn_count: i32,
|
||||
custom_input_placeholder: &str,
|
||||
pending_quest: Option<Value>,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
json!({
|
||||
"text": dialogue
|
||||
.iter()
|
||||
.filter_map(|entry| read_optional_string_field(entry, "text"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"displayMode": "dialogue",
|
||||
"dialogue": dialogue,
|
||||
"streaming": false,
|
||||
"npcChatState": {
|
||||
"npcId": npc_id,
|
||||
"npcName": npc_name,
|
||||
"turnCount": turn_count,
|
||||
"customInputPlaceholder": custom_input_placeholder,
|
||||
"pendingQuestOffer": pending_quest.map(|quest| json!({ "quest": quest })),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_next_pending_quest_offer(
|
||||
game_state: &Value,
|
||||
npc_id: &str,
|
||||
npc_name: &str,
|
||||
previous_quest_id: Option<&str>,
|
||||
) -> Value {
|
||||
let next_id = if previous_quest_id.is_some_and(|id| id == "quest-bridge-offer") {
|
||||
"quest-bridge-replaced"
|
||||
} else {
|
||||
"quest-generated-replaced"
|
||||
};
|
||||
let title = if next_id == "quest-bridge-replaced" {
|
||||
"断桥夜巡"
|
||||
} else {
|
||||
"新的临时委托"
|
||||
};
|
||||
let scene_id = read_object_field(game_state, "currentScenePreset")
|
||||
.and_then(|scene| read_optional_string_field(scene, "id"));
|
||||
json!({
|
||||
"id": next_id,
|
||||
"issuerNpcId": npc_id,
|
||||
"issuerNpcName": npc_name,
|
||||
"sceneId": scene_id,
|
||||
"title": title,
|
||||
"description": format!("{title}的详细说明。"),
|
||||
"summary": format!("{title}的简要目标。"),
|
||||
"objective": {
|
||||
"kind": "talk_to_npc",
|
||||
"requiredCount": 1
|
||||
},
|
||||
"progress": 0,
|
||||
"status": "active",
|
||||
"reward": {
|
||||
"affinityBonus": 6,
|
||||
"currency": 30,
|
||||
"items": []
|
||||
},
|
||||
"rewardText": "完成后可以领取报酬。",
|
||||
"steps": [{
|
||||
"id": format!("{next_id}-step-1"),
|
||||
"title": "查清线索",
|
||||
"kind": "talk_to_npc",
|
||||
"requiredCount": 1,
|
||||
"progress": 0,
|
||||
"revealText": "先去断桥口附近把相关线索问清楚。",
|
||||
"completeText": "关键线索已经问清。"
|
||||
}],
|
||||
"activeStepId": format!("{next_id}-step-1")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_active_quest_for_issuer<'a>(
|
||||
game_state: &'a Value,
|
||||
issuer_npc_id: &str,
|
||||
) -> Option<&'a Value> {
|
||||
read_array_field(game_state, "quests")
|
||||
.into_iter()
|
||||
.find(|quest| {
|
||||
read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||||
&& read_optional_string_field(quest, "status")
|
||||
.is_some_and(|status| status != "turned_in")
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn push_quest_record(game_state: &mut Value, quest: &Value) {
|
||||
let root = ensure_json_object(game_state);
|
||||
let quests = root
|
||||
.entry("quests".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !quests.is_array() {
|
||||
*quests = Value::Array(Vec::new());
|
||||
}
|
||||
quests
|
||||
.as_array_mut()
|
||||
.expect("quests should be array")
|
||||
.push(quest.clone());
|
||||
}
|
||||
|
||||
pub(super) fn first_quest_reveal_text(quest: &Value) -> Option<String> {
|
||||
read_array_field(quest, "steps")
|
||||
.first()
|
||||
.and_then(|step| read_optional_string_field(step, "revealText"))
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_accept_result_text(quest: &Value) -> String {
|
||||
let issuer_name =
|
||||
read_optional_string_field(quest, "issuerNpcName").unwrap_or_else(|| "对方".to_string());
|
||||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||||
format!("你正式接下了 {issuer_name} 的委托「{title}」,接下来可以开始推进任务目标。")
|
||||
}
|
||||
|
||||
pub(super) fn turn_in_quest_record(
|
||||
game_state: &mut Value,
|
||||
issuer_npc_id: &str,
|
||||
quest_id: &str,
|
||||
) -> Result<Value, String> {
|
||||
let root = ensure_json_object(game_state);
|
||||
let quests = root
|
||||
.entry("quests".to_string())
|
||||
.or_insert_with(|| Value::Array(Vec::new()));
|
||||
if !quests.is_array() {
|
||||
*quests = Value::Array(Vec::new());
|
||||
}
|
||||
let quests = quests.as_array_mut().expect("quests should be array");
|
||||
let Some(index) = quests.iter().position(|quest| {
|
||||
read_optional_string_field(quest, "id").as_deref() == Some(quest_id)
|
||||
&& read_optional_string_field(quest, "issuerNpcId").as_deref() == Some(issuer_npc_id)
|
||||
}) else {
|
||||
return Err("当前没有可交付的委托。".to_string());
|
||||
};
|
||||
|
||||
let mut turned_in = quests[index].clone();
|
||||
if read_optional_string_field(&turned_in, "status").as_deref() != Some("completed") {
|
||||
return Err("这份委托还没有达到可交付状态。".to_string());
|
||||
}
|
||||
if let Some(object) = turned_in.as_object_mut() {
|
||||
object.insert("status".to_string(), Value::String("turned_in".to_string()));
|
||||
object.insert("completionNotified".to_string(), Value::Bool(true));
|
||||
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
|
||||
for step in steps.iter_mut() {
|
||||
let required_count = read_i32_field(step, "requiredCount").unwrap_or(0);
|
||||
if let Some(step_object) = step.as_object_mut() {
|
||||
step_object.insert("progress".to_string(), json!(required_count.max(0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quests[index] = turned_in.clone();
|
||||
Ok(turned_in)
|
||||
}
|
||||
|
||||
pub(super) fn build_quest_turn_in_result_text(quest: &Value) -> String {
|
||||
let title = read_optional_string_field(quest, "title").unwrap_or_else(|| "委托".to_string());
|
||||
let reward_text = read_optional_string_field(quest, "rewardText")
|
||||
.unwrap_or_else(|| "报酬已经结清。".to_string());
|
||||
format!("你已经完成并交付了「{title}」。{reward_text}")
|
||||
}
|
||||
|
||||
pub(super) fn apply_quest_turn_in_rewards(game_state: &mut Value, quest: &Value) {
|
||||
let Some(reward) = read_field(quest, "reward") else {
|
||||
return;
|
||||
};
|
||||
|
||||
let currency = read_i32_field(reward, "currency").unwrap_or(0).max(0);
|
||||
if currency > 0 {
|
||||
add_player_currency(game_state, currency);
|
||||
}
|
||||
|
||||
let reward_items = read_array_field(reward, "items")
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !reward_items.is_empty() {
|
||||
add_player_inventory_items(game_state, reward_items);
|
||||
}
|
||||
|
||||
let experience = read_i32_field(reward, "experience").unwrap_or(0).max(0);
|
||||
if experience > 0 {
|
||||
grant_player_progression_experience(game_state, experience, "quest");
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn infer_option_scope(function_id: &str) -> &'static str {
|
||||
if function_id.starts_with("battle_") || function_id == "inventory_use" {
|
||||
"combat"
|
||||
} else if function_id.starts_with("npc_") {
|
||||
"npc"
|
||||
} else {
|
||||
"story"
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build_legacy_current_story(
|
||||
story_text: &str,
|
||||
options: &[RuntimeStoryOptionView],
|
||||
) -> Value {
|
||||
json!({
|
||||
"text": story_text,
|
||||
"options": options.iter().map(build_story_option_from_runtime_option).collect::<Vec<_>>(),
|
||||
"streaming": false
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
|
||||
json!({
|
||||
"functionId": option.function_id,
|
||||
"actionText": option.action_text,
|
||||
"text": option.action_text,
|
||||
"detailText": option.detail_text,
|
||||
"visuals": {
|
||||
"playerAnimation": "idle",
|
||||
"playerMoveMeters": 0,
|
||||
"playerOffsetY": 0,
|
||||
"playerFacing": "right",
|
||||
"scrollWorld": false,
|
||||
"monsterChanges": []
|
||||
},
|
||||
"interaction": option.interaction,
|
||||
"runtimePayload": option.payload,
|
||||
"disabled": option.disabled,
|
||||
"disabledReason": option.reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
||||
}
|
||||
|
||||
pub(super) fn build_fallback_story_text(game_state: &Value) -> String {
|
||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||
let encounter_name = read_object_field(game_state, "currentEncounter")
|
||||
.and_then(|encounter| read_optional_string_field(encounter, "npcName"))
|
||||
.unwrap_or_else(|| "眼前的敌人".to_string());
|
||||
return format!("战斗还没有结束,{encounter_name} 仍在逼你立刻做出下一步判断。");
|
||||
}
|
||||
|
||||
if let Some(encounter) = read_object_field(game_state, "currentEncounter")
|
||||
&& let Some(npc_name) = read_optional_string_field(encounter, "npcName")
|
||||
{
|
||||
return format!("{npc_name} 正在等你表态,当前局势已经可以继续推进。");
|
||||
}
|
||||
|
||||
"当前故事状态已经同步到兼容状态桥,可以继续推进这一轮运行时动作。".to_string()
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_view_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可查看。".to_string())?;
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("查看{}提出的委托", encounter.npc_name), request),
|
||||
result_text: pending_offer.intro_text.clone().unwrap_or_else(|| {
|
||||
build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &pending_offer.quest)
|
||||
}),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_replace_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可更换。".to_string())?;
|
||||
let next_quest = build_next_pending_quest_offer(
|
||||
game_state,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
Some(pending_offer.quest_id.as_str()),
|
||||
);
|
||||
let quest_text = build_quest_offer_dialogue_text(encounter.npc_name.as_str(), &next_quest);
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "能不能换一份更适合眼下局势的委托?"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": quest_text,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_pending_quest_offer_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
Some(next_quest.clone()),
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("请{}更换委托", encounter.npc_name), request),
|
||||
result_text: quest_text.clone(),
|
||||
story_text: Some(quest_text),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_offer_abandon_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可放弃。".to_string())?;
|
||||
let npc_reply = format!(
|
||||
"{}点了点头,没有继续强求,只把这份委托暂时收了回去。",
|
||||
encounter.npc_name
|
||||
);
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "这件事我先不接,咱们还是先聊别的。"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": npc_reply,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_post_quest_offer_chat_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
None,
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("暂不接受{}的委托", encounter.npc_name), request),
|
||||
result_text: npc_reply.clone(),
|
||||
story_text: Some(npc_reply),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_accept_action(
|
||||
game_state: &mut Value,
|
||||
current_story: Option<&Value>,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let pending_offer = read_pending_quest_offer_context(current_story, encounter.npc_id.as_str())
|
||||
.ok_or_else(|| "当前没有待处理的委托可接下。".to_string())?;
|
||||
if find_active_quest_for_issuer(game_state, encounter.npc_id.as_str()).is_some() {
|
||||
return Err("当前角色已经有未结清的委托。".to_string());
|
||||
}
|
||||
|
||||
let quest = pending_offer.quest.clone();
|
||||
push_quest_record(game_state, &quest);
|
||||
increment_runtime_stat(game_state, "questsAccepted", 1);
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
|
||||
let reply_text = first_quest_reveal_text(&quest)
|
||||
.map(|text| format!("那就拜托你了。{text}"))
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"那就拜托你了。{}",
|
||||
read_optional_string_field(&quest, "summary")
|
||||
.unwrap_or_else(|| "这份委托的关键要点我已经交给你。".to_string())
|
||||
)
|
||||
});
|
||||
let dialogue = append_dialogue_turns(
|
||||
pending_offer.dialogue.as_slice(),
|
||||
vec![
|
||||
json!({
|
||||
"speaker": "player",
|
||||
"text": "这件事我愿意接下,你把关键要点交给我。"
|
||||
}),
|
||||
json!({
|
||||
"speaker": "npc",
|
||||
"speakerName": encounter.npc_name,
|
||||
"text": reply_text,
|
||||
}),
|
||||
],
|
||||
);
|
||||
let options = build_post_quest_accept_chat_options(encounter.npc_id.as_str());
|
||||
let saved_current_story = build_pending_quest_offer_story(
|
||||
dialogue,
|
||||
encounter.npc_id.as_str(),
|
||||
encounter.npc_name.as_str(),
|
||||
pending_offer.turn_count,
|
||||
pending_offer.custom_input_placeholder.as_str(),
|
||||
None,
|
||||
options.as_slice(),
|
||||
);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("接下{}的委托", encounter.npc_name), request),
|
||||
result_text: build_quest_accept_result_text(&quest),
|
||||
story_text: Some(
|
||||
saved_current_story["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
),
|
||||
presentation_options: Some(options),
|
||||
saved_current_story: Some(saved_current_story),
|
||||
patches: vec![],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn resolve_pending_quest_turn_in_action(
|
||||
game_state: &mut Value,
|
||||
request: &RuntimeStoryActionRequest,
|
||||
) -> Result<StoryResolution, String> {
|
||||
let encounter = current_encounter_npc_quest_context(game_state)?;
|
||||
let quest_id = request
|
||||
.action
|
||||
.payload
|
||||
.as_ref()
|
||||
.and_then(|payload| read_optional_string_field(payload, "questId"))
|
||||
.or_else(|| request.action.target_id.clone())
|
||||
.or_else(|| {
|
||||
find_active_quest_for_issuer(game_state, encounter.npc_id.as_str())
|
||||
.and_then(|quest| read_optional_string_field(quest, "id"))
|
||||
})
|
||||
.ok_or_else(|| "当前没有可交付的委托。".to_string())?;
|
||||
let turned_in = turn_in_quest_record(game_state, encounter.npc_id.as_str(), quest_id.as_str())?;
|
||||
let previous_affinity = read_current_npc_affinity(game_state);
|
||||
let affinity_bonus = read_field(&turned_in, "reward")
|
||||
.and_then(|reward| read_i32_field(reward, "affinityBonus"))
|
||||
.unwrap_or(0);
|
||||
let next_affinity = previous_affinity.saturating_add(affinity_bonus);
|
||||
write_current_npc_state_i32_field(game_state, "affinity", next_affinity);
|
||||
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||
apply_quest_turn_in_rewards(game_state, &turned_in);
|
||||
|
||||
Ok(StoryResolution {
|
||||
action_text: resolve_action_text(&format!("向{}交付委托", encounter.npc_name), request),
|
||||
result_text: build_quest_turn_in_result_text(&turned_in),
|
||||
story_text: None,
|
||||
presentation_options: None,
|
||||
saved_current_story: None,
|
||||
patches: vec![RuntimeStoryPatch::NpcAffinityChanged {
|
||||
npc_id: encounter.npc_id,
|
||||
previous_affinity,
|
||||
next_affinity,
|
||||
}],
|
||||
battle: None,
|
||||
toast: None,
|
||||
})
|
||||
}
|
||||
2165
server-rs/crates/api-server/src/runtime_story/compat/tests.rs
Normal file
2165
server-rs/crates/api-server/src/runtime_story/compat/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,28 @@
|
||||
5. `story-sessions/begin`
|
||||
6. `story-sessions/continue`
|
||||
|
||||
当前阶段新增 Stage6 `character visual` 兼容 DTO:
|
||||
|
||||
1. `assets/character-visual/generate`
|
||||
2. `assets/character-visual/jobs/:taskId`
|
||||
3. `assets/character-visual/publish`
|
||||
|
||||
当前阶段新增 Stage7 `character animation` 模板与导入兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/templates`
|
||||
2. `assets/character-animation/import-video`
|
||||
|
||||
当前阶段新增 Stage8 `character workflow cache` 第一批兼容 DTO:
|
||||
|
||||
1. `assets/character-workflow-cache`
|
||||
2. `assets/character-workflow-cache/:characterId`
|
||||
|
||||
当前阶段新增 Stage9 `character animation` 主链兼容 DTO:
|
||||
|
||||
1. `assets/character-animation/generate`
|
||||
2. `assets/character-animation/jobs/:taskId`
|
||||
3. `assets/character-animation/publish`
|
||||
|
||||
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||
|
||||
1. `runtime/story/state/resolve` 请求 DTO
|
||||
|
||||
@@ -4,6 +4,7 @@ use platform_oss::{
|
||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
|
||||
pub profile_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterVisualSourceMode {
|
||||
TextToImage,
|
||||
ImageToImage,
|
||||
Upload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
pub candidate_count: u32,
|
||||
pub image_model: String,
|
||||
pub size: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualDraftPayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub image_src: String,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub drafts: Vec<CharacterVisualDraftPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CharacterAssetJobStatusText {
|
||||
Queued,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAssetJobStatusPayload {
|
||||
pub task_id: String,
|
||||
pub kind: String,
|
||||
pub status: CharacterAssetJobStatusText,
|
||||
pub character_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animation: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub strategy: Option<String>,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishRequest {
|
||||
pub character_id: String,
|
||||
pub source_mode: CharacterVisualSourceMode,
|
||||
#[serde(default)]
|
||||
pub prompt_text: Option<String>,
|
||||
pub selected_preview_source: String,
|
||||
#[serde(default)]
|
||||
pub preview_sources: Vec<String>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterVisualPublishResponse {
|
||||
pub ok: bool,
|
||||
pub asset_id: String,
|
||||
pub portrait_path: String,
|
||||
pub override_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatePayload {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub animation: String,
|
||||
pub prompt_suffix: String,
|
||||
pub notes: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationTemplatesResponse {
|
||||
pub ok: bool,
|
||||
pub templates: Vec<CharacterAnimationTemplatePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoRequest {
|
||||
pub character_id: String,
|
||||
pub animation: String,
|
||||
pub video_source: String,
|
||||
#[serde(default)]
|
||||
pub source_label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationImportVideoResponse {
|
||||
pub ok: bool,
|
||||
pub imported_video_path: String,
|
||||
pub draft_id: String,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum CharacterAnimationStrategy {
|
||||
ImageSequence,
|
||||
ImageToVideo,
|
||||
MotionTransfer,
|
||||
ReferenceToVideo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateRequest {
|
||||
pub character_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub animation: String,
|
||||
pub prompt_text: String,
|
||||
#[serde(default)]
|
||||
pub character_brief_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub action_template_id: Option<String>,
|
||||
pub visual_source: String,
|
||||
#[serde(default)]
|
||||
pub reference_image_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_video_data_urls: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub last_frame_image_data_url: Option<String>,
|
||||
pub frame_count: u32,
|
||||
pub fps: u32,
|
||||
pub duration_seconds: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub use_chroma_key: bool,
|
||||
pub resolution: String,
|
||||
pub ratio: String,
|
||||
pub image_sequence_model: String,
|
||||
pub video_model: String,
|
||||
pub reference_video_model: String,
|
||||
pub motion_transfer_model: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationGenerateResponse {
|
||||
pub ok: bool,
|
||||
pub task_id: String,
|
||||
pub strategy: CharacterAnimationStrategy,
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub image_sources: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationDraftPayload {
|
||||
pub frames_data_urls: Vec<String>,
|
||||
pub fps: u32,
|
||||
#[serde(rename = "loop")]
|
||||
pub loop_: bool,
|
||||
pub frame_width: u32,
|
||||
pub frame_height: u32,
|
||||
#[serde(default)]
|
||||
pub preview_video_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishRequest {
|
||||
pub character_id: String,
|
||||
pub visual_asset_id: String,
|
||||
pub animations: BTreeMap<String, CharacterAnimationDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub update_character_override: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterAnimationPublishResponse {
|
||||
pub ok: bool,
|
||||
pub animation_set_id: String,
|
||||
pub override_map: Value,
|
||||
pub animation_map: Value,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCachePayload {
|
||||
pub character_id: String,
|
||||
pub visual_prompt_text: String,
|
||||
pub animation_prompt_text: String,
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
pub selected_visual_draft_id: String,
|
||||
pub selected_animation: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveRequest {
|
||||
pub character_id: String,
|
||||
#[serde(default)]
|
||||
pub visual_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_prompt_text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
|
||||
#[serde(default)]
|
||||
pub selected_visual_draft_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub selected_animation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub image_src: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_visual_asset_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub generated_animation_set_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_map: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheGetResponse {
|
||||
pub ok: bool,
|
||||
pub cache: Option<CharacterWorkflowCachePayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CharacterWorkflowCacheSaveResponse {
|
||||
pub ok: bool,
|
||||
pub cache: CharacterWorkflowCachePayload,
|
||||
pub save_message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateDirectUploadTicketResponse {
|
||||
@@ -358,4 +642,177 @@ mod tests {
|
||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_source_mode_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterVisualSourceMode::ImageToImage)
|
||||
.expect("source mode should serialize");
|
||||
|
||||
assert_eq!(payload, json!("image-to-image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_visual_generate_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterVisualGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "visual_1".to_string(),
|
||||
model: "rust-svg-character-visual".to_string(),
|
||||
prompt: "角色提示词".to_string(),
|
||||
drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "candidate-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/visual_1/candidate-01.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("visual_1"));
|
||||
assert_eq!(
|
||||
payload["drafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/visual_1/candidate-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_templates_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationTemplatesResponse {
|
||||
ok: true,
|
||||
templates: vec![CharacterAnimationTemplatePayload {
|
||||
id: "idle_loop".to_string(),
|
||||
label: "待机循环".to_string(),
|
||||
animation: "idle".to_string(),
|
||||
prompt_suffix: "保持呼吸感。".to_string(),
|
||||
notes: "默认待机模板。".to_string(),
|
||||
}],
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["templates"][0]["id"], json!("idle_loop"));
|
||||
assert_eq!(
|
||||
payload["templates"][0]["promptSuffix"],
|
||||
json!("保持呼吸感。")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_import_video_response_keeps_legacy_shape() {
|
||||
let payload =
|
||||
serde_json::to_value(CharacterAnimationImportVideoResponse {
|
||||
ok: true,
|
||||
imported_video_path:
|
||||
"/generated-character-drafts/hero/animation/idle/import-1/reference.mp4"
|
||||
.to_string(),
|
||||
draft_id: "animation-import-1".to_string(),
|
||||
save_message: "参考视频已导入 OSS 草稿区。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(
|
||||
payload["importedVideoPath"],
|
||||
json!("/generated-character-drafts/hero/animation/idle/import-1/reference.mp4")
|
||||
);
|
||||
assert_eq!(payload["draftId"], json!("animation-import-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_workflow_cache_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterWorkflowCacheSaveResponse {
|
||||
ok: true,
|
||||
cache: CharacterWorkflowCachePayload {
|
||||
character_id: "hero".to_string(),
|
||||
visual_prompt_text: "主形象".to_string(),
|
||||
animation_prompt_text: "待机".to_string(),
|
||||
visual_drafts: vec![CharacterVisualDraftPayload {
|
||||
id: "draft-1".to_string(),
|
||||
label: "候选 1".to_string(),
|
||||
image_src: "/generated-character-drafts/hero/visual/job/candidate.svg"
|
||||
.to_string(),
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
}],
|
||||
selected_visual_draft_id: "draft-1".to_string(),
|
||||
selected_animation: "idle".to_string(),
|
||||
image_src: Some("/generated-characters/hero/master.png".to_string()),
|
||||
generated_visual_asset_id: None,
|
||||
generated_animation_set_id: None,
|
||||
animation_map: Some(json!({ "idle": { "frames": 4 } })),
|
||||
updated_at: Some("2026-04-22T12:00:00Z".to_string()),
|
||||
},
|
||||
save_message: "角色形象生成缓存已更新。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["cache"]["characterId"], json!("hero"));
|
||||
assert_eq!(
|
||||
payload["cache"]["visualDrafts"][0]["imageSrc"],
|
||||
json!("/generated-character-drafts/hero/visual/job/candidate.svg")
|
||||
);
|
||||
assert_eq!(payload["cache"]["animationMap"]["idle"]["frames"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_strategy_uses_legacy_kebab_case() {
|
||||
let payload = serde_json::to_value(CharacterAnimationStrategy::MotionTransfer)
|
||||
.expect("strategy should serialize");
|
||||
|
||||
assert_eq!(payload, json!("motion-transfer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_generate_response_keeps_image_sequence_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationGenerateResponse {
|
||||
ok: true,
|
||||
task_id: "animation_1".to_string(),
|
||||
strategy: CharacterAnimationStrategy::ImageSequence,
|
||||
model: "rust-svg-animation-sequence".to_string(),
|
||||
prompt: "待机动作".to_string(),
|
||||
image_sources: vec![
|
||||
"/generated-character-drafts/hero/animation/idle/job/frame-01.svg".to_string(),
|
||||
],
|
||||
preview_video_path: None,
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["ok"], json!(true));
|
||||
assert_eq!(payload["taskId"], json!("animation_1"));
|
||||
assert_eq!(payload["strategy"], json!("image-sequence"));
|
||||
assert_eq!(
|
||||
payload["imageSources"][0],
|
||||
json!("/generated-character-drafts/hero/animation/idle/job/frame-01.svg")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_animation_publish_response_keeps_legacy_shape() {
|
||||
let payload = serde_json::to_value(CharacterAnimationPublishResponse {
|
||||
ok: true,
|
||||
animation_set_id: "animation-set-1".to_string(),
|
||||
override_map: json!({}),
|
||||
animation_map: json!({
|
||||
"idle": {
|
||||
"folder": "idle",
|
||||
"prefix": "frame",
|
||||
"frames": 2,
|
||||
"startFrame": 1,
|
||||
"extension": "svg",
|
||||
"basePath": "/generated-animations/hero/animation-set-1/idle",
|
||||
"frameWidth": 192,
|
||||
"frameHeight": 256,
|
||||
"fps": 8,
|
||||
"loop": true
|
||||
}
|
||||
}),
|
||||
save_message: "基础动作资源已写入 OSS 并绑定当前角色。".to_string(),
|
||||
})
|
||||
.expect("response should serialize");
|
||||
|
||||
assert_eq!(payload["animationSetId"], json!("animation-set-1"));
|
||||
assert_eq!(payload["animationMap"]["idle"]["frames"], json!(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +116,6 @@ pub enum RuntimeStoryOptionInteraction {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
quest_id: Option<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Treasure {
|
||||
action: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
@@ -308,11 +304,11 @@ mod tests {
|
||||
assert_eq!(payload["clientVersion"], json!(8));
|
||||
assert_eq!(payload["action"]["type"], json!("story_choice"));
|
||||
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
|
||||
assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper"));
|
||||
assert_eq!(
|
||||
payload["action"]["targetId"],
|
||||
json!("npc_camp_firekeeper")
|
||||
payload["snapshot"]["savedAt"],
|
||||
json!("2026-04-22T12:00:00.000Z")
|
||||
);
|
||||
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
753
server-rs/crates/spacetime-module/src/ai/mod.rs
Normal file
753
server-rs/crates/spacetime-module/src/ai/mod.rs
Normal file
@@ -0,0 +1,753 @@
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_task,
|
||||
index(accessor = by_ai_task_owner_user_id, btree(columns = [owner_user_id])),
|
||||
index(accessor = by_ai_task_status, btree(columns = [status])),
|
||||
index(accessor = by_ai_task_kind, btree(columns = [task_kind]))
|
||||
)]
|
||||
pub struct AiTask {
|
||||
#[primary_key]
|
||||
task_id: String,
|
||||
task_kind: AiTaskKind,
|
||||
owner_user_id: String,
|
||||
request_label: String,
|
||||
source_module: String,
|
||||
source_entity_id: Option<String>,
|
||||
request_payload_json: Option<String>,
|
||||
status: AiTaskStatus,
|
||||
failure_message: Option<String>,
|
||||
latest_text_output: Option<String>,
|
||||
latest_structured_payload_json: Option<String>,
|
||||
version: u32,
|
||||
created_at: Timestamp,
|
||||
started_at: Option<Timestamp>,
|
||||
completed_at: Option<Timestamp>,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_task_stage,
|
||||
index(accessor = by_ai_task_stage_task_id, btree(columns = [task_id])),
|
||||
index(accessor = by_ai_task_stage_task_order, btree(columns = [task_id, stage_order]))
|
||||
)]
|
||||
pub struct AiTaskStage {
|
||||
#[primary_key]
|
||||
task_stage_id: String,
|
||||
task_id: String,
|
||||
stage_kind: AiTaskStageKind,
|
||||
label: String,
|
||||
detail: String,
|
||||
stage_order: u32,
|
||||
status: AiTaskStageStatus,
|
||||
text_output: Option<String>,
|
||||
structured_payload_json: Option<String>,
|
||||
warning_messages: Vec<String>,
|
||||
started_at: Option<Timestamp>,
|
||||
completed_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_text_chunk,
|
||||
index(accessor = by_ai_text_chunk_task_id, btree(columns = [task_id])),
|
||||
index(
|
||||
accessor = by_ai_text_chunk_task_stage_sequence,
|
||||
btree(columns = [task_id, stage_kind, sequence])
|
||||
)
|
||||
)]
|
||||
pub struct AiTextChunk {
|
||||
#[primary_key]
|
||||
text_chunk_row_id: String,
|
||||
chunk_id: String,
|
||||
task_id: String,
|
||||
stage_kind: AiTaskStageKind,
|
||||
sequence: u32,
|
||||
delta_text: String,
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = ai_result_reference,
|
||||
index(accessor = by_ai_result_reference_task_id, btree(columns = [task_id]))
|
||||
)]
|
||||
pub struct AiResultReference {
|
||||
#[primary_key]
|
||||
result_reference_row_id: String,
|
||||
result_ref_id: String,
|
||||
task_id: String,
|
||||
reference_kind: AiResultReferenceKind,
|
||||
reference_id: String,
|
||||
label: Option<String>,
|
||||
created_at: Timestamp,
|
||||
}
|
||||
|
||||
// AI 任务当前先固定成 private 真相表,后续由 Axum / platform-llm 再往外包一层 HTTP 与 SSE 协议。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn create_ai_task(ctx: &ReducerContext, input: AiTaskCreateInput) -> Result<(), String> {
|
||||
create_ai_task_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn create_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskCreateInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| create_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn start_ai_task(ctx: &ReducerContext, input: AiTaskStartInput) -> Result<(), String> {
|
||||
start_ai_task_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
#[spacetimedb::reducer]
|
||||
pub fn start_ai_task_stage(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStageStartInput,
|
||||
) -> Result<(), String> {
|
||||
start_ai_task_stage_tx(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// 流式增量写入需要同步返回 chunk 与聚合后的任务快照,方便后续 Axum facade 直接复用。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn append_ai_text_chunk_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTextChunkAppendInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| append_ai_text_chunk_tx(tx, input.clone())) {
|
||||
Ok((task, text_chunk)) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: Some(text_chunk),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_ai_stage_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiStageCompletionInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_ai_stage_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn attach_ai_result_reference_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiResultReferenceInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| attach_ai_result_reference_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn complete_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskFinishInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| complete_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn fail_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskFailureInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| fail_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[spacetimedb::procedure]
|
||||
pub fn cancel_ai_task_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AiTaskCancelInput,
|
||||
) -> AiTaskProcedureResult {
|
||||
match ctx.try_with_tx(|tx| cancel_ai_task_tx(tx, input.clone())) {
|
||||
Ok(task) => AiTaskProcedureResult {
|
||||
ok: true,
|
||||
task: Some(task),
|
||||
text_chunk: None,
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AiTaskProcedureResult {
|
||||
ok: false,
|
||||
task: None,
|
||||
text_chunk: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn create_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskCreateInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
validate_task_create_input(&input).map_err(|error| error.to_string())?;
|
||||
|
||||
if ctx.db.ai_task().task_id().find(&input.task_id).is_some() {
|
||||
return Err("ai_task.task_id 已存在".to_string());
|
||||
}
|
||||
|
||||
let task_snapshot = build_ai_task_snapshot_from_create_input(&input);
|
||||
ctx.db.ai_task().insert(build_ai_task_row(&task_snapshot));
|
||||
replace_ai_task_stages(ctx, &task_snapshot.task_id, &task_snapshot.stages);
|
||||
|
||||
get_ai_task_snapshot_tx(ctx, &task_snapshot.task_id)
|
||||
}
|
||||
|
||||
fn start_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStartInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
snapshot.updated_at_micros = input.started_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn start_ai_task_stage_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskStageStartInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
stage.status = AiTaskStageStatus::Running;
|
||||
if stage.started_at_micros.is_none() {
|
||||
stage.started_at_micros = Some(input.started_at_micros);
|
||||
}
|
||||
snapshot.updated_at_micros = input.started_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn append_ai_text_chunk_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTextChunkAppendInput,
|
||||
) -> Result<(AiTaskSnapshot, AiTextChunkSnapshot), String> {
|
||||
if input.delta_text.trim().is_empty() {
|
||||
return Err("ai_text_chunk.delta_text 不能为空".to_string());
|
||||
}
|
||||
if input.sequence == 0 {
|
||||
return Err("ai_text_chunk.sequence 必须大于 0".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
let chunk = AiTextChunkSnapshot {
|
||||
chunk_id: generate_ai_text_chunk_id(input.created_at_micros, input.sequence),
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
stage_kind: input.stage_kind,
|
||||
sequence: input.sequence,
|
||||
delta_text: input.delta_text.trim().to_string(),
|
||||
created_at_micros: input.created_at_micros,
|
||||
};
|
||||
ctx.db
|
||||
.ai_text_chunk()
|
||||
.insert(build_ai_text_chunk_row(&chunk));
|
||||
|
||||
let aggregated_text = collect_ai_stage_text_output(ctx, &chunk.task_id, chunk.stage_kind);
|
||||
|
||||
snapshot.status = AiTaskStatus::Running;
|
||||
if snapshot.started_at_micros.is_none() {
|
||||
snapshot.started_at_micros = Some(input.created_at_micros);
|
||||
}
|
||||
stage.status = AiTaskStageStatus::Running;
|
||||
if stage.started_at_micros.is_none() {
|
||||
stage.started_at_micros = Some(input.created_at_micros);
|
||||
}
|
||||
stage.text_output = aggregated_text.clone();
|
||||
snapshot.latest_text_output = aggregated_text;
|
||||
snapshot.updated_at_micros = input.created_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok((snapshot, chunk))
|
||||
}
|
||||
|
||||
fn complete_ai_stage_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiStageCompletionInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let stage = snapshot
|
||||
.stages
|
||||
.iter_mut()
|
||||
.find(|stage| stage.stage_kind == input.stage_kind)
|
||||
.ok_or_else(|| "ai_task.stage 不存在".to_string())?;
|
||||
|
||||
stage.status = AiTaskStageStatus::Completed;
|
||||
stage.completed_at_micros = Some(input.completed_at_micros);
|
||||
stage.text_output = normalize_optional_text(input.text_output.clone());
|
||||
stage.structured_payload_json = normalize_optional_text(input.structured_payload_json.clone());
|
||||
stage.warning_messages = normalize_string_list(input.warning_messages.clone());
|
||||
|
||||
snapshot.latest_text_output = stage.text_output.clone();
|
||||
snapshot.latest_structured_payload_json = stage.structured_payload_json.clone();
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn attach_ai_result_reference_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiResultReferenceInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let reference_id = input.reference_id.trim().to_string();
|
||||
if reference_id.is_empty() {
|
||||
return Err("ai_result_reference.reference_id 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
let reference = AiResultReferenceSnapshot {
|
||||
result_ref_id: generate_ai_result_ref_id(input.created_at_micros),
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
reference_kind: input.reference_kind,
|
||||
reference_id,
|
||||
label: normalize_optional_text(input.label),
|
||||
created_at_micros: input.created_at_micros,
|
||||
};
|
||||
ctx.db
|
||||
.ai_result_reference()
|
||||
.insert(build_ai_result_reference_row(&reference));
|
||||
|
||||
snapshot.result_references.push(reference);
|
||||
snapshot.updated_at_micros = input.created_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn complete_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskFinishInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Completed;
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn fail_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskFailureInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let failure_message = input.failure_message.trim().to_string();
|
||||
if failure_message.is_empty() {
|
||||
return Err("ai_task.failure_message 不能为空".to_string());
|
||||
}
|
||||
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Failed;
|
||||
snapshot.failure_message = Some(failure_message);
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn cancel_ai_task_tx(
|
||||
ctx: &ReducerContext,
|
||||
input: AiTaskCancelInput,
|
||||
) -> Result<AiTaskSnapshot, String> {
|
||||
let mut snapshot = get_ai_task_snapshot_tx(ctx, &input.task_id)?;
|
||||
ensure_ai_task_can_transition(snapshot.status)?;
|
||||
|
||||
snapshot.status = AiTaskStatus::Cancelled;
|
||||
snapshot.completed_at_micros = Some(input.completed_at_micros);
|
||||
snapshot.updated_at_micros = input.completed_at_micros;
|
||||
snapshot.version += 1;
|
||||
|
||||
persist_ai_task_snapshot(ctx, &snapshot)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
fn get_ai_task_snapshot_tx(ctx: &ReducerContext, task_id: &str) -> Result<AiTaskSnapshot, String> {
|
||||
let row = ctx
|
||||
.db
|
||||
.ai_task()
|
||||
.task_id()
|
||||
.find(&task_id.trim().to_string())
|
||||
.ok_or_else(|| "ai_task 不存在".to_string())?;
|
||||
|
||||
Ok(build_ai_task_snapshot_from_row(ctx, &row))
|
||||
}
|
||||
|
||||
fn persist_ai_task_snapshot(ctx: &ReducerContext, snapshot: &AiTaskSnapshot) -> Result<(), String> {
|
||||
ctx.db.ai_task().task_id().delete(&snapshot.task_id);
|
||||
ctx.db.ai_task().insert(build_ai_task_row(snapshot));
|
||||
replace_ai_task_stages(ctx, &snapshot.task_id, &snapshot.stages);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_ai_task_stages(ctx: &ReducerContext, task_id: &str, stages: &[AiTaskStageSnapshot]) {
|
||||
let stage_ids = ctx
|
||||
.db
|
||||
.ai_task_stage()
|
||||
.iter()
|
||||
.filter(|row| row.task_id == task_id)
|
||||
.map(|row| row.task_stage_id.clone())
|
||||
.collect::<Vec<_>>();
|
||||
for stage_id in stage_ids {
|
||||
ctx.db.ai_task_stage().task_stage_id().delete(&stage_id);
|
||||
}
|
||||
|
||||
for stage in stages {
|
||||
ctx.db
|
||||
.ai_task_stage()
|
||||
.insert(build_ai_task_stage_row(task_id, stage));
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_ai_stage_text_output(
|
||||
ctx: &ReducerContext,
|
||||
task_id: &str,
|
||||
stage_kind: AiTaskStageKind,
|
||||
) -> Option<String> {
|
||||
let mut chunks = ctx
|
||||
.db
|
||||
.ai_text_chunk()
|
||||
.iter()
|
||||
.filter(|row| row.task_id == task_id && row.stage_kind == stage_kind)
|
||||
.map(|row| build_ai_text_chunk_snapshot_from_row(&row))
|
||||
.collect::<Vec<_>>();
|
||||
chunks.sort_by_key(|chunk| chunk.sequence);
|
||||
|
||||
let aggregated = chunks
|
||||
.into_iter()
|
||||
.map(|chunk| chunk.delta_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if aggregated.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(aggregated)
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_ai_task_can_transition(status: AiTaskStatus) -> Result<(), String> {
|
||||
if matches!(
|
||||
status,
|
||||
AiTaskStatus::Completed | AiTaskStatus::Failed | AiTaskStatus::Cancelled
|
||||
) {
|
||||
Err("当前 ai_task 状态不允许执行该操作".to_string())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_snapshot_from_create_input(input: &AiTaskCreateInput) -> AiTaskSnapshot {
|
||||
AiTaskSnapshot {
|
||||
task_id: input.task_id.trim().to_string(),
|
||||
task_kind: input.task_kind,
|
||||
owner_user_id: input.owner_user_id.trim().to_string(),
|
||||
request_label: input.request_label.trim().to_string(),
|
||||
source_module: input.source_module.trim().to_string(),
|
||||
source_entity_id: normalize_optional_text(input.source_entity_id.clone()),
|
||||
request_payload_json: normalize_optional_text(input.request_payload_json.clone()),
|
||||
status: AiTaskStatus::Pending,
|
||||
failure_message: None,
|
||||
stages: input
|
||||
.stages
|
||||
.iter()
|
||||
.map(|stage| AiTaskStageSnapshot {
|
||||
stage_kind: stage.stage_kind,
|
||||
label: stage.label.trim().to_string(),
|
||||
detail: stage.detail.trim().to_string(),
|
||||
order: stage.order,
|
||||
status: AiTaskStageStatus::Pending,
|
||||
text_output: None,
|
||||
structured_payload_json: None,
|
||||
warning_messages: Vec::new(),
|
||||
started_at_micros: None,
|
||||
completed_at_micros: None,
|
||||
})
|
||||
.collect(),
|
||||
result_references: Vec::new(),
|
||||
latest_text_output: None,
|
||||
latest_structured_payload_json: None,
|
||||
version: INITIAL_AI_TASK_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
started_at_micros: None,
|
||||
completed_at_micros: None,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_row(snapshot: &AiTaskSnapshot) -> AiTask {
|
||||
AiTask {
|
||||
task_id: snapshot.task_id.clone(),
|
||||
task_kind: snapshot.task_kind,
|
||||
owner_user_id: snapshot.owner_user_id.clone(),
|
||||
request_label: snapshot.request_label.clone(),
|
||||
source_module: snapshot.source_module.clone(),
|
||||
source_entity_id: snapshot.source_entity_id.clone(),
|
||||
request_payload_json: snapshot.request_payload_json.clone(),
|
||||
status: snapshot.status,
|
||||
failure_message: snapshot.failure_message.clone(),
|
||||
latest_text_output: snapshot.latest_text_output.clone(),
|
||||
latest_structured_payload_json: snapshot.latest_structured_payload_json.clone(),
|
||||
version: snapshot.version,
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
started_at: snapshot
|
||||
.started_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
completed_at: snapshot
|
||||
.completed_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
updated_at: Timestamp::from_micros_since_unix_epoch(snapshot.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_snapshot_from_row(ctx: &ReducerContext, row: &AiTask) -> AiTaskSnapshot {
|
||||
let mut stages = ctx
|
||||
.db
|
||||
.ai_task_stage()
|
||||
.iter()
|
||||
.filter(|stage| stage.task_id == row.task_id)
|
||||
.map(|stage| build_ai_task_stage_snapshot_from_row(&stage))
|
||||
.collect::<Vec<_>>();
|
||||
stages.sort_by_key(|stage| stage.order);
|
||||
|
||||
let mut result_references = ctx
|
||||
.db
|
||||
.ai_result_reference()
|
||||
.iter()
|
||||
.filter(|reference| reference.task_id == row.task_id)
|
||||
.map(|reference| build_ai_result_reference_snapshot_from_row(&reference))
|
||||
.collect::<Vec<_>>();
|
||||
result_references.sort_by_key(|reference| reference.created_at_micros);
|
||||
|
||||
AiTaskSnapshot {
|
||||
task_id: row.task_id.clone(),
|
||||
task_kind: row.task_kind,
|
||||
owner_user_id: row.owner_user_id.clone(),
|
||||
request_label: row.request_label.clone(),
|
||||
source_module: row.source_module.clone(),
|
||||
source_entity_id: row.source_entity_id.clone(),
|
||||
request_payload_json: row.request_payload_json.clone(),
|
||||
status: row.status,
|
||||
failure_message: row.failure_message.clone(),
|
||||
stages,
|
||||
result_references,
|
||||
latest_text_output: row.latest_text_output.clone(),
|
||||
latest_structured_payload_json: row.latest_structured_payload_json.clone(),
|
||||
version: row.version,
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_row(task_id: &str, snapshot: &AiTaskStageSnapshot) -> AiTaskStage {
|
||||
AiTaskStage {
|
||||
task_stage_id: generate_ai_task_stage_id(task_id, snapshot.stage_kind),
|
||||
task_id: task_id.to_string(),
|
||||
stage_kind: snapshot.stage_kind,
|
||||
label: snapshot.label.clone(),
|
||||
detail: snapshot.detail.clone(),
|
||||
stage_order: snapshot.order,
|
||||
status: snapshot.status,
|
||||
text_output: snapshot.text_output.clone(),
|
||||
structured_payload_json: snapshot.structured_payload_json.clone(),
|
||||
warning_messages: snapshot.warning_messages.clone(),
|
||||
started_at: snapshot
|
||||
.started_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
completed_at: snapshot
|
||||
.completed_at_micros
|
||||
.map(Timestamp::from_micros_since_unix_epoch),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_task_stage_snapshot_from_row(row: &AiTaskStage) -> AiTaskStageSnapshot {
|
||||
AiTaskStageSnapshot {
|
||||
stage_kind: row.stage_kind,
|
||||
label: row.label.clone(),
|
||||
detail: row.detail.clone(),
|
||||
order: row.stage_order,
|
||||
status: row.status,
|
||||
text_output: row.text_output.clone(),
|
||||
structured_payload_json: row.structured_payload_json.clone(),
|
||||
warning_messages: row.warning_messages.clone(),
|
||||
started_at_micros: row
|
||||
.started_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
completed_at_micros: row
|
||||
.completed_at
|
||||
.map(|value| value.to_micros_since_unix_epoch()),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_row(snapshot: &AiTextChunkSnapshot) -> AiTextChunk {
|
||||
AiTextChunk {
|
||||
text_chunk_row_id: format!(
|
||||
"{}{}_{}_{}",
|
||||
AI_TEXT_CHUNK_ID_PREFIX,
|
||||
snapshot.task_id,
|
||||
snapshot.stage_kind.as_str(),
|
||||
snapshot.sequence
|
||||
),
|
||||
chunk_id: snapshot.chunk_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
stage_kind: snapshot.stage_kind,
|
||||
sequence: snapshot.sequence,
|
||||
delta_text: snapshot.delta_text.clone(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_text_chunk_snapshot_from_row(row: &AiTextChunk) -> AiTextChunkSnapshot {
|
||||
AiTextChunkSnapshot {
|
||||
chunk_id: row.chunk_id.clone(),
|
||||
task_id: row.task_id.clone(),
|
||||
stage_kind: row.stage_kind,
|
||||
sequence: row.sequence,
|
||||
delta_text: row.delta_text.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_row(snapshot: &AiResultReferenceSnapshot) -> AiResultReference {
|
||||
AiResultReference {
|
||||
result_reference_row_id: format!(
|
||||
"{}{}_{}",
|
||||
AI_RESULT_REF_ID_PREFIX, snapshot.task_id, snapshot.result_ref_id
|
||||
),
|
||||
result_ref_id: snapshot.result_ref_id.clone(),
|
||||
task_id: snapshot.task_id.clone(),
|
||||
reference_kind: snapshot.reference_kind,
|
||||
reference_id: snapshot.reference_id.clone(),
|
||||
label: snapshot.label.clone(),
|
||||
created_at: Timestamp::from_micros_since_unix_epoch(snapshot.created_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ai_result_reference_snapshot_from_row(
|
||||
row: &AiResultReference,
|
||||
) -> AiResultReferenceSnapshot {
|
||||
AiResultReferenceSnapshot {
|
||||
result_ref_id: row.result_ref_id.clone(),
|
||||
task_id: row.task_id.clone(),
|
||||
reference_kind: row.reference_kind,
|
||||
reference_id: row.reference_id.clone(),
|
||||
label: row.label.clone(),
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
}
|
||||
}
|
||||
305
server-rs/crates/spacetime-module/src/asset_metadata/mod.rs
Normal file
305
server-rs/crates/spacetime-module/src/asset_metadata/mod.rs
Normal file
@@ -0,0 +1,305 @@
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_object,
|
||||
index(accessor = by_bucket_object_key, btree(columns = [bucket, object_key]))
|
||||
)]
|
||||
pub struct AssetObject {
|
||||
#[primary_key]
|
||||
asset_object_id: String,
|
||||
// 正式对象定位固定拆成 bucket + object_key 两列,避免后续再从单字符串路径做 schema 拆分。
|
||||
bucket: String,
|
||||
object_key: String,
|
||||
access_policy: AssetObjectAccessPolicy,
|
||||
content_type: Option<String>,
|
||||
content_length: u64,
|
||||
content_hash: Option<String>,
|
||||
version: u32,
|
||||
source_job_id: Option<String>,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
entity_id: Option<String>,
|
||||
#[index(btree)]
|
||||
asset_kind: String,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
#[spacetimedb::table(
|
||||
accessor = asset_entity_binding,
|
||||
index(accessor = by_entity_slot, btree(columns = [entity_kind, entity_id, slot])),
|
||||
index(accessor = by_asset_object_id, btree(columns = [asset_object_id]))
|
||||
)]
|
||||
pub struct AssetEntityBinding {
|
||||
#[primary_key]
|
||||
binding_id: String,
|
||||
asset_object_id: String,
|
||||
entity_kind: String,
|
||||
entity_id: String,
|
||||
slot: String,
|
||||
asset_kind: String,
|
||||
owner_user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
created_at: Timestamp,
|
||||
updated_at: Timestamp,
|
||||
}
|
||||
|
||||
// reducer 负责固定资产对象的正式写规则,供后续内部模块逻辑复用。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn confirm_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_object(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步确认接口,返回最终持久化后的对象记录,避免 HTTP 层再额外查询 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn confirm_asset_object_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> AssetObjectProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_object(tx, input.clone())) {
|
||||
Ok(record) => AssetObjectProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetObjectProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// reducer 负责把已确认对象绑定到实体槽位,强业务资产表稳定前先用通用绑定表承接关系。
|
||||
#[spacetimedb::reducer]
|
||||
pub fn bind_asset_object_to_entity(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<(), String> {
|
||||
upsert_asset_entity_binding(ctx, input).map(|_| ())
|
||||
}
|
||||
|
||||
// procedure 面向 Axum 同步绑定接口,返回最终绑定快照,避免 HTTP 层读取 private table。
|
||||
#[spacetimedb::procedure]
|
||||
pub fn bind_asset_object_to_entity_and_return(
|
||||
ctx: &mut ProcedureContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> AssetEntityBindingProcedureResult {
|
||||
match ctx.try_with_tx(|tx| upsert_asset_entity_binding(tx, input.clone())) {
|
||||
Ok(record) => AssetEntityBindingProcedureResult {
|
||||
ok: true,
|
||||
record: Some(record),
|
||||
error_message: None,
|
||||
},
|
||||
Err(message) => AssetEntityBindingProcedureResult {
|
||||
ok: false,
|
||||
record: None,
|
||||
error_message: Some(message),
|
||||
},
|
||||
}
|
||||
}
|
||||
fn upsert_asset_object(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetObjectUpsertInput,
|
||||
) -> Result<AssetObjectUpsertSnapshot, String> {
|
||||
validate_asset_object_fields(
|
||||
&input.bucket,
|
||||
&input.object_key,
|
||||
&input.asset_kind,
|
||||
input.version,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 这里先保持最小可发布实现:查重语义已经冻结,后续再把实现优化回组合索引扫描。
|
||||
let current = ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.iter()
|
||||
.find(|row| row.bucket == input.bucket && row.object_key == input.object_key);
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.delete(&existing.asset_object_id);
|
||||
let row = AssetObject {
|
||||
asset_object_id: existing.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: existing.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetObject {
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
bucket: input.bucket.clone(),
|
||||
object_key: input.object_key.clone(),
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type.clone(),
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash.clone(),
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_object().insert(row);
|
||||
|
||||
AssetObjectUpsertSnapshot {
|
||||
asset_object_id: input.asset_object_id,
|
||||
bucket: input.bucket,
|
||||
object_key: input.object_key,
|
||||
access_policy: input.access_policy,
|
||||
content_type: input.content_type,
|
||||
content_length: input.content_length,
|
||||
content_hash: input.content_hash,
|
||||
version: input.version,
|
||||
source_job_id: input.source_job_id,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
entity_id: input.entity_id,
|
||||
asset_kind: input.asset_kind,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
fn upsert_asset_entity_binding(
|
||||
ctx: &ReducerContext,
|
||||
input: AssetEntityBindingInput,
|
||||
) -> Result<AssetEntityBindingSnapshot, String> {
|
||||
validate_asset_entity_binding_fields(
|
||||
&input.binding_id,
|
||||
&input.asset_object_id,
|
||||
&input.entity_kind,
|
||||
&input.entity_id,
|
||||
&input.slot,
|
||||
&input.asset_kind,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if ctx
|
||||
.db
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.find(&input.asset_object_id)
|
||||
.is_none()
|
||||
{
|
||||
return Err("asset_entity_binding.asset_object_id 对应的 asset_object 不存在".to_string());
|
||||
}
|
||||
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
|
||||
// 首版绑定按 entity_kind + entity_id + slot 幂等定位,后续访问量明确后再改为组合索引扫描。
|
||||
let current = ctx.db.asset_entity_binding().iter().find(|row| {
|
||||
row.entity_kind == input.entity_kind
|
||||
&& row.entity_id == input.entity_id
|
||||
&& row.slot == input.slot
|
||||
});
|
||||
|
||||
let snapshot = match current {
|
||||
Some(existing) => {
|
||||
ctx.db
|
||||
.asset_entity_binding()
|
||||
.binding_id()
|
||||
.delete(&existing.binding_id);
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: existing.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at: existing.created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: existing.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: existing.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let created_at = updated_at;
|
||||
let row = AssetEntityBinding {
|
||||
binding_id: input.binding_id.clone(),
|
||||
asset_object_id: input.asset_object_id.clone(),
|
||||
entity_kind: input.entity_kind.clone(),
|
||||
entity_id: input.entity_id.clone(),
|
||||
slot: input.slot.clone(),
|
||||
asset_kind: input.asset_kind.clone(),
|
||||
owner_user_id: input.owner_user_id.clone(),
|
||||
profile_id: input.profile_id.clone(),
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
ctx.db.asset_entity_binding().insert(row);
|
||||
|
||||
AssetEntityBindingSnapshot {
|
||||
binding_id: input.binding_id,
|
||||
asset_object_id: input.asset_object_id,
|
||||
entity_kind: input.entity_kind,
|
||||
entity_id: input.entity_id,
|
||||
slot: input.slot,
|
||||
asset_kind: input.asset_kind,
|
||||
owner_user_id: input.owner_user_id,
|
||||
profile_id: input.profile_id,
|
||||
created_at_micros: input.updated_at_micros,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(snapshot)
|
||||
}
|
||||
3254
server-rs/crates/spacetime-module/src/custom_world/mod.rs
Normal file
3254
server-rs/crates/spacetime-module/src/custom_world/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
29
server-rs/crates/spacetime-module/src/domain_types.rs
Normal file
29
server-rs/crates/spacetime-module/src/domain_types.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct ResolveNpcBattleInteractionInput {
|
||||
pub npc_interaction: ResolveNpcInteractionInput,
|
||||
pub story_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub battle_state_id: Option<String>,
|
||||
pub player_hp: i32,
|
||||
pub player_max_hp: i32,
|
||||
pub player_mana: i32,
|
||||
pub player_max_mana: i32,
|
||||
pub target_hp: i32,
|
||||
pub target_max_hp: i32,
|
||||
pub experience_reward: u32,
|
||||
pub reward_items: Vec<RuntimeItemRewardItemSnapshot>,
|
||||
}
|
||||
|
||||
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct NpcBattleInteractionResult {
|
||||
pub interaction: module_npc::NpcInteractionResult,
|
||||
pub battle_state: BattleStateSnapshot,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
pub struct NpcBattleInteractionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<NpcBattleInteractionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
23
server-rs/crates/spacetime-module/src/entry.rs
Normal file
23
server-rs/crates/spacetime-module/src/entry.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
// 当前阶段先落可发布的最小模块入口,后续再补对象确认、业务绑定与任务编排 reducer。
|
||||
#[spacetimedb::reducer(init)]
|
||||
pub fn init(_ctx: &ReducerContext) {
|
||||
log::info!(
|
||||
"spacetime-module 初始化完成,asset_object 已固定 bucket/object_key 双列主存储口径,runtime_setting 已固定默认音量={} 和默认主题={},battle_state 前缀={},战斗初始版本={},npc_state 前缀={},npc 招募阈值={},story_session 前缀={},story_event 前缀={},inventory_slot 前缀={},inventory_mutation 前缀={},quest_log 前缀={},treasure_record 前缀={},player_progression 与 chapter_progression 已接入成长真相表,M5 custom_world_profile/session/agent/gallery 首批表骨架已接入,默认对象 ID 前缀={},默认绑定 ID 前缀={},资产初始版本={},故事会话初始版本={}",
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
DEFAULT_PLATFORM_THEME.as_str(),
|
||||
BATTLE_STATE_ID_PREFIX,
|
||||
INITIAL_BATTLE_VERSION,
|
||||
NPC_STATE_ID_PREFIX,
|
||||
NPC_RECRUIT_AFFINITY_THRESHOLD,
|
||||
STORY_SESSION_ID_PREFIX,
|
||||
STORY_EVENT_ID_PREFIX,
|
||||
INVENTORY_SLOT_ID_PREFIX,
|
||||
INVENTORY_MUTATION_ID_PREFIX,
|
||||
QUEST_LOG_ID_PREFIX,
|
||||
TREASURE_RECORD_ID_PREFIX,
|
||||
ASSET_OBJECT_ID_PREFIX,
|
||||
ASSET_BINDING_ID_PREFIX,
|
||||
INITIAL_ASSET_OBJECT_VERSION,
|
||||
INITIAL_STORY_SESSION_VERSION
|
||||
);
|
||||
}
|
||||
2110
server-rs/crates/spacetime-module/src/gameplay/mod.rs
Normal file
2110
server-rs/crates/spacetime-module/src/gameplay/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1288
server-rs/crates/spacetime-module/src/runtime/mod.rs
Normal file
1288
server-rs/crates/spacetime-module/src/runtime/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
80
server-rs/scripts/m7-preflight.ps1
Normal file
80
server-rs/scripts/m7-preflight.ps1
Normal file
@@ -0,0 +1,80 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Alias("h")]
|
||||
[switch]$Help,
|
||||
[switch]$RunSmoke,
|
||||
[switch]$RunSpacetimeBuild
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Write-Usage {
|
||||
@(
|
||||
'Usage:',
|
||||
' ./server-rs/scripts/m7-preflight.ps1',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSmoke',
|
||||
' ./server-rs/scripts/m7-preflight.ps1 -RunSpacetimeBuild',
|
||||
'',
|
||||
'Notes:',
|
||||
' 1. Run M7 cutover preflight checks for Rust backend',
|
||||
' 2. Default checks are non-destructive and do not publish or clear SpacetimeDB data',
|
||||
' 3. -RunSmoke starts a temporary api-server and verifies /healthz contract',
|
||||
' 4. -RunSpacetimeBuild requires spacetime CLI and only builds the module'
|
||||
) -join [Environment]::NewLine
|
||||
}
|
||||
|
||||
if ($Help) {
|
||||
Write-Usage
|
||||
exit 0
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$serverRsDir = Split-Path -Parent $scriptDir
|
||||
$repoRoot = Split-Path -Parent $serverRsDir
|
||||
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
|
||||
$modulePath = Join-Path $serverRsDir "crates\spacetime-module"
|
||||
|
||||
if (-not (Test-Path $manifestPath)) {
|
||||
throw "Missing server-rs/Cargo.toml, cannot start M7 preflight."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] repo root: $repoRoot"
|
||||
Write-Host "[m7:preflight] server-rs: $serverRsDir"
|
||||
|
||||
Push-Location $serverRsDir
|
||||
try {
|
||||
Write-Host "[m7:preflight] step: cargo check -p spacetime-module"
|
||||
cargo check -p spacetime-module --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo check -p api-server"
|
||||
cargo check -p api-server --manifest-path $manifestPath
|
||||
|
||||
Write-Host "[m7:preflight] step: cargo test -p shared-contracts"
|
||||
cargo test -p shared-contracts --manifest-path $manifestPath
|
||||
|
||||
if ($RunSpacetimeBuild) {
|
||||
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
|
||||
if ($null -eq $spacetimeCommand) {
|
||||
throw "Missing spacetime CLI, cannot run spacetime build."
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] step: spacetime build --debug"
|
||||
Push-Location $modulePath
|
||||
try {
|
||||
& $spacetimeCommand.Source build --debug
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
if ($RunSmoke) {
|
||||
Write-Host "[m7:preflight] step: server-rs smoke"
|
||||
& (Join-Path $serverRsDir "scripts\smoke.ps1")
|
||||
}
|
||||
|
||||
Write-Host "[m7:preflight] all checks passed"
|
||||
@@ -17,9 +17,16 @@ export default defineConfig(({mode}) => {
|
||||
'**/public/generated-custom-world-scenes/**',
|
||||
'**/public/generated-qwen-sprites/**',
|
||||
];
|
||||
const runtimeServerTarget =
|
||||
const backendStack = (env.GENARRATIVE_BACKEND_STACK || 'node').trim().toLowerCase();
|
||||
const nodeServerTarget =
|
||||
env.NODE_SERVER_TARGET ||
|
||||
'http://127.0.0.1:8081';
|
||||
const rustServerTarget =
|
||||
env.RUST_SERVER_TARGET ||
|
||||
'http://127.0.0.1:3000';
|
||||
const runtimeServerTarget =
|
||||
env.GENARRATIVE_RUNTIME_SERVER_TARGET ||
|
||||
(backendStack === 'rust' ? rustServerTarget : nodeServerTarget);
|
||||
|
||||
return {
|
||||
root: __dirname,
|
||||
@@ -59,11 +66,21 @@ export default defineConfig(({mode}) => {
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-animations': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-custom-world-scenes': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-custom-world-covers': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/generated-qwen-sprites': {
|
||||
target: runtimeServerTarget,
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user