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, With>, ) -> Option { 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>, defender_q: Query>, 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, With)>, target_q: Query>, mut commands: Commands, ) { let current = current_q.single().unwrap(); let target = target_q.single().unwrap(); // TODO: Group fights commands .entity(current) .remove::() .insert(CurrentTarget); // TODO: Distraction commands .entity(target) .insert(Current) .remove::(); } fn check_term_condition( mut state: ResMut, defender_q: Query<(), (With, Without)>, attacker_q: Query<(), (With, Without)>, ) { if defender_q.is_empty() || attacker_q.is_empty() { *state = FightStatus::Over; } } pub fn pick_action( p_query: Query<(Entity, &Weapons, &PlayerStrategy), (With, Without)>, usable_q: Query, With>, 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, With), >, target_q: Query>, child_q: Query>, weapon_trigger_q: Query<&TurnTriggeredEffect>, mut commands: Commands, mut effects: Effects, metrics: Res, ) { 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); 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, health: &Health, max_health: &SimpleStatEffective, ) -> 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, p_max_health: &SimpleStatEffective, part: BodyPart, dmg_bonus: &mut SimpleStatEffective, 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, &'static SimpleStatEffective, &'static SimpleStatEffective, &'static Children, &'static WeaponSlot, Has, ), (With, With, Without), >, /// The player who's using the weapon and their related stats player_q: Query< 'w, 's, ( Entity, &'static EffectiveStat, &'static EffectiveStat, &'static SimpleStatEffective, &'static SimpleStatEffective, &'static SimpleStatEffective, &'static SimpleStatEffective, &'static Children, &'static PickedAction, &'static mut Health, Has, ), (With, With), >, /// The player who is targeted with the weapon target_q: Query< 'w, 's, ( Entity, &'static EffectiveStat, &'static EffectiveStat, &'static SimpleStatEffective, &'static ArmourBodyParts, &'static Children, &'static PickedAction, &'static mut Health, ), (With, Without), >, } // 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, &RateOfFire, &SimpleStatEffective, &AmmoType, )>, Query<&mut Uses>, ), dmg_spread: Local, ) { 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::(); } } 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::(); } 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), (With, With)>, target_q: Query>, other_attackers_q: Query<(), (With, Without)>, mut state: ResMut, mut commands: Commands, mut logger: Logger, metrics: Res, ) { 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, mut player_q: Query<(Entity, &mut CombatTurns, Has)>, mut commands: Commands, ) { *state = FightStatus::Ongoing; for (player, mut turns, attacker) in player_q.iter_mut() { turns.0 = 0; commands.entity(player).remove::(); if attacker { commands .entity(player) .remove::() .insert(Current); } else { commands .entity(player) .remove::() .insert(CurrentTarget); } } } fn record_post_fight_stats(player_q: Query<(Entity, &Health)>, metrics: Res) { 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)>, 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, mut logger: Logger, mut p_query: Query< ( Entity, &SimpleStatEffective, &mut Health, Option<&ExtraStatusEffectEffectiveness<1, Hardened>>, ), (With, With), >, ) { 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, (With, With)>, picked_q: Query>, mut commands: Commands, ) { if !attacker_turn.single().unwrap() { for player in picked_q { commands.entity(player).remove::(); } } } fn remove_picked_actions(picked_q: Query>, mut commands: Commands) { for player in picked_q { commands.entity(player).remove::(); } } pub(crate) fn configure(stages: &mut Stages) { stats::configure(stages); status_effect::configure(stages); stages.add_event::(); stages.add_event::(); 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)); }