This commit is contained in:
15
server-rs/crates/module-combat/Cargo.toml
Normal file
15
server-rs/crates/module-combat/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "module-combat"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
spacetime-types = ["dep:spacetimedb"]
|
||||
|
||||
[dependencies]
|
||||
module-runtime-item = { path = "../module-runtime-item", default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shared-kernel = { path = "../shared-kernel" }
|
||||
spacetimedb = { workspace = true, optional = true }
|
||||
47
server-rs/crates/module-combat/README.md
Normal file
47
server-rs/crates/module-combat/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# module-combat
|
||||
|
||||
日期:`2026-04-21`
|
||||
|
||||
## 1. package 职责
|
||||
|
||||
`module-combat` 是 M4 阶段的战斗规则 crate,当前负责:
|
||||
|
||||
1. `battle_state` 首版领域类型与字段校验
|
||||
2. `resolve_combat_action` 的纯规则推进
|
||||
3. `fight / spar` 两种模式下的战斗收束规则
|
||||
4. 为 `spacetime-module` 提供可直接复用的战斗状态与 reducer 输入输出类型
|
||||
|
||||
## 2. 当前实现范围
|
||||
|
||||
当前已经真实落地:
|
||||
|
||||
1. `BattleMode / BattleStatus / CombatOutcome`
|
||||
2. `BattleStateInput / BattleStateSnapshot / BattleStateQueryInput`
|
||||
3. `ResolveCombatActionInput / ResolveCombatActionResult`
|
||||
4. `BattleStateProcedureResult / ResolveCombatActionProcedureResult`
|
||||
5. `battle_attack_basic / battle_recover_breath / battle_use_skill / battle_escape_breakout`
|
||||
6. 旧攻击类 function 的兼容解析
|
||||
7. `chapter_id / experience_reward` 最小承载字段,供 `spacetime-module` 在胜利时联动成长结算
|
||||
|
||||
当前刻意未做:
|
||||
|
||||
1. `inventory_use`
|
||||
2. 掉落、好感、任务信号联动
|
||||
3. story AI 续写触发
|
||||
4. 多目标或完整 build / cooldown 真相建模
|
||||
|
||||
## 3. 配套文档
|
||||
|
||||
落地依据见:
|
||||
|
||||
1. [../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_SPACETIMEDB_BASELINE_2026-04-21.md)
|
||||
2. [../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md](../../../docs/technical/M4_MODULE_COMBAT_AXUM_FACADE_DESIGN_2026-04-21.md)
|
||||
3. [../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md](../../../docs/technical/M4_MODULE_COMBAT_STATE_QUERY_DESIGN_2026-04-22.md)
|
||||
4. [../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md](../../../docs/technical/M4_PROGRESSION_QUEST_COMBAT_INTEGRATION_2026-04-21.md)
|
||||
5. [../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md](../../../docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md)
|
||||
|
||||
## 4. 边界约束
|
||||
|
||||
1. `module-combat` 只做纯规则、纯状态推进,不承接 HTTP、LLM、OSS 或文件 IO。
|
||||
2. 任何与 `inventory / progression / npc / story` 的联动,都应先在文档里冻结边界后再继续接入。
|
||||
3. 该 crate 的目标不是替代 Axum facade,而是成为 `spacetime-module` 里的战斗真相规则层。
|
||||
835
server-rs/crates/module-combat/src/lib.rs
Normal file
835
server-rs/crates/module-combat/src/lib.rs
Normal file
@@ -0,0 +1,835 @@
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use module_runtime_item::{
|
||||
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
|
||||
#[cfg(feature = "spacetime-types")]
|
||||
use spacetimedb::SpacetimeType;
|
||||
|
||||
pub const BATTLE_STATE_ID_PREFIX: &str = "battle_";
|
||||
pub const INITIAL_BATTLE_VERSION: u32 = 1;
|
||||
pub const BASIC_FIGHT_COUNTER_RATIO: f32 = 0.14;
|
||||
pub const MIN_FIGHT_COUNTER_DAMAGE: i32 = 4;
|
||||
pub const SPAR_MIN_HP: i32 = 1;
|
||||
|
||||
const LEGACY_ATTACK_FUNCTION_IDS: [&str; 5] = [
|
||||
"battle_all_in_crush",
|
||||
"battle_guard_break",
|
||||
"battle_probe_pressure",
|
||||
"battle_feint_step",
|
||||
"battle_finisher_window",
|
||||
];
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BattleMode {
|
||||
Fight,
|
||||
Spar,
|
||||
}
|
||||
|
||||
impl BattleMode {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Fight => "fight",
|
||||
Self::Spar => "spar",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BattleStatus {
|
||||
Ongoing,
|
||||
Resolved,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
impl BattleStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ongoing => "ongoing",
|
||||
Self::Resolved => "resolved",
|
||||
Self::Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CombatOutcome {
|
||||
Ongoing,
|
||||
Victory,
|
||||
SparComplete,
|
||||
Escaped,
|
||||
}
|
||||
|
||||
impl CombatOutcome {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ongoing => "ongoing",
|
||||
Self::Victory => "victory",
|
||||
Self::SparComplete => "spar_complete",
|
||||
Self::Escaped => "escaped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CombatFieldError {
|
||||
MissingBattleStateId,
|
||||
MissingStorySessionId,
|
||||
MissingRuntimeSessionId,
|
||||
MissingActorUserId,
|
||||
MissingTargetNpcId,
|
||||
MissingTargetName,
|
||||
MissingFunctionId,
|
||||
InvalidVersion,
|
||||
InvalidPlayerVitals,
|
||||
InvalidTargetVitals,
|
||||
InvalidRewardItem(String),
|
||||
BattleAlreadyResolved,
|
||||
UnsupportedFunctionId,
|
||||
InsufficientMana,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateInput {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
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>,
|
||||
pub created_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateSnapshot {
|
||||
pub battle_state_id: String,
|
||||
pub story_session_id: String,
|
||||
pub runtime_session_id: String,
|
||||
pub actor_user_id: String,
|
||||
pub chapter_id: Option<String>,
|
||||
pub target_npc_id: String,
|
||||
pub target_name: String,
|
||||
pub battle_mode: BattleMode,
|
||||
pub status: BattleStatus,
|
||||
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>,
|
||||
pub turn_index: u32,
|
||||
pub last_action_function_id: Option<String>,
|
||||
pub last_action_text: Option<String>,
|
||||
pub last_result_text: Option<String>,
|
||||
pub last_damage_dealt: i32,
|
||||
pub last_damage_taken: i32,
|
||||
pub last_outcome: CombatOutcome,
|
||||
pub version: u32,
|
||||
pub created_at_micros: i64,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionInput {
|
||||
pub battle_state_id: String,
|
||||
pub function_id: String,
|
||||
pub action_text: String,
|
||||
pub base_damage: i32,
|
||||
pub mana_cost: i32,
|
||||
pub heal: i32,
|
||||
pub mana_restore: i32,
|
||||
pub counter_multiplier_basis_points: u32,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateQueryInput {
|
||||
pub battle_state_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionResult {
|
||||
pub snapshot: BattleStateSnapshot,
|
||||
pub damage_dealt: i32,
|
||||
pub damage_taken: i32,
|
||||
pub outcome: CombatOutcome,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BattleStateProcedureResult {
|
||||
pub ok: bool,
|
||||
pub snapshot: Option<BattleStateSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ResolveCombatActionProcedureResult {
|
||||
pub ok: bool,
|
||||
pub result: Option<ResolveCombatActionResult>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_input(input: &BattleStateInput) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.story_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingStorySessionId);
|
||||
}
|
||||
if normalize_required_string(&input.runtime_session_id).is_none() {
|
||||
return Err(CombatFieldError::MissingRuntimeSessionId);
|
||||
}
|
||||
if normalize_required_string(&input.actor_user_id).is_none() {
|
||||
return Err(CombatFieldError::MissingActorUserId);
|
||||
}
|
||||
if normalize_required_string(&input.target_npc_id).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetNpcId);
|
||||
}
|
||||
if normalize_required_string(&input.target_name).is_none() {
|
||||
return Err(CombatFieldError::MissingTargetName);
|
||||
}
|
||||
if input.player_max_hp <= 0 || input.player_hp <= 0 || input.player_hp > input.player_max_hp {
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.player_max_mana < 0
|
||||
|| input.player_mana < 0
|
||||
|| input.player_mana > input.player_max_mana
|
||||
{
|
||||
return Err(CombatFieldError::InvalidPlayerVitals);
|
||||
}
|
||||
if input.target_max_hp <= 0 || input.target_hp <= 0 || input.target_hp > input.target_max_hp {
|
||||
return Err(CombatFieldError::InvalidTargetVitals);
|
||||
}
|
||||
for reward_item in input.reward_items.iter().cloned() {
|
||||
normalize_reward_item_snapshot(reward_item).map_err(map_reward_item_field_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_resolve_combat_action_input(
|
||||
input: &ResolveCombatActionInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
if normalize_required_string(&input.function_id).is_none() {
|
||||
return Err(CombatFieldError::MissingFunctionId);
|
||||
}
|
||||
if !is_supported_combat_function_id(&input.function_id) {
|
||||
return Err(CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_query_input(
|
||||
battle_state_id: String,
|
||||
) -> Result<BattleStateQueryInput, CombatFieldError> {
|
||||
let input = BattleStateQueryInput {
|
||||
battle_state_id: normalize_required_string(battle_state_id).unwrap_or_default(),
|
||||
};
|
||||
|
||||
validate_battle_state_query_input(&input)?;
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
pub fn validate_battle_state_query_input(
|
||||
input: &BattleStateQueryInput,
|
||||
) -> Result<(), CombatFieldError> {
|
||||
if normalize_required_string(&input.battle_state_id).is_none() {
|
||||
return Err(CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn build_battle_state_snapshot(input: BattleStateInput) -> BattleStateSnapshot {
|
||||
BattleStateSnapshot {
|
||||
battle_state_id: input.battle_state_id,
|
||||
story_session_id: input.story_session_id,
|
||||
runtime_session_id: input.runtime_session_id,
|
||||
actor_user_id: input.actor_user_id,
|
||||
chapter_id: input.chapter_id,
|
||||
target_npc_id: input.target_npc_id,
|
||||
target_name: input.target_name,
|
||||
battle_mode: input.battle_mode,
|
||||
status: BattleStatus::Ongoing,
|
||||
player_hp: input.player_hp,
|
||||
player_max_hp: input.player_max_hp,
|
||||
player_mana: input.player_mana,
|
||||
player_max_mana: input.player_max_mana,
|
||||
target_hp: input.target_hp,
|
||||
target_max_hp: input.target_max_hp,
|
||||
experience_reward: input.experience_reward,
|
||||
reward_items: input.reward_items,
|
||||
turn_index: 0,
|
||||
last_action_function_id: None,
|
||||
last_action_text: None,
|
||||
last_result_text: None,
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Ongoing,
|
||||
version: INITIAL_BATTLE_VERSION,
|
||||
created_at_micros: input.created_at_micros,
|
||||
updated_at_micros: input.created_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve_combat_action(
|
||||
current: BattleStateSnapshot,
|
||||
input: ResolveCombatActionInput,
|
||||
) -> Result<ResolveCombatActionResult, CombatFieldError> {
|
||||
validate_resolve_combat_action_input(&input)?;
|
||||
|
||||
if current.version == 0 {
|
||||
return Err(CombatFieldError::InvalidVersion);
|
||||
}
|
||||
if current.status != BattleStatus::Ongoing {
|
||||
return Err(CombatFieldError::BattleAlreadyResolved);
|
||||
}
|
||||
if current.player_mana < input.mana_cost.max(0) {
|
||||
return Err(CombatFieldError::InsufficientMana);
|
||||
}
|
||||
|
||||
let action_text = if input.action_text.trim().is_empty() {
|
||||
input.function_id.clone()
|
||||
} else {
|
||||
normalize_required_string(input.action_text).unwrap_or_else(|| input.function_id.clone())
|
||||
};
|
||||
|
||||
if input.function_id == "battle_escape_breakout" {
|
||||
let next = BattleStateSnapshot {
|
||||
status: BattleStatus::Resolved,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(format!("你抓住空当摆脱了{}的压制。", current.target_name)),
|
||||
last_damage_dealt: 0,
|
||||
last_damage_taken: 0,
|
||||
last_outcome: CombatOutcome::Escaped,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
return Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt: 0,
|
||||
damage_taken: 0,
|
||||
outcome: CombatOutcome::Escaped,
|
||||
});
|
||||
}
|
||||
|
||||
let mana_cost = input.mana_cost.max(0);
|
||||
let heal = input.heal.max(0);
|
||||
let mana_restore = input.mana_restore.max(0);
|
||||
let base_damage = input.base_damage.max(0);
|
||||
|
||||
let mut next_player_hp = current.player_hp;
|
||||
let mut next_player_mana = (current.player_mana - mana_cost).max(0);
|
||||
let mut next_target_hp = current.target_hp;
|
||||
let mut damage_dealt = 0;
|
||||
let mut damage_taken = 0;
|
||||
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp + heal,
|
||||
current.player_max_hp,
|
||||
);
|
||||
next_player_mana = clamp_mana(next_player_mana + mana_restore, current.player_max_mana);
|
||||
|
||||
if base_damage > 0 {
|
||||
next_target_hp =
|
||||
clamp_target_hp_after_damage(current.battle_mode, current.target_hp, base_damage);
|
||||
damage_dealt = current.target_hp - next_target_hp;
|
||||
}
|
||||
|
||||
let (status, outcome, result_text) = if is_target_resolved(current.battle_mode, next_target_hp)
|
||||
{
|
||||
let outcome = match current.battle_mode {
|
||||
BattleMode::Fight => CombatOutcome::Victory,
|
||||
BattleMode::Spar => CombatOutcome::SparComplete,
|
||||
};
|
||||
|
||||
(
|
||||
BattleStatus::Resolved,
|
||||
outcome,
|
||||
build_resolved_result_text(&action_text, ¤t.target_name, outcome),
|
||||
)
|
||||
} else {
|
||||
damage_taken = compute_counter_damage(
|
||||
current.battle_mode,
|
||||
current.target_max_hp,
|
||||
input.counter_multiplier_basis_points,
|
||||
);
|
||||
next_player_hp = clamp_hp(
|
||||
current.battle_mode,
|
||||
next_player_hp - damage_taken,
|
||||
current.player_max_hp,
|
||||
);
|
||||
|
||||
(
|
||||
BattleStatus::Ongoing,
|
||||
CombatOutcome::Ongoing,
|
||||
build_ongoing_result_text(&input.function_id, &action_text, ¤t.target_name),
|
||||
)
|
||||
};
|
||||
|
||||
let next = BattleStateSnapshot {
|
||||
player_hp: next_player_hp,
|
||||
player_mana: next_player_mana,
|
||||
target_hp: next_target_hp,
|
||||
status,
|
||||
turn_index: current.turn_index + 1,
|
||||
last_action_function_id: Some(input.function_id),
|
||||
last_action_text: Some(action_text),
|
||||
last_result_text: Some(result_text),
|
||||
last_damage_dealt: damage_dealt,
|
||||
last_damage_taken: damage_taken,
|
||||
last_outcome: outcome,
|
||||
version: current.version + 1,
|
||||
updated_at_micros: input.updated_at_micros,
|
||||
..current
|
||||
};
|
||||
|
||||
Ok(ResolveCombatActionResult {
|
||||
snapshot: next,
|
||||
damage_dealt,
|
||||
damage_taken,
|
||||
outcome,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn generate_battle_state_id(seed_micros: i64) -> String {
|
||||
build_prefixed_seed_id(BATTLE_STATE_ID_PREFIX, seed_micros)
|
||||
}
|
||||
|
||||
pub fn is_supported_combat_function_id(function_id: &str) -> bool {
|
||||
matches!(
|
||||
function_id,
|
||||
"battle_attack_basic"
|
||||
| "battle_recover_breath"
|
||||
| "battle_use_skill"
|
||||
| "battle_escape_breakout"
|
||||
) || LEGACY_ATTACK_FUNCTION_IDS.contains(&function_id)
|
||||
}
|
||||
|
||||
fn clamp_hp(mode: BattleMode, value: i32, max_hp: i32) -> i32 {
|
||||
let min_hp = match mode {
|
||||
BattleMode::Fight => 0,
|
||||
BattleMode::Spar => SPAR_MIN_HP,
|
||||
};
|
||||
|
||||
value.clamp(min_hp, max_hp)
|
||||
}
|
||||
|
||||
fn clamp_mana(value: i32, max_mana: i32) -> i32 {
|
||||
value.clamp(0, max_mana)
|
||||
}
|
||||
|
||||
fn clamp_target_hp_after_damage(mode: BattleMode, current_hp: i32, damage: i32) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Fight => (current_hp - damage).max(0),
|
||||
BattleMode::Spar => (current_hp - damage).max(SPAR_MIN_HP),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_target_resolved(mode: BattleMode, target_hp: i32) -> bool {
|
||||
match mode {
|
||||
BattleMode::Fight => target_hp <= 0,
|
||||
BattleMode::Spar => target_hp <= SPAR_MIN_HP,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_counter_damage(
|
||||
mode: BattleMode,
|
||||
target_max_hp: i32,
|
||||
counter_multiplier_basis_points: u32,
|
||||
) -> i32 {
|
||||
match mode {
|
||||
BattleMode::Spar => 1,
|
||||
BattleMode::Fight => {
|
||||
let multiplier = counter_multiplier_basis_points as f32 / 10_000.0;
|
||||
let raw =
|
||||
(target_max_hp as f32 * BASIC_FIGHT_COUNTER_RATIO * multiplier).round() as i32;
|
||||
raw.max(MIN_FIGHT_COUNTER_DAMAGE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_resolved_result_text(
|
||||
action_text: &str,
|
||||
target_name: &str,
|
||||
outcome: CombatOutcome,
|
||||
) -> String {
|
||||
match outcome {
|
||||
CombatOutcome::Victory => {
|
||||
format!(
|
||||
"{}命中了{},这轮战斗已经正式结束。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::SparComplete => {
|
||||
format!(
|
||||
"{}压住了{}的节奏,这场切磋已经分出高下。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
CombatOutcome::Escaped => {
|
||||
format!("{}后你成功脱离了当前战斗。", action_text)
|
||||
}
|
||||
CombatOutcome::Ongoing => format!("{}已完成结算。", action_text),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_ongoing_result_text(function_id: &str, action_text: &str, target_name: &str) -> String {
|
||||
match function_id {
|
||||
"battle_recover_breath" => {
|
||||
format!(
|
||||
"你先把伤势和气息稳住了一轮,但{}仍在持续逼近。",
|
||||
target_name
|
||||
)
|
||||
}
|
||||
"battle_use_skill" => {
|
||||
format!(
|
||||
"{}命中了{},这一轮技能效果已经直接结算。",
|
||||
action_text, target_name
|
||||
)
|
||||
}
|
||||
_ => format!(
|
||||
"{}命中了{},本次攻击已经完成结算。",
|
||||
action_text, target_name
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_reward_item_field_error(error: TreasureFieldError) -> CombatFieldError {
|
||||
let message = match error {
|
||||
TreasureFieldError::MissingRewardItemId => {
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemCategory => {
|
||||
"battle_state.reward_items[].category 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemName => {
|
||||
"battle_state.reward_items[].item_name 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::InvalidRewardItemQuantity => {
|
||||
"battle_state.reward_items[].quantity 必须大于 0".to_string()
|
||||
}
|
||||
TreasureFieldError::MissingRewardItemStackKey => {
|
||||
"battle_state.reward_items[].stack_key 不能为空".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardEquipmentItemCannotStack => {
|
||||
"battle_state.reward_items[] 可装备物品不能标记为 stackable".to_string()
|
||||
}
|
||||
TreasureFieldError::RewardNonStackableItemMustStaySingleQuantity => {
|
||||
"battle_state.reward_items[] 不可堆叠物品必须固定为单槽位单数量".to_string()
|
||||
}
|
||||
other => other.to_string(),
|
||||
};
|
||||
|
||||
CombatFieldError::InvalidRewardItem(message)
|
||||
}
|
||||
|
||||
impl fmt::Display for CombatFieldError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::MissingBattleStateId => f.write_str("battle_state.battle_state_id 不能为空"),
|
||||
Self::MissingStorySessionId => f.write_str("battle_state.story_session_id 不能为空"),
|
||||
Self::MissingRuntimeSessionId => {
|
||||
f.write_str("battle_state.runtime_session_id 不能为空")
|
||||
}
|
||||
Self::MissingActorUserId => f.write_str("battle_state.actor_user_id 不能为空"),
|
||||
Self::MissingTargetNpcId => f.write_str("battle_state.target_npc_id 不能为空"),
|
||||
Self::MissingTargetName => f.write_str("battle_state.target_name 不能为空"),
|
||||
Self::MissingFunctionId => f.write_str("resolve_combat_action.function_id 不能为空"),
|
||||
Self::InvalidVersion => f.write_str("battle_state.version 必须大于 0"),
|
||||
Self::InvalidPlayerVitals => f.write_str("battle_state 玩家生命或灵力字段不合法"),
|
||||
Self::InvalidTargetVitals => f.write_str("battle_state 目标生命字段不合法"),
|
||||
Self::InvalidRewardItem(message) => f.write_str(message),
|
||||
Self::BattleAlreadyResolved => f.write_str("battle_state 已经结束,不能继续结算"),
|
||||
Self::UnsupportedFunctionId => {
|
||||
f.write_str("resolve_combat_action.function_id 当前不受支持")
|
||||
}
|
||||
Self::InsufficientMana => f.write_str("当前灵力不足,无法执行该战斗动作"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CombatFieldError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn build_fight_snapshot() -> BattleStateSnapshot {
|
||||
build_battle_state_snapshot(BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 60,
|
||||
player_max_hp: 60,
|
||||
player_mana: 20,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 18,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 10,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_battle_state_input_accepts_minimal_contract() {
|
||||
let result = validate_battle_state_input(&BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 50,
|
||||
player_max_hp: 60,
|
||||
player_mana: 10,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 12,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 1,
|
||||
});
|
||||
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_battle_state_input_rejects_invalid_reward_items() {
|
||||
let error = validate_battle_state_input(&BattleStateInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_001".to_string()),
|
||||
target_npc_id: "npc_001".to_string(),
|
||||
target_name: "黑爪狼".to_string(),
|
||||
battle_mode: BattleMode::Fight,
|
||||
player_hp: 50,
|
||||
player_max_hp: 60,
|
||||
player_mana: 10,
|
||||
player_max_mana: 20,
|
||||
target_hp: 30,
|
||||
target_max_hp: 30,
|
||||
experience_reward: 12,
|
||||
reward_items: vec![RuntimeItemRewardItemSnapshot {
|
||||
item_id: String::new(),
|
||||
category: "遗物".to_string(),
|
||||
item_name: "铜钥残片".to_string(),
|
||||
description: None,
|
||||
quantity: 1,
|
||||
rarity: module_runtime_item::RuntimeItemRewardItemRarity::Rare,
|
||||
tags: vec![],
|
||||
stackable: false,
|
||||
stack_key: String::new(),
|
||||
equipment_slot_id: None,
|
||||
}],
|
||||
created_at_micros: 1,
|
||||
})
|
||||
.expect_err("invalid reward item should be rejected");
|
||||
|
||||
assert_eq!(
|
||||
error,
|
||||
CombatFieldError::InvalidRewardItem(
|
||||
"battle_state.reward_items[].item_id 不能为空".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_battle_state_query_input_trims_and_validates_id() {
|
||||
let input = build_battle_state_query_input(" battle_001 ".to_string())
|
||||
.expect("query input should build");
|
||||
|
||||
assert_eq!(input.battle_state_id, "battle_001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_battle_state_query_input_rejects_empty_id() {
|
||||
let error =
|
||||
build_battle_state_query_input(" ".to_string()).expect_err("empty id should fail");
|
||||
|
||||
assert_eq!(error, CombatFieldError::MissingBattleStateId);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_basic_attack_advances_turn_and_applies_counter_damage() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_attack_basic".to_string(),
|
||||
action_text: "普通攻击".to_string(),
|
||||
base_damage: 10,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 10_000,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("basic attack should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.turn_index, 1);
|
||||
assert_eq!(result.snapshot.target_hp, 20);
|
||||
assert_eq!(result.snapshot.player_hp, 56);
|
||||
assert_eq!(result.snapshot.last_damage_dealt, 10);
|
||||
assert_eq!(result.snapshot.last_damage_taken, 4);
|
||||
assert_eq!(result.outcome, CombatOutcome::Ongoing);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_escape_marks_battle_resolved() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_escape_breakout".to_string(),
|
||||
action_text: "逃跑".to_string(),
|
||||
base_damage: 0,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 0,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("escape should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.snapshot.last_outcome, CombatOutcome::Escaped);
|
||||
assert_eq!(result.damage_dealt, 0);
|
||||
assert_eq!(result.damage_taken, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_skill_can_finish_fight() {
|
||||
let result = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "battle_use_skill".to_string(),
|
||||
action_text: "试锋斩".to_string(),
|
||||
base_damage: 35,
|
||||
mana_cost: 8,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 9_500,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("skill should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.snapshot.target_hp, 0);
|
||||
assert_eq!(result.snapshot.player_mana, 12);
|
||||
assert_eq!(result.outcome, CombatOutcome::Victory);
|
||||
assert_eq!(result.damage_taken, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spar_mode_keeps_hp_floor_at_one() {
|
||||
let snapshot = build_battle_state_snapshot(BattleStateInput {
|
||||
battle_state_id: "battle_002".to_string(),
|
||||
story_session_id: "storysess_001".to_string(),
|
||||
runtime_session_id: "runtime_001".to_string(),
|
||||
actor_user_id: "user_001".to_string(),
|
||||
chapter_id: Some("chapter_spar".to_string()),
|
||||
target_npc_id: "npc_002".to_string(),
|
||||
target_name: "卫队长".to_string(),
|
||||
battle_mode: BattleMode::Spar,
|
||||
player_hp: 5,
|
||||
player_max_hp: 5,
|
||||
player_mana: 10,
|
||||
player_max_mana: 10,
|
||||
target_hp: 3,
|
||||
target_max_hp: 3,
|
||||
experience_reward: 0,
|
||||
reward_items: vec![],
|
||||
created_at_micros: 10,
|
||||
});
|
||||
|
||||
let result = resolve_combat_action(
|
||||
snapshot,
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_002".to_string(),
|
||||
function_id: "battle_attack_basic".to_string(),
|
||||
action_text: "普通攻击".to_string(),
|
||||
base_damage: 5,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 10_000,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect("spar attack should succeed");
|
||||
|
||||
assert_eq!(result.snapshot.target_hp, 1);
|
||||
assert_eq!(result.snapshot.status, BattleStatus::Resolved);
|
||||
assert_eq!(result.outcome, CombatOutcome::SparComplete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_unsupported_function() {
|
||||
let error = resolve_combat_action(
|
||||
build_fight_snapshot(),
|
||||
ResolveCombatActionInput {
|
||||
battle_state_id: "battle_001".to_string(),
|
||||
function_id: "inventory_use".to_string(),
|
||||
action_text: "使用物品".to_string(),
|
||||
base_damage: 0,
|
||||
mana_cost: 0,
|
||||
heal: 0,
|
||||
mana_restore: 0,
|
||||
counter_multiplier_basis_points: 7_200,
|
||||
updated_at_micros: 20,
|
||||
},
|
||||
)
|
||||
.expect_err("inventory_use should be deferred for now");
|
||||
|
||||
assert_eq!(error, CombatFieldError::UnsupportedFunctionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user