推进 server-rs DDD 分层与新接口接线

This commit is contained in:
Codex
2026-04-29 15:46:16 +08:00
parent 9d3fcfae77
commit f82775b852
89 changed files with 3657 additions and 9636 deletions

View File

@@ -0,0 +1,881 @@
use serde_json::{Map, Value, json};
use shared_contracts::runtime_story::{
RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryOptionView, RuntimeStoryPatch,
};
use crate::{
StoryResolution, append_active_build_buffs, build_status_patch, clear_encounter_only,
clear_encounter_state, current_encounter_id, current_encounter_name_from_battle,
ensure_json_object, first_hostile_npc_string_field, grant_player_progression_experience,
increment_runtime_stat, read_array_field, read_field, read_i32_field, read_object_field,
read_optional_string_field, remove_player_inventory_item, resolve_action_text,
write_bool_field, write_current_encounter_i32_field, write_first_hostile_npc_i32_field,
write_i32_field, write_null_field, write_string_field,
};
/// 战斗 compat 纯结算链已经不依赖 HTTP / AppState。
///
/// 这里同时承接 battle action 的状态结算、资源恢复和战斗选项编译,
/// 让 `api-server` 只保留 HTTP 外壳与最终响应拼装。
struct BattleActionPlan {
action_text: String,
result_text: String,
damage_dealt: i32,
damage_taken: i32,
heal: i32,
mana_restore: i32,
mana_cost: i32,
cooldown_tick_turns: i32,
cooldown_bonus_turns: i32,
applied_skill_cooldown: Option<(String, i32)>,
build_buffs: Vec<Value>,
consumed_item_id: Option<String>,
}
struct BattleSkillView {
id: String,
name: String,
damage: i32,
mana_cost: i32,
cooldown_turns: i32,
build_buffs: Vec<Value>,
}
pub struct BattleInventoryUseProfile {
hp_restore: i32,
mana_restore: i32,
cooldown_reduction: i32,
build_buffs: Vec<Value>,
}
struct BattleInventoryItemView {
id: String,
name: String,
quantity: i32,
use_profile: Option<BattleInventoryUseProfile>,
}
/// 兼容战斗结算的胜负状态。
///
/// 这里显式补齐失败分支,避免“玩家已死但敌方也被打空时”被错误归类成胜利。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum BattleResolutionOutcome {
Ongoing,
Victory,
SparComplete,
Defeat,
}
impl BattleResolutionOutcome {
fn as_battle_outcome(self) -> &'static str {
match self {
Self::Ongoing => "ongoing",
Self::Victory => "victory",
Self::SparComplete => "spar_complete",
Self::Defeat => "defeat",
}
}
fn as_status_outcome(self) -> Option<&'static str> {
match self {
Self::Ongoing => None,
Self::Victory => Some("fight_victory"),
Self::SparComplete => Some("spar_complete"),
Self::Defeat => Some("fight_defeat"),
}
}
}
pub fn resolve_battle_action(
game_state: &mut Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<StoryResolution, String> {
let target_id = current_encounter_id(game_state)
.or_else(|| first_hostile_npc_string_field(game_state, "id"))
.unwrap_or_else(|| "battle_target".to_string());
let target_name = current_encounter_name_from_battle(game_state);
let battle_mode = read_optional_string_field(game_state, "currentNpcBattleMode")
.unwrap_or_else(|| "fight".to_string());
if function_id == "battle_escape_breakout" {
clear_encounter_state(game_state);
return Ok(StoryResolution {
action_text: resolve_action_text("强行脱离战斗", request),
result_text: "你抓住空隙强行脱离战斗,把这一轮危险先甩在身后。".to_string(),
story_text: None,
presentation_options: None,
saved_current_story: None,
patches: vec![
RuntimeStoryPatch::BattleResolved {
function_id: function_id.to_string(),
target_id: Some(target_id.clone()),
damage_dealt: Some(0),
damage_taken: Some(0),
outcome: "escaped".to_string(),
},
build_status_patch(game_state),
RuntimeStoryPatch::EncounterChanged { encounter_id: None },
],
battle: Some(RuntimeBattlePresentation {
target_id: Some(target_id),
target_name: Some(target_name),
damage_dealt: Some(0),
damage_taken: Some(0),
outcome: Some("escaped".to_string()),
}),
toast: Some("已脱离战斗".to_string()),
});
}
let plan = build_battle_action_plan(game_state, request, function_id)?;
spend_player_mana(game_state, plan.mana_cost);
restore_player_resource(game_state, plan.heal, plan.mana_restore);
tick_player_skill_cooldowns(game_state, plan.cooldown_tick_turns);
reduce_player_skill_cooldowns(game_state, plan.cooldown_bonus_turns);
if let Some((skill_id, turns)) = plan.applied_skill_cooldown.as_ref() {
set_player_skill_cooldown(game_state, skill_id.as_str(), *turns);
}
if !plan.build_buffs.is_empty() {
append_active_build_buffs(game_state, plan.build_buffs.clone());
}
if let Some(item_id) = plan.consumed_item_id.as_ref() {
remove_player_inventory_item(game_state, item_id.as_str(), 1);
increment_runtime_stat(game_state, "itemsUsed", 1);
}
let player_hp = apply_player_damage(game_state, plan.damage_taken);
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
let outcome = resolve_battle_resolution_outcome(player_hp, target_hp, battle_mode.as_str());
let victory_experience = if outcome == BattleResolutionOutcome::Victory {
battle_victory_experience_reward(game_state)
} else {
0
};
if outcome != BattleResolutionOutcome::Ongoing {
write_bool_field(game_state, "inBattle", false);
write_bool_field(game_state, "npcInteractionActive", false);
write_null_field(game_state, "currentNpcBattleMode");
if let Some(status_outcome) = outcome.as_status_outcome() {
write_string_field(game_state, "currentNpcBattleOutcome", status_outcome);
}
if outcome == BattleResolutionOutcome::Victory {
clear_encounter_only(game_state);
increment_runtime_stat(game_state, "hostileNpcsDefeated", 1);
if victory_experience > 0 {
grant_player_progression_experience(game_state, victory_experience, "hostile_npc");
}
} else if outcome == BattleResolutionOutcome::Defeat {
clear_encounter_state(game_state);
}
}
let mut patches = vec![
RuntimeStoryPatch::BattleResolved {
function_id: function_id.to_string(),
target_id: Some(target_id.clone()),
damage_dealt: Some(plan.damage_dealt),
damage_taken: Some(plan.damage_taken),
outcome: outcome.as_battle_outcome().to_string(),
},
build_status_patch(game_state),
];
if outcome == BattleResolutionOutcome::Victory {
patches.push(RuntimeStoryPatch::EncounterChanged { encounter_id: None });
}
Ok(StoryResolution {
action_text: resolve_action_text(plan.action_text.as_str(), request),
result_text: if outcome == BattleResolutionOutcome::Ongoing {
plan.result_text
} else if outcome == BattleResolutionOutcome::SparComplete {
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
} else if outcome == BattleResolutionOutcome::Defeat {
format!("你在与 {target_name} 的交锋中被压制倒下,这场战斗以你的败北收束。")
} else {
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
},
story_text: None,
presentation_options: None,
saved_current_story: None,
patches,
battle: Some(RuntimeBattlePresentation {
target_id: Some(target_id),
target_name: Some(target_name),
damage_dealt: Some(plan.damage_dealt),
damage_taken: Some(plan.damage_taken),
outcome: Some(outcome.as_battle_outcome().to_string()),
}),
toast: battle_action_toast(function_id, request),
})
}
pub fn restore_player_resource(game_state: &mut Value, hp_restore: i32, mana_restore: i32) {
let max_hp = read_i32_field(game_state, "playerMaxHp")
.unwrap_or(1)
.max(1);
let max_mana = read_i32_field(game_state, "playerMaxMana")
.unwrap_or(0)
.max(0);
let hp = read_i32_field(game_state, "playerHp").unwrap_or(max_hp);
let mana = read_i32_field(game_state, "playerMana").unwrap_or(max_mana);
write_i32_field(game_state, "playerHp", (hp + hp_restore).clamp(0, max_hp));
write_i32_field(
game_state,
"playerMana",
(mana + mana_restore).clamp(0, max_mana),
);
}
pub fn build_battle_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
let mut options = vec![
RuntimeStoryOptionView {
detail_text: Some(build_basic_attack_detail_text(game_state)),
..build_static_runtime_story_option("battle_attack_basic", "普通攻击", "combat")
},
RuntimeStoryOptionView {
detail_text: Some("回血 12 / 回蓝 9 / 冷却 -1".to_string()),
..build_static_runtime_story_option("battle_recover_breath", "恢复", "combat")
},
];
let preferred_item = pick_preferred_battle_inventory_item(game_state);
if let Some(item) = preferred_item {
let effect = item
.use_profile
.expect("preferred battle item must have use profile");
options.push(build_runtime_story_option_with_payload(
"inventory_use",
&format!("使用物品:{}", item.name),
"combat",
Some(build_battle_item_summary(&effect)),
json!({
"itemId": item.id
}),
));
} else {
options.push(build_disabled_runtime_story_option(
"inventory_use",
"使用物品",
"combat",
Some("当前没有可直接结算的战斗消耗品".to_string()),
"暂无可用物品",
None,
));
}
options.extend(build_battle_skill_runtime_story_options(game_state));
options.push(build_static_runtime_story_option(
"battle_escape_breakout",
"强行脱离战斗",
"combat",
));
options
}
fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
if mana_cost <= 0 {
return;
}
let mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
}
fn apply_player_damage(game_state: &mut Value, damage: i32) -> i32 {
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1).max(0);
let next_hp = if damage <= 0 {
hp
} else {
(hp - damage).max(0)
};
write_i32_field(game_state, "playerHp", next_hp);
next_hp
}
fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
let target_hp = read_object_field(game_state, "currentEncounter")
.and_then(|encounter| {
read_i32_field(encounter, "hp")
.or_else(|| read_i32_field(encounter, "currentHp"))
.or_else(|| read_i32_field(encounter, "targetHp"))
})
.or_else(|| {
read_array_field(game_state, "sceneHostileNpcs")
.first()
.and_then(|target| read_i32_field(target, "hp"))
})
.unwrap_or(24);
let next_hp = target_hp - damage.max(0);
write_current_encounter_i32_field(game_state, "hp", next_hp);
write_first_hostile_npc_i32_field(game_state, "hp", next_hp);
next_hp
}
fn resolve_battle_resolution_outcome(
player_hp: i32,
target_hp: i32,
battle_mode: &str,
) -> BattleResolutionOutcome {
// 中文注释:玩家死亡优先级高于敌方倒地。
// 这样即便同一回合双方都被打到 0也必须按玩家败北处理不能误发胜利。
if player_hp <= 0 {
return BattleResolutionOutcome::Defeat;
}
if target_hp <= 0 {
if battle_mode == "spar" {
BattleResolutionOutcome::SparComplete
} else {
BattleResolutionOutcome::Victory
}
} else {
BattleResolutionOutcome::Ongoing
}
}
fn read_player_skills(game_state: &Value) -> Vec<BattleSkillView> {
read_field(game_state, "playerCharacter")
.map(|character| read_array_field(character, "skills"))
.unwrap_or_default()
.into_iter()
.filter_map(|entry| {
let id = read_optional_string_field(entry, "id")?;
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
Some(BattleSkillView {
id,
name,
damage: read_i32_field(entry, "damage").unwrap_or(14).max(0),
mana_cost: read_i32_field(entry, "manaCost").unwrap_or(0).max(0),
cooldown_turns: read_i32_field(entry, "cooldownTurns").unwrap_or(0).max(0),
build_buffs: read_array_field(entry, "buildBuffs")
.into_iter()
.cloned()
.collect(),
})
})
.collect()
}
fn find_player_skill_by_id(game_state: &Value, skill_id: &str) -> Option<BattleSkillView> {
read_player_skills(game_state)
.into_iter()
.find(|skill| skill.id == skill_id)
}
fn build_basic_attack_detail_text(game_state: &Value) -> String {
let strength = read_field(game_state, "playerCharacter")
.and_then(|character| read_field(character, "attributes"))
.and_then(|attributes| read_i32_field(attributes, "strength"))
.unwrap_or(8);
let agility = read_field(game_state, "playerCharacter")
.and_then(|character| read_field(character, "attributes"))
.and_then(|attributes| read_i32_field(attributes, "agility"))
.unwrap_or(0);
let preview_damage = ((strength * 85 + agility * 45) / 100).max(8);
format!("不耗蓝 / 伤害 {preview_damage}")
}
fn read_player_skill_cooldowns(game_state: &Value) -> std::collections::BTreeMap<String, i32> {
read_object_field(game_state, "playerSkillCooldowns")
.and_then(Value::as_object)
.map(|cooldowns| {
cooldowns
.iter()
.map(|(skill_id, turns)| {
(
skill_id.clone(),
turns
.as_i64()
.and_then(|value| i32::try_from(value).ok())
.unwrap_or(0)
.max(0),
)
})
.collect()
})
.unwrap_or_default()
}
fn build_battle_skill_runtime_story_options(game_state: &Value) -> Vec<RuntimeStoryOptionView> {
let cooldowns = read_player_skill_cooldowns(game_state);
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
read_player_skills(game_state)
.into_iter()
.map(|skill| {
let detail_text = Some(format!(
"耗蓝 {} / 伤害 {} / 冷却 {}",
skill.mana_cost.max(0),
skill.damage.max(0),
skill.cooldown_turns.max(0)
));
let payload = Some(json!({
"skillId": skill.id
}));
let remaining_cooldown = cooldowns.get(skill.id.as_str()).copied().unwrap_or(0);
if remaining_cooldown > 0 {
return build_disabled_runtime_story_option(
"battle_use_skill",
&skill.name,
"combat",
detail_text,
format!("冷却中,还需 {} 回合", remaining_cooldown).as_str(),
payload,
);
}
if skill.mana_cost > player_mana {
return build_disabled_runtime_story_option(
"battle_use_skill",
&skill.name,
"combat",
detail_text,
"灵力不足",
payload,
);
}
RuntimeStoryOptionView {
detail_text,
payload,
..build_static_runtime_story_option("battle_use_skill", &skill.name, "combat")
}
})
.collect()
}
fn tick_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
if turns <= 0 {
return;
}
let root = ensure_json_object(game_state);
let cooldowns = root
.entry("playerSkillCooldowns".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !cooldowns.is_object() {
*cooldowns = Value::Object(Map::new());
}
let cooldowns = cooldowns
.as_object_mut()
.expect("playerSkillCooldowns should be object");
for value in cooldowns.values_mut() {
let current = value
.as_i64()
.and_then(|number| i32::try_from(number).ok())
.unwrap_or(0);
*value = json!((current - turns).max(0));
}
}
fn reduce_player_skill_cooldowns(game_state: &mut Value, turns: i32) {
if turns <= 0 {
return;
}
tick_player_skill_cooldowns(game_state, turns);
}
fn set_player_skill_cooldown(game_state: &mut Value, skill_id: &str, turns: i32) {
let root = ensure_json_object(game_state);
let cooldowns = root
.entry("playerSkillCooldowns".to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !cooldowns.is_object() {
*cooldowns = Value::Object(Map::new());
}
cooldowns
.as_object_mut()
.expect("playerSkillCooldowns should be object")
.insert(skill_id.to_string(), json!(turns.max(0)));
}
fn read_player_inventory_items(game_state: &Value) -> Vec<BattleInventoryItemView> {
read_array_field(game_state, "playerInventory")
.into_iter()
.filter_map(|entry| {
let id = read_optional_string_field(entry, "id")?;
let name = read_optional_string_field(entry, "name").unwrap_or_else(|| id.clone());
let use_profile =
read_field(entry, "useProfile").map(|profile| BattleInventoryUseProfile {
hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0),
mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0),
cooldown_reduction: read_i32_field(profile, "cooldownReduction")
.unwrap_or(0)
.max(0),
build_buffs: read_array_field(profile, "buildBuffs")
.into_iter()
.cloned()
.collect(),
});
Some(BattleInventoryItemView {
id,
name,
quantity: read_i32_field(entry, "quantity").unwrap_or(0).max(0),
use_profile,
})
})
.collect()
}
pub fn read_inventory_item_use_profile(item: &Value) -> Option<BattleInventoryUseProfile> {
read_field(item, "useProfile").map(|profile| BattleInventoryUseProfile {
hp_restore: read_i32_field(profile, "hpRestore").unwrap_or(0).max(0),
mana_restore: read_i32_field(profile, "manaRestore").unwrap_or(0).max(0),
cooldown_reduction: read_i32_field(profile, "cooldownReduction")
.unwrap_or(0)
.max(0),
build_buffs: read_array_field(profile, "buildBuffs")
.into_iter()
.cloned()
.collect(),
})
}
pub fn inventory_item_has_usable_effect(item: &Value) -> bool {
read_inventory_item_use_profile(item).is_some_and(|effect| {
effect.hp_restore > 0
|| effect.mana_restore > 0
|| effect.cooldown_reduction > 0
|| !effect.build_buffs.is_empty()
})
}
fn find_player_inventory_item(
game_state: &Value,
item_id: &str,
) -> Option<BattleInventoryItemView> {
read_player_inventory_items(game_state)
.into_iter()
.find(|item| item.id == item_id)
}
/// 旧前端一次只展示一个“推荐”战斗物品,这里继续用确定性打分,避免展示面漂移。
fn pick_preferred_battle_inventory_item(game_state: &Value) -> Option<BattleInventoryItemView> {
let has_cooling_skill = read_player_skill_cooldowns(game_state)
.values()
.any(|remaining| *remaining > 0);
let player_hp = read_i32_field(game_state, "playerHp").unwrap_or(0);
let player_max_hp = read_i32_field(game_state, "playerMaxHp")
.unwrap_or(1)
.max(1);
let player_mana = read_i32_field(game_state, "playerMana").unwrap_or(0);
let player_max_mana = read_i32_field(game_state, "playerMaxMana")
.unwrap_or(1)
.max(1);
let hp_low = player_hp * 100 <= player_max_hp * 45;
let mana_low = player_mana * 100 <= player_max_mana * 45;
read_player_inventory_items(game_state)
.into_iter()
.filter(|item| item.quantity > 0 && item.use_profile.is_some())
.filter_map(|item| {
let effect = item.use_profile.as_ref()?;
let mut score = effect.build_buffs.len() as i32 * 8;
score += effect.hp_restore * if hp_low { 3 } else { 1 };
score += effect.mana_restore * if mana_low { 2 } else { 1 };
score += effect.cooldown_reduction * if has_cooling_skill { 18 } else { 6 };
Some((score, item))
})
.max_by(|left, right| {
left.0
.cmp(&right.0)
.then_with(|| left.1.name.cmp(&right.1.name).reverse())
})
.map(|(_, item)| item)
}
fn build_battle_item_summary(effect: &BattleInventoryUseProfile) -> String {
let mut parts = Vec::new();
if effect.hp_restore > 0 {
parts.push(format!("回血 {}", effect.hp_restore));
}
if effect.mana_restore > 0 {
parts.push(format!("回蓝 {}", effect.mana_restore));
}
if effect.cooldown_reduction > 0 {
parts.push(format!("冷却 -{}", effect.cooldown_reduction));
}
if !effect.build_buffs.is_empty() {
let buff_names = effect
.build_buffs
.iter()
.filter_map(|buff| read_optional_string_field(buff, "name"))
.collect::<Vec<_>>();
if !buff_names.is_empty() {
parts.push(format!("增益 {}", buff_names.join("")));
}
}
if parts.is_empty() {
"立即结算一次物品效果".to_string()
} else {
parts.join(" / ")
}
}
fn build_static_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
function_id: function_id.to_string(),
action_text: action_text.to_string(),
detail_text: None,
scope: scope.to_string(),
interaction: None,
payload: None,
disabled: None,
reason: None,
}
}
fn build_runtime_story_option_with_payload(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
payload: Value,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload: Some(payload),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
fn build_disabled_runtime_story_option(
function_id: &str,
action_text: &str,
scope: &str,
detail_text: Option<String>,
reason: &str,
payload: Option<Value>,
) -> RuntimeStoryOptionView {
RuntimeStoryOptionView {
detail_text,
payload,
disabled: Some(true),
reason: Some(reason.to_string()),
..build_static_runtime_story_option(function_id, action_text, scope)
}
}
fn battle_victory_experience_reward(game_state: &Value) -> i32 {
let hostile = read_array_field(game_state, "sceneHostileNpcs")
.first()
.copied()
.or_else(|| read_field(game_state, "currentEncounter"));
let explicit_reward = hostile
.and_then(|entry| read_i32_field(entry, "experienceReward"))
.unwrap_or(0)
.max(0);
if explicit_reward > 0 {
return explicit_reward;
}
let level = hostile
.and_then(|entry| read_field(entry, "levelProfile"))
.and_then(|profile| read_i32_field(profile, "level"))
.unwrap_or(1)
.max(1);
12 + 6 * (level - 1)
}
fn battle_action_numbers(
function_id: &str,
) -> (i32, i32, i32, i32, i32, &'static str, &'static str) {
match function_id {
"battle_recover_breath" => (
0,
0,
8,
6,
0,
"恢复",
"你先稳住呼吸,把状态从危险边缘拉回一点。",
),
"battle_use_skill" => (
14,
4,
0,
0,
4,
"施放技能",
"你调动灵力打出一记更重的攻势,同时也承受了对方的反扑。",
),
"battle_all_in_crush" => (
22,
8,
0,
0,
6,
"全力压制",
"你把这一轮节奏全部压上去,试图用最强硬的方式打穿对方防线。",
),
"battle_feint_step" => (
6,
2,
0,
0,
0,
"佯攻换位",
"你用一次短促佯攻换开角度,虽然伤害有限,但避开了更重的反击。",
),
"battle_finisher_window" => (
18,
3,
0,
0,
3,
"抓住终结窗口",
"你抓住破绽打出决定性的一击,战斗天平明显向你倾斜。",
),
"battle_guard_break" => (
12,
5,
0,
0,
2,
"破开防守",
"你顶住压力破开对方防守,为后续行动争到更直接的窗口。",
),
"battle_probe_pressure" => (
5,
1,
0,
0,
0,
"试探压迫",
"你没有贸然压上,而是用轻攻测试对方反应。",
),
_ => (
10,
4,
0,
0,
0,
"普通攻击",
"你抓住当前窗口打出一记直接攻击,对方也立刻做出反击。",
),
}
}
fn build_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
function_id: &str,
) -> Result<BattleActionPlan, String> {
if function_id == "battle_use_skill" {
return build_skill_battle_action_plan(game_state, request);
}
if function_id == "inventory_use" {
return build_inventory_use_battle_action_plan(game_state, request);
}
let (damage_dealt, damage_taken, heal, mana_restore, mana_cost, action_text, result_text) =
battle_action_numbers(function_id);
Ok(BattleActionPlan {
action_text: action_text.to_string(),
result_text: result_text.to_string(),
damage_dealt,
damage_taken,
heal,
mana_restore,
mana_cost,
cooldown_tick_turns: 1,
cooldown_bonus_turns: 0,
applied_skill_cooldown: None,
build_buffs: Vec::new(),
consumed_item_id: None,
})
}
fn build_skill_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Result<BattleActionPlan, String> {
let payload = request
.action
.payload
.as_ref()
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
let skill_id = read_optional_string_field(payload, "skillId")
.ok_or_else(|| "battle_use_skill 缺少 skillId".to_string())?;
let skill = find_player_skill_by_id(game_state, skill_id.as_str())
.ok_or_else(|| format!("未找到技能:{skill_id}"))?;
let cooldowns = read_player_skill_cooldowns(game_state);
if cooldowns.get(skill_id.as_str()).copied().unwrap_or(0) > 0 {
return Err(format!("{} 仍在冷却中", skill.name));
}
if skill.mana_cost > read_i32_field(game_state, "playerMana").unwrap_or(0) {
return Err("当前灵力不足,无法执行这个战斗动作".to_string());
}
Ok(BattleActionPlan {
action_text: skill.name.clone(),
result_text: format!("{} 命中了敌人,这一轮技能效果已经直接结算。", skill.name),
damage_dealt: skill.damage.max(1),
damage_taken: 4,
heal: 0,
mana_restore: 0,
mana_cost: skill.mana_cost.max(0),
cooldown_tick_turns: 1,
cooldown_bonus_turns: 0,
applied_skill_cooldown: Some((skill.id, skill.cooldown_turns.max(0))),
build_buffs: skill.build_buffs,
consumed_item_id: None,
})
}
fn build_inventory_use_battle_action_plan(
game_state: &Value,
request: &RuntimeStoryActionRequest,
) -> Result<BattleActionPlan, String> {
let payload = request
.action
.payload
.as_ref()
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
let item_id = read_optional_string_field(payload, "itemId")
.ok_or_else(|| "inventory_use 缺少 itemId".to_string())?;
let item = find_player_inventory_item(game_state, item_id.as_str())
.ok_or_else(|| "未找到可用于战斗结算的物品".to_string())?;
if item.quantity <= 0 {
return Err("未找到可用于战斗结算的物品".to_string());
}
if item.use_profile.is_none() {
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
}
let effect = item.use_profile.expect("use_profile should exist");
if effect.hp_restore <= 0
&& effect.mana_restore <= 0
&& effect.cooldown_reduction <= 0
&& effect.build_buffs.is_empty()
{
return Err(format!("{} 当前没有可直接结算的战斗效果", item.name));
}
Ok(BattleActionPlan {
action_text: format!("使用{}", item.name),
result_text: format!("你立刻用下{},当前回合的物品效果已经生效。", item.name),
damage_dealt: 0,
damage_taken: 8,
heal: effect.hp_restore.max(0),
mana_restore: effect.mana_restore.max(0),
mana_cost: 0,
cooldown_tick_turns: 1,
cooldown_bonus_turns: effect.cooldown_reduction.max(0),
applied_skill_cooldown: None,
build_buffs: effect.build_buffs,
consumed_item_id: Some(item.id),
})
}
fn battle_action_toast(function_id: &str, request: &RuntimeStoryActionRequest) -> Option<String> {
if function_id != "inventory_use" {
return None;
}
request
.action
.payload
.as_ref()
.and_then(|payload| read_optional_string_field(payload, "itemId"))
.map(|_| "Build 增益已写回当前快照".to_string())
}