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:
2026-04-22 20:37:56 +08:00
82 changed files with 26950 additions and 1312 deletions

View File

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

View File

@@ -113,6 +113,7 @@
3. 部署 3. 部署
4. 观测 4. 观测
5. 灰度切流 5. 灰度切流
6. 收口 `spacetime-module` 主工程结构,拆分过大的 `src/lib.rs`
详见: 详见:

View File

@@ -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 再做远端灰度与回退验证

View File

@@ -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 2custom world `scene-image` 走真实 DashScope 图片生成,并继续写入 `OSS + asset_object + asset_entity_binding`
- [ ] 迁移 Qwen 整表生成 - [x] 迁移封面图上传(已完成 Stage 2custom 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] 所有新生成资产都写入 OSSStage 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 强结构化,再进入独立阶段。

View File

@@ -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 与 tracingM7 固化字段口径)
- [ ] 接入关键 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 的完整运行环境,不在无外部服务的本地预检中虚假勾选。

View File

@@ -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 的前提下可跑通
- [ ] 能完成灰度切流,并保留可回退能力 - [ ] 能完成灰度切流,并保留可回退能力

View File

@@ -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. 灰度环境双跑对比。

View File

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

View 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 放进新 crateroute 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 组合逻辑

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` 中路径兼容项完成勾选。

View File

@@ -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 指向 RustNode 进程保留但不作为主入口。
前端切换方式:
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` 组织的文件结构。

View File

@@ -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/*` 历史路径兼容,不再存在独立路由主链。
## 总览 ## 总览

View File

@@ -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 落地口径,明确当前上传主链为服务器上传 OSSWeb 端只负责签名读下载。 - [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 落地口径,明确当前上传主链为服务器上传 OSSWeb 端只负责签名读下载。
- [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`

View 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 清单,避免只完成编译而遗漏外部可访问路径。

View File

@@ -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 只替换传输层,不改变业务协议。

View File

@@ -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 联调和灰度切流验收。

View File

@@ -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. 验收标准
重写完成至少要满足: 重写完成至少要满足:

View File

@@ -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 级改动范围

View File

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

View File

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

View 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
View 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
View 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
View 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;
});

View 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
View File

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

View File

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

View File

@@ -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` 切流和回退开关。
后续任务会继续在本目录内按顺序补齐: 后续任务会继续在本目录内按顺序补齐:

View File

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

View File

@@ -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 分片。

View File

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

File diff suppressed because it is too large Load Diff

View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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"
);
}
}

View File

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

View File

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

View 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()
);
}
}

View File

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

View File

@@ -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")
} }

View File

@@ -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")
}
}

View 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))
}

View 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} 的局势随之向下一步展开。")
}

View File

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

View File

@@ -0,0 +1,5 @@
use super::*;
pub(super) use module_runtime_story_compat::{
build_runtime_equipment_item, build_runtime_material_item,
};

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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 不能为空"),

View 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"] }

View 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。

View 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())
}

View 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())
}

View 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}")
}

View File

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

View 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}")
}

View 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")
}

View 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))
}

View 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,
})
}

View File

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

View File

@@ -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
当前仍刻意未做: 当前仍刻意未做:

View File

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

View File

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

View 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(),
}
}

View 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)
}

File diff suppressed because it is too large Load Diff

View 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>,
}

View 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
);
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View 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"

View File

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