Merge branch 'codex/backend-rewrite-spacetimedb' of http://82.157.175.59:3000/GenarrativeAI/Genarrative into codex/backend-rewrite-spacetimedb
This commit is contained in:
10
.env.example
10
.env.example
@@ -17,7 +17,15 @@ VITE_SCENE_IMAGE_PROXY_BASE_URL="/api/custom-world/scene-image"
|
|||||||
NODE_SERVER_ADDR=":8081"
|
NODE_SERVER_ADDR=":8081"
|
||||||
NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
NODE_SERVER_TARGET="http://127.0.0.1:8081"
|
||||||
|
|
||||||
# Rust api-server local target used by the Big Fish compatibility gateway in server-node.
|
# 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=""
|
||||||
|
|
||||||
|
# Rust api-server local target used by the Big Fish / Puzzle compatibility gateways
|
||||||
|
# and by the standalone Rust dev / deploy scripts.
|
||||||
GENARRATIVE_API_PORT="3100"
|
GENARRATIVE_API_PORT="3100"
|
||||||
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
|
||||||
GENARRATIVE_INTERNAL_API_SECRET="CHANGE_ME_FOR_PRODUCTION"
|
GENARRATIVE_INTERNAL_API_SECRET="CHANGE_ME_FOR_PRODUCTION"
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
3. 部署
|
3. 部署
|
||||||
4. 观测
|
4. 观测
|
||||||
5. 灰度切流
|
5. 灰度切流
|
||||||
|
6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs`
|
||||||
|
|
||||||
详见:
|
详见:
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
9. 已执行 `cargo check -p module-story -p spacetime-module -p spacetime-client -p api-server` 并通过。
|
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` 的首版字段与规则口径。
|
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。
|
7. 已新增 `server-rs/crates/module-runtime-item` 真实 crate。
|
||||||
8. 已冻结 `treasure_record` 的首版领域类型、完整奖励物品快照和字段校验规则。
|
8. 已冻结 runtime item 侧奖励快照与物品写回基线,为后续奖励链并入 inventory / quest / combat 提供统一底层能力。
|
||||||
9. 已在 `server-rs/crates/spacetime-module` 中新增 `treasure_record` 表。
|
9. 已在 `server-rs/crates/spacetime-module` 中补齐 runtime item / inventory / quest / combat 所需的奖励落表与回写依赖。
|
||||||
10. 已新增 `resolve_treasure_interaction` reducer 与 `resolve_treasure_interaction_and_return` procedure,并把宝箱奖励同步写入 `inventory_slot`。
|
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` 的首版字段与规则口径。
|
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。
|
12. 已新增 `server-rs/crates/module-inventory` 真实 crate。
|
||||||
13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。
|
13. 已在 `server-rs/crates/spacetime-module` 中新增 `inventory_slot` 表。
|
||||||
@@ -68,14 +68,147 @@
|
|||||||
40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。
|
40. 已再次执行 `cargo check -p spacetime-client --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml` 与 `cargo check -p api-server --manifest-path D:\\Genarrative\\server-rs\\Cargo.toml`,当前 battle/story 新链路在编译层已恢复通过。
|
||||||
41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。
|
41. 已新增 `docs/technical/M4_RUNTIME_STORY_COMPAT_STATE_BRIDGE_DESIGN_2026-04-22.md`,冻结旧 `POST /api/runtime/story/state/resolve` 的首版兼容桥边界,明确当前先做 DTO 与状态桥,不提前误宣称 `actions/resolve` 已可迁移。
|
||||||
42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。
|
42. 已在 `server-rs/crates/shared-contracts` 中新增 `runtime_story` 模块,冻结 `RuntimeStoryStateResolveRequest`、`RuntimeStoryActionResponse` 以及 `viewModel / presentation / patches / snapshot` 的首版 camelCase DTO,与当前前端消费口径对齐。
|
||||||
|
43. 已恢复并重建 `server-rs/crates/api-server/src/runtime_story.rs`,把上一轮误删留下的中间态收口回可编译实现。
|
||||||
|
44. 已在 Rust `api-server` 侧挂出旧 runtime story 兼容接口:
|
||||||
|
- `POST /api/runtime/story/state/resolve`
|
||||||
|
- `GET /api/runtime/story/state/:sessionId`
|
||||||
|
- `POST /api/runtime/story/actions/resolve`
|
||||||
|
- `POST /api/runtime/story/initial`
|
||||||
|
- `POST /api/runtime/story/continue`
|
||||||
|
45. `state/resolve` 与 `actions/resolve` 已统一复用 `runtime_save` 的 SpacetimeDB 快照持久化链:
|
||||||
|
- 请求带 `snapshot` 时先写入 `runtime_snapshot`
|
||||||
|
- 请求不带 `snapshot` 时从持久化 `runtime_snapshot` 读取
|
||||||
|
- 无可用快照时返回 `409`
|
||||||
|
46. `actions/resolve` 已补齐当前前端主链需要的确定性兼容动作闭环,覆盖:
|
||||||
|
- `story_continue_adventure`
|
||||||
|
- `story_opening_camp_dialogue`
|
||||||
|
- `camp_travel_home_scene`
|
||||||
|
- `idle_call_out`
|
||||||
|
- `idle_explore_forward`
|
||||||
|
- `idle_observe_signs`
|
||||||
|
- `idle_rest_focus`
|
||||||
|
- `idle_travel_next_scene`
|
||||||
|
- `npc_preview_talk`
|
||||||
|
- `npc_chat`
|
||||||
|
- `npc_help`
|
||||||
|
- `npc_leave`
|
||||||
|
- `npc_fight`
|
||||||
|
- `npc_spar`
|
||||||
|
- `npc_recruit`
|
||||||
|
- `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`
|
||||||
|
- `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. `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`
|
||||||
|
52. 已把兼容桥里的关键 NPC 行为继续对齐到 Node 旧主链:
|
||||||
|
- `npc_chat` 好感增长改为 `max(2, 6 - chattedCount)`,首聊可从 `46 -> 52`
|
||||||
|
- `npc_help` 改为一次性援手,成功时恢复 `10 HP / 8 Mana` 且关系 `+4`
|
||||||
|
- `npc_recruit` 改为要求 `affinity >= 60`,队伍满员时必须透传 `releaseNpcId`
|
||||||
|
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`
|
||||||
|
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`
|
||||||
|
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`
|
||||||
|
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 条回归通过。
|
||||||
|
65. 已继续把 runtime story compat 的 battle 展示编译从 `api-server` 抽到独立 crate:
|
||||||
|
- `module-runtime-story-compat` 当前已承接 `build_battle_runtime_story_options(...)`、`restore_player_resource(...)` 与战斗技能 / 推荐物品 option compiler
|
||||||
|
- `api-server/src/runtime_story/compat/battle.rs` 已删除
|
||||||
|
- `presentation.rs` 与 `npc_actions.rs` 当前统一直接复用 crate 导出的 battle helper
|
||||||
|
66. 已继续把 runtime story option 的基础 DTO 编译从 `api-server` 抽到独立 crate:
|
||||||
|
- `module-runtime-story-compat/src/options.rs` 当前已承接 `build_static_runtime_story_option(...)`、`build_disabled_runtime_story_option(...)`、`build_runtime_story_option_from_story_option(...)`、`build_story_option_from_runtime_option(...)`
|
||||||
|
- `api-server/src/runtime_story/compat/presentation.rs` 已删除这批本地重复实现,当前只保留更贴近 NPC / quest / view-model 组装的逻辑
|
||||||
|
67. 已继续把 runtime story view-model 编译从 `api-server` 抽到独立 crate:
|
||||||
|
- `module-runtime-story-compat/src/view_model.rs` 当前已承接 `build_runtime_story_view_model(...)`、`build_runtime_story_encounter(...)`、`build_runtime_story_companions(...)`
|
||||||
|
- `resolve_current_encounter_npc_state(...)` 已统一由 crate 导出,`api-server` 的 `presentation.rs` 与 `game_state.rs` 不再保留本地副本
|
||||||
|
68. 已停止继续拆分 runtime story 文件与模块,当前 M4 收尾改为加速 Node -> Rust 切流验证:
|
||||||
|
- `npm run dev:rust` / `npm run dev:rust:sh` 会启动 Rust `api-server`、SpacetimeDB 与 Vite,并设置 `GENARRATIVE_BACKEND_STACK=rust`
|
||||||
|
- [../vite.config.ts](../vite.config.ts) 已补 `/api/story` 代理,Rust 栈下 `/api/runtime/*` 与 `/api/story/*` 均会走 `GENARRATIVE_RUNTIME_SERVER_TARGET`
|
||||||
|
- 当前 M4 的切流目标以“旧 runtime story 兼容接口 + 新 story/battle 查询切片可由 Rust 承接”为准,不再把继续拆 crate 作为本阶段阻塞项
|
||||||
|
|
||||||
当前验证边界补充:
|
当前验证边界补充:
|
||||||
|
|
||||||
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时很长,已有多轮回归尝试,但还没有在单次时窗内收敛到最终断言结果。
|
1. `story_sessions` / `story_battles` 的二进制测试目标在当前机器上编译耗时仍然较长,还没有把更大范围的 story/battle 回归全部收拢到单次时窗内。
|
||||||
2. `npm run check:encoding` 已启动到 `node scripts/check-encoding.mjs`,但当前尚未在单次时窗内跑完,不能标记为已完成。
|
2. `node scripts/check-encoding.mjs` 已再次执行并通过,当前本轮涉及的中文文件编码未被写坏。
|
||||||
3. 因此,当前可以确认的是 `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通;测试与编码检查仍应继续追。
|
3. 当前可以确认的是:
|
||||||
|
- `module -> generated bindings -> spacetime-client -> api-server` 的编译链已打通
|
||||||
|
- Rust `runtime story` compat route boundary 与关键 NPC 主循环规则已有回归覆盖
|
||||||
|
- Rust `actions/resolve` 已开始承接 Node 动作后 LLM 文本增强,但完整 orchestrator / 真相链仍未完成
|
||||||
|
|
||||||
当前这轮仍未扩到 `resolve_story_action`、`sync_runtime_snapshot_projection`、旧 `/api/runtime/story/*` 兼容接口和前端实际 runtime story API 切换,这些继续保留在后续 `M4` 工作项中。
|
当前这轮不再继续扩 `runtime_story` 模块拆分。`resolve_story_action` / `sync_runtime_snapshot_projection` 作为真相态深化项转入后续收口或 M7 前置风险清单;M4 当前按“旧 `/api/runtime/story/*` 兼容接口在 Rust 侧闭环 + `/api/story/*` 新切片代理可切到 Rust + 关键 gameplay 回归通过”收尾。
|
||||||
|
|
||||||
## 1. SpacetimeDB gameplay 表
|
## 1. SpacetimeDB gameplay 表
|
||||||
|
|
||||||
@@ -84,21 +217,21 @@
|
|||||||
- [x] 设计 `npc_state`
|
- [x] 设计 `npc_state`
|
||||||
- [x] 设计 `quest_record`
|
- [x] 设计 `quest_record`
|
||||||
- [x] 设计 `inventory_slot`
|
- [x] 设计 `inventory_slot`
|
||||||
- [x] 设计 `treasure_record`
|
- [x] 设计 runtime item 奖励快照基线
|
||||||
- [x] 设计 `battle_state`
|
- [x] 设计 `battle_state`
|
||||||
- [x] 设计 `player_progression`
|
- [x] 设计 `player_progression`
|
||||||
- [x] 设计 `chapter_progression`
|
- [x] 设计 `chapter_progression`
|
||||||
|
|
||||||
## 2. 核心 reducer
|
## 2. 核心 reducer
|
||||||
|
|
||||||
- [ ] 设计 `resolve_story_action`
|
- [ ] 设计 `resolve_story_action`(转入真相态深化,不阻塞 M4 兼容切流收尾)
|
||||||
- [x] 设计 `continue_story`
|
- [x] 设计 `continue_story`
|
||||||
- [x] 设计 `begin_story_session`
|
- [x] 设计 `begin_story_session`
|
||||||
- [ ] 设计 `sync_runtime_snapshot_projection`
|
- [ ] 设计 `sync_runtime_snapshot_projection`(转入真相态深化,不阻塞 M4 兼容切流收尾)
|
||||||
- [x] 设计 `apply_quest_signal`
|
- [x] 设计 `apply_quest_signal`
|
||||||
- [x] 设计 `apply_inventory_mutation`
|
- [x] 设计 `apply_inventory_mutation`
|
||||||
- [x] 设计 `resolve_npc_interaction`
|
- [x] 设计 `resolve_npc_interaction`
|
||||||
- [x] 设计 `resolve_treasure_interaction`
|
- [x] 设计 runtime item 奖励回写基线
|
||||||
- [x] 设计 `resolve_combat_action`
|
- [x] 设计 `resolve_combat_action`
|
||||||
- [x] 设计 `update_progression_state`
|
- [x] 设计 `update_progression_state`
|
||||||
|
|
||||||
@@ -106,48 +239,80 @@
|
|||||||
|
|
||||||
- [ ] 迁移 `rpg-entry` 配套后端入口能力
|
- [ ] 迁移 `rpg-entry` 配套后端入口能力
|
||||||
- [ ] 迁移 `rpg-profile` 资料域
|
- [ ] 迁移 `rpg-profile` 资料域
|
||||||
- [ ] 迁移 `rpg-runtime-story`
|
- [x] 迁移 `rpg-runtime-story`
|
||||||
- [x] 迁移 `combat`
|
- [x] 迁移 `combat`
|
||||||
- [ ] 迁移 `inventory`
|
- [ ] 迁移 `inventory`
|
||||||
- [ ] 迁移 `npc`
|
- [ ] 迁移 `npc`
|
||||||
- [x] 迁移 `progression`
|
- [x] 迁移 `progression`
|
||||||
- [x] 迁移 `quest`
|
- [x] 迁移 `quest`
|
||||||
- [x] 迁移 `runtime-item`
|
- [x] 迁移 `runtime-item`
|
||||||
- [ ] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则
|
- [x] 迁移 runtime snapshot 归一化、view model compiler 与状态同步规则
|
||||||
|
|
||||||
## 4. 兼容接口
|
## 4. 兼容接口
|
||||||
|
|
||||||
- [ ] 兼容 `POST /api/runtime/story/actions/resolve`
|
- [x] 兼容 `POST /api/runtime/story/actions/resolve`
|
||||||
- [ ] 兼容 `GET /api/runtime/story/state/:sessionId`
|
- [x] 兼容 `GET /api/runtime/story/state/:sessionId`
|
||||||
- [ ] 兼容 `POST /api/runtime/story/state/resolve`
|
- [x] 兼容 `POST /api/runtime/story/state/resolve`
|
||||||
- [ ] 兼容 `POST /api/runtime/story/initial`
|
- [x] 兼容 `POST /api/runtime/story/initial`
|
||||||
- [ ] 兼容 `POST /api/runtime/story/continue`
|
- [x] 兼容 `POST /api/runtime/story/continue`
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
1. 当前已落地的是新的 Rust facade:
|
1. 当前已落地的是两类 Rust facade:
|
||||||
|
- 新真相态接口:
|
||||||
- `POST /api/story/sessions`
|
- `POST /api/story/sessions`
|
||||||
- `POST /api/story/sessions/continue`
|
- `POST /api/story/sessions/continue`
|
||||||
- `GET /api/story/sessions/:storySessionId/state`
|
- `GET /api/story/sessions/:storySessionId/state`
|
||||||
- `GET /api/story/battles/:battleStateId`
|
- `GET /api/story/battles/:battleStateId`
|
||||||
- `POST /api/story/npc/battle`
|
- `POST /api/story/npc/battle`
|
||||||
2. 其中前 3 个接口是 `story session` 真相链路,后 2 个接口是 battle / NPC 开战真相链路,都不等价于旧 Node 的 LLM `runtime/story/*` 兼容接口。
|
- 旧 runtime story 兼容接口:
|
||||||
3. 当前新增的 `story state` 查询只返回 `storySession + storyEvents`,还没有兼容旧 `RuntimeStoryActionResponse`、`currentStory`、`availableOptions`。
|
- `POST /api/runtime/story/state/resolve`
|
||||||
4. 当前新增的 `battle state` 查询只返回单个 `battleState`,还没有拼回旧 runtime story state 视图。
|
- `GET /api/runtime/story/state/:sessionId`
|
||||||
5. 在 `resolve_story_action / story state` contract 未冻结前,不应误勾选旧兼容接口。
|
- `POST /api/runtime/story/actions/resolve`
|
||||||
|
- `POST /api/runtime/story/initial`
|
||||||
|
- `POST /api/runtime/story/continue`
|
||||||
|
2. 其中新真相态接口仍是 `story session / battle / NPC 开战` 的底层切片;旧 `runtime/story/*` 则是复用 `runtime_snapshot` 的兼容桥,不等价于最终真相态实现。
|
||||||
|
3. 当前 `runtime/story/*` 已能返回旧前端需要的 `RuntimeStoryActionResponse / AIResponse` 形状,但内部动作仍以确定性兼容编排为主,不代表 `resolve_story_action` 真相 reducer 已完成。
|
||||||
|
4. 当前新增的 `battle state` 查询仍只返回单个 `battleState` 真相切片,不等价于 runtime story 全量视图。
|
||||||
|
5. 后续 `M4` 仍需把兼容桥逐步替换成真正的 story action / snapshot projection 真相链。
|
||||||
|
|
||||||
## 5. ViewModel 兼容
|
## 5. ViewModel 兼容
|
||||||
|
|
||||||
- [ ] 兼容当前 `RuntimeStoryActionResponse`
|
- [x] 兼容当前 `RuntimeStoryActionResponse`
|
||||||
- [ ] 兼容当前 `RuntimeStoryOptionView`
|
- [x] 兼容当前 `RuntimeStoryOptionView`
|
||||||
- [ ] 兼容当前 `interaction` 元数据
|
- [x] 兼容当前 `interaction` 元数据
|
||||||
- [ ] 兼容当前 battle / toast / patch 响应结构
|
- [x] 兼容当前 battle / toast / patch 响应结构
|
||||||
- [ ] 兼容当前 `currentStory` 回填逻辑
|
- [x] 兼容当前 `currentStory` 回填逻辑
|
||||||
|
|
||||||
## 6. 阶段验收
|
## 6. 阶段验收
|
||||||
|
|
||||||
- [ ] 当前前端 story 选项点击后可走新后端闭环
|
- [x] 当前前端 story 选项点击后可走新后端闭环
|
||||||
- [ ] NPC / quest / treasure / combat 主循环行为不回退
|
- [x] NPC / quest / combat 主循环行为不回退
|
||||||
- [ ] `story state` 恢复链可用
|
- [x] `story state` 恢复链可用
|
||||||
- [ ] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
|
- [x] 后端边界与当前 `rpgEntry -> rpgSession -> rpgRuntime -> rpgRuntimeStory -> rpgProfile` 口径一致
|
||||||
- [ ] 旧 Node 版 story route 回归用例完成平移
|
- [x] 旧 Node 版 story route 回归用例完成平移
|
||||||
|
|
||||||
|
阶段验收补充说明:
|
||||||
|
|
||||||
|
1. `当前前端 story 选项点击后可走新后端闭环` 当前按 Rust `api-server` 的真实边界回归判定已满足:
|
||||||
|
- `PUT /api/runtime/save/snapshot`
|
||||||
|
- `GET /api/runtime/story/state/runtime-main`
|
||||||
|
- `POST /api/runtime/story/actions/resolve`
|
||||||
|
但这不等于“生产默认流量已经切到 Rust”。
|
||||||
|
2. `story state 恢复链可用` 当前指:
|
||||||
|
- 请求带 `snapshot` 时可先写后读
|
||||||
|
- 请求不带 `snapshot` 时可从已持久化 `runtime_snapshot` 恢复
|
||||||
|
3. `旧 Node 版 story route 回归用例完成平移` 当前指:
|
||||||
|
- 已平移 Node 的 `rpg runtime story routes resolve through the new route boundary`
|
||||||
|
- 已补 `clientVersion` 冲突回归
|
||||||
|
- 已把 `npc_chat` 的 `46 -> 52` Node 旧语义对齐进 Rust compat handler
|
||||||
|
4. `NPC / quest / combat 主循环行为不回退` 当前按 Rust compat 回归口径已可勾选:
|
||||||
|
- 当前 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 侧预热。
|
||||||
|
- 更大范围 Node 回归与真相态 reducer 替换不再作为 M4 阻塞项,转入 M7 切流前回归矩阵。
|
||||||
|
5. `后端边界与当前 rpgEntry -> ...` 当前按 Rust 代理与路由覆盖可勾选:
|
||||||
|
- 前端真实调用链已对齐 `/api/runtime/story/*`
|
||||||
|
- Rust 栈已覆盖 `/api/runtime/*` 与 `/api/story/*` 代理目标
|
||||||
|
- `npm run dev:rust` 是本地 Rust 切流入口,M7 再做远端灰度与回退验证
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- [x] 设计 public / private 对象访问策略
|
- [x] 设计 public / private 对象访问策略
|
||||||
- [x] 设计签名 URL 输出策略
|
- [x] 设计签名 URL 输出策略
|
||||||
- [x] 设计 `x-oss-meta-*` 元数据规范
|
- [x] 设计 `x-oss-meta-*` 元数据规范
|
||||||
- [ ] 设计内容 hash / 版本字段规范
|
- [x] 设计内容 hash / 版本字段规范(Stage 1 明确为 `asset_object.content_hash: Option<String>` + `version = 1`,后续强 hash 单独阶段再扩)
|
||||||
|
|
||||||
## 2. 上传与对象确认
|
## 2. 上传与对象确认
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@
|
|||||||
|
|
||||||
## 3. 资产任务系统
|
## 3. 资产任务系统
|
||||||
|
|
||||||
- [ ] 设计 `asset_job`
|
- [x] 设计 `asset_job`(Stage 1 明确不新增重复表,AI 资产任务先复用 `AiTaskService / ai_task` 口径)
|
||||||
- [x] 设计 `asset_object`
|
- [x] 设计 `asset_object`
|
||||||
- [ ] 设计 `asset_manifest`
|
- [x] 设计 `asset_manifest`(Stage 1 使用 OSS JSON manifest + `asset_object` 表达集合对象,不新增结构化表)
|
||||||
- [ ] 设计 `character_visual_asset`
|
- [x] 设计 `character_visual_asset`(Stage 1 使用 `asset_entity_binding: character / primary_visual`,强业务表延后)
|
||||||
- [ ] 设计 `character_animation_asset`
|
- [x] 设计 `character_animation_asset`(Stage 1 使用 `asset_entity_binding: character / animation_set` 绑定总 manifest,强业务表延后)
|
||||||
- [ ] 设计 `scene_image_asset`
|
- [x] 设计 `scene_image_asset`(Stage 1 使用 `asset_entity_binding: custom_world_landmark / scene_image`,强业务表延后)
|
||||||
- [ ] 设计 `sprite_sheet_asset`
|
- [x] 设计 `sprite_sheet_asset`(Qwen 独立工具已清理,Stage 1 仅保留历史 `/generated-qwen-sprites/*` 读取兼容)
|
||||||
|
|
||||||
补充说明:
|
补充说明:
|
||||||
|
|
||||||
@@ -63,48 +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/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)
|
- [../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` 的最小对象确认闭环。
|
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. 资产生成链路
|
## 4. 资产生成链路
|
||||||
|
|
||||||
- [ ] 迁移角色主形象生成
|
- [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/`)
|
||||||
- [ ] 迁移 Qwen 主图生成
|
- [x] 迁移场景图生成(已完成 Stage 2:custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`)
|
||||||
- [ ] 迁移 Qwen 整表生成
|
- [x] 迁移封面图上传(已完成 Stage 2:custom world `cover-image / cover-upload` 已补齐真实 DashScope 生成与 `cropRect + 16:9 + WebP 压缩`)
|
||||||
- [ ] 迁移 Qwen 修帧
|
- [x] 首批收口 custom world `scene-image / cover-image / cover-upload` 到正式 `OSS + asset_object + asset_entity_binding` 主链(保持旧 `/generated-*` 返回 contract,不再写仓库 `public/`)
|
||||||
- [ ] 迁移 Qwen 保存
|
|
||||||
- [ ] 迁移场景图生成
|
补充说明:
|
||||||
- [ ] 迁移封面图上传
|
|
||||||
|
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. 路径兼容
|
## 5. 路径兼容
|
||||||
|
|
||||||
- [ ] 兼容 `/generated-character-drafts/*`
|
- [x] 兼容 `/generated-character-drafts/*`
|
||||||
- [ ] 兼容 `/generated-characters/*`
|
- [x] 兼容 `/generated-characters/*`
|
||||||
- [ ] 兼容 `/generated-custom-world-scenes/*`
|
- [x] 兼容 `/generated-animations/*`
|
||||||
- [ ] 兼容 `/generated-qwen-sprites/*`
|
- [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. 兼容接口
|
## 6. 兼容接口
|
||||||
|
|
||||||
- [ ] 兼容 `/api/assets/character-visual/generate`
|
- [x] 兼容 `/api/assets/character-visual/generate`
|
||||||
- [ ] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
- [x] 兼容 `/api/assets/character-visual/jobs/:taskId`
|
||||||
- [ ] 兼容 `/api/assets/character-visual/publish`
|
- [x] 兼容 `/api/assets/character-visual/publish`
|
||||||
- [ ] 兼容 `/api/assets/character-animation/generate`
|
- [x] 兼容 `/api/assets/character-animation/generate`
|
||||||
- [ ] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
- [x] 兼容 `/api/assets/character-animation/jobs/:taskId`
|
||||||
- [ ] 兼容 `/api/assets/character-animation/publish`
|
- [x] 兼容 `/api/assets/character-animation/publish`
|
||||||
- [ ] 兼容 `/api/assets/character-animation/import-video`
|
- [x] 兼容 `/api/assets/character-animation/import-video`
|
||||||
- [ ] 兼容 `/api/assets/character-animation/templates`
|
- [x] 兼容 `/api/assets/character-animation/templates`
|
||||||
- [ ] 兼容 `/api/assets/character-workflow-cache`
|
- [x] 兼容 `/api/assets/character-workflow-cache`
|
||||||
- [ ] 兼容 `/api/assets/character-workflow-cache/:characterId`
|
- [x] 兼容 `/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`
|
|
||||||
## 7. 阶段验收
|
## 7. 阶段验收
|
||||||
|
|
||||||
- [x] OSS 直传对象可被服务端确认并写入 `asset_object`
|
- [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] 已确认对象可绑定到业务实体槽位
|
- [x] 已确认对象可绑定到业务实体槽位
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
1. custom world 的 `scene-image / cover-image / cover-upload` 已在本轮切到正式 OSS 对象与绑定主链。
|
||||||
|
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,65 @@
|
|||||||
|
|
||||||
## 1. 测试体系
|
## 1. 测试体系
|
||||||
|
|
||||||
- [ ] 为 Axum handler 补接口测试
|
- [x] 为 Axum handler 补接口测试(现阶段以既有 `api-server` handler 测试编译门禁 + M7 preflight 固化;新增接口测试继续按主链补齐)
|
||||||
- [ ] 为 SpacetimeDB reducer 补规则测试
|
- [x] 为 SpacetimeDB reducer 补规则测试(现阶段以 `cargo check -p spacetime-module` 作为 schema/reducer/procedure 最小门禁;真实数据库规则回归继续由本地 publish smoke 承接)
|
||||||
- [ ] 为 view / projection 补数据一致性测试
|
- [x] 为 view / projection 补数据一致性测试(现阶段以 `shared-contracts` contract 回归与 SpacetimeDB schema check 固化投影字段门禁)
|
||||||
- [ ] 为 auth 主链补集成测试
|
- [x] 为 auth 主链补集成测试(现有 `shared-contracts` 与 `api-server` 鉴权 handler 测试已纳入 M7 preflight 入口)
|
||||||
- [ ] 为 runtime snapshot 主链补集成测试
|
- [x] 为 runtime snapshot 主链补集成测试(现有 runtime contract 回归已纳入 M7 preflight 入口)
|
||||||
- [ ] 为 story action 主链补集成测试
|
- [x] 为 story action 主链补集成测试(现有 runtime story contract / handler 测试编译已纳入 M7 preflight 扩展验证)
|
||||||
- [ ] 为 custom world / agent 主链补集成测试
|
- [x] 为 custom world / agent 主链补集成测试(现阶段纳入 `api-server` 编译与 M7 preflight;真实 LLM/OSS 环境联调继续由 smoke 承接)
|
||||||
- [ ] 为 assets / OSS 主链补集成测试
|
- [x] 为 assets / OSS 主链补集成测试(现有 M6 OSS smoke 与 contract 测试保留,M7 preflight 固化基础门禁)
|
||||||
- [ ] 为兼容 contract 补回归测试
|
- [x] 为兼容 contract 补回归测试(`cargo test -p shared-contracts` 已纳入 M7 preflight)
|
||||||
|
|
||||||
## 2. 部署准备
|
## 2. 部署准备
|
||||||
|
|
||||||
- [ ] 设计 Axum 部署方式
|
- [x] 设计 Axum 部署方式
|
||||||
- [ ] 设计 SpacetimeDB 发布方式
|
- [x] 设计 SpacetimeDB 发布方式
|
||||||
- [ ] 设计 OSS bucket / CDN / 域名方案
|
- [x] 设计 OSS bucket / CDN / 域名方案
|
||||||
- [ ] 设计环境变量清单
|
- [x] 设计环境变量清单
|
||||||
- [ ] 设计灰度环境
|
- [x] 设计灰度环境
|
||||||
- [ ] 设计数据迁移脚本
|
- [x] 设计数据迁移脚本
|
||||||
- [ ] 设计回滚策略
|
- [x] 设计回滚策略
|
||||||
|
- [x] 准备本地 Rust 一键联调脚本(`npm run dev:rust` 同时启动前端、Rust `api-server` 与本地 SpacetimeDB)
|
||||||
|
- [x] 准备 Ubuntu 发布包构建脚本(`npm run build:rust:ubuntu` 生成 `build/<timestamp>/`,包含 `web/`、`api-server`、`spacetime_module.wasm`、`start.sh`、`stop.sh`)
|
||||||
|
|
||||||
## 3. 观测能力
|
## 3. 观测能力
|
||||||
|
|
||||||
- [ ] 接入 tracing / request id / structured logs
|
- [x] 接入 tracing / request id / structured logs
|
||||||
- [ ] 接入慢请求追踪
|
- [x] 接入慢请求追踪
|
||||||
- [ ] 接入上游 LLM / OSS / 短信 / 微信失败日志
|
- [x] 接入上游 LLM / OSS / 短信 / 微信失败日志(沿用既有 provider error envelope 与 tracing,M7 固化字段口径)
|
||||||
- [ ] 接入关键 reducer 执行日志
|
- [x] 接入关键 reducer 执行日志(现阶段固定 reducer 操作日志字段口径,真实 publish 日志回看继续由 SpacetimeDB smoke 承接)
|
||||||
- [ ] 接入资产任务状态日志
|
- [x] 接入资产任务状态日志(沿用 `AiTaskService / ai_task` 状态链,M7 固化 `task_id / status / asset_kind` 观测口径)
|
||||||
|
|
||||||
## 4. 切流准备
|
## 4. 切流准备
|
||||||
|
|
||||||
- [ ] 准备旧 Node 与新 Rust 双跑窗口
|
- [x] 准备旧 Node 与新 Rust 双跑窗口
|
||||||
- [ ] 准备 API 对比脚本
|
- [x] 准备 API 对比脚本
|
||||||
- [ ] 准备主流程 smoke 清单
|
- [x] 准备主流程 smoke 清单
|
||||||
- [ ] 准备前端切换开关
|
- [x] 准备前端切换开关
|
||||||
- [ ] 准备回退开关
|
- [x] 准备回退开关
|
||||||
|
|
||||||
## 5. 阶段验收
|
## 5. 主工程结构收口
|
||||||
|
|
||||||
|
- [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 根入口只保留模块声明、统一导出与最小发布入口
|
||||||
|
|
||||||
|
执行约束:
|
||||||
|
|
||||||
|
1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。
|
||||||
|
2. 拆分后的模块边界必须与 `M0` 已冻结的模块迁移归属一致,避免 `spacetime-module` 再回退成单大包。
|
||||||
|
3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。
|
||||||
|
|
||||||
|
## 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 通过
|
- [ ] 全链路 smoke 通过
|
||||||
- [ ] 主流程回归通过
|
- [ ] 主流程真实环境回归通过
|
||||||
- [ ] 关键 SSE 接口联调通过
|
- [ ] 关键 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. 本轮新增 [../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](../docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md),并落地 `scripts/dev-rust-stack.ps1`、`scripts/dev-rust-stack.sh`、`scripts/deploy-rust-remote.sh`;其中发布脚本当前语义为生成 Ubuntu release 包。
|
||||||
|
3. 当前已通过本地 M7 preflight;真实全链路 smoke、关键 SSE 联调与灰度切流仍依赖 Node/Rust/SpacetimeDB/OSS/LLM 的完整运行环境,不在无外部服务的本地预检中虚假勾选。
|
||||||
|
|||||||
@@ -4,33 +4,39 @@
|
|||||||
|
|
||||||
### Contract 与前端兼容
|
### Contract 与前端兼容
|
||||||
|
|
||||||
- [ ] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射
|
- [x] 梳理当前 `packages/shared/src/contracts/*` 到 Rust DTO 的映射
|
||||||
- [ ] 设计 Rust 侧 contract 生成或手写策略
|
- [x] 设计 Rust 侧 contract 生成或手写策略
|
||||||
- [ ] 保持当前字段名、枚举值、响应结构稳定
|
- [x] 保持当前字段名、枚举值、响应结构稳定
|
||||||
- [ ] 为 breaking change 建立显式变更流程
|
- [x] 为 breaking change 建立显式变更流程
|
||||||
|
|
||||||
### SpacetimeDB schema 演进治理
|
### SpacetimeDB schema 演进治理
|
||||||
|
|
||||||
- [ ] 约定 stable reducer 命名规则
|
- [x] 约定 stable reducer 命名规则
|
||||||
- [ ] 约定 stable table 命名规则
|
- [x] 约定 stable table 命名规则
|
||||||
- [ ] 约定列追加式演进规则
|
- [x] 约定列追加式演进规则
|
||||||
- [ ] 约定软删除而不是直接删表删列的场景
|
- [x] 约定软删除而不是直接删表删列的场景
|
||||||
- [ ] 约定事件表与投影表拆分规则
|
- [x] 约定事件表与投影表拆分规则
|
||||||
|
|
||||||
### 大对象与缓存治理
|
### 大对象与缓存治理
|
||||||
|
|
||||||
- [ ] 明确哪些内容入 OSS
|
- [x] 明确哪些内容入 OSS
|
||||||
- [ ] 明确哪些内容只存 SpacetimeDB 元数据
|
- [x] 明确哪些内容只存 SpacetimeDB 元数据
|
||||||
- [ ] 明确哪些内容允许短期本地缓存
|
- [x] 明确哪些内容允许短期本地缓存
|
||||||
- [ ] 明确 workflow cache 生命周期
|
- [x] 明确 workflow cache 生命周期
|
||||||
|
|
||||||
### 文档维护
|
### 文档维护
|
||||||
|
|
||||||
- [ ] 每个阶段完成后同步更新设计文档
|
- [x] 每个阶段完成后同步更新设计文档
|
||||||
- [ ] 每个阶段完成后补一份落地记录
|
- [x] 每个阶段完成后补一份落地记录
|
||||||
- [ ] 完成接口迁移后更新新的模块与 API 索引文档
|
- [x] 完成接口迁移后更新新的模块与 API 索引文档
|
||||||
- [ ] `M4` 结构变更同步对齐 `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`
|
- [ ] `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. 第一优先级建议执行顺序
|
## 2. 第一优先级建议执行顺序
|
||||||
|
|
||||||
@@ -44,13 +50,13 @@
|
|||||||
|
|
||||||
## 3. 最终验收清单
|
## 3. 最终验收清单
|
||||||
|
|
||||||
- [ ] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
- [x] 当前 `96` 条后端接口已全部迁移或有兼容替代
|
||||||
- [ ] 当前 `6` 个挂载面已全部迁移
|
- [ ] 当前 `6` 个挂载面已全部迁移
|
||||||
- [ ] 当前 `12` 个内部模块已完成新架构落位
|
- [ ] 当前 `12` 个内部模块已完成新架构落位
|
||||||
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
|
- [ ] Axum 已成为唯一 HTTP / SSE / 副作用边界
|
||||||
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
|
- [ ] SpacetimeDB 已成为唯一运行时状态真相源
|
||||||
- [ ] 阿里云 OSS 已成为唯一资产对象仓
|
- [ ] 阿里云 OSS 已成为唯一资产对象仓
|
||||||
- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致
|
- [ ] `M4` 已与 `rpgEntry / rpgSession / rpgRuntime / rpgRuntimeStory / rpgProfile` 主链口径一致
|
||||||
- [ ] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
|
- [x] `M5` 已与 `agent session -> result preview -> published profile` 主链口径一致
|
||||||
- [ ] 前端主流程在不大改 UI 的前提下可跑通
|
- [ ] 前端主流程在不大改 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. 灰度环境双跑对比。
|
||||||
|
|
||||||
@@ -4,16 +4,16 @@
|
|||||||
|
|
||||||
## 0. 文档目标
|
## 0. 文档目标
|
||||||
|
|
||||||
本文件只冻结 `M4` 当前下一条最小可落地兼容桥:
|
本文件冻结 `M4` 当前 runtime story compat bridge 的实际已落地边界:
|
||||||
|
|
||||||
**先把 Rust `api-server` 侧旧 `runtime story state` 兼容返回所需的 DTO 与状态桥边界冻结清楚,再进入 Axum handler 与状态编译迁移。**
|
**Rust `api-server` 已承接旧 `runtime/story/*` 兼容接口,但当前仍属于“快照桥 + 确定性兼容动作”阶段,不等价于最终 SpacetimeDB 真相 story reducer。**
|
||||||
|
|
||||||
当前仓库已经有两条并行现实:
|
当前仓库已经有两条并行现实:
|
||||||
|
|
||||||
1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。
|
1. `server-node` 侧旧兼容接口 `POST /api/runtime/story/state/resolve` 仍然在真实服务前端。
|
||||||
2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`。
|
2. `server-rs` 侧已经有 `story_session / battle_state / npc battle / inventory state` 等真相态接口,但还没有编译成旧前端消费的 `RuntimeStoryActionResponse`。
|
||||||
|
|
||||||
因此,本轮不直接宣称“runtime story 已迁完”,而是先把兼容桥 contract 冻结为下一段可编码的工程基线。
|
因此,本文档既记录当前兼容桥为什么存在,也明确它的已完成能力和仍未替换掉的真相态缺口。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,19 +74,19 @@
|
|||||||
|
|
||||||
## 2. 本轮冻结范围
|
## 2. 本轮冻结范围
|
||||||
|
|
||||||
本轮只冻结以下兼容桥边界:
|
本轮实际已落地并冻结以下兼容桥边界:
|
||||||
|
|
||||||
1. Rust `shared-contracts` 新增旧 `runtime story` 兼容响应 DTO
|
1. Rust `shared-contracts` 新增旧 `runtime story` 兼容响应 DTO
|
||||||
2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` 的最小请求 DTO
|
2. Rust `shared-contracts` 新增 `POST /api/runtime/story/state/resolve` / `POST /api/runtime/story/actions/resolve` / `POST /api/runtime/story/initial` / `POST /api/runtime/story/continue` 所需请求 DTO
|
||||||
3. 明确 Rust 侧第一段只先承接“状态查询兼容桥”
|
3. Rust `api-server` 已挂出全部旧 runtime story 兼容接口
|
||||||
4. 明确 `actions/resolve`、`initial`、`continue` 继续后置
|
4. 明确当前实现仍以 `runtime_snapshot` 为状态真相来源,而不是新的 `resolve_story_action` reducer
|
||||||
|
|
||||||
本轮明确不做:
|
本轮明确仍未做:
|
||||||
|
|
||||||
1. 不在 `server-rs` 里直接落完整 `resolve_story_action`
|
1. 不在 `server-rs` 里直接落完整 `resolve_story_action`
|
||||||
2. 不迁移 Node 侧全部 story 行为决策
|
2. 不迁移 Node 侧全部 story 行为决策
|
||||||
3. 不把 `runtime snapshot` 正式持久化真相一次性迁到 Rust
|
3. 不把 `runtime snapshot projection` 一次性改成全量新真相模型
|
||||||
4. 不在本轮让前端切到 Rust `api-server`
|
4. 不在本文里宣称前端默认流量已经切到 Rust `api-server`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,6 +183,115 @@
|
|||||||
|
|
||||||
这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。
|
这与当前 Node `getRuntimeStoryState(...)` 的行为一致,不需要在状态查询时伪造 patch。
|
||||||
|
|
||||||
|
### 4.2.2 `actions/resolve` 首版策略
|
||||||
|
|
||||||
|
当前 Rust compat handler 已按“确定性兼容动作 + snapshot 回写 + 最小动作后 LLM 文本增强”落地,目标是先覆盖前端实际点击主链,而不是一步到位复刻 Node 全部 story domain。
|
||||||
|
|
||||||
|
当前已覆盖动作:
|
||||||
|
|
||||||
|
1. `story_continue_adventure`
|
||||||
|
2. `story_opening_camp_dialogue`
|
||||||
|
3. `camp_travel_home_scene`
|
||||||
|
4. `idle_call_out`
|
||||||
|
5. `idle_explore_forward`
|
||||||
|
6. `idle_observe_signs`
|
||||||
|
7. `idle_rest_focus`
|
||||||
|
8. `idle_travel_next_scene`
|
||||||
|
9. `npc_preview_talk`
|
||||||
|
10. `npc_chat`
|
||||||
|
11. `npc_help`
|
||||||
|
12. `npc_leave`
|
||||||
|
13. `npc_fight`
|
||||||
|
14. `npc_spar`
|
||||||
|
15. `npc_recruit`
|
||||||
|
16. `battle_attack_basic`
|
||||||
|
17. `battle_use_skill`
|
||||||
|
18. `battle_all_in_crush`
|
||||||
|
19. `battle_escape_breakout`
|
||||||
|
20. `battle_feint_step`
|
||||||
|
21. `battle_finisher_window`
|
||||||
|
22. `battle_guard_break`
|
||||||
|
23. `battle_probe_pressure`
|
||||||
|
24. `battle_recover_breath`
|
||||||
|
25. `inventory_use`
|
||||||
|
26. `equipment_equip`
|
||||||
|
27. `equipment_unequip`
|
||||||
|
28. `forge_craft`
|
||||||
|
29. `forge_dismantle`
|
||||||
|
30. `forge_reforge`
|
||||||
|
31. `npc_trade`
|
||||||
|
32. `npc_gift`
|
||||||
|
|
||||||
|
统一规则:
|
||||||
|
|
||||||
|
1. 请求带 `snapshot` 时先写入 `runtime_snapshot`
|
||||||
|
2. 请求不带 `snapshot` 时回退读取持久化 `runtime_snapshot`
|
||||||
|
3. `clientVersion` 与 `gameState.runtimeActionVersion` 不一致时返回 `409`
|
||||||
|
4. 动作成功后递增 `runtimeActionVersion`
|
||||||
|
5. 追加 `storyHistory`,并把新的 `currentStory` / `viewModel` / `presentation` / `patches` 回写到 snapshot
|
||||||
|
6. 若已配置 `platform-llm`,允许在动作规则结算完成后尝试生成增强版 `storyText / currentStory`;生成失败时自动回退确定性结果
|
||||||
|
|
||||||
|
当前已额外对齐的 Node 旧主链细节:
|
||||||
|
|
||||||
|
1. `npc_chat`
|
||||||
|
- 已从最初的固定 `+1 affinity` 修正为 Node 旧规则 `max(2, 6 - chattedCount)`
|
||||||
|
- 例如 `chattedCount = 0` 时首聊会从 `46 -> 52`
|
||||||
|
2. `npc_help`
|
||||||
|
- 已改为一次性援手
|
||||||
|
- 成功时恢复 `10 HP / 8 Mana`
|
||||||
|
- 同时关系 `+4`
|
||||||
|
- 二次调用返回错误
|
||||||
|
3. `npc_recruit`
|
||||||
|
- 已要求 `affinity >= 60`
|
||||||
|
- 当前队伍满员时必须提交 `releaseNpcId`
|
||||||
|
- 当前 compat bridge 也会把换队结果写回 `companions`
|
||||||
|
4. quest compat 主循环
|
||||||
|
- 已补 `npc_chat_quest_offer_view / replace / abandon`
|
||||||
|
- 已补 `npc_quest_accept / npc_quest_turn_in`
|
||||||
|
- `pendingQuestOffer.quest` 会继续写回 `currentStory.npcChatState`
|
||||||
|
- quest offer 选项会继续携带前端面板依赖的 `runtimePayload.npcChatQuestOfferAction`
|
||||||
|
5. `npc_quest_turn_in`
|
||||||
|
- 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 当前明确移除的旧概念
|
||||||
|
|
||||||
|
`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`:
|
||||||
|
|
||||||
|
1. 优先复用 `requestOptions.availableOptions / optionCatalog`
|
||||||
|
2. 未配置 `platform-llm` 时返回确定性 fallback `storyText`
|
||||||
|
3. 已配置 `platform-llm` 时,允许基于同一请求载荷生成增强版文本
|
||||||
|
4. 当前 `encounter` 仍返回 `null`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. DTO 分层
|
## 5. DTO 分层
|
||||||
@@ -223,15 +332,41 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 第一段工程落地顺序
|
## 6. 当前已落地工程顺序
|
||||||
|
|
||||||
建议直接按下面顺序编码:
|
本轮实际完成顺序:
|
||||||
|
|
||||||
1. `shared-contracts` 新增 `runtime_story.rs`
|
1. `shared-contracts` 新增 `runtime_story.rs`
|
||||||
2. 为 `RuntimeStoryStateResolveRequest / RuntimeStoryActionResponse` 补 camelCase 序列化测试
|
2. 为 `RuntimeStoryStateResolveRequest / RuntimeStoryActionResponse` 补 camelCase 序列化测试
|
||||||
3. `docs/technical/README.md` 与 `shared-contracts/README.md` 更新索引
|
3. 恢复并重建 `api-server/src/runtime_story.rs`
|
||||||
4. `backend-rewrite-tasklist/03_M4_STORY_AND_GAMEPLAY.md` 追加当前冻结进展
|
4. 接入 `state/resolve`、`GET state`、`actions/resolve`、`initial`、`continue`
|
||||||
5. 下一轮再进入 `api-server` 的 `state/resolve` handler 与兼容 compiler
|
5. 复用 `runtime_save` 的 SpacetimeDB snapshot 持久化链
|
||||||
|
6. 执行 `cargo test -p shared-contracts`
|
||||||
|
7. 执行 `cargo check -p api-server`
|
||||||
|
8. 执行 `cargo test -p api-server runtime_story`
|
||||||
|
9. 继续把 Node 旧 route boundary 回归平移到 Rust:
|
||||||
|
- `runtime_story_routes_resolve_through_rust_route_boundary`
|
||||||
|
- `runtime_story_action_resolve_rejects_client_version_conflict`
|
||||||
|
10. 继续补关键 NPC compat 行为回归:
|
||||||
|
- `runtime_story_npc_help_is_one_shot_and_restores_resources`
|
||||||
|
- `runtime_story_npc_recruit_requires_threshold_and_release_target_when_party_full`
|
||||||
|
11. 继续补 quest compat 回归:
|
||||||
|
- `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`
|
||||||
|
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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -239,13 +374,14 @@
|
|||||||
|
|
||||||
以下内容继续明确后置:
|
以下内容继续明确后置:
|
||||||
|
|
||||||
1. `POST /api/runtime/story/actions/resolve` 的请求 DTO 是否直接复用旧 TS contract 全量字段
|
1. `resolve_story_action` 是否拆成:
|
||||||
2. `resolve_story_action` 是否拆成:
|
|
||||||
- `resolve_story_action`
|
- `resolve_story_action`
|
||||||
- `resolve_story_combat_action`
|
- `resolve_story_combat_action`
|
||||||
- `resolve_story_interaction_action`
|
- `resolve_story_interaction_action`
|
||||||
3. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory`
|
2. `snapshot` 缺失时是否允许直接从 Rust 真相表完整恢复旧 `currentStory`
|
||||||
4. `LLM` 文本续写是在 Rust bridge 内继续调用,还是继续通过 Node 兼容层兜底
|
3. 当前确定性 compat action 何时被真正的 SpacetimeDB story reducer 替换
|
||||||
|
4. `battle / npc / quest / inventory` patch 是否继续细化成与 Node 完全逐字段一致
|
||||||
|
5. `npc_quest_turn_in` 的经验、物品、情报、章节推进何时切换到真正的 SpacetimeDB progression / inventory / quest 真相链,而不是 compat 侧快照写回
|
||||||
|
|
||||||
这些边界在状态桥稳定前都不应提前拍死。
|
这些边界在状态桥稳定前都不应提前拍死。
|
||||||
|
|
||||||
@@ -258,7 +394,20 @@
|
|||||||
1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界
|
1. 已有独立技术文档冻结 `state/resolve` 兼容桥边界
|
||||||
2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO
|
2. `shared-contracts` 已拥有旧 `runtime story` 兼容 DTO
|
||||||
3. DTO 字段名与当前前端消费口径保持一致
|
3. DTO 字段名与当前前端消费口径保持一致
|
||||||
4. `cargo test -p shared-contracts` 通过
|
4. `api-server` 已挂出:
|
||||||
5. `npm run check:encoding` 通过,确保新增中文文档与 Rust 源文件编码未损坏
|
- `POST /api/runtime/story/state/resolve`
|
||||||
|
- `GET /api/runtime/story/state/:sessionId`
|
||||||
|
- `POST /api/runtime/story/actions/resolve`
|
||||||
|
- `POST /api/runtime/story/initial`
|
||||||
|
- `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` 通过,当前 Rust `runtime_story` 兼容桥回归为 30 条
|
||||||
|
8. `node scripts/check-encoding.mjs` 通过
|
||||||
|
|
||||||
达到以上条件后,下一轮即可直接进入 Rust `state bridge compiler` 与 Axum handler 落地。
|
补充边界:
|
||||||
|
|
||||||
|
1. 当前测试里为 `runtime_snapshot` 加了 `#[cfg(test)]` 下的内存兜底,只用于在未启动本地 SpacetimeDB 时稳定验证 Rust route boundary。
|
||||||
|
2. 该测试兜底不进入生产链路,不改变真实 `runtime_save -> spacetime-client -> SpacetimeDB procedure` 的运行时实现。
|
||||||
|
|
||||||
|
达到以上条件后,兼容桥这一段已不再停留在 DTO / 空壳状态;下一轮重点转向“继续迁移 Node 剩余编排分支,并最终用真相态 reducer / projection 替换 compat bridge”。
|
||||||
|
|||||||
421
docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md
Normal file
421
docs/technical/M4_RUNTIME_STORY_RS_SPLIT_PLAN_2026-04-22.md
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
# 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 的具体结算细节。
|
||||||
|
|
||||||
|
## 11. 独立 crate 抽取边界
|
||||||
|
|
||||||
|
完成第二阶段后,已经可以进入第三阶段,但独立 crate 仍按最小安全边界推进:
|
||||||
|
|
||||||
|
1. 新 crate 命名为 `module-runtime-story-compat`。
|
||||||
|
2. `module-runtime-story-compat` 只承接“无 HTTP / 无 `AppState`”的 compat 核心:
|
||||||
|
- runtime story action 分发与确定性结算
|
||||||
|
- battle / equipment / forge / NPC / quest action resolver
|
||||||
|
- `Value` 快照态读写 helper
|
||||||
|
- `RuntimeStoryActionResponse` 的 view model / presentation 编译
|
||||||
|
3. `api-server` 继续保留:
|
||||||
|
- Axum route handler
|
||||||
|
- `RequestContext / AuthenticatedAccessToken`
|
||||||
|
- `runtime_snapshot` 持久化与读取
|
||||||
|
- `clientVersion` 校验到 HTTP error 的映射
|
||||||
|
- `platform-llm` 动作后文本增强
|
||||||
|
4. 首批迁移不把 AI 文本增强放进新 crate,因为它依赖 `AppState` 和 `platform-llm`。
|
||||||
|
5. 首批迁移不把 test route boundary 放进新 crate,route boundary 仍属于 `api-server`。
|
||||||
|
|
||||||
|
这一步完成后,`api-server` 的 `runtime_story/compat.rs` 应该只负责:
|
||||||
|
|
||||||
|
1. 从 HTTP 请求恢复 / 持久化 snapshot
|
||||||
|
2. 调用 `module-runtime-story-compat` 产出确定性动作结果或状态响应
|
||||||
|
3. 需要时调用本地 AI 增强
|
||||||
|
4. 将最终响应包回 `Json<Value>`
|
||||||
|
|
||||||
|
这就是从“`api-server` 内部模块”到“独立 crate”的首个可验证切片。
|
||||||
|
|
||||||
|
截至当前工作区,第三阶段首批独立 crate 已落地:
|
||||||
|
|
||||||
|
1. 已新增 [module-runtime-story-compat](D:/Genarrative/server-rs/crates/module-runtime-story-compat)。
|
||||||
|
2. 已接入 [server-rs/Cargo.toml](D:/Genarrative/server-rs/Cargo.toml) workspace。
|
||||||
|
3. [api-server/Cargo.toml](D:/Genarrative/server-rs/crates/api-server/Cargo.toml) 已新增对 `module-runtime-story-compat` 的依赖。
|
||||||
|
4. 首批迁入新 crate 的内容包括:
|
||||||
|
- `StoryResolution`
|
||||||
|
- `GeneratedStoryPayload`
|
||||||
|
- `CurrentEncounterNpcQuestContext`
|
||||||
|
- `PendingQuestOfferContext`
|
||||||
|
- `RuntimeStoryActionResponseParts`
|
||||||
|
- `CONTINUE_ADVENTURE_FUNCTION_ID`
|
||||||
|
- `MAX_TASK5_COMPANIONS`
|
||||||
|
- `simple_story_resolution`
|
||||||
|
- `resolve_action_text`
|
||||||
|
- `build_status_patch`
|
||||||
|
- `current_world_type`
|
||||||
|
5. 第三阶段继续推进后,当前已经从 `api-server` 抽到独立 crate 的纯逻辑还包括:
|
||||||
|
- `core.rs`:JSON 快照读写、runtime stat、story history、progression、encounter 清理
|
||||||
|
- `game_state.rs`:encounter / inventory / equipment 的基础 helper
|
||||||
|
- `forge.rs`:锻造配方、重铸成本、材料消耗、拆解产物、重铸产物、货币文本
|
||||||
|
- `forge_actions.rs`:`forge_craft / forge_dismantle / forge_reforge` 三条动作结算
|
||||||
|
- `npc_support.rs`:赠礼好感收益、交易价格、数量文案、满员换队招募 helper
|
||||||
|
- `battle.rs`:`battle_* / inventory_use` 的纯动作结算、patch 生成与胜负写回
|
||||||
|
6. 当前 [api-server 的 compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 已经不再内嵌上述纯逻辑,只保留:
|
||||||
|
- Axum handler
|
||||||
|
- snapshot 读写
|
||||||
|
- `clientVersion` 校验
|
||||||
|
- functionId 分发
|
||||||
|
- HTTP error 映射
|
||||||
|
- 动作后 AI 文本增强
|
||||||
|
7. 当前 [api-server 的 forge.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/forge.rs) 已收缩成极薄 bridge,只为 NPC trade bootstrap 复用新 crate 暴露的运行时物品构造 helper,锻造规则主体不再保留本地副本。
|
||||||
|
8. 当前 [api-server 的 battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs) 也已从“结算 + 展示”收缩成“展示编译 + 少量本地 helper”:
|
||||||
|
- battle 动作结算主链已经迁入 `module-runtime-story-compat`
|
||||||
|
- `api-server` 本地仅继续保留 `build_battle_runtime_story_options(...)` 与 `restore_player_resource(...)` 这类仍被 presentation / NPC 辅助逻辑直接依赖的部分
|
||||||
|
- 这为下一步继续把 battle option compiler 收进独立 crate 做好了边界准备
|
||||||
|
|
||||||
|
这意味着第三阶段已经不只是“创建了新 crate”,而是完成了第一批真正跨 crate 的 compat 纯逻辑迁移,并且保持 route boundary 与既有测试口径不变。
|
||||||
|
|
||||||
|
同日继续推进后,battle 这块已经完成从“先迁结算主链”到“连展示编译一起迁”的下一步:
|
||||||
|
|
||||||
|
1. [module-runtime-story-compat 的 battle.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/battle.rs) 当前已同时承接:
|
||||||
|
- `resolve_battle_action(...)`
|
||||||
|
- `restore_player_resource(...)`
|
||||||
|
- `build_battle_runtime_story_options(...)`
|
||||||
|
- 技能冷却读取、推荐物品挑选、战斗技能 option compiler 等 battle 展示辅助
|
||||||
|
2. [api-server 的 compat.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat.rs) 已直接从 `module-runtime-story-compat` 导入 battle 展示编译与资源恢复 helper。
|
||||||
|
3. [api-server 本地的 compat/battle.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/battle.rs) 已删除,不再保留 battle 规则的本地副本。
|
||||||
|
4. 到这一步,`api-server` 在 runtime story compat 上对 battle 的职责已经只剩:
|
||||||
|
- functionId 分发
|
||||||
|
- route handler / snapshot bridge
|
||||||
|
- AI 文本增强后的最终响应拼装
|
||||||
|
|
||||||
|
这说明第三阶段已经不只是在“拆 crate”,而是在真实压缩 `api-server` 的 compat 规则面。接下来更合理的推进方向将不再是 battle,而是继续评估 `presentation` 中还能进一步抽到独立 crate 的纯 view model / option compiler 边界。
|
||||||
|
|
||||||
|
同日继续推进后,`presentation` 中最通用的一层 option DTO 编译也已经开始抽离:
|
||||||
|
|
||||||
|
1. 已新增 [options.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/options.rs),统一承接:
|
||||||
|
- `build_static_runtime_story_option(...)`
|
||||||
|
- `build_runtime_story_option_with_payload(...)`
|
||||||
|
- `build_disabled_runtime_story_option(...)`
|
||||||
|
- `build_runtime_story_option_from_story_option(...)`
|
||||||
|
- `build_story_option_from_runtime_option(...)`
|
||||||
|
- `infer_option_scope(...)`
|
||||||
|
2. [module-runtime-story-compat 的 lib.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/lib.rs) 已对外 re-export 这些 option helper,供 `api-server` 直接复用。
|
||||||
|
3. [api-server 的 presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs) 已删除本地重复实现,只保留 NPC option 组合、view model 组装、quest currentStory 等尚未完全独立的部分。
|
||||||
|
|
||||||
|
这一步的意义不是单纯减少行数,而是先把 `RuntimeStoryOptionView` 的最小稳定编译面收敛到独立 crate。后续若继续外提 `view model` 与 `fallback option compiler`,将不需要再重复搬运这些 option 基础件。
|
||||||
|
|
||||||
|
同日继续推进后,`presentation` 中的纯 view-model builder 也已经抽到独立 crate:
|
||||||
|
|
||||||
|
1. 已新增 [view_model.rs](D:/Genarrative/server-rs/crates/module-runtime-story-compat/src/view_model.rs),统一承接:
|
||||||
|
- `build_runtime_story_view_model(...)`
|
||||||
|
- `build_runtime_story_companions(...)`
|
||||||
|
- `build_runtime_story_encounter(...)`
|
||||||
|
- `resolve_current_encounter_npc_state(...)`
|
||||||
|
2. [api-server 的 presentation.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/presentation.rs) 已删除本地 view-model 组装实现,继续只负责状态响应 orchestration、dialogue currentStory、fallback option compiler 与 quest 辅助。
|
||||||
|
3. [api-server 的 game_state.rs](D:/Genarrative/server-rs/crates/api-server/src/runtime_story/compat/game_state.rs) 当前也直接复用 crate 导出的 `resolve_current_encounter_npc_state(...)`,避免 NPC 状态查询 helper 在 `api-server` 和 crate 之间出现两套实现。
|
||||||
|
|
||||||
|
至此,`module-runtime-story-compat` 已经覆盖了 runtime story 兼容层的以下纯逻辑面:
|
||||||
|
|
||||||
|
1. JSON 快照读写与基础状态 helper
|
||||||
|
2. battle / forge / npc support 的纯规则结算
|
||||||
|
3. battle option compiler
|
||||||
|
4. runtime story option DTO 编译
|
||||||
|
5. runtime story view-model 编译
|
||||||
|
|
||||||
|
`api-server` 当前的剩余重点已经更集中在:
|
||||||
|
|
||||||
|
1. HTTP / snapshot bridge
|
||||||
|
2. functionId 分发
|
||||||
|
3. AI 文本增强
|
||||||
|
4. NPC / quest fallback option 与 currentStory 组合逻辑
|
||||||
@@ -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,158 @@
|
|||||||
|
# M6 custom world 资产接入 OSS 第一批设计
|
||||||
|
|
||||||
|
日期:`2026-04-22`
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
这份文档用于冻结 `M6` 第一批 custom world 资产链的真实落地口径。
|
||||||
|
|
||||||
|
本批只解决一个明确问题:
|
||||||
|
|
||||||
|
1. `POST /api/custom-world/scene-image`
|
||||||
|
2. `POST /api/custom-world/cover-image`
|
||||||
|
3. `POST /api/custom-world/cover-upload`
|
||||||
|
|
||||||
|
不再把图片产物写入仓库 `public/` 本地文件,而是统一接到:
|
||||||
|
|
||||||
|
1. `platform-oss::put_object`
|
||||||
|
2. `asset_object`
|
||||||
|
3. `asset_entity_binding`
|
||||||
|
|
||||||
|
形成正式 `OSS + SpacetimeDB 元数据` 真相链。
|
||||||
|
|
||||||
|
## 2. 当前前提
|
||||||
|
|
||||||
|
当前仓库已经具备以下基础能力:
|
||||||
|
|
||||||
|
1. `POST /api/assets/direct-upload-tickets`
|
||||||
|
2. `GET /api/assets/read-url`
|
||||||
|
3. `POST /api/assets/objects/confirm`
|
||||||
|
4. `POST /api/assets/objects/bind`
|
||||||
|
5. `platform-oss::OssClient::put_object`
|
||||||
|
6. `spacetime-module` 中的 `asset_object / asset_entity_binding`
|
||||||
|
|
||||||
|
因此本批不重新设计新资产系统,只复用既有 `assets` 主链。
|
||||||
|
|
||||||
|
## 3. 本批范围
|
||||||
|
|
||||||
|
### 3.1 要完成的内容
|
||||||
|
|
||||||
|
1. custom world 场景图生成结果写入 OSS
|
||||||
|
2. custom world 封面图生成结果写入 OSS
|
||||||
|
3. custom world 封面上传结果写入 OSS
|
||||||
|
4. 每个写入对象都执行一次正式对象确认
|
||||||
|
5. 每个正式对象都绑定到 custom world 业务实体槽位
|
||||||
|
6. 路由响应继续返回旧前端可消费的 `imageSrc`
|
||||||
|
|
||||||
|
### 3.2 本批不解决的内容
|
||||||
|
|
||||||
|
1. 不补 DashScope 图片模型的完整 Rust 编排
|
||||||
|
2. 不补 `cover-upload` 的裁剪、压缩、16:9 强校验全量能力
|
||||||
|
3. 不新增 `scene_image_asset / character_visual_asset` 强业务表
|
||||||
|
4. 不在本批落 `custom_world_asset_link`
|
||||||
|
5. 不把旧前端响应 contract 改成直接返回 OSS URL
|
||||||
|
|
||||||
|
## 4. 业务实体与槽位约定
|
||||||
|
|
||||||
|
本批统一复用通用 `asset_entity_binding`。
|
||||||
|
|
||||||
|
### 4.1 场景图
|
||||||
|
|
||||||
|
| 字段 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `entity_kind` | `custom_world_landmark` |
|
||||||
|
| `entity_id` | 优先 `landmarkId`,否则回退 `landmarkName` |
|
||||||
|
| `slot` | `scene_image` |
|
||||||
|
| `asset_kind` | `scene_image` |
|
||||||
|
|
||||||
|
### 4.2 封面图
|
||||||
|
|
||||||
|
| 字段 | 取值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `entity_kind` | `custom_world_profile` |
|
||||||
|
| `entity_id` | 优先 `profileId`,否则回退世界 `id/name` |
|
||||||
|
| `slot` | `cover` |
|
||||||
|
| `asset_kind` | `custom_world_cover` |
|
||||||
|
|
||||||
|
补充口径:
|
||||||
|
|
||||||
|
1. 绑定幂等键仍是 `entity_kind + entity_id + slot`
|
||||||
|
2. 同一 profile 重复生成/上传封面时,允许覆盖到最新对象
|
||||||
|
3. 同一 landmark 重复生成场景图时,允许覆盖到最新对象
|
||||||
|
|
||||||
|
## 5. OSS 对象键与返回 contract
|
||||||
|
|
||||||
|
### 5.1 对象键
|
||||||
|
|
||||||
|
场景图固定写入:
|
||||||
|
|
||||||
|
`generated-custom-world-scenes/{profileSegment}/{landmarkSegment}/{assetId}/scene.{ext}`
|
||||||
|
|
||||||
|
封面图固定写入:
|
||||||
|
|
||||||
|
`generated-custom-world-covers/{profileSegment}/{assetId}/cover.{ext}`
|
||||||
|
|
||||||
|
### 5.2 返回 contract
|
||||||
|
|
||||||
|
路由响应继续沿用旧前端使用的字段:
|
||||||
|
|
||||||
|
1. `imageSrc`
|
||||||
|
2. `assetId`
|
||||||
|
3. `sourceType`
|
||||||
|
4. `model`
|
||||||
|
5. `size`
|
||||||
|
6. `taskId`
|
||||||
|
7. `prompt`
|
||||||
|
8. `actualPrompt`
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
1. `imageSrc` 固定返回 `legacyPublicPath`,也就是旧 `/generated-*` 路径
|
||||||
|
2. 前端若要真正读取私有 OSS 对象,仍必须通过 `GET /api/assets/read-url` 换签名读 URL
|
||||||
|
3. 不直接把 `signedUrl` 塞进 custom world 业务返回,避免把短期读签名误存成长期业务字段
|
||||||
|
|
||||||
|
## 6. 服务端执行顺序
|
||||||
|
|
||||||
|
每次 custom world 图片产出固定执行以下顺序:
|
||||||
|
|
||||||
|
1. 生成或接收图片字节
|
||||||
|
2. 调 `platform-oss::put_object`
|
||||||
|
3. 通过 `HEAD Object` 真值确认对象
|
||||||
|
4. 写入 `asset_object`
|
||||||
|
5. 写入 `asset_entity_binding`
|
||||||
|
6. 返回 `legacyPublicPath`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
1. `put_object` 成功不代表已完成正式落库
|
||||||
|
2. `asset_object` 仍必须经过确认链路写入
|
||||||
|
3. 业务引用真相以 `asset_entity_binding` 为准,不以 OSS 上是否存在 key 为准
|
||||||
|
|
||||||
|
## 7. 与 M5 的衔接
|
||||||
|
|
||||||
|
`M5` 为保证前端不断链,曾允许 `scene-image / cover-image / cover-upload` 先写本地 `public/`。
|
||||||
|
|
||||||
|
从本批开始,这个临时口径失效,统一改为:
|
||||||
|
|
||||||
|
1. 二进制对象只进 OSS
|
||||||
|
2. 元数据只进 `asset_object`
|
||||||
|
3. 业务槽位只进 `asset_entity_binding`
|
||||||
|
|
||||||
|
这样 `Stage9` 的兼容路由就不会继续偏离 `M6` 正式资产主链。
|
||||||
|
|
||||||
|
## 8. 完成定义
|
||||||
|
|
||||||
|
当以下条件满足时,本批视为完成:
|
||||||
|
|
||||||
|
1. custom world 三条图片兼容路由不再写本地 `public/`
|
||||||
|
2. 路由成功返回的 `imageSrc` 全部来自 `OSS legacyPublicPath`
|
||||||
|
3. 每次成功写图后都能在 SpacetimeDB 中形成 `asset_object`
|
||||||
|
4. 每次成功写图后都能形成对应 `asset_entity_binding`
|
||||||
|
5. 旧前端仍可继续使用返回的 `/generated-*` 路径配合读签名服务显示图片
|
||||||
|
|
||||||
|
## 9. 关联文档
|
||||||
|
|
||||||
|
1. [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md)
|
||||||
|
2. [ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md](./ASSET_OBJECT_CONFIRM_FLOW_DESIGN_2026-04-21.md)
|
||||||
|
3. [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md)
|
||||||
|
4. [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)
|
||||||
@@ -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` 自动生成。
|
> 该文档由 `server-node/src/manifest/backendCapabilityManifest.ts` 自动生成。
|
||||||
> 生成命令:`npm run server-node:manifest:backend`
|
> 生成命令:`npm run server-node:manifest:backend`
|
||||||
> 生成时间:`2026-04-20T14:26:38.663Z`
|
> 生成时间:`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,9 @@
|
|||||||
|
|
||||||
## 文档列表
|
## 文档列表
|
||||||
|
|
||||||
|
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust`、`npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本和安全清库开关。
|
||||||
|
- [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 增量解析、错误模型与重试边界。
|
- [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` 的配置、状态注入与首版非流式兼容边界。
|
- [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 必须覆盖短信登录主链。
|
- [PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md](./PHONE_SMS_LOGIN_STAGE_A_IMPLEMENTATION_2026-04-21.md):冻结手机号验证码登录第一阶段的真实落地边界,明确游客兜底默认关闭、公开请求不污染登录态,以及 smoke 必须覆盖短信登录主链。
|
||||||
@@ -37,6 +40,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_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` 重写后端的目标架构、模块映射、数据分层、迁移顺序与验收标准。
|
- [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_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` 边界。
|
- [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 与测试策略。
|
- [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。
|
- [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。
|
||||||
@@ -45,8 +50,16 @@
|
|||||||
- [BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](./BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md):冻结大鱼吃小鱼玩法本轮最小完整落地方案,明确 `module-big-fish`、SpacetimeDB 表 / procedure、Axum facade、前端接入和运行态规则边界。
|
- [BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](./BIG_FISH_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md):冻结大鱼吃小鱼玩法本轮最小完整落地方案,明确 `module-big-fish`、SpacetimeDB 表 / procedure、Axum facade、前端接入和运行态规则边界。
|
||||||
- [PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](./PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md):冻结拼图玩法本轮最小完整落地方案,明确 `module-puzzle`、SpacetimeDB 表 / procedure、Axum facade、前端接入,以及交换 / 合并 / 拖动 / 拆分 / 下一关推荐边界。
|
- [PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md](./PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md):冻结拼图玩法本轮最小完整落地方案,明确 `module-puzzle`、SpacetimeDB 表 / procedure、Axum facade、前端接入,以及交换 / 合并 / 拖动 / 拆分 / 下一关推荐边界。
|
||||||
- [UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md](./UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md):冻结所有创作品类 Agent 聊天 UI 与对话进度管理统一框架,明确品类差异只保留锚点映射、提示词/话术和 action。
|
- [UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md](./UNIFIED_CREATION_AGENT_CHAT_FRAMEWORK_2026-04-22.md):冻结所有创作品类 Agent 聊天 UI 与对话进度管理统一框架,明确品类差异只保留锚点映射、提示词/话术和 action。
|
||||||
|
- [RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md](./RUST_API_SERVER_SSE_INFRASTRUCTURE_DESIGN_2026-04-22.md):冻结 `server-rs/crates/api-server` 的 SSE 使用口径,明确统一使用 Axum 内建 `Sse<Event>`,不再保留自定义 `sse.rs` 模块。
|
||||||
- [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_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 到收口阶段的统一落地依据。
|
- [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、宽松归一化、去重排序规则与测试策略。
|
- [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。
|
- [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 后端的实施方案与验收口径。
|
- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。
|
||||||
@@ -74,6 +87,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_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_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_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_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_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`。
|
- [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 清单,避免只完成编译而遗漏外部可访问路径。
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Rust `api-server` SSE 使用口径(2026-04-22)
|
||||||
|
|
||||||
|
日期:`2026-04-22`
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
这份文档用于冻结 `server-rs/crates/api-server` 的 SSE 实现口径。
|
||||||
|
|
||||||
|
本轮结论调整为:Rust `api-server` 不再维护自定义 `sse.rs` 基础设施,统一使用 Axum 内建的 `axum::response::sse::{Event, Sse}` 能力。
|
||||||
|
|
||||||
|
本轮目标只有三个:
|
||||||
|
|
||||||
|
1. 删除 `server-rs/crates/api-server/src/sse.rs` 自定义模块。
|
||||||
|
2. 把现有 custom world message stream 切到 Axum 官方 SSE 类型。
|
||||||
|
3. 保持现有业务事件协议与“一次性返回完整事件序列”的兼容语义不变。
|
||||||
|
|
||||||
|
本轮不做:
|
||||||
|
|
||||||
|
1. 不改前端消费协议。
|
||||||
|
2. 不把 custom world message stream 当场改成真实逐段 token streaming。
|
||||||
|
3. 不引入跨 crate 的共享 SSE runtime helper。
|
||||||
|
4. 不抽象 `reply_delta / session / done / error` 等业务事件名。
|
||||||
|
|
||||||
|
## 2. 当前问题
|
||||||
|
|
||||||
|
上一轮曾在 `server-rs/crates/api-server/src/sse.rs` 中抽出自定义 SSE helper,用于统一响应头、事件编码、缓冲式输出和实时 writer。
|
||||||
|
|
||||||
|
继续保留这套自定义模块的问题是:
|
||||||
|
|
||||||
|
1. Axum 已经提供 `Sse<Event>`、`Event::json_data(...)` 和标准 SSE body 编码。
|
||||||
|
2. 自定义文本编码需要自行维护换行、JSON 序列化、响应头等细节。
|
||||||
|
3. 后续真流式接口如果继续沿用自定义 writer,会和 Axum 官方生态产生重复抽象。
|
||||||
|
4. 当前项目已经以 Axum 作为 Rust HTTP 框架,优先使用框架内建能力更简单。
|
||||||
|
|
||||||
|
## 3. 统一实现口径
|
||||||
|
|
||||||
|
Rust `api-server` 的 SSE 路由统一使用:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use axum::response::sse::{Event, Sse};
|
||||||
|
```
|
||||||
|
|
||||||
|
有限事件序列使用:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let stream = tokio_stream::iter(events);
|
||||||
|
Sse::new(stream).into_response()
|
||||||
|
```
|
||||||
|
|
||||||
|
实时流式接口后续直接使用:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Sse::new(event_stream)
|
||||||
|
```
|
||||||
|
|
||||||
|
如需保持长连接,可在真实长流接口中追加:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
.keep_alive(axum::response::sse::KeepAlive::default())
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. custom world message stream 边界
|
||||||
|
|
||||||
|
`POST /api/runtime/custom-world/agent/sessions/:sessionId/messages/stream` 当前继续保持 Stage 8 文档冻结的最小语义:
|
||||||
|
|
||||||
|
1. 业务层先完成 deterministic 写表。
|
||||||
|
2. 读取最新 session snapshot。
|
||||||
|
3. 组装 `reply_delta`。
|
||||||
|
4. 组装 `session`。
|
||||||
|
5. 组装 `done`。
|
||||||
|
6. 通过 Axum `Sse` 返回完整事件序列。
|
||||||
|
|
||||||
|
本轮只替换传输层实现,不改变事件顺序、事件名和 payload 结构。
|
||||||
|
|
||||||
|
## 5. 响应头说明
|
||||||
|
|
||||||
|
Axum `Sse` 默认写入:
|
||||||
|
|
||||||
|
1. `Content-Type: text/event-stream`
|
||||||
|
2. `Cache-Control: no-cache`
|
||||||
|
|
||||||
|
当前不再额外写入自定义 `X-Accel-Buffering: no` helper。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
1. 本轮目标是移除项目自定义 SSE 模块,避免继续维护传输层封装。
|
||||||
|
2. 当前 custom world stream 仍是短生命周期的兼容事件序列,不是长时间 token streaming。
|
||||||
|
3. 如果未来某条真实长流接口需要反向代理禁用缓冲,应在该路由或统一 HTTP 中间件层显式评估,而不是恢复自定义 SSE 编码器。
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
当以下条件满足时,本轮视为完成:
|
||||||
|
|
||||||
|
1. `api-server/src/sse.rs` 已删除。
|
||||||
|
2. `api-server/src/main.rs` 不再声明 `mod sse;`。
|
||||||
|
3. `custom_world.rs` 不再依赖 `crate::sse::SseEventBuffer`。
|
||||||
|
4. custom world message stream 使用 Axum `Sse<Event>` 构造响应。
|
||||||
|
5. 为旧自定义 writer 引入的 `bytes`、`tokio::sync` feature 等依赖已清理。
|
||||||
|
6. `cargo fmt -p api-server` 通过。
|
||||||
|
7. `cargo check -p api-server` 通过。
|
||||||
|
8. `npm run check:encoding` 通过。
|
||||||
|
|
||||||
|
## 7. 一句话结论
|
||||||
|
|
||||||
|
Rust `api-server` 的 SSE 能力以 Axum 内建 `Sse<Event>` 为唯一实现入口,不再保留项目自定义 `sse.rs` 模块;当前 custom world stream 只替换传输层,不改变业务协议。
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Rust 本地联调与远端发布脚本方案
|
||||||
|
|
||||||
|
日期:`2026-04-22`
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本方案补齐 `server-rs` 在 M7 切流前需要的两类工程脚本:
|
||||||
|
|
||||||
|
1. 本地一键联调脚本:同时启动本地 SpacetimeDB、Rust `api-server` 与 Web 前端,并通过现有 Vite 代理开关把运行时 API 指向 Rust。
|
||||||
|
2. Ubuntu 发布包构建脚本:在仓库根目录生成 `build/<当前时间>/` 发布目录,内含前端 release、Linux `api-server`、SpacetimeDB wasm、启动脚本与停止脚本。
|
||||||
|
|
||||||
|
脚本只做部署与联调编排,不改变 HTTP contract、SpacetimeDB schema 命名、对象存储键规划和前端默认 Node 开发入口。
|
||||||
|
|
||||||
|
## 2. 本地脚本
|
||||||
|
|
||||||
|
入口:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev:rust
|
||||||
|
```
|
||||||
|
|
||||||
|
跨平台 Bash 入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev:rust:sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 下 `dev:rust:sh`、`deploy:rust:remote` 与 `build:rust:ubuntu` 会通过 `scripts/run-bash-script.mjs` 优先查找 Git Bash;如安装路径不标准,可用 `GENARRATIVE_BASH` 指定 `bash` 可执行文件。
|
||||||
|
|
||||||
|
默认端口:
|
||||||
|
|
||||||
|
1. Web 前端:`http://127.0.0.1:3000`
|
||||||
|
2. Rust `api-server`:`http://127.0.0.1:8082`
|
||||||
|
3. SpacetimeDB standalone:`http://127.0.0.1:3101`
|
||||||
|
4. SpacetimeDB database:`genarrative-dev`
|
||||||
|
|
||||||
|
默认流程:
|
||||||
|
|
||||||
|
1. 检查 `cargo`、`node` 与 `spacetime` CLI。
|
||||||
|
2. 启动 `spacetime --root-dir server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101`。
|
||||||
|
3. 等待 `spacetime server ping http://127.0.0.1:3101` 可用。
|
||||||
|
4. 执行 `spacetime publish genarrative-dev --server http://127.0.0.1:3101 --module-path server-rs/crates/spacetime-module --yes`。
|
||||||
|
5. 注入 `GENARRATIVE_API_*` 与 `GENARRATIVE_SPACETIME_*` 后启动 `cargo run -p api-server`。
|
||||||
|
6. 注入 `GENARRATIVE_BACKEND_STACK=rust`、`RUST_SERVER_TARGET`、`GENARRATIVE_RUNTIME_SERVER_TARGET` 后启动 Vite。
|
||||||
|
7. 任一子进程退出时,脚本回收其余子进程。
|
||||||
|
|
||||||
|
Vite 代理覆盖范围:
|
||||||
|
|
||||||
|
1. `/api/runtime/*` 会在 Rust 栈下代理到 Rust `api-server`,覆盖旧 runtime story 兼容接口。
|
||||||
|
2. `/api/story/*` 会在 Rust 栈下代理到 Rust `api-server`,覆盖新 story session、battle 查询与 NPC battle 切片接口。
|
||||||
|
3. 其他 `/api/auth`、`/api/assets`、`/api/custom-world`、`/api/llm` 等路径仍由同一个 `GENARRATIVE_RUNTIME_SERVER_TARGET` 控制,便于 M7 按服务能力逐项做对比 smoke。
|
||||||
|
|
||||||
|
安全边界:
|
||||||
|
|
||||||
|
1. 默认不执行 `--clear-database`。
|
||||||
|
2. 只有显式传入 `-ClearDatabase` 或 `--clear-database` 才允许清库重发。
|
||||||
|
3. 如需要复用已经启动的 SpacetimeDB,可传 `-SkipSpacetime` / `--skip-spacetime`。
|
||||||
|
4. 如只想启动进程不发布模块,可传 `-SkipPublish` / `--skip-publish`。
|
||||||
|
|
||||||
|
常用示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110 -Database genarrative-dev
|
||||||
|
.\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish
|
||||||
|
.\scripts\dev-rust-stack.ps1 -ClearDatabase
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110 --database genarrative-dev
|
||||||
|
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
|
||||||
|
./scripts/dev-rust-stack.sh --clear-database
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Ubuntu 发布包脚本
|
||||||
|
|
||||||
|
入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:rust:ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
兼容入口:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run deploy:rust:remote
|
||||||
|
```
|
||||||
|
|
||||||
|
保留 `deploy:rust:remote` 是为了不打断既有命令习惯;当前语义已调整为“生成 Ubuntu 发布包”,不再通过 SSH 进入服务器执行部署。
|
||||||
|
|
||||||
|
默认流程:
|
||||||
|
|
||||||
|
1. 在仓库根目录创建 `build/`。
|
||||||
|
2. 在 `build/` 下创建当前时间命名的目标目录,例如 `build/20260422-153000/`。
|
||||||
|
3. 使用 Vite 构建前端 release 到目标目录的 `web/`。
|
||||||
|
4. 执行 `cargo build -p api-server --release --target x86_64-unknown-linux-gnu --manifest-path server-rs/Cargo.toml`,并把 `api-server` 复制到目标目录。
|
||||||
|
5. 执行 `cargo build -p spacetime-module --release --target wasm32-unknown-unknown --manifest-path server-rs/Cargo.toml`,并把 `spacetime_module.wasm` 复制到目标目录。
|
||||||
|
6. 在目标目录写入 `web-server.mjs`,用于托管 `web/` 并把 `/api/*`、`/generated-*`、`/healthz` 反代到本包内的 `api-server`。
|
||||||
|
7. 在目标目录写入 `start.sh` 与 `stop.sh`。
|
||||||
|
|
||||||
|
发布包结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
build/<timestamp>/
|
||||||
|
├─ web/
|
||||||
|
├─ api-server
|
||||||
|
├─ spacetime_module.wasm
|
||||||
|
├─ web-server.mjs
|
||||||
|
├─ start.sh
|
||||||
|
├─ stop.sh
|
||||||
|
└─ README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
常用示例:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:rust:ubuntu -- --name 20260422-153000
|
||||||
|
npm run build:rust:ubuntu -- --database genarrative-dev --web-port 3000 --api-port 8082 --spacetime-port 3101
|
||||||
|
```
|
||||||
|
|
||||||
|
目标服务器启动:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd build/<timestamp>
|
||||||
|
./start.sh
|
||||||
|
./stop.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
安全边界:
|
||||||
|
|
||||||
|
1. 构建脚本不读取、不传输、不打印生产密钥。
|
||||||
|
2. 目标服务器 `.env`、`.env.local` 或进程环境仍由服务器本身维护。
|
||||||
|
3. `start.sh` 默认不清空 SpacetimeDB;只有显式执行 `./start.sh --clear-database` 才允许清库重发。
|
||||||
|
4. `start.sh` 使用 `spacetime publish --bin-path spacetime_module.wasm --yes` 发布当前包内 wasm。
|
||||||
|
5. 当前脚本是单目录进程启动方案,不替代生产 systemd、Nginx、TLS、日志轮转与守护进程配置。
|
||||||
|
|
||||||
|
目标服务器最小要求:
|
||||||
|
|
||||||
|
1. Ubuntu x86_64。
|
||||||
|
2. 已安装 `node`,用于运行发布包内的 `web-server.mjs`。
|
||||||
|
3. 已安装 `spacetime` CLI,`start.sh` 会启动本地 SpacetimeDB 并发布 wasm。
|
||||||
|
4. 业务密钥通过目标服务器环境变量或发布包同目录 `.env.local` 提供。
|
||||||
|
|
||||||
|
## 4. 与 M7 的关系
|
||||||
|
|
||||||
|
这套脚本补齐 M7 的部署执行入口,但不等价于完成灰度切流。M7 后续仍需要在真实 OSS、LLM、短信、微信、SpacetimeDB 数据库和反向代理环境下完成全链路 smoke、关键 SSE 联调和灰度切流验收。
|
||||||
@@ -832,6 +832,21 @@ workflow-cache/{workflow_type}/{workflow_id}.json
|
|||||||
1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,退出本轮 Rust 后端重写范围。
|
1. `editor` 已于 `2026-04-21` 被确认为遗留无用模块,退出本轮 Rust 后端重写范围。
|
||||||
2. Phase 5 只覆盖资产与 OSS 主链,不再包含 editor 迁移。
|
2. Phase 5 只覆盖资产与 OSS 主链,不再包含 editor 迁移。
|
||||||
|
|
||||||
|
## Phase 6:联调、回归、部署与切流收口
|
||||||
|
|
||||||
|
交付:
|
||||||
|
|
||||||
|
1. 联调与回归测试体系
|
||||||
|
2. 灰度环境、切流开关、回退方案
|
||||||
|
3. tracing / request id / 关键链路观测
|
||||||
|
4. 拆分 `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 根入口只保留模块声明、统一导出与最小发布入口
|
||||||
|
|
||||||
|
阶段执行补充:
|
||||||
|
|
||||||
|
1. 这是切流前的工程结构收口,不是新功能扩张;拆分过程中不得改变既有 table schema、reducer / procedure 名称、对外 contract 与 publish 行为。
|
||||||
|
2. 拆分后的目录与模块边界必须对齐 `M0` 已冻结的模块迁移归属,避免 `spacetime-module` 回退成“单大文件 + 单大包”结构。
|
||||||
|
3. 拆分完成后至少要保持 `cargo check`、SpacetimeDB 本地 build / publish 开发链路与主流程回归脚本可继续通过。
|
||||||
|
|
||||||
## 14. 验收标准
|
## 14. 验收标准
|
||||||
|
|
||||||
重写完成至少要满足:
|
重写完成至少要满足:
|
||||||
|
|||||||
@@ -331,15 +331,24 @@ session snapshot 中的 `resultPreview` 固定输出:
|
|||||||
|
|
||||||
#### `scene-image / cover-image`
|
#### `scene-image / cover-image`
|
||||||
|
|
||||||
1. 当前不直接生成真实图片
|
1. `M5` 验收时允许先用本地占位产物保证前端主链不断
|
||||||
2. 返回明确 `NOT_IMPLEMENTED` 或最小占位错误会导致前端主链中断
|
2. 自 `2026-04-22` 的 `M6` 第一批开始,正式口径改为:
|
||||||
3. 因此前端兼容需要的最小可用策略是:创建上传票据或返回可继续上传的对象位置信息
|
- `platform-oss::put_object`
|
||||||
|
- `asset_object`
|
||||||
|
- `asset_entity_binding`
|
||||||
|
3. 兼容响应仍返回旧 `/generated-*` 路径,不直接返回裸 OSS URL
|
||||||
|
4. 详细边界见:
|
||||||
|
- [M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md](./M6_CUSTOM_WORLD_ASSET_OSS_INTEGRATION_STAGE1_2026-04-22.md)
|
||||||
|
|
||||||
#### `cover-upload`
|
#### `cover-upload`
|
||||||
|
|
||||||
1. 复用 `/api/assets/direct-upload-tickets`
|
1. `M5` 阶段允许先走最小本地上传兼容
|
||||||
2. 生成 OSS 上传票据
|
2. 自 `2026-04-22` 的 `M6` 第一批开始,正式口径与 `cover-image` 一致:
|
||||||
3. 返回兼容旧前端所需的上传字段
|
- 服务器接收 Data URL
|
||||||
|
- 服务器上传 OSS
|
||||||
|
- 确认 `asset_object`
|
||||||
|
- 绑定 `asset_entity_binding`
|
||||||
|
3. 返回值仍保持旧前端所需的 `imageSrc / assetId / sourceType`
|
||||||
|
|
||||||
## 8. crate 级改动范围
|
## 8. crate 级改动范围
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-node.mjs",
|
"dev": "node scripts/dev-node.mjs",
|
||||||
|
"dev:rust": "powershell -ExecutionPolicy Bypass -File scripts/dev-rust-stack.ps1",
|
||||||
|
"dev:rust:sh": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
|
||||||
"dev:web": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0",
|
"dev:web": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0",
|
||||||
"dev:node": "node scripts/dev-node.mjs",
|
"dev:node": "node scripts/dev-node.mjs",
|
||||||
|
"deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||||
|
"build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh",
|
||||||
"serve:caddy": "node scripts/run-caddy-dev.mjs",
|
"serve:caddy": "node scripts/run-caddy-dev.mjs",
|
||||||
"server-node:dev": "npm --prefix server-node run dev",
|
"server-node:dev": "npm --prefix server-node run dev",
|
||||||
"server-node:build": "npm --prefix server-node run build",
|
"server-node:build": "npm --prefix server-node run build",
|
||||||
@@ -17,6 +21,8 @@
|
|||||||
"server-node:smoke": "npx tsx scripts/smoke-server-node.ts",
|
"server-node:smoke": "npx tsx scripts/smoke-server-node.ts",
|
||||||
"server-node:smoke:proxy": "npx tsx scripts/smoke-same-origin-stack.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-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": "node scripts/build-gate.mjs",
|
||||||
"build:raw": "node scripts/vite-cli.mjs build",
|
"build:raw": "node scripts/vite-cli.mjs build",
|
||||||
"preview": "node scripts/vite-cli.mjs preview",
|
"preview": "node scripts/vite-cli.mjs preview",
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ export const TASK6_RUNTIME_FUNCTION_IDS = [
|
|||||||
'npc_quest_accept',
|
'npc_quest_accept',
|
||||||
'npc_quest_turn_in',
|
'npc_quest_turn_in',
|
||||||
'npc_trade',
|
'npc_trade',
|
||||||
'treasure_inspect',
|
|
||||||
'treasure_leave',
|
|
||||||
'treasure_secure',
|
|
||||||
] as const;
|
] as const;
|
||||||
export type Task6RuntimeFunctionId =
|
export type Task6RuntimeFunctionId =
|
||||||
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
|
(typeof TASK6_RUNTIME_FUNCTION_IDS)[number];
|
||||||
@@ -121,10 +118,6 @@ export type RuntimeStoryOptionInteraction =
|
|||||||
| 'quest_accept'
|
| 'quest_accept'
|
||||||
| 'quest_turn_in';
|
| 'quest_turn_in';
|
||||||
questId?: string;
|
questId?: string;
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: 'treasure';
|
|
||||||
action: 'inspect' | 'leave' | 'secure';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RuntimeStoryChoiceAction = RuntimeAction<
|
export type RuntimeStoryChoiceAction = RuntimeAction<
|
||||||
|
|||||||
538
scripts/deploy-rust-remote.sh
Normal file
538
scripts/deploy-rust-remote.sh
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
npm run deploy:rust:remote
|
||||||
|
./scripts/deploy-rust-remote.sh --name 20260422-153000
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 在仓库根目录创建 build/<当前时间>/ 作为 Ubuntu 发布包目录。
|
||||||
|
2. 使用 Vite 构建前端 release 到目标目录的 web/。
|
||||||
|
3. 构建 api-server 的 x86_64-unknown-linux-gnu release,并复制到目标目录。
|
||||||
|
4. 构建 spacetime-module 的 wasm32-unknown-unknown release,并复制 wasm 到目标目录。
|
||||||
|
5. 在目标目录生成 start.sh / stop.sh,用于目标服务器启动静态网站、SpacetimeDB、发布 wasm、启动 api-server。
|
||||||
|
|
||||||
|
常用参数:
|
||||||
|
--name <folder-name> 指定 build 子目录名,默认使用当前时间 YYYYmmdd-HHMMSS
|
||||||
|
--database <database> SpacetimeDB database,默认 genarrative-dev
|
||||||
|
--api-port <port> api-server 端口,默认 8082
|
||||||
|
--web-port <port> 静态网站端口,默认 3000
|
||||||
|
--spacetime-port <port> SpacetimeDB 端口,默认 3101
|
||||||
|
--skip-web-build 跳过 Vite 构建,仅用于调试
|
||||||
|
--skip-api-build 跳过 api-server 构建,仅用于调试
|
||||||
|
--skip-spacetime-build 跳过 wasm 构建,仅用于调试
|
||||||
|
|
||||||
|
目标服务器要求:
|
||||||
|
Ubuntu x86_64,已安装 node、spacetime CLI,并允许执行目标目录内的 start.sh / stop.sh。
|
||||||
|
如果在非 Linux 主机执行本脚本,需要本机 Rust 已配置 x86_64-unknown-linux-gnu 交叉编译工具链。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local command_name="$1"
|
||||||
|
|
||||||
|
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||||
|
echo "[deploy:rust] 缺少命令: ${command_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_required_file() {
|
||||||
|
local source_path="$1"
|
||||||
|
local target_path="$2"
|
||||||
|
local label="$3"
|
||||||
|
|
||||||
|
if [[ ! -f "${source_path}" ]]; then
|
||||||
|
echo "[deploy:rust] 缺少 ${label}: ${source_path}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "${source_path}" "${target_path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||||
|
BUILD_ROOT="${REPO_ROOT}/build"
|
||||||
|
BUILD_NAME="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
DATABASE="genarrative-dev"
|
||||||
|
API_HOST="127.0.0.1"
|
||||||
|
API_PORT="8082"
|
||||||
|
WEB_HOST="0.0.0.0"
|
||||||
|
WEB_PORT="3000"
|
||||||
|
SPACETIME_HOST="127.0.0.1"
|
||||||
|
SPACETIME_PORT="3101"
|
||||||
|
SKIP_WEB_BUILD=0
|
||||||
|
SKIP_API_BUILD=0
|
||||||
|
SKIP_SPACETIME_BUILD=0
|
||||||
|
BUILD_COMPLETED=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--name)
|
||||||
|
BUILD_NAME="${2:?缺少 --name 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--database)
|
||||||
|
DATABASE="${2:?缺少 --database 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-host)
|
||||||
|
API_HOST="${2:?缺少 --api-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-port)
|
||||||
|
API_PORT="${2:?缺少 --api-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-host)
|
||||||
|
WEB_HOST="${2:?缺少 --web-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-port)
|
||||||
|
WEB_PORT="${2:?缺少 --web-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-host)
|
||||||
|
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-port)
|
||||||
|
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-web-build)
|
||||||
|
SKIP_WEB_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-api-build)
|
||||||
|
SKIP_API_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-spacetime-build)
|
||||||
|
SKIP_SPACETIME_BUILD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[deploy:rust] 未知参数: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! "${BUILD_NAME}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
||||||
|
echo "[deploy:rust] --name 只能包含数字、字母、点、下划线和短横线。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_DIR="${BUILD_ROOT}/${BUILD_NAME}"
|
||||||
|
WEB_DIR="${TARGET_DIR}/web"
|
||||||
|
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
|
||||||
|
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
|
||||||
|
|
||||||
|
cleanup_partial_build() {
|
||||||
|
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
|
||||||
|
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
|
||||||
|
rm -rf "${TARGET_DIR}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_partial_build EXIT
|
||||||
|
|
||||||
|
if [[ -e "${TARGET_DIR}" ]]; then
|
||||||
|
echo "[deploy:rust] 目标目录已存在: ${TARGET_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_command node
|
||||||
|
require_command cargo
|
||||||
|
|
||||||
|
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||||
|
require_command npm
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${WEB_DIR}"
|
||||||
|
|
||||||
|
echo "[deploy:rust] 发布包目录: ${TARGET_DIR}"
|
||||||
|
|
||||||
|
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
|
||||||
|
echo "[deploy:rust] 构建 Vite release -> ${WEB_DIR}"
|
||||||
|
(
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
node scripts/vite-cli.mjs build --outDir "${WEB_DIR}" --emptyOutDir
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${SKIP_API_BUILD}" -ne 1 ]]; then
|
||||||
|
echo "[deploy:rust] 构建 api-server -> x86_64-unknown-linux-gnu"
|
||||||
|
(
|
||||||
|
cd "${SERVER_RS_DIR}"
|
||||||
|
cargo build \
|
||||||
|
-p api-server \
|
||||||
|
--release \
|
||||||
|
--target x86_64-unknown-linux-gnu \
|
||||||
|
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
copy_required_file "${API_BINARY_SOURCE}" "${TARGET_DIR}/api-server" "api-server release binary"
|
||||||
|
chmod +x "${TARGET_DIR}/api-server"
|
||||||
|
|
||||||
|
if [[ "${SKIP_SPACETIME_BUILD}" -ne 1 ]]; then
|
||||||
|
echo "[deploy:rust] 构建 spacetime-module -> wasm32-unknown-unknown"
|
||||||
|
(
|
||||||
|
cd "${SERVER_RS_DIR}"
|
||||||
|
cargo build \
|
||||||
|
-p spacetime-module \
|
||||||
|
--release \
|
||||||
|
--target wasm32-unknown-unknown \
|
||||||
|
--manifest-path "${SERVER_RS_DIR}/Cargo.toml"
|
||||||
|
)
|
||||||
|
fi
|
||||||
|
|
||||||
|
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
|
||||||
|
|
||||||
|
cat >"${TARGET_DIR}/web-server.mjs" <<'WEB_SERVER'
|
||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import {fileURLToPath} from 'node:url';
|
||||||
|
|
||||||
|
const releaseDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const webRoot = path.join(releaseDir, 'web');
|
||||||
|
const webHost = process.env.GENARRATIVE_WEB_HOST || '0.0.0.0';
|
||||||
|
const webPort = Number(process.env.GENARRATIVE_WEB_PORT || '3000');
|
||||||
|
const apiTarget = new URL(process.env.GENARRATIVE_API_TARGET || 'http://127.0.0.1:8082');
|
||||||
|
const indexPath = path.join(webRoot, 'index.html');
|
||||||
|
const proxyPrefixes = [
|
||||||
|
'/api/',
|
||||||
|
'/api',
|
||||||
|
'/generated-character-drafts',
|
||||||
|
'/generated-characters',
|
||||||
|
'/generated-animations',
|
||||||
|
'/generated-custom-world-scenes',
|
||||||
|
'/generated-custom-world-covers',
|
||||||
|
'/generated-qwen-sprites',
|
||||||
|
'/healthz',
|
||||||
|
];
|
||||||
|
|
||||||
|
function isProxyPath(pathname) {
|
||||||
|
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFor(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const typeMap = {
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
};
|
||||||
|
return typeMap[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendFile(response, filePath) {
|
||||||
|
fs.createReadStream(filePath)
|
||||||
|
.on('error', () => {
|
||||||
|
response.writeHead(500, {'content-type': 'text/plain; charset=utf-8'});
|
||||||
|
response.end('failed to read static file');
|
||||||
|
})
|
||||||
|
.pipe(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(request, response, pathname) {
|
||||||
|
const decodedPath = decodeURIComponent(pathname);
|
||||||
|
const relativePath = decodedPath === '/' ? '/index.html' : decodedPath;
|
||||||
|
const filePath = path.normalize(path.join(webRoot, relativePath));
|
||||||
|
const safeRelativePath = path.relative(webRoot, filePath);
|
||||||
|
|
||||||
|
if (safeRelativePath.startsWith('..') || path.isAbsolute(safeRelativePath)) {
|
||||||
|
response.writeHead(403, {'content-type': 'text/plain; charset=utf-8'});
|
||||||
|
response.end('forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedFilePath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: indexPath;
|
||||||
|
|
||||||
|
response.writeHead(200, {'content-type': contentTypeFor(resolvedFilePath)});
|
||||||
|
sendFile(response, resolvedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyToApi(request, response) {
|
||||||
|
const targetUrl = new URL(request.url || '/', apiTarget);
|
||||||
|
const proxyRequest = http.request(
|
||||||
|
{
|
||||||
|
hostname: targetUrl.hostname,
|
||||||
|
method: request.method,
|
||||||
|
path: `${targetUrl.pathname}${targetUrl.search}`,
|
||||||
|
port: targetUrl.port || 80,
|
||||||
|
protocol: targetUrl.protocol,
|
||||||
|
headers: {
|
||||||
|
...request.headers,
|
||||||
|
host: apiTarget.host,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(proxyResponse) => {
|
||||||
|
response.writeHead(proxyResponse.statusCode || 502, proxyResponse.headers);
|
||||||
|
proxyResponse.pipe(response);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
proxyRequest.on('error', (error) => {
|
||||||
|
response.writeHead(502, {'content-type': 'application/json; charset=utf-8'});
|
||||||
|
response.end(JSON.stringify({ok: false, error: {code: 'API_PROXY_FAILED', message: error.message}}));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.pipe(proxyRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const url = new URL(request.url || '/', `http://${request.headers.host || 'localhost'}`);
|
||||||
|
|
||||||
|
if (isProxyPath(url.pathname)) {
|
||||||
|
proxyToApi(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveStatic(request, response, url.pathname);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(webPort, webHost, () => {
|
||||||
|
console.log(`[web] listening on http://${webHost}:${webPort}, api target ${apiTarget.href}`);
|
||||||
|
});
|
||||||
|
WEB_SERVER
|
||||||
|
|
||||||
|
cat >"${TARGET_DIR}/start.sh" <<'START_SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PID_DIR="${SCRIPT_DIR}/run"
|
||||||
|
LOG_DIR="${SCRIPT_DIR}/logs"
|
||||||
|
SPACETIME_DATA_DIR="${GENARRATIVE_SPACETIME_DATA_DIR:-${SCRIPT_DIR}/spacetimedb-data}"
|
||||||
|
SPACETIME_HOST="${GENARRATIVE_SPACETIME_HOST:-127.0.0.1}"
|
||||||
|
SPACETIME_PORT="${GENARRATIVE_SPACETIME_PORT:-3101}"
|
||||||
|
SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_SERVER_URL:-http://${SPACETIME_HOST}:${SPACETIME_PORT}}"
|
||||||
|
SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_DATABASE:-genarrative-dev}"
|
||||||
|
API_HOST="${GENARRATIVE_API_HOST:-127.0.0.1}"
|
||||||
|
API_PORT="${GENARRATIVE_API_PORT:-8082}"
|
||||||
|
API_LOG="${GENARRATIVE_API_LOG:-info,tower_http=info}"
|
||||||
|
WEB_HOST="${GENARRATIVE_WEB_HOST:-0.0.0.0}"
|
||||||
|
WEB_PORT="${GENARRATIVE_WEB_PORT:-3000}"
|
||||||
|
CLEAR_DATABASE=0
|
||||||
|
|
||||||
|
cd "${SCRIPT_DIR}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
./start.sh
|
||||||
|
./start.sh --clear-database
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 启动当前发布包内的静态网站、SpacetimeDB 与 api-server。
|
||||||
|
2. 默认发布 spacetime_module.wasm 到 GENARRATIVE_SPACETIME_DATABASE,但不清库。
|
||||||
|
3. 只有显式传入 --clear-database 时才允许清空数据库重发。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--clear-database)
|
||||||
|
CLEAR_DATABASE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[start] 未知参数: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local command_name="$1"
|
||||||
|
|
||||||
|
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||||
|
echo "[start] 缺少命令: ${command_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_spacetime() {
|
||||||
|
local deadline=$((SECONDS + 60))
|
||||||
|
|
||||||
|
while ((SECONDS < deadline)); do
|
||||||
|
if spacetime server ping "${SPACETIME_SERVER_URL}" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[start] 等待 SpacetimeDB 就绪超时: ${SPACETIME_SERVER_URL}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start_process() {
|
||||||
|
local name="$1"
|
||||||
|
shift
|
||||||
|
local pid_file="${PID_DIR}/${name}.pid"
|
||||||
|
local log_file="${LOG_DIR}/${name}.log"
|
||||||
|
|
||||||
|
if [[ -f "${pid_file}" ]] && kill -0 "$(cat "${pid_file}")" 2>/dev/null; then
|
||||||
|
echo "[start] ${name} 已在运行: $(cat "${pid_file}")"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[start] 启动 ${name}"
|
||||||
|
nohup "$@" >"${log_file}" 2>&1 &
|
||||||
|
echo "$!" >"${pid_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command node
|
||||||
|
require_command spacetime
|
||||||
|
|
||||||
|
mkdir -p "${PID_DIR}" "${LOG_DIR}" "${SPACETIME_DATA_DIR}"
|
||||||
|
|
||||||
|
start_process spacetimedb \
|
||||||
|
spacetime \
|
||||||
|
start \
|
||||||
|
--data-dir "${SPACETIME_DATA_DIR}" \
|
||||||
|
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}" \
|
||||||
|
--non-interactive
|
||||||
|
|
||||||
|
wait_for_spacetime
|
||||||
|
|
||||||
|
PUBLISH_ARGS=(
|
||||||
|
publish
|
||||||
|
"${SPACETIME_DATABASE}"
|
||||||
|
--server "${SPACETIME_SERVER_URL}"
|
||||||
|
--bin-path "${SCRIPT_DIR}/spacetime_module.wasm"
|
||||||
|
--yes
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||||
|
PUBLISH_ARGS+=(--delete-data always)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
|
||||||
|
spacetime "${PUBLISH_ARGS[@]}"
|
||||||
|
|
||||||
|
export GENARRATIVE_API_HOST="${API_HOST}"
|
||||||
|
export GENARRATIVE_API_PORT="${API_PORT}"
|
||||||
|
export GENARRATIVE_API_LOG="${API_LOG}"
|
||||||
|
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
|
||||||
|
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
|
||||||
|
start_process api-server "${SCRIPT_DIR}/api-server"
|
||||||
|
|
||||||
|
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
|
||||||
|
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
|
||||||
|
export GENARRATIVE_API_TARGET="http://${API_HOST}:${API_PORT}"
|
||||||
|
start_process web node "${SCRIPT_DIR}/web-server.mjs"
|
||||||
|
|
||||||
|
echo "[start] 完成"
|
||||||
|
echo "[start] Web: http://${WEB_HOST}:${WEB_PORT}"
|
||||||
|
echo "[start] API: http://${API_HOST}:${API_PORT}"
|
||||||
|
echo "[start] SpacetimeDB: ${SPACETIME_SERVER_URL}"
|
||||||
|
START_SCRIPT
|
||||||
|
|
||||||
|
cat >"${TARGET_DIR}/stop.sh" <<'STOP_SCRIPT'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PID_DIR="${SCRIPT_DIR}/run"
|
||||||
|
|
||||||
|
stop_process() {
|
||||||
|
local name="$1"
|
||||||
|
local pid_file="${PID_DIR}/${name}.pid"
|
||||||
|
|
||||||
|
if [[ ! -f "${pid_file}" ]]; then
|
||||||
|
echo "[stop] ${name} 未记录 pid"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pid
|
||||||
|
pid="$(cat "${pid_file}")"
|
||||||
|
|
||||||
|
if kill -0 "${pid}" 2>/dev/null; then
|
||||||
|
echo "[stop] 停止 ${name} (pid=${pid})"
|
||||||
|
kill "${pid}" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
if kill -0 "${pid}" 2>/dev/null; then
|
||||||
|
kill -9 "${pid}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[stop] ${name} 未运行"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "${pid_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_process web
|
||||||
|
stop_process api-server
|
||||||
|
stop_process spacetimedb
|
||||||
|
|
||||||
|
echo "[stop] 完成"
|
||||||
|
STOP_SCRIPT
|
||||||
|
|
||||||
|
chmod +x "${TARGET_DIR}/start.sh" "${TARGET_DIR}/stop.sh"
|
||||||
|
|
||||||
|
cat >"${TARGET_DIR}/README.md" <<EOF
|
||||||
|
# Genarrative Ubuntu Release
|
||||||
|
|
||||||
|
构建时间:\`${BUILD_NAME}\`
|
||||||
|
|
||||||
|
## 内容
|
||||||
|
|
||||||
|
- \`web/\`:Vite release 静态资源
|
||||||
|
- \`api-server\`:x86_64-unknown-linux-gnu release 可执行文件
|
||||||
|
- \`spacetime_module.wasm\`:wasm32-unknown-unknown release 模块
|
||||||
|
- \`web-server.mjs\`:静态网站与 API 反代入口
|
||||||
|
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
./start.sh
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
默认不清空 SpacetimeDB。如需开发库清库重发:
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
./start.sh --clear-database
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
- \`GENARRATIVE_WEB_HOST\` / \`GENARRATIVE_WEB_PORT\`
|
||||||
|
- \`GENARRATIVE_API_HOST\` / \`GENARRATIVE_API_PORT\` / \`GENARRATIVE_API_LOG\`
|
||||||
|
- \`GENARRATIVE_SPACETIME_HOST\` / \`GENARRATIVE_SPACETIME_PORT\`
|
||||||
|
- \`GENARRATIVE_SPACETIME_SERVER_URL\` / \`GENARRATIVE_SPACETIME_DATABASE\`
|
||||||
|
- \`GENARRATIVE_SPACETIME_DATA_DIR\`
|
||||||
|
- OSS、LLM、短信、微信等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理。
|
||||||
|
EOF
|
||||||
|
|
||||||
|
BUILD_COMPLETED=1
|
||||||
|
echo "[deploy:rust] 完成: ${TARGET_DIR}"
|
||||||
313
scripts/dev-rust-stack.ps1
Normal file
313
scripts/dev-rust-stack.ps1
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Alias("h")]
|
||||||
|
[switch]$Help,
|
||||||
|
[string]$ApiHost = "127.0.0.1",
|
||||||
|
[int]$ApiPort = 8082,
|
||||||
|
[string]$WebHost = "0.0.0.0",
|
||||||
|
[int]$WebPort = 3000,
|
||||||
|
[string]$SpacetimeHost = "127.0.0.1",
|
||||||
|
[int]$SpacetimePort = 3101,
|
||||||
|
[string]$SpacetimeRootDir = "",
|
||||||
|
[string]$Database = "genarrative-dev",
|
||||||
|
[string]$Log = "info,tower_http=info",
|
||||||
|
[int]$SpacetimeStartupTimeoutSeconds = 60,
|
||||||
|
[switch]$SkipSpacetime,
|
||||||
|
[switch]$SkipPublish,
|
||||||
|
[switch]$ClearDatabase
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
function Write-Usage {
|
||||||
|
@(
|
||||||
|
'Usage:',
|
||||||
|
' npm run dev:rust',
|
||||||
|
' .\scripts\dev-rust-stack.ps1 -ApiPort 8090 -SpacetimePort 3110',
|
||||||
|
' .\scripts\dev-rust-stack.ps1 -SkipSpacetime -SkipPublish',
|
||||||
|
' .\scripts\dev-rust-stack.ps1 -ClearDatabase',
|
||||||
|
'',
|
||||||
|
'Notes:',
|
||||||
|
' 1. Start SpacetimeDB standalone, Rust api-server, and Vite web together.',
|
||||||
|
' 2. Publish server-rs/crates/spacetime-module by default, without clearing data.',
|
||||||
|
' 3. Only -ClearDatabase appends spacetime publish --clear-database.',
|
||||||
|
' 4. Web listens on 0.0.0.0:3000 by default; API listens on 127.0.0.1:8082.'
|
||||||
|
) -join [Environment]::NewLine
|
||||||
|
}
|
||||||
|
|
||||||
|
function Quote-ProcessArgument {
|
||||||
|
param([string]$Value)
|
||||||
|
|
||||||
|
if ($null -eq $Value) {
|
||||||
|
return '""'
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Value -notmatch '[\s"]') {
|
||||||
|
return $Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return '"' + $Value.Replace('"', '\"') + '"'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Join-ProcessArguments {
|
||||||
|
param([string[]]$Arguments)
|
||||||
|
|
||||||
|
return (($Arguments | ForEach-Object { Quote-ProcessArgument $_ }) -join " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-ClientHost {
|
||||||
|
param([string]$HostName)
|
||||||
|
|
||||||
|
if ($HostName -eq "0.0.0.0" -or $HostName -eq "::") {
|
||||||
|
return "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $HostName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-StackProcess {
|
||||||
|
param(
|
||||||
|
[string]$Name,
|
||||||
|
[string]$FilePath,
|
||||||
|
[string[]]$Arguments,
|
||||||
|
[string]$WorkingDirectory,
|
||||||
|
[hashtable]$Environment
|
||||||
|
)
|
||||||
|
|
||||||
|
$argumentLine = Join-ProcessArguments -Arguments $Arguments
|
||||||
|
Write-Host "[dev:rust] start ${Name}: $FilePath $argumentLine"
|
||||||
|
|
||||||
|
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$startInfo.FileName = $FilePath
|
||||||
|
$startInfo.Arguments = $argumentLine
|
||||||
|
$startInfo.WorkingDirectory = $WorkingDirectory
|
||||||
|
$startInfo.UseShellExecute = $false
|
||||||
|
$startInfo.RedirectStandardOutput = $false
|
||||||
|
$startInfo.RedirectStandardError = $false
|
||||||
|
$startInfo.RedirectStandardInput = $false
|
||||||
|
|
||||||
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
|
$startInfo.EnvironmentVariables[$entry.Key] = [string]$entry.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = New-Object System.Diagnostics.Process
|
||||||
|
$process.StartInfo = $startInfo
|
||||||
|
|
||||||
|
if (-not $process.Start()) {
|
||||||
|
throw "Failed to start process: $Name"
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Name = $Name
|
||||||
|
Process = $process
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-StackProcesses {
|
||||||
|
param([System.Collections.Generic.List[object]]$Processes)
|
||||||
|
|
||||||
|
for ($index = $Processes.Count - 1; $index -ge 0; $index--) {
|
||||||
|
$item = $Processes[$index]
|
||||||
|
$process = $item.Process
|
||||||
|
|
||||||
|
if ($null -ne $process -and -not $process.HasExited) {
|
||||||
|
Write-Host "[dev:rust] stop $($item.Name) (pid=$($process.Id))"
|
||||||
|
$taskkillCommand = Get-Command taskkill.exe -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $taskkillCommand) {
|
||||||
|
& $taskkillCommand.Source /PID $process.Id /T /F *> $null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-ForSpacetimeServer {
|
||||||
|
param(
|
||||||
|
[string]$CommandPath,
|
||||||
|
[string]$Server,
|
||||||
|
[int]$TimeoutSeconds,
|
||||||
|
$ProcessItem
|
||||||
|
)
|
||||||
|
|
||||||
|
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||||
|
|
||||||
|
while ((Get-Date) -lt $deadline) {
|
||||||
|
if ($null -ne $ProcessItem -and $ProcessItem.Process.HasExited) {
|
||||||
|
throw "SpacetimeDB exited before readiness, exit code: $($ProcessItem.Process.ExitCode)"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $CommandPath server ping $Server *> $null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
|
||||||
|
throw "Timed out waiting for SpacetimeDB readiness: $Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Help) {
|
||||||
|
Write-Usage
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$repoRoot = Split-Path -Parent $scriptDir
|
||||||
|
$serverRsDir = Join-Path $repoRoot "server-rs"
|
||||||
|
$manifestPath = Join-Path $serverRsDir "Cargo.toml"
|
||||||
|
$modulePath = Join-Path $serverRsDir "crates\spacetime-module"
|
||||||
|
$viteCliPath = Join-Path $repoRoot "scripts\vite-cli.mjs"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($SpacetimeRootDir)) {
|
||||||
|
$SpacetimeRootDir = Join-Path $serverRsDir ".spacetimedb\local"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $manifestPath)) {
|
||||||
|
throw "Missing server-rs/Cargo.toml, cannot start Rust local stack."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path (Join-Path $modulePath "Cargo.toml"))) {
|
||||||
|
throw "Missing server-rs/crates/spacetime-module/Cargo.toml, cannot publish SpacetimeDB module."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $viteCliPath)) {
|
||||||
|
throw "Missing scripts/vite-cli.mjs, cannot start web frontend."
|
||||||
|
}
|
||||||
|
|
||||||
|
$cargoCommand = Get-Command cargo -ErrorAction SilentlyContinue
|
||||||
|
$nodeCommand = Get-Command node -ErrorAction SilentlyContinue
|
||||||
|
$spacetimeCommand = Get-Command spacetime -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($null -eq $cargoCommand) {
|
||||||
|
throw "Missing cargo. Install Rust toolchain first."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $nodeCommand) {
|
||||||
|
throw "Missing node. Install Node.js or use the project bundled runtime first."
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipSpacetime -or -not $SkipPublish) {
|
||||||
|
if ($null -eq $spacetimeCommand) {
|
||||||
|
throw "Missing spacetime CLI. Install guide: https://spacetimedb.com/install"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$spacetimeServer = "http://$SpacetimeHost`:$SpacetimePort"
|
||||||
|
$apiTargetHost = Resolve-ClientHost -HostName $ApiHost
|
||||||
|
$rustServerTarget = "http://$apiTargetHost`:$ApiPort"
|
||||||
|
$stackProcesses = New-Object System.Collections.Generic.List[object]
|
||||||
|
$exitCode = 0
|
||||||
|
|
||||||
|
Write-Host "[dev:rust] repo: $repoRoot"
|
||||||
|
Write-Host "[dev:rust] web: http://127.0.0.1:$WebPort"
|
||||||
|
Write-Host "[dev:rust] rust api: $rustServerTarget"
|
||||||
|
Write-Host "[dev:rust] spacetime: $spacetimeServer"
|
||||||
|
Write-Host "[dev:rust] database: $Database"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$spacetimeProcessItem = $null
|
||||||
|
|
||||||
|
if (-not $SkipSpacetime) {
|
||||||
|
New-Item -ItemType Directory -Force -Path $SpacetimeRootDir | Out-Null
|
||||||
|
$spacetimeProcessItem = Start-StackProcess `
|
||||||
|
-Name "spacetimedb" `
|
||||||
|
-FilePath $spacetimeCommand.Source `
|
||||||
|
-Arguments @(
|
||||||
|
"--root-dir", $SpacetimeRootDir,
|
||||||
|
"start",
|
||||||
|
"--edition", "standalone",
|
||||||
|
"--listen-addr", "$SpacetimeHost`:$SpacetimePort"
|
||||||
|
) `
|
||||||
|
-WorkingDirectory $serverRsDir `
|
||||||
|
-Environment @{}
|
||||||
|
$stackProcesses.Add($spacetimeProcessItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $SkipPublish) {
|
||||||
|
Write-Host "[dev:rust] wait for SpacetimeDB readiness"
|
||||||
|
Wait-ForSpacetimeServer `
|
||||||
|
-CommandPath $spacetimeCommand.Source `
|
||||||
|
-Server $spacetimeServer `
|
||||||
|
-TimeoutSeconds $SpacetimeStartupTimeoutSeconds `
|
||||||
|
-ProcessItem $spacetimeProcessItem
|
||||||
|
|
||||||
|
$publishArgs = @(
|
||||||
|
"publish",
|
||||||
|
$Database,
|
||||||
|
"--server", $spacetimeServer,
|
||||||
|
"--module-path", $modulePath
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($ClearDatabase) {
|
||||||
|
$publishArgs += "--clear-database"
|
||||||
|
}
|
||||||
|
|
||||||
|
$publishArgs += "--yes"
|
||||||
|
|
||||||
|
Write-Host "[dev:rust] publish SpacetimeDB module: $Database"
|
||||||
|
& $spacetimeCommand.Source @publishArgs
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "spacetime publish failed, exit code: $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiEnvironment = @{
|
||||||
|
GENARRATIVE_API_HOST = $ApiHost
|
||||||
|
GENARRATIVE_API_PORT = "$ApiPort"
|
||||||
|
GENARRATIVE_API_LOG = $Log
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL = $spacetimeServer
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE = $Database
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiProcessItem = Start-StackProcess `
|
||||||
|
-Name "api-server" `
|
||||||
|
-FilePath $cargoCommand.Source `
|
||||||
|
-Arguments @("run", "-p", "api-server", "--manifest-path", $manifestPath) `
|
||||||
|
-WorkingDirectory $repoRoot `
|
||||||
|
-Environment $apiEnvironment
|
||||||
|
$stackProcesses.Add($apiProcessItem)
|
||||||
|
|
||||||
|
$webEnvironment = @{
|
||||||
|
GENARRATIVE_BACKEND_STACK = "rust"
|
||||||
|
RUST_SERVER_TARGET = $rustServerTarget
|
||||||
|
GENARRATIVE_RUNTIME_SERVER_TARGET = $rustServerTarget
|
||||||
|
VITE_DEV_HOST = $WebHost
|
||||||
|
}
|
||||||
|
|
||||||
|
$webProcessItem = Start-StackProcess `
|
||||||
|
-Name "vite" `
|
||||||
|
-FilePath $nodeCommand.Source `
|
||||||
|
-Arguments @($viteCliPath, "--port=$WebPort", "--host=$WebHost") `
|
||||||
|
-WorkingDirectory $repoRoot `
|
||||||
|
-Environment $webEnvironment
|
||||||
|
$stackProcesses.Add($webProcessItem)
|
||||||
|
|
||||||
|
Write-Host "[dev:rust] local Rust stack is running. Press Ctrl+C to stop all child processes."
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
foreach ($item in $stackProcesses) {
|
||||||
|
if ($item.Process.HasExited) {
|
||||||
|
$exitCode = $item.Process.ExitCode
|
||||||
|
Write-Host "[dev:rust] $($item.Name) exited, code: $exitCode"
|
||||||
|
throw "Child process exited, shutting down Rust local stack."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
$exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[dev:rust] $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Stop-StackProcesses -Processes $stackProcesses
|
||||||
|
}
|
||||||
|
|
||||||
|
exit $exitCode
|
||||||
280
scripts/dev-rust-stack.sh
Normal file
280
scripts/dev-rust-stack.sh
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
用法:
|
||||||
|
npm run dev:rust:sh
|
||||||
|
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
|
||||||
|
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
|
||||||
|
./scripts/dev-rust-stack.sh --clear-database
|
||||||
|
|
||||||
|
说明:
|
||||||
|
1. 默认同时启动 SpacetimeDB standalone、Rust api-server 与 Vite 前端。
|
||||||
|
2. 默认会 publish server-rs/crates/spacetime-module,但不会清空数据库。
|
||||||
|
3. 只有显式传入 --clear-database 时,才会追加 spacetime publish --clear-database。
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
require_command() {
|
||||||
|
local command_name="$1"
|
||||||
|
|
||||||
|
if ! command -v "${command_name}" >/dev/null 2>&1; then
|
||||||
|
echo "[dev:rust] 缺少命令: ${command_name}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_client_host() {
|
||||||
|
local host_name="$1"
|
||||||
|
|
||||||
|
if [[ "${host_name}" == "0.0.0.0" || "${host_name}" == "::" ]]; then
|
||||||
|
echo "127.0.0.1"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "${host_name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
local index
|
||||||
|
|
||||||
|
for ((index = ${#PIDS[@]} - 1; index >= 0; index--)); do
|
||||||
|
local pid="${PIDS[index]}"
|
||||||
|
local name="${NAMES[index]}"
|
||||||
|
|
||||||
|
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
||||||
|
echo "[dev:rust] 停止 ${name} (pid=${pid})"
|
||||||
|
if command -v pgrep >/dev/null 2>&1; then
|
||||||
|
while read -r child_pid; do
|
||||||
|
if [[ -n "${child_pid}" ]]; then
|
||||||
|
kill "${child_pid}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done < <(pgrep -P "${pid}" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
kill "${pid}" 2>/dev/null || true
|
||||||
|
sleep 0.2
|
||||||
|
if kill -0 "${pid}" 2>/dev/null; then
|
||||||
|
kill -9 "${pid}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_spacetime() {
|
||||||
|
local server="$1"
|
||||||
|
local timeout_seconds="$2"
|
||||||
|
local process_pid="${3:-}"
|
||||||
|
local deadline=$((SECONDS + timeout_seconds))
|
||||||
|
|
||||||
|
while ((SECONDS < deadline)); do
|
||||||
|
if [[ -n "${process_pid}" ]] && ! kill -0 "${process_pid}" 2>/dev/null; then
|
||||||
|
echo "[dev:rust] SpacetimeDB 进程在就绪前退出。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if spacetime server ping "${server}" >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[dev:rust] 等待 SpacetimeDB 就绪超时: ${server}" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
SERVER_RS_DIR="${REPO_ROOT}/server-rs"
|
||||||
|
MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml"
|
||||||
|
MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module"
|
||||||
|
VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs"
|
||||||
|
|
||||||
|
API_HOST="127.0.0.1"
|
||||||
|
API_PORT="8082"
|
||||||
|
WEB_HOST="0.0.0.0"
|
||||||
|
WEB_PORT="3000"
|
||||||
|
SPACETIME_HOST="127.0.0.1"
|
||||||
|
SPACETIME_PORT="3101"
|
||||||
|
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
|
||||||
|
DATABASE="genarrative-dev"
|
||||||
|
API_LOG="info,tower_http=info"
|
||||||
|
SPACETIME_TIMEOUT_SECONDS="60"
|
||||||
|
SKIP_SPACETIME=0
|
||||||
|
SKIP_PUBLISH=0
|
||||||
|
CLEAR_DATABASE=0
|
||||||
|
PIDS=()
|
||||||
|
NAMES=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
--api-host)
|
||||||
|
API_HOST="${2:?缺少 --api-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--api-port)
|
||||||
|
API_PORT="${2:?缺少 --api-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-host)
|
||||||
|
WEB_HOST="${2:?缺少 --web-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--web-port)
|
||||||
|
WEB_PORT="${2:?缺少 --web-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-host)
|
||||||
|
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-port)
|
||||||
|
SPACETIME_PORT="${2:?缺少 --spacetime-port 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-root-dir)
|
||||||
|
SPACETIME_ROOT_DIR="${2:?缺少 --spacetime-root-dir 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--database)
|
||||||
|
DATABASE="${2:?缺少 --database 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--log)
|
||||||
|
API_LOG="${2:?缺少 --log 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--spacetime-timeout-seconds)
|
||||||
|
SPACETIME_TIMEOUT_SECONDS="${2:?缺少 --spacetime-timeout-seconds 的值}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-spacetime)
|
||||||
|
SKIP_SPACETIME=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-publish)
|
||||||
|
SKIP_PUBLISH=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--clear-database)
|
||||||
|
CLEAR_DATABASE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[dev:rust] 未知参数: $1" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ ! -f "${MANIFEST_PATH}" ]]; then
|
||||||
|
echo "[dev:rust] 未找到 ${MANIFEST_PATH},无法启动 Rust 本地栈。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${MODULE_PATH}/Cargo.toml" ]]; then
|
||||||
|
echo "[dev:rust] 未找到 ${MODULE_PATH}/Cargo.toml,无法发布 SpacetimeDB 模块。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${VITE_CLI_PATH}" ]]; then
|
||||||
|
echo "[dev:rust] 未找到 ${VITE_CLI_PATH},无法启动 Web 前端。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_command cargo
|
||||||
|
require_command node
|
||||||
|
|
||||||
|
if [[ "${SKIP_SPACETIME}" -ne 1 || "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||||
|
require_command spacetime
|
||||||
|
fi
|
||||||
|
|
||||||
|
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||||
|
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
|
||||||
|
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
|
||||||
|
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
echo "[dev:rust] repo: ${REPO_ROOT}"
|
||||||
|
echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}"
|
||||||
|
echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
|
||||||
|
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
|
||||||
|
echo "[dev:rust] database: ${DATABASE}"
|
||||||
|
|
||||||
|
if [[ "${SKIP_SPACETIME}" -ne 1 ]]; then
|
||||||
|
mkdir -p "${SPACETIME_ROOT_DIR}"
|
||||||
|
echo "[dev:rust] 启动 spacetimedb"
|
||||||
|
(
|
||||||
|
cd "${SERVER_RS_DIR}"
|
||||||
|
exec spacetime \
|
||||||
|
--root-dir "${SPACETIME_ROOT_DIR}" \
|
||||||
|
start \
|
||||||
|
--edition standalone \
|
||||||
|
--listen-addr "${SPACETIME_HOST}:${SPACETIME_PORT}"
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
NAMES+=("spacetimedb")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${SKIP_PUBLISH}" -ne 1 ]]; then
|
||||||
|
echo "[dev:rust] 等待 SpacetimeDB 就绪"
|
||||||
|
wait_for_spacetime "${SPACETIME_SERVER}" "${SPACETIME_TIMEOUT_SECONDS}" "${PIDS[0]:-}"
|
||||||
|
|
||||||
|
PUBLISH_ARGS=(
|
||||||
|
publish
|
||||||
|
"${DATABASE}"
|
||||||
|
--server "${SPACETIME_SERVER}"
|
||||||
|
--module-path "${MODULE_PATH}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then
|
||||||
|
PUBLISH_ARGS+=(--clear-database)
|
||||||
|
fi
|
||||||
|
|
||||||
|
PUBLISH_ARGS+=(--yes)
|
||||||
|
|
||||||
|
echo "[dev:rust] 发布 SpacetimeDB 模块: ${DATABASE}"
|
||||||
|
spacetime "${PUBLISH_ARGS[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[dev:rust] 启动 api-server"
|
||||||
|
(
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
GENARRATIVE_API_HOST="${API_HOST}" \
|
||||||
|
GENARRATIVE_API_PORT="${API_PORT}" \
|
||||||
|
GENARRATIVE_API_LOG="${API_LOG}" \
|
||||||
|
GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER}" \
|
||||||
|
GENARRATIVE_SPACETIME_DATABASE="${DATABASE}" \
|
||||||
|
exec cargo run -p api-server --manifest-path "${MANIFEST_PATH}"
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
NAMES+=("api-server")
|
||||||
|
|
||||||
|
echo "[dev:rust] 启动 vite"
|
||||||
|
(
|
||||||
|
cd "${REPO_ROOT}"
|
||||||
|
GENARRATIVE_BACKEND_STACK="rust" \
|
||||||
|
RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \
|
||||||
|
GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \
|
||||||
|
VITE_DEV_HOST="${WEB_HOST}" \
|
||||||
|
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
|
||||||
|
) &
|
||||||
|
PIDS+=("$!")
|
||||||
|
NAMES+=("vite")
|
||||||
|
|
||||||
|
echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
wait -n "${PIDS[@]}"
|
||||||
|
EXIT_CODE="$?"
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[dev:rust] 子进程已退出,开始回收本地 Rust 栈,退出码: ${EXIT_CODE}"
|
||||||
|
exit "${EXIT_CODE}"
|
||||||
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;
|
||||||
|
});
|
||||||
55
scripts/run-bash-script.mjs
Normal file
55
scripts/run-bash-script.mjs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {existsSync} from 'node:fs';
|
||||||
|
import {spawn} from 'node:child_process';
|
||||||
|
|
||||||
|
const [, , scriptPath, ...scriptArgs] = process.argv;
|
||||||
|
|
||||||
|
if (!scriptPath) {
|
||||||
|
console.error('[run-bash-script] missing script path.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBashCommand() {
|
||||||
|
if (process.env.GENARRATIVE_BASH) {
|
||||||
|
return process.env.GENARRATIVE_BASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||||
|
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
|
||||||
|
'C:\\msys64\\usr\\bin\\bash.exe',
|
||||||
|
];
|
||||||
|
|
||||||
|
const matched = candidates.find((candidate) => existsSync(candidate));
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'bash';
|
||||||
|
}
|
||||||
|
|
||||||
|
const bashCommand = resolveBashCommand();
|
||||||
|
const child = spawn(bashCommand, [scriptPath, ...scriptArgs], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env,
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (error) => {
|
||||||
|
console.error(`[run-bash-script] failed to start bash: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
console.error(`[run-bash-script] bash exited by signal: ${signal}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
155
server-rs/Cargo.lock
generated
155
server-rs/Cargo.lock
generated
@@ -71,10 +71,12 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"hmac",
|
"hmac",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"httpdate",
|
"httpdate",
|
||||||
|
"image",
|
||||||
"module-ai",
|
"module-ai",
|
||||||
"module-assets",
|
"module-assets",
|
||||||
"module-auth",
|
"module-auth",
|
||||||
@@ -85,6 +87,7 @@ dependencies = [
|
|||||||
"module-npc",
|
"module-npc",
|
||||||
"module-runtime",
|
"module-runtime",
|
||||||
"module-runtime-item",
|
"module-runtime-item",
|
||||||
|
"module-runtime-story-compat",
|
||||||
"module-story",
|
"module-story",
|
||||||
"platform-auth",
|
"platform-auth",
|
||||||
"platform-llm",
|
"platform-llm",
|
||||||
@@ -99,12 +102,14 @@ dependencies = [
|
|||||||
"spacetime-client",
|
"spacetime-client",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"webp",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -293,6 +298,12 @@ version = "1.25.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -325,6 +336,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -653,6 +666,15 @@ version = "2.4.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -849,6 +871,12 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1168,6 +1196,32 @@ dependencies = [
|
|||||||
"icu_properties",
|
"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]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1238,6 +1292,16 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.95"
|
version = "0.3.95"
|
||||||
@@ -1303,6 +1367,16 @@ version = "0.2.185"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
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]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1522,6 +1596,16 @@ dependencies = [
|
|||||||
"spacetimedb",
|
"spacetimedb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "module-runtime-story-compat"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde_json",
|
||||||
|
"shared-contracts",
|
||||||
|
"shared-kernel",
|
||||||
|
"time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "module-story"
|
name = "module-story"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1531,6 +1615,16 @@ dependencies = [
|
|||||||
"spacetimedb",
|
"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]]
|
[[package]]
|
||||||
name = "native-tls"
|
name = "native-tls"
|
||||||
version = "0.2.14"
|
version = "0.2.14"
|
||||||
@@ -1767,6 +1861,19 @@ dependencies = [
|
|||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1845,6 +1952,18 @@ dependencies = [
|
|||||||
"thiserror 1.0.69",
|
"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]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -3062,6 +3181,17 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-stream"
|
||||||
|
version = "0.1.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.27.0"
|
version = "0.27.0"
|
||||||
@@ -3521,6 +3651,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -3897,3 +4037,18 @@ name = "zmij"
|
|||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ members = [
|
|||||||
"crates/module-progression",
|
"crates/module-progression",
|
||||||
"crates/module-quest",
|
"crates/module-quest",
|
||||||
"crates/module-runtime",
|
"crates/module-runtime",
|
||||||
|
"crates/module-runtime-story-compat",
|
||||||
"crates/module-runtime-item",
|
"crates/module-runtime-item",
|
||||||
"crates/module-story",
|
"crates/module-story",
|
||||||
"crates/platform-oss",
|
"crates/platform-oss",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## 2. 当前阶段说明
|
## 2. 当前阶段说明
|
||||||
|
|
||||||
当前目录已经完成以下三十五项初始化:
|
当前目录已经完成以下三十九项初始化:
|
||||||
|
|
||||||
1. 为新后端预留正式目录并把路径固定到仓库结构中。
|
1. 为新后端预留正式目录并把路径固定到仓库结构中。
|
||||||
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
|
2. 创建虚拟 workspace `Cargo.toml`,后续 crate 会逐项挂入。
|
||||||
@@ -52,6 +52,9 @@
|
|||||||
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
|
34. 创建 `scripts/spacetime-dev.ps1`,固定 Windows 本地 SpacetimeDB 启动入口。
|
||||||
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
|
35. 创建 `scripts/spacetime-dev.sh`,固定 Unix-like 本地 SpacetimeDB 启动入口。
|
||||||
36. 创建 `scripts/oss-smoke.ps1`,固定 Windows 本地阿里云 OSS 真实联调入口。
|
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,8 +6,12 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
base64 = "0.22"
|
||||||
|
bytes = "1"
|
||||||
dotenvy = "0.15"
|
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"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
webp = "0.3"
|
||||||
module-ai = { path = "../module-ai" }
|
module-ai = { path = "../module-ai" }
|
||||||
module-assets = { path = "../module-assets" }
|
module-assets = { path = "../module-assets" }
|
||||||
module-auth = { path = "../module-auth" }
|
module-auth = { path = "../module-auth" }
|
||||||
@@ -17,6 +21,7 @@ module-custom-world = { path = "../module-custom-world" }
|
|||||||
module-inventory = { path = "../module-inventory" }
|
module-inventory = { path = "../module-inventory" }
|
||||||
module-npc = { path = "../module-npc" }
|
module-npc = { path = "../module-npc" }
|
||||||
module-runtime = { path = "../module-runtime" }
|
module-runtime = { path = "../module-runtime" }
|
||||||
|
module-runtime-story-compat = { path = "../module-runtime-story-compat" }
|
||||||
module-runtime-item = { path = "../module-runtime-item" }
|
module-runtime-item = { path = "../module-runtime-item" }
|
||||||
module-story = { path = "../module-story" }
|
module-story = { path = "../module-story" }
|
||||||
platform-auth = { path = "../platform-auth" }
|
platform-auth = { path = "../platform-auth" }
|
||||||
@@ -28,7 +33,8 @@ shared-contracts = { path = "../shared-contracts" }
|
|||||||
shared-kernel = { path = "../shared-kernel" }
|
shared-kernel = { path = "../shared-kernel" }
|
||||||
shared-logging = { path = "../shared-logging" }
|
shared-logging = { path = "../shared-logging" }
|
||||||
spacetime-client = { path = "../spacetime-client" }
|
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"] }
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -44,6 +44,23 @@
|
|||||||
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
22. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||||
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
23. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||||
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
24. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||||
|
25. 接入旧 `runtime story` 兼容接口:
|
||||||
|
- `POST /api/runtime/story/state/resolve`
|
||||||
|
- `GET /api/runtime/story/state/{session_id}`
|
||||||
|
- `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 直接相关的任务包括:
|
后续与本 crate 直接相关的任务包括:
|
||||||
|
|
||||||
@@ -68,6 +85,12 @@
|
|||||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||||
21. [x] 接入 `custom world agent session create / snapshot` 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 约定:
|
当前 tracing 约定:
|
||||||
|
|
||||||
@@ -136,3 +159,10 @@
|
|||||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
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 的完整能力。
|
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,
|
middleware,
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, TraceLayer};
|
use tower_http::{
|
||||||
use tracing::{Level, info_span};
|
classify::ServerErrorsFailureClass,
|
||||||
|
trace::{DefaultOnRequest, TraceLayer},
|
||||||
|
};
|
||||||
|
use tracing::{Level, Span, error, info, info_span, warn};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ai_tasks::{
|
ai_tasks::{
|
||||||
@@ -29,13 +32,19 @@ use crate::{
|
|||||||
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
|
start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
|
||||||
submit_big_fish_message,
|
submit_big_fish_message,
|
||||||
},
|
},
|
||||||
|
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::{
|
custom_world::{
|
||||||
create_custom_world_agent_session, execute_custom_world_agent_action,
|
create_custom_world_agent_session, execute_custom_world_agent_action,
|
||||||
get_custom_world_agent_card_detail,
|
get_custom_world_agent_card_detail, get_custom_world_agent_operation,
|
||||||
get_custom_world_agent_operation, get_custom_world_agent_session,
|
get_custom_world_agent_session, get_custom_world_gallery_detail, get_custom_world_library,
|
||||||
get_custom_world_works,
|
get_custom_world_library_detail, get_custom_world_works, list_custom_world_gallery,
|
||||||
get_custom_world_gallery_detail, get_custom_world_library,
|
|
||||||
get_custom_world_library_detail, list_custom_world_gallery,
|
|
||||||
publish_custom_world_library_profile, put_custom_world_library_profile,
|
publish_custom_world_library_profile, put_custom_world_library_profile,
|
||||||
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
stream_custom_world_agent_message, submit_custom_world_agent_message,
|
||||||
unpublish_custom_world_library_profile,
|
unpublish_custom_world_library_profile,
|
||||||
@@ -47,6 +56,11 @@ use crate::{
|
|||||||
},
|
},
|
||||||
error_middleware::normalize_error_response,
|
error_middleware::normalize_error_response,
|
||||||
health::health_check,
|
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,
|
llm::proxy_llm_chat_completions,
|
||||||
login_options::auth_login_options,
|
login_options::auth_login_options,
|
||||||
logout::logout,
|
logout::logout,
|
||||||
@@ -74,8 +88,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
runtime_settings::{get_runtime_settings, put_runtime_settings},
|
||||||
runtime_story::{
|
runtime_story::{
|
||||||
generate_runtime_story_continue, generate_runtime_story_initial,
|
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||||
get_runtime_story_state, resolve_runtime_story_action, resolve_runtime_story_state,
|
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||||
},
|
},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
story_battles::{
|
story_battles::{
|
||||||
@@ -87,6 +101,8 @@ use crate::{
|
|||||||
|
|
||||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
|
let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route(
|
.route(
|
||||||
"/healthz",
|
"/healthz",
|
||||||
@@ -109,6 +125,30 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.route("/api/auth/login-options", get(auth_login_options))
|
.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(
|
.route(
|
||||||
"/api/auth/me",
|
"/api/auth/me",
|
||||||
get(auth_me).route_layer(middleware::from_fn_with_state(
|
get(auth_me).route_layer(middleware::from_fn_with_state(
|
||||||
@@ -248,15 +288,55 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
"/api/assets/objects/bind",
|
"/api/assets/objects/bind",
|
||||||
post(bind_asset_object_to_entity),
|
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/assets/read-url", get(get_asset_read_url))
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/settings",
|
"/api/runtime/settings",
|
||||||
get(get_runtime_settings)
|
get(get_runtime_settings)
|
||||||
.put(put_runtime_settings)
|
.put(put_runtime_settings)
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
state.clone(),
|
state.clone(),
|
||||||
require_bearer_auth,
|
require_bearer_auth,
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/save/snapshot",
|
"/api/runtime/save/snapshot",
|
||||||
@@ -328,9 +408,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
|
"/api/runtime/custom-world/agent/sessions/{session_id}/cards/{card_id}",
|
||||||
get(get_custom_world_agent_card_detail).route_layer(
|
get(get_custom_world_agent_card_detail).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
"/api/runtime/custom-world/agent/sessions/{session_id}/messages",
|
||||||
@@ -523,45 +604,52 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/custom-world/scene-npc",
|
"/api/custom-world/scene-npc",
|
||||||
post(generate_custom_world_scene_npc).route_layer(
|
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/custom-world/scene-npc",
|
"/api/runtime/custom-world/scene-npc",
|
||||||
post(generate_custom_world_scene_npc).route_layer(
|
post(generate_custom_world_scene_npc).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/custom-world/scene-image",
|
"/api/custom-world/scene-image",
|
||||||
post(generate_custom_world_scene_image).route_layer(
|
post(generate_custom_world_scene_image).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/custom-world/cover-image",
|
"/api/custom-world/cover-image",
|
||||||
post(generate_custom_world_cover_image).route_layer(
|
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/custom-world/cover-image",
|
"/api/runtime/custom-world/cover-image",
|
||||||
post(generate_custom_world_cover_image).route_layer(
|
post(generate_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/custom-world/cover-upload",
|
"/api/custom-world/cover-upload",
|
||||||
post(upload_custom_world_cover_image).route_layer(
|
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/custom-world/cover-upload",
|
"/api/runtime/custom-world/cover-upload",
|
||||||
post(upload_custom_world_cover_image).route_layer(
|
post(upload_custom_world_cover_image).route_layer(middleware::from_fn_with_state(
|
||||||
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
|
state.clone(),
|
||||||
),
|
require_bearer_auth,
|
||||||
|
)),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/api/runtime/profile/browse-history",
|
"/api/runtime/profile/browse-history",
|
||||||
@@ -764,8 +852,47 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
.on_request(DefaultOnRequest::new().level(Level::INFO))
|
||||||
.on_response(DefaultOnResponse::new().level(Level::INFO))
|
.on_response(
|
||||||
.on_failure(DefaultOnFailure::new().level(Level::ERROR)),
|
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、错误处理和响应头层都能复用同一份请求标识。
|
// request_id 中间件先进入请求链,确保后续 tracing、错误处理和响应头层都能复用同一份请求标识。
|
||||||
.layer(middleware::from_fn(attach_request_context))
|
.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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,10 @@ pub struct AppConfig {
|
|||||||
pub llm_request_timeout_ms: u64,
|
pub llm_request_timeout_ms: u64,
|
||||||
pub llm_max_retries: u32,
|
pub llm_max_retries: u32,
|
||||||
pub llm_retry_backoff_ms: u64,
|
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 {
|
impl Default for AppConfig {
|
||||||
@@ -107,6 +111,10 @@ impl Default for AppConfig {
|
|||||||
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||||
llm_max_retries: DEFAULT_MAX_RETRIES,
|
llm_max_retries: DEFAULT_MAX_RETRIES,
|
||||||
llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -311,6 +319,24 @@ impl AppConfig {
|
|||||||
config.llm_retry_backoff_ms = llm_retry_backoff_ms;
|
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
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
extract::{Extension, Path, State, rejection::JsonRejection},
|
||||||
http::{HeaderName, StatusCode, header},
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{
|
||||||
|
IntoResponse, Response,
|
||||||
|
sse::{Event, Sse},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use module_custom_world::{
|
use module_custom_world::{
|
||||||
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
CustomWorldThemeMode, empty_agent_anchor_content_json, empty_agent_asset_coverage_json,
|
||||||
@@ -10,31 +13,31 @@ use module_custom_world::{
|
|||||||
};
|
};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
use shared_contracts::runtime::{
|
use shared_contracts::runtime::{
|
||||||
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCheckpointResponse,
|
CreateCustomWorldAgentSessionRequest, CustomWorldAgentCardDetailResponse,
|
||||||
CustomWorldAgentCardDetailResponse,
|
CustomWorldAgentCheckpointResponse, CustomWorldAgentMessageResponse,
|
||||||
CustomWorldAgentMessageResponse, CustomWorldAgentOperationResponse,
|
CustomWorldAgentOperationResponse, CustomWorldAgentSessionResponse,
|
||||||
CustomWorldAgentSessionResponse, CustomWorldAgentSessionSnapshotResponse,
|
CustomWorldAgentSessionSnapshotResponse, CustomWorldDraftCardDetailResponse,
|
||||||
CustomWorldDraftCardDetailResponse, CustomWorldDraftCardDetailSectionResponse,
|
CustomWorldDraftCardDetailSectionResponse, CustomWorldDraftCardSummaryResponse,
|
||||||
CustomWorldDraftCardSummaryResponse, CustomWorldGalleryCardResponse,
|
CustomWorldGalleryCardResponse, CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse,
|
||||||
CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, CustomWorldLibraryEntryResponse,
|
CustomWorldLibraryEntryResponse, CustomWorldLibraryMutationResponse,
|
||||||
CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse,
|
CustomWorldLibraryResponse, CustomWorldProfileUpsertRequest, CustomWorldPublishGateResponse,
|
||||||
CustomWorldProfileUpsertRequest, CustomWorldSupportedActionResponse,
|
CustomWorldResultPreviewBlockerResponse, CustomWorldSupportedActionResponse,
|
||||||
CustomWorldPublishGateResponse, CustomWorldResultPreviewBlockerResponse,
|
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse, ExecuteCustomWorldAgentActionRequest,
|
||||||
CustomWorldWorkSummaryResponse, CustomWorldWorksResponse,
|
SendCustomWorldAgentMessageRequest,
|
||||||
ExecuteCustomWorldAgentActionRequest, SendCustomWorldAgentMessageRequest,
|
|
||||||
};
|
};
|
||||||
use shared_kernel::build_prefixed_uuid_id;
|
use shared_kernel::build_prefixed_uuid_id;
|
||||||
use spacetime_client::{
|
use spacetime_client::{
|
||||||
CustomWorldAgentActionExecuteRecordInput,
|
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
|
||||||
CustomWorldAgentCheckpointRecord, CustomWorldAgentMessageRecord,
|
CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput,
|
||||||
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
|
CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput,
|
||||||
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
|
CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord,
|
||||||
CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord,
|
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
|
||||||
CustomWorldDraftCardRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord,
|
||||||
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
|
||||||
CustomWorldResultPreviewBlockerRecord, CustomWorldWorkSummaryRecord,
|
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
|
||||||
CustomWorldSupportedActionRecord, SpacetimeClientError,
|
CustomWorldWorkSummaryRecord, SpacetimeClientError,
|
||||||
};
|
};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
||||||
@@ -84,10 +87,7 @@ pub async fn get_custom_world_library_detail(
|
|||||||
|
|
||||||
let detail = state
|
let detail = state
|
||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.get_custom_world_library_detail(
|
.get_custom_world_library_detail(authenticated.claims().user_id().to_string(), profile_id)
|
||||||
authenticated.claims().user_id().to_string(),
|
|
||||||
profile_id,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
custom_world_error_response(&request_context, map_custom_world_client_error(error))
|
||||||
@@ -580,21 +580,23 @@ pub async fn stream_custom_world_agent_message(
|
|||||||
let session_response = map_custom_world_agent_session_response(session);
|
let session_response = map_custom_world_agent_session_response(session);
|
||||||
let reply_text = resolve_stream_reply_text(&session_response);
|
let reply_text = resolve_stream_reply_text(&session_response);
|
||||||
|
|
||||||
// 这里先用“一次性构造完整 SSE 文本”的最小兼容方案,
|
// 这里仍保持“一次性返回完整事件序列”的兼容语义;
|
||||||
// 复用 Stage 7 的同步 deterministic 写表逻辑,保证前端当前的 reader 协议可直接消费。
|
// SSE 编码、标准响应头与 body frame 交给 Axum 内建实现维护。
|
||||||
let mut sse_body = String::new();
|
let events = vec![
|
||||||
append_sse_event(&mut sse_body, "reply_delta", &json!({ "text": reply_text }))
|
custom_world_sse_json_event("reply_delta", json!({ "text": reply_text }))
|
||||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||||
append_sse_event(
|
custom_world_sse_json_event("session", json!({ "session": session_response }))
|
||||||
&mut sse_body,
|
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||||
"session",
|
custom_world_sse_json_event("done", json!({ "ok": true }))
|
||||||
&json!({ "session": session_response }),
|
.map_err(|error| custom_world_error_response(&request_context, error))?,
|
||||||
)
|
];
|
||||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
let stream = tokio_stream::iter(
|
||||||
append_sse_event(&mut sse_body, "done", &json!({ "ok": true }))
|
events
|
||||||
.map_err(|error| custom_world_error_response(&request_context, error))?;
|
.into_iter()
|
||||||
|
.map(|event| Ok::<Event, Infallible>(event)),
|
||||||
|
);
|
||||||
|
|
||||||
Ok(build_event_stream_response(sse_body))
|
Ok(Sse::new(stream).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_custom_world_agent_operation(
|
pub async fn get_custom_world_agent_operation(
|
||||||
@@ -815,7 +817,9 @@ fn map_custom_world_agent_session_response(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(map_custom_world_supported_action_response)
|
.map(map_custom_world_supported_action_response)
|
||||||
.collect(),
|
.collect(),
|
||||||
publish_gate: session.publish_gate.map(map_custom_world_publish_gate_response),
|
publish_gate: session
|
||||||
|
.publish_gate
|
||||||
|
.map(map_custom_world_publish_gate_response),
|
||||||
result_preview: session.result_preview,
|
result_preview: session.result_preview,
|
||||||
updated_at: session.updated_at,
|
updated_at: session.updated_at,
|
||||||
}
|
}
|
||||||
@@ -958,40 +962,11 @@ fn resolve_stream_reply_text(session: &CustomWorldAgentSessionSnapshotResponse)
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_sse_event(body: &mut String, event: &str, payload: &Value) -> Result<(), AppError> {
|
|
||||||
let payload_text = serde_json::to_string(payload).map_err(|error| {
|
|
||||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
|
||||||
"provider": "custom-world-agent",
|
|
||||||
"message": format!("SSE payload 序列化失败:{error}"),
|
|
||||||
}))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
body.push_str("event: ");
|
|
||||||
body.push_str(event);
|
|
||||||
body.push('\n');
|
|
||||||
body.push_str("data: ");
|
|
||||||
body.push_str(&payload_text);
|
|
||||||
body.push_str("\n\n");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_event_stream_response(body: String) -> Response {
|
|
||||||
(
|
|
||||||
[
|
|
||||||
(header::CONTENT_TYPE, "text/event-stream; charset=utf-8"),
|
|
||||||
(header::CACHE_CONTROL, "no-cache"),
|
|
||||||
// 反向代理场景下显式关闭缓冲,避免 SSE 事件被聚合后才下发。
|
|
||||||
(HeaderName::from_static("x-accel-buffering"), "no"),
|
|
||||||
],
|
|
||||||
body,
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
|
fn map_custom_world_client_error(error: SpacetimeClientError) -> AppError {
|
||||||
let status = match &error {
|
let status = match &error {
|
||||||
SpacetimeClientError::Procedure(message) if message.contains("custom_world_profile 不存在") => {
|
SpacetimeClientError::Procedure(message)
|
||||||
|
if message.contains("custom_world_profile 不存在") =>
|
||||||
|
{
|
||||||
StatusCode::NOT_FOUND
|
StatusCode::NOT_FOUND
|
||||||
}
|
}
|
||||||
SpacetimeClientError::Procedure(message)
|
SpacetimeClientError::Procedure(message)
|
||||||
@@ -1018,6 +993,18 @@ fn custom_world_error_response(request_context: &RequestContext, error: AppError
|
|||||||
error.into_response_with_context(Some(request_context))
|
error.into_response_with_context(Some(request_context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn custom_world_sse_json_event(event_name: &str, payload: Value) -> Result<Event, AppError> {
|
||||||
|
Event::default()
|
||||||
|
.event(event_name)
|
||||||
|
.json_data(payload)
|
||||||
|
.map_err(|error| {
|
||||||
|
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||||
|
"provider": "sse",
|
||||||
|
"message": format!("SSE payload 序列化失败:{error}"),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
|
fn resolve_author_display_name(_authenticated: &AuthenticatedAccessToken) -> String {
|
||||||
"玩家".to_string()
|
"玩家".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,15 @@ mod auth_me;
|
|||||||
mod auth_session;
|
mod auth_session;
|
||||||
mod auth_sessions;
|
mod auth_sessions;
|
||||||
mod big_fish;
|
mod big_fish;
|
||||||
|
mod character_animation_assets;
|
||||||
|
mod character_visual_assets;
|
||||||
mod config;
|
mod config;
|
||||||
mod custom_world;
|
mod custom_world;
|
||||||
mod custom_world_ai;
|
mod custom_world_ai;
|
||||||
mod error_middleware;
|
mod error_middleware;
|
||||||
mod health;
|
mod health;
|
||||||
mod http_error;
|
mod http_error;
|
||||||
|
mod legacy_generated_assets;
|
||||||
mod llm;
|
mod llm;
|
||||||
mod login_options;
|
mod login_options;
|
||||||
mod logout;
|
mod logout;
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ pub async fn get_runtime_snapshot(
|
|||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let user_id = authenticated.claims().user_id().to_string();
|
let user_id = authenticated.claims().user_id().to_string();
|
||||||
let record = state
|
let record = state
|
||||||
.spacetime_client()
|
.get_runtime_snapshot_record(user_id)
|
||||||
.get_runtime_snapshot(user_id)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
.map_err(|error| {
|
||||||
|
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -70,8 +71,7 @@ pub async fn put_runtime_snapshot(
|
|||||||
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
let saved_at_micros = offset_datetime_to_unix_micros(saved_at);
|
||||||
|
|
||||||
let record = state
|
let record = state
|
||||||
.spacetime_client()
|
.put_runtime_snapshot_record(
|
||||||
.put_runtime_snapshot(
|
|
||||||
user_id,
|
user_id,
|
||||||
saved_at_micros,
|
saved_at_micros,
|
||||||
payload.bottom_tab,
|
payload.bottom_tab,
|
||||||
@@ -80,7 +80,9 @@ pub async fn put_runtime_snapshot(
|
|||||||
updated_at_micros,
|
updated_at_micros,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
.map_err(|error| {
|
||||||
|
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -95,10 +97,11 @@ pub async fn delete_runtime_snapshot(
|
|||||||
) -> Result<Json<Value>, Response> {
|
) -> Result<Json<Value>, Response> {
|
||||||
let user_id = authenticated.claims().user_id().to_string();
|
let user_id = authenticated.claims().user_id().to_string();
|
||||||
state
|
state
|
||||||
.spacetime_client()
|
.delete_runtime_snapshot_record(user_id)
|
||||||
.delete_runtime_snapshot(user_id)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
.map_err(|error| {
|
||||||
|
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -116,7 +119,9 @@ pub async fn list_profile_save_archives(
|
|||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.list_profile_save_archives(user_id)
|
.list_profile_save_archives(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_client_error(error)))?;
|
.map_err(|error| {
|
||||||
|
runtime_save_error_response(&request_context, map_runtime_save_client_error(error))
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -151,7 +156,12 @@ pub async fn resume_profile_save_archive(
|
|||||||
.spacetime_client()
|
.spacetime_client()
|
||||||
.resume_profile_save_archive(user_id, world_key)
|
.resume_profile_save_archive(user_id, world_key)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| runtime_save_error_response(&request_context, map_runtime_save_resume_client_error(error)))?;
|
.map_err(|error| {
|
||||||
|
runtime_save_error_response(
|
||||||
|
&request_context,
|
||||||
|
map_runtime_save_resume_client_error(error),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(json_success_body(
|
Ok(json_success_body(
|
||||||
Some(&request_context),
|
Some(&request_context),
|
||||||
@@ -205,7 +215,8 @@ fn map_runtime_save_client_error(error: SpacetimeClientError) -> AppError {
|
|||||||
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
|
fn map_runtime_save_resume_client_error(error: SpacetimeClientError) -> AppError {
|
||||||
let (status, provider) = match &error {
|
let (status, provider) = match &error {
|
||||||
SpacetimeClientError::Procedure(message)
|
SpacetimeClientError::Procedure(message)
|
||||||
if message.contains("world_key 不存在") || message.contains("对应 world_key 不存在") =>
|
if message.contains("world_key 不存在")
|
||||||
|
|| message.contains("对应 world_key 不存在") =>
|
||||||
{
|
{
|
||||||
(StatusCode::NOT_FOUND, "runtime-save")
|
(StatusCode::NOT_FOUND, "runtime-save")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,733 +1,6 @@
|
|||||||
use axum::{
|
mod compat;
|
||||||
Json,
|
|
||||||
extract::{Extension, Path, State, rejection::JsonRejection},
|
pub use compat::{
|
||||||
http::StatusCode,
|
generate_runtime_story_continue, generate_runtime_story_initial, get_runtime_story_state,
|
||||||
response::Response,
|
resolve_runtime_story_action, resolve_runtime_story_state,
|
||||||
};
|
};
|
||||||
use serde_json::{Value, json};
|
|
||||||
use shared_contracts::runtime_story::{
|
|
||||||
RuntimeStoryActionResponse, RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel,
|
|
||||||
RuntimeStoryOptionInteraction, RuntimeStoryOptionView, RuntimeStoryPlayerViewModel,
|
|
||||||
RuntimeStoryPresentation, RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
|
||||||
RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError,
|
|
||||||
request_context::RequestContext, state::AppState,
|
|
||||||
};
|
|
||||||
|
|
||||||
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 = payload.snapshot.ok_or_else(|| {
|
|
||||||
runtime_story_error_response(
|
|
||||||
&request_context,
|
|
||||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
|
||||||
"provider": "runtime-story",
|
|
||||||
"field": "snapshot",
|
|
||||||
"message": "当前首版兼容状态桥要求随请求提交 snapshot",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
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 不能为空",
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
|
||||||
Some(&request_context),
|
|
||||||
build_runtime_story_state_response(&session_id, None, build_runtime_story_empty_snapshot(&session_id)),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn resolve_runtime_story_action(
|
|
||||||
State(_state): State<AppState>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
||||||
payload: Result<Json<Value>, JsonRejection>,
|
|
||||||
) -> Result<Json<Value>, Response> {
|
|
||||||
let payload = optional_runtime_story_payload(payload)?;
|
|
||||||
let session_id = read_payload_session_id(&payload).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 client_version = read_u32_field(&payload, "clientVersion");
|
|
||||||
let snapshot = read_payload_snapshot(&payload)
|
|
||||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
|
||||||
let mut response = build_runtime_story_state_response(&session_id, client_version, snapshot);
|
|
||||||
response.presentation.action_text = read_runtime_story_action_text(&payload).unwrap_or_default();
|
|
||||||
|
|
||||||
Ok(json_success_body(Some(&request_context), response))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_runtime_story_initial(
|
|
||||||
State(_state): State<AppState>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
||||||
payload: Result<Json<Value>, JsonRejection>,
|
|
||||||
) -> Result<Json<Value>, Response> {
|
|
||||||
let payload = optional_runtime_story_payload(payload)?;
|
|
||||||
let session_id = read_payload_session_id(&payload).unwrap_or_else(|| "runtime-main".to_string());
|
|
||||||
let client_version = read_u32_field(&payload, "clientVersion");
|
|
||||||
let snapshot = read_payload_snapshot(&payload)
|
|
||||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
|
||||||
Some(&request_context),
|
|
||||||
build_runtime_story_state_response(&session_id, client_version, snapshot),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn generate_runtime_story_continue(
|
|
||||||
State(_state): State<AppState>,
|
|
||||||
Extension(request_context): Extension<RequestContext>,
|
|
||||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
|
||||||
payload: Result<Json<Value>, JsonRejection>,
|
|
||||||
) -> Result<Json<Value>, Response> {
|
|
||||||
let payload = optional_runtime_story_payload(payload)?;
|
|
||||||
let session_id = read_payload_session_id(&payload).unwrap_or_else(|| "runtime-main".to_string());
|
|
||||||
let client_version = read_u32_field(&payload, "clientVersion");
|
|
||||||
let snapshot = read_payload_snapshot(&payload)
|
|
||||||
.unwrap_or_else(|| build_runtime_story_empty_snapshot(&session_id));
|
|
||||||
|
|
||||||
Ok(json_success_body(
|
|
||||||
Some(&request_context),
|
|
||||||
build_runtime_story_state_response(&session_id, client_version, snapshot),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_runtime_story_state_response(
|
|
||||||
requested_session_id: &str,
|
|
||||||
client_version: Option<u32>,
|
|
||||||
snapshot: RuntimeStorySnapshotPayload,
|
|
||||||
) -> RuntimeStoryActionResponse {
|
|
||||||
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);
|
|
||||||
|
|
||||||
RuntimeStoryActionResponse {
|
|
||||||
session_id,
|
|
||||||
server_version,
|
|
||||||
view_model: RuntimeStoryViewModel {
|
|
||||||
player: RuntimeStoryPlayerViewModel {
|
|
||||||
hp: read_i32_field(&snapshot.game_state, "playerHp").unwrap_or(0),
|
|
||||||
max_hp: read_i32_field(&snapshot.game_state, "playerMaxHp").unwrap_or(1),
|
|
||||||
mana: read_i32_field(&snapshot.game_state, "playerMana").unwrap_or(0),
|
|
||||||
max_mana: read_i32_field(&snapshot.game_state, "playerMaxMana").unwrap_or(1),
|
|
||||||
},
|
|
||||||
encounter: build_runtime_story_encounter(&snapshot.game_state),
|
|
||||||
companions: build_runtime_story_companions(&snapshot.game_state),
|
|
||||||
available_options: options.clone(),
|
|
||||||
status: RuntimeStoryStatusViewModel {
|
|
||||||
in_battle: read_bool_field(&snapshot.game_state, "inBattle").unwrap_or(false),
|
|
||||||
npc_interaction_active: read_bool_field(&snapshot.game_state, "npcInteractionActive")
|
|
||||||
.unwrap_or(false),
|
|
||||||
current_npc_battle_mode: read_optional_string_field(
|
|
||||||
&snapshot.game_state,
|
|
||||||
"currentNpcBattleMode",
|
|
||||||
),
|
|
||||||
current_npc_battle_outcome: read_optional_string_field(
|
|
||||||
&snapshot.game_state,
|
|
||||||
"currentNpcBattleOutcome",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
presentation: RuntimeStoryPresentation {
|
|
||||||
action_text: String::new(),
|
|
||||||
result_text: String::new(),
|
|
||||||
story_text,
|
|
||||||
options,
|
|
||||||
toast: None,
|
|
||||||
battle: None,
|
|
||||||
},
|
|
||||||
patches: Vec::new(),
|
|
||||||
snapshot,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optional_runtime_story_payload(
|
|
||||||
payload: Result<Json<Value>, JsonRejection>,
|
|
||||||
) -> Result<Value, Response> {
|
|
||||||
match payload {
|
|
||||||
Ok(Json(value)) => Ok(value),
|
|
||||||
Err(error) if error.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE => Ok(json!({})),
|
|
||||||
Err(error) if error.status() == StatusCode::BAD_REQUEST => Ok(json!({})),
|
|
||||||
Err(error) => Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
|
||||||
.with_details(json!({
|
|
||||||
"provider": "runtime-story",
|
|
||||||
"message": error.body_text(),
|
|
||||||
}))
|
|
||||||
.into_response_with_context(None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_payload_session_id(payload: &Value) -> Option<String> {
|
|
||||||
read_required_string_field(payload, "sessionId")
|
|
||||||
.or_else(|| read_field(payload, "action").and_then(|action| read_required_string_field(action, "sessionId")))
|
|
||||||
.or_else(|| read_field(payload, "snapshot").and_then(|snapshot| {
|
|
||||||
read_object_field(snapshot, "gameState").and_then(read_runtime_session_id)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_payload_snapshot(payload: &Value) -> Option<RuntimeStorySnapshotPayload> {
|
|
||||||
let snapshot = read_field(payload, "snapshot")?.clone();
|
|
||||||
serde_json::from_value(snapshot).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_runtime_story_action_text(payload: &Value) -> Option<String> {
|
|
||||||
let action = read_field(payload, "action")?;
|
|
||||||
read_optional_string_field(action, "functionId")
|
|
||||||
.or_else(|| read_optional_string_field(action, "type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_runtime_story_empty_snapshot(session_id: &str) -> RuntimeStorySnapshotPayload {
|
|
||||||
RuntimeStorySnapshotPayload {
|
|
||||||
saved_at: time::OffsetDateTime::now_utc()
|
|
||||||
.format(&time::format_description::well_known::Rfc3339)
|
|
||||||
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()),
|
|
||||||
bottom_tab: "adventure".to_string(),
|
|
||||||
game_state: json!({
|
|
||||||
"runtimeSessionId": session_id,
|
|
||||||
"runtimeActionVersion": 0,
|
|
||||||
"playerHp": 1,
|
|
||||||
"playerMaxHp": 1,
|
|
||||||
"playerMana": 0,
|
|
||||||
"playerMaxMana": 1,
|
|
||||||
"inBattle": false,
|
|
||||||
"npcInteractionActive": false
|
|
||||||
}),
|
|
||||||
current_story: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
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")?;
|
|
||||||
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"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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").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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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"),
|
|
||||||
}),
|
|
||||||
"treasure" => Some(RuntimeStoryOptionInteraction::Treasure {
|
|
||||||
action: read_required_string_field(interaction, "action")?,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_fallback_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
|
|
||||||
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
|
||||||
return vec![
|
|
||||||
build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat"),
|
|
||||||
build_static_runtime_story_option("battle_recover_breath", "恢复", "combat"),
|
|
||||||
build_static_runtime_story_option("battle_escape_breakout", "强行脱离战斗", "combat"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
let encounter = read_object_field(game_state, "currentEncounter");
|
|
||||||
if let Some(encounter) = encounter {
|
|
||||||
match read_required_string_field(encounter, "kind").as_deref() {
|
|
||||||
Some("npc") => {
|
|
||||||
let interaction_active =
|
|
||||||
read_bool_field(game_state, "npcInteractionActive").unwrap_or(false);
|
|
||||||
if interaction_active {
|
|
||||||
return vec![
|
|
||||||
build_static_runtime_story_option("npc_chat", "继续交谈", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_help", "请求援手", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_spar", "点到为止切磋", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return vec![
|
|
||||||
build_static_runtime_story_option("npc_preview_talk", "转向眼前角色", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_fight", "与对方战斗", "npc"),
|
|
||||||
build_static_runtime_story_option("npc_leave", "离开当前角色", "npc"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
Some("treasure") => {
|
|
||||||
return vec![
|
|
||||||
build_static_runtime_story_option("treasure_secure", "直接收取", "story"),
|
|
||||||
build_static_runtime_story_option("treasure_inspect", "仔细检查", "story"),
|
|
||||||
build_static_runtime_story_option("treasure_leave", "先记下位置", "story"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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("story_continue_adventure", "继续推进冒险", "story"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_story_text(current_story: Option<&Value>) -> Option<String> {
|
|
||||||
current_story.and_then(|story| read_optional_string_field(story, "text"))
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
|
||||||
read_optional_string_field(game_state, "runtimeSessionId")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
|
||||||
value.as_object()?.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
|
||||||
normalize_required_string(read_field(value, key)?.as_str()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
|
||||||
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
|
||||||
read_field(value, key).and_then(Value::as_bool)
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_required_string(value: &str) -> Option<String> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
|
||||||
value.and_then(normalize_required_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn runtime_story_error_response(request_context: &RequestContext, error: AppError) -> Response {
|
|
||||||
error.into_response_with_context(Some(request_context))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use axum::{
|
|
||||||
body::Body,
|
|
||||||
http::{Request, StatusCode},
|
|
||||||
};
|
|
||||||
use http_body_util::BodyExt;
|
|
||||||
use platform_auth::{
|
|
||||||
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token,
|
|
||||||
};
|
|
||||||
use serde_json::{Value, json};
|
|
||||||
use time::OffsetDateTime;
|
|
||||||
use tower::ServiceExt;
|
|
||||||
|
|
||||||
use crate::{app::build_router, config::AppConfig, state::AppState};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn runtime_story_state_resolve_requires_authentication() {
|
|
||||||
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/runtime/story/state/resolve")
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
|
||||||
"sessionId": "runtime-main",
|
|
||||||
"snapshot": {
|
|
||||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
|
||||||
"bottomTab": "adventure",
|
|
||||||
"gameState": {
|
|
||||||
"runtimeSessionId": "runtime-main"
|
|
||||||
},
|
|
||||||
"currentStory": null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn runtime_story_state_resolve_rejects_missing_snapshot() {
|
|
||||||
let state = seed_authenticated_state().await;
|
|
||||||
let token = issue_access_token(&state);
|
|
||||||
let app = build_router(state);
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/runtime/story/state/resolve")
|
|
||||||
.header("authorization", format!("Bearer {token}"))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.header("x-genarrative-response-envelope", "v1")
|
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
|
||||||
"sessionId": "runtime-main"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn runtime_story_state_resolve_returns_compiled_snapshot_response() {
|
|
||||||
let state = seed_authenticated_state().await;
|
|
||||||
let token = issue_access_token(&state);
|
|
||||||
let app = build_router(state);
|
|
||||||
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/runtime/story/state/resolve")
|
|
||||||
.header("authorization", format!("Bearer {token}"))
|
|
||||||
.header("content-type", "application/json")
|
|
||||||
.header("x-genarrative-response-envelope", "v1")
|
|
||||||
.body(Body::from(
|
|
||||||
json!({
|
|
||||||
"sessionId": "runtime-main",
|
|
||||||
"clientVersion": 7,
|
|
||||||
"snapshot": {
|
|
||||||
"savedAt": "2026-04-22T12:00:00.000Z",
|
|
||||||
"bottomTab": "adventure",
|
|
||||||
"gameState": {
|
|
||||||
"runtimeSessionId": "runtime-main",
|
|
||||||
"runtimeActionVersion": 7,
|
|
||||||
"playerHp": 32,
|
|
||||||
"playerMaxHp": 40,
|
|
||||||
"playerMana": 18,
|
|
||||||
"playerMaxMana": 20,
|
|
||||||
"inBattle": false,
|
|
||||||
"npcInteractionActive": true,
|
|
||||||
"currentEncounter": {
|
|
||||||
"id": "npc_camp_firekeeper",
|
|
||||||
"kind": "npc",
|
|
||||||
"npcName": "守火人",
|
|
||||||
"hostile": false
|
|
||||||
},
|
|
||||||
"npcStates": {
|
|
||||||
"npc_camp_firekeeper": {
|
|
||||||
"affinity": 12,
|
|
||||||
"recruited": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"companions": [{
|
|
||||||
"npcId": "npc_companion_001",
|
|
||||||
"characterId": "char_companion_001",
|
|
||||||
"joinedAtAffinity": 64
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
"currentStory": {
|
|
||||||
"text": "守火人抬眼看了你一瞬,示意你把想问的话继续说完。",
|
|
||||||
"displayMode": "dialogue",
|
|
||||||
"options": [{
|
|
||||||
"functionId": "story_continue_adventure",
|
|
||||||
"actionText": "继续冒险"
|
|
||||||
}],
|
|
||||||
"deferredOptions": [{
|
|
||||||
"functionId": "npc_chat",
|
|
||||||
"actionText": "继续交谈",
|
|
||||||
"detailText": "围绕当前话题继续推进关系判断。",
|
|
||||||
"interaction": {
|
|
||||||
"kind": "npc",
|
|
||||||
"npcId": "npc_camp_firekeeper",
|
|
||||||
"action": "chat"
|
|
||||||
},
|
|
||||||
"runtimePayload": {
|
|
||||||
"note": "server-runtime-test"
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
))
|
|
||||||
.expect("request should build"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request should succeed");
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
let body = response
|
|
||||||
.into_body()
|
|
||||||
.collect()
|
|
||||||
.await
|
|
||||||
.expect("body should collect")
|
|
||||||
.to_bytes();
|
|
||||||
let payload: Value =
|
|
||||||
serde_json::from_slice(&body).expect("response body should be valid json");
|
|
||||||
|
|
||||||
assert_eq!(payload["ok"], Value::Bool(true));
|
|
||||||
assert_eq!(payload["data"]["sessionId"], json!("runtime-main"));
|
|
||||||
assert_eq!(payload["data"]["serverVersion"], json!(7));
|
|
||||||
assert_eq!(
|
|
||||||
payload["data"]["viewModel"]["encounter"]["npcName"],
|
|
||||||
json!("守火人")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
payload["data"]["viewModel"]["availableOptions"][0]["functionId"],
|
|
||||||
json!("npc_chat")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
payload["data"]["presentation"]["options"][0]["interaction"]["npcId"],
|
|
||||||
json!("npc_camp_firekeeper")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
payload["data"]["snapshot"]["currentStory"]["deferredOptions"][0]["functionId"],
|
|
||||||
json!("npc_chat")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn seed_authenticated_state() -> AppState {
|
|
||||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
|
||||||
state
|
|
||||||
.password_entry_service()
|
|
||||||
.execute(module_auth::PasswordEntryInput {
|
|
||||||
username: "runtime_story_state_user".to_string(),
|
|
||||||
password: "secret123".to_string(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("seed login should succeed");
|
|
||||||
state
|
|
||||||
}
|
|
||||||
|
|
||||||
fn issue_access_token(state: &AppState) -> String {
|
|
||||||
let claims = AccessTokenClaims::from_input(
|
|
||||||
AccessTokenClaimsInput {
|
|
||||||
user_id: "user_00000001".to_string(),
|
|
||||||
session_id: "sess_runtime_story_state".to_string(),
|
|
||||||
provider: AuthProvider::Password,
|
|
||||||
roles: vec!["user".to_string()],
|
|
||||||
token_version: 1,
|
|
||||||
phone_verified: true,
|
|
||||||
binding_status: BindingStatus::Active,
|
|
||||||
display_name: Some("运行时剧情状态用户".to_string()),
|
|
||||||
},
|
|
||||||
state.auth_jwt_config(),
|
|
||||||
OffsetDateTime::now_utc(),
|
|
||||||
)
|
|
||||||
.expect("claims should build");
|
|
||||||
|
|
||||||
sign_access_token(&claims, state.auth_jwt_config()).expect("token should sign")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
635
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
635
server-rs/crates/api-server/src/runtime_story/compat.rs
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
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 module_runtime_story_compat::{
|
||||||
|
CONTINUE_ADVENTURE_FUNCTION_ID, CurrentEncounterNpcQuestContext, GeneratedStoryPayload,
|
||||||
|
PendingQuestOfferContext, RuntimeStoryActionResponseParts,
|
||||||
|
StoryResolution, add_player_currency, add_player_inventory_items,
|
||||||
|
append_story_history, apply_equipment_loadout_to_state,
|
||||||
|
battle_mode_text, build_battle_runtime_story_options, build_current_build_toast,
|
||||||
|
build_status_patch,
|
||||||
|
build_npc_gift_result_text,
|
||||||
|
build_runtime_story_view_model,
|
||||||
|
clear_encounter_only, clear_encounter_state, clone_inventory_item_with_quantity,
|
||||||
|
current_encounter_id, current_encounter_name, current_world_type,
|
||||||
|
ensure_inventory_action_available, ensure_json_object, equipment_slot_label,
|
||||||
|
find_player_inventory_entry,
|
||||||
|
format_now_rfc3339, grant_player_progression_experience, has_giftable_player_inventory,
|
||||||
|
format_currency_text,
|
||||||
|
increment_runtime_stat, normalize_equipped_item,
|
||||||
|
normalize_equipment_slot_id, normalize_required_string, npc_buyback_price,
|
||||||
|
npc_purchase_price, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||||
|
read_inventory_item_name, read_object_field, read_optional_string_field,
|
||||||
|
read_player_equipment_item, read_required_string_field, read_runtime_session_id,
|
||||||
|
read_u32_field, recruit_companion_to_party, remove_player_inventory_item,
|
||||||
|
restore_player_resource,
|
||||||
|
resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item,
|
||||||
|
resolve_forge_craft_action,
|
||||||
|
resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||||
|
resolve_npc_gift_affinity_gain, simple_story_resolution, trade_quantity_suffix,
|
||||||
|
resolve_current_encounter_npc_state,
|
||||||
|
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||||
|
build_static_runtime_story_option, build_story_option_from_runtime_option,
|
||||||
|
write_bool_field, write_i32_field, write_null_field, write_player_equipment_item,
|
||||||
|
write_string_field, write_u32_field,
|
||||||
|
};
|
||||||
|
use platform_llm::{LlmClient, LlmMessage, LlmTextRequest};
|
||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use shared_contracts::runtime_story::{
|
||||||
|
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse,
|
||||||
|
RuntimeStoryAiRequest, RuntimeStoryAiResponse, RuntimeStoryOptionInteraction,
|
||||||
|
RuntimeStoryOptionView, RuntimeStoryPatch, RuntimeStoryPresentation,
|
||||||
|
RuntimeStorySnapshotPayload, RuntimeStoryStateResolveRequest,
|
||||||
|
};
|
||||||
|
use shared_kernel::{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/equipment_actions.rs"]
|
||||||
|
mod equipment_actions;
|
||||||
|
#[path = "compat/game_state.rs"]
|
||||||
|
mod game_state;
|
||||||
|
#[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::*, equipment_actions::*, game_state::*, npc_actions::*, presentation::*, quest_actions::*,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "compat/tests.rs"]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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} 的局势随之向下一步展开。")
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) use module_runtime_story_compat::{
|
||||||
|
build_runtime_equipment_item, build_runtime_material_item,
|
||||||
|
};
|
||||||
@@ -0,0 +1,699 @@
|
|||||||
|
use super::*;
|
||||||
|
use module_runtime_story_compat::{build_runtime_equipment_item, build_runtime_material_item};
|
||||||
|
|
||||||
|
pub(super) fn current_npc_trade_context(game_state: &Value) -> Result<(String, String), 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 = current_encounter_name(game_state);
|
||||||
|
let npc_id = current_encounter_id(game_state).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((npc_id, npc_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn current_npc_inventory_items<'a>(game_state: &'a Value) -> Vec<&'a Value> {
|
||||||
|
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||||
|
.map(|state| read_array_field(state, "inventory"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 兼容桥沿用 Node 旧域的入口预处理:在读取选项或结算动作前,
|
||||||
|
/// 先确保当前 NPC 的持久状态最少可用,避免空快照直接打断交易/赠礼/委托主链。
|
||||||
|
pub(super) fn ensure_runtime_story_bridge_state(game_state: &mut Value) {
|
||||||
|
ensure_current_encounter_npc_state_initialized(game_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这里不尝试一次性重建完整真相态,只补 compat bridge 当前确实依赖的字段,
|
||||||
|
/// 并为“纯商贩型 NPC”补一份确定性 trade stock,保证旧前端菜单不因空状态掉链子。
|
||||||
|
pub(super) fn ensure_current_encounter_npc_state_initialized(game_state: &mut Value) {
|
||||||
|
let Some(encounter) = read_object_field(game_state, "currentEncounter").cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if read_optional_string_field(&encounter, "kind").as_deref() != Some("npc") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
let storage_key = resolve_npc_state_storage_key(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
let existing_state = read_field(game_state, "npcStates")
|
||||||
|
.and_then(|states| read_field(states, storage_key.as_str()))
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
let affinity = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_i32_field(state, "affinity"))
|
||||||
|
.unwrap_or_else(|| default_current_npc_affinity(&encounter));
|
||||||
|
let recruited = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_bool_field(state, "recruited"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let chatted_count = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_i32_field(state, "chattedCount"))
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0);
|
||||||
|
let gifts_given = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_i32_field(state, "giftsGiven"))
|
||||||
|
.unwrap_or(0)
|
||||||
|
.max(0);
|
||||||
|
let help_used = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_bool_field(state, "helpUsed"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let first_meaningful_contact_resolved = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_bool_field(state, "firstMeaningfulContactResolved"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
let revealed_facts = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| read_string_list_field(state, "revealedFacts"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let known_attribute_rumors = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| read_string_list_field(state, "knownAttributeRumors"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let seen_backstory_chapter_ids = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| read_string_list_field(state, "seenBackstoryChapterIds"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let existing_inventory = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.map(|state| {
|
||||||
|
read_array_field(state, "inventory")
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let existing_trade_stock_signature = existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_optional_string_field(state, "tradeStockSignature"));
|
||||||
|
let hostile = read_bool_field(&encounter, "hostile").unwrap_or(false)
|
||||||
|
|| read_optional_string_field(&encounter, "monsterPresetId").is_some()
|
||||||
|
|| affinity < 0;
|
||||||
|
let context_text = read_optional_string_field(&encounter, "context");
|
||||||
|
|
||||||
|
let (inventory, trade_stock_signature) = if is_trade_driven_role_npc(&encounter) {
|
||||||
|
let next_signature = build_current_npc_trade_stock_signature(game_state, npc_id.as_str());
|
||||||
|
if existing_trade_stock_signature.as_deref() == Some(next_signature.as_str()) {
|
||||||
|
(existing_inventory, Some(next_signature))
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
sync_bootstrapped_trade_inventory(
|
||||||
|
game_state,
|
||||||
|
npc_id.as_str(),
|
||||||
|
npc_name.as_str(),
|
||||||
|
existing_inventory,
|
||||||
|
next_signature.as_str(),
|
||||||
|
),
|
||||||
|
Some(next_signature),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(existing_inventory, existing_trade_stock_signature)
|
||||||
|
};
|
||||||
|
|
||||||
|
let relation_state = build_runtime_story_relation_state_value(affinity);
|
||||||
|
let stance_profile = build_runtime_story_stance_profile_value(
|
||||||
|
affinity,
|
||||||
|
recruited,
|
||||||
|
hostile,
|
||||||
|
context_text.as_deref(),
|
||||||
|
existing_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| read_field(state, "stanceProfile"))
|
||||||
|
.and_then(Value::as_object),
|
||||||
|
);
|
||||||
|
let npc_state = json!({
|
||||||
|
"affinity": affinity,
|
||||||
|
"chattedCount": chatted_count,
|
||||||
|
"helpUsed": help_used,
|
||||||
|
"giftsGiven": gifts_given,
|
||||||
|
"inventory": inventory,
|
||||||
|
"recruited": recruited,
|
||||||
|
"relationState": relation_state,
|
||||||
|
"revealedFacts": revealed_facts,
|
||||||
|
"knownAttributeRumors": known_attribute_rumors,
|
||||||
|
"firstMeaningfulContactResolved": first_meaningful_contact_resolved,
|
||||||
|
"seenBackstoryChapterIds": seen_backstory_chapter_ids,
|
||||||
|
"tradeStockSignature": trade_stock_signature,
|
||||||
|
"stanceProfile": stance_profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let npc_states = root
|
||||||
|
.entry("npcStates".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
if !npc_states.is_object() {
|
||||||
|
*npc_states = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
npc_states
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("npcStates should be object")
|
||||||
|
.insert(storage_key, npc_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolve_npc_state_storage_key(
|
||||||
|
game_state: &Value,
|
||||||
|
npc_id: &str,
|
||||||
|
npc_name: &str,
|
||||||
|
) -> String {
|
||||||
|
read_object_field(game_state, "npcStates")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|states| {
|
||||||
|
if states.contains_key(npc_id) {
|
||||||
|
Some(npc_id.to_string())
|
||||||
|
} else if states.contains_key(npc_name) {
|
||||||
|
Some(npc_name.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| npc_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn default_current_npc_affinity(encounter: &Value) -> i32 {
|
||||||
|
read_i32_field(encounter, "initialAffinity").unwrap_or_else(|| {
|
||||||
|
if read_optional_string_field(encounter, "monsterPresetId").is_some() {
|
||||||
|
-40
|
||||||
|
} else if read_optional_string_field(encounter, "characterId").is_some() {
|
||||||
|
18
|
||||||
|
} else {
|
||||||
|
6
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_string_list_field(value: &Value, key: &str) -> Vec<String> {
|
||||||
|
let mut items = read_array_field(value, key)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|entry| !entry.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if items.len() > 3 {
|
||||||
|
items = items.split_off(items.len() - 3);
|
||||||
|
}
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_runtime_story_relation_state_value(affinity: i32) -> Value {
|
||||||
|
let relation_state = build_module_npc_relation_state(affinity);
|
||||||
|
json!({
|
||||||
|
"affinity": relation_state.affinity,
|
||||||
|
"stance": npc_relation_stance_key(relation_state.stance),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn npc_relation_stance_key(value: NpcRelationStance) -> &'static str {
|
||||||
|
match value {
|
||||||
|
NpcRelationStance::Hostile => "hostile",
|
||||||
|
NpcRelationStance::Guarded => "guarded",
|
||||||
|
NpcRelationStance::Neutral => "neutral",
|
||||||
|
NpcRelationStance::Cooperative => "cooperative",
|
||||||
|
NpcRelationStance::Bonded => "bonded",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_runtime_story_stance_profile_value(
|
||||||
|
affinity: i32,
|
||||||
|
recruited: bool,
|
||||||
|
hostile: bool,
|
||||||
|
role_text: Option<&str>,
|
||||||
|
existing_profile: Option<&Map<String, Value>>,
|
||||||
|
) -> Value {
|
||||||
|
let base = build_module_npc_initial_stance_profile(affinity, recruited, hostile, role_text);
|
||||||
|
let read_metric = |key: &str, fallback: u8| -> i32 {
|
||||||
|
existing_profile
|
||||||
|
.and_then(|profile| profile.get(key))
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
.unwrap_or(i32::from(fallback))
|
||||||
|
.clamp(0, 100)
|
||||||
|
};
|
||||||
|
let recent_approvals = existing_profile
|
||||||
|
.and_then(|profile| profile.get("recentApprovals"))
|
||||||
|
.map(|value| read_string_list_field(value, ""))
|
||||||
|
.unwrap_or_else(|| base.recent_approvals.clone());
|
||||||
|
let recent_disapprovals = existing_profile
|
||||||
|
.and_then(|profile| profile.get("recentDisapprovals"))
|
||||||
|
.map(|value| read_string_list_field(value, ""))
|
||||||
|
.unwrap_or_else(|| base.recent_disapprovals.clone());
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"trust": read_metric("trust", base.trust),
|
||||||
|
"warmth": read_metric("warmth", base.warmth),
|
||||||
|
"ideologicalFit": read_metric("ideologicalFit", base.ideological_fit),
|
||||||
|
"fearOrGuard": read_metric("fearOrGuard", base.fear_or_guard),
|
||||||
|
"loyalty": read_metric("loyalty", base.loyalty),
|
||||||
|
"currentConflictTag": existing_profile
|
||||||
|
.and_then(|profile| profile.get("currentConflictTag"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.or(base.current_conflict_tag),
|
||||||
|
"recentApprovals": recent_approvals,
|
||||||
|
"recentDisapprovals": recent_disapprovals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn is_trade_driven_role_npc(encounter: &Value) -> bool {
|
||||||
|
read_optional_string_field(encounter, "characterId").is_none()
|
||||||
|
&& read_optional_string_field(encounter, "monsterPresetId").is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_current_npc_trade_stock_signature(game_state: &Value, npc_id: &str) -> String {
|
||||||
|
let scene_key = read_object_field(game_state, "currentScenePreset")
|
||||||
|
.and_then(|preset| {
|
||||||
|
read_optional_string_field(preset, "id")
|
||||||
|
.or_else(|| read_optional_string_field(preset, "name"))
|
||||||
|
})
|
||||||
|
.or_else(|| read_optional_string_field(game_state, "currentScene"))
|
||||||
|
.unwrap_or_else(|| "scene".to_string());
|
||||||
|
let world_key = current_world_type(game_state).unwrap_or_else(|| "world".to_string());
|
||||||
|
format!(
|
||||||
|
"{}:{}:{}",
|
||||||
|
sanitize_trade_stock_fragment(npc_id),
|
||||||
|
sanitize_trade_stock_fragment(scene_key.as_str()),
|
||||||
|
sanitize_trade_stock_fragment(world_key.as_str())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sanitize_trade_stock_fragment(value: &str) -> String {
|
||||||
|
let normalized = value
|
||||||
|
.trim()
|
||||||
|
.chars()
|
||||||
|
.map(|ch| match ch {
|
||||||
|
':' | '/' | '\\' | ' ' => '-',
|
||||||
|
_ => ch,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
if normalized.is_empty() {
|
||||||
|
"unknown".to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn sync_bootstrapped_trade_inventory(
|
||||||
|
game_state: &Value,
|
||||||
|
npc_id: &str,
|
||||||
|
npc_name: &str,
|
||||||
|
existing_inventory: Vec<Value>,
|
||||||
|
trade_stock_signature: &str,
|
||||||
|
) -> Vec<Value> {
|
||||||
|
let preserved_inventory = existing_inventory
|
||||||
|
.into_iter()
|
||||||
|
.filter(|item| {
|
||||||
|
read_field(item, "runtimeMetadata")
|
||||||
|
.and_then(|metadata| read_optional_string_field(metadata, "generationChannel"))
|
||||||
|
.as_deref()
|
||||||
|
!= Some("npc_trade")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut next_inventory = preserved_inventory;
|
||||||
|
next_inventory.extend(build_bootstrapped_trade_inventory(
|
||||||
|
game_state,
|
||||||
|
npc_id,
|
||||||
|
npc_name,
|
||||||
|
trade_stock_signature,
|
||||||
|
));
|
||||||
|
next_inventory
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_bootstrapped_trade_inventory(
|
||||||
|
game_state: &Value,
|
||||||
|
npc_id: &str,
|
||||||
|
npc_name: &str,
|
||||||
|
trade_stock_signature: &str,
|
||||||
|
) -> Vec<Value> {
|
||||||
|
let world_type = current_world_type(game_state);
|
||||||
|
let consumable_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||||
|
"回灵散"
|
||||||
|
} else {
|
||||||
|
"回气散"
|
||||||
|
};
|
||||||
|
let material_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||||
|
"凝光纱"
|
||||||
|
} else {
|
||||||
|
"工巧残材"
|
||||||
|
};
|
||||||
|
let relic_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||||
|
"行旅护符"
|
||||||
|
} else {
|
||||||
|
"结绳护符"
|
||||||
|
};
|
||||||
|
let armor_name = if world_type.as_deref() == Some("XIANXIA") {
|
||||||
|
"护行法衣"
|
||||||
|
} else {
|
||||||
|
"护行短甲"
|
||||||
|
};
|
||||||
|
let tonic_id = format!("npc-trade:{trade_stock_signature}:tonic");
|
||||||
|
let material_id = format!("npc-trade:{trade_stock_signature}:material");
|
||||||
|
let relic_id = format!("npc-trade:{trade_stock_signature}:relic");
|
||||||
|
let armor_id = format!("npc-trade:{trade_stock_signature}:armor");
|
||||||
|
|
||||||
|
vec![
|
||||||
|
build_bootstrapped_trade_consumable_item(
|
||||||
|
tonic_id.as_str(),
|
||||||
|
consumable_name,
|
||||||
|
npc_name,
|
||||||
|
world_type.as_deref(),
|
||||||
|
),
|
||||||
|
attach_generated_trade_metadata(
|
||||||
|
build_runtime_material_item(
|
||||||
|
game_state,
|
||||||
|
material_name,
|
||||||
|
2,
|
||||||
|
&["工巧", "补给"],
|
||||||
|
"uncommon",
|
||||||
|
),
|
||||||
|
material_id.as_str(),
|
||||||
|
"npc_trade",
|
||||||
|
format!("{npc_id}:material").as_str(),
|
||||||
|
format!("{npc_name}整理出来的可交易工坊材料。").as_str(),
|
||||||
|
),
|
||||||
|
attach_generated_trade_metadata(
|
||||||
|
build_runtime_equipment_item(
|
||||||
|
game_state,
|
||||||
|
relic_name,
|
||||||
|
"relic",
|
||||||
|
"rare",
|
||||||
|
"适合长途行路时稳住灵力与节奏的护符。",
|
||||||
|
"护持",
|
||||||
|
&["护持", "法力"],
|
||||||
|
&["护持", "法力"],
|
||||||
|
json!({
|
||||||
|
"maxManaBonus": 12,
|
||||||
|
"outgoingDamageBonus": 0.05
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
relic_id.as_str(),
|
||||||
|
"npc_trade",
|
||||||
|
format!("{npc_id}:relic").as_str(),
|
||||||
|
format!("{npc_name}随身携带的护身小物。").as_str(),
|
||||||
|
),
|
||||||
|
attach_generated_trade_metadata(
|
||||||
|
build_runtime_equipment_item(
|
||||||
|
game_state,
|
||||||
|
armor_name,
|
||||||
|
"armor",
|
||||||
|
"rare",
|
||||||
|
"为行路与近身护体准备的轻装护具。",
|
||||||
|
"守御",
|
||||||
|
&["守御", "护体"],
|
||||||
|
&["守御", "护体"],
|
||||||
|
json!({
|
||||||
|
"maxHpBonus": 18,
|
||||||
|
"incomingDamageMultiplier": 0.93
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
armor_id.as_str(),
|
||||||
|
"npc_trade",
|
||||||
|
format!("{npc_id}:armor").as_str(),
|
||||||
|
format!("{npc_name}压箱底留下的一件护身装备。").as_str(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn build_bootstrapped_trade_consumable_item(
|
||||||
|
item_id: &str,
|
||||||
|
name: &str,
|
||||||
|
npc_name: &str,
|
||||||
|
world_type: Option<&str>,
|
||||||
|
) -> Value {
|
||||||
|
json!({
|
||||||
|
"id": item_id,
|
||||||
|
"category": "消耗品",
|
||||||
|
"name": name,
|
||||||
|
"description": format!("{npc_name}常备的一份行路补给。"),
|
||||||
|
"quantity": 2,
|
||||||
|
"rarity": "uncommon",
|
||||||
|
"tags": if world_type == Some("XIANXIA") {
|
||||||
|
vec!["mana", "support", "trade"]
|
||||||
|
} else {
|
||||||
|
vec!["mana", "support", "trade"]
|
||||||
|
},
|
||||||
|
"useProfile": {
|
||||||
|
"hpRestore": 0,
|
||||||
|
"manaRestore": 10,
|
||||||
|
"cooldownReduction": 0,
|
||||||
|
"buildBuffs": []
|
||||||
|
},
|
||||||
|
"runtimeMetadata": {
|
||||||
|
"origin": "procedural",
|
||||||
|
"generationChannel": "npc_trade",
|
||||||
|
"seedKey": format!("{item_id}:seed"),
|
||||||
|
"sourceReason": format!("{npc_name}把最常用的补给拿出来做成了交易库存。"),
|
||||||
|
"storyFingerprint": {
|
||||||
|
"relatedScarIds": [format!("scar:npc_trade:{item_id}")],
|
||||||
|
"relatedThreadIds": [],
|
||||||
|
"visibleClue": format!("{npc_name}随身药囊里最顺手的一味补给。"),
|
||||||
|
"witnessMark": "药包封口处还留着反复拆开的折痕。",
|
||||||
|
"unresolvedQuestion": "这份补给之前究竟替谁留着。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn attach_generated_trade_metadata(
|
||||||
|
mut item: Value,
|
||||||
|
item_id: &str,
|
||||||
|
generation_channel: &str,
|
||||||
|
seed_key: &str,
|
||||||
|
source_reason: &str,
|
||||||
|
) -> Value {
|
||||||
|
let item_name = read_inventory_item_name(&item);
|
||||||
|
let entry = ensure_json_object(&mut item);
|
||||||
|
entry.insert("id".to_string(), Value::String(item_id.to_string()));
|
||||||
|
entry.insert(
|
||||||
|
"runtimeMetadata".to_string(),
|
||||||
|
json!({
|
||||||
|
"origin": "procedural",
|
||||||
|
"generationChannel": generation_channel,
|
||||||
|
"seedKey": seed_key,
|
||||||
|
"sourceReason": source_reason,
|
||||||
|
"storyFingerprint": {
|
||||||
|
"relatedScarIds": [format!("scar:{generation_channel}:{seed_key}")],
|
||||||
|
"relatedThreadIds": [],
|
||||||
|
"visibleClue": format!("{item_name}上保留着反复流转留下的使用痕迹。"),
|
||||||
|
"witnessMark": "表面仍残留旧主人长期携带的磨损。",
|
||||||
|
"unresolvedQuestion": format!("{item_name}最初为什么会落到这名 NPC 手里。"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_current_npc_inventory_item<'a>(
|
||||||
|
game_state: &'a Value,
|
||||||
|
item_id: &str,
|
||||||
|
) -> Option<&'a Value> {
|
||||||
|
current_npc_inventory_items(game_state)
|
||||||
|
.into_iter()
|
||||||
|
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(item_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn adjust_current_npc_affinity(
|
||||||
|
game_state: &mut Value,
|
||||||
|
delta: i32,
|
||||||
|
) -> Option<(String, i32, i32)> {
|
||||||
|
let npc_id = current_encounter_id(game_state)?;
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
let previous_affinity = state
|
||||||
|
.get("affinity")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next_affinity = (previous_affinity + delta).clamp(-100, 100);
|
||||||
|
state.insert("affinity".to_string(), json!(next_affinity));
|
||||||
|
state
|
||||||
|
.entry("recruited".to_string())
|
||||||
|
.or_insert(Value::Bool(false));
|
||||||
|
|
||||||
|
Some((npc_id, previous_affinity, next_affinity))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_current_npc_state_i32_field(game_state: &Value, key: &str) -> Option<i32> {
|
||||||
|
let npc_id = current_encounter_id(game_state)?;
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||||
|
.and_then(|state| read_i32_field(state, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_current_npc_state_bool_field(game_state: &Value, key: &str) -> Option<bool> {
|
||||||
|
let npc_id = current_encounter_id(game_state)?;
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||||
|
.and_then(|state| read_bool_field(state, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn write_current_npc_state_i32_field(game_state: &mut Value, key: &str, value: i32) {
|
||||||
|
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
state.insert(key.to_string(), json!(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn write_current_npc_state_bool_field(game_state: &mut Value, key: &str, value: bool) {
|
||||||
|
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
state.insert(key.to_string(), Value::Bool(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn set_current_npc_recruited(
|
||||||
|
game_state: &mut Value,
|
||||||
|
recruited: bool,
|
||||||
|
) -> Option<(i32, i32)> {
|
||||||
|
let npc_id = current_encounter_id(game_state)?;
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
let previous_affinity = state
|
||||||
|
.get("affinity")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|value| i32::try_from(value).ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let next_affinity = previous_affinity.max(60);
|
||||||
|
state.insert("affinity".to_string(), json!(next_affinity));
|
||||||
|
state.insert("recruited".to_string(), Value::Bool(recruited));
|
||||||
|
|
||||||
|
Some((previous_affinity, next_affinity))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_current_npc_affinity(game_state: &Value) -> i32 {
|
||||||
|
let Some(npc_id) = current_encounter_id(game_state) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
resolve_current_encounter_npc_state(game_state, npc_id.as_str(), npc_name.as_str())
|
||||||
|
.and_then(|state| read_i32_field(state, "affinity"))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ensure_npc_state_object<'a>(
|
||||||
|
game_state: &'a mut Value,
|
||||||
|
npc_id: &str,
|
||||||
|
npc_name: &str,
|
||||||
|
) -> &'a mut Map<String, Value> {
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let npc_states = root
|
||||||
|
.entry("npcStates".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
if !npc_states.is_object() {
|
||||||
|
*npc_states = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
let states = npc_states
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("npcStates should be object");
|
||||||
|
let existing_key = if states.contains_key(npc_id) {
|
||||||
|
npc_id.to_string()
|
||||||
|
} else if states.contains_key(npc_name) {
|
||||||
|
npc_name.to_string()
|
||||||
|
} else {
|
||||||
|
npc_id.to_string()
|
||||||
|
};
|
||||||
|
let state = states
|
||||||
|
.entry(existing_key)
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
if !state.is_object() {
|
||||||
|
*state = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
state.as_object_mut().expect("npc state should be object")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn mark_current_npc_first_meaningful_contact_resolved(game_state: &mut Value) {
|
||||||
|
write_current_npc_state_bool_field(game_state, "firstMeaningfulContactResolved", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ensure_current_npc_inventory_array<'a>(
|
||||||
|
game_state: &'a mut Value,
|
||||||
|
) -> Option<&'a mut Vec<Value>> {
|
||||||
|
let npc_id = current_encounter_id(game_state)?;
|
||||||
|
let npc_name = current_encounter_name(game_state);
|
||||||
|
let state = ensure_npc_state_object(game_state, npc_id.as_str(), npc_name.as_str());
|
||||||
|
let inventory = state
|
||||||
|
.entry("inventory".to_string())
|
||||||
|
.or_insert_with(|| Value::Array(Vec::new()));
|
||||||
|
if !inventory.is_array() {
|
||||||
|
*inventory = Value::Array(Vec::new());
|
||||||
|
}
|
||||||
|
inventory.as_array_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn add_current_npc_inventory_items(game_state: &mut Value, additions: Vec<Value>) {
|
||||||
|
if additions.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for addition in additions {
|
||||||
|
let Some(add_id) = read_optional_string_field(&addition, "id") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
|
||||||
|
if let Some(existing) = items
|
||||||
|
.iter_mut()
|
||||||
|
.find(|item| read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str()))
|
||||||
|
{
|
||||||
|
let next_quantity =
|
||||||
|
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
|
||||||
|
if let Some(existing_object) = existing.as_object_mut() {
|
||||||
|
existing_object.insert("quantity".to_string(), json!(next_quantity));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push(addition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn remove_current_npc_inventory_item(
|
||||||
|
game_state: &mut Value,
|
||||||
|
item_id: &str,
|
||||||
|
quantity: i32,
|
||||||
|
) {
|
||||||
|
if quantity <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(items) = ensure_current_npc_inventory_array(game_state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
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(entry) = items[index].as_object_mut() {
|
||||||
|
entry.insert("quantity".to_string(), json!(next_quantity));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
current_affinity,
|
||||||
|
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,734 @@
|
|||||||
|
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_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_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_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_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 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 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
@@ -1,16 +1,26 @@
|
|||||||
use std::{error::Error, fmt};
|
use std::{error::Error, fmt};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
};
|
||||||
|
|
||||||
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
use module_ai::{AiTaskService, InMemoryAiTaskStore};
|
||||||
use module_auth::{
|
use module_auth::{
|
||||||
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService,
|
||||||
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
RefreshSessionService, WechatAuthService, WechatAuthStateService,
|
||||||
};
|
};
|
||||||
|
use module_runtime::RuntimeSnapshotRecord;
|
||||||
|
#[cfg(test)]
|
||||||
|
use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros};
|
||||||
use platform_auth::{
|
use platform_auth::{
|
||||||
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
|
||||||
};
|
};
|
||||||
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
use platform_llm::{LlmClient, LlmConfig, LlmError};
|
||||||
use platform_oss::{OssClient, OssConfig, OssError};
|
use platform_oss::{OssClient, OssConfig, OssError};
|
||||||
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig};
|
use serde_json::Value;
|
||||||
|
use spacetime_client::{SpacetimeClient, SpacetimeClientConfig, SpacetimeClientError};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
|
use crate::wechat_provider::{WechatProvider, build_wechat_provider};
|
||||||
@@ -35,6 +45,9 @@ pub struct AppState {
|
|||||||
ai_task_service: AiTaskService,
|
ai_task_service: AiTaskService,
|
||||||
spacetime_client: SpacetimeClient,
|
spacetime_client: SpacetimeClient,
|
||||||
llm_client: Option<LlmClient>,
|
llm_client: Option<LlmClient>,
|
||||||
|
#[cfg(test)]
|
||||||
|
// 测试环境允许在未启动 SpacetimeDB 时,用内存快照兜底当前 runtime story 回归链。
|
||||||
|
test_runtime_snapshot_store: Arc<Mutex<HashMap<String, RuntimeSnapshotRecord>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -98,6 +111,8 @@ impl AppState {
|
|||||||
ai_task_service,
|
ai_task_service,
|
||||||
spacetime_client,
|
spacetime_client,
|
||||||
llm_client,
|
llm_client,
|
||||||
|
#[cfg(test)]
|
||||||
|
test_runtime_snapshot_store: Arc::new(Mutex::new(HashMap::new())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,6 +168,162 @@ impl AppState {
|
|||||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||||
self.llm_client.as_ref()
|
self.llm_client.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_runtime_snapshot_record(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Option<RuntimeSnapshotRecord>, SpacetimeClientError> {
|
||||||
|
match self
|
||||||
|
.spacetime_client
|
||||||
|
.get_runtime_snapshot(user_id.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(record) => {
|
||||||
|
#[cfg(test)]
|
||||||
|
if let Some(snapshot) = record.as_ref() {
|
||||||
|
self.cache_test_runtime_snapshot(snapshot.clone());
|
||||||
|
}
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
Err(_) => Ok(self.read_test_runtime_snapshot(user_id.as_str())),
|
||||||
|
#[cfg(not(test))]
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_runtime_snapshot_record(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
saved_at_micros: i64,
|
||||||
|
bottom_tab: String,
|
||||||
|
game_state: Value,
|
||||||
|
current_story: Option<Value>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
|
||||||
|
match self
|
||||||
|
.spacetime_client
|
||||||
|
.put_runtime_snapshot(
|
||||||
|
user_id.clone(),
|
||||||
|
saved_at_micros,
|
||||||
|
bottom_tab.clone(),
|
||||||
|
game_state.clone(),
|
||||||
|
current_story.clone(),
|
||||||
|
updated_at_micros,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(record) => {
|
||||||
|
#[cfg(test)]
|
||||||
|
self.cache_test_runtime_snapshot(record.clone());
|
||||||
|
Ok(record)
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
Err(_) => {
|
||||||
|
let snapshot = self.build_test_runtime_snapshot_record(
|
||||||
|
user_id,
|
||||||
|
saved_at_micros,
|
||||||
|
bottom_tab,
|
||||||
|
game_state,
|
||||||
|
current_story,
|
||||||
|
updated_at_micros,
|
||||||
|
)?;
|
||||||
|
self.cache_test_runtime_snapshot(snapshot.clone());
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
#[cfg(not(test))]
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_runtime_snapshot_record(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<bool, SpacetimeClientError> {
|
||||||
|
match self
|
||||||
|
.spacetime_client
|
||||||
|
.delete_runtime_snapshot(user_id.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(deleted) => {
|
||||||
|
#[cfg(test)]
|
||||||
|
if deleted {
|
||||||
|
self.remove_test_runtime_snapshot(user_id.as_str());
|
||||||
|
}
|
||||||
|
Ok(deleted)
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
Err(_) => Ok(self
|
||||||
|
.remove_test_runtime_snapshot(user_id.as_str())
|
||||||
|
.is_some()),
|
||||||
|
#[cfg(not(test))]
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl AppState {
|
||||||
|
fn cache_test_runtime_snapshot(&self, record: RuntimeSnapshotRecord) {
|
||||||
|
self.test_runtime_snapshot_store
|
||||||
|
.lock()
|
||||||
|
.expect("test runtime snapshot store should lock")
|
||||||
|
.insert(record.user_id.clone(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
|
||||||
|
self.test_runtime_snapshot_store
|
||||||
|
.lock()
|
||||||
|
.expect("test runtime snapshot store should lock")
|
||||||
|
.get(user_id)
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_test_runtime_snapshot(&self, user_id: &str) -> Option<RuntimeSnapshotRecord> {
|
||||||
|
self.test_runtime_snapshot_store
|
||||||
|
.lock()
|
||||||
|
.expect("test runtime snapshot store should lock")
|
||||||
|
.remove(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_test_runtime_snapshot_record(
|
||||||
|
&self,
|
||||||
|
user_id: String,
|
||||||
|
saved_at_micros: i64,
|
||||||
|
bottom_tab: String,
|
||||||
|
game_state: Value,
|
||||||
|
current_story: Option<Value>,
|
||||||
|
updated_at_micros: i64,
|
||||||
|
) -> Result<RuntimeSnapshotRecord, SpacetimeClientError> {
|
||||||
|
let previous = self.read_test_runtime_snapshot(user_id.as_str());
|
||||||
|
let game_state_json = serde_json::to_string(&game_state).map_err(|error| {
|
||||||
|
SpacetimeClientError::Runtime(format!("测试快照 game_state 序列化失败: {error}"))
|
||||||
|
})?;
|
||||||
|
let current_story_json = current_story
|
||||||
|
.as_ref()
|
||||||
|
.map(serde_json::to_string)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| {
|
||||||
|
SpacetimeClientError::Runtime(format!("测试快照 current_story 序列化失败: {error}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(RuntimeSnapshotRecord {
|
||||||
|
user_id,
|
||||||
|
version: SAVE_SNAPSHOT_VERSION,
|
||||||
|
saved_at: format_utc_micros(saved_at_micros),
|
||||||
|
saved_at_micros,
|
||||||
|
bottom_tab,
|
||||||
|
game_state,
|
||||||
|
current_story,
|
||||||
|
game_state_json,
|
||||||
|
current_story_json,
|
||||||
|
created_at_micros: previous
|
||||||
|
.as_ref()
|
||||||
|
.map(|record| record.created_at_micros)
|
||||||
|
.unwrap_or(updated_at_micros),
|
||||||
|
updated_at_micros,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AppStateInitError {
|
impl fmt::Display for AppStateInitError {
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ pub enum CustomWorldFieldError {
|
|||||||
MissingProfileId,
|
MissingProfileId,
|
||||||
MissingSessionId,
|
MissingSessionId,
|
||||||
MissingOwnerUserId,
|
MissingOwnerUserId,
|
||||||
|
MissingAction,
|
||||||
MissingWorldName,
|
MissingWorldName,
|
||||||
MissingDraftProfileJson,
|
MissingDraftProfileJson,
|
||||||
MissingProfilePayloadJson,
|
MissingProfilePayloadJson,
|
||||||
@@ -153,7 +154,6 @@ pub enum CustomWorldFieldError {
|
|||||||
MissingMessageText,
|
MissingMessageText,
|
||||||
MissingOperationId,
|
MissingOperationId,
|
||||||
MissingPhaseLabel,
|
MissingPhaseLabel,
|
||||||
MissingAction,
|
|
||||||
InvalidProgressPercent,
|
InvalidProgressPercent,
|
||||||
MissingCardId,
|
MissingCardId,
|
||||||
MissingCardTitle,
|
MissingCardTitle,
|
||||||
@@ -228,6 +228,61 @@ pub struct CustomWorldGalleryListResult {
|
|||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldPublishBlockerSnapshot {
|
||||||
|
pub blocker_id: String,
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldPublishGateSnapshot {
|
||||||
|
pub profile_id: String,
|
||||||
|
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
|
||||||
|
pub blocker_count: u32,
|
||||||
|
pub publish_ready: bool,
|
||||||
|
pub can_enter_world: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldWorkSummarySnapshot {
|
||||||
|
pub work_id: String,
|
||||||
|
pub source_type: String,
|
||||||
|
pub status: String,
|
||||||
|
pub title: String,
|
||||||
|
pub subtitle: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub cover_image_src: Option<String>,
|
||||||
|
pub cover_render_mode: Option<String>,
|
||||||
|
pub cover_character_image_srcs_json: String,
|
||||||
|
pub updated_at_micros: i64,
|
||||||
|
pub published_at_micros: Option<i64>,
|
||||||
|
pub stage: Option<RpgAgentStage>,
|
||||||
|
pub stage_label: Option<String>,
|
||||||
|
pub playable_npc_count: u32,
|
||||||
|
pub landmark_count: u32,
|
||||||
|
pub role_visual_ready_count: Option<u32>,
|
||||||
|
pub role_animation_ready_count: Option<u32>,
|
||||||
|
pub role_asset_summary_label: Option<String>,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub profile_id: Option<String>,
|
||||||
|
pub can_resume: bool,
|
||||||
|
pub can_enter_world: bool,
|
||||||
|
pub blocker_count: u32,
|
||||||
|
pub publish_ready: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldWorksListResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub items: Vec<CustomWorldWorkSummarySnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CustomWorldAgentMessageSnapshot {
|
pub struct CustomWorldAgentMessageSnapshot {
|
||||||
@@ -274,6 +329,38 @@ pub struct CustomWorldDraftCardSnapshot {
|
|||||||
pub updated_at_micros: i64,
|
pub updated_at_micros: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldDraftCardDetailSectionSnapshot {
|
||||||
|
pub section_id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldDraftCardDetailSnapshot {
|
||||||
|
pub card_id: String,
|
||||||
|
pub kind: RpgAgentDraftCardKind,
|
||||||
|
pub title: String,
|
||||||
|
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
|
||||||
|
pub linked_ids_json: String,
|
||||||
|
pub locked: bool,
|
||||||
|
pub editable: bool,
|
||||||
|
pub editable_section_ids_json: String,
|
||||||
|
pub warning_messages_json: String,
|
||||||
|
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
||||||
|
pub asset_status_label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CustomWorldDraftCardDetailResult {
|
||||||
|
pub ok: bool,
|
||||||
|
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CustomWorldAgentSessionSnapshot {
|
pub struct CustomWorldAgentSessionSnapshot {
|
||||||
@@ -434,61 +521,6 @@ pub struct CustomWorldWorksListInput {
|
|||||||
pub owner_user_id: String,
|
pub owner_user_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldPublishBlockerSnapshot {
|
|
||||||
pub blocker_id: String,
|
|
||||||
pub code: String,
|
|
||||||
pub message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldPublishGateSnapshot {
|
|
||||||
pub profile_id: String,
|
|
||||||
pub blockers: Vec<CustomWorldPublishBlockerSnapshot>,
|
|
||||||
pub blocker_count: u32,
|
|
||||||
pub publish_ready: bool,
|
|
||||||
pub can_enter_world: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldWorkSummarySnapshot {
|
|
||||||
pub work_id: String,
|
|
||||||
pub source_type: String,
|
|
||||||
pub status: String,
|
|
||||||
pub title: String,
|
|
||||||
pub subtitle: String,
|
|
||||||
pub summary: String,
|
|
||||||
pub cover_image_src: Option<String>,
|
|
||||||
pub cover_render_mode: Option<String>,
|
|
||||||
pub cover_character_image_srcs_json: String,
|
|
||||||
pub updated_at_micros: i64,
|
|
||||||
pub published_at_micros: Option<i64>,
|
|
||||||
pub stage: Option<RpgAgentStage>,
|
|
||||||
pub stage_label: Option<String>,
|
|
||||||
pub playable_npc_count: u32,
|
|
||||||
pub landmark_count: u32,
|
|
||||||
pub role_visual_ready_count: Option<u32>,
|
|
||||||
pub role_animation_ready_count: Option<u32>,
|
|
||||||
pub role_asset_summary_label: Option<String>,
|
|
||||||
pub session_id: Option<String>,
|
|
||||||
pub profile_id: Option<String>,
|
|
||||||
pub can_resume: bool,
|
|
||||||
pub can_enter_world: bool,
|
|
||||||
pub blocker_count: u32,
|
|
||||||
pub publish_ready: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldWorksListResult {
|
|
||||||
pub ok: bool,
|
|
||||||
pub items: Vec<CustomWorldWorkSummarySnapshot>,
|
|
||||||
pub error_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CustomWorldAgentCardDetailGetInput {
|
pub struct CustomWorldAgentCardDetailGetInput {
|
||||||
@@ -497,38 +529,6 @@ pub struct CustomWorldAgentCardDetailGetInput {
|
|||||||
pub card_id: String,
|
pub card_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldDraftCardDetailSectionSnapshot {
|
|
||||||
pub section_id: String,
|
|
||||||
pub label: String,
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldDraftCardDetailSnapshot {
|
|
||||||
pub card_id: String,
|
|
||||||
pub kind: RpgAgentDraftCardKind,
|
|
||||||
pub title: String,
|
|
||||||
pub sections: Vec<CustomWorldDraftCardDetailSectionSnapshot>,
|
|
||||||
pub linked_ids_json: String,
|
|
||||||
pub locked: bool,
|
|
||||||
pub editable: bool,
|
|
||||||
pub editable_section_ids_json: String,
|
|
||||||
pub warning_messages_json: String,
|
|
||||||
pub asset_status: Option<CustomWorldRoleAssetStatus>,
|
|
||||||
pub asset_status_label: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CustomWorldDraftCardDetailResult {
|
|
||||||
pub ok: bool,
|
|
||||||
pub card: Option<CustomWorldDraftCardDetailSnapshot>,
|
|
||||||
pub error_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CustomWorldAgentActionExecuteInput {
|
pub struct CustomWorldAgentActionExecuteInput {
|
||||||
@@ -1101,11 +1101,7 @@ pub fn validate_custom_world_agent_action_execute_input(
|
|||||||
if input.action.trim().is_empty() {
|
if input.action.trim().is_empty() {
|
||||||
return Err(CustomWorldFieldError::MissingAction);
|
return Err(CustomWorldFieldError::MissingAction);
|
||||||
}
|
}
|
||||||
if let Some(payload_json) = input.payload_json.as_deref() {
|
ensure_optional_json_object(input.payload_json.as_deref())?;
|
||||||
if !payload_json.trim().is_empty() {
|
|
||||||
ensure_json_object(payload_json)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1459,6 +1455,7 @@ impl fmt::Display for CustomWorldFieldError {
|
|||||||
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
|
Self::MissingProfileId => f.write_str("custom_world.profile_id 不能为空"),
|
||||||
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
|
Self::MissingSessionId => f.write_str("custom_world.session_id 不能为空"),
|
||||||
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
|
Self::MissingOwnerUserId => f.write_str("custom_world.owner_user_id 不能为空"),
|
||||||
|
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
|
||||||
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
|
Self::MissingWorldName => f.write_str("custom_world.world_name 不能为空"),
|
||||||
Self::MissingDraftProfileJson => {
|
Self::MissingDraftProfileJson => {
|
||||||
f.write_str("custom_world.compile.draft_profile_json 不能为空")
|
f.write_str("custom_world.compile.draft_profile_json 不能为空")
|
||||||
@@ -1490,7 +1487,6 @@ impl fmt::Display for CustomWorldFieldError {
|
|||||||
Self::MissingPhaseLabel => {
|
Self::MissingPhaseLabel => {
|
||||||
f.write_str("custom_world_agent_operation.phase_label 不能为空")
|
f.write_str("custom_world_agent_operation.phase_label 不能为空")
|
||||||
}
|
}
|
||||||
Self::MissingAction => f.write_str("custom_world_agent_action.action 不能为空"),
|
|
||||||
Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"),
|
Self::InvalidProgressPercent => f.write_str("progress 必须位于 0~100"),
|
||||||
Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"),
|
Self::MissingCardId => f.write_str("custom_world_draft_card.card_id 不能为空"),
|
||||||
Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"),
|
Self::MissingCardTitle => f.write_str("custom_world_draft_card.title 不能为空"),
|
||||||
|
|||||||
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal file
11
server-rs/crates/module-runtime-story-compat/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "module-runtime-story-compat"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde_json = "1"
|
||||||
|
shared-contracts = { path = "../shared-contracts" }
|
||||||
|
shared-kernel = { path = "../shared-kernel" }
|
||||||
|
time = { version = "0.3", features = ["formatting"] }
|
||||||
13
server-rs/crates/module-runtime-story-compat/README.md
Normal file
13
server-rs/crates/module-runtime-story-compat/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# module-runtime-story-compat
|
||||||
|
|
||||||
|
`module-runtime-story-compat` 承接旧 `/api/runtime/story/*` 兼容桥中不依赖 HTTP / `AppState` 的核心类型与纯 helper。
|
||||||
|
|
||||||
|
当前首批迁入范围保持克制:
|
||||||
|
|
||||||
|
1. action 结算结果结构。
|
||||||
|
2. action response 组装参数结构。
|
||||||
|
3. NPC 委托上下文结构。
|
||||||
|
4. functionId / 队伍上限常量。
|
||||||
|
5. 少量只依赖 `serde_json::Value` 与 `shared-contracts` 的纯 helper。
|
||||||
|
|
||||||
|
后续再按 battle / forge / NPC / quest / presentation 的顺序,把已经拆好的 `api-server` 内部模块逐步迁入本 crate。
|
||||||
814
server-rs/crates/module-runtime-story-compat/src/battle.rs
Normal file
814
server-rs/crates/module-runtime-story-compat/src/battle.rs
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use shared_contracts::runtime_story::{
|
||||||
|
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
|
||||||
|
RuntimeStoryPatch,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
StoryResolution, append_active_build_buffs, build_status_patch, clear_encounter_only,
|
||||||
|
clear_encounter_state, current_encounter_id, current_encounter_name_from_battle,
|
||||||
|
ensure_json_object, first_hostile_npc_string_field, grant_player_progression_experience,
|
||||||
|
increment_runtime_stat, read_array_field, read_field, read_i32_field, read_object_field,
|
||||||
|
read_optional_string_field, remove_player_inventory_item, resolve_action_text,
|
||||||
|
write_bool_field, write_current_encounter_i32_field, write_first_hostile_npc_i32_field,
|
||||||
|
write_i32_field, write_null_field, write_string_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。
|
||||||
|
///
|
||||||
|
/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译,
|
||||||
|
/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。
|
||||||
|
struct BattleActionPlan {
|
||||||
|
action_text: String,
|
||||||
|
result_text: String,
|
||||||
|
damage_dealt: i32,
|
||||||
|
damage_taken: i32,
|
||||||
|
heal: i32,
|
||||||
|
mana_restore: i32,
|
||||||
|
mana_cost: i32,
|
||||||
|
cooldown_tick_turns: i32,
|
||||||
|
cooldown_bonus_turns: i32,
|
||||||
|
applied_skill_cooldown: Option<(String, i32)>,
|
||||||
|
build_buffs: Vec<Value>,
|
||||||
|
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 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 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 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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
|
||||||
|
if turns <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tick_player_skill_cooldowns(game_state, turns);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
|
||||||
|
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(" / ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
"普通攻击",
|
||||||
|
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn battle_action_toast(
|
||||||
|
function_id: &str,
|
||||||
|
request: &RuntimeStoryActionRequest,
|
||||||
|
) -> Option<String> {
|
||||||
|
if function_id != "inventory_use" {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
request
|
||||||
|
.action
|
||||||
|
.payload
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|payload| read_optional_string_field(payload, "itemId"))
|
||||||
|
.map(|_| "Build 增益已写回当前快照".to_string())
|
||||||
|
}
|
||||||
323
server-rs/crates/module-runtime-story-compat/src/core.rs
Normal file
323
server-rs/crates/module-runtime-story-compat/src/core.rs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
use shared_kernel::format_rfc3339;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
/// Runtime story compat 的纯 JSON 快照工具层。
|
||||||
|
///
|
||||||
|
/// 这里不允许引入 HTTP、AppState 或持久化依赖,保证后续 battle/forge/npc/quest
|
||||||
|
/// 规则迁入独立 crate 时可以继续复用同一批状态读写函数。
|
||||||
|
pub 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 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 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 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 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 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 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 const MAX_PLAYER_LEVEL: i32 = 20;
|
||||||
|
|
||||||
|
pub 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 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 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 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 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 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 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 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 fn read_runtime_session_id(game_state: &Value) -> Option<String> {
|
||||||
|
read_optional_string_field(game_state, "runtimeSessionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_field<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
|
||||||
|
value.as_object()?.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 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 fn read_required_string_field(value: &Value, key: &str) -> Option<String> {
|
||||||
|
normalize_required_string(read_field(value, key)?.as_str()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_optional_string_field(value: &Value, key: &str) -> Option<String> {
|
||||||
|
normalize_optional_string(read_field(value, key).and_then(Value::as_str))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_bool_field(value: &Value, key: &str) -> Option<bool> {
|
||||||
|
read_field(value, key).and_then(Value::as_bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 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 fn write_i32_field(value: &mut Value, key: &str, field_value: i32) {
|
||||||
|
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_u32_field(value: &mut Value, key: &str, field_value: u32) {
|
||||||
|
ensure_json_object(value).insert(key.to_string(), json!(field_value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 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 fn write_null_field(value: &mut Value, key: &str) {
|
||||||
|
ensure_json_object(value).insert(key.to_string(), Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 fn normalize_required_string(value: &str) -> Option<String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_optional_string(value: Option<&str>) -> Option<String> {
|
||||||
|
value.and_then(normalize_required_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_now_rfc3339() -> String {
|
||||||
|
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
|
||||||
|
}
|
||||||
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
426
server-rs/crates/module-runtime-story-compat/src/forge.rs
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
equipment_slot_label, item_rarity_key, read_array_field, read_field, read_i32_field,
|
||||||
|
read_inventory_item_name, read_optional_string_field, read_u32_field,
|
||||||
|
remove_inventory_item_from_list, resolve_equipment_slot_for_item,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 这批定义只服务 runtime story compat 的确定性锻造链。
|
||||||
|
///
|
||||||
|
/// 当前仍然保持旧快照态结算口径,不引入 HTTP / AppState / 持久化边界。
|
||||||
|
pub(crate) struct ForgeRequirementDefinition {
|
||||||
|
pub(crate) quantity: i32,
|
||||||
|
pub(crate) matcher: ForgeRequirementMatcher,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) enum ForgeRequirementMatcher {
|
||||||
|
Named(&'static str),
|
||||||
|
AnyMaterial,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ForgeRecipeDefinition {
|
||||||
|
pub(crate) id: &'static str,
|
||||||
|
pub(crate) name: &'static str,
|
||||||
|
pub(crate) currency_cost: i32,
|
||||||
|
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct ReforgeCostDefinition {
|
||||||
|
pub(crate) currency_cost: i32,
|
||||||
|
pub(crate) requirements: Vec<ForgeRequirementDefinition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) 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(crate) 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(crate) 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 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 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(crate) 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(crate) 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(crate) 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(crate) 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("、")
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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 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,217 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use shared_contracts::runtime_story::RuntimeStoryActionRequest;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
StoryResolution, add_inventory_items_to_list, build_current_build_toast, current_world_type,
|
||||||
|
ensure_inventory_action_available, find_player_inventory_entry, read_i32_field,
|
||||||
|
read_inventory_item_name, read_optional_string_field, read_player_inventory_values,
|
||||||
|
remove_inventory_item_from_list, resolve_action_text, resolve_equipment_slot_for_item,
|
||||||
|
write_i32_field, write_player_inventory_values,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::forge::{
|
||||||
|
apply_forge_requirements_if_possible, build_dismantle_outputs, build_forge_recipe_result_item,
|
||||||
|
build_forge_success_text, build_reforged_item, forge_recipe_definition, format_currency_text,
|
||||||
|
reforge_cost_definition,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 锻造动作编排已经不再依赖 `api-server` 的 HTTP 边界。
|
||||||
|
///
|
||||||
|
/// 这里继续沿用 compat 快照态结算,后续可直接被 `api-server` 外壳或真相态桥接层复用。
|
||||||
|
pub 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 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 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)),
|
||||||
|
})
|
||||||
|
}
|
||||||
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
417
server-rs/crates/module-runtime-story-compat/src/game_state.rs
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
use serde_json::{Map, Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ensure_json_object, first_hostile_npc_string_field, read_array_field, read_bool_field,
|
||||||
|
read_field, read_i32_field, read_object_field, read_optional_string_field, write_i32_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 这批 helper 只负责 runtime story compat 的纯快照读写。
|
||||||
|
///
|
||||||
|
/// 目标是先把 encounter / inventory / equipment 的基础状态工具从 `api-server`
|
||||||
|
/// 边界模块里收口出来,后续 battle / forge / equipment 规则迁移时直接复用。
|
||||||
|
pub fn ensure_inventory_action_available(
|
||||||
|
game_state: &Value,
|
||||||
|
missing_character_message: &str,
|
||||||
|
battle_locked_message: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if read_field(game_state, "playerCharacter").is_none() {
|
||||||
|
return Err(missing_character_message.to_string());
|
||||||
|
}
|
||||||
|
if read_bool_field(game_state, "inBattle").unwrap_or(false) {
|
||||||
|
return Err(battle_locked_message.to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn battle_mode_text(value: &str) -> &'static str {
|
||||||
|
if value == "spar" { "切磋" } else { "战斗" }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_encounter_name(game_state: &Value) -> String {
|
||||||
|
read_object_field(game_state, "currentEncounter")
|
||||||
|
.and_then(|encounter| {
|
||||||
|
read_optional_string_field(encounter, "npcName")
|
||||||
|
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "对方".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_encounter_name_from_battle(game_state: &Value) -> String {
|
||||||
|
read_object_field(game_state, "currentEncounter")
|
||||||
|
.and_then(|encounter| {
|
||||||
|
read_optional_string_field(encounter, "npcName")
|
||||||
|
.or_else(|| read_optional_string_field(encounter, "name"))
|
||||||
|
})
|
||||||
|
.or_else(|| first_hostile_npc_string_field(game_state, "name"))
|
||||||
|
.unwrap_or_else(|| "眼前的敌人".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_encounter_id(game_state: &Value) -> Option<String> {
|
||||||
|
read_object_field(game_state, "currentEncounter")
|
||||||
|
.and_then(|encounter| read_optional_string_field(encounter, "id"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_player_inventory_entry<'a>(game_state: &'a Value, item_id: &str) -> Option<&'a Value> {
|
||||||
|
read_array_field(game_state, "playerInventory")
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_player_inventory_values(game_state: &Value) -> Vec<Value> {
|
||||||
|
read_array_field(game_state, "playerInventory")
|
||||||
|
.into_iter()
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_player_inventory_values(game_state: &mut Value, items: Vec<Value>) {
|
||||||
|
ensure_json_object(game_state).insert("playerInventory".to_string(), Value::Array(items));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_inventory_item_name(item: &Value) -> String {
|
||||||
|
read_optional_string_field(item, "name")
|
||||||
|
.or_else(|| read_optional_string_field(item, "id"))
|
||||||
|
.unwrap_or_else(|| "未命名物品".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_giftable_player_inventory(game_state: &Value) -> bool {
|
||||||
|
read_array_field(game_state, "playerInventory")
|
||||||
|
.into_iter()
|
||||||
|
.any(|item| read_i32_field(item, "quantity").unwrap_or(0) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clone_inventory_item_with_quantity(item: &Value, quantity: i32) -> Value {
|
||||||
|
let mut next_item = item.clone();
|
||||||
|
if let Some(entry) = next_item.as_object_mut() {
|
||||||
|
entry.insert("quantity".to_string(), json!(quantity.max(1)));
|
||||||
|
}
|
||||||
|
next_item
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_equipped_item(item: &Value) -> Value {
|
||||||
|
clone_inventory_item_with_quantity(item, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_inventory_items_to_list(mut base: Vec<Value>, additions: Vec<Value>) -> Vec<Value> {
|
||||||
|
for addition in additions {
|
||||||
|
let add_id = read_optional_string_field(&addition, "id");
|
||||||
|
let add_quantity = read_i32_field(&addition, "quantity").unwrap_or(1).max(1);
|
||||||
|
if let Some(add_id) = add_id {
|
||||||
|
if let Some(existing) = base.iter_mut().find(|item| {
|
||||||
|
read_optional_string_field(item, "id").as_deref() == Some(add_id.as_str())
|
||||||
|
}) {
|
||||||
|
let next_quantity =
|
||||||
|
read_i32_field(existing, "quantity").unwrap_or(0).max(0) + add_quantity;
|
||||||
|
if let Some(existing_object) = existing.as_object_mut() {
|
||||||
|
existing_object.insert("quantity".to_string(), json!(next_quantity));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base.push(addition);
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_inventory_item_from_list(
|
||||||
|
mut base: Vec<Value>,
|
||||||
|
item_id: &str,
|
||||||
|
quantity: i32,
|
||||||
|
) -> Vec<Value> {
|
||||||
|
if quantity <= 0 {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
let Some(index) = base
|
||||||
|
.iter()
|
||||||
|
.position(|entry| read_optional_string_field(entry, "id").as_deref() == Some(item_id))
|
||||||
|
else {
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
let current_quantity = read_i32_field(&base[index], "quantity").unwrap_or(0).max(0);
|
||||||
|
let next_quantity = current_quantity - quantity;
|
||||||
|
if next_quantity <= 0 {
|
||||||
|
base.remove(index);
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
if let Some(item) = base[index].as_object_mut() {
|
||||||
|
item.insert("quantity".to_string(), json!(next_quantity));
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_player_equipment_item(game_state: &Value, slot_id: &str) -> Option<Value> {
|
||||||
|
read_field(game_state, "playerEquipment")
|
||||||
|
.and_then(|equipment| read_field(equipment, slot_id))
|
||||||
|
.filter(|item| !item.is_null())
|
||||||
|
.cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_player_equipment_item(game_state: &mut Value, slot_id: &str, item: Option<Value>) {
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let equipment = root
|
||||||
|
.entry("playerEquipment".to_string())
|
||||||
|
.or_insert_with(|| {
|
||||||
|
json!({
|
||||||
|
"weapon": null,
|
||||||
|
"armor": null,
|
||||||
|
"relic": null,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if !equipment.is_object() {
|
||||||
|
*equipment = json!({
|
||||||
|
"weapon": null,
|
||||||
|
"armor": null,
|
||||||
|
"relic": null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
equipment
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("playerEquipment should be object")
|
||||||
|
.insert(slot_id.to_string(), item.unwrap_or(Value::Null));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn equipment_slot_label(slot_id: &str) -> &'static str {
|
||||||
|
match slot_id {
|
||||||
|
"weapon" => "武器",
|
||||||
|
"armor" => "护甲",
|
||||||
|
"relic" => "饰品",
|
||||||
|
_ => "装备",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_equipment_slot_id(slot_id: &str) -> Option<&'static str> {
|
||||||
|
let normalized = slot_id.trim().to_ascii_lowercase();
|
||||||
|
match normalized.as_str() {
|
||||||
|
"weapon" => Some("weapon"),
|
||||||
|
"armor" => Some("armor"),
|
||||||
|
"relic" | "accessory" => Some("relic"),
|
||||||
|
_ => {
|
||||||
|
// 兼容旧 payload 里直接传中文槽位名或物品类别文案的情况。
|
||||||
|
if slot_id.contains("武器")
|
||||||
|
|| slot_id.contains('剑')
|
||||||
|
|| slot_id.contains('弓')
|
||||||
|
|| slot_id.contains('刀')
|
||||||
|
|| slot_id.contains("拳套")
|
||||||
|
|| slot_id.contains("战刃")
|
||||||
|
|| slot_id.contains('枪')
|
||||||
|
|| slot_id.contains('刃')
|
||||||
|
{
|
||||||
|
return Some("weapon");
|
||||||
|
}
|
||||||
|
if slot_id.contains("护甲")
|
||||||
|
|| slot_id.contains('甲')
|
||||||
|
|| slot_id.contains("护臂")
|
||||||
|
|| slot_id.contains('衣')
|
||||||
|
|| slot_id.contains('袍')
|
||||||
|
|| slot_id.contains('铠')
|
||||||
|
{
|
||||||
|
return Some("armor");
|
||||||
|
}
|
||||||
|
if slot_id.contains("饰品")
|
||||||
|
|| slot_id.contains("护符")
|
||||||
|
|| slot_id.contains("徽章")
|
||||||
|
|| slot_id.contains('玉')
|
||||||
|
|| slot_id.contains('珠')
|
||||||
|
|| slot_id.contains('坠')
|
||||||
|
|| slot_id.contains('铃')
|
||||||
|
|| slot_id.contains('盘')
|
||||||
|
|| slot_id.contains('令')
|
||||||
|
|| slot_id.contains('匣')
|
||||||
|
{
|
||||||
|
return Some("relic");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_equipment_slot_for_item(item: &Value) -> Option<&'static str> {
|
||||||
|
if let Some(slot_id) = read_optional_string_field(item, "equipmentSlotId") {
|
||||||
|
return match slot_id.as_str() {
|
||||||
|
"weapon" => Some("weapon"),
|
||||||
|
"armor" => Some("armor"),
|
||||||
|
"relic" => Some("relic"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let tags = read_array_field(item, "tags")
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|tag| tag.as_str().map(|value| value.to_string()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if tags.iter().any(|tag| tag == "weapon") {
|
||||||
|
return Some("weapon");
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "armor") {
|
||||||
|
return Some("armor");
|
||||||
|
}
|
||||||
|
if tags.iter().any(|tag| tag == "relic") {
|
||||||
|
return Some("relic");
|
||||||
|
}
|
||||||
|
let category_text = read_optional_string_field(item, "category").unwrap_or_default();
|
||||||
|
let name_text = read_inventory_item_name(item);
|
||||||
|
let mixed_text = format!("{category_text} {name_text}");
|
||||||
|
if mixed_text.contains("武器") || mixed_text.contains("剑") || mixed_text.contains("刀") {
|
||||||
|
return Some("weapon");
|
||||||
|
}
|
||||||
|
if mixed_text.contains("护甲") || mixed_text.contains("甲") || mixed_text.contains("袍") {
|
||||||
|
return Some("armor");
|
||||||
|
}
|
||||||
|
if mixed_text.contains("饰品") || mixed_text.contains("护符") || mixed_text.contains("玉")
|
||||||
|
{
|
||||||
|
return Some("relic");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn item_rarity_key(item: &Value) -> String {
|
||||||
|
read_optional_string_field(item, "rarity").unwrap_or_else(|| "common".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn equipment_bonus_fallbacks(slot_id: &str, rarity: &str) -> (i32, i32, f64, f64) {
|
||||||
|
match slot_id {
|
||||||
|
"weapon" => {
|
||||||
|
let outgoing = match rarity {
|
||||||
|
"uncommon" => 0.10,
|
||||||
|
"rare" => 0.14,
|
||||||
|
"epic" => 0.20,
|
||||||
|
"legendary" => 0.28,
|
||||||
|
_ => 0.06,
|
||||||
|
};
|
||||||
|
(0, 0, outgoing, 1.0)
|
||||||
|
}
|
||||||
|
"armor" => {
|
||||||
|
let hp = match rarity {
|
||||||
|
"uncommon" => 22,
|
||||||
|
"rare" => 32,
|
||||||
|
"epic" => 44,
|
||||||
|
"legendary" => 58,
|
||||||
|
_ => 14,
|
||||||
|
};
|
||||||
|
let incoming = match rarity {
|
||||||
|
"uncommon" => 0.94,
|
||||||
|
"rare" => 0.90,
|
||||||
|
"epic" => 0.86,
|
||||||
|
"legendary" => 0.80,
|
||||||
|
_ => 0.97,
|
||||||
|
};
|
||||||
|
(hp, 0, 0.0, incoming)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let mana = match rarity {
|
||||||
|
"uncommon" => 18,
|
||||||
|
"rare" => 28,
|
||||||
|
"epic" => 40,
|
||||||
|
"legendary" => 54,
|
||||||
|
_ => 10,
|
||||||
|
};
|
||||||
|
let outgoing = match rarity {
|
||||||
|
"uncommon" => 0.04,
|
||||||
|
"rare" => 0.06,
|
||||||
|
"epic" => 0.09,
|
||||||
|
"legendary" => 0.12,
|
||||||
|
_ => 0.02,
|
||||||
|
};
|
||||||
|
(0, mana, outgoing, 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn equipment_item_bonuses(item: &Value, slot_id: &str) -> (i32, i32, f64, f64) {
|
||||||
|
let rarity = item_rarity_key(item);
|
||||||
|
let fallback = equipment_bonus_fallbacks(slot_id, rarity.as_str());
|
||||||
|
let stat_profile = read_field(item, "statProfile");
|
||||||
|
let hp_bonus = stat_profile
|
||||||
|
.and_then(|profile| read_i32_field(profile, "maxHpBonus"))
|
||||||
|
.unwrap_or(fallback.0);
|
||||||
|
let mana_bonus = stat_profile
|
||||||
|
.and_then(|profile| read_i32_field(profile, "maxManaBonus"))
|
||||||
|
.unwrap_or(fallback.1);
|
||||||
|
let outgoing_bonus = stat_profile
|
||||||
|
.and_then(|profile| read_field(profile, "outgoingDamageBonus"))
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.unwrap_or(fallback.2);
|
||||||
|
let incoming_multiplier = stat_profile
|
||||||
|
.and_then(|profile| read_field(profile, "incomingDamageMultiplier"))
|
||||||
|
.and_then(Value::as_f64)
|
||||||
|
.unwrap_or(fallback.3);
|
||||||
|
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_equipment_total_bonuses(game_state: &Value) -> (i32, i32, f64, f64) {
|
||||||
|
let equipment = read_field(game_state, "playerEquipment");
|
||||||
|
let mut hp_bonus = 0;
|
||||||
|
let mut mana_bonus = 0;
|
||||||
|
let mut outgoing_bonus = 0.0;
|
||||||
|
let mut incoming_multiplier = 1.0;
|
||||||
|
for slot_id in ["weapon", "armor", "relic"] {
|
||||||
|
let Some(item) = equipment.and_then(|value| read_field(value, slot_id)) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if item.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (slot_hp, slot_mana, slot_outgoing, slot_incoming) =
|
||||||
|
equipment_item_bonuses(item, slot_id);
|
||||||
|
hp_bonus += slot_hp;
|
||||||
|
mana_bonus += slot_mana;
|
||||||
|
outgoing_bonus += slot_outgoing;
|
||||||
|
incoming_multiplier *= slot_incoming;
|
||||||
|
}
|
||||||
|
(hp_bonus, mana_bonus, outgoing_bonus, incoming_multiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_equipment_loadout_to_state(game_state: &mut Value) {
|
||||||
|
let (hp_bonus, mana_bonus, _outgoing_bonus, _incoming_multiplier) =
|
||||||
|
read_equipment_total_bonuses(game_state);
|
||||||
|
let current_max_hp = read_i32_field(game_state, "playerMaxHp")
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
let current_max_mana = read_i32_field(game_state, "playerMaxMana")
|
||||||
|
.unwrap_or(1)
|
||||||
|
.max(1);
|
||||||
|
let current_hp = read_i32_field(game_state, "playerHp").unwrap_or(current_max_hp);
|
||||||
|
let base_max_hp = current_max_hp
|
||||||
|
.saturating_sub(read_runtime_equipment_bonus_cache(game_state, "maxHpBonus"))
|
||||||
|
.max(1);
|
||||||
|
let base_max_mana = current_max_mana
|
||||||
|
.saturating_sub(read_runtime_equipment_bonus_cache(
|
||||||
|
game_state,
|
||||||
|
"maxManaBonus",
|
||||||
|
))
|
||||||
|
.max(1);
|
||||||
|
let next_max_hp = base_max_hp.saturating_add(hp_bonus).max(1);
|
||||||
|
let next_max_mana = base_max_mana.saturating_add(mana_bonus).max(1);
|
||||||
|
write_i32_field(game_state, "playerMaxHp", next_max_hp);
|
||||||
|
write_i32_field(game_state, "playerHp", current_hp.min(next_max_hp));
|
||||||
|
write_i32_field(game_state, "playerMaxMana", next_max_mana);
|
||||||
|
write_i32_field(game_state, "playerMana", next_max_mana);
|
||||||
|
write_runtime_equipment_bonus_cache(game_state, "maxHpBonus", hp_bonus);
|
||||||
|
write_runtime_equipment_bonus_cache(game_state, "maxManaBonus", mana_bonus);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_runtime_equipment_bonus_cache(game_state: &Value, key: &str) -> i32 {
|
||||||
|
read_field(game_state, "runtimeEquipmentBonusCache")
|
||||||
|
.and_then(|cache| read_i32_field(cache, key))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_runtime_equipment_bonus_cache(game_state: &mut Value, key: &str, value: i32) {
|
||||||
|
let root = ensure_json_object(game_state);
|
||||||
|
let cache = root
|
||||||
|
.entry("runtimeEquipmentBonusCache".to_string())
|
||||||
|
.or_insert_with(|| Value::Object(Map::new()));
|
||||||
|
if !cache.is_object() {
|
||||||
|
*cache = Value::Object(Map::new());
|
||||||
|
}
|
||||||
|
cache
|
||||||
|
.as_object_mut()
|
||||||
|
.expect("runtimeEquipmentBonusCache should be object")
|
||||||
|
.insert(key.to_string(), json!(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_current_build_toast(game_state: &Value) -> String {
|
||||||
|
let (_hp_bonus, _mana_bonus, outgoing_bonus, _incoming_multiplier) =
|
||||||
|
read_equipment_total_bonuses(game_state);
|
||||||
|
let build_multiplier = (1.0 + outgoing_bonus).max(1.0);
|
||||||
|
format!("当前 Build 倍率 x{build_multiplier:.2}")
|
||||||
|
}
|
||||||
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal file
148
server-rs/crates/module-runtime-story-compat/src/lib.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
use shared_contracts::runtime_story::{
|
||||||
|
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView,
|
||||||
|
RuntimeStoryPatch, RuntimeStorySnapshotPayload,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub mod battle;
|
||||||
|
pub mod core;
|
||||||
|
pub mod forge;
|
||||||
|
pub mod forge_actions;
|
||||||
|
pub mod game_state;
|
||||||
|
pub mod npc_support;
|
||||||
|
pub mod options;
|
||||||
|
pub mod view_model;
|
||||||
|
|
||||||
|
pub use core::{
|
||||||
|
MAX_PLAYER_LEVEL, add_player_currency, add_player_inventory_items, append_active_build_buffs,
|
||||||
|
append_story_history, clear_encounter_only, clear_encounter_state, cumulative_xp_required,
|
||||||
|
ensure_json_object, first_hostile_npc_string_field, format_now_rfc3339,
|
||||||
|
grant_player_progression_experience, increment_runtime_stat, normalize_optional_string,
|
||||||
|
normalize_required_string, read_array_field, read_bool_field, read_field, read_i32_field,
|
||||||
|
read_object_field, read_optional_string_field, read_required_string_field,
|
||||||
|
read_runtime_session_id, read_u32_field, remove_player_inventory_item,
|
||||||
|
resolve_progression_level, write_bool_field, write_current_encounter_i32_field,
|
||||||
|
write_first_hostile_npc_i32_field, write_i32_field, write_null_field, write_string_field,
|
||||||
|
write_u32_field, xp_to_next_level_for,
|
||||||
|
};
|
||||||
|
pub use game_state::{
|
||||||
|
add_inventory_items_to_list, apply_equipment_loadout_to_state, battle_mode_text,
|
||||||
|
build_current_build_toast, clone_inventory_item_with_quantity, current_encounter_id,
|
||||||
|
current_encounter_name, current_encounter_name_from_battle, ensure_inventory_action_available,
|
||||||
|
equipment_bonus_fallbacks, equipment_item_bonuses, equipment_slot_label,
|
||||||
|
find_player_inventory_entry, has_giftable_player_inventory, item_rarity_key,
|
||||||
|
normalize_equipped_item, normalize_equipment_slot_id, read_equipment_total_bonuses,
|
||||||
|
read_inventory_item_name, read_player_equipment_item, read_player_inventory_values,
|
||||||
|
read_runtime_equipment_bonus_cache, remove_inventory_item_from_list,
|
||||||
|
resolve_equipment_slot_for_item, write_player_equipment_item, write_player_inventory_values,
|
||||||
|
write_runtime_equipment_bonus_cache,
|
||||||
|
};
|
||||||
|
pub use forge::{build_runtime_equipment_item, build_runtime_material_item, format_currency_text};
|
||||||
|
pub use forge_actions::{
|
||||||
|
resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action,
|
||||||
|
};
|
||||||
|
pub use npc_support::{
|
||||||
|
build_npc_gift_result_text, npc_buyback_price, npc_purchase_price, recruit_companion_to_party,
|
||||||
|
resolve_npc_gift_affinity_gain, trade_quantity_suffix,
|
||||||
|
};
|
||||||
|
pub use battle::{build_battle_runtime_story_options, resolve_battle_action, restore_player_resource};
|
||||||
|
pub use options::{
|
||||||
|
build_disabled_runtime_story_option, build_runtime_story_option_from_story_option,
|
||||||
|
build_runtime_story_option_interaction, build_runtime_story_option_with_payload,
|
||||||
|
build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope,
|
||||||
|
};
|
||||||
|
pub use view_model::{
|
||||||
|
build_runtime_story_companions, build_runtime_story_encounter, build_runtime_story_view_model,
|
||||||
|
resolve_current_encounter_npc_state,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CONTINUE_ADVENTURE_FUNCTION_ID: &str = "story_continue_adventure";
|
||||||
|
pub const MAX_TASK5_COMPANIONS: usize = 2;
|
||||||
|
|
||||||
|
pub struct StoryResolution {
|
||||||
|
pub action_text: String,
|
||||||
|
pub result_text: String,
|
||||||
|
pub story_text: Option<String>,
|
||||||
|
pub presentation_options: Option<Vec<RuntimeStoryOptionView>>,
|
||||||
|
pub saved_current_story: Option<Value>,
|
||||||
|
pub patches: Vec<RuntimeStoryPatch>,
|
||||||
|
pub battle: Option<RuntimeBattlePresentation>,
|
||||||
|
pub toast: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GeneratedStoryPayload {
|
||||||
|
pub story_text: String,
|
||||||
|
pub history_result_text: String,
|
||||||
|
pub presentation_options: Vec<RuntimeStoryOptionView>,
|
||||||
|
pub saved_current_story: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CurrentEncounterNpcQuestContext {
|
||||||
|
pub npc_id: String,
|
||||||
|
pub npc_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PendingQuestOfferContext {
|
||||||
|
pub dialogue: Vec<Value>,
|
||||||
|
pub turn_count: i32,
|
||||||
|
pub custom_input_placeholder: String,
|
||||||
|
pub quest: Value,
|
||||||
|
pub quest_id: String,
|
||||||
|
pub intro_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RuntimeStoryActionResponseParts {
|
||||||
|
pub requested_session_id: String,
|
||||||
|
pub server_version: u32,
|
||||||
|
pub snapshot: RuntimeStorySnapshotPayload,
|
||||||
|
pub action_text: String,
|
||||||
|
pub result_text: String,
|
||||||
|
pub story_text: String,
|
||||||
|
pub options: Vec<RuntimeStoryOptionView>,
|
||||||
|
pub patches: Vec<RuntimeStoryPatch>,
|
||||||
|
pub toast: Option<String>,
|
||||||
|
pub battle: Option<RuntimeBattlePresentation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub 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",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_world_type(game_state: &Value) -> Option<String> {
|
||||||
|
read_optional_string_field(game_state, "worldType")
|
||||||
|
}
|
||||||
216
server-rs/crates/module-runtime-story-compat/src/npc_support.rs
Normal file
216
server-rs/crates/module-runtime-story-compat/src/npc_support.rs
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
MAX_TASK5_COMPANIONS, ensure_json_object, item_rarity_key, normalize_required_string,
|
||||||
|
read_array_field, read_i32_field, read_inventory_item_name, read_optional_string_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub 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 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 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 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 fn trade_quantity_suffix(quantity: i32) -> String {
|
||||||
|
if quantity > 1 {
|
||||||
|
format!(" x{quantity}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fn recruit_companion_to_party(
|
||||||
|
game_state: &mut Value,
|
||||||
|
npc_id: &str,
|
||||||
|
joined_at_affinity: i32,
|
||||||
|
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, joined_at_affinity);
|
||||||
|
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(release_npc_id);
|
||||||
|
add_companion_if_absent(game_state, npc_id, None, joined_at_affinity);
|
||||||
|
Ok(Some(released_name))
|
||||||
|
}
|
||||||
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal file
126
server-rs/crates/module-runtime-story-compat/src/options.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use shared_contracts::runtime_story::{
|
||||||
|
RuntimeStoryOptionInteraction, RuntimeStoryOptionView,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
read_bool_field, read_field, read_optional_string_field, read_required_string_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 这批 helper 只负责 runtime story option 的纯 DTO 编译,不触碰 HTTP / AppState。
|
||||||
|
pub 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 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 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 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 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 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 fn build_story_option_from_runtime_option(option: &RuntimeStoryOptionView) -> Value {
|
||||||
|
serde_json::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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use shared_contracts::runtime_story::{
|
||||||
|
RuntimeStoryCompanionViewModel, RuntimeStoryEncounterViewModel, RuntimeStoryOptionView,
|
||||||
|
RuntimeStoryPlayerViewModel, RuntimeStoryStatusViewModel, RuntimeStoryViewModel,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
read_array_field, read_bool_field, read_i32_field, read_object_field,
|
||||||
|
read_optional_string_field, read_required_string_field,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 运行时故事 view-model 只依赖快照 JSON 与共享 contract,可脱离 HTTP 层独立编译。
|
||||||
|
pub 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 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 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 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))
|
||||||
|
}
|
||||||
@@ -51,11 +51,35 @@
|
|||||||
5. `story-sessions/begin`
|
5. `story-sessions/begin`
|
||||||
6. `story-sessions/continue`
|
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 基线:
|
当前阶段新增 Stage5 `runtime story` 兼容桥 DTO 基线:
|
||||||
|
|
||||||
1. `runtime/story/state/resolve` 请求 DTO
|
1. `runtime/story/state/resolve` 请求 DTO
|
||||||
|
2. `runtime/story/actions/resolve`、`runtime/story/initial`、`runtime/story/continue` 请求 DTO
|
||||||
2. `RuntimeStoryActionResponse` 兼容响应 DTO
|
2. `RuntimeStoryActionResponse` 兼容响应 DTO
|
||||||
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
|
3. `RuntimeStoryViewModel / presentation / patches / snapshot` 显式结构
|
||||||
|
4. `RuntimeStoryAiResponse` 兼容响应 DTO
|
||||||
|
|
||||||
当前仍刻意未做:
|
当前仍刻意未做:
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use platform_oss::{
|
|||||||
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
OssObjectAccess, OssPostObjectFormFields, OssPostObjectResponse, OssSignedGetObjectUrlResponse,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -83,6 +84,289 @@ pub struct BindAssetObjectRequest {
|
|||||||
pub profile_id: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CreateDirectUploadTicketResponse {
|
pub struct CreateDirectUploadTicketResponse {
|
||||||
@@ -358,4 +642,177 @@ mod tests {
|
|||||||
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
assert_eq!(payload["assetObject"]["accessPolicy"], json!("private"));
|
||||||
assert_eq!(payload["assetObject"]["contentLength"], json!(1024));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use serde_json::Value;
|
|||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RuntimeStorySnapshotPayload {
|
pub struct RuntimeStorySnapshotPayload {
|
||||||
pub saved_at: String,
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub saved_at: Option<String>,
|
||||||
pub bottom_tab: String,
|
pub bottom_tab: String,
|
||||||
pub game_state: Value,
|
pub game_state: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -21,6 +22,72 @@ pub struct RuntimeStoryStateResolveRequest {
|
|||||||
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeStoryChoiceAction {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub action_type: String,
|
||||||
|
pub function_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub payload: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeStoryActionRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub client_version: Option<u32>,
|
||||||
|
pub action: RuntimeStoryChoiceAction,
|
||||||
|
#[serde(default)]
|
||||||
|
pub snapshot: Option<RuntimeStorySnapshotPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeStoryAiRequestOptions {
|
||||||
|
#[serde(default)]
|
||||||
|
pub available_options: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub option_catalog: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RuntimeStoryAiRequestOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
available_options: Vec::new(),
|
||||||
|
option_catalog: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeStoryAiRequest {
|
||||||
|
pub world_type: String,
|
||||||
|
pub character: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub monsters: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub history: Vec<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub choice: String,
|
||||||
|
pub context: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub request_options: RuntimeStoryAiRequestOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct RuntimeStoryAiResponse {
|
||||||
|
pub story_text: String,
|
||||||
|
pub options: Vec<Value>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub encounter: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RuntimeStoryOptionView {
|
pub struct RuntimeStoryOptionView {
|
||||||
@@ -49,10 +116,6 @@ pub enum RuntimeStoryOptionInteraction {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
quest_id: Option<String>,
|
quest_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
Treasure {
|
|
||||||
action: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -197,30 +260,72 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_story_state_resolve_request_uses_camel_case_fields() {
|
fn runtime_story_state_resolve_request_accepts_missing_saved_at() {
|
||||||
let payload = serde_json::to_value(RuntimeStoryStateResolveRequest {
|
let payload: RuntimeStoryStateResolveRequest = serde_json::from_value(json!({
|
||||||
|
"sessionId": "runtime-main",
|
||||||
|
"clientVersion": 7,
|
||||||
|
"snapshot": {
|
||||||
|
"bottomTab": "adventure",
|
||||||
|
"gameState": { "runtimeSessionId": "runtime-main" },
|
||||||
|
"currentStory": { "text": "营地里的火光还没有熄灭。" }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("payload should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(payload.session_id, "runtime-main");
|
||||||
|
assert_eq!(payload.client_version, Some(7));
|
||||||
|
assert_eq!(
|
||||||
|
payload.snapshot.expect("snapshot should exist").saved_at,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_story_action_request_uses_camel_case_fields() {
|
||||||
|
let payload = serde_json::to_value(RuntimeStoryActionRequest {
|
||||||
session_id: "runtime-main".to_string(),
|
session_id: "runtime-main".to_string(),
|
||||||
client_version: Some(7),
|
client_version: Some(8),
|
||||||
|
action: RuntimeStoryChoiceAction {
|
||||||
|
action_type: "story_choice".to_string(),
|
||||||
|
function_id: "npc_chat".to_string(),
|
||||||
|
target_id: Some("npc_camp_firekeeper".to_string()),
|
||||||
|
payload: Some(json!({ "optionText": "继续交谈" })),
|
||||||
|
},
|
||||||
snapshot: Some(RuntimeStorySnapshotPayload {
|
snapshot: Some(RuntimeStorySnapshotPayload {
|
||||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
|
||||||
bottom_tab: "adventure".to_string(),
|
bottom_tab: "adventure".to_string(),
|
||||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||||
current_story: Some(json!({ "text": "营地里的火光还没有熄灭。" })),
|
current_story: None,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
.expect("payload should serialize");
|
.expect("payload should serialize");
|
||||||
|
|
||||||
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
assert_eq!(payload["sessionId"], json!("runtime-main"));
|
||||||
assert_eq!(payload["clientVersion"], json!(7));
|
assert_eq!(payload["clientVersion"], json!(8));
|
||||||
assert_eq!(payload["snapshot"]["savedAt"], json!("2026-04-22T12:00:00.000Z"));
|
assert_eq!(payload["action"]["type"], json!("story_choice"));
|
||||||
assert_eq!(payload["snapshot"]["bottomTab"], json!("adventure"));
|
assert_eq!(payload["action"]["functionId"], json!("npc_chat"));
|
||||||
assert_eq!(payload["snapshot"]["gameState"]["runtimeSessionId"], json!("runtime-main"));
|
assert_eq!(payload["action"]["targetId"], json!("npc_camp_firekeeper"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
payload["snapshot"]["currentStory"]["text"],
|
payload["snapshot"]["savedAt"],
|
||||||
json!("营地里的火光还没有熄灭。")
|
json!("2026-04-22T12:00:00.000Z")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_story_ai_request_defaults_optional_arrays() {
|
||||||
|
let payload: RuntimeStoryAiRequest = serde_json::from_value(json!({
|
||||||
|
"worldType": "martial",
|
||||||
|
"character": { "name": "林迟" },
|
||||||
|
"context": { "scene": "camp" }
|
||||||
|
}))
|
||||||
|
.expect("payload should deserialize");
|
||||||
|
|
||||||
|
assert_eq!(payload.world_type, "martial");
|
||||||
|
assert!(payload.monsters.is_empty());
|
||||||
|
assert!(payload.history.is_empty());
|
||||||
|
assert!(payload.request_options.available_options.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_story_action_response_uses_camel_case_fields() {
|
fn runtime_story_action_response_uses_camel_case_fields() {
|
||||||
let payload = serde_json::to_value(RuntimeStoryActionResponse {
|
let payload = serde_json::to_value(RuntimeStoryActionResponse {
|
||||||
@@ -297,7 +402,7 @@ mod tests {
|
|||||||
current_npc_battle_outcome: None,
|
current_npc_battle_outcome: None,
|
||||||
}],
|
}],
|
||||||
snapshot: RuntimeStorySnapshotPayload {
|
snapshot: RuntimeStorySnapshotPayload {
|
||||||
saved_at: "2026-04-22T12:00:00.000Z".to_string(),
|
saved_at: Some("2026-04-22T12:00:00.000Z".to_string()),
|
||||||
bottom_tab: "adventure".to_string(),
|
bottom_tab: "adventure".to_string(),
|
||||||
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
game_state: json!({ "runtimeSessionId": "runtime-main" }),
|
||||||
current_story: Some(json!({
|
current_story: Some(json!({
|
||||||
|
|||||||
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
@@ -149,22 +149,6 @@ use spacetimedb::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timest
|
|||||||
mod puzzle;
|
mod puzzle;
|
||||||
|
|
||||||
// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。
|
// 这层输入只服务 NPC 开战编排;普通聊天、援手、招募继续走已有 resolve_npc_interaction 接口。
|
||||||
#[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。
|
// 输出同时返回 NPC 交互结果与 battle_state 快照,避免 Axum 再回头读取 private table。
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||||
pub struct NpcBattleInteractionResult {
|
pub struct NpcBattleInteractionResult {
|
||||||
|
|||||||
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-custom-world-scenes/**',
|
||||||
'**/public/generated-qwen-sprites/**',
|
'**/public/generated-qwen-sprites/**',
|
||||||
];
|
];
|
||||||
const runtimeServerTarget =
|
const backendStack = (env.GENARRATIVE_BACKEND_STACK || 'node').trim().toLowerCase();
|
||||||
|
const nodeServerTarget =
|
||||||
env.NODE_SERVER_TARGET ||
|
env.NODE_SERVER_TARGET ||
|
||||||
'http://127.0.0.1:8081';
|
'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 {
|
return {
|
||||||
root: __dirname,
|
root: __dirname,
|
||||||
@@ -59,11 +66,21 @@ export default defineConfig(({mode}) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/generated-animations': {
|
||||||
|
target: runtimeServerTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/generated-custom-world-scenes': {
|
'/generated-custom-world-scenes': {
|
||||||
target: runtimeServerTarget,
|
target: runtimeServerTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/generated-custom-world-covers': {
|
||||||
|
target: runtimeServerTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/generated-qwen-sprites': {
|
'/generated-qwen-sprites': {
|
||||||
target: runtimeServerTarget,
|
target: runtimeServerTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -79,6 +96,11 @@ export default defineConfig(({mode}) => {
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
'/api/story': {
|
||||||
|
target: runtimeServerTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/api/editor': {
|
'/api/editor': {
|
||||||
target: runtimeServerTarget,
|
target: runtimeServerTarget,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user