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, Option<&Children>), With>, ) -> 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>, 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( mut p_query: Query< (Entity, &Weapons, &PlayerStrategy, &mut CombatTurns), (With, With), >, target_q: Query>, usable_q: Query<(Has, Option<&Children>), With>, weapon_trigger_q: Query<&TurnTriggeredEffect>, mut commands: Commands, mut effects: Effects, metrics: Res, ) { 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); 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 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 Children, &'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 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): ( 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, &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_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::(); } } 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 { 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(); // TODO: special ammo 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), (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)); } } pub(crate) fn configure(stages: &mut Stages) { stats::configure(stages); status_effect::configure(stages); stages.add_event::(); 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)); }