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, 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, 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, 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, pub turn_index: u32, pub last_action_function_id: Option, pub last_action_text: Option, pub last_result_text: Option, 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, pub error_message: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolveCombatActionProcedureResult { pub ok: bool, pub result: Option, pub error_message: Option, } 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 { 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 { 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); } }