# M4 module-combat SpacetimeDB 基线设计(2026-04-21) 更新时间:`2026-04-22` ## 0. 文档目标 本文件只冻结一件事: **把 `module-combat` 从“只有 README 占位”推进到“首版 battle_state 与 resolve_combat_action 可真实编码、可编译、可继续扩展”的工程基线。** 本轮不宣称完成完整 `runtime story action` 迁移,也不把 `inventory / npc / story AI 续写` 直接耦进战斗 reducer;跨子域写入继续收敛在 `spacetime-module` 聚合层。 --- ## 1. 本轮落地范围 本轮只做下面 5 件事: 1. 新增 `server-rs/crates/module-combat/` 真实 crate。 2. 冻结 `battle_state` 的首版领域类型、枚举、输入结构与字段校验 helper。 3. 冻结 `resolve_combat_action` 的首版输入、输出与纯规则推进逻辑。 4. 在 `server-rs/crates/spacetime-module/` 中新增 `battle_state` 表。 5. 在 `spacetime-module` 中新增 `create_battle_state`、`resolve_combat_action` 两个 reducer。 --- ## 2. 当前冻结的实现边界 ### 2.1 首版必须支持的战斗 function 首版与 [../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md) 保持一致,只支持以下单行为入口: 1. `battle_attack_basic` 2. `battle_recover_breath` 3. `battle_use_skill` 4. `battle_escape_breakout` 5. 旧兼容攻击类: - `battle_all_in_crush` - `battle_guard_break` - `battle_probe_pressure` - `battle_feint_step` - `battle_finisher_window` 本轮刻意不接入: 1. `inventory_use` 2. 技能与物品的正式外部明细读取 3. 与 `quest_record`、`npc_state` 的联动写入 4. 脱战后 `story_event` 追加与 AI 续写触发 ### 2.2 为什么先不做 `inventory_use` 当前 Rust 侧还没有 `inventory_slot` 正式表,也没有稳定的战斗内物品快照输入。 如果现在把 `inventory_use` 硬塞进 `module-combat`,只会出现两种坏结果: 1. reducer 内部引入并不存在的 inventory 真相依赖; 2. 退回成“让 Axum 先算完再写 battle_state”的伪迁移。 因此本轮明确冻结为: 1. `module-combat` 先完成纯战斗状态推进; 2. `inventory_use` 留到 `inventory_slot` 与 runtime snapshot projection 口径稳定后再接。 --- ## 3. `battle_state` 首版字段 首版 `battle_state` 冻结为以下字段: 1. `battle_state_id` 2. `story_session_id` 3. `runtime_session_id` 4. `actor_user_id` 5. `target_npc_id` 6. `target_name` 7. `battle_mode` 8. `status` 9. `player_hp` 10. `player_max_hp` 11. `player_mana` 12. `player_max_mana` 13. `target_hp` 14. `target_max_hp` 15. `chapter_id` 16. `experience_reward` 17. `reward_items` 18. `turn_index` 19. `last_action_function_id` 20. `last_action_text` 21. `last_result_text` 22. `last_damage_dealt` 23. `last_damage_taken` 24. `last_outcome` 25. `version` 26. `created_at` 27. `updated_at` ### 3.1 设计意图 首版只解决下面这些真相问题: 1. 当前战斗是否存在、是否仍在进行中; 2. 玩家与当前目标的 HP / MP 最小数值状态; 3. 当前是 `fight` 还是 `spar`; 4. 当前战斗归属哪个章节; 5. 本场战斗若胜利应发多少经验; 6. 本场战斗若胜利应发哪些已编译好的 reward item; 7. 最近一次动作结算了什么; 8. 当前 battle reducer 是否发生过版本推进。 ### 3.2 当前刻意不放入的字段 本轮明确不放: 1. 多目标列表 2. 技能冷却 map 3. build buff 详情 4. 掉落预算、好感预算、剧情上下文大对象 5. 大型 `rawGameState` 镜像字段 原因很直接:这些都属于后续跨子域联动层,不适合在 `battle_state` 首版里重新堆一个大 JSON。 --- ## 4. 枚举与动作口径 ### 4.1 `BattleMode` 只保留两种: 1. `Fight` 2. `Spar` ### 4.2 `BattleStatus` 只保留三种: 1. `Ongoing` 2. `Resolved` 3. `Aborted` 说明: 1. `Resolved` 表示战斗已正常收束,包括胜利、切磋结束、成功逃脱。 2. `Aborted` 预留给后续 session 中断、外部清理、投影回滚等异常收束场景。 ### 4.3 `CombatOutcome` 首版冻结: 1. `Ongoing` 2. `Victory` 3. `SparComplete` 4. `Escaped` 这与当前共享契约里的 `RuntimeBattlePresentation.outcome` 一致,避免首版就制造新的枚举翻译成本。 --- ## 5. `resolve_combat_action` 首版规则 ### 5.1 输入 首版 reducer 输入只包含: 1. `battle_state_id` 2. `function_id` 3. `action_text` 4. `base_damage` 5. `mana_cost` 6. `heal` 7. `mana_restore` 8. `counter_multiplier` 9. `updated_at_micros` ### 5.2 为什么允许输入 `base_damage` 本轮 `module-combat` 的职责是把战斗推进规则固定到 SpacetimeDB。 但玩家技能、装备 build、物品 buff、成长曲线这些正式真相仍未迁完,因此首版允许上游把已算好的 `base_damage / mana_cost / heal / mana_restore` 作为确定输入传进 reducer。 这意味着当前模块边界是: 1. `module-combat` 负责状态推进、反击、逃跑、战斗收束规则; 2. 更高层的 build / skill / item 数值来源仍可在后续模块中逐步收敛; 3. 等 `inventory / progression / runtime build` 真相表稳定后,再继续把这些输入收得更窄。 ### 5.3 动作规则 #### A. `battle_escape_breakout` 直接结束战斗: 1. `status = Resolved` 2. `last_outcome = Escaped` 3. `last_damage_dealt = 0` 4. `last_damage_taken = 0` #### B. `battle_recover_breath` 恢复类动作: 1. 玩家回复 `heal` 2. 玩家回复 `mana_restore` 3. 若战斗仍持续,则按 `counter_multiplier` 吃一次敌方反击 #### C. `battle_attack_basic` / 旧兼容攻击类 / `battle_use_skill` 攻击类动作: 1. 目标扣除 `base_damage` 2. 若目标已收束,则按 `battle_mode` 进入 `Victory / SparComplete` 3. 若目标未收束,则玩家按 `counter_multiplier` 吃一次敌方反击 ### 5.4 反击规则 首版固定: 1. `fight` 下敌方基础反击伤害 = `max(4, round(target_max_hp * 0.14 * counter_multiplier))` 2. `spar` 下敌方基础反击伤害固定为 `1` 这是对当前 Node 逻辑的直接收敛,先保证行为方向不漂移,不在本轮发明新的战斗公式。 ### 5.5 HP 下限规则 1. `fight` 下正常下限为 `0` 2. `spar` 下双方 HP 最低保留为 `1` 这样能保留当前“切磋点到为止”的旧行为,不把 `spar` 错结算成死亡战斗。 --- ## 6. `spacetime-module` 接线口径 ### 6.1 battle_state 表 `spacetime-module` 首版只新增一张 private 真相表: 1. `battle_state` 建议索引: 1. `by_story_session_id` 2. `by_runtime_session_id` 3. `by_actor_user_id` ### 6.2 reducer 当前仍只保留两个战斗 reducer: 1. `create_battle_state` 2. `resolve_combat_action` 职责: 1. `create_battle_state` 只负责插入 battle 真相,不负责故事会话编排。 2. `resolve_combat_action` 负责推进 battle 真相。 3. 当 `Victory` 收束时,由 `spacetime-module` 聚合层继续把 `experience_reward` 联动写入 `player_progression / chapter_progression`。 4. 当 `Victory` 收束且 `reward_items` 非空时,由 `spacetime-module` 聚合层继续把战利品写入 `inventory_slot`。 5. `resolve_combat_action` 仍不负责 AI 续写和 quest signal 全量分发。 --- ## 7. 与后续子域的边界 ### 7.1 与 `story` 当前关系: 1. `story` 负责更高层 action 路由与后续 story_event 追加; 2. `combat` 只返回 battle 真相推进结果。 后续再补: 1. 战斗结束时的 `story_event` 2. 脱战后的 `continue_story` / `resolve_story_action` ### 7.2 与 `inventory` 当前不直接耦合到 `module-combat` reducer。 后续再补: 1. 战斗内 `inventory_use` 2. 消耗品扣减 3. 战斗 buff 写入 当前已存在的聚合层联动: 1. `Victory` 时可把 `battle_state.reward_items` 写入 `inventory_slot` ### 7.3 与 `progression` 当前不直接在 `module-combat` reducer 内发经验与等级变更。 后续再补: 1. hostile scaling 与 reward 编译口径 当前已存在的聚合层联动: 1. `fight_victory` 的经验发放 2. 章节账本写入 ### 7.4 与 `npc` 当前不直接改好感。 后续再补: 1. `spar_complete` 的 affinity 变化 2. `fight / spar` 与 encounter 状态同步 --- ## 8. 本轮验收口径 满足以下条件,视为本轮 `module-combat` 基线完成: 1. `server-rs/crates/module-combat` 已从 README 占位升级为真实 crate。 2. `battle_state`、`BattleMode`、`BattleStatus`、`CombatOutcome`、`ResolveCombatActionInput` 已冻结到代码。 3. `spacetime-module` 已新增 `battle_state` 表。 4. `spacetime-module` 已新增 `create_battle_state` 与 `resolve_combat_action` reducer。 5. `cargo check -p module-combat -p spacetime-module` 通过。 --- ## 9. 下一步建议 在本轮基线稳定后,下一步按以下顺序推进最稳: 1. 设计 `inventory_slot` 与战斗内 `inventory_use` 的最小真相输入。 2. 设计 `resolve_story_action` 如何编排 `story + combat + npc + quest + inventory`。 3. 把 `battle_state` 结束事件接入 `story_event`。 4. 再把 Axum facade 与 `RuntimeStoryActionResponse.battle` 真正打通。