This commit is contained in:
2026-04-28 02:05:12 +08:00
parent 271db02e4a
commit 1eb090e4a5
39 changed files with 2671 additions and 165 deletions

View File

@@ -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"))

View File

@@ -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()));
}

View File

@@ -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;