Files
Genarrative/server-rs/crates/module-runtime-story/src/battle.rs

882 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
/// 战斗纯结算链已经不依赖 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())
}