From 86f9333aecca06bf232bbbd60e41f8bea87ebedf Mon Sep 17 00:00:00 2001 From: TotallyNot <44345987+TotallyNot@users.noreply.github.com> Date: Sun, 31 Dec 2023 21:26:43 +0100 Subject: [PATCH] initial commit --- .gitignore | 3 + Cargo.toml | 23 + macros/Cargo.toml | 15 + macros/src/lib.rs | 10 + macros/src/log_message.rs | 114 +++++ rust-toolchain.toml | 2 + src/armour/mod.rs | 284 +++++++++++ src/dto.rs | 361 ++++++++++++++ src/effect.rs | 299 +++++++++++ src/entity_registry.rs | 63 +++ src/hierarchy.rs | 178 +++++++ src/lib.rs | 444 +++++++++++++++++ src/log.rs | 565 +++++++++++++++++++++ src/metrics.rs | 130 +++++ src/passives.rs | 405 +++++++++++++++ src/player/mod.rs | 916 ++++++++++++++++++++++++++++++++++ src/player/stats.rs | 572 +++++++++++++++++++++ src/player/status_effect.rs | 583 ++++++++++++++++++++++ src/weapon/bonus.rs | 278 +++++++++++ src/weapon/mod.rs | 955 ++++++++++++++++++++++++++++++++++++ src/weapon/temp.rs | 249 ++++++++++ 21 files changed, 6449 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 macros/Cargo.toml create mode 100644 macros/src/lib.rs create mode 100644 macros/src/log_message.rs create mode 100644 rust-toolchain.toml create mode 100644 src/armour/mod.rs create mode 100644 src/dto.rs create mode 100644 src/effect.rs create mode 100644 src/entity_registry.rs create mode 100644 src/hierarchy.rs create mode 100644 src/lib.rs create mode 100644 src/log.rs create mode 100644 src/metrics.rs create mode 100644 src/passives.rs create mode 100644 src/player/mod.rs create mode 100644 src/player/stats.rs create mode 100644 src/player/status_effect.rs create mode 100644 src/weapon/bonus.rs create mode 100644 src/weapon/mod.rs create mode 100644 src/weapon/temp.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82b32a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +.DS_Store +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4c2297a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[workspace] +resolver = "2" +members = [".", "macros"] + +[package] +name = "proxisim" +version = "0.1.0" +edition = "2021" + +[features] +default = ["json"] +json = ["dep:serde", "dep:serde_json"] + +[dependencies] +bevy_ecs = "0.12.1" +rand = { version = "0.8.5", default-features = false, features = ["std", "alloc", "small_rng"] } +rand_distr = "0.4.3" +strum = { version = "0.25.0", features = ["derive"] } +serde = { version = "1", features = [ "derive" ], optional = true } +serde_json = { version = "1", optional = true } + +[dependencies.macros] +path = "macros" diff --git a/macros/Cargo.toml b/macros/Cargo.toml new file mode 100644 index 0000000..1b13ba7 --- /dev/null +++ b/macros/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "macros" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["parsing"] } +proc-macro2 = "1" +quote = "1" + diff --git a/macros/src/lib.rs b/macros/src/lib.rs new file mode 100644 index 0000000..9f4d175 --- /dev/null +++ b/macros/src/lib.rs @@ -0,0 +1,10 @@ +mod log_message; + +#[proc_macro_derive(LogMessage, attributes(log))] +pub fn derive_log_message(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let ast = syn::parse(input).unwrap(); + + log_message::impl_log_message(&ast) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} diff --git a/macros/src/log_message.rs b/macros/src/log_message.rs new file mode 100644 index 0000000..a4156c1 --- /dev/null +++ b/macros/src/log_message.rs @@ -0,0 +1,114 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{spanned::Spanned, Data, DeriveInput, Fields}; + +fn camel_to_snake_case(input: String) -> String { + let mut prev_lowercase = false; + let mut output = String::with_capacity(input.len()); + + for char in input.chars() { + if char.is_lowercase() { + prev_lowercase = true; + output.push(char); + } else { + if prev_lowercase { + output.push('_'); + } + prev_lowercase = false; + output.push(char.to_lowercase().next().unwrap()) + } + } + + output +} + +enum LogOption { + Default, + Display, + Debug, + Player, + Weapon, +} + +pub(crate) fn impl_log_message(ast: &DeriveInput) -> syn::Result { + let Data::Struct(r#struct) = &ast.data else { + return Err(syn::Error::new( + ast.ident.span(), + "LogMessage can only be derived for structs", + )); + }; + + let Fields::Named(named_fields) = &r#struct.fields else { + return Err(syn::Error::new( + ast.span(), + "LogMessage cannot be derived for tuple structs", + )); + }; + + let mut fields = Vec::with_capacity(named_fields.named.len()); + for field in &named_fields.named { + let mut option = None; + for attr in &field.attrs { + if attr.path().is_ident("log") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("display") { + option = Some(LogOption::Display); + Ok(()) + } else if meta.path.is_ident("debug") { + option = Some(LogOption::Debug); + Ok(()) + } else if meta.path.is_ident("player") { + option = Some(LogOption::Player); + Ok(()) + } else if meta.path.is_ident("weapon") { + option = Some(LogOption::Weapon); + Ok(()) + } else { + Err(meta.error("Unrecognised attribute")) + } + })?; + } + } + + let option = option.unwrap_or(LogOption::Default); + + let field_name = field.ident.as_ref().unwrap(); + let name = field_name.to_string(); + + let getter = match option { + LogOption::Default => quote! { + (#name, crate::log::LogValue::from(self.#field_name.clone())) + }, + LogOption::Display => quote! { + (#name, crate::log::LogValue::Display(&self.#field_name)) + }, + LogOption::Debug => quote! { + (#name, crate::log::LogValue::Debug(&self.#field_name)) + }, + LogOption::Player => quote! { + (#name, crate::log::LogValue::Player(self.#field_name)) + }, + LogOption::Weapon => quote! { + (#name, crate::log::LogValue::Weapon(self.#field_name)) + }, + }; + + fields.push(getter); + } + + let name = &ast.ident; + + let tag = camel_to_snake_case(name.to_string()); + + let gen = quote! { + impl crate::log::LogMessage for #name { + fn tag(&self) -> &'static str { #tag } + + fn entries<'a>(&'a self) -> Vec<(&'static str, crate::log::LogValue<'a>)> { + vec![#(#fields,)*] + } + } + }; + + Ok(gen) +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/armour/mod.rs b/src/armour/mod.rs new file mode 100644 index 0000000..b139710 --- /dev/null +++ b/src/armour/mod.rs @@ -0,0 +1,284 @@ +use bevy_ecs::prelude::*; +use rand::distributions::Distribution; +use strum::IntoEnumIterator; + +use crate::{ + hierarchy::HierarchyBuilder, + player::{ + status_effect::{ + ConcussionGrenade, FlashGrenade, PepperSpray, Sand, TearGas, TempDebuffImmunity, + }, + BodyPart, + }, + Name, Stages, +}; + +#[derive(Component, Default)] +pub struct Armour; + +#[derive(Debug)] +pub struct BodyPartCoverage { + pub armour: Entity, + pub coverage: f32, + pub armour_value: f32, +} + +#[derive(Component, Default)] +pub struct ArmourCoverage(ArmourVec); + +#[derive(Component, Default)] +pub struct ArmourValue(pub f32); + +#[derive(Component)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum Set { + Riot, + Assault, + Dune, + Delta, + Vanguard, + Sentinel, + Marauder, + Eod, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum Immunity { + Radiation, + TearGas, + PepperSpray, + NerveGas, + Sand, + ConcussionGrenades, + FlashGrenades, +} + +#[derive(Component)] +pub struct Immunities(pub Vec); + +#[derive(Bundle, Default)] +pub struct ArmourBundle { + pub name: Name, + pub armour: Armour, + pub coverage: ArmourCoverage, + pub armour_value: ArmourValue, +} + +impl ArmourBundle { + pub fn new(name: String, coverage: [f32; 10], armour_value: f32) -> Self { + Self { + name: Name(name), + armour: Armour, + coverage: ArmourCoverage(ArmourVec(coverage)), + armour_value: ArmourValue(armour_value / 100.0), + } + } +} + +#[derive(Component, Default, Debug)] +pub struct ArmourBodyPart { + pub armour_pieces: Vec, +} + +#[derive(Component, Default)] +pub struct EquippedArmour { + pub head: Option, + pub body: Option, + pub legs: Option, + pub feet: Option, + pub hands: Option, +} + +#[derive(Component, Debug)] +pub struct ArmourBodyParts(pub ArmourVec); + +enum ArmourIterState { + Head, + Body, + Legs, + Feet, + Hands, +} + +pub struct ArmourIter<'a> { + state: ArmourIterState, + equipped_armour: &'a EquippedArmour, +} + +impl<'a> Iterator for ArmourIter<'a> { + type Item = Entity; + + fn next(&mut self) -> Option { + loop { + let (next, piece) = match self.state { + ArmourIterState::Head => (ArmourIterState::Body, self.equipped_armour.head), + ArmourIterState::Body => (ArmourIterState::Legs, self.equipped_armour.body), + ArmourIterState::Legs => (ArmourIterState::Feet, self.equipped_armour.legs), + ArmourIterState::Feet => (ArmourIterState::Hands, self.equipped_armour.feet), + ArmourIterState::Hands => return self.equipped_armour.hands, + }; + + self.state = next; + if piece.is_some() { + return piece; + } + } + } +} + +impl<'a> IntoIterator for &'a EquippedArmour { + type Item = Entity; + + type IntoIter = ArmourIter<'a>; + + fn into_iter(self) -> Self::IntoIter { + ArmourIter { + state: ArmourIterState::Head, + equipped_armour: self, + } + } +} + +#[repr(usize)] +#[derive(Clone, Copy, strum::EnumIter)] +pub enum ArmourBodyPartSlot { + Arms, + Stomach, + Heart, + Chest, + Throat, + Hands, + Groin, + Legs, + Head, + Feet, +} + +impl From for ArmourBodyPartSlot { + fn from(value: BodyPart) -> Self { + match value { + BodyPart::LeftArm | BodyPart::RightArm => Self::Arms, + BodyPart::Stomach => Self::Stomach, + BodyPart::Heart => Self::Heart, + BodyPart::Chest => Self::Chest, + BodyPart::Throat => Self::Throat, + BodyPart::LeftHand | BodyPart::RightHand => Self::Hands, + BodyPart::Groin => Self::Groin, + BodyPart::LeftLeg | BodyPart::RightLeg => Self::Legs, + BodyPart::Head => Self::Head, + BodyPart::LeftFoot | BodyPart::RightFoot => Self::Feet, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ArmourVec([T; 10]); + +impl std::ops::Index for ArmourVec { + type Output = T; + + fn index(&self, index: ArmourBodyPartSlot) -> &Self::Output { + &self.0[index as usize] + } +} + +impl std::ops::IndexMut for ArmourVec { + fn index_mut(&mut self, index: ArmourBodyPartSlot) -> &mut Self::Output { + &mut self.0[index as usize] + } +} + +impl IntoIterator for ArmourVec { + type Item = T; + + type IntoIter = std::array::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> Distribution> for &'a ArmourBodyPart { + fn sample(&self, rng: &mut R) -> Option<&'a BodyPartCoverage> { + let mut current = None; + for piece in &self.armour_pieces { + // NOTE: This is not strictly speaking correct, but the edge cases where this applies + // should be very rare, and it should be a decent enough heuristic to barely make a + // difference from the actual pixel comparisons that torn seems to be using for this. + if current + .map(|c: &BodyPartCoverage| c.armour_value) + .unwrap_or_default() + < piece.armour_value + && rng.gen_bool(piece.coverage as f64) + { + current = Some(piece); + } + } + current + } +} + +fn generate_body_parts( + equip_q: Query<(Entity, &EquippedArmour)>, + armour_q: Query<(Entity, &ArmourCoverage, &ArmourValue, Option<&Immunities>)>, + mut commands: Commands, +) { + for (player, equipped_armour) in equip_q.iter() { + let mut parts = ArmourVec::::default(); + + for (armour, coverage, armour_value, immunities) in armour_q.iter_many(equipped_armour) { + commands.entity(armour).set_parent(player); + + if let Some(immunities) = immunities { + let mut player = commands.entity(player); + for immunity in &immunities.0 { + match immunity { + // TODO: Going to need this if irradiate is ever added + Immunity::Radiation => (), + // NOTE: It's an unreleased DOT temp, so the exact effect is currently + // unknwown + Immunity::NerveGas => (), + Immunity::TearGas => { + player.insert(TempDebuffImmunity::::default()); + } + Immunity::PepperSpray => { + player.insert(TempDebuffImmunity::::default()); + } + Immunity::FlashGrenades => { + player.insert(TempDebuffImmunity::::default()); + } + Immunity::Sand => { + player.insert(TempDebuffImmunity::::default()); + } + Immunity::ConcussionGrenades => { + player.insert(TempDebuffImmunity::::default()); + } + } + } + } + + for slot in ArmourBodyPartSlot::iter() { + if coverage.0[slot] > 0.0 { + parts[slot].armour_pieces.push(BodyPartCoverage { + armour, + coverage: coverage.0[slot] / 100.0, + armour_value: armour_value.0, + }); + } + } + } + + let parts = parts.0.map(|p| commands.spawn(p).id()); + + commands + .entity(player) + .add_children(parts) + .insert(ArmourBodyParts(ArmourVec(parts))); + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.equip.add_systems(generate_body_parts); +} diff --git a/src/dto.rs b/src/dto.rs new file mode 100644 index 0000000..e72b76e --- /dev/null +++ b/src/dto.rs @@ -0,0 +1,361 @@ +use bevy_ecs::prelude::*; + +use crate::entity_registry::EntityInfo; +pub use crate::passives::{DrugCooldown, Education, FactionUpgrades, Merits}; +pub use crate::player::PlayerStrategy; +use crate::weapon::Japanese; +pub use crate::weapon::{bonus::WeaponBonus, temp::Temp, WeaponCategory, WeaponMod, WeaponSlot}; +use crate::{ + armour::EquippedArmour, + passives::PassiveBundle, + player::PlayerBundle, + weapon::{AmmoWeaponBundle, DamagingWeaponBundle, WeaponBundle, WeaponVerb}, +}; +use crate::{ + armour::{ArmourBundle, Immunities, Immunity, Set}, + weapon::bonus::WeaponBonusBundle, +}; +use crate::{ + hierarchy::HierarchyBuilder, + player::stats::{Defence, Dexterity, Speed, StatBundle, Strength}, +}; + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct Stats { + pub str: f32, + pub def: f32, + pub spd: f32, + pub dex: f32, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct WeaponAmmo { + pub clips: u16, + pub clip_size: u16, + pub rate_of_fire: [u16; 2], +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct WeaponBonusInfo { + pub bonus: WeaponBonus, + pub value: f32, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct Weapon { + pub name: String, + pub cat: WeaponCategory, + pub dmg: f32, + pub acc: f32, + pub ammo: WeaponAmmo, + pub mods: Vec, + pub bonuses: Vec, + pub experience: f32, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct MeleeWeapon { + pub name: String, + pub cat: WeaponCategory, + pub japanese: bool, + pub dmg: f32, + pub acc: f32, + pub bonuses: Vec, + pub experience: f32, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[derive(Default)] +pub struct Weapons { + pub primary: Option, + pub secondary: Option, + pub melee: Option, + pub temp: Option, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[derive(Default)] +pub struct Armour { + pub armour_value: f32, + pub name: String, + pub coverage: [f32; 10], + pub immunities: Vec, + pub set: Option, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[derive(Default)] +pub struct ArmourPieces { + pub helmet: Option, + pub body: Option, + pub pants: Option, + pub gloves: Option, + pub boots: Option, +} + +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct Player { + pub name: String, + pub id: usize, + pub level: u16, + pub stats: Stats, + pub merits: Option, + pub education: Option, + pub weapons: Weapons, + pub armour: ArmourPieces, + pub faction: Option, + pub drug: Option, + pub strategy: PlayerStrategy, +} + +#[cfg_attr(feature = "json", derive(serde::Serialize))] +#[derive(Debug)] +pub struct Counter { + pub value: u64, + pub entity: EntityInfo, + pub label: &'static str, +} + +#[cfg_attr(feature = "json", derive(serde::Serialize))] +#[derive(Debug)] +pub struct Histogram { + pub values: Vec, + pub entity: EntityInfo, + pub label: &'static str, +} + +impl Player { + pub(crate) fn spawn(self, world: &mut World) -> EntityWorldMut<'_> { + let primary = self.weapons.primary.map(|w| { + let primary = world + .spawn(( + WeaponBundle::new( + w.name, + self.id * 100 + 3, + WeaponVerb::Fired, + WeaponSlot::Primary, + ), + DamagingWeaponBundle::new(w.dmg, w.acc, w.mods, w.experience, w.cat), + AmmoWeaponBundle::new(w.ammo.clips, w.ammo.clip_size, w.ammo.rate_of_fire), + )) + .id(); + + if !w.bonuses.is_empty() { + for bonus in w.bonuses { + world + .spawn(WeaponBonusBundle::new(bonus.bonus, bonus.value)) + .set_parent(primary); + } + } + + primary + }); + + let secondary = self.weapons.secondary.map(|w| { + let secondary = world + .spawn(( + WeaponBundle::new( + w.name, + self.id * 100 + 4, + WeaponVerb::Fired, + WeaponSlot::Secondary, + ), + DamagingWeaponBundle::new(w.dmg, w.acc, w.mods, w.experience, w.cat), + AmmoWeaponBundle::new(w.ammo.clips, w.ammo.clip_size, w.ammo.rate_of_fire), + )) + .id(); + + if !w.bonuses.is_empty() { + for bonus in w.bonuses { + world + .spawn(WeaponBonusBundle::new(bonus.bonus, bonus.value)) + .set_parent(secondary); + } + } + + secondary + }); + + let melee = self.weapons.melee.map(|w| { + let mut melee = world.spawn(( + WeaponBundle::new( + w.name, + self.id * 100 + 5, + WeaponVerb::Hit, + WeaponSlot::Melee, + ), + DamagingWeaponBundle::new(w.dmg, w.acc, Vec::default(), w.experience, w.cat), + )); + if w.japanese { + melee.insert(Japanese); + } + let melee = melee.id(); + + if !w.bonuses.is_empty() { + for bonus in w.bonuses { + world + .spawn(WeaponBonusBundle::new(bonus.bonus, bonus.value)) + .set_parent(melee); + } + } + + melee + }); + + let temporary = self + .weapons + .temp + .map(|t| t.spawn(world, self.id * 100 + 2).id()); + + let fists = world + .spawn(( + WeaponBundle::fists(self.id * 100), + DamagingWeaponBundle::fists(), + )) + .id(); + + let kick = world + .spawn(( + WeaponBundle::kick(self.id * 100 + 1), + DamagingWeaponBundle::kick(), + )) + .id(); + + let weapons = crate::player::Weapons { + primary, + secondary, + melee, + temporary, + fists: Some(fists), + kick: Some(kick), + }; + + let helmet = self.armour.helmet.map(|h| { + let mut helmet = world.spawn(ArmourBundle::new(h.name, h.coverage, h.armour_value)); + if let Some(set) = h.set { + helmet.insert(set); + } + if !h.immunities.is_empty() { + helmet.insert(Immunities(h.immunities)); + } + helmet.id() + }); + + let body = self.armour.body.map(|b| { + let mut body = world.spawn(ArmourBundle::new(b.name, b.coverage, b.armour_value)); + if let Some(set) = b.set { + body.insert(set); + } + body.id() + }); + + let pants = self.armour.pants.map(|p| { + let mut pants = world.spawn(ArmourBundle::new(p.name, p.coverage, p.armour_value)); + if let Some(set) = p.set { + pants.insert(set); + } + pants.id() + }); + + let gloves = self.armour.gloves.map(|g| { + let mut gloves = world.spawn(ArmourBundle::new(g.name, g.coverage, g.armour_value)); + if let Some(set) = g.set { + gloves.insert(set); + } + gloves.id() + }); + + let boots = self.armour.boots.map(|b| { + let mut boots = world.spawn(ArmourBundle::new(b.name, b.coverage, b.armour_value)); + if let Some(set) = b.set { + boots.insert(set); + } + boots.id() + }); + + let armour = EquippedArmour { + head: helmet, + body, + legs: pants, + hands: gloves, + feet: boots, + }; + + let mut player = world.spawn(( + PlayerBundle::new(self.name, self.id, self.level, self.strategy), + StatBundle::::new(self.stats.str), + StatBundle::::new(self.stats.def), + StatBundle::::new(self.stats.spd), + StatBundle::::new(self.stats.dex), + weapons, + armour, + PassiveBundle { + merits: self.merits.unwrap_or_default(), + education: self.education.unwrap_or_default(), + faction: self.faction.unwrap_or_default(), + }, + )); + + if let Some(drug) = self.drug { + player.insert(drug); + } + + player + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_spawn_player() { + let mut world = World::new(); + + let player = Player { + name: "Test".to_owned(), + id: 0, + level: 10, + stats: Stats { + str: 10.0, + def: 10.0, + spd: 10.0, + dex: 10.0, + }, + merits: Default::default(), + education: Default::default(), + faction: Default::default(), + drug: None, + strategy: PlayerStrategy::AlwaysFists, + weapons: Weapons { + primary: Some(Weapon { + name: "Test".to_owned(), + cat: WeaponCategory::Rifle, + dmg: 50.0, + acc: 50.0, + ammo: WeaponAmmo { + clips: 3, + clip_size: 25, + rate_of_fire: [3, 5], + }, + mods: Vec::default(), + bonuses: Vec::default(), + experience: 100.0, + }), + ..Default::default() + }, + armour: ArmourPieces { + helmet: Some(Armour { + name: "Test".to_owned(), + armour_value: 50.0, + coverage: [0.0, 0.0, 0.0, 0.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0], + immunities: Vec::default(), + set: None, + }), + ..Default::default() + }, + }; + + player.spawn(&mut world); + } +} diff --git a/src/effect.rs b/src/effect.rs new file mode 100644 index 0000000..acea7db --- /dev/null +++ b/src/effect.rs @@ -0,0 +1,299 @@ +use std::{any::TypeId, collections::HashMap, sync::Mutex}; + +use bevy_ecs::{prelude::*, system::SystemParam}; + +use crate::{ + hierarchy::{HierarchyBuilder, Parent}, + player::{Current, Defender, Player}, + Stages, +}; + +const SECONDS_PER_ACTION: f32 = 1.1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Component)] +pub struct EffectId(usize); + +pub struct EffectInfo { + pub apply: Box, Out = ()>>, + pub teardown: Box, Out = ()>>, +} + +// TODO: remove need for unsafe code by splitting the system info from this struct +#[derive(Resource, Default)] +struct EffectRegistry { + system_info: HashMap, + type_map: HashMap, + id_counter: usize, +} + +#[derive(Resource, Default)] +struct ScheduledEffects { + create: Mutex>>, + teardown: Mutex>>, +} + +#[derive(Component)] +pub struct TurnLimitedEffect { + pub player: Entity, + pub turns: u16, +} + +impl TurnLimitedEffect { + pub fn new(player: Entity, turns: u16) -> Self { + Self { player, turns } + } +} + +#[derive(Component)] +pub struct TimeLimitedEffect(pub f32); + +#[derive(Component)] +struct Timestamp(f32); + +#[derive(Component)] +struct Permanent; + +#[derive(Resource, Default)] +pub struct Clock(pub f32); + +/// Marker for effects that last until the beginning of the next round +#[derive(Component)] +pub struct FullRoundEffect; + +#[derive(SystemParam)] +pub struct Effects<'w, 's> { + registry: Res<'w, EffectRegistry>, + scheduled: Res<'w, ScheduledEffects>, + effect_q: Query<'w, 's, &'static EffectId>, + commands: Commands<'w, 's>, +} + +// TODO: Schedule effects using commands in order to avoid the need for having a separate +// `Schedule` dedicated to them. +impl<'w, 's> Effects<'w, 's> { + pub fn spawn_and_insert( + &mut self, + effect: T, + to: Entity, + addins: impl Bundle, + ) -> Entity { + let id = self.registry.type_map.get(&TypeId::of::()).unwrap(); + + let spawned_effect = self + .commands + .spawn(effect) + .insert(*id) + .insert(addins) + .set_parent(to) + .id(); + + self.scheduled + .create + .lock() + .unwrap() + .entry(*id) + .or_default() + .push(spawned_effect); + + spawned_effect + } + + pub fn spawn(&mut self, effect: T, to: Entity) -> Entity { + self.spawn_and_insert(effect, to, ()) + } + + pub fn remove(&mut self, entity: Entity) { + let id = self.effect_q.get(entity).unwrap(); + self.scheduled + .teardown + .lock() + .unwrap() + .entry(*id) + .or_default() + .push(entity); + } +} + +pub struct EffectBuilder<'w> { + world: &'w mut World, + apply: Option, Out = ()>>>, + teardown: Option, Out = ()>>>, + id: EffectId, +} + +impl<'r> EffectBuilder<'r> { + #[must_use] + pub fn apply(&mut self, system: S) -> &mut Self + where + S: IntoSystem, (), A> + 'static, + { + let mut system = IntoSystem::into_system(system); + system.initialize(self.world); + self.apply = Some(Box::new(system)); + self + } + + #[must_use] + pub fn teardown(&mut self, system: S) -> &mut Self + where + S: IntoSystem, (), T> + 'static, + { + let mut system = IntoSystem::into_system(system); + system.initialize(self.world); + self.teardown = Some(Box::new(system)); + self + } + + pub fn build(&mut self) { + let mut registry = self.world.resource_mut::(); + registry.system_info.insert( + self.id, + EffectInfo { + apply: self.apply.take().unwrap(), + teardown: self.teardown.take().unwrap(), + }, + ); + } +} + +pub(crate) fn run_effects(world: &mut World) { + loop { + let (next_effect, entities) = { + let scheduled = world.resource_mut::(); + let mut create = scheduled.create.lock().unwrap(); + let Some(next_effect) = create.keys().next().copied() else { + break; + }; + let entities = create.remove(&next_effect).unwrap(); + + (next_effect, entities) + }; + + // safety: since the registry can't be mutably accessed by effect handlers this should be + // fine + // ...probably + unsafe { + let unsafe_world = world.as_unsafe_world_cell(); + let mut registry = unsafe_world.get_resource_mut::().unwrap(); + let info = registry.system_info.get_mut(&next_effect).unwrap(); + info.apply.run(entities, unsafe_world.world_mut()); + info.apply.apply_deferred(unsafe_world.world_mut()); + }; + } + + loop { + let (next_effect, entities) = { + let scheduled = world.resource_mut::(); + let mut teardown = scheduled.teardown.lock().unwrap(); + let Some(next_effect) = teardown.keys().next().copied() else { + break; + }; + let entities = teardown.remove(&next_effect).unwrap(); + + (next_effect, entities) + }; + + unsafe { + let unsafe_world = world.as_unsafe_world_cell(); + let mut registry = unsafe_world.get_resource_mut::().unwrap(); + let info = registry.system_info.get_mut(&next_effect).unwrap(); + info.teardown + .run(entities.clone(), unsafe_world.world_mut()); + info.teardown.apply_deferred(unsafe_world.world_mut()); + }; + + for entity in entities { + world.despawn(entity); + } + } +} + +#[must_use] +pub(crate) fn register_effect(stages: &mut Stages) -> EffectBuilder { + let mut registry = stages.world.resource_mut::(); + let id = EffectId(registry.id_counter); + registry.id_counter += 1; + registry.type_map.insert(TypeId::of::(), id); + + EffectBuilder { + world: &mut stages.world, + apply: None, + teardown: None, + id, + } +} + +fn update_round_limited_effects( + mut effect_q: Query<(Entity, &mut TurnLimitedEffect)>, + current_q: Query>, + mut effects: Effects, +) { + for (entity, mut effect) in effect_q.iter_mut() { + if !current_q.get(effect.player).unwrap() { + continue; + } + + if effect.turns == 0 { + effects.remove(entity); + continue; + } + effect.turns -= 1; + } +} + +fn advance_clock( + mut clock: ResMut, + current_q: Query, (With, With)>, +) { + let is_defender = current_q.single(); + if !is_defender { + clock.0 += SECONDS_PER_ACTION; + } +} + +fn update_time_limited_effects( + new_effects_q: Query>, + active_effects_q: Query<(Entity, &Timestamp, &TimeLimitedEffect)>, + mut commands: Commands, + mut effects: Effects, + clock: Res, +) { + for entity in new_effects_q.iter() { + commands.entity(entity).insert(Timestamp(clock.0)); + } + + for (entity, timestamp, effect) in active_effects_q.iter() { + if (timestamp.0 + effect.0) < clock.0 { + effects.remove(entity); + } + } +} + +fn mark_permanent_effects(effect_q: Query>, mut commands: Commands) { + for effect in effect_q.iter() { + commands.entity(effect).insert(Permanent); + } +} + +fn remove_transient_effects( + effect_q: Query<(Entity, &Parent), (With, Without)>, + mut commands: Commands, +) { + for (effect, target) in effect_q.iter() { + commands.entity(effect).despawn(); + commands.entity(target.get()).remove_child(effect); + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.world.init_resource::(); + stages.world.init_resource::(); + stages.world.init_resource::(); + + stages.snapshot.add_systems(mark_permanent_effects); + stages + .pre_turn + .add_systems((advance_clock, update_round_limited_effects)); + stages.post_turn.add_systems(update_time_limited_effects); + stages.restore.add_systems(remove_transient_effects); +} diff --git a/src/entity_registry.rs b/src/entity_registry.rs new file mode 100644 index 0000000..e21d846 --- /dev/null +++ b/src/entity_registry.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; + +use bevy_ecs::prelude::*; + +use crate::{ + hierarchy::Parent, + player::{Attacker, Player}, + weapon::Weapon, + Id, Name, Stages, +}; + +#[cfg_attr(feature = "json", derive(serde::Serialize))] +#[cfg_attr(feature = "json", serde(tag = "type", rename_all = "snake_case"))] +#[derive(Debug, Clone)] +pub enum EntityInfo { + Player { + name: String, + id: usize, + is_attacker: bool, + }, + Weapon { + name: String, + owner: usize, + id: usize, + }, +} + +#[derive(Resource, Default)] +pub struct EntityRegistry(pub HashMap); + +fn read_entities( + player_q: Query<(Entity, &Name, &Id, Has), With>, + weapon_q: Query<(Entity, &Parent, &Name, &Id), With>, + mut registry: ResMut, +) { + for (player, name, id, is_attacker) in player_q.iter() { + registry.0.insert( + player, + EntityInfo::Player { + name: name.0.clone(), + id: id.0, + is_attacker, + }, + ); + } + + for (weapon, player, name, id) in weapon_q.iter() { + let (_, _, player_id, _) = player_q.get(player.get()).unwrap(); + registry.0.insert( + weapon, + EntityInfo::Weapon { + name: name.0.clone(), + owner: player_id.0, + id: id.0, + }, + ); + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.world.init_resource::(); + stages.snapshot.add_systems(read_entities); +} diff --git a/src/hierarchy.rs b/src/hierarchy.rs new file mode 100644 index 0000000..9252b85 --- /dev/null +++ b/src/hierarchy.rs @@ -0,0 +1,178 @@ +use bevy_ecs::{ + prelude::*, + system::{Command, EntityCommands}, +}; + +#[derive(Component, Clone, Copy)] +pub struct Parent(Entity); + +impl Parent { + pub fn get(&self) -> Entity { + self.0 + } +} + +#[derive(Component, Clone, Default)] +pub struct Children(Vec); + +impl Children { + pub fn get(&self) -> &[Entity] { + &self.0 + } +} + +struct AddChild { + parent: Entity, + child: Entity, +} + +impl Command for AddChild { + fn apply(self, world: &mut World) { + let mut parent = world.entity_mut(self.parent); + if let Some(mut children) = parent.get_mut::() { + children.0.push(self.child); + } else { + parent.insert(Children(vec![self.child])); + } + } +} + +struct AddChildren { + parent: Entity, + children: Vec, +} + +impl Command for AddChildren { + fn apply(mut self, world: &mut World) { + let mut parent = world.entity_mut(self.parent); + if let Some(mut children) = parent.get_mut::() { + children.0.append(&mut self.children); + } else { + parent.insert(Children(self.children)); + } + } +} + +struct RemoveChild { + parent: Entity, + child: Entity, +} + +impl Command for RemoveChild { + fn apply(self, world: &mut World) { + let mut parent = world.entity_mut(self.parent); + let mut children = parent + .get_mut::() + .expect("Parent component has no children"); + children.0.retain(|child| *child != self.child); + } +} + +pub trait HierarchyBuilder { + fn add_child(&mut self, child: Entity) -> &mut Self; + + fn add_children(&mut self, children: impl AsRef<[Entity]>) -> &mut Self; + + fn remove_child(&mut self, child: Entity) -> &mut Self; + + fn set_parent(&mut self, parent: Entity) -> &mut Self; +} + +impl<'w, 's, 'a> HierarchyBuilder for EntityCommands<'w, 's, 'a> { + fn add_child(&mut self, child: Entity) -> &mut Self { + let parent = self.id(); + self.commands().add(AddChild { parent, child }); + self.commands().entity(child).insert(Parent(parent)); + self + } + + fn add_children(&mut self, children: impl AsRef<[Entity]>) -> &mut Self { + let children = children.as_ref(); + let parent = self.id(); + self.commands().add(AddChildren { + parent, + children: children.to_owned(), + }); + self.commands().insert_or_spawn_batch( + children + .iter() + .map(|e| (*e, Parent(parent))) + .collect::>(), + ); + self + } + + fn remove_child(&mut self, child: Entity) -> &mut Self { + let parent = self.id(); + self.commands().add(RemoveChild { parent, child }); + self.commands().entity(child).remove::(); + self + } + + fn set_parent(&mut self, parent: Entity) -> &mut Self { + let child = self.id(); + self.commands().add(AddChild { parent, child }); + self.commands().entity(child).insert(Parent(parent)); + self + } +} + +impl<'w> HierarchyBuilder for EntityWorldMut<'w> { + fn add_child(&mut self, child: Entity) -> &mut Self { + let parent_id = self.id(); + unsafe { + self.world_mut() + .entity_mut(child) + .insert(Parent(parent_id)) + .update_location(); + } + if let Some(mut children) = self.get_mut::() { + children.0.push(child); + self + } else { + self.insert(Children(vec![child])) + } + } + + fn add_children(&mut self, children: impl AsRef<[Entity]>) -> &mut Self { + let parent_id = self.id(); + unsafe { + for child in children.as_ref() { + self.world_mut() + .entity_mut(*child) + .insert(Parent(parent_id)) + .update_location(); + } + } + if let Some(mut old_children) = self.get_mut::() { + old_children.0.append(&mut children.as_ref().to_owned()); + self + } else { + self.insert(Children(children.as_ref().to_owned())) + } + } + + fn remove_child(&mut self, child: Entity) -> &mut Self { + unsafe { + self.world_mut() + .entity_mut(child) + .remove::() + .update_location(); + } + if let Some(mut children) = self.get_mut::() { + children.0.retain(|c| *c != child); + } + self + } + + fn set_parent(&mut self, parent: Entity) -> &mut Self { + let child_id = self.id(); + unsafe { + self.world_mut() + .entity_mut(parent) + .add_child(child_id) + .update_location() + } + self + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..c1a3243 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,444 @@ +#![warn(clippy::perf, clippy::style, clippy::all)] +#![allow(clippy::type_complexity)] +use bevy_ecs::{prelude::*, schedule::ScheduleLabel}; +use effect::{register_effect, EffectBuilder}; +use metrics::Metrics; +use rand::SeedableRng; + +use crate::{ + log::{Log, Logging}, + player::{Attacker, Current, Defender}, +}; + +mod armour; +pub mod dto; +mod effect; +mod entity_registry; +mod hierarchy; +pub mod log; +mod metrics; +mod passives; +mod player; +mod weapon; + +#[derive(Component, Debug, Default)] +struct Name(String); + +#[derive(Component, Debug, Default)] +struct Id(usize); + +// TODO: This is a bottleneck, so probably better to change this to a `Local` or use `thread_rng` +// instead. Then again, the whole simulator isn't very parallelisable, so it may be a moot point +#[derive(Resource)] +struct Rng(pub rand::rngs::SmallRng); + +impl std::ops::Deref for Rng { + type Target = rand::rngs::SmallRng; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl std::ops::DerefMut for Rng { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[derive(Resource, Debug, PartialEq, Eq)] +enum FightStatus { + Ongoing, + Over, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ScheduleLabel)] +enum Stage { + Equip, + Snapshot, + PreFight, + PreTurn, + Turn, + PostTurn, + PostFight, + Restore, +} + +struct Stages { + equip: Schedule, + snapshot: Schedule, + pre_fight: Schedule, + pre_turn: Schedule, + turn: Schedule, + post_turn: Schedule, + post_fight: Schedule, + restore: Schedule, + world: World, +} + +impl Stages { + fn add_event(&mut self) -> &mut Self + where + T: Event, + { + if !self.world.contains_resource::>() { + self.world.init_resource::>(); + self.pre_turn.add_systems( + bevy_ecs::event::event_update_system:: + .run_if(bevy_ecs::event::event_update_condition::), + ); + } + self + } + + fn register_effect(&mut self) -> EffectBuilder { + register_effect::(self) + } +} + +pub struct Simulation(Stages); + +impl Simulation { + pub fn new(attacker: dto::Player, defender: dto::Player) -> Self { + let world = World::new(); + let mut stages = Stages { + equip: Schedule::new(Stage::Equip), + snapshot: Schedule::new(Stage::Snapshot), + pre_fight: Schedule::new(Stage::PreFight), + pre_turn: Schedule::new(Stage::PreTurn), + turn: Schedule::new(Stage::Turn), + post_turn: Schedule::new(Stage::PostTurn), + post_fight: Schedule::new(Stage::PostFight), + restore: Schedule::new(Stage::Restore), + world, + }; + + metrics::configure(&mut stages); + effect::configure(&mut stages); + player::configure(&mut stages); + passives::configure(&mut stages); + weapon::configure(&mut stages); + armour::configure(&mut stages); + log::configure(&mut stages); + entity_registry::configure(&mut stages); + + stages.world.insert_resource(FightStatus::Ongoing); + stages + .world + .insert_resource(Rng(rand::rngs::SmallRng::from_entropy())); + + stages.world.insert_resource(Logging(true)); + + attacker + .spawn(&mut stages.world) + .insert((Attacker, Current)); + + defender.spawn(&mut stages.world).insert(Defender); + + stages.equip.run(&mut stages.world); + stages.pre_fight.run(&mut stages.world); + effect::run_effects(&mut stages.world); + stages.snapshot.run(&mut stages.world); + + Self(stages) + } + + pub fn set_logging(&mut self, logging: bool) { + self.0.world.insert_resource(Logging(logging)); + } + + pub fn truncate_log(&mut self) { + let mut log = self.0.world.resource_mut::(); + log.entries.clear(); + } + + pub fn set_metrics(&mut self, recording: bool) { + let mut metrics = self.0.world.resource_mut::(); + metrics.active = recording; + } + + pub fn consume_metrics(&mut self) -> (Vec, Vec) { + metrics::consume_metrics(&self.0.world) + } + + pub fn run_once(&mut self) { + loop { + self.0.pre_turn.run(&mut self.0.world); + effect::run_effects(&mut self.0.world); + self.0.turn.run(&mut self.0.world); + effect::run_effects(&mut self.0.world); + self.0.post_turn.run(&mut self.0.world); + effect::run_effects(&mut self.0.world); + let state = self.0.world.resource::(); + if *state == FightStatus::Over { + break; + } + } + self.0.post_fight.run(&mut self.0.world); + self.0.restore.run(&mut self.0.world); + } + + #[cfg(feature = "json")] + pub fn read_log(&self) -> serde_json::Value { + let log = self.0.world.resource::(); + log.to_value() + } +} + +/* fn main() { + let attacker = dto::Player { + name: "Pyrit".to_owned(), + level: 100, + stats: dto::Stats { + str: 1_035_562_970.0, + def: 1_309_681_178.0, + spd: 1_035_547_487.0, + dex: 339_651_454.0, + }, + merits: dto::Merits { + life: 10, + crits: 10, + brawn: 10, + protection: 10, + sharpness: 10, + ..Default::default() + }, + + education: dto::Education::default(), + faction: dto::FactionUpgrades { + def: 14, + dex: 14, + side_effects: 10, + ..Default::default() + }, + drug: Some(dto::DrugCooldown::Xanax), + weapons: dto::Weapons { + primary: Some(dto::Weapon { + name: "ArmaLite M-15A4".to_owned(), + cat: dto::WeaponCategory::Rifle, + dmg: 68.73, + acc: 59.41, + mods: vec![dto::WeaponMod::AcogSight], + ammo: dto::WeaponAmmo { + clips: 3, + clip_size: 15, + rate_of_fire: [3, 5], + }, + experience: 100.0, + }), + melee: Some(dto::MeleeWeapon { + name: "Pillow".to_owned(), + cat: dto::WeaponCategory::Club, + japanese: false, + dmg: 1.18, + acc: 64.41, + experience: 100.0, + }), + temp: Some(dto::Temp::PepperSpray), + ..Default::default() + }, + strategy: dto::PlayerStrategy::InOrder { + order: vec![ + // crate::dto::WeaponSlot::Melee, + // crate::dto::WeaponSlot::Temporary, + // crate::dto::WeaponSlot::Primary, + ], + reload: true, + }, + }; + + let defender = dto::Player { + name: "olesien".to_owned(), + level: 100, + stats: dto::Stats { + str: 1_101_841_257.0, + def: 745_915_274.0, + spd: 1_218_894_919.0, + dex: 1_301_489_826.0, + }, + merits: dto::Merits { + life: 10, + crits: 10, + brawn: 10, + protection: 9, + sharpness: 10, + evasion: 10, + ..Default::default() + }, + education: dto::Education::default(), + faction: dto::FactionUpgrades { + str: 14, + spd: 13, + acc: 5, + dmg: 1, + side_effects: 10, + ..Default::default() + }, + drug: Some(dto::DrugCooldown::Xanax), + weapons: dto::Weapons { + primary: Some(dto::Weapon { + name: "ArmaLite M-15A4".to_owned(), + cat: dto::WeaponCategory::Rifle, + dmg: 68.73, + acc: 59.41, + mods: vec![dto::WeaponMod::Laser100mw], + ammo: dto::WeaponAmmo { + clips: 3, + clip_size: 15, + rate_of_fire: [3, 5], + }, + experience: 100.0, + }), + melee: Some(dto::MeleeWeapon { + name: "Pillow".to_owned(), + cat: dto::WeaponCategory::Club, + japanese: false, + dmg: 1.54, + acc: 64.41, + experience: 100.0, + }), + temp: Some(crate::dto::Temp::Heg), + ..Default::default() + }, + strategy: dto::PlayerStrategy::InOrder { + order: vec![ + // crate::dto::WeaponSlot::Melee, + // crate::dto::WeaponSlot::Temporary, + // crate::dto::WeaponSlot::Primary, + ], + reload: false, + }, + }; + + let mut simulation = Simulation::new(attacker, defender); + + let log_value = simulation.run_once(); + println!("{log_value:?}"); +} */ + +#[cfg(test)] +mod tests { + use super::*; + + fn attacker() -> dto::Player { + use dto::*; + + Player { + name: "Test".to_owned(), + id: 0, + level: 10, + stats: Stats { + str: 10_000.0, + def: 10.0, + spd: 10.0, + dex: 10.0, + }, + merits: Default::default(), + education: Default::default(), + faction: Default::default(), + drug: None, + strategy: PlayerStrategy::AlwaysFists, + weapons: Weapons { + primary: Some(Weapon { + name: "Test".to_owned(), + cat: WeaponCategory::Rifle, + dmg: 50.0, + acc: 50.0, + ammo: WeaponAmmo { + clips: 3, + clip_size: 25, + rate_of_fire: [3, 5], + }, + mods: Vec::default(), + bonuses: vec![WeaponBonusInfo { + bonus: WeaponBonus::Expose, + value: 9.0, + }], + experience: 100.0, + }), + ..Default::default() + }, + armour: ArmourPieces { + helmet: Some(Armour { + name: "Test".to_owned(), + armour_value: 50.0, + coverage: [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + ], + immunities: Vec::default(), + set: None, + }), + ..Default::default() + }, + } + } + + fn defender() -> dto::Player { + use dto::*; + + Player { + name: "Test".to_owned(), + id: 1, + level: 10, + stats: Stats { + str: 10_000.0, + def: 10.0, + spd: 10.0, + dex: 10.0, + }, + merits: Default::default(), + education: Default::default(), + faction: Default::default(), + drug: None, + strategy: PlayerStrategy::AlwaysFists, + weapons: Weapons { + primary: Some(Weapon { + name: "Test".to_owned(), + cat: WeaponCategory::Rifle, + dmg: 50.0, + acc: 50.0, + ammo: WeaponAmmo { + clips: 3, + clip_size: 25, + rate_of_fire: [3, 5], + }, + mods: Vec::default(), + bonuses: vec![WeaponBonusInfo { + bonus: WeaponBonus::Powerful, + value: 35.0, + }], + experience: 100.0, + }), + ..Default::default() + }, + armour: ArmourPieces { + helmet: Some(Armour { + name: "Test".to_owned(), + armour_value: 50.0, + coverage: [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, + ], + immunities: Vec::default(), + set: None, + }), + ..Default::default() + }, + } + } + + #[test] + fn init_simulator() { + let mut sim = Simulation::new(attacker(), defender()); + sim.run_once(); + } + + #[test] + fn metrics() { + let mut sim = Simulation::new(attacker(), defender()); + sim.set_metrics(true); + for _ in 0..20 { + sim.run_once(); + } + sim.consume_metrics(); + } +} diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..7c3a210 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,565 @@ +use std::{collections::HashMap, sync::Mutex}; + +use bevy_ecs::{prelude::*, system::SystemParam}; +use macros::LogMessage; + +use crate::{ + hierarchy::Children, + player::{ + stats::{ + AdditiveBonus, BaselineStat, CritRate, DamageBonus, Defence, Dexterity, EffectiveStat, + MultiplicativeBonus, SimpleStatBaseline, SimpleStatBonus, SimpleStatEffective, + SimpleStatMarker, Speed, StatMarker, StatType, Strength, WeaponAccuracy, + }, + Player, + }, + weapon::{Weapon, WeaponVerb}, + Name, Stages, +}; + +#[derive(Resource)] +pub struct Logging(pub bool); + +#[derive(Event)] +struct LogEvent(Mutex>>); + +impl From for LogEvent +where + T: LogMessage, +{ + fn from(value: T) -> Self { + Self(Mutex::new(Some(Box::new(value)))) + } +} + +#[derive(Debug)] +pub struct PlayerInfo { + pub name: String, +} + +#[derive(Debug)] +pub struct WeaponInfo { + pub name: String, + pub verb: WeaponVerb, +} + +#[derive(Clone)] +pub enum LogValue<'a> { + Float(f32), + Unsigned(u32), + Bool(bool), + String(String), + OptionNone, + Display(&'a (dyn std::fmt::Display + Send + Sync)), + Debug(&'a (dyn std::fmt::Debug + Send + Sync)), + Player(Entity), + Weapon(Entity), +} + +impl<'a> From for LogValue<'a> { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl<'a> From for LogValue<'a> { + fn from(value: f32) -> Self { + Self::Float(value) + } +} + +impl<'a> From for LogValue<'a> { + fn from(value: u32) -> Self { + Self::Unsigned(value) + } +} + +impl<'a> From for LogValue<'a> { + fn from(value: u16) -> Self { + Self::Unsigned(value.into()) + } +} + +impl From for LogValue<'static> { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl<'a, T> From> for LogValue<'a> +where + T: Into>, +{ + fn from(value: Option) -> Self { + match value { + Some(val) => val.into(), + None => LogValue::OptionNone, + } + } +} + +#[cfg(feature = "json")] +impl<'a> LogValue<'a> { + fn to_value( + &self, + player_registry: &HashMap, + weapon_registry: &HashMap, + ) -> serde_json::Value { + match self { + LogValue::OptionNone => serde_json::Value::Null, + LogValue::Float(val) => { + serde_json::Value::Number(serde_json::Number::from_f64(*val as f64).unwrap()) + } + LogValue::String(val) => serde_json::Value::String(val.clone()), + LogValue::Bool(val) => serde_json::Value::Bool(*val), + LogValue::Unsigned(val) => serde_json::Value::Number(serde_json::Number::from(*val)), + LogValue::Debug(boxed) => serde_json::Value::String(format!("{boxed:?}")), + LogValue::Display(boxed) => serde_json::Value::String(format!("{boxed}")), + LogValue::Player(id) => serde_json::json!({ + "type": "player", + "name": player_registry.get(id).unwrap().name, + }), + LogValue::Weapon(id) => serde_json::json!({ + "type": "weapon", + "name": weapon_registry.get(id).unwrap().name, + }), + } + } +} + +pub trait LogMessage: Send + Sync + 'static { + fn torn_style( + &self, + _player_registry: HashMap, + _weapon_registry: HashMap, + ) -> Option { + None + } + + fn tag(&self) -> &'static str; + + fn entries(&self) -> Vec<(&'static str, LogValue<'_>)>; +} + +#[derive(Resource, Default)] +pub struct Log { + pub player_registry: HashMap, + pub weapon_registry: HashMap, + + pub entries: Vec>, + + pub expanded: bool, +} + +impl Log { + #[cfg(feature = "json")] + pub fn to_value(&self) -> serde_json::Value { + use serde_json::json; + + serde_json::json!({ + "entries": self.entries.iter().map(|e| + json!({ + "type": e.tag(), + "values": serde_json::Value::Object( + e.entries().iter().map(|e| (e.0.to_owned(), e.1.to_value(&self.player_registry, &self.weapon_registry))).collect() + ) + }) + ).collect::>() + }) + } +} + +impl std::fmt::Display for Log { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut iter = self.entries.iter().peekable(); + while let Some(entry) = iter.next() { + write!(f, "{}: {{ ", entry.tag())?; + + let mut fields = entry.entries().into_iter().peekable(); + while let Some(field) = fields.next() { + if self.expanded { + write!(f, "\n ")?; + } + write!(f, "{} = ", field.0)?; + + match field.1 { + LogValue::String(val) => write!(f, "\"{val}\"")?, + LogValue::Float(val) => write!(f, "{val}")?, + LogValue::Bool(val) => write!(f, "{val}")?, + LogValue::Unsigned(val) => write!(f, "{val}")?, + LogValue::OptionNone => write!(f, "None")?, + LogValue::Display(val) => write!(f, "\"{val}\"")?, + LogValue::Debug(val) => write!(f, "\"{val:?}\"")?, + LogValue::Player(id) => { + write!(f, "\"{}\"", self.player_registry.get(&id).unwrap().name)? + } + LogValue::Weapon(id) => { + write!(f, "\"{}\"", self.weapon_registry.get(&id).unwrap().name)? + } + }; + + if fields.peek().is_some() { + write!(f, ", ")?; + } + } + + if self.expanded { + writeln!(f)?; + } else { + write!(f, " ")?; + } + + if iter.peek().is_some() { + writeln!(f, "}}")?; + } else { + write!(f, "}}")?; + } + } + + Ok(()) + } +} + +/* impl std::fmt::Display for Log { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for entry in &self.entries { + match entry { + LogEntry::Hit { + actor, + recipient, + weapon, + dmg, + rounds, + crit, + part, + } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + let weapon_info = self.weapon_registry.get(weapon).unwrap(); + + write!(f, "{} ", actor_info.name)?; + + match weapon_info.verb { + WeaponVerb::Fired => { + write!( + f, + "fired {} rounds from of their {} ", + rounds.unwrap(), + weapon_info.name + )?; + if *crit { + write!(f, "critically ")?; + } + writeln!( + f, + "hitting {} in the {} for {}", + recipient_info.name, part, dmg + )?; + } + WeaponVerb::Hit => { + if *crit { + write!(f, "critically ")?; + } + + // TODO: Pronouns and weapon verbs + writeln!( + f, + "hit {} with their {} in the {} for {}", + recipient_info.name, weapon_info.name, part, dmg + )?; + } + WeaponVerb::Exploded => { + writeln!( + f, + "{} threw a {} at {}, it exploded for {}", + actor_info.name, weapon_info.name, recipient_info.name, dmg + )?; + } + _ => todo!(), + } + } + LogEntry::Miss { + actor, + recipient, + weapon, + rounds, + } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + let weapon_info = self.weapon_registry.get(weapon).unwrap(); + + match weapon_info.verb { + WeaponVerb::Hit => { + writeln!( + f, + "{} missed {} with their {}", + actor_info.name, recipient_info.name, weapon_info.name + )?; + } + WeaponVerb::Fired => { + writeln!( + f, + "{} fired {} rounds of their {} missing {}", + actor_info.name, + rounds.unwrap(), + weapon_info.name, + recipient_info.name + )?; + } + _ => todo!(), + } + } + LogEntry::Defeat { actor, recipient } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + + writeln!(f, "{} defeated {}", actor_info.name, recipient_info.name)?; + } + LogEntry::Stalemate { actor, recipient } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + + writeln!( + f, + "{} stalemated against {}", + actor_info.name, recipient_info.name + )?; + } + LogEntry::Loss { actor, recipient } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + + writeln!( + f, + "{} lost against {}", + recipient_info.name, actor_info.name + )?; + } + LogEntry::Reload { actor, weapon } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let weapon_info = self.weapon_registry.get(weapon).unwrap(); + + writeln!(f, "{} reloaded their {}", actor_info.name, weapon_info.name)?; + } + LogEntry::UsedDebuffTemp { + actor, + recipient, + temp, + weapon, + immune, + } => { + let actor_info = self.player_registry.get(actor).unwrap(); + let recipient_info = self.player_registry.get(recipient).unwrap(); + let weapon_info = self.weapon_registry.get(weapon).unwrap(); + + match temp { + DebuffingTemp::SmokeGrenade => { + write!( + f, + "{} threw a Smoke Grenade, smoke clouds around {}", + actor_info.name, recipient_info.name + )?; + } + DebuffingTemp::TearGas => { + write!( + f, + "{} threw a Tear Gas Grenade near {}", + actor_info.name, recipient_info.name + )?; + } + DebuffingTemp::PepperSpray => { + write!( + f, + "{} sprayed Pepper Spray in {}'s face", + actor_info.name, recipient_info.name + )?; + } + _ => { + write!( + f, + "{} threw a {} at {}", + actor_info.name, recipient_info.name, weapon_info.name + )?; + } + } + + if *immune { + writeln!(f, " but it was ineffective")?; + } else { + writeln!(f)?; + } + } + } + } + + Ok(()) + } +} */ + +#[derive(SystemParam)] +pub struct Logger<'w> { + event_writer: EventWriter<'w, LogEvent>, + logging: Res<'w, Logging>, +} + +impl<'w> Logger<'w> { + pub fn log(&mut self, body: B) + where + B: FnOnce() -> M, + M: LogMessage, + { + if self.logging.0 { + self.event_writer.send(body().into()); + } + } +} + +fn logging_enabled(logging: Res) -> bool { + logging.0 +} + +fn register_entities( + player_q: Query<(Entity, &Name), With>, + weapon_q: Query<(Entity, &Name, &WeaponVerb), With>, + mut log: ResMut, +) { + for (player, name) in player_q.iter() { + log.player_registry.insert( + player, + PlayerInfo { + name: name.0.clone(), + }, + ); + } + + for (weapon, name, verb) in weapon_q.iter() { + log.weapon_registry.insert( + weapon, + WeaponInfo { + name: name.0.clone(), + verb: *verb, + }, + ); + } +} + +fn append_log_messages(mut events: EventReader, mut log: ResMut) { + for event in events.read() { + log.entries.push(event.0.lock().unwrap().take().unwrap()); + } +} + +#[derive(macros::LogMessage)] +struct StatChange { + #[log(player)] + target: Entity, + #[log(debug)] + stat: StatType, + + #[log(debug)] + effects_add: Vec<(&'static str, f32)>, + + #[log(debug)] + effects_mult: Vec<(&'static str, f32)>, + + baseline: f32, + + effective: f32, +} + +fn log_stat_changes( + stat_q: Query< + (Entity, &BaselineStat, &EffectiveStat, &Children), + Changed>, + >, + add_q: Query<&AdditiveBonus>, + mult_q: Query<&MultiplicativeBonus>, + mut logger: Logger, +) { + for (player, baseline, effective, children) in stat_q.iter() { + let effects_add = add_q + .iter_many(children.get()) + .map(|eff| (eff.label, eff.value)) + .collect(); + let effects_mult = mult_q + .iter_many(children.get()) + .map(|eff| (eff.label, eff.value)) + .collect(); + + logger.log(|| StatChange { + target: player, + stat: Stat::stat_type(), + effects_add, + effects_mult, + baseline: baseline.value, + effective: effective.value, + }) + } +} + +fn log_simple_stat_changes( + stat_q: Query< + ( + Entity, + &SimpleStatBaseline, + &SimpleStatEffective, + &Children, + ), + Changed>, + >, + bonus_q: Query<&SimpleStatBonus>, + mut logger: Logger, +) where + Stat::ValueType: Into>, + Stat::BonusType: std::fmt::Debug, +{ + #[derive(LogMessage)] + struct AppliedBonus { + #[log(debug)] + target: Entity, + #[log(display)] + stat: &'static str, + baseline: LogValue<'static>, + effective: LogValue<'static>, + #[log(debug)] + bonuses: Vec<(&'static str, String)>, + } + + for (target, baseline, effective, children) in stat_q.iter() { + let bonuses = bonus_q + .iter_many(children.get()) + .map(|eff| (eff.label, format!("{:?}", eff.value))) + .collect(); + + logger.log(|| AppliedBonus { + target, + stat: std::any::type_name::(), + baseline: baseline.value.into(), + effective: effective.value.into(), + bonuses, + }); + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.world.insert_resource(Log::default()); + stages.world.insert_resource(Logging(false)); + stages.add_event::(); + stages + .equip + .add_systems(register_entities.run_if(logging_enabled)); + + stages.post_turn.add_systems(append_log_messages); + stages.turn.add_systems( + ( + log_stat_changes::, + log_stat_changes::, + log_stat_changes::, + log_stat_changes::, + log_simple_stat_changes::, + log_simple_stat_changes::, + log_simple_stat_changes::, + ) + .run_if(logging_enabled), + ); +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 0000000..9b10a52 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,130 @@ +use bevy_ecs::prelude::*; +use std::{ + collections::HashMap, + sync::{atomic, Mutex, RwLock}, +}; + +use crate::{dto, entity_registry::EntityRegistry, Stages}; + +#[derive(Default)] +pub struct Histogram +where + T: Copy + Send + Sync, +{ + inner: Mutex>, +} + +impl Histogram +where + T: Copy + Send + Sync, +{ + #[inline(always)] + pub fn record(&self, val: T) { + self.inner.lock().unwrap().push(val); + } +} + +#[derive(Default)] +pub struct Counter { + inner: atomic::AtomicU64, +} + +impl Counter { + #[inline(always)] + pub fn increment(&self, value: u64) { + self.inner.fetch_add(value, atomic::Ordering::Relaxed); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MetricKey { + entity: Entity, + label: &'static str, +} + +#[derive(Resource, Default)] +pub struct Metrics { + pub(crate) active: bool, + counters: RwLock>, + histograms: RwLock>>, +} + +impl Metrics { + pub fn record_histogram(&self, entity: Entity, label: &'static str, value: u32) { + if self.active { + let key = MetricKey { entity, label }; + let r_hist = self.histograms.read().unwrap(); + if let Some(hist) = r_hist.get(&key) { + hist.record(value); + } else { + std::mem::drop(r_hist); + + let mut histograms = self.histograms.write().unwrap(); + histograms.insert( + key, + Histogram { + inner: vec![value].into(), + }, + ); + } + } + } + + pub fn increment_counter(&self, entity: Entity, label: &'static str, value: u64) { + if self.active { + let key = MetricKey { entity, label }; + let r_counters = self.counters.read().unwrap(); + if let Some(counter) = r_counters.get(&key) { + counter.increment(value); + } else { + std::mem::drop(r_counters); + + let mut counters = self.counters.write().unwrap(); + counters.insert( + key, + Counter { + inner: value.into(), + }, + ); + } + } + } +} + +pub(crate) fn consume_metrics(world: &World) -> (Vec, Vec) { + let metrics = world.resource::(); + let entities = world.resource::(); + + let counters = metrics + .counters + .try_write() + .unwrap() + .drain() + .map(|(key, value)| dto::Counter { + entity: entities.0.get(&key.entity).unwrap().clone(), + value: value.inner.load(atomic::Ordering::Relaxed), + label: key.label, + }) + .collect(); + + let histograms = metrics + .histograms + .try_write() + .unwrap() + .drain() + .map(|(key, value)| dto::Histogram { + entity: entities.0.get(&key.entity).unwrap().clone(), + values: value.inner.into_inner().unwrap(), + label: key.label, + }) + .collect(); + + (counters, histograms) +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.world.init_resource::(); +} + +#[cfg(test)] +mod test {} diff --git a/src/passives.rs b/src/passives.rs new file mode 100644 index 0000000..17ca8c7 --- /dev/null +++ b/src/passives.rs @@ -0,0 +1,405 @@ +use bevy_ecs::prelude::*; + +use crate::{ + effect::Effects, + player::stats::{ + AdditiveBonus, CritRate, Defence, Dexterity, SimpleStatBonus, Speed, Strength, + }, + Stages, +}; + +#[derive(Component, Default)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct Merits { + pub life: u16, + pub crits: u16, + + pub brawn: u16, + pub protection: u16, + pub sharpness: u16, + pub evasion: u16, + + pub heavy_artillery_mastery: u16, + pub machine_gun_mastery: u16, + pub rifle_mastery: u16, + pub smg_mastery: u16, + pub shotgun_mastery: u16, + pub pistol_mastery: u16, + pub club_mastery: u16, + pub piercing_mastery: u16, + pub slashing_mastery: u16, + pub mechanical_mastery: u16, + pub temporary_mastery: u16, +} + +#[derive(Component)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct Education { + /// Gain a 1% damage bonus to all weapons + pub bio2350: bool, + /// Gain a 10% damage increase when hitting an opponent's throat + pub bio2380: bool, + /// Gain a 3% chance increase of achieving a critical hit + pub bio2410: bool, + + /// Gain a 1% passive bonus to speed + pub cbt2790: bool, + /// Gain a +1.00 accuracy increase with Machine Guns + pub cbt2820: bool, + /// Gain a +1.00 accuracy increase with Submachine guns + pub cbt2830: bool, + /// Gain a +1.00 accuracy increase with Pistols + pub cbt2840: bool, + /// Gain a +1.00 accuracy increase with Rifles + pub cbt2850: bool, + /// Gain a +1.00 accuracy increase with Heavy Artillery + pub cbt2860: bool, + /// Gain a +1.00 accuracy increase with Shotguns + pub cbt2125: bool, + + /// Gain a +1.00 accuracy increase with Temporary weapons + pub gen2116: bool, + /// Gain a 5% damage increase with Temporary weapons + pub gen2119: bool, + + /// Gain a 1% passive bonus to dexterity + pub haf2104: bool, + /// Gain a 1% passive bonus to speed + pub haf2105: bool, + /// Gain a 1% passive bonus to strength + pub haf2106: bool, + /// Gain a 2% passive bonus to strength + pub haf2107: bool, + /// Gain a 1% passive bonus to dexterity + pub haf2108: bool, + /// Gain a 3% passive bonus to speed + pub haf2109: bool, + + /// Gain a 10% damage increase with Japanese blade weapons + pub his2160: bool, + /// Gain a 2% bonus to all melee damage + pub his2170: bool, + + /// Gain a 1% passive bonus to speed + pub mth2240: bool, + /// Gain a 1% passive bonus to speed + pub mth2250: bool, + /// Gain a 1% passive bonus to defense + pub mth2260: bool, + /// Gain a 2% passive bonus to defense + pub mth2320: bool, + + /// Gain a 5% bonus to ammo conservation + pub mth2310: bool, + /// Gain a 20% bonus to ammo conservation + pub mth3330: bool, + + /// Gain a 1% passive bonus to dexterity + pub psy2640: bool, + /// Gain a 2% passive bonus to dexterity + pub psy2650: bool, + /// Gain a 4% passive bonus to dexterity + pub psy2660: bool, + /// Gain an 8% passive bonus to dexterity + pub psy2670: bool, + + /// Gain a 1% passive bonus to defense + pub def2710: bool, + /// Gain a 2% passive bonus to defense + pub def2730: bool, + /// Gain a 3% passive bonus to defense + pub def2740: bool, + /// Gain a 2% passive bonus to speed + pub def2750: bool, + /// Gain a 3% passive bonus to speed + pub def2760: bool, + /// Gain a 100% increase in damage dealt when using fists alone + pub def3770: bool, + + /// Gain a 10% increase in steroid effectiveness + // NOTE: this effect is additive with the strip club perks + pub spt2480: bool, + /// Gain a 2% passive bonus to speed and strength + pub spt2490: bool, + /// Gain a 2% passive bonus to defense and dexterity + pub spt2500: bool, +} + +impl Default for Education { + fn default() -> Self { + Self { + bio2350: true, + bio2380: true, + bio2410: true, + cbt2790: true, + cbt2820: true, + cbt2830: true, + cbt2840: true, + cbt2850: true, + cbt2860: true, + cbt2125: true, + gen2116: true, + gen2119: true, + haf2104: true, + haf2105: true, + haf2106: true, + haf2107: true, + haf2108: true, + haf2109: true, + his2160: true, + his2170: true, + mth2240: true, + mth2250: true, + mth2260: true, + mth2320: true, + mth2310: true, + mth3330: true, + psy2640: true, + psy2650: true, + psy2660: true, + psy2670: true, + def2710: true, + def2730: true, + def2740: true, + def2750: true, + def2760: true, + def3770: true, + spt2480: true, + spt2490: true, + spt2500: true, + } + } +} + +#[derive(Component, Default)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub struct FactionUpgrades { + pub str: u16, + pub spd: u16, + pub def: u16, + pub dex: u16, + pub life: u16, + pub acc: u16, + pub dmg: u16, + pub side_effects: u16, +} + +#[derive(Component)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub enum DrugCooldown { + Xanax, + Vicodin, +} + +#[derive(Bundle, Default)] +pub(crate) struct PassiveBundle { + pub merits: Merits, + pub education: Education, + pub faction: FactionUpgrades, +} + +fn spawn_permanent_effects( + merit_q: Query<( + Entity, + &Merits, + &Education, + &FactionUpgrades, + Option<&DrugCooldown>, + )>, + mut effects: Effects, +) { + for (player, merits, edu, faction, drug_cd) in merit_q.iter() { + if merits.brawn > 0 { + effects.spawn( + AdditiveBonus::::new("brawn", (merits.brawn as f32) * 0.03), + player, + ); + } + + if merits.protection > 0 { + effects.spawn( + AdditiveBonus::::new("protection", (merits.protection as f32) * 0.03), + player, + ); + } + + if merits.sharpness > 0 { + effects.spawn( + AdditiveBonus::::new("sharpness", (merits.sharpness as f32) * 0.03), + player, + ); + } + + if merits.evasion > 0 { + effects.spawn( + AdditiveBonus::::new("evasion", (merits.evasion as f32) * 0.03), + player, + ); + } + if merits.crits > 0 { + effects.spawn( + SimpleStatBonus::::new("merits", merits.crits), + player, + ); + } + + if faction.spd > 0 { + effects.spawn( + AdditiveBonus::::new("faction", (faction.spd as f32) * 0.01), + player, + ); + } + if faction.str > 0 { + effects.spawn( + AdditiveBonus::::new("faction", (faction.str as f32) * 0.01), + player, + ); + } + if faction.def > 0 { + effects.spawn( + AdditiveBonus::::new("faction", (faction.def as f32) * 0.01), + player, + ); + } + if faction.dex > 0 { + effects.spawn( + AdditiveBonus::::new("faction", (faction.dex as f32) * 0.01), + player, + ); + } + + #[allow(clippy::too_many_arguments)] + fn spawn_drug_bonuses( + label: &'static str, + player: Entity, + effects: &mut Effects, + str: f32, + def: f32, + spd: f32, + dex: f32, + mit: f32, + ) { + fn mitigate(val: f32, mit: f32) -> f32 { + if val.is_sign_negative() { + // NOTE: The rounding here is pure speculation + (val * mit).floor() / 100.0 + } else { + val / 100.0 + } + } + effects.spawn( + AdditiveBonus::::new(label, mitigate(str, mit)), + player, + ); + effects.spawn( + AdditiveBonus::::new(label, mitigate(def, mit)), + player, + ); + effects.spawn( + AdditiveBonus::::new(label, mitigate(spd, mit)), + player, + ); + effects.spawn( + AdditiveBonus::::new(label, mitigate(dex, mit)), + player, + ); + } + + let mit = 1.0 - (faction.side_effects as f32) * 0.03; + + match drug_cd { + Some(DrugCooldown::Xanax) => { + spawn_drug_bonuses( + "xanax", + player, + &mut effects, + -35.0, + -35.0, + -35.0, + -35.0, + mit, + ); + } + Some(DrugCooldown::Vicodin) => { + spawn_drug_bonuses("vicodin", player, &mut effects, 25.0, 25.0, 25.0, 25.0, mit); + } + _ => (), + } + + if edu.bio2410 { + effects.spawn(SimpleStatBonus::::new("BIO2410", 6), player); + } + if edu.cbt2790 { + effects.spawn(AdditiveBonus::::new("CBT2790", 0.01), player); + } + if edu.haf2104 { + effects.spawn(AdditiveBonus::::new("HAF2104", 0.01), player); + } + if edu.haf2105 { + effects.spawn(AdditiveBonus::::new("HAF2105", 0.01), player); + } + if edu.haf2106 { + effects.spawn(AdditiveBonus::::new("HAF2106", 0.01), player); + } + if edu.haf2107 { + effects.spawn(AdditiveBonus::::new("HAF2107", 0.02), player); + } + if edu.haf2108 { + effects.spawn(AdditiveBonus::::new("HAF2108", 0.01), player); + } + if edu.haf2109 { + effects.spawn(AdditiveBonus::::new("HAF2109", 0.03), player); + } + if edu.mth2240 { + effects.spawn(AdditiveBonus::::new("MTH2240", 0.01), player); + } + if edu.mth2250 { + effects.spawn(AdditiveBonus::::new("MTH2250", 0.01), player); + } + if edu.mth2260 { + effects.spawn(AdditiveBonus::::new("MTH2260", 0.01), player); + } + if edu.mth2320 { + effects.spawn(AdditiveBonus::::new("MTH2320", 0.02), player); + } + if edu.psy2640 { + effects.spawn(AdditiveBonus::::new("PSY2640", 0.01), player); + } + if edu.psy2650 { + effects.spawn(AdditiveBonus::::new("PSY2650", 0.02), player); + } + if edu.psy2660 { + effects.spawn(AdditiveBonus::::new("PSY2660", 0.04), player); + } + if edu.psy2670 { + effects.spawn(AdditiveBonus::::new("PSY2670", 0.08), player); + } + if edu.def2710 { + effects.spawn(AdditiveBonus::::new("DEF2710", 0.01), player); + } + if edu.def2730 { + effects.spawn(AdditiveBonus::::new("DEF2730", 0.02), player); + } + if edu.def2740 { + effects.spawn(AdditiveBonus::::new("DEF2740", 0.03), player); + } + if edu.def2750 { + effects.spawn(AdditiveBonus::::new("DEF2750", 0.02), player); + } + if edu.def2760 { + effects.spawn(AdditiveBonus::::new("DEF2760", 0.03), player); + } + if edu.spt2490 { + effects.spawn(AdditiveBonus::::new("SPT2490", 0.02), player); + effects.spawn(AdditiveBonus::::new("SPT2490", 0.02), player); + } + if edu.spt2500 { + effects.spawn(AdditiveBonus::::new("SPT2500", 0.02), player); + effects.spawn(AdditiveBonus::::new("SPT2500", 0.02), player); + } + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.equip.add_systems(spawn_permanent_effects); +} diff --git a/src/player/mod.rs b/src/player/mod.rs new file mode 100644 index 0000000..85da508 --- /dev/null +++ b/src/player/mod.rs @@ -0,0 +1,916 @@ +use bevy_ecs::prelude::*; +use macros::LogMessage; +use rand::Rng as _; +use strum::Display; + +use crate::{ + armour, + effect::Effects, + hierarchy::Children, + log::Logger, + metrics::Metrics, + passives::{Education, FactionUpgrades, Merits}, + weapon::{ + bonus::MultiTurnBonus, + temp::{NonTargeted, Uses}, + Ammo, DamageProcEffect, DamageStat, NeedsReload, RateOfFire, TurnTriggeredEffect, Usable, + Weapon, WeaponSlot, + }, + FightStatus, Id, Name, Rng, Stages, +}; + +use self::stats::{ + AmmoControl, Clips, CritRate, DamageBonus, Defence, Dexterity, EffectiveStat, Health, + SimpleStatBundle, SimpleStatEffective, Speed, Strength, WeaponAccuracy, +}; + +pub mod stats; +pub mod status_effect; + +#[derive(Component)] +pub struct Attacker; + +#[derive(Component)] +pub struct Defender; + +#[derive(Component)] +pub struct Defeated; + +#[derive(Component)] +pub struct Current; + +#[derive(Component)] +pub struct CurrentTarget; + +#[derive(Component, Default)] +pub struct Player; + +#[derive(Component)] +pub struct Level(pub u16); + +impl Default for Level { + fn default() -> Self { + Self(1) + } +} + +#[derive(Component, Debug)] +pub struct MaxHealth(pub u16); + +impl Default for MaxHealth { + fn default() -> Self { + Self(100) + } +} + +#[derive(Component, Debug, Default)] +pub struct CombatTurns(pub u16); + +#[derive(Component, Default, Debug)] +pub struct Weapons { + pub primary: Option, + pub secondary: Option, + pub melee: Option, + pub temporary: Option, + pub fists: Option, + pub kick: Option, +} + +impl Weapons { + fn select( + &self, + slot: WeaponSlot, + reload: bool, + usable_q: &Query<(Has, &Children), With>, + ) -> Option<(Entity, Children)> { + let id = match slot { + WeaponSlot::Primary => self.primary?, + WeaponSlot::Secondary => self.secondary?, + WeaponSlot::Melee => self.melee?, + WeaponSlot::Temporary => self.temporary?, + WeaponSlot::Fists => self.fists?, + WeaponSlot::Kick => self.kick?, + }; + + let (needs_reload, children) = usable_q.get(id).ok()?; + + if !reload && needs_reload { + None + } else { + Some((id, children.clone())) + } + } +} + +#[derive(Component, Debug)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(tag = "type", rename_all = "snake_case"))] +pub enum PlayerStrategy { + AlwaysFists, + AlwaysKicks, + PrimaryMelee { + reload: bool, + }, + InOrder { + order: Vec, + reload: bool, + }, +} + +impl Default for PlayerStrategy { + fn default() -> Self { + Self::AlwaysFists + } +} + +#[derive(Event)] +pub struct ChooseWeapon(pub Entity); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyPart { + LeftHand, + RightHand, + LeftArm, + RightArm, + LeftFoot, + RightFoot, + LeftLeg, + RightLeg, + Stomach, + Chest, + Groin, + Head, + Throat, + Heart, +} + +impl std::fmt::Display for BodyPart { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LeftHand => write!(f, "Left hand"), + Self::RightHand => write!(f, "Right hand"), + Self::LeftArm => write!(f, "Left arm"), + Self::RightArm => write!(f, "Right arm"), + Self::LeftFoot => write!(f, "Left foot"), + Self::RightFoot => write!(f, "Right foot"), + Self::LeftLeg => write!(f, "Left leg"), + Self::RightLeg => write!(f, "Right leg"), + Self::Stomach => write!(f, "Stomach"), + Self::Chest => write!(f, "Chest"), + Self::Groin => write!(f, "Groin"), + Self::Head => write!(f, "Head"), + Self::Throat => write!(f, "Throat"), + Self::Heart => write!(f, "Heart"), + } + } +} + +#[derive(Event)] +pub struct InitiateHit { + pub body_part: BodyPart, + pub weapon: Entity, + pub rounds: Option, + pub dmg: f32, + pub dmg_bonus_weapon: f32, + pub dmg_bonus_player: f32, + pub hit_chance: f32, + pub crit_rate: u16, +} + +#[derive(Clone, Copy, PartialEq, Eq, Display)] +pub enum FightEndType { + Victory, + Stalemate, + Loss, +} + +#[derive(LogMessage)] +struct FightEnd { + #[log(player)] + actor: Entity, + #[log(player)] + recipient: Entity, + #[log(display)] + fight_end_type: FightEndType, +} + +#[derive(Bundle)] +pub struct PlayerBundle { + pub name: Name, + pub id: Id, + pub player: Player, + pub level: Level, + pub crit_rate: SimpleStatBundle, + // TODO: since these two need to be tracked here anyways it might be preferable to shift all + // player specific passives here instead of tracking them on the weapons + pub acc_bonus: SimpleStatBundle, + pub dmg_bonus: SimpleStatBundle, + + pub strategy: PlayerStrategy, + pub combat_turns: CombatTurns, +} + +impl PlayerBundle { + pub fn new(name: impl ToString, id: usize, level: u16, strategy: PlayerStrategy) -> Self { + Self { + name: Name(name.to_string()), + id: Id(id), + player: Player, + level: Level(level), + crit_rate: SimpleStatBundle::new(24), + acc_bonus: SimpleStatBundle::new(0.0), + dmg_bonus: SimpleStatBundle::new(0.0), + strategy, + combat_turns: Default::default(), + } + } +} + +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(); + 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(); + let target = target_q.single(); + + // 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, &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(); + let (weapon, children) = match strat { + PlayerStrategy::AlwaysFists => (weapons.fists.unwrap(), Default::default()), + PlayerStrategy::AlwaysKicks => weapons + .select(WeaponSlot::Kick, true, &usable_q) + .unwrap_or_else(|| (weapons.fists.unwrap(), Default::default())), + PlayerStrategy::PrimaryMelee { reload } => weapons + .select(WeaponSlot::Primary, *reload, &usable_q) + .or_else(|| weapons.select(WeaponSlot::Melee, true, &usable_q)) + .unwrap_or_else(|| (weapons.fists.unwrap(), Default::default())), + PlayerStrategy::InOrder { order, reload } => order + .iter() + .find_map(|slot| weapons.select(*slot, *reload, &usable_q)) + .unwrap_or_else(|| (weapons.fists.unwrap(), Default::default())), + }; + metrics.increment_counter(current, "turn", 1); + metrics.increment_counter(weapon, "turn", 1); + + commands.entity(weapon).insert(Current); + + let target = target_q.single(); + + 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()) + } +} + +fn receive_hit( + (mut rng, spread): (ResMut, Local), + mut hit_init_events: EventReader, + current_q: Query< + ( + Entity, + &Education, + Option<&Attacker>, + &EffectiveStat, + ), + (With, With), + >, + mut target_q: Query< + ( + Entity, + &mut SimpleStatEffective, + &armour::ArmourBodyParts, + &EffectiveStat, + ), + With, + >, + armour_q: Query<&armour::ArmourBodyPart>, + (mut commands, mut logger): (Commands, Logger), + metrics: Res, +) { + #[derive(LogMessage)] + struct HitTarget { + #[log(player)] + actor: Entity, + #[log(player)] + recipient: Entity, + #[log(weapon)] + weapon: Entity, + + #[log(display)] + part: BodyPart, + part_mult: f32, + rounds: Option, + + dmg: u32, + health_before: u16, + health_after: u16, + + dmg_intrinsic: f32, + dmg_spread: f32, + + armour_mitigation: f32, + def_mitigation: f32, + + weapon_dmg: f32, + bonus_dmg: f32, + hit_chance: f32, + + crit_rate: u16, + } + + if hit_init_events.is_empty() { + return; + } + + let (target, mut health, body_parts, target_def) = target_q.single_mut(); + let (current, edu, attacker, current_str) = current_q.single(); + + let def_str_ratio = (target_def.value / current_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 * (current_str.value / 10.0).log10().powi(2) + + 27.0 * (current_str.value / 10.0).log10() + + 30.0; + + for event in hit_init_events.read() { + let mult = match event.body_part { + BodyPart::Head | BodyPart::Heart | BodyPart::Throat => { + metrics.increment_counter(current, "crit", 1); + metrics.increment_counter(event.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(current, "hit", 1); + metrics.increment_counter(event.weapon, "hit", 1); + + let armour_parts = armour_q.get(body_parts.0[event.body_part.into()]).unwrap(); + let piece = rng.sample(armour_parts); + let armour_mitigation = piece.map_or(0.0, |p| p.armour_value); + + // NOTE: The beta distribution is defined on [0,1], so we rescale here + let dmg_spread = rng.sample(spread.0) / 10.0 + 1.0; + + let mut dmg_bonus = event.dmg_bonus_weapon + event.dmg_bonus_player; + + if edu.bio2380 && event.body_part == BodyPart::Throat { + dmg_bonus += 0.10; + } + + let dmg = dmg_intrinsic + * event.dmg + * dmg_bonus + * (1.0 - armour_mitigation) + * (1.0 - def_mitigation) + * mult + * dmg_spread; + let dmg = dmg.round() as u32; + + metrics.record_histogram(current, "dmg", dmg); + metrics.record_histogram(event.weapon, "dmg", dmg); + + let health_before = health.value; + + health.value = health.value.saturating_sub(dmg as u16); + + logger.log(|| HitTarget { + actor: current, + recipient: target, + weapon: event.weapon, + part: event.body_part, + part_mult: mult, + rounds: event.rounds, + dmg, + health_before, + health_after: health.value, + dmg_spread, + dmg_intrinsic, + armour_mitigation, + def_mitigation, + weapon_dmg: event.dmg, + bonus_dmg: dmg_bonus, + hit_chance: event.hit_chance, + crit_rate: event.crit_rate, + }); + + if health.value == 0 { + commands.entity(target).insert(Defeated); + logger.log(|| FightEnd { + actor: current, + recipient: target, + fight_end_type: if attacker.is_some() { + FightEndType::Victory + } else { + FightEndType::Loss + }, + }); + metrics.increment_counter(current, "victory", 1); + } + } +} + +// 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, + &Education, + Has, + ), + (With, With), + >, + mut target_q: Query< + ( + Entity, + &EffectiveStat, + &EffectiveStat, + &armour::ArmourBodyParts, + &mut SimpleStatEffective, + ), + With, + >, + armour_q: Query<&armour::ArmourBodyPart>, + damage_proc_q: Query<&DamageProcEffect>, + (mut ammo_q, mut temp_q): ( + Query<( + &mut Ammo, + &SimpleStatEffective, + &RateOfFire, + &SimpleStatEffective, + )>, + Query<&mut Uses>, + ), + (mut logger, mut commands, dmg_spread, metrics): ( + Logger, + Commands, + Local, + Res, + ), +) { + #[derive(LogMessage)] + pub struct MissTarget { + #[log(player)] + pub actor: Entity, + #[log(player)] + pub recipient: Entity, + #[log(weapon)] + pub weapon: Entity, + pub rounds: Option, + } + + #[derive(LogMessage)] + struct HitTarget { + #[log(player)] + actor: Entity, + #[log(player)] + recipient: Entity, + #[log(weapon)] + weapon: Entity, + + #[log(display)] + part: BodyPart, + part_mult: f32, + rounds: Option, + + dmg: u32, + health_before: u16, + health_after: u16, + + dmg_intrinsic: f32, + dmg_spread: f32, + + armour_mitigation: f32, + def_mitigation: f32, + + weapon_dmg: f32, + bonus_dmg: f32, + hit_chance: f32, + + crit_rate: u16, + } + + let Ok((weapon, w_dmg, acc, dmg_bonus, crit, children, non_targeted)) = weapon_q.get_single() + else { + return; + }; + let (player, player_spd, player_str, player_crit, acc_bonus, p_dmg_bonus, edu, attacker) = + player_q.single(); + let (target, target_dex, target_def, armour_parts, mut health) = target_q.single_mut(); + + 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(|(ref mut ammo, clips, rof)| { + let rounds = (rng.gen_range(rof.clone()).round() as u16).clamp(1, ammo.0); + metrics.increment_counter(player, "rounds_fired", rounds.into()); + metrics.increment_counter(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.gen_bool(hit_chance as f64) { + logger.log(|| MissTarget { + weapon, + actor: player, + recipient: target, + rounds, + }); + metrics.increment_counter(player, "miss", 1); + metrics.increment_counter(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(player, "crit", 1); + metrics.increment_counter(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(player, "hit", 1); + metrics.increment_counter(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: The beta distribution is defined on [0,1], so we rescale here + let dmg_spread = rng.sample(dmg_spread.0) / 10.0 + 1.0; + + let mut dmg_bonus = dmg_bonus + p_dmg_bonus; + + if edu.bio2380 && body_part == BodyPart::Throat { + dmg_bonus.value += 0.10; + } + + // TODO: special ammo + + let dmg = dmg_intrinsic + * w_dmg.0 + * dmg_bonus.value + * (1.0 - armour_mitigation) + * (1.0 - def_mitigation) + * mult + * dmg_spread; + let dmg = dmg.round() as u32; + + metrics.record_histogram(player, "dmg", dmg); + metrics.record_histogram(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_none() => + { + if rng.gen_bool(*value as f64) { + 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.gen_range(2..=8))) + } + MultiTurnBonus::DoubleTap => { + multi_attack_proc = + Some(MultiAttack::DoubleTap { first_shot: true }) + } + }; + } + } + _ => (), + } + } + } + + let health_before = health.value; + + health.value = health.value.saturating_sub(dmg as u16); + + logger.log(|| HitTarget { + actor: player, + recipient: target, + weapon, + part: body_part, + part_mult: mult, + rounds, + dmg, + health_before, + health_after: health.value, + dmg_spread, + dmg_intrinsic, + armour_mitigation, + def_mitigation, + weapon_dmg: w_dmg.0, + bonus_dmg: dmg_bonus.value, + hit_chance, + crit_rate: crit.value, + }); + + if health.value == 0 && !defeated { + defeated = true; + + commands.entity(target).insert(Defeated); + logger.log(|| FightEnd { + actor: player, + recipient: target, + fight_end_type: if attacker { + FightEndType::Victory + } else { + FightEndType::Loss + }, + }); + metrics.increment_counter(player, "victory", 1); + } + } + + match multi_attack_proc { + Some(MultiAttack::Blindfire) => { + if !ammo.as_ref().map(|(a, _, _)| a.0 != 0).unwrap_or_default() { + break; + } + + acc_eff.value -= 5.0 / 50.0; + } + 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, Option<&Attacker>), (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(); + if *state == FightStatus::Ongoing && current_turns.0 >= 25 && attacker.is_some() { + commands.entity(current).insert(Defeated); + let target = target_q.single(); + logger.log(|| FightEnd { + actor: current, + recipient: target, + fight_end_type: FightEndType::Stalemate, + }); + + metrics.increment_counter(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(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.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); +} diff --git a/src/player/stats.rs b/src/player/stats.rs new file mode 100644 index 0000000..7ba0bf4 --- /dev/null +++ b/src/player/stats.rs @@ -0,0 +1,572 @@ +use std::marker::PhantomData; + +use bevy_ecs::prelude::*; + +use crate::{hierarchy::Parent, player::BodyPart, Stages}; + +pub trait SimpleStatMarker: Send + Sync + 'static { + type ValueType: Send + Sync + Copy + std::fmt::Display + 'static; + type BonusType: Send + Sync + Copy + 'static; + + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType; + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType; +} + +#[derive(Component)] +pub struct SimpleStatBaseline { + pub value: Stat::ValueType, + marker: PhantomData, +} + +#[derive(Component)] +pub struct SimpleStatEffective { + pub value: Stat::ValueType, + marker: PhantomData, +} + +#[derive(Component)] +pub struct SimpleStatBonus { + pub label: &'static str, + pub value: Stat::BonusType, + marker: PhantomData, +} + +impl SimpleStatBonus { + pub fn new(label: &'static str, value: Stat::BonusType) -> Self { + Self { + label, + value, + marker: PhantomData, + } + } +} + +#[derive(Component)] +struct SimpleStatSnapshot { + value: Stat::ValueType, + marker: PhantomData, +} + +#[derive(Bundle)] +pub struct SimpleStatBundle { + baseline: SimpleStatBaseline, + effective: SimpleStatEffective, +} + +impl SimpleStatBundle { + pub fn new(value: Stat::ValueType) -> Self { + Self { + baseline: SimpleStatBaseline { + value, + marker: PhantomData, + }, + effective: SimpleStatEffective { + value, + marker: PhantomData, + }, + } + } +} + +impl Clone for SimpleStatEffective { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for SimpleStatEffective where Stat::ValueType: Copy {} + +#[derive(Default)] +pub struct CritRate; + +impl SimpleStatMarker for CritRate { + type ValueType = u16; + type BonusType = u16; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value + bonus + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value - bonus + } +} + +impl std::ops::Add<&SimpleStatEffective> for &SimpleStatEffective +where + Stat: SimpleStatMarker, + Stat::ValueType: std::ops::Add, +{ + type Output = SimpleStatEffective; + + fn add(self, rhs: &SimpleStatEffective) -> Self::Output { + SimpleStatEffective { + value: self.value + rhs.value, + marker: PhantomData, + } + } +} + +impl rand::distributions::Distribution for SimpleStatEffective { + fn sample(&self, rng: &mut R) -> BodyPart { + if rng.gen_ratio((self.value) as u32, 200) { + match rng.gen_range(1..=10) { + 1 => BodyPart::Heart, + 2 => BodyPart::Throat, + _ => BodyPart::Heart, + } + } else { + match rng.gen_range(1..=20) { + 1 => BodyPart::LeftHand, + 2 => BodyPart::RightHand, + 3 => BodyPart::LeftArm, + 4 => BodyPart::RightArm, + 5 => BodyPart::LeftFoot, + 6 => BodyPart::RightFoot, + 7 | 8 => BodyPart::RightLeg, + 9 | 10 => BodyPart::LeftLeg, + 11..=15 => BodyPart::Chest, + 16 => BodyPart::Groin, + _ => BodyPart::Stomach, + } + } + } +} + +#[derive(Default)] +pub struct AmmoControl; + +impl SimpleStatMarker for AmmoControl { + type ValueType = f32; + type BonusType = f32; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value + bonus + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value - bonus + } +} + +#[derive(Default)] +pub struct DamageBonus; + +impl SimpleStatMarker for DamageBonus { + type ValueType = f32; + type BonusType = f32; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value + bonus + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value - bonus + } +} + +#[derive(Default)] +pub struct WeaponAccuracy; + +impl SimpleStatMarker for WeaponAccuracy { + type ValueType = f32; + type BonusType = f32; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value + bonus + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value - bonus + } +} + +#[derive(Default)] +pub struct ClipSize; + +impl SimpleStatMarker for ClipSize { + type ValueType = u16; + type BonusType = f32; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + ((value as f32) * bonus).round() as u16 + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + ((value as f32) / bonus).round() as u16 + } +} + +#[derive(Default)] +pub struct Clips; + +impl SimpleStatMarker for Clips { + type ValueType = u16; + type BonusType = i16; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + ((value as i16) + bonus) as u16 + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + ((value as i16) - bonus) as u16 + } +} + +#[derive(Default)] +pub struct Health; + +impl SimpleStatMarker for Health { + type ValueType = u16; + type BonusType = u16; + fn apply_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value + bonus + } + fn revert_bonus(value: Self::ValueType, bonus: Self::BonusType) -> Self::ValueType { + value - bonus + } +} + +#[derive(Debug, Clone, Copy)] +pub enum StatType { + Str, + Def, + Spd, + Dex, +} + +pub trait StatMarker: Send + Sync + 'static { + fn stat_type() -> StatType; +} + +#[derive(Debug, Default)] +pub struct Strength; +impl StatMarker for Strength { + fn stat_type() -> StatType { + StatType::Str + } +} + +#[derive(Debug, Default)] +pub struct Defence; +impl StatMarker for Defence { + fn stat_type() -> StatType { + StatType::Def + } +} + +#[derive(Debug, Default)] +pub struct Speed; +impl StatMarker for Speed { + fn stat_type() -> StatType { + StatType::Spd + } +} + +#[derive(Debug, Default)] +pub struct Dexterity; +impl StatMarker for Dexterity { + fn stat_type() -> StatType { + StatType::Dex + } +} + +#[derive(Component)] +pub struct BaselineStat { + pub value: f32, + pub marker: PhantomData, +} + +impl Default for BaselineStat { + fn default() -> Self { + Self { + value: 10.0, + marker: PhantomData, + } + } +} + +#[derive(Component, Default)] +pub struct EffectiveStat { + pub value: f32, + pub marker: PhantomData, +} + +#[derive(Component)] +pub struct AdditiveBonuses { + pub factor: f32, + pub marker: PhantomData, +} + +impl Default for AdditiveBonuses { + fn default() -> Self { + Self { + factor: 1.0, + marker: PhantomData, + } + } +} + +#[derive(Component)] +pub struct MultiplicativeBonuses { + pub factor: f32, + pub marker: PhantomData, +} + +impl Default for MultiplicativeBonuses { + fn default() -> Self { + Self { + factor: 1.0, + marker: PhantomData, + } + } +} + +#[derive(Bundle, Default)] +pub struct StatBundle { + baseline: BaselineStat, + additive: AdditiveBonuses, + multiplicative: MultiplicativeBonuses, + effective: EffectiveStat, +} + +#[derive(Component)] +struct StatSnapshot { + additive_bonuses: f32, + multiplicative_bonuses: f32, + effective: f32, + marker: PhantomData, +} + +impl StatBundle { + pub fn new(baseline: f32) -> Self { + Self { + baseline: BaselineStat { + value: baseline, + marker: PhantomData, + }, + effective: EffectiveStat { + value: baseline, + marker: PhantomData, + }, + additive: AdditiveBonuses { + factor: 1.0, + marker: PhantomData, + }, + multiplicative: MultiplicativeBonuses { + factor: 1.0, + marker: PhantomData, + }, + } + } +} + +#[derive(Component)] +pub struct AdditiveBonus { + pub label: &'static str, + pub value: f32, + marker: PhantomData, +} + +impl AdditiveBonus { + pub fn new(label: &'static str, value: f32) -> Self { + Self { + label, + value, + marker: PhantomData, + } + } +} + +#[derive(Component)] +pub struct MultiplicativeBonus { + pub label: &'static str, + pub value: f32, + marker: PhantomData, +} + +impl MultiplicativeBonus { + pub fn new(label: &'static str, value: f32) -> Self { + Self { + label, + value, + marker: PhantomData, + } + } +} + +fn add_additive_bonus( + In(entities): In>, + effect_q: Query<(&AdditiveBonus, &Parent)>, + mut stat_q: Query<( + &BaselineStat, + &mut AdditiveBonuses, + &MultiplicativeBonuses, + &mut EffectiveStat, + )>, +) { + for (bonus, player) in effect_q.iter_many(entities) { + let (baseline, mut add, mult, mut eff) = stat_q.get_mut(player.get()).unwrap(); + add.factor += bonus.value; + eff.value = baseline.value * add.factor * mult.factor; + } +} + +fn revert_additive_bonus( + In(entities): In>, + effect_q: Query<(&AdditiveBonus, &Parent)>, + mut stat_q: Query<( + &BaselineStat, + &mut AdditiveBonuses, + &MultiplicativeBonuses, + &mut EffectiveStat, + )>, +) { + for (bonus, player) in effect_q.iter_many(entities) { + let (baseline, mut add, mult, mut eff) = stat_q.get_mut(player.get()).unwrap(); + add.factor -= bonus.value; + eff.value = baseline.value * add.factor * mult.factor; + } +} + +fn add_multiplicative_bonus( + In(entities): In>, + effect_q: Query<(&MultiplicativeBonus, &Parent)>, + mut stat_q: Query<( + &BaselineStat, + &AdditiveBonuses, + &mut MultiplicativeBonuses, + &mut EffectiveStat, + )>, +) { + for (bonus, player) in effect_q.iter_many(entities) { + let (baseline, add, mut mult, mut eff) = stat_q.get_mut(player.get()).unwrap(); + mult.factor *= bonus.value; + eff.value = baseline.value * add.factor * mult.factor; + } +} + +fn revert_multiplicative_bonus( + In(entities): In>, + effect_q: Query<(&MultiplicativeBonus, &Parent)>, + mut stat_q: Query<( + &BaselineStat, + &AdditiveBonuses, + &mut MultiplicativeBonuses, + &mut EffectiveStat, + )>, +) { + for (bonus, player) in effect_q.iter_many(entities) { + let (baseline, add, mut mult, mut eff) = stat_q.get_mut(player.get()).unwrap(); + mult.factor /= bonus.value; + eff.value = baseline.value * add.factor * mult.factor; + } +} + +fn create_stat_snapshots( + stat_q: Query<( + Entity, + &AdditiveBonuses, + &MultiplicativeBonuses, + &EffectiveStat, + )>, + mut commands: Commands, +) { + for (stat, add, mult, eff) in stat_q.iter() { + commands.entity(stat).insert(StatSnapshot:: { + additive_bonuses: add.factor, + multiplicative_bonuses: mult.factor, + effective: eff.value, + marker: PhantomData, + }); + } +} + +fn restore_stats( + mut stat_q: Query<( + &StatSnapshot, + &mut AdditiveBonuses, + &mut MultiplicativeBonuses, + &mut EffectiveStat, + )>, +) { + for (snapshot, mut add, mut mult, mut eff) in stat_q.iter_mut() { + add.factor = snapshot.additive_bonuses; + mult.factor = snapshot.multiplicative_bonuses; + eff.value = snapshot.effective; + } +} + +fn apply_simple_stat_bonus( + In(entities): In>, + effect_q: Query<(&SimpleStatBonus, &Parent)>, + mut stat_q: Query<&mut SimpleStatEffective>, +) { + for (bonus, target) in effect_q.iter_many(entities) { + let mut effective = stat_q.get_mut(target.get()).unwrap(); + effective.value = Stat::apply_bonus(effective.value, bonus.value); + } +} + +fn revert_simple_stat_bonus( + In(entities): In>, + effect_q: Query<(&SimpleStatBonus, &Parent)>, + mut stat_q: Query<&mut SimpleStatEffective>, +) { + for (bonus, target) in effect_q.iter_many(entities) { + let mut effective = stat_q.get_mut(target.get()).unwrap(); + effective.value = Stat::revert_bonus(effective.value, bonus.value); + } +} + +fn create_simple_stat_snapshots( + stat_q: Query<(Entity, &SimpleStatEffective)>, + mut commands: Commands, +) { + for (stat, eff) in stat_q.iter() { + commands.entity(stat).insert(SimpleStatSnapshot:: { + value: eff.value, + marker: PhantomData, + }); + } +} + +fn restore_simple_stats( + mut stat_q: Query<(&mut SimpleStatEffective, &SimpleStatSnapshot)>, +) { + for (mut eff, snapshot) in stat_q.iter_mut() { + eff.value = snapshot.value; + } +} + +pub(crate) fn configure(stages: &mut Stages) { + fn register_stat_effects(stages: &mut Stages) { + stages + .register_effect::>() + .apply(add_additive_bonus::) + .teardown(revert_additive_bonus::) + .build(); + + stages + .register_effect::>() + .apply(add_multiplicative_bonus::) + .teardown(revert_multiplicative_bonus::) + .build(); + + stages.snapshot.add_systems(create_stat_snapshots::); + stages.restore.add_systems(restore_stats::); + } + + fn register_simple_stat_effects(stages: &mut Stages) { + stages + .register_effect::>() + .apply(apply_simple_stat_bonus::) + .teardown(revert_simple_stat_bonus::) + .build(); + + stages + .snapshot + .add_systems(create_simple_stat_snapshots::); + stages.restore.add_systems(restore_simple_stats::); + } + + register_stat_effects::(stages); + register_stat_effects::(stages); + register_stat_effects::(stages); + register_stat_effects::(stages); + + register_simple_stat_effects::(stages); + register_simple_stat_effects::(stages); + register_simple_stat_effects::(stages); + register_simple_stat_effects::(stages); + register_simple_stat_effects::(stages); + register_simple_stat_effects::(stages); +} diff --git a/src/player/status_effect.rs b/src/player/status_effect.rs new file mode 100644 index 0000000..0df2132 --- /dev/null +++ b/src/player/status_effect.rs @@ -0,0 +1,583 @@ +use std::{collections::VecDeque, marker::PhantomData}; + +use bevy_ecs::prelude::*; +use macros::LogMessage; +use rand::Rng as _; + +use crate::{ + effect::{Effects, TimeLimitedEffect}, + hierarchy::{HierarchyBuilder, Parent}, + log::Logger, + weapon::temp::AssociatedWeapon, + Rng, Stages, +}; + +use super::stats::{ + AdditiveBonus, Defence, Dexterity, MultiplicativeBonus, Speed, StatMarker, Strength, +}; + +#[derive(Component)] +pub struct StatusEffectStack { + pub effects: VecDeque, + pub bonus: Entity, + pub marker: std::marker::PhantomData, +} + +#[derive(Component, Default)] +pub struct TempDebuffEffect(std::marker::PhantomData); + +#[derive(Component, Default)] +pub struct TempDebuffImmunity(std::marker::PhantomData); + +pub trait DebuffingTempMarker: Send + Sync + 'static { + type Stat: StatMarker; + + fn factor() -> f32; + fn duration() -> std::ops::Range; +} + +#[derive(Component, Default)] +pub struct TearGas; + +impl DebuffingTempMarker for TearGas { + type Stat = Dexterity; + fn factor() -> f32 { + 1.0 / 3.0 + } + fn duration() -> std::ops::Range { + 120.0..180.0 + } +} + +#[derive(Component, Default)] +pub struct SmokeGrenade; + +impl DebuffingTempMarker for SmokeGrenade { + type Stat = Speed; + fn factor() -> f32 { + 1.0 / 3.0 + } + fn duration() -> std::ops::Range { + 120.0..180.0 + } +} + +#[derive(Component, Default)] +pub struct PepperSpray; + +impl DebuffingTempMarker for PepperSpray { + type Stat = Dexterity; + fn factor() -> f32 { + 1.0 / 5.0 + } + fn duration() -> std::ops::Range { + 15.0..20.0 + } +} + +#[derive(Component, Default)] +pub struct ConcussionGrenade; + +impl DebuffingTempMarker for ConcussionGrenade { + type Stat = Dexterity; + fn factor() -> f32 { + 1.0 / 5.0 + } + fn duration() -> std::ops::Range { + 15.0..20.0 + } +} + +#[derive(Component, Default)] +pub struct FlashGrenade; + +impl DebuffingTempMarker for FlashGrenade { + type Stat = Speed; + fn factor() -> f32 { + 1.0 / 5.0 + } + fn duration() -> std::ops::Range { + 15.0..20.0 + } +} + +#[derive(Component, Default)] +pub struct Sand; + +impl DebuffingTempMarker for Sand { + type Stat = Speed; + fn factor() -> f32 { + 1.0 / 5.0 + } + fn duration() -> std::ops::Range { + 15.0..20.0 + } +} + +#[derive(Component)] +struct LinkedComponents([Entity; N]); + +trait Stats { + fn spawn_additive_effects( + effects: &mut Effects, + target: Entity, + value: f32, + label: &'static str, + ) -> [Entity; N]; +} + +impl Stats<1> for T +where + T: StatMarker, +{ + fn spawn_additive_effects( + effects: &mut Effects, + target: Entity, + value: f32, + label: &'static str, + ) -> [Entity; 1] { + <(T,) as Stats<1>>::spawn_additive_effects(effects, target, value, label) + } +} + +macro_rules! impl_n_stats { + ($n:expr, $($t:tt),+) => { + impl<$($t,)+> Stats<$n> for ($($t,)+) where $($t: StatMarker,)+ { + fn spawn_additive_effects( + effects: &mut Effects, + target: Entity, + value: f32, + label: &'static str, + ) -> [Entity; $n] { + [$(effects.spawn(AdditiveBonus::<$t>::new(label, value), target),)+] + } + } + }; +} + +impl_n_stats!(1, A); +impl_n_stats!(2, A, B); +impl_n_stats!(3, A, B, C); +impl_n_stats!(4, A, B, C, D); + +trait AdditiveStatusEffectMarker: Send + Sync + 'static { + type AffectedStats: Stats; + fn max_stack() -> usize; + fn factor() -> f32; + fn duration() -> f32; +} + +#[derive(Component)] +struct AdditiveStatusEffect +where + M: AdditiveStatusEffectMarker, +{ + marker: PhantomData, + extra_effectiveness: f32, + extra_duration: f32, +} + +impl> Default for AdditiveStatusEffect { + fn default() -> Self { + Self { + marker: PhantomData, + extra_effectiveness: 0.0, + extra_duration: 0.0, + } + } +} + +impl> AdditiveStatusEffect { + pub fn new(extra_effectiveness: f32, extra_duration: f32) -> Self { + Self { + marker: PhantomData, + extra_effectiveness, + extra_duration, + } + } +} + +pub struct Withered; + +impl AdditiveStatusEffectMarker<1> for Withered { + type AffectedStats = Strength; + fn max_stack() -> usize { + 3 + } + fn factor() -> f32 { + -0.25 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Weakened; + +impl AdditiveStatusEffectMarker<1> for Weakened { + type AffectedStats = Defence; + fn max_stack() -> usize { + 3 + } + fn factor() -> f32 { + -0.25 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Slow; + +impl AdditiveStatusEffectMarker<1> for Slow { + type AffectedStats = Speed; + fn max_stack() -> usize { + 3 + } + fn factor() -> f32 { + -0.25 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Crippled; + +impl AdditiveStatusEffectMarker<1> for Crippled { + type AffectedStats = Dexterity; + fn max_stack() -> usize { + 3 + } + fn factor() -> f32 { + -0.25 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Motivate; + +impl AdditiveStatusEffectMarker<4> for Motivate { + type AffectedStats = (Strength, Defence, Speed, Dexterity); + fn max_stack() -> usize { + 5 + } + fn factor() -> f32 { + 0.1 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Strengthened; + +impl AdditiveStatusEffectMarker<1> for Strengthened { + type AffectedStats = Strength; + fn max_stack() -> usize { + 1 + } + fn factor() -> f32 { + 5.0 + } + fn duration() -> f32 { + 120.0 + } +} + +pub struct Hardened; + +impl AdditiveStatusEffectMarker<1> for Hardened { + type AffectedStats = Defence; + fn max_stack() -> usize { + 1 + } + fn factor() -> f32 { + 3.0 + } + fn duration() -> f32 { + 120.0 + } +} + +pub struct Hastened; + +impl AdditiveStatusEffectMarker<1> for Hastened { + type AffectedStats = Speed; + fn max_stack() -> usize { + 1 + } + fn factor() -> f32 { + 5.0 + } + fn duration() -> f32 { + 120.0 + } +} + +pub struct Sharpened; + +impl AdditiveStatusEffectMarker<1> for Sharpened { + type AffectedStats = Dexterity; + fn max_stack() -> usize { + 1 + } + fn factor() -> f32 { + 5.0 + } + fn duration() -> f32 { + 120.0 + } +} + +pub struct Demoralise; + +impl AdditiveStatusEffectMarker<4> for Demoralise { + type AffectedStats = (Strength, Defence, Speed, Dexterity); + fn max_stack() -> usize { + 5 + } + fn factor() -> f32 { + -0.1 + } + fn duration() -> f32 { + 300.0 + } +} + +pub struct Frozen; + +impl AdditiveStatusEffectMarker<2> for Frozen { + type AffectedStats = (Speed, Dexterity); + fn max_stack() -> usize { + 1 + } + fn factor() -> f32 { + -0.5 + } + fn duration() -> f32 { + 300.0 + } +} + +fn apply_additive_status_effect>( + In(entities): In>, + effect_q: Query<(Entity, &Parent, &AdditiveStatusEffect)>, + mut parent_q: Query>>, + mut commands: Commands, + mut effects: Effects, +) { + for (entity, player, effect) in effect_q.iter_many(entities) { + let stack = parent_q.get_mut(player.get()).unwrap(); + + let new_effects = >::spawn_additive_effects( + &mut effects, + player.get(), + M::factor() * (1.0 + effect.extra_effectiveness), + std::any::type_name::(), + ); + + commands.entity(entity).insert(( + LinkedComponents(new_effects), + TimeLimitedEffect(M::duration() * (1.0 + effect.extra_duration)), + )); + + if let Some(mut stack) = stack { + stack.effects.push_back(entity); + if stack.effects.len() > M::max_stack() { + let first = stack.effects.pop_front().unwrap(); + effects.remove(first); + } + } else { + commands.spawn(StatusEffectStack:: { + effects: VecDeque::from([entity]), + bonus: entity, + marker: PhantomData, + }); + } + } +} + +fn remove_additive_status_effect>( + In(entities): In>, + effect_q: Query<(Entity, &Parent)>, + mut parent_q: Query>>, + linked_q: Query<&LinkedComponents>, + mut effects: Effects, +) { + for (effect, player) in effect_q.iter_many(entities) { + if let Some(mut stack) = parent_q.get_mut(player.get()).unwrap() { + if stack.effects.front() == Some(&effect) { + stack.effects.pop_front(); + } + } + + let linked = linked_q.get(effect).unwrap(); + + for linked_effect in linked.0 { + effects.remove(linked_effect); + } + } +} + +fn apply_temp_debuff_effect( + In(entities): In>, + mut rng: ResMut, + temp_q: Query<(Entity, &Parent, &AssociatedWeapon)>, + weapon_q: Query<&Parent>, + mut parent_q: Query<( + Option<&mut StatusEffectStack>, + Has>, + )>, + (mut commands, mut effects): (Commands, Effects), + mut logger: Logger, +) { + #[derive(LogMessage)] + pub struct UsedDebuffTemp { + #[log(player)] + pub actor: Entity, + #[log(player)] + pub recipient: Entity, + #[log(weapon)] + pub weapon: Entity, + pub immune: bool, + } + + for (effect, player, weapon) in temp_q.iter_many(entities) { + let (stack, immunity) = parent_q.get_mut(player.get()).unwrap(); + let user = weapon_q.get(weapon.0).unwrap(); + if immunity { + commands.entity(effect).despawn(); + commands.entity(player.get()).remove_child(effect); + logger.log(|| UsedDebuffTemp { + actor: user.get(), + recipient: player.get(), + weapon: weapon.0, + immune: true, + }); + continue; + } + + let duration = rng.gen_range(Temp::duration()); + commands.entity(effect).insert(TimeLimitedEffect(duration)); + + let stack_size = stack.as_ref().map_or(0, |s| s.effects.len()) as i32; + + let effective_factor = (0..=stack_size).fold(1.0, |acc, i| { + acc * (1.0 - (1.0 - Temp::factor()) * 2.0f32.powi(-i)) + }); + + let bonus = effects.spawn( + MultiplicativeBonus::::new(std::any::type_name::(), effective_factor), + player.get(), + ); + + if let Some(mut stack) = stack { + effects.remove(stack.bonus); + stack.bonus = bonus; + stack.effects.push_back(effect); + } else { + commands + .entity(player.get()) + .insert(StatusEffectStack:: { + effects: VecDeque::from([effect]), + bonus, + marker: PhantomData, + }); + } + + logger.log(|| UsedDebuffTemp { + actor: user.get(), + recipient: player.get(), + weapon: weapon.0, + immune: false, + }); + } +} + +fn remove_temp_debuff_effect( + In(entities): In>, + temp_q: Query<&Parent>, + mut parent_q: Query<(&mut StatusEffectStack, Has>)>, + mut commands: Commands, + _logger: Logger, + mut effects: Effects, +) { + #[derive(LogMessage)] + struct RemovedDebuffTemp { + #[log(player)] + recipient: Entity, + factor: f32, + factor_remaining: f32, + } + + for player in temp_q.iter_many(entities) { + let (mut stack, immunity) = parent_q.get_mut(player.get()).unwrap(); + if immunity { + continue; + } + + effects.remove(stack.bonus); + + let stack_size = (stack.effects.len() - 1) as i32; + + if stack_size > 0 { + let effective_factor = (0..=stack_size).fold(1.0, |acc, i| { + acc * (1.0 - (1.0 - Temp::factor()) * 2.0f32.powi(-i)) + }); + + stack.bonus = effects.spawn( + MultiplicativeBonus::::new( + std::any::type_name::(), + effective_factor, + ), + player.get(), + ); + + stack.effects.pop_front(); + } else { + commands + .entity(player.get()) + .remove::>(); + } + } +} + +fn register_debuff_temp(stages: &mut Stages) { + stages + .register_effect::>() + .apply(apply_temp_debuff_effect::) + .teardown(remove_temp_debuff_effect::) + .build(); +} + +fn register_status_effect>(stages: &mut Stages) { + stages + .register_effect::>() + .apply(apply_additive_status_effect::) + .teardown(remove_additive_status_effect::) + .build(); +} + +pub(crate) fn configure(stages: &mut Stages) { + register_debuff_temp::(stages); + register_debuff_temp::(stages); + register_debuff_temp::(stages); + register_debuff_temp::(stages); + register_debuff_temp::(stages); + register_debuff_temp::(stages); + + register_status_effect::<1, Withered>(stages); + register_status_effect::<1, Weakened>(stages); + register_status_effect::<1, Slow>(stages); + register_status_effect::<1, Crippled>(stages); + register_status_effect::<1, Strengthened>(stages); + register_status_effect::<1, Hardened>(stages); + register_status_effect::<1, Hastened>(stages); + register_status_effect::<1, Sharpened>(stages); + register_status_effect::<4, Motivate>(stages); + register_status_effect::<4, Demoralise>(stages); + register_status_effect::<2, Frozen>(stages); +} diff --git a/src/weapon/bonus.rs b/src/weapon/bonus.rs new file mode 100644 index 0000000..70b054f --- /dev/null +++ b/src/weapon/bonus.rs @@ -0,0 +1,278 @@ +use bevy_ecs::prelude::*; + +use crate::{ + effect::{Effects, TurnLimitedEffect}, + hierarchy::{HierarchyBuilder, Parent}, + player::stats::{ + AdditiveBonus, AmmoControl, Clips, CritRate, DamageBonus, SimpleStatBonus, + SimpleStatEffective, Speed, Strength, WeaponAccuracy, + }, + Stages, +}; + +use super::{DamageProcEffect, FirstTurnEffect, TurnTriggeredEffect}; + +#[derive(Component, Debug, Clone, Copy)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum WeaponBonus { + // Weapon passives + Berserk, + Conserve, + Expose, + Grace, + Powerful, + Specialist, + + // Turn triggered passives + Empower, + Quicken, + + // First turn effects + Assassinate, + + // Additive status effects triggered by damaging hits + Cripple, + Demoralise, + Freeze, + Motivate, + Slow, + Toxin, + Weaken, + Wither, + + // DOT status effects + Bleed, + Burning, + Lacerate, + Poison, + SevereBurning, + + // Other status effects + Eviscerate, + Paralyse, + Schock, + Stun, + + // Multi attack bonuses + Blindfire, + Fury, + DoubleTap, + Rage, + + // Body part multipliers + Achilles, + Crusher, + Cupid, + Deadeye, + Roshambo, + Throttle, + + // Attack nullification types + Homerun, + Parry, +} + +#[derive(Component)] +pub struct BonusValue(f32); + +#[derive(Bundle)] +pub struct WeaponBonusBundle { + pub bonus: WeaponBonus, + pub value: BonusValue, +} + +impl WeaponBonusBundle { + pub fn new(bonus: WeaponBonus, value: f32) -> Self { + Self { + bonus, + value: BonusValue(value), + } + } +} + +#[derive(Clone, Copy)] +pub enum TurnTriggeredBonus { + Empower, + Quicken, +} + +impl TurnTriggeredBonus { + pub fn trigger(self, value: f32, effects: &mut Effects, current: Entity, _target: Entity) { + match self { + Self::Empower => { + effects.spawn_and_insert( + AdditiveBonus::::new("empower", value / 100.0), + current, + TurnLimitedEffect::new(current, 0), + ); + } + Self::Quicken => { + effects.spawn_and_insert( + AdditiveBonus::::new("empower", value / 100.0), + current, + TurnLimitedEffect::new(current, 0), + ); + } + } + } +} + +#[derive(Clone, Copy)] +pub enum FirstTurnBonus { + Assassinate, +} + +impl FirstTurnBonus { + pub(crate) fn spawn(self, effects: &mut Effects, weapon: Entity, owner: Entity, value: f32) { + match self { + Self::Assassinate => effects.spawn_and_insert( + SimpleStatBonus::::new("assassinate", value / 100.0), + weapon, + TurnLimitedEffect::new(owner, 1), + ), + }; + } +} + +#[derive(Clone, Copy)] +pub enum MultiTurnBonus { + Blindfire, + Fury, + DoubleTap, + Rage, +} + +pub(crate) fn prepare_bonuses( + bonus_q: Query<( + &Parent, + &WeaponBonus, + &BonusValue, + Option<&SimpleStatEffective>, + )>, + mut effects: Effects, + mut commands: Commands, +) { + for (weapon, bonus, value, clips) in bonus_q.iter() { + match bonus { + WeaponBonus::Berserk => { + effects.spawn( + SimpleStatBonus::::new("beserk", value.0 / 100.0), + weapon.get(), + ); + effects.spawn( + SimpleStatBonus::::new("beserk", -value.0 / 2.0 / 50.0), + weapon.get(), + ); + } + WeaponBonus::Conserve => { + effects.spawn( + SimpleStatBonus::::new("conserve", value.0 / 100.0), + weapon.get(), + ); + } + WeaponBonus::Expose => { + effects.spawn( + SimpleStatBonus::::new("expose", (value.0 / 0.5) as u16), + weapon.get(), + ); + } + WeaponBonus::Grace => { + effects.spawn( + SimpleStatBonus::::new("grace", -value.0 / 2.0 / 100.0), + weapon.get(), + ); + effects.spawn( + SimpleStatBonus::::new("grace", value.0 / 50.0), + weapon.get(), + ); + } + WeaponBonus::Powerful => { + effects.spawn( + SimpleStatBonus::::new("powerful", value.0 / 100.0), + weapon.get(), + ); + } + WeaponBonus::Specialist => { + effects.spawn( + SimpleStatBonus::::new("specialist", value.0 / 100.0), + weapon.get(), + ); + effects.spawn( + SimpleStatBonus::::new( + "specialist", + -clips.map(|c| c.value as i16).unwrap_or_default(), + ), + weapon.get(), + ); + } + + WeaponBonus::Empower => { + commands + .spawn(TurnTriggeredEffect::Bonus { + value: value.0, + bonus: TurnTriggeredBonus::Empower, + }) + .set_parent(weapon.get()); + } + WeaponBonus::Quicken => { + commands + .spawn(TurnTriggeredEffect::Bonus { + value: value.0, + bonus: TurnTriggeredBonus::Quicken, + }) + .set_parent(weapon.get()); + } + + WeaponBonus::Assassinate => { + commands + .spawn(FirstTurnEffect::Bonus { + value: value.0, + bonus: FirstTurnBonus::Assassinate, + }) + .set_parent(weapon.get()); + } + + WeaponBonus::Blindfire => { + commands + .spawn(DamageProcEffect::MultiTurn { + value: value.0, + bonus: MultiTurnBonus::Blindfire, + }) + .set_parent(weapon.get()); + } + WeaponBonus::Fury => { + commands + .spawn(DamageProcEffect::MultiTurn { + value: value.0, + bonus: MultiTurnBonus::Fury, + }) + .set_parent(weapon.get()); + } + WeaponBonus::Rage => { + commands + .spawn(DamageProcEffect::MultiTurn { + value: value.0, + bonus: MultiTurnBonus::Rage, + }) + .set_parent(weapon.get()); + } + WeaponBonus::DoubleTap => { + commands + .spawn(DamageProcEffect::MultiTurn { + value: value.0, + bonus: MultiTurnBonus::DoubleTap, + }) + .set_parent(weapon.get()); + } + + val => unimplemented!("{val:?}"), + } + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages + .pre_fight + .add_systems(prepare_bonuses.after(super::apply_passives)); +} diff --git a/src/weapon/mod.rs b/src/weapon/mod.rs new file mode 100644 index 0000000..b67d4a6 --- /dev/null +++ b/src/weapon/mod.rs @@ -0,0 +1,955 @@ +use bevy_ecs::prelude::*; +use macros::LogMessage; +use rand::Rng as _; + +use crate::{ + effect::{Effects, TurnLimitedEffect}, + hierarchy::{HierarchyBuilder, Parent}, + log::Logger, + metrics::Metrics, + passives::{Education, FactionUpgrades, Merits}, + player::{ + stats::{ + AdditiveBonus, AmmoControl, ClipSize, Clips, CritRate, DamageBonus, Dexterity, + EffectiveStat, SimpleStatBonus, SimpleStatBundle, SimpleStatEffective, Speed, + WeaponAccuracy, + }, + BodyPart, Current, CurrentTarget, InitiateHit, Player, Weapons, + }, + Id, Name, Rng, Stages, +}; + +use self::{ + bonus::{FirstTurnBonus, MultiTurnBonus, TurnTriggeredBonus}, + temp::{NonTargeted, Uses}, +}; + +pub mod bonus; +pub mod temp; + +#[derive(Component)] +pub struct Usable; + +#[derive(Component)] +pub struct Weapon; + +#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum WeaponSlot { + Primary, + Secondary, + Melee, + Temporary, + Fists, + Kick, +} + +#[derive(Component, Debug, Clone, Copy)] +pub enum WeaponVerb { + Hit, + Kicked, + Fired, + Threw, + Exploded, +} + +#[derive(Component, Clone, Copy)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum WeaponCategory { + HeavyArtillery, + MachineGun, + Rifle, + Smg, + Shotgun, + Pistol, + Club, + Piercing, + Slashing, + Mechanical, + Temporary, + HandToHand, +} + +#[derive(Component, Debug)] +pub struct DamageStat(pub f32); + +#[derive(Component)] +pub struct Japanese; + +#[derive(Component)] +pub struct Ammo(pub u16); + +#[derive(Component)] +pub struct RateOfFire(pub [u16; 2]); + +#[derive(Component)] +pub struct NeedsReload; + +#[derive(Clone, Copy)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +#[cfg_attr(feature = "json", serde(rename_all = "snake_case"))] +pub enum WeaponMod { + ReflexSight, + HolographicSight, + AcogSight, + ThermalSight, + Laser1mw, + Laser5mw, + Laser30mw, + Laser100mw, + SmallSuppressor, + StandardSuppressor, + LargeSuppressor, + ExtendedMags, + HighCapacityMags, + ExtraClip, + ExtraClip2, + AdjustableTrigger, + HairTrigger, + Bipod, + Tripod, + CustomGrip, + SkeetChoke, + ImprovedChoke, + FullChoke, + RecoilPad, + StandardBrake, + HeavyDutyBreak, + TacticalBrake, + SmallLight, + PrecisionLight, + TacticalIlluminator, +} + +#[derive(Clone, Copy)] +pub enum TurnTriggeredMod { + Bipod, + Tripod, + SmallLight, + PrecisionLight, + TacticalIlluminator, +} + +impl TurnTriggeredMod { + pub fn trigger(self, effects: &mut Effects, current: Entity, target: Entity) { + match self { + Self::Bipod => { + effects.spawn_and_insert( + AdditiveBonus::::new("bipod", -0.3), + current, + TurnLimitedEffect::new(current, 0), + ); + } + Self::Tripod => { + effects.spawn_and_insert( + AdditiveBonus::::new("tripod", -0.3), + current, + TurnLimitedEffect::new(current, 0), + ); + } + Self::SmallLight => { + effects.spawn_and_insert( + SimpleStatBonus::::new("small light", -3.0 / 50.0), + target, + TurnLimitedEffect::new(target, 1), + ); + } + Self::PrecisionLight => { + effects.spawn_and_insert( + SimpleStatBonus::::new("precision light", -4.0 / 50.0), + target, + TurnLimitedEffect::new(target, 1), + ); + } + Self::TacticalIlluminator => { + effects.spawn_and_insert( + SimpleStatBonus::::new("tactical illuminator", -5.0 / 50.0), + target, + TurnLimitedEffect::new(target, 1), + ); + } + } + } +} + +/// Effects that are triggered by selecting a weapon +#[derive(Component)] +pub enum TurnTriggeredEffect { + Mod(TurnTriggeredMod), + Bonus { + value: f32, + bonus: TurnTriggeredBonus, + }, +} + +impl TurnTriggeredEffect { + pub fn trigger(&self, effects: &mut Effects, current: Entity, target: Entity) { + match self { + TurnTriggeredEffect::Mod(weapon_mod) => { + weapon_mod.trigger(effects, current, target); + } + TurnTriggeredEffect::Bonus { value, bonus } => { + bonus.trigger(*value, effects, current, target) + } + } + } +} + +#[derive(Clone, Copy)] +pub enum FirstTurnMod { + AdjustableTrigger, + HairTrigger, +} + +impl FirstTurnMod { + fn spawn(self, effects: &mut Effects, weapon: Entity, owner: Entity) { + match self { + Self::AdjustableTrigger => effects.spawn_and_insert( + SimpleStatBonus::::new("adjustable trigger", 5.0 / 50.0), + weapon, + TurnLimitedEffect::new(owner, 1), + ), + Self::HairTrigger => effects.spawn_and_insert( + SimpleStatBonus::::new("hair trigger", 7.5 / 50.0), + weapon, + TurnLimitedEffect::new(owner, 1), + ), + }; + } +} + +#[derive(Component)] +pub enum FirstTurnEffect { + Mod(FirstTurnMod), + Bonus { value: f32, bonus: FirstTurnBonus }, +} + +#[derive(Component)] +pub enum DamageProcEffect { + MultiTurn { value: f32, bonus: MultiTurnBonus }, +} + +impl FirstTurnEffect { + fn spawn(&self, effects: &mut Effects, weapon: Entity, owner: Entity) { + match self { + Self::Mod(weapon_mod) => weapon_mod.spawn(effects, weapon, owner), + Self::Bonus { value, bonus } => bonus.spawn(effects, weapon, owner, *value), + }; + } +} + +#[derive(Component)] +pub struct EquippedMods(pub Vec); + +#[derive(Component)] +pub struct Experience(pub f32); + +#[derive(LogMessage)] +pub struct ReloadWeapon { + #[log(player)] + pub actor: Entity, + #[log(weapon)] + pub weapon: Entity, +} + +#[derive(LogMessage)] +pub struct MissTarget { + #[log(player)] + pub actor: Entity, + #[log(player)] + pub recipient: Entity, + #[log(weapon)] + pub weapon: Entity, + pub rounds: Option, +} + +#[derive(Bundle)] +pub struct WeaponBundle { + pub usable: Usable, + pub weapon: Weapon, + pub name: Name, + pub id: Id, + pub verb: WeaponVerb, + pub slot: WeaponSlot, +} + +#[derive(Bundle)] +pub struct DamagingWeaponBundle { + pub crit_rate: SimpleStatBundle, + pub dmg: DamageStat, + pub acc: SimpleStatBundle, + pub dmg_bonus: SimpleStatBundle, + pub equipped_mods: EquippedMods, + pub experience: Experience, + pub category: WeaponCategory, +} + +#[derive(Bundle)] +pub struct AmmoWeaponBundle { + pub ammo: Ammo, + pub clips: SimpleStatBundle, + pub clip_size: SimpleStatBundle, + pub rate_of_fire: RateOfFire, + pub ammo_control: SimpleStatBundle, +} + +impl WeaponBundle { + pub fn new(name: String, id: usize, verb: WeaponVerb, slot: WeaponSlot) -> Self { + Self { + usable: Usable, + weapon: Weapon, + name: Name(name), + id: Id(id), + verb, + slot, + } + } + + pub fn fists(id: usize) -> Self { + Self::new("Fists".to_owned(), id, WeaponVerb::Hit, WeaponSlot::Fists) + } + + pub fn kick(id: usize) -> Self { + Self::new("Kick".to_owned(), id, WeaponVerb::Kicked, WeaponSlot::Kick) + } +} + +impl DamagingWeaponBundle { + pub fn new( + dmg: f32, + acc: f32, + mods: Vec, + exp: f32, + category: WeaponCategory, + ) -> Self { + Self { + crit_rate: SimpleStatBundle::new(0), + dmg: DamageStat(dmg / 10.0), + acc: SimpleStatBundle::new((acc - 50.0) / 50.0), + dmg_bonus: SimpleStatBundle::new(1.0), + equipped_mods: EquippedMods(mods), + experience: Experience(exp), + category, + } + } + + pub fn fists() -> Self { + // NOTE: The accuracy value is taken from the attack page. The damage value here differs + // from the one in Proxima's simulator, but in some quick tests 10.0 proofed to be a better fit + // This might have changed in the weapon damage update + Self::new(10.0, 50.0, Vec::default(), 0.0, WeaponCategory::HandToHand) + } + + pub fn kick() -> Self { + // NOTE: The accuracy value is taken from the attack page. The damage value here differs + // from the one in Proxima's simulator, but in some quick tests 30.0 proofed to be a better fit + // This might have changed in the weapon damage update + Self::new(30.0, 40.0, Vec::default(), 0.0, WeaponCategory::HandToHand) + } +} + +impl AmmoWeaponBundle { + pub fn new(clips: u16, clip_size: u16, rof: [u16; 2]) -> Self { + Self { + ammo: Ammo(clip_size), + clips: SimpleStatBundle::new(clips - 1), + clip_size: SimpleStatBundle::new(clip_size), + rate_of_fire: RateOfFire(rof), + ammo_control: SimpleStatBundle::new(0.0), + } + } +} + +fn set_owner(weapons_q: Query<(Entity, &Weapons)>, mut commands: Commands) { + for (player, weapons) in weapons_q.iter() { + if let Some(primary) = weapons.primary { + commands.entity(primary).set_parent(player); + } + if let Some(secondary) = weapons.secondary { + commands.entity(secondary).set_parent(player); + } + if let Some(melee) = weapons.melee { + commands.entity(melee).set_parent(player); + } + if let Some(temp) = weapons.temporary { + commands.entity(temp).set_parent(player); + } + commands.entity(weapons.fists.unwrap()).set_parent(player); + if let Some(kick) = weapons.kick { + commands.entity(kick).set_parent(player); + } + } +} + +fn apply_passives( + weapon_q: Query<( + Entity, + &EquippedMods, + &Experience, + &WeaponCategory, + &WeaponSlot, + Has, + &Parent, + )>, + player_q: Query<(&Merits, &Education, &FactionUpgrades)>, + mut effects: Effects, + mut commands: Commands, +) { + for (weapon, mods, exp, cat, slot, japanese, player) in weapon_q.iter() { + let exp = (exp.0 / 10.0).round(); + if exp > 0.0 { + effects.spawn( + SimpleStatBonus::::new("experience", exp * 0.2 / 50.0), + weapon, + ); + effects.spawn( + SimpleStatBonus::::new("experience", exp / 100.0), + weapon, + ); + } + + for w_mod in &mods.0 { + match w_mod { + WeaponMod::ReflexSight => { + effects.spawn( + SimpleStatBonus::::new("reflex sight", 1.0 / 50.0), + weapon, + ); + } + WeaponMod::HolographicSight => { + effects.spawn( + SimpleStatBonus::::new("holographic sight", 1.25 / 50.0), + weapon, + ); + } + WeaponMod::AcogSight => { + effects.spawn( + SimpleStatBonus::::new("ACOG sight", 1.5 / 50.0), + weapon, + ); + } + WeaponMod::ThermalSight => { + effects.spawn( + SimpleStatBonus::::new("thermal sight", 1.75 / 50.0), + weapon, + ); + } + WeaponMod::Laser1mw => { + effects.spawn(SimpleStatBonus::::new("1mw laser", 4), weapon); + } + WeaponMod::Laser5mw => { + effects.spawn(SimpleStatBonus::::new("5mw laser", 6), weapon); + } + WeaponMod::Laser30mw => { + effects.spawn(SimpleStatBonus::::new("30mw laser", 8), weapon); + } + WeaponMod::Laser100mw => { + effects.spawn(SimpleStatBonus::::new("100mw laser", 10), weapon); + } + WeaponMod::SmallSuppressor => { + effects.spawn( + SimpleStatBonus::::new("small suppressor", -0.05), + weapon, + ); + } + WeaponMod::StandardSuppressor => { + effects.spawn( + SimpleStatBonus::::new("standard suppressor", -0.05), + weapon, + ); + } + WeaponMod::LargeSuppressor => { + effects.spawn( + SimpleStatBonus::::new("large suppressor", -0.05), + weapon, + ); + } + WeaponMod::ExtendedMags => { + effects.spawn( + SimpleStatBonus::::new("extended mags", 1.2), + weapon, + ); + } + WeaponMod::HighCapacityMags => { + effects.spawn( + SimpleStatBonus::::new("high capacity mags", 1.3), + weapon, + ); + } + WeaponMod::CustomGrip => { + effects.spawn( + SimpleStatBonus::::new("custom grip", 0.75), + weapon, + ); + } + WeaponMod::SkeetChoke => { + effects.spawn( + SimpleStatBonus::::new("skeet choke", 0.06), + weapon, + ); + } + WeaponMod::ImprovedChoke => { + effects.spawn( + SimpleStatBonus::::new("improved choke", 0.08), + weapon, + ); + } + WeaponMod::FullChoke => { + effects.spawn( + SimpleStatBonus::::new("full choke", 0.10), + weapon, + ); + } + WeaponMod::ExtraClip => { + effects.spawn(SimpleStatBonus::::new("extra clip", 1), weapon); + } + WeaponMod::ExtraClip2 => { + effects.spawn(SimpleStatBonus::::new("extra clip x2", 2), weapon); + } + WeaponMod::RecoilPad => { + effects.spawn( + SimpleStatBonus::::new("recoil pad", 0.24), + weapon, + ); + } + WeaponMod::StandardBrake => { + effects.spawn( + SimpleStatBonus::::new("standard brake", 1.00 / 50.0), + weapon, + ); + } + WeaponMod::HeavyDutyBreak => { + effects.spawn( + SimpleStatBonus::::new("heavy duty brake", 1.25 / 50.0), + weapon, + ); + } + WeaponMod::TacticalBrake => { + effects.spawn( + SimpleStatBonus::::new("tactical brake", 1.50 / 50.0), + weapon, + ); + } + WeaponMod::Bipod => { + effects.spawn( + SimpleStatBonus::::new("bipod", 1.75 / 50.0), + weapon, + ); + commands + .spawn(TurnTriggeredEffect::Mod(TurnTriggeredMod::Bipod)) + .set_parent(weapon); + } + WeaponMod::Tripod => { + effects.spawn( + SimpleStatBonus::::new("bipod", 2.00 / 50.0), + weapon, + ); + commands + .spawn(TurnTriggeredEffect::Mod(TurnTriggeredMod::Tripod)) + .set_parent(weapon); + } + WeaponMod::SmallLight => { + commands + .spawn(TurnTriggeredEffect::Mod(TurnTriggeredMod::SmallLight)) + .set_parent(weapon); + } + WeaponMod::PrecisionLight => { + commands + .spawn(TurnTriggeredEffect::Mod(TurnTriggeredMod::PrecisionLight)) + .set_parent(weapon); + } + WeaponMod::TacticalIlluminator => { + commands + .spawn(TurnTriggeredEffect::Mod( + TurnTriggeredMod::TacticalIlluminator, + )) + .set_parent(weapon); + } + WeaponMod::AdjustableTrigger => { + commands + .spawn(FirstTurnEffect::Mod(FirstTurnMod::AdjustableTrigger)) + .set_parent(weapon); + } + WeaponMod::HairTrigger => { + commands + .spawn(FirstTurnEffect::Mod(FirstTurnMod::HairTrigger)) + .set_parent(weapon); + } + } + } + + let (merits, education, faction) = player_q.get(player.get()).unwrap(); + + let (mastery, edu_acc) = match cat { + WeaponCategory::HeavyArtillery => ( + merits.heavy_artillery_mastery, + education.cbt2860.then_some("CBT2860"), + ), + WeaponCategory::MachineGun => ( + merits.machine_gun_mastery, + education.cbt2830.then_some("CTB2830"), + ), + WeaponCategory::Rifle => (merits.rifle_mastery, education.cbt2850.then_some("CBT2850")), + WeaponCategory::Smg => (merits.smg_mastery, education.cbt2830.then_some("CBT2830")), + WeaponCategory::Shotgun => ( + merits.shotgun_mastery, + education.cbt2125.then_some("CBT2125"), + ), + WeaponCategory::Pistol => ( + merits.pistol_mastery, + education.cbt2840.then_some("CBT2840"), + ), + WeaponCategory::Club => (merits.club_mastery, None), + WeaponCategory::Piercing => (merits.piercing_mastery, None), + WeaponCategory::Slashing => (merits.slashing_mastery, None), + WeaponCategory::Mechanical => (merits.mechanical_mastery, None), + WeaponCategory::Temporary => { + if education.gen2119 { + effects.spawn(SimpleStatBonus::::new("GEN2119", 0.05), weapon); + } + ( + merits.temporary_mastery, + education.gen2116.then_some("GEN2116"), + ) + } + WeaponCategory::HandToHand => (0, None), + }; + + if let Some(label) = edu_acc { + effects.spawn( + SimpleStatBonus::::new(label, 1.0 / 50.0), + weapon, + ); + } + + // NOTE: Even though it says "weapons" in the description, this also applies to h2h + if education.bio2350 { + effects.spawn(SimpleStatBonus::::new("BIO2350", 0.01), weapon); + } + + if education.def3770 && *slot == WeaponSlot::Fists { + effects.spawn(SimpleStatBonus::::new("DEF3770", 1.0), weapon); + } + + if education.his2170 && *slot == WeaponSlot::Melee { + effects.spawn(SimpleStatBonus::::new("HIS2170", 0.02), weapon); + } + + if matches!(*slot, WeaponSlot::Primary | WeaponSlot::Secondary) { + if education.mth2310 { + effects.spawn(SimpleStatBonus::::new("MTH2310", 0.05), weapon); + } + if education.mth3330 { + effects.spawn(SimpleStatBonus::::new("MTH3330", 0.20), weapon); + } + } + + if education.his2160 && japanese { + effects.spawn(SimpleStatBonus::::new("HIS2160", 0.10), weapon); + } + + if mastery > 0 { + effects.spawn( + SimpleStatBonus::::new("mastery", (mastery as f32) * 0.2 / 50.0), + weapon, + ); + effects.spawn( + SimpleStatBonus::::new("mastery", (mastery as f32) / 100.0), + weapon, + ); + } + + if faction.dmg > 0 { + effects.spawn( + SimpleStatBonus::::new("faction", (faction.dmg as f32) * 0.01), + weapon, + ); + } + if faction.acc > 0 { + effects.spawn( + SimpleStatBonus::::new( + "faction", + (faction.dmg as f32) * 0.2 / 50.0, + ), + weapon, + ); + } + } +} + +fn unset_current(weapon_q: Query, With)>, mut commands: Commands) { + for weapon in weapon_q.iter() { + commands.entity(weapon).remove::(); + } +} + +// TODO: Move all mitigation aspects of this into the player system +pub fn use_damaging_weapon( + mut rng: ResMut, + weapon_q: Query< + ( + Entity, + &DamageStat, + &SimpleStatEffective, + &SimpleStatEffective, + &SimpleStatEffective, + Has, + ), + (With, With, Without), + >, + player_q: Query< + ( + Entity, + &EffectiveStat, + &SimpleStatEffective, + &SimpleStatEffective, + &SimpleStatEffective, + ), + (With, With), + >, + target_q: Query<(Entity, &EffectiveStat), With>, + (mut ammo_q, mut temp_q): ( + Query<( + &mut Ammo, + &SimpleStatEffective, + &RateOfFire, + &SimpleStatEffective, + )>, + Query<&mut Uses>, + ), + mut hit_events: EventWriter, + (mut logger, mut commands, metrics): (Logger, Commands, Res), +) { + let Ok((weapon, dmg, acc, dmg_bonus, crit, non_targeted)) = weapon_q.get_single() else { + return; + }; + let (player, player_spd, player_crit, acc_bonus, p_dmg_bonus) = player_q.single(); + let (target, target_dex) = target_q.single(); + + 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(usize), + Fury(usize), + DoubleTap { fired_first: bool }, + } + + let mut multi_attack_proc = None; + let crit = player_crit + crit; + + loop { + let rounds = ammo.as_mut().map(|(ref mut ammo, clips, rof)| { + let rounds = (rng.gen_range(rof.clone()).round() as u16).clamp(1, ammo.0); + metrics.increment_counter(player, "rounds_fired", rounds.into()); + metrics.increment_counter(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.gen_bool(hit_chance as f64) { + logger.log(|| MissTarget { + weapon, + actor: player, + recipient: target, + rounds, + }); + metrics.increment_counter(player, "miss", 1); + metrics.increment_counter(weapon, "miss", 1); + + if multi_attack_proc.is_none() { + return; + }; + } else { + let body_part = if !non_targeted { + rng.sample(crit) + } else { + BodyPart::Stomach + }; + + hit_events.send(InitiateHit { + body_part, + weapon, + rounds, + dmg: dmg.0, + dmg_bonus_weapon: dmg_bonus.value, + dmg_bonus_player: p_dmg_bonus.value, + hit_chance, + crit_rate: crit.value, + }); + } + + match multi_attack_proc { + Some(MultiAttack::Blindfire) => acc_eff.value -= 5.0 / 50.0, + Some(MultiAttack::Rage(turns @ 1..)) => { + multi_attack_proc = Some(MultiAttack::Rage(turns - 1)) + } + Some(MultiAttack::Fury(turns @ 1..)) => { + multi_attack_proc = Some(MultiAttack::Fury(turns - 1)) + } + Some(MultiAttack::DoubleTap { fired_first: false }) => { + multi_attack_proc = Some(MultiAttack::DoubleTap { fired_first: true }) + } + _ => break, + } + } + /* + 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.gen_bool(hit_chance as f64) { + logger.log(|| MissTarget { + weapon, + actor: player, + recipient: target, + rounds: rounds.map(|(rounds, _)| rounds), + }); + metrics.increment_counter(player, "miss", 1); + metrics.increment_counter(weapon, "miss", 1); + return; + } + + let crit = player_crit + crit; + + let body_part = if !non_targeted { + rng.sample(crit) + } else { + BodyPart::Stomach + }; + + let def_str_ratio = (target_def.value / player_str.value).clamp(1.0 / 32.0, 14.0); + let 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; + + init_hit.send(InitiateHit { + body_part, + weapon, + rounds: rounds.map(|(rounds, _)| rounds), + crit_rate: crit.value, + dmg: dmg.0, + dmg_bonus_weapon: (dmg_bonus + p_dmg_bonus).value, + dmg_intrinsic, + def_mitigation: mitigation, + hit_chance, + }); */ +} + +fn reload_weapon( + mut weapon_q: Query< + ( + Entity, + &Parent, + &mut Ammo, + &mut SimpleStatEffective, + &SimpleStatEffective, + ), + (With, With), + >, + mut commands: Commands, + mut logger: Logger, +) { + for (weapon, player, mut ammo, mut clips, clip_size) in weapon_q.iter_mut() { + ammo.0 = clip_size.value; + clips.value -= 1; + + logger.log(|| ReloadWeapon { + actor: player.get(), + weapon, + }); + + commands.entity(weapon).remove::(); + } +} + +fn restore_usability(weapon_q: Query>, mut commands: Commands) { + for weapon in weapon_q.iter() { + commands.entity(weapon).insert(Usable); + } +} + +fn restore_ammo( + mut ammo_q: Query<(Entity, &mut Ammo, &SimpleStatEffective)>, + mut commands: Commands, +) { + for (weapon, mut ammo, clip_size) in ammo_q.iter_mut() { + ammo.0 = clip_size.value; + commands.entity(weapon).remove::(); + } +} + +fn apply_first_turn_effects( + effect_q: Query<(&Parent, &FirstTurnEffect)>, + player_q: Query<&Parent>, + mut effects: Effects, +) { + for (weapon, effect) in effect_q.iter() { + let player = player_q.get(weapon.get()).unwrap(); + effect.spawn(&mut effects, weapon.get(), player.get()); + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.equip.add_systems(set_owner); + stages.pre_fight.add_systems(( + apply_passives, + apply_first_turn_effects + .after(apply_passives) + .after(bonus::prepare_bonuses), + )); + stages.turn.add_systems(reload_weapon); + stages.post_turn.add_systems(unset_current); + stages + .restore + .add_systems((restore_ammo, restore_usability, apply_first_turn_effects)); + + temp::configure(stages); + bonus::configure(stages); +} diff --git a/src/weapon/temp.rs b/src/weapon/temp.rs new file mode 100644 index 0000000..4c849c1 --- /dev/null +++ b/src/weapon/temp.rs @@ -0,0 +1,249 @@ +use bevy_ecs::prelude::*; +use strum::Display; + +use crate::{ + effect::Effects, + player::{ + status_effect::{ + ConcussionGrenade, FlashGrenade, PepperSpray, Sand, SmokeGrenade, TearGas, + TempDebuffEffect, + }, + Current, CurrentTarget, + }, + Stages, +}; + +use super::{DamagingWeaponBundle, Usable, WeaponBundle, WeaponCategory, WeaponSlot, WeaponVerb}; + +#[derive(Component, Default)] +pub struct NonTargeted; + +#[derive(Component, Default)] +pub struct Temporary; + +#[derive(Component)] +pub struct Uses(pub u16); + +impl Default for Uses { + fn default() -> Self { + Self(1) + } +} + +#[derive(Component)] +pub struct AssociatedWeapon(pub Entity); + +#[derive(Component, Debug, Clone, Copy, Display)] +pub enum DebuffingTemp { + TearGas, + SmokeGrenade, + PepperSpray, + ConcussionGrenade, + FlashGrenade, + Sand, +} + +#[derive(Bundle, Default)] +pub struct TemporaryBundle { + pub temporary: Temporary, + pub uses: Uses, +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "json", derive(serde::Deserialize))] +pub enum Temp { + Heg, + NailBomb, + Grenade, + Fireworks, + ClaymoreMine, + TearGas, + SmokeGrenade, + PepperSpray, + ConcussionGrenade, + FlashGrenade, + Sand, +} + +impl Temp { + pub fn spawn(self, world: &mut World, id: usize) -> EntityWorldMut<'_> { + match self { + Self::Heg => world.spawn(( + WeaponBundle::new( + "HEG".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + DamagingWeaponBundle::new(90.00, 116.00, vec![], 0.0, WeaponCategory::Temporary), + TemporaryBundle::default(), + NonTargeted, + )), + Self::NailBomb => world.spawn(( + WeaponBundle::new( + "Nail Bomb".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + DamagingWeaponBundle::new(99.00, 106.00, vec![], 0.0, WeaponCategory::Temporary), + TemporaryBundle::default(), + NonTargeted, + )), + Self::Grenade => world.spawn(( + WeaponBundle::new( + "Grenade".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + DamagingWeaponBundle::new(86.00, 106.00, vec![], 0.0, WeaponCategory::Temporary), + TemporaryBundle::default(), + NonTargeted, + )), + Self::Fireworks => world.spawn(( + WeaponBundle::new( + "Fireworks".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + DamagingWeaponBundle::new(45.00, 34.00, vec![], 0.0, WeaponCategory::Temporary), + TemporaryBundle::default(), + NonTargeted, + )), + Self::ClaymoreMine => world.spawn(( + WeaponBundle::new( + "Claymore Mine".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + DamagingWeaponBundle::new(83.00, 27.00, vec![], 0.0, WeaponCategory::Temporary), + TemporaryBundle::default(), + NonTargeted, + )), + Self::TearGas => world.spawn(( + WeaponBundle::new( + "Tear Gas".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::TearGas, + )), + Self::SmokeGrenade => world.spawn(( + WeaponBundle::new( + "Smoke Grenade".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::SmokeGrenade, + )), + Self::PepperSpray => world.spawn(( + WeaponBundle::new( + "Pepper Spray".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::PepperSpray, + )), + Self::ConcussionGrenade => world.spawn(( + WeaponBundle::new( + "Concussion Grenade".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::ConcussionGrenade, + )), + Self::FlashGrenade => world.spawn(( + WeaponBundle::new( + "Flash Grenade".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::FlashGrenade, + )), + Self::Sand => world.spawn(( + WeaponBundle::new( + "Sand".to_owned(), + id, + WeaponVerb::Exploded, + WeaponSlot::Temporary, + ), + TemporaryBundle::default(), + DebuffingTemp::Sand, + )), + } + } +} + +fn use_debuffing_temp( + mut temp_q: Query<(Entity, &DebuffingTemp, &mut Uses), With>, + target_q: Query>, + mut effects: Effects, + mut commands: Commands, +) { + let Ok((weapon, temp, mut uses)) = temp_q.get_single_mut() else { + return; + }; + let target = target_q.single(); + + match temp { + DebuffingTemp::TearGas => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + DebuffingTemp::SmokeGrenade => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + DebuffingTemp::PepperSpray => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + DebuffingTemp::ConcussionGrenade => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + DebuffingTemp::FlashGrenade => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + DebuffingTemp::Sand => effects.spawn_and_insert( + TempDebuffEffect::::default(), + target, + AssociatedWeapon(weapon), + ), + }; + + uses.0 -= 1; + if uses.0 == 0 { + commands.entity(weapon).remove::(); + } +} + +fn restore_uses(mut uses_q: Query<&mut Uses>) { + for mut uses in uses_q.iter_mut() { + uses.0 = 1; + } +} + +pub(crate) fn configure(stages: &mut Stages) { + stages.turn.add_systems(use_debuffing_temp); + stages.restore.add_systems(restore_uses); +}