diff --git a/Cargo.toml b/Cargo.toml index d0e186a..2b26e7b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,5 +34,10 @@ log = "0.4.22" env_logger = "0.11.5" thiserror = "1.0.63" moka = { version = "0.12.8", optional = true, features = ["future"] } -strum = { version = "0.26.3", features = ["derive"] } urlencoding = "2.1.3" +derive_more = { version = "1.0.0", features = ["full"] } +serde_with = "3.11.0" + +# TODO: use this lint in V7 +# [lints.clippy] +# pedantic = "warn" diff --git a/src/worldstate/models/damage_type.rs b/src/worldstate/models/damage_type.rs new file mode 100644 index 0000000..24164e5 --- /dev/null +++ b/src/worldstate/models/damage_type.rs @@ -0,0 +1,118 @@ +use serde::Deserialize; +use serde_with::rust::deserialize_ignore_any; + +macro_rules! unordered_pattern { + ($a:pat, $b:pat) => { + ($a, $b) | ($b, $a) + }; +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, derive_more::From, Hash)] +#[serde(untagged)] +pub enum DamageType { + // Physical Damage + Physical(PhysicalDamage), + // Primary Elemental Damage + Elemental(ElementalDamage), + // Secondary Elemental Damage + Combined(CombinedElementalDamage), + + #[serde(deserialize_with = "deserialize_ignore_any")] + Other, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, derive_more::Display, Hash)] +#[serde(rename_all = "lowercase")] +pub enum PhysicalDamage { + Impact, + Puncture, + Slash, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, derive_more::Display, Hash)] +#[serde(rename_all = "lowercase")] +pub enum ElementalDamage { + Heat, + Cold, + Electricity, + Toxin, + Void, + Tau, +} + +impl ElementalDamage { + /// Combines two Primary Elements into their Combined Element. + /// + /// Returns [None] if both `a` and `b` are equal. + pub const fn combine(a: Self, b: Self) -> Option { + use CombinedElementalDamage::*; + use ElementalDamage::*; + + let combined_element = match (a, b) { + unordered_pattern!(Heat, Cold) => Blast, + unordered_pattern!(Heat, Electricity) => Radiation, + unordered_pattern!(Heat, Toxin) => Gas, + unordered_pattern!(Toxin, Cold) => Viral, + unordered_pattern!(Toxin, Electricity) => Corrosive, + unordered_pattern!(Cold, Electricity) => Magnetic, + _ => return None, + }; + + Some(combined_element) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, derive_more::Display, Hash)] +#[serde(rename_all = "lowercase")] +pub enum CombinedElementalDamage { + Radiation, + Blast, + Viral, + Corrosive, + Gas, + Magnetic, +} + +impl CombinedElementalDamage { + /// Breaks down a combined element into both of its [PrimaryElement]s. + /// + /// The order in which they are returned follows classic HCET. + pub const fn break_down(self) -> (ElementalDamage, ElementalDamage) { + use CombinedElementalDamage::*; + use ElementalDamage::*; + + match self { + Radiation => (Heat, Electricity), + Blast => (Heat, Cold), + Viral => (Cold, Toxin), + Corrosive => (Electricity, Toxin), + Gas => (Heat, Toxin), + Magnetic => (Cold, Electricity), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_combine() { + let cold = ElementalDamage::Cold; + let electricity = ElementalDamage::Electricity; + + let combined: Option = ElementalDamage::combine(cold, electricity); + + assert_eq!(combined.unwrap(), CombinedElementalDamage::Magnetic); + } + + #[test] + fn test_break_down() { + let corrosive = CombinedElementalDamage::Corrosive; + + assert_eq!( + corrosive.break_down(), + (ElementalDamage::Electricity, ElementalDamage::Toxin) + ); + } +} diff --git a/src/worldstate/models/faction.rs b/src/worldstate/models/faction.rs index 84487da..94982de 100644 --- a/src/worldstate/models/faction.rs +++ b/src/worldstate/models/faction.rs @@ -1,27 +1,98 @@ -use super::macros::enum_builder; -enum_builder! { - :"A Faction in Warframe" - Faction; - :"Orokin" +use super::damage_type::{ + CombinedElementalDamage, + DamageType, + ElementalDamage, + PhysicalDamage, +}; + +#[derive(Debug, serde::Deserialize, PartialEq, PartialOrd, Clone, Eq, Copy)] +/// A Faction in Warframe +pub enum Faction { + /// Orokin Orokin, - :"Corrupted" + /// Corrupted Corrupted, - :"Infested" + /// Infested + #[serde(alias = "Infestation")] Infested, - :"Infestation" - Infestation, - :"Corpus" + /// Corpus Corpus, - :"Grineer" + /// Grineer Grineer, - :"Tenno" + /// Tenno Tenno, - :"Narmer" + /// Narmer Narmer, - :"Crossfire" + /// Crossfire, which is Faction-less Crossfire, - :"Murmur" - Murmur = "The Murmur", - :"ManInTheWall" - ManInTheWall = "Man in the Wall" + /// Murmur + #[serde(rename(deserialize = "The Murmur"))] + Murmur, + /// This is a placeholder faction. For example, it was used during the Jade event + #[serde(rename(deserialize = "Man in the Wall"))] + ManInTheWall, +} + +impl crate::ws::VariantDocumentation for Faction { + fn docs(&self) -> &'static str { + match self { + Faction::Orokin => "Orokin", + Faction::Corrupted => "Corrupted", + Faction::Infested => "Infested", + Faction::Corpus => "Corpus", + Faction::Grineer => "Grineer", + Faction::Tenno => "Tenno", + Faction::Narmer => "Narmer", + Faction::Crossfire => "Crossfire, which is Faction-less", + Faction::ManInTheWall => { + "This is a placeholder faction. For example, it was used during" + } + Faction::Murmur => "Murmur", + } + } +} +impl crate::ws::TypeDocumentation for Faction { + fn docs() -> &'static str { + "A Faction in Warframe" + } +} + +impl Faction { + pub fn vulnerable_to(self) -> Vec { + use CombinedElementalDamage::*; + use DamageType::*; + use ElementalDamage::*; + use Faction::*; + use PhysicalDamage::*; + + match self { + Orokin | Corrupted => vec![Physical(Puncture), Combined(Viral)], + Infested => vec![Physical(Slash), Elemental(Heat)], + Corpus => vec![Physical(Puncture), Combined(Magnetic)], + Grineer => vec![Physical(Impact), Combined(Corrosive)], + Narmer => vec![Physical(Slash), Elemental(Toxin)], + Murmur => vec![Elemental(Electricity), Combined(Radiation)], + Tenno => vec![], + Crossfire => vec![], + ManInTheWall => vec![], + } + } + + pub fn resistant_to(self) -> Option { + use CombinedElementalDamage::*; + use DamageType::*; + use Faction::*; + + match self { + Orokin | Corrupted => Some(Combined(Radiation)), + Narmer => Some(Combined(Magnetic)), + Murmur => Some(Combined(Viral)), + Grineer => None, + Corpus => None, + Infested => None, + Tenno => None, + Crossfire => None, + ManInTheWall => None, + } + } } diff --git a/src/worldstate/models/items/mod.rs b/src/worldstate/models/items/mod.rs index 7a0b12d..da00d0a 100644 --- a/src/worldstate/models/items/mod.rs +++ b/src/worldstate/models/items/mod.rs @@ -39,7 +39,7 @@ pub mod warframe; pub mod weapon; /// Represents a polarity -#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, strum::Display)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, derive_more::Display)] #[serde(rename_all = "lowercase")] pub enum Polarity { /// V (Damage, Powers) - Commonly dropped by Grineer diff --git a/src/worldstate/models/items/weapon.rs b/src/worldstate/models/items/weapon.rs index 29ffbb8..cd70c4d 100644 --- a/src/worldstate/models/items/weapon.rs +++ b/src/worldstate/models/items/weapon.rs @@ -9,15 +9,43 @@ use super::{ Introduced, Polarity, }; +use crate::worldstate::models::DamageType; + +fn as_f64<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s: String = Deserialize::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) +} + +fn as_f64_option<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let s: Option = Deserialize::deserialize(deserializer)?; + if let Some(s) = s { + s.parse().map(Some).map_err(serde::de::Error::custom) + } else { + Ok(None) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Weapon { + Melee(MeleeWeapon), + Ranged(RangedWeapon), +} #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct Weapon { +pub struct RangedWeapon { pub accuracy: f64, - pub attacks: Vec, + pub attacks: Vec, - pub build_price: i64, + pub build_price: f64, pub build_quantity: i64, @@ -31,7 +59,7 @@ pub struct Weapon { pub critical_chance: f64, - pub critical_multiplier: i64, + pub critical_multiplier: f64, pub damage: HashMap, @@ -90,7 +118,8 @@ pub struct Weapon { pub unique_name: String, - pub vaulted: bool, + /// This will be [Some], if [RangedWeapon::is_prime] is true + pub vaulted: Option, pub wikia_thumbnail: String, @@ -98,10 +127,16 @@ pub struct Weapon { } #[derive(Clone, Debug, Deserialize, PartialEq)] -pub struct Attack { +pub struct RangedAttack { pub name: String, - pub speed: i64, + pub duration: Option, + + pub charge_time: Option, + + pub channeling: Option, + + pub speed: f64, pub crit_chance: f64, @@ -113,18 +148,170 @@ pub struct Attack { pub shot_speed: Option, - pub flight: Option, + pub flight: Option, - pub damage: HashMap, + pub damage: HashMap, pub falloff: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MeleeWeapon { + pub attacks: Vec, + + pub build_price: f64, + + pub build_quantity: i64, + + pub build_time: i64, + + pub category: Category, + + pub components: Vec, + + pub consume_on_build: bool, + + pub critical_chance: f64, + + pub critical_multiplier: f64, + + pub damage: HashMap, + + pub damage_per_shot: Vec, + + pub description: String, + + pub disposition: f64, + + #[serde(rename = "fireRate")] + pub attack_speed: f64, + + pub image_name: String, + + pub introduced: Introduced, + + pub is_prime: bool, + + pub masterable: bool, + + pub mastery_req: i64, + + pub name: String, + + pub omega_attenuation: f64, + + pub polarities: Vec, + + pub proc_chance: f64, + + pub product_category: String, + + pub release_date: NaiveDate, + + pub skip_build_time_price: i64, + + pub slot: i64, + + pub tags: Vec, + + pub total_damage: f64, + + pub tradable: bool, + + #[serde(rename = "type")] + pub weapon_type: String, + + pub unique_name: String, + + /// This will be [Some], if [MeleeWeapon::is_prime] is true + pub vaulted: Option, + + pub wikia_thumbnail: String, + + pub wikia_url: String, + + pub stance_polarity: Polarity, + + pub blocking_angle: f64, + + pub combo_duration: f64, + + pub follow_through: f64, + + pub range: f64, + + pub slam_attack: f64, + + pub slam_radial_damage: f64, + + pub slam_radius: f64, + + pub slide_attack: f64, + + pub heavy_attack_damage: f64, + + pub heavy_slam_attack: f64, + + pub heavy_slam_radial_damage: f64, + + pub heavy_slam_radius: f64, + + pub wind_up: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct MeleeAttack { + pub name: String, + + pub duration: Option, + + #[serde(rename = "chargeTime")] + pub wind_up: Option, + + pub speed: f64, + + pub crit_chance: f64, + + pub crit_mult: f64, + + pub status_chance: f64, + + pub shot_speed: Option, + + pub flight: Option, + + pub damage: HashMap, + + pub falloff: Option, + + #[serde(default, deserialize_with = "as_f64_option")] + pub slide: Option, + + pub slam: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct RadialAttack { + #[serde(deserialize_with = "as_f64")] + pub damage: f64, + + // #[serde(deserialize_with = "as_f64")] + pub radius: f64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +pub struct SlamAttack { + #[serde(deserialize_with = "as_f64")] + pub damage: f64, + pub radial: RadialAttack, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] pub struct Falloff { - pub start: i64, + pub start: f64, - pub end: i64, + pub end: f64, pub reduction: f64, } @@ -150,13 +337,29 @@ pub enum ShotType { HitScan, Projectile, AoE, + Thrown, +} + +#[tokio::test] +async fn test_weapon_query_primary() -> Result<(), Box> { + let weapon = reqwest::get("https://api.warframestat.us/items/acceltra%20prime/") + .await? + .json::() + .await?; + + assert!(matches!(weapon, Weapon::Ranged(_))); + + Ok(()) } #[tokio::test] -async fn test_weapon_query() -> Result<(), Box> { - let _weapon = reqwest::get("https://api.warframestat.us/items/acceltra%20prime/") +async fn test_weapon_query_melee() -> Result<(), Box> { + let weapon = reqwest::get("https://api.warframestat.us/items/pathocyst/") .await? .json::() .await?; + + assert!(matches!(weapon, Weapon::Melee(_))); + Ok(()) } diff --git a/src/worldstate/models/macros.rs b/src/worldstate/models/macros.rs index ace9709..382080c 100644 --- a/src/worldstate/models/macros.rs +++ b/src/worldstate/models/macros.rs @@ -82,7 +82,7 @@ macro_rules! impl_timed_event { // --------------------------------- macro_rules! enum_builder { ($(:$enum_doc:literal)? $enum_name:ident; $(:$option_doc1:literal)? $enum_option1:ident $(= $enum_option_deserialize1:literal)?, $(:$option_doc2:literal)? $enum_option2:ident $(= $enum_option_deserialize2:literal)? $(,)?) => { - #[derive(Debug, serde::Deserialize, PartialEq, PartialOrd, Clone)] + #[derive(Debug, serde::Deserialize, PartialEq, PartialOrd, Clone, Eq, Copy)] $(#[doc = $enum_doc])? pub enum $enum_name { $( @@ -136,7 +136,7 @@ macro_rules! enum_builder { }; ($(:$enum_doc:literal)? $enum_name:ident; $($(:$option_doc:literal)? $enum_option:ident $(= $enum_option_deserialize:literal)?),*$(,)?) => { - #[derive(Debug, serde::Deserialize, PartialEq, PartialOrd, Clone)] + #[derive(Debug, serde::Deserialize, PartialEq, PartialOrd, Clone, Eq, Copy)] $(#[doc = $enum_doc])? pub enum $enum_name { $( @@ -174,7 +174,7 @@ macro_rules! enum_builder { }; ($(:$enum_doc:literal)? $enum_name:ident; $($(:$option_doc:literal)? $enum_option:ident $(= $enum_option_deserialize:literal)? $(: $enum_option_num_value:expr)?),*$(,)?) => { - #[derive(Debug, serde_repr::Deserialize_repr, PartialEq, PartialOrd, Clone)] + #[derive(Debug, serde_repr::Deserialize_repr, PartialEq, PartialOrd, Clone, Eq, Copy)] #[repr(u8)] $(#[doc = $enum_doc])? pub enum $enum_name { diff --git a/src/worldstate/models/mod.rs b/src/worldstate/models/mod.rs index fdb4180..23fe7c8 100644 --- a/src/worldstate/models/mod.rs +++ b/src/worldstate/models/mod.rs @@ -40,6 +40,7 @@ mod fissure; mod flash_sale; pub mod items; // mod global_upgrades; +mod damage_type; mod invasion; pub(crate) mod macros; mod mission; @@ -73,6 +74,12 @@ pub use cetus::{ // pub use global_upgrades::GlobalUpgrade; pub use construction_progress::ConstructionProgress; pub use daily_deal::DailyDeal; +pub use damage_type::{ + CombinedElementalDamage, + DamageType, + ElementalDamage, + PhysicalDamage, +}; pub use faction::Faction; pub use fissure::{ Fissure,