Close DDD cleanup and tests-support closure

This commit is contained in:
2026-04-30 16:15:05 +08:00
parent 7ab0933f6d
commit fd08262bf0
81 changed files with 8415 additions and 6662 deletions

View File

@@ -1,3 +1,291 @@
//! 战斗应用编排过渡落位
//! 战斗应用编排。
//!
//! 这里只返回结算结果与待处理事件,不直接写入其他上下文表。
use crate::commands::{
BattleStateInput, ResolveCombatActionInput, validate_resolve_combat_action_input,
};
use crate::domain::{
BASIC_FIGHT_COUNTER_RATIO, BATTLE_STATE_ID_PREFIX, BattleMode, BattleStateSnapshot,
BattleStatus, CombatOutcome, INITIAL_BATTLE_VERSION, MIN_FIGHT_COUNTER_DAMAGE, SPAR_MIN_HP,
};
use crate::errors::CombatFieldError;
use serde::{Deserialize, Serialize};
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[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 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, &current.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, &current.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)
}
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
),
}
}

View File

@@ -1,3 +1,170 @@
//! 战斗写入命令过渡落位
//! 战斗写入命令。
//!
//! 用于表达创建战斗、使用技能、逃离和结算等输入,不直接携带表行类型。
use crate::domain::{BattleMode, LEGACY_ATTACK_FUNCTION_IDS};
use crate::errors::CombatFieldError;
use module_runtime_item::{
RuntimeItemRewardItemSnapshot, TreasureFieldError, normalize_reward_item_snapshot,
};
use serde::{Deserialize, Serialize};
use shared_kernel::normalize_required_string;
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[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 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,
}
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 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 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)
}

View File

@@ -1,8 +1,9 @@
//! 战斗领域模型过渡落位
//! 战斗领域模型。
//!
//! 后续迁移 `BattleState` 与行动结算规则时,只保留单聚合内部状态变化;
//! 背包奖励、成长记账和任务联动由应用服务或 SpacetimeDB 事务 adapter 编排。
//! 本文件只承载战斗聚合内部状态和值对象;背包奖励、成长记账和任务联动由
//! SpacetimeDB 事务 adapter 编排,不在战斗领域内直连其他上下文
use module_runtime_item::RuntimeItemRewardItemSnapshot;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
@@ -83,3 +84,35 @@ impl CombatOutcome {
}
}
}
#[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,
}

View File

@@ -1,3 +1,50 @@
//! 战斗领域错误过渡落位
//! 战斗领域错误。
//!
//! 错误保持纯领域语义,不能绑定 HTTP 状态码或 SpacetimeDB 字符串格式。
use std::{error::Error, fmt};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CombatFieldError {
MissingBattleStateId,
MissingStorySessionId,
MissingRuntimeSessionId,
MissingActorUserId,
MissingTargetNpcId,
MissingTargetName,
MissingFunctionId,
InvalidVersion,
InvalidPlayerVitals,
InvalidTargetVitals,
InvalidRewardItem(String),
BattleAlreadyResolved,
UnsupportedFunctionId,
InsufficientMana,
}
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 {}

View File

@@ -1,3 +1,34 @@
//! 战斗领域事件过渡落位
//! 战斗领域事件。
//!
//! 用于表达战斗胜利、切磋完成、奖励待发放和战斗被终止等事实。
use crate::domain::CombatOutcome;
use serde::{Deserialize, Serialize};
#[cfg(feature = "spacetime-types")]
use spacetimedb::SpacetimeType;
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum CombatDomainEvent {
BattleActionResolved(CombatBattleActionResolvedEvent),
BattleRewardPending(CombatBattleRewardPendingEvent),
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleActionResolvedEvent {
pub battle_state_id: String,
pub outcome: CombatOutcome,
pub damage_dealt: i32,
pub damage_taken: i32,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CombatBattleRewardPendingEvent {
pub battle_state_id: String,
pub actor_user_id: String,
pub experience_reward: u32,
pub occurred_at_micros: i64,
}

View File

@@ -4,531 +4,16 @@ mod domain;
mod errors;
mod events;
pub use application::*;
pub use commands::*;
pub use domain::*;
use std::{error::Error, fmt};
use crate::domain::LEGACY_ATTACK_FUNCTION_IDS;
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;
#[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, &current.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, &current.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 {}
pub use errors::*;
pub use events::*;
#[cfg(test)]
mod tests {
use super::*;
use module_runtime_item::RuntimeItemRewardItemSnapshot;
fn build_fight_snapshot() -> BattleStateSnapshot {
build_battle_state_snapshot(BattleStateInput {