use bevy_ecs::prelude::*; use proxisim_models::{ bundle::{ armour::{ArmourBodyPart, ArmourBodyParts}, passive::{FactionUpgrades, Merits}, player::{ Attacker, BodyPart, ChooseWeapon, CombatTurns, Current, CurrentTarget, Defeated, Defender, FightEndType, Level, MaxHealth, PartDamageBonus, Player, PlayerStrategy, Weapons, }, stat::{ AmmoControl, Clips, CritRate, DamageBonus, Defence, Dexterity, EffectiveStat, Health, SimpleStatBundle, SimpleStatEffective, Speed, Strength, WeaponAccuracy, }, weapon::{ Ammo, DamageStat, NeedsReload, NonTargeted, RateOfFire, Usable, Uses, Weapon, WeaponSlot, }, }, hierarchy::Children, }; use rand::Rng as _; use crate::{ FightStatus, Rng, Stages, effect::Effects, log, log::Logger, metrics::Metrics, 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, Option<&Children>), With>, ) -> Option<(Entity, 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, children) = usable_q.get(id).ok()?; if !reload && needs_reload { None } else { Some((id, children.cloned())) } } fn derive_max_health( level_query: Query<(Entity, &Level, &Merits, &FactionUpgrades)>, mut cmd: Commands, ) { for (entity, level, merits, faction) in level_query.iter() { let base_life = match level.0 { 1..=8 => 100 + (level.0 - 1) * 25, 9..=95 => 275 + (level.0 - 8) * 50, 96.. => 4625 + (level.0 - 95) * 75, 0 => unreachable!(), }; let max_health = ((base_life as f32) * (1.0 + ((merits.life * 5 + faction.life) as f32) / 100.0)) as u16; cmd.entity(entity).insert(( MaxHealth(max_health), SimpleStatBundle::::new(max_health), )); } } 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.get()) { 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()) } } // 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)] pub fn use_damaging_weapon( mut rng: ResMut, weapon_q: Query< ( Entity, &DamageStat, &SimpleStatEffective, &SimpleStatEffective, &SimpleStatEffective, &Children, Has, ), (With, With, Without), >, player_q: Query< ( Entity, &EffectiveStat, &EffectiveStat, &SimpleStatEffective, &SimpleStatEffective, &SimpleStatEffective, Has, ), (With, With), >, mut target_q: Query< ( Entity, &EffectiveStat, &EffectiveStat, &ArmourBodyParts, &mut SimpleStatEffective, ), With, >, 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, )>, Query<&mut Uses>, ), (mut logger, mut commands, dmg_spread, metrics, mut effects): ( Logger, Commands, Local, Res, Effects, ), ) { let Ok((weapon, w_dmg, acc, dmg_bonus, crit, children, non_targeted)) = weapon_q.single() else { return; }; let (player, player_spd, player_str, player_crit, acc_bonus, p_dmg_bonus, attacker) = player_q.single().unwrap(); let (target, target_dex, target_def, armour_parts, mut health) = target_q.single_mut().unwrap(); if let Ok(mut uses) = temp_q.get_mut(weapon) { uses.0 -= 1; if uses.0 == 0 { 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)| { 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); (ammo, clips, rof_eff) }); 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 = (rng.random_range(rof.clone()).round() as u16).clamp(1, ammo.0); metrics.increment_counter(Some(player), "rounds_fired", rounds.into()); metrics.increment_counter(Some(weapon), "rounds_fired", rounds.into()); ammo.0 -= rounds; if ammo.0 == 0 { if clips.value == 0 { commands.entity(weapon).remove::(); } else { 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 && !rng.random_bool(hit_chance as f64) { log!(logger, "miss_target", { weapon: weapon, actor: player, recipient: target, rounds: rounds, hit_chance: hit_chance, }); metrics.increment_counter(Some(player), "miss", 1); metrics.increment_counter(Some(weapon), "miss", 1); if multi_attack_proc.is_none() { return; }; } else { let body_part = if !non_targeted { rng.sample(crit) } else { BodyPart::Stomach }; let mult = match body_part { BodyPart::Head | BodyPart::Heart | BodyPart::Throat => { metrics.increment_counter(Some(player), "crit", 1); 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, }; metrics.increment_counter(Some(player), "hit", 1); metrics.increment_counter(Some(weapon), "hit", 1); let armour_parts = armour_q.get(armour_parts.0[body_part.into()]).unwrap(); let piece = rng.sample(armour_parts); let 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 = 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.get()) { if let Some(bonus) = part_bonus.dmg_bonus(body_part) { dmg_bonus.value += bonus; } } // TODO: special ammo let dmg = dmg_intrinsic * w_dmg.0 * (1.0 + dmg_bonus.value) * (1.0 - armour_mitigation) * (1.0 - def_mitigation) * mult * dmg_spread; let dmg = dmg.round() as u32; metrics.record_histogram(Some(player), "dmg", dmg); metrics.record_histogram(Some(weapon), "dmg", dmg); if dmg > 0 { for effect in damage_proc_q.iter_many(children.get()) { match *effect { DamageProcEffect::MultiTurn { value, bonus } => { if multi_attack_proc.is_some() { continue; } let chance = (value / 100.0) as f64; if chance > 1.0 || 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(rng.random_range(2..=8))) } MultiTurnBonus::DoubleTap => { multi_attack_proc = Some(MultiAttack::DoubleTap { first_shot: true }) } }; metrics.increment_counter(Some(player), bonus.counter_label(), 1); metrics.increment_counter(Some(weapon), bonus.counter_label(), 1); } } DamageProcEffect::SelfEffect { value, bonus } => { let chance = (value / 100.0) as f64; if chance > 1.0 || rng.random_bool(chance) { bonus.spawn(player, &mut effects); } } DamageProcEffect::OpponentEffect { value, bonus } => { let chance = (value / 100.0) as f64; if chance > 1.0 || rng.random_bool(chance) { bonus.spawn(target, &mut effects, &mut rng.0); } } } } } let health_before = health.value; health.value = health.value.saturating_sub(dmg as u16); log!(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.value, dmg, dmg_spread, dmg_intrinsic, dmg_weapon: w_dmg.0, armour_mitigation, def_mitigation, bonus_dmg: dmg_bonus.value, hit_chance: hit_chance, crit_rate: crit.value, }); if health.value == 0 && !defeated { defeated = true; commands.entity(target).insert(Defeated); log!(logger, "fight_end", { actor: player, recipient: target, fight_end_type: %if attacker { FightEndType::Victory } else { FightEndType::Loss }, }); metrics.increment_counter(Some(player), "victory", 1); } } // Technically only douple tap and blindfire have this condition, but we can run into 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, &SimpleStatEffective)>, metrics: Res, ) { for (player, health) in player_q.iter() { metrics.record_histogram(Some(player), "rem_health", health.value as u32); } } 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(derive_max_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); }