882 lines
31 KiB
Rust
882 lines
31 KiB
Rust
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())
|
||
}
|