1
This commit is contained in:
@@ -56,6 +56,37 @@ struct BattleInventoryItemView {
|
||||
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,
|
||||
@@ -114,43 +145,31 @@ pub fn resolve_battle_action(
|
||||
increment_runtime_stat(game_state, "itemsUsed", 1);
|
||||
}
|
||||
|
||||
apply_player_damage(game_state, plan.damage_taken);
|
||||
let player_hp = apply_player_damage(game_state, plan.damage_taken);
|
||||
let target_hp = apply_target_damage(game_state, plan.damage_dealt);
|
||||
let outcome = if target_hp <= 0 {
|
||||
if battle_mode == "spar" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"victory"
|
||||
}
|
||||
} else {
|
||||
"ongoing"
|
||||
};
|
||||
let outcome = resolve_battle_resolution_outcome(player_hp, target_hp, battle_mode.as_str());
|
||||
|
||||
let victory_experience = if outcome == "victory" {
|
||||
let victory_experience = if outcome == BattleResolutionOutcome::Victory {
|
||||
battle_victory_experience_reward(game_state)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
if outcome != "ongoing" {
|
||||
if outcome != BattleResolutionOutcome::Ongoing {
|
||||
write_bool_field(game_state, "inBattle", false);
|
||||
write_bool_field(game_state, "npcInteractionActive", false);
|
||||
write_null_field(game_state, "currentNpcBattleMode");
|
||||
write_string_field(
|
||||
game_state,
|
||||
"currentNpcBattleOutcome",
|
||||
if outcome == "spar_complete" {
|
||||
"spar_complete"
|
||||
} else {
|
||||
"fight_victory"
|
||||
},
|
||||
);
|
||||
if outcome == "victory" {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +179,22 @@ pub fn resolve_battle_action(
|
||||
target_id: Some(target_id.clone()),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: outcome.to_string(),
|
||||
outcome: outcome.as_battle_outcome().to_string(),
|
||||
},
|
||||
build_status_patch(game_state),
|
||||
];
|
||||
if outcome == "victory" {
|
||||
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 == "ongoing" {
|
||||
result_text: if outcome == BattleResolutionOutcome::Ongoing {
|
||||
plan.result_text
|
||||
} else if outcome == "spar_complete" {
|
||||
} else if outcome == BattleResolutionOutcome::SparComplete {
|
||||
format!("{target_name} 收住了最后一击,这场切磋已经分出结果。")
|
||||
} else if outcome == BattleResolutionOutcome::Defeat {
|
||||
format!("你在与 {target_name} 的交锋中被压制倒下,这场战斗以你的败北收束。")
|
||||
} else {
|
||||
format!("{target_name} 被你压制下去,眼前的战斗已经结束。")
|
||||
},
|
||||
@@ -186,7 +207,7 @@ pub fn resolve_battle_action(
|
||||
target_name: Some(target_name),
|
||||
damage_dealt: Some(plan.damage_dealt),
|
||||
damage_taken: Some(plan.damage_taken),
|
||||
outcome: Some(outcome.to_string()),
|
||||
outcome: Some(outcome.as_battle_outcome().to_string()),
|
||||
}),
|
||||
toast: battle_action_toast(function_id, request),
|
||||
})
|
||||
@@ -263,12 +284,15 @@ fn spend_player_mana(game_state: &mut Value, mana_cost: i32) {
|
||||
write_i32_field(game_state, "playerMana", (mana - mana_cost).max(0));
|
||||
}
|
||||
|
||||
fn apply_player_damage(game_state: &mut Value, damage: i32) {
|
||||
if damage <= 0 {
|
||||
return;
|
||||
}
|
||||
let hp = read_i32_field(game_state, "playerHp").unwrap_or(1);
|
||||
write_i32_field(game_state, "playerHp", (hp - damage).max(1));
|
||||
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 {
|
||||
@@ -290,6 +314,27 @@ fn apply_target_damage(game_state: &mut Value, damage: i32) -> i32 {
|
||||
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"))
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
use serde_json::json;
|
||||
|
||||
use shared_contracts::runtime_story::{RuntimeStoryActionRequest, RuntimeStoryChoiceAction, RuntimeStoryPatch};
|
||||
|
||||
use crate::{
|
||||
battle::resolve_battle_action,
|
||||
build_status_patch,
|
||||
read_bool_field,
|
||||
read_i32_field,
|
||||
read_optional_string_field,
|
||||
};
|
||||
|
||||
fn build_battle_fixture() -> serde_json::Value {
|
||||
json!({
|
||||
"inBattle": true,
|
||||
"npcInteractionActive": false,
|
||||
"playerHp": 4,
|
||||
"playerMaxHp": 40,
|
||||
"playerMana": 10,
|
||||
"playerMaxMana": 10,
|
||||
"playerSkillCooldowns": {},
|
||||
"runtimeStats": {
|
||||
"hostileNpcsDefeated": 0,
|
||||
"itemsUsed": 0,
|
||||
"questsAccepted": 0,
|
||||
"scenesTraveled": 0,
|
||||
"playTimeMs": 0,
|
||||
"lastPlayTickAt": null
|
||||
},
|
||||
"currentNpcBattleMode": "fight",
|
||||
"currentNpcBattleOutcome": null,
|
||||
"currentEncounter": {
|
||||
"kind": "npc",
|
||||
"id": "npc_bandit_01",
|
||||
"npcName": "断桥匪首",
|
||||
"hostile": true,
|
||||
"hp": 8,
|
||||
"experienceReward": 24
|
||||
},
|
||||
"sceneHostileNpcs": [{
|
||||
"id": "npc_bandit_01",
|
||||
"name": "断桥匪首",
|
||||
"hp": 8,
|
||||
"maxHp": 80,
|
||||
"experienceReward": 24
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequest {
|
||||
RuntimeStoryActionRequest {
|
||||
session_id: "runtime-main".to_string(),
|
||||
client_version: Some(0),
|
||||
action: RuntimeStoryChoiceAction {
|
||||
action_type: "story_choice".to_string(),
|
||||
function_id: function_id.to_string(),
|
||||
target_id: None,
|
||||
payload: Some(json!({
|
||||
"optionText": option_text
|
||||
})),
|
||||
},
|
||||
snapshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() {
|
||||
let request = build_request("battle_all_in_crush", "全力压制");
|
||||
let mut game_state = build_battle_fixture();
|
||||
|
||||
let resolution = resolve_battle_action(&mut game_state, &request, "battle_all_in_crush")
|
||||
.expect("battle action should resolve");
|
||||
|
||||
assert_eq!(read_i32_field(&game_state, "playerHp"), Some(0));
|
||||
assert_eq!(
|
||||
read_optional_string_field(&game_state, "currentNpcBattleOutcome"),
|
||||
Some("fight_defeat".to_string())
|
||||
);
|
||||
assert_eq!(read_bool_field(&game_state, "inBattle"), Some(false));
|
||||
assert!(resolution.result_text.contains("败北"));
|
||||
assert!(matches!(
|
||||
resolution.patches.first(),
|
||||
Some(RuntimeStoryPatch::BattleResolved { outcome, .. }) if outcome == "defeat"
|
||||
));
|
||||
assert_eq!(resolution.patches.get(1), Some(&build_status_patch(&game_state)));
|
||||
assert_eq!(resolution.battle.and_then(|battle| battle.outcome), Some("defeat".to_string()));
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use shared_contracts::runtime_story::{
|
||||
};
|
||||
|
||||
pub mod battle;
|
||||
#[cfg(test)]
|
||||
mod battle_tests;
|
||||
pub mod core;
|
||||
pub mod forge;
|
||||
pub mod forge_actions;
|
||||
|
||||
Reference in New Issue
Block a user