proxisim/src/player/mod.rs

1080 lines
36 KiB
Rust

use bevy_ecs::{prelude::*, system::SystemParam};
use proxisim_models::bundle::{
armour::{ArmourBodyPart, ArmourBodyParts, BodyPartCoverage},
bonus::{ArmourBypassBonus, DamageMitigationBonus, MiscBonus},
player::{
ActionNullification, Attacker, BodyPart, ChooseWeapon, CombatTurns, Current, CurrentTarget,
Defeated, Defender, FightEndType, Health, HealthChange, PartDamageBonus, PickedAction,
Player, PlayerStrategy, Weapons,
},
stat::{
AmmoControl, Clips, CritRate, DamageBonus, Defence, Dexterity, EffectiveStat, MaxHealth,
SimpleStatEffective, Speed, Strength, WeaponAccuracy,
},
weapon::{
Ammo, AmmoType, DamageStat, NeedsReload, NonTargeted, RateOfFire, Usable, Uses, Weapon,
WeaponSlot,
},
};
use rand::Rng as _;
use crate::{
FightStatus, Rng, Stages,
effect::Effects,
log,
log::Logger,
metrics::Metrics,
player::status_effect::{
Bleed, DamageOverTime, DamageOverTimeType, DeferredDamage, ExtraStatusEffectEffectiveness,
Hardened,
},
weapon::{DamageProcEffect, TurnTriggeredEffect, bonus::MultiTurnBonus},
};
pub mod stats;
pub mod status_effect;
fn select_weapon(
weapons: &Weapons,
slot: WeaponSlot,
reload: bool,
usable_q: &Query<Has<NeedsReload>, With<Usable>>,
) -> Option<WeaponSlot> {
let id = match slot {
WeaponSlot::Primary => weapons.primary?,
WeaponSlot::Secondary => weapons.secondary?,
WeaponSlot::Melee => weapons.melee?,
WeaponSlot::Temporary => weapons.temporary?,
WeaponSlot::Fists => weapons.fists,
WeaponSlot::Kick => weapons.kick,
};
let needs_reload = usable_q.get(id).ok()?;
if !reload && needs_reload {
None
} else {
Some(slot)
}
}
fn designate_first(
attacker_q: Query<Entity, With<Attacker>>,
defender_q: Query<Entity, With<Defender>>,
mut commands: Commands,
) {
let attacker = attacker_q.iter().next().unwrap();
let defender = defender_q.single().unwrap();
commands.entity(attacker).insert(Current);
commands.entity(defender).insert(CurrentTarget);
}
fn change_roles(
current_q: Query<Entity, (With<Current>, With<Player>)>,
target_q: Query<Entity, With<CurrentTarget>>,
mut commands: Commands,
) {
let current = current_q.single().unwrap();
let target = target_q.single().unwrap();
// TODO: Group fights
commands
.entity(current)
.remove::<Current>()
.insert(CurrentTarget);
// TODO: Distraction
commands
.entity(target)
.insert(Current)
.remove::<CurrentTarget>();
}
fn check_term_condition(
mut state: ResMut<FightStatus>,
defender_q: Query<(), (With<Defender>, Without<Defeated>)>,
attacker_q: Query<(), (With<Attacker>, Without<Defeated>)>,
) {
if defender_q.is_empty() || attacker_q.is_empty() {
*state = FightStatus::Over;
}
}
pub fn pick_action(
p_query: Query<(Entity, &Weapons, &PlayerStrategy), (With<Player>, Without<PickedAction>)>,
usable_q: Query<Has<NeedsReload>, With<Usable>>,
mut commands: Commands,
) {
for (player, weapons, strat) in p_query {
let slot = match strat {
PlayerStrategy::AlwaysFists => WeaponSlot::Fists,
PlayerStrategy::AlwaysKicks => {
select_weapon(weapons, WeaponSlot::Kick, true, &usable_q)
.unwrap_or(WeaponSlot::Fists)
}
PlayerStrategy::PrimaryMelee { reload } => {
select_weapon(weapons, WeaponSlot::Primary, *reload, &usable_q)
.or_else(|| select_weapon(weapons, WeaponSlot::Melee, true, &usable_q))
.unwrap_or(WeaponSlot::Fists)
}
PlayerStrategy::InOrder { order, reload } => order
.iter()
.find_map(|slot| select_weapon(weapons, *slot, *reload, &usable_q))
.unwrap_or(WeaponSlot::Fists),
};
commands.entity(player).insert(PickedAction(slot));
}
}
pub fn prepare_weapon(
mut p_query: Query<
(Entity, &Weapons, &PickedAction, &mut CombatTurns),
(With<Current>, With<Player>),
>,
target_q: Query<Entity, With<CurrentTarget>>,
child_q: Query<Option<&Children>>,
weapon_trigger_q: Query<&TurnTriggeredEffect>,
mut commands: Commands,
mut effects: Effects,
metrics: Res<Metrics>,
) {
let (current, weapons, picked_action, mut turns) = p_query.single_mut().unwrap();
let weapon = match picked_action.0 {
WeaponSlot::Primary => weapons.primary.unwrap_or(weapons.fists),
WeaponSlot::Secondary => weapons.secondary.unwrap_or(weapons.fists),
WeaponSlot::Melee => weapons.melee.unwrap_or(weapons.fists),
WeaponSlot::Temporary => weapons.temporary.unwrap_or(weapons.fists),
WeaponSlot::Fists => weapons.fists,
WeaponSlot::Kick => weapons.kick,
};
metrics.increment_counter(Some(current), "turn", 1);
metrics.increment_counter(Some(weapon), "turn", 1);
commands.entity(weapon).insert(Current);
let children = child_q.get(weapon).unwrap();
let target = target_q.single().unwrap();
if let Some(children) = children {
for effect in weapon_trigger_q.iter_many(children) {
effect.trigger(&mut effects, current, target);
}
}
turns.0 += 1;
}
pub struct DamageSpread(rand_distr::Beta<f32>);
impl FromWorld for DamageSpread {
fn from_world(_world: &mut World) -> Self {
Self(rand_distr::Beta::new(3.0, 3.0).unwrap())
}
}
#[derive(SystemParam)]
struct SharedParams<'w, 's> {
commands: Commands<'w, 's>,
rng: ResMut<'w, Rng>,
effects: Effects<'w, 's>,
logger: Logger<'w>,
metrics: Res<'w, Metrics>,
}
#[derive(SystemParam)]
struct BonusMitigationParams<'w, 's> {
bypass_q: Query<'w, 's, &'static ArmourBypassBonus>,
mitigation_q: Query<'w, 's, Option<&'static mut DamageMitigationBonus>>,
}
#[allow(clippy::too_many_arguments)]
#[inline(always)]
fn check_bonus_mitigation(
params: &mut BonusMitigationParams,
shared: &mut SharedParams,
w_children: &Children,
armour_mitigation: &mut f32,
piece: &mut Option<&BodyPartCoverage>,
slot: WeaponSlot,
rounds: &Option<u16>,
health: &Health,
max_health: &SimpleStatEffective<MaxHealth>,
) -> f32 {
for bypass in params.bypass_q.iter_many(w_children) {
match bypass {
ArmourBypassBonus::Penetrate { mitigation } => {
*armour_mitigation *= 1.0 - mitigation;
}
ArmourBypassBonus::Puncture { chance } => {
if *chance >= 1.0 || shared.rng.random_bool(*chance as f64) {
*armour_mitigation = 0.0;
*piece = None;
return 0.0;
}
}
}
}
if let Some(piece) = piece
&& let Some(mut mitigation) = params.mitigation_q.get_mut(piece.armour).unwrap()
{
match mitigation.as_mut() {
DamageMitigationBonus::Impregnable { mitigation } if slot == WeaponSlot::Melee => {
*mitigation
}
DamageMitigationBonus::Impenetrable { mitigation } if rounds.is_some() => *mitigation,
DamageMitigationBonus::Insurmountable { mitigation } => {
if health.0 as f32 / max_health.value as f32 <= 0.25 {
*mitigation
} else {
0.0
}
}
DamageMitigationBonus::Impassable { chance } => {
if *chance >= 1.0 || shared.rng.random_bool(*chance as f64) {
1.0
} else {
0.0
}
}
DamageMitigationBonus::Kinetokinesis { mitigation } => {
shared.commands.entity(piece.armour).insert(
DamageMitigationBonus::ActiveKinetokinesis {
mitigation: *mitigation,
remaining_turns: 10,
},
);
0.0
}
DamageMitigationBonus::ActiveKinetokinesis {
mitigation,
remaining_turns,
} => {
*remaining_turns -= 1;
if *remaining_turns == 0 {
shared.commands.entity(piece.armour).insert(
DamageMitigationBonus::Kinetokinesis {
mitigation: *mitigation,
},
);
}
*mitigation
}
_ => 0.0,
}
} else {
0.0
}
}
#[derive(SystemParam)]
struct DamageBonusParams<'w, 's> {
misc_bonus_q: Query<'w, 's, &'static MiscBonus>,
part_bonus_q: Query<'w, 's, &'static PartDamageBonus>,
}
/// Process miscellaneous bonuses that did not fall in any of the other categories
#[allow(clippy::too_many_arguments)]
#[inline(always)]
fn check_damage_bonuses(
queries: &DamageBonusParams,
shared: &mut SharedParams,
p_children: &Children,
t_health: &Health,
p_health: &Health,
t_max_health: &SimpleStatEffective<MaxHealth>,
p_max_health: &SimpleStatEffective<MaxHealth>,
part: BodyPart,
dmg_bonus: &mut SimpleStatEffective<DamageBonus>,
double_edged: &mut bool,
) {
for bonus in queries.misc_bonus_q.iter_many(p_children) {
match bonus {
MiscBonus::Blindside { bonus } if t_health.0 == t_max_health.value => {
dmg_bonus.value += bonus;
}
MiscBonus::Comeback { bonus }
if p_health.0 as f32 / p_max_health.value as f32 <= 0.25 =>
{
dmg_bonus.value += bonus;
}
MiscBonus::Deadly { chance } if *chance >= 1.0 || shared.rng.random_bool(*chance) => {
dmg_bonus.value += 5.0;
}
MiscBonus::DoubleEdged { chance }
if *chance >= 1.0 || shared.rng.random_bool(*chance) =>
{
dmg_bonus.value += 1.0;
*double_edged = true;
}
_ => (),
}
}
for bonus in queries.part_bonus_q.iter_many(p_children) {
if let Some(bonus) = bonus.dmg_bonus(part) {
dmg_bonus.value += bonus;
}
}
}
#[derive(SystemParam)]
struct NullificationParams<'w, 's> {
nullification_q: Query<'w, 's, &'static ActionNullification>,
}
fn check_action_nullification(
params: &NullificationParams,
shared: &mut SharedParams,
target_children: &Children,
target_action: &PickedAction,
player_action: &PickedAction,
) -> Option<&'static str> {
for bonus in params.nullification_q.iter_many(target_children) {
match bonus {
ActionNullification::Parry { chance }
if player_action.0 == WeaponSlot::Melee
&& target_action.0 == WeaponSlot::Melee
&& (*chance >= 1.0 || shared.rng.random_bool(*chance)) =>
{
return Some("parry");
}
ActionNullification::Homerun { chance }
if player_action.0 == WeaponSlot::Temporary
&& target_action.0 == WeaponSlot::Melee
&& (*chance >= 1.0 || shared.rng.random_bool(*chance)) =>
{
return Some("homerun");
}
ActionNullification::GentsStripClub
if player_action.0 == WeaponSlot::Melee && shared.rng.random_bool(0.25) =>
{
return Some("dodged");
}
_ => (),
}
}
None
}
#[derive(SystemParam)]
struct DeferredDamageParams<'w, 's> {
damage_q: Query<'w, 's, (Entity, &'static DeferredDamage)>,
}
/// Checks and applied deferred damage.
/// Returns `true` if the attacking player was defeated
#[inline(always)]
fn check_deferred_damage(
deferred: &DeferredDamageParams,
shared: &mut SharedParams,
player: Entity,
player_children: &Children,
player_health: &mut Health,
is_attacker: bool,
target: Entity,
) -> bool {
for (instance, damage) in deferred.damage_q.iter_many(player_children) {
let health_before = player_health.0;
player_health.0 = player_health.0.saturating_sub(damage.amount);
log!(shared.logger, "deferred_damage", {
health_before: health_before,
health_after: player_health.0,
amount: damage.amount,
label: damage.label,
target: player,
});
shared.commands.entity(instance).despawn();
if player_health.0 == 0 {
shared.commands.entity(player).insert(Defeated);
log!(shared.logger, "fight_end", {
actor: target,
recipient: player,
fight_end_type: %if is_attacker {
FightEndType::Loss
} else {
FightEndType::Victory
},
});
shared.metrics.increment_counter(Some(target), "victory", 1);
return true;
}
}
false
}
#[derive(SystemParam)]
struct EntityParams<'w, 's> {
/// The weapon and its related stats
weapon_q: Query<
'w,
's,
(
Entity,
&'static DamageStat,
&'static SimpleStatEffective<WeaponAccuracy>,
&'static SimpleStatEffective<DamageBonus>,
&'static SimpleStatEffective<CritRate>,
&'static Children,
&'static WeaponSlot,
Has<NonTargeted>,
),
(With<Weapon>, With<Current>, Without<NeedsReload>),
>,
/// The player who's using the weapon and their related stats
player_q: Query<
'w,
's,
(
Entity,
&'static EffectiveStat<Speed>,
&'static EffectiveStat<Strength>,
&'static SimpleStatEffective<CritRate>,
&'static SimpleStatEffective<WeaponAccuracy>,
&'static SimpleStatEffective<DamageBonus>,
&'static SimpleStatEffective<MaxHealth>,
&'static Children,
&'static PickedAction,
&'static mut Health,
Has<Attacker>,
),
(With<Player>, With<Current>),
>,
/// The player who is targeted with the weapon
target_q: Query<
'w,
's,
(
Entity,
&'static EffectiveStat<Dexterity>,
&'static EffectiveStat<Defence>,
&'static SimpleStatEffective<MaxHealth>,
&'static ArmourBodyParts,
&'static Children,
&'static PickedAction,
&'static mut Health,
),
(With<CurrentTarget>, Without<Current>),
>,
}
// NOTE: unfortunately this function can't really be split into smaller parts due to the existence
// of multi turn bonuses
#[allow(clippy::too_many_arguments)]
fn use_damaging_weapon(
(mut shared, mut bonus_mitigation, deferred, misc_bonus, nullification): (
SharedParams,
BonusMitigationParams,
DeferredDamageParams,
DamageBonusParams,
NullificationParams,
),
mut entities: EntityParams,
armour_q: Query<&ArmourBodyPart>,
damage_proc_q: Query<&DamageProcEffect>,
(mut ammo_q, mut temp_q): (
Query<(
&mut Ammo,
&SimpleStatEffective<Clips>,
&RateOfFire,
&SimpleStatEffective<AmmoControl>,
&AmmoType,
)>,
Query<&mut Uses>,
),
dmg_spread: Local<DamageSpread>,
) {
let Ok((weapon, w_dmg, acc, dmg_bonus, crit, children, slot, non_targeted)) =
entities.weapon_q.single()
else {
return;
};
let (
player,
player_spd,
player_str,
player_crit,
acc_bonus,
p_dmg_bonus,
p_max_health,
p_children,
p_action,
mut p_health,
attacker,
) = entities.player_q.single_mut().unwrap();
let (
target,
target_dex,
target_def,
target_max_health,
armour_parts,
t_children,
t_action,
mut health,
) = entities.target_q.single_mut().unwrap();
if check_deferred_damage(
&deferred,
&mut shared,
player,
p_children,
&mut p_health,
attacker,
target,
) {
return;
}
if let Ok(mut uses) = temp_q.get_mut(weapon) {
uses.0 -= 1;
if uses.0 == 0 {
shared.commands.entity(weapon).remove::<Usable>();
}
}
let spd_dex_ratio = (player_spd.value / target_dex.value).clamp(1.0 / 64.0, 64.0);
let base_hit_chance = if spd_dex_ratio < 1.0 {
0.5 * (8.0 * spd_dex_ratio.sqrt() - 1.0) / 7.0
} else {
1.0 - 0.5 * (8.0 / spd_dex_ratio.sqrt() - 1.0) / 7.0
};
let mut acc_eff = acc + acc_bonus;
let mut ammo = ammo_q
.get_mut(weapon)
.ok()
.map(|(ammo, clips, rof, ammo_ctrl, kind)| {
let ammo_ctrl = 1.0 - (ammo_ctrl).value;
let rof_eff = ((rof.0[0] as f32) * ammo_ctrl)..=((rof.0[1] as f32) * ammo_ctrl);
if *kind == AmmoType::Tracer {
acc_eff.value += 10.0 / 50.0;
}
(ammo, clips, rof_eff, kind)
});
enum MultiAttack {
Blindfire,
Rage(u16),
Fury { first_hit: bool },
DoubleTap { first_shot: bool },
}
let mut multi_attack_proc = None;
let mut defeated = false;
let crit = player_crit + crit;
let def_str_ratio = (target_def.value / player_str.value).clamp(1.0 / 32.0, 14.0);
let def_mitigation = if def_str_ratio < 1.0 {
0.5 * def_str_ratio.log(32.0) + 0.5
} else {
0.5 * def_str_ratio.log(14.0) + 0.5
};
let dmg_intrinsic = 7.0 * (player_str.value / 10.0).log10().powi(2)
+ 27.0 * (player_str.value / 10.0).log10()
+ 30.0;
loop {
let rounds = ammo.as_mut().map(|(ammo, clips, rof, _)| {
let rounds = (shared.rng.random_range(rof.clone()).round() as u16).clamp(1, ammo.0);
shared
.metrics
.increment_counter(Some(player), "rounds_fired", rounds.into());
shared
.metrics
.increment_counter(Some(weapon), "rounds_fired", rounds.into());
ammo.0 -= rounds;
if ammo.0 == 0 {
if clips.value == 0 {
shared.commands.entity(weapon).remove::<Usable>();
} else {
shared.commands.entity(weapon).insert(NeedsReload);
}
}
rounds
});
let hit_chance = if base_hit_chance < 0.5 {
base_hit_chance + acc_eff.value * base_hit_chance
} else {
base_hit_chance + acc_eff.value * (1.0 - base_hit_chance)
};
if hit_chance <= 1.0 && !shared.rng.random_bool(hit_chance as f64) {
log!(shared.logger, "miss_target", {
weapon: weapon,
actor: player,
recipient: target,
rounds: rounds,
hit_chance: hit_chance,
});
shared.metrics.increment_counter(Some(player), "miss", 1);
shared.metrics.increment_counter(Some(weapon), "miss", 1);
if multi_attack_proc.is_none() {
return;
};
} else {
if multi_attack_proc.is_none()
&& let Some(kind) = check_action_nullification(
&nullification,
&mut shared,
t_children,
t_action,
p_action,
)
{
log!(shared.logger, "nullified", {
actor: player,
recipient: player,
weapon: weapon,
rounds,
kind,
});
return;
}
let body_part = if !non_targeted {
shared.rng.sample(crit)
} else {
BodyPart::Stomach
};
let mult = match body_part {
BodyPart::Head | BodyPart::Heart | BodyPart::Throat => {
shared.metrics.increment_counter(Some(player), "crit", 1);
shared.metrics.increment_counter(Some(weapon), "crit", 1);
1.0
}
BodyPart::LeftHand
| BodyPart::RightHand
| BodyPart::LeftFoot
| BodyPart::RightFoot => 0.2,
BodyPart::LeftArm | BodyPart::RightArm | BodyPart::LeftLeg | BodyPart::RightLeg => {
1.0 / 3.5
}
BodyPart::Groin | BodyPart::Stomach | BodyPart::Chest => 1.0 / 1.75,
};
shared.metrics.increment_counter(Some(player), "hit", 1);
shared.metrics.increment_counter(Some(weapon), "hit", 1);
let armour_parts = armour_q.get(armour_parts.0[body_part.into()]).unwrap();
let mut piece = shared.rng.sample(armour_parts);
let mut armour_mitigation = piece.map_or(0.0, |p| p.armour_value);
// NOTE: Proxima's simulator seems to have the damage spread be between 95% and 105%,
// but from my brief tests it seems that 100% to 110% lines up better, at least for h2h.
// It might be better to revivisit this detail later down the line and run more tests.
let dmg_spread = shared.rng.sample(dmg_spread.0) / 10.0 + 1.0;
let mut dmg_bonus = dmg_bonus + p_dmg_bonus;
let bonus_mitigation = check_bonus_mitigation(
&mut bonus_mitigation,
&mut shared,
children,
&mut armour_mitigation,
&mut piece,
*slot,
&rounds,
&health,
target_max_health,
);
if let Some((_, _, _, kind)) = ammo {
match kind {
AmmoType::HollowPoint if piece.is_none() => armour_mitigation *= 1.5,
AmmoType::HollowPoint => dmg_bonus.value += 0.50,
AmmoType::Incindiary => dmg_bonus.value += 0.40,
AmmoType::Piercer => armour_mitigation *= 0.5,
_ => (),
}
}
let mut double_edged = false;
check_damage_bonuses(
&misc_bonus,
&mut shared,
children,
&health,
&p_health,
target_max_health,
p_max_health,
body_part,
&mut dmg_bonus,
&mut double_edged,
);
let dmg = dmg_intrinsic
* w_dmg.0
* (1.0 + dmg_bonus.value)
* (1.0 - armour_mitigation)
* (1.0 - def_mitigation)
* (1.0 - bonus_mitigation)
* mult
* dmg_spread;
let dmg_i = dmg.round() as u32;
if double_edged {
shared
.commands
.write_message(HealthChange::DoubleEdged { dmg: dmg_i });
}
shared.metrics.record_histogram(Some(player), "dmg", dmg_i);
shared.metrics.record_histogram(Some(weapon), "dmg", dmg_i);
if multi_attack_proc.is_none() && dmg_i > 0 {
for effect in damage_proc_q.iter_many(children) {
match *effect {
DamageProcEffect::MultiTurn { value, bonus } => {
if multi_attack_proc.is_some() {
continue;
}
let chance = (value / 100.0) as f64;
if chance >= 1.0 || shared.rng.random_bool(chance) {
match bonus {
MultiTurnBonus::Blindfire => {
multi_attack_proc = Some(MultiAttack::Blindfire)
}
MultiTurnBonus::Fury => {
multi_attack_proc =
Some(MultiAttack::Fury { first_hit: true })
}
MultiTurnBonus::Rage => {
multi_attack_proc =
Some(MultiAttack::Rage(shared.rng.random_range(2..=8)))
}
MultiTurnBonus::DoubleTap => {
multi_attack_proc =
Some(MultiAttack::DoubleTap { first_shot: true })
}
};
shared.metrics.increment_counter(
Some(player),
bonus.counter_label(),
1,
);
shared.metrics.increment_counter(
Some(weapon),
bonus.counter_label(),
1,
);
}
}
DamageProcEffect::SelfEffect { value, bonus } => {
let chance = (value / 100.0) as f64;
if chance > 1.0 || shared.rng.random_bool(chance) {
bonus.spawn(player, &mut shared.effects);
}
}
DamageProcEffect::OpponentEffect { value, bonus } => {
let chance = (value / 100.0) as f64;
if chance > 1.0 || shared.rng.random_bool(chance) {
bonus.spawn(target, &mut shared.effects, &mut shared.rng.0);
}
}
DamageProcEffect::DamageOverTime { value, kind } => {
let chance = (value / 100.0) as f64;
if chance > 1.0 || shared.rng.random_bool(chance) {
match kind {
DamageOverTimeType::Bleed => {
shared.commands.entity(target).insert(DamageOverTime::<
Bleed,
>::new(
dmg_i
));
}
}
}
}
DamageProcEffect::Execute { cutoff } => {
if health.0 as f32 / target_max_health.value as f32 <= cutoff {
shared.commands.entity(target).insert(Defeated);
let health_before = health.0;
health.0 = 0;
log!(shared.logger, "executed", {
actor: player,
recipient: target,
health_before,
});
log!(shared.logger, "fight_end", {
actor: player,
recipient: target,
fight_end_type: %if attacker {
FightEndType::Victory
} else {
FightEndType::Loss
},
});
shared.metrics.increment_counter(Some(player), "victory", 1);
return;
}
}
DamageProcEffect::Bloodlust { ratio } => {
shared.commands.write_message(HealthChange::Bloodlust {
dmg: dmg_i,
value: ratio,
});
}
}
}
}
let health_before = health.0;
health.0 = health.0.saturating_sub(dmg_i as u16);
log!(shared.logger, "hit_target", {
actor: player,
acc: acc_eff.value,
recipient: target,
weapon,
part: %body_part,
part_mult: mult,
rounds,
health_before: health_before,
health_after: health.0,
dmg: dmg_i,
dmg_spread,
dmg_intrinsic,
dmg_weapon: w_dmg.0,
armour_mitigation,
def_mitigation,
bonus_mitigation,
bonus_dmg: dmg_bonus.value,
hit_chance: hit_chance,
crit_rate: crit.value,
});
if health.0 == 0 && !defeated {
defeated = true;
shared.commands.entity(target).insert(Defeated);
log!(shared.logger, "fight_end", {
actor: player,
recipient: target,
fight_end_type: %if attacker {
FightEndType::Victory
} else {
FightEndType::Loss
},
});
shared.metrics.increment_counter(Some(player), "victory", 1);
}
}
// Technically only douple tap and blindfire have this condition, but we can run into
// panics with invalid bonus/weapon combinations without checking this for all bonuses
if ammo.as_ref().is_some_and(|(a, _, _, _)| a.0 == 0) {
break;
}
match multi_attack_proc {
Some(MultiAttack::Blindfire) => {
acc_eff.value -= 5.0 / 50.0;
// Prevent infinite loop if used on a melee
if ammo.is_none() {
break;
}
}
Some(MultiAttack::Fury { first_hit: true }) => {
multi_attack_proc = Some(MultiAttack::Fury { first_hit: false })
}
Some(MultiAttack::Rage(turns @ 1..)) => {
multi_attack_proc = Some(MultiAttack::Rage(turns - 1))
}
Some(MultiAttack::DoubleTap { first_shot: true }) => {
multi_attack_proc = Some(MultiAttack::DoubleTap { first_shot: false })
}
_ => break,
}
}
}
pub fn check_stalemate(
current_q: Query<(Entity, &CombatTurns, Has<Attacker>), (With<Current>, With<Player>)>,
target_q: Query<Entity, With<CurrentTarget>>,
other_attackers_q: Query<(), (With<Attacker>, Without<Current>)>,
mut state: ResMut<FightStatus>,
mut commands: Commands,
mut logger: Logger,
metrics: Res<Metrics>,
) {
let (current, current_turns, attacker) = current_q.single().unwrap();
if *state == FightStatus::Ongoing && current_turns.0 >= 25 && attacker {
commands.entity(current).insert(Defeated);
let target = target_q.single();
log!(logger, "fight_end", {
actor: current,
recipient: target,
fight_end_type: %FightEndType::Stalemate,
});
metrics.increment_counter(Some(current), "stalemate", 1);
if other_attackers_q.is_empty() {
*state = FightStatus::Over
}
}
}
pub fn restore_initial_state(
mut state: ResMut<FightStatus>,
mut player_q: Query<(Entity, &mut CombatTurns, Has<Attacker>)>,
mut commands: Commands,
) {
*state = FightStatus::Ongoing;
for (player, mut turns, attacker) in player_q.iter_mut() {
turns.0 = 0;
commands.entity(player).remove::<Defeated>();
if attacker {
commands
.entity(player)
.remove::<CurrentTarget>()
.insert(Current);
} else {
commands
.entity(player)
.remove::<Current>()
.insert(CurrentTarget);
}
}
}
fn record_post_fight_stats(player_q: Query<(Entity, &Health)>, metrics: Res<Metrics>) {
for (player, health) in player_q.iter() {
metrics.record_histogram(Some(player), "rem_health", health.0 as u32);
}
}
fn restore_health(
health_q: Query<(Entity, &SimpleStatEffective<MaxHealth>)>,
mut commands: Commands,
mut logger: Logger,
) {
for (player, max_health) in health_q {
log!(logger, "initial_health", {
target: player,
max_health: max_health.value,
});
commands.entity(player).insert(Health(max_health.value));
}
}
fn process_health_changes(
mut messages: MessageReader<HealthChange>,
mut logger: Logger,
mut p_query: Query<
(
Entity,
&SimpleStatEffective<MaxHealth>,
&mut Health,
Option<&ExtraStatusEffectEffectiveness<1, Hardened>>,
),
(With<Player>, With<Current>),
>,
) {
for message in messages.read() {
let (player, max_health, mut health, extra_effectiveness) = p_query.single_mut().unwrap();
let (source, delta) = match message {
HealthChange::Cauterise => {
let delta = (max_health.value as f32 * 0.2) as i16;
("Cauterise", delta)
}
HealthChange::Serotonin => {
let delta = (max_health.value as f32
* 0.25
* extra_effectiveness.map_or(1.0, |e| e.factor))
as i16;
("Serotonin", delta)
}
HealthChange::Bloodlust { dmg, value } => {
let delta = (*dmg as f32 * *value) as i16;
("Bloodlust", delta)
}
HealthChange::DoubleEdged { dmg } => {
let delta = -(*dmg as f32 * 0.25) as i16;
("Double-edged", delta)
}
};
let health_before = health.0;
health.0 = (health.0 as i16 + delta).clamp(1, max_health.value as i16) as u16;
log!(logger, "health_change", {
recipient: player,
source,
delta,
health_before,
health_after: health.0
})
}
}
fn remove_action_end_of_turn(
attacker_turn: Query<Has<Attacker>, (With<Current>, With<Player>)>,
picked_q: Query<Entity, With<PickedAction>>,
mut commands: Commands,
) {
if !attacker_turn.single().unwrap() {
for player in picked_q {
commands.entity(player).remove::<PickedAction>();
}
}
}
fn remove_picked_actions(picked_q: Query<Entity, With<PickedAction>>, mut commands: Commands) {
for player in picked_q {
commands.entity(player).remove::<PickedAction>();
}
}
pub(crate) fn configure(stages: &mut Stages) {
stats::configure(stages);
status_effect::configure(stages);
stages.add_event::<ChooseWeapon>();
stages.add_event::<HealthChange>();
stages.equip.add_systems(designate_first);
stages.pre_fight.add_systems((restore_health, pick_action));
stages.pre_turn.add_systems(prepare_weapon);
stages
.turn
.add_systems((use_damaging_weapon, remove_action_end_of_turn));
stages
.post_turn
.add_systems((
check_term_condition,
change_roles,
process_health_changes,
pick_action,
))
.add_systems(
check_stalemate
.after(check_term_condition)
.before(change_roles),
);
stages
.post_fight
.add_systems((record_post_fight_stats, remove_picked_actions));
stages
.restore
.add_systems((restore_initial_state, restore_health, pick_action));
}