proxisim/src/player/mod.rs

846 lines
29 KiB
Rust

use bevy_ecs::{prelude::*, system::SystemParam};
use proxisim_models::bundle::{
armour::{ArmourBodyPart, ArmourBodyParts, BodyPartCoverage},
bonus::{ArmourBypassBonus, DamageMitigationBonus},
player::{
Attacker, BodyPart, ChooseWeapon, CombatTurns, Current, CurrentTarget, Defeated, Defender,
FightEndType, Health, PartDamageBonus, 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},
weapon::{DamageProcEffect, TurnTriggeredEffect, bonus::MultiTurnBonus},
};
pub mod stats;
pub mod status_effect;
fn select_weapon<'a>(
weapons: &Weapons,
slot: WeaponSlot,
reload: bool,
usable_q: &'a Query<(Has<NeedsReload>, Option<&Children>), With<Usable>>,
) -> Option<(Entity, Option<&'a Children>)> {
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, children) = usable_q.get(id).ok()?;
if !reload && needs_reload {
None
} else {
Some((id, children))
}
}
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(
mut p_query: Query<
(Entity, &Weapons, &PlayerStrategy, &mut CombatTurns),
(With<Current>, With<Player>),
>,
target_q: Query<Entity, With<CurrentTarget>>,
usable_q: Query<(Has<NeedsReload>, Option<&Children>), With<Usable>>,
weapon_trigger_q: Query<&TurnTriggeredEffect>,
mut commands: Commands,
mut effects: Effects,
metrics: Res<Metrics>,
) {
let (current, weapons, strat, mut turns) = p_query.single_mut().unwrap();
let (weapon, children) = match strat {
PlayerStrategy::AlwaysFists => (weapons.fists, None),
PlayerStrategy::AlwaysKicks => select_weapon(weapons, WeaponSlot::Kick, true, &usable_q)
.unwrap_or_else(|| (weapons.fists, Default::default())),
PlayerStrategy::PrimaryMelee { reload } => {
select_weapon(weapons, WeaponSlot::Primary, *reload, &usable_q)
.or_else(|| select_weapon(weapons, WeaponSlot::Melee, true, &usable_q))
.unwrap_or_else(|| (weapons.fists, Default::default()))
}
PlayerStrategy::InOrder { order, reload } => order
.iter()
.find_map(|slot| select_weapon(weapons, *slot, *reload, &usable_q))
.unwrap_or_else(|| (weapons.fists, Default::default())),
};
metrics.increment_counter(Some(current), "turn", 1);
metrics.increment_counter(Some(weapon), "turn", 1);
commands.entity(weapon).insert(Current);
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 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 Children,
&'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 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): (
SharedParams,
BonusMitigationParams,
DeferredDamageParams,
),
mut entities: EntityParams,
armour_q: Query<&ArmourBodyPart>,
(damage_proc_q, part_bonus_q): (Query<&DamageProcEffect>, Query<&PartDamageBonus>),
(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_children,
mut p_health,
attacker,
) = entities.player_q.single_mut().unwrap();
let (target, target_dex, target_def, target_max_health, armour_parts, 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 {
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;
for part_bonus in part_bonus_q.iter_many(children) {
if let Some(bonus) = part_bonus.dmg_bonus(body_part) {
dmg_bonus.value += bonus;
}
}
let bonus_mitigation = check_bonus_mitigation(
&mut bonus_mitigation,
&mut shared,
children,
&mut armour_mitigation,
&mut piece,
*slot,
&rounds,
&health,
target_max_health,
);
let spammo_bonus = ammo
.as_ref()
.map(|(_, _, _, kind)| match kind {
AmmoType::HollowPoint if piece.is_none() => {
armour_mitigation *= 1.5;
0.0
}
AmmoType::HollowPoint => 0.50,
AmmoType::Incindiary => 0.40,
AmmoType::Piercer => {
armour_mitigation *= 0.5;
0.0
}
_ => 0.0,
})
.unwrap_or_default();
let mut dmg = dmg_intrinsic
* w_dmg.0
* (1.0 + dmg_bonus.value + spammo_bonus)
* (1.0 - armour_mitigation)
* (1.0 - def_mitigation)
* (1.0 - bonus_mitigation)
* mult
* dmg_spread;
let mut dmg_i = dmg.round() as u32;
shared.metrics.record_histogram(Some(player), "dmg", dmg_i);
shared.metrics.record_histogram(Some(weapon), "dmg", dmg_i);
if 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::Deadly { chance } => {
if chance >= 1.0 || shared.rng.random_bool(chance as f64) {
dmg = dmg_intrinsic
* w_dmg.0
* (1.0 + dmg_bonus.value + spammo_bonus + 5.0)
* (1.0 - armour_mitigation)
* (1.0 - def_mitigation)
* (1.0 - bonus_mitigation)
* mult
* dmg_spread;
dmg_i = dmg.round() as u32;
}
}
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;
}
}
}
}
}
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));
}
}
pub(crate) fn configure(stages: &mut Stages) {
stats::configure(stages);
status_effect::configure(stages);
stages.add_event::<ChooseWeapon>();
stages.equip.add_systems(designate_first);
stages.pre_fight.add_systems(restore_health);
stages.pre_turn.add_systems(pick_action);
stages.turn.add_systems(use_damaging_weapon);
stages
.post_turn
.add_systems((check_term_condition, change_roles))
.add_systems(
check_stalemate
.after(check_term_condition)
.before(change_roles),
);
stages.post_fight.add_systems(record_post_fight_stats);
stages
.restore
.add_systems((restore_initial_state, restore_health));
}