dnd_character/api/
classes.rs

1use super::shared::schema;
2use crate::api::classes::CustomLevelFeatureType::Ignored;
3use crate::api::shared::ApiError;
4use crate::classes::{Class, Classes, UsableSlots};
5use crate::GRAPHQL_API_URL;
6use cynic::http::ReqwestExt;
7use cynic::{impl_scalar, QueryBuilder};
8use futures::StreamExt;
9use lazy_static::lazy_static;
10use reqwest::Client;
11use serde_json::json;
12use std::collections::HashMap;
13
14#[derive(cynic::QueryVariables, Debug)]
15struct SpellcastingAbilityQueryVariables {
16    pub index: Option<String>,
17}
18
19#[derive(cynic::QueryFragment, Debug)]
20#[cynic(
21    graphql_type = "Query",
22    variables = "SpellcastingAbilityQueryVariables"
23)]
24struct SpellcastingAbilityQuery {
25    #[arguments(index: $ index)]
26    pub class: Option<ClassSpellCasting>,
27}
28
29#[derive(cynic::QueryFragment, Debug)]
30#[cynic(graphql_type = "Class")]
31struct ClassSpellCasting {
32    pub spellcasting: Option<ClassSpellcasting>,
33}
34
35#[derive(cynic::QueryFragment, Debug)]
36struct ClassSpellcasting {
37    #[cynic(rename = "spellcasting_ability")]
38    pub spellcasting_ability: AbilityScore,
39}
40
41#[derive(cynic::QueryFragment, Debug)]
42struct AbilityScore {
43    pub index: String,
44}
45
46#[derive(cynic::QueryVariables, Debug)]
47pub struct SpellcastingQueryVariables {
48    pub index: Option<String>,
49}
50
51#[derive(cynic::QueryFragment, Debug)]
52#[cynic(graphql_type = "Query", variables = "SpellcastingQueryVariables")]
53pub struct SpellcastingQuery {
54    #[arguments(index: $ index)]
55    pub level: Option<Level>,
56}
57
58#[derive(cynic::QueryFragment, Debug)]
59pub struct Level {
60    pub spellcasting: Option<LevelSpellcasting>,
61}
62
63#[derive(cynic::QueryFragment, Debug, Copy, Clone)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize))]
65pub struct LevelSpellcasting {
66    #[cynic(rename = "cantrips_known")]
67    pub cantrips_known: Option<i32>,
68    #[cynic(rename = "spell_slots_level_1")]
69    pub spell_slots_level_1: Option<i32>,
70    #[cynic(rename = "spell_slots_level_2")]
71    pub spell_slots_level_2: Option<i32>,
72    #[cynic(rename = "spell_slots_level_3")]
73    pub spell_slots_level_3: Option<i32>,
74    #[cynic(rename = "spell_slots_level_4")]
75    pub spell_slots_level_4: Option<i32>,
76    #[cynic(rename = "spell_slots_level_5")]
77    pub spell_slots_level_5: Option<i32>,
78    #[cynic(rename = "spell_slots_level_6")]
79    pub spell_slots_level_6: Option<i32>,
80    #[cynic(rename = "spell_slots_level_7")]
81    pub spell_slots_level_7: Option<i32>,
82    #[cynic(rename = "spell_slots_level_8")]
83    pub spell_slots_level_8: Option<i32>,
84    #[cynic(rename = "spell_slots_level_9")]
85    pub spell_slots_level_9: Option<i32>,
86}
87
88impl Into<UsableSlots> for LevelSpellcasting {
89    fn into(self) -> UsableSlots {
90        UsableSlots {
91            cantrip_slots: self.cantrips_known.unwrap_or(0) as u8,
92            level_1: self.spell_slots_level_1.unwrap_or(0) as u8,
93            level_2: self.spell_slots_level_2.unwrap_or(0) as u8,
94            level_3: self.spell_slots_level_3.unwrap_or(0) as u8,
95            level_4: self.spell_slots_level_4.unwrap_or(0) as u8,
96            level_5: self.spell_slots_level_5.unwrap_or(0) as u8,
97            level_6: self.spell_slots_level_6.unwrap_or(0) as u8,
98            level_7: self.spell_slots_level_7.unwrap_or(0) as u8,
99            level_8: self.spell_slots_level_8.unwrap_or(0) as u8,
100            level_9: self.spell_slots_level_9.unwrap_or(0) as u8,
101        }
102    }
103}
104
105#[derive(cynic::QueryVariables, Debug)]
106pub struct LevelFeaturesQueryVariables {
107    pub class: Option<StringFilter>,
108    pub level: Option<LevelFilter>,
109}
110
111#[derive(serde::Serialize, Debug)]
112pub struct LevelFilter {
113    pub gt: Option<u8>,
114    pub gte: Option<u8>,
115    pub lte: Option<u8>,
116}
117
118impl_scalar!(LevelFilter, schema::IntFilter);
119
120#[derive(cynic::QueryFragment, Debug)]
121#[cynic(graphql_type = "Query", variables = "LevelFeaturesQueryVariables")]
122pub struct LevelFeaturesQuery {
123    #[arguments(class: $ class, level: $level )]
124    pub features: Option<Vec<Feature>>,
125}
126
127#[derive(cynic::QueryFragment, Debug)]
128pub struct Feature {
129    pub index: String,
130}
131
132#[derive(cynic::Scalar, Debug, Clone)]
133pub struct StringFilter(pub String);
134
135#[derive(Clone)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize))]
137#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
138pub enum ChoosableCustomLevelFeature {
139    /// Ask the user to spend 2 points in any ability score
140    AbilityScoreImprovement,
141    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/hunters-prey
142    HuntersPrey,
143    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/defensive-tactics
144    DefensiveTactics,
145    /// https://www.dnd5eapi.co/api/features/pact-boon
146    WarlockPact,
147    /// https://www.dnd5eapi.co/api/features/additional-fighting-style
148    AdditionalFighterFightingStyle,
149    /// https://www.dnd5eapi.co/api/features/fighter-fighting-style
150    FighterFightingStyle,
151    /// https://www.dnd5eapi.co/api/features/ranger-fighting-style
152    RangerFightingStyle,
153    /// https://www.dnd5eapi.co/api/features/bonus-proficiencies
154    BonusBardProficiency,
155    /// Used for
156    /// https://www.dnd5eapi.co/api/features/bard-expertise-1
157    /// https://www.dnd5eapi.co/api/features/bard-expertise-2
158    /// https://www.dnd5eapi.co/api/features/rogue-expertise-1
159    /// https://www.dnd5eapi.co/api/features/rogue-expertise-2
160    MultiplyTwoSkillProficiency,
161    /// https://www.dnd5eapi.co/api/features/magical-secrets-1
162    /// https://www.dnd5eapi.co/api/features/magical-secrets-2
163    /// https://www.dnd5eapi.co/api/features/magical-secrets-3
164    ChooseTwoSpellForAnyClass,
165    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-6th-level
166    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-7th-level
167    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-8th-level
168    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-9th-level
169    ChooseOne6thLevelSpellFromWarlockList,
170    /// https://www.dnd5eapi.co/api/features/paladin-fighting-style
171    PaladinFightingStyle,
172    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/multiattack
173    Multiattack,
174    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/superior-hunters-defense
175    SuperiorHuntersDefense,
176}
177
178#[derive(Clone, Debug)]
179#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
180#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
181pub enum ChoosableCustomLevelFeatureOption {
182    StrengthPlusOne,
183    DexterityPlusOne,
184    ConstitutionPlusOne,
185    IntelligencePlusOne,
186    WisdomPlusOne,
187    CharismaPlusOne,
188
189    PactOfTheChain,
190    PactOfTheBlade,
191    PactOfTheTome,
192
193    FighterFightingStyleArchery,
194    FighterFightingStyleDefense,
195    FighterFightingStyleDueling,
196    FighterFightingStyleGreatWeaponFighting,
197    FighterFightingStyleProtection,
198    FighterFightingStyleTwoWeaponFighting,
199
200    RangerFightingStyleArchery,
201    RangerFightingStyleDefense,
202    RangerFightingStyleDueling,
203    RangerFightingStyleTwoWeaponFighting,
204
205    BardProficiencyStrength,
206    BardProficiencyDexterity,
207    BardProficiencyConstitution,
208    BardProficiencyIntelligence,
209    BardProficiencyWisdom,
210    BardProficiencyCharisma,
211
212    FightingStyleDefense,
213    FightingStyleDueling,
214    FightingStyleGreatWeaponFighting,
215    FightingStyleProtection,
216
217    HuntersPreyGiantKiller,
218    HuntersPreyHordeBreaker,
219    HuntersPreyColossusSlayer,
220
221    DefensiveTacticsSteelWill,
222    DefensiveTacticsEscapeTheHorde,
223    DefensiveTacticsMultiattackDefense,
224
225    MultiattackVolley,
226    MultiattackWhirlwindAttack,
227
228    SuperiorHuntersDefenseEvasion,
229    SuperiorHuntersDefenseStandAgainstTheTide,
230    SuperiorHuntersDefenseUncannyDodge,
231}
232
233impl ChoosableCustomLevelFeatureOption {
234    #[cfg(feature = "serde")]
235    pub fn as_index_str(&self) -> &str {
236        serde_variant::to_variant_name(self).unwrap()
237    }
238
239    #[cfg(feature = "serde")]
240    pub fn from_index_str(index: &str) -> Option<ChoosableCustomLevelFeatureOption> {
241        #[derive(serde::Deserialize)]
242        struct Helper {
243            value: ChoosableCustomLevelFeatureOption,
244        }
245
246        let json = json!({
247            "value": index
248        });
249
250        serde_json::from_value::<Helper>(json)
251            .map(|helper| helper.value)
252            .ok()
253    }
254}
255
256impl ChoosableCustomLevelFeature {
257    #[cfg(feature = "serde")]
258    pub fn as_index_str(&self) -> &str {
259        serde_variant::to_variant_name(self).unwrap()
260    }
261
262    pub fn to_options(&self) -> Vec<Vec<ChoosableCustomLevelFeatureOption>> {
263        use ChoosableCustomLevelFeatureOption::*;
264
265        match self {
266            ChoosableCustomLevelFeature::AbilityScoreImprovement => {
267                let ability_names = vec![
268                    StrengthPlusOne,
269                    DexterityPlusOne,
270                    ConstitutionPlusOne,
271                    IntelligencePlusOne,
272                    WisdomPlusOne,
273                    CharismaPlusOne,
274                ];
275
276                vec![ability_names.clone(), ability_names]
277            }
278            ChoosableCustomLevelFeature::WarlockPact => {
279                vec![vec![PactOfTheChain, PactOfTheBlade, PactOfTheTome]]
280            }
281            ChoosableCustomLevelFeature::AdditionalFighterFightingStyle
282            | ChoosableCustomLevelFeature::FighterFightingStyle => {
283                vec![vec![
284                    FighterFightingStyleArchery,
285                    FighterFightingStyleDefense,
286                    FighterFightingStyleDueling,
287                    FighterFightingStyleGreatWeaponFighting,
288                    FighterFightingStyleProtection,
289                    FighterFightingStyleTwoWeaponFighting,
290                ]]
291            }
292            ChoosableCustomLevelFeature::RangerFightingStyle => {
293                vec![vec![
294                    RangerFightingStyleArchery,
295                    RangerFightingStyleDefense,
296                    RangerFightingStyleDueling,
297                    RangerFightingStyleTwoWeaponFighting,
298                ]]
299            }
300            ChoosableCustomLevelFeature::BonusBardProficiency => {
301                let ability_names = vec![
302                    BardProficiencyStrength,
303                    BardProficiencyDexterity,
304                    BardProficiencyConstitution,
305                    BardProficiencyIntelligence,
306                    BardProficiencyWisdom,
307                    BardProficiencyCharisma,
308                ];
309
310                vec![ability_names.clone(), ability_names.clone(), ability_names]
311            }
312            ChoosableCustomLevelFeature::MultiplyTwoSkillProficiency => {
313                // TODO: Implement this
314                vec![vec![]]
315            }
316            ChoosableCustomLevelFeature::ChooseTwoSpellForAnyClass => {
317                // TODO: Implement this
318                vec![vec![]]
319            }
320            ChoosableCustomLevelFeature::ChooseOne6thLevelSpellFromWarlockList => {
321                // TODO: Implement this when other warlock features are implemented
322                vec![vec![]]
323            }
324            ChoosableCustomLevelFeature::PaladinFightingStyle => {
325                vec![vec![
326                    FightingStyleDefense,
327                    FightingStyleDueling,
328                    FightingStyleGreatWeaponFighting,
329                    FightingStyleProtection,
330                ]]
331            }
332            ChoosableCustomLevelFeature::HuntersPrey => {
333                vec![vec![
334                    HuntersPreyGiantKiller,
335                    HuntersPreyHordeBreaker,
336                    HuntersPreyColossusSlayer,
337                ]]
338            }
339            ChoosableCustomLevelFeature::DefensiveTactics => {
340                vec![vec![
341                    DefensiveTacticsSteelWill,
342                    DefensiveTacticsEscapeTheHorde,
343                    DefensiveTacticsMultiattackDefense,
344                ]]
345            }
346            ChoosableCustomLevelFeature::Multiattack => {
347                vec![vec![MultiattackWhirlwindAttack, MultiattackVolley]]
348            }
349            ChoosableCustomLevelFeature::SuperiorHuntersDefense => {
350                vec![vec![
351                    SuperiorHuntersDefenseEvasion,
352                    SuperiorHuntersDefenseStandAgainstTheTide,
353                    SuperiorHuntersDefenseUncannyDodge,
354                ]]
355            }
356        }
357    }
358}
359
360pub enum SheetLevelFeatureType {
361    /// https://www.dnd5eapi.co/api/features/primal-champion
362    PrimalChampion,
363}
364
365pub enum CustomLevelFeatureType {
366    Choosable(ChoosableCustomLevelFeature),
367    Sheet(SheetLevelFeatureType),
368    Passive,
369    Ignored,
370}
371
372impl CustomLevelFeatureType {
373    pub fn identify(index: String) -> Option<CustomLevelFeatureType> {
374        use ChoosableCustomLevelFeature::*;
375        use CustomLevelFeatureType::*;
376        use SheetLevelFeatureType::*;
377        match index.as_str() {
378            // Ignore all subclass choices since we have only one subclass per class
379            "bard-college"
380            | "divine-domain"
381            | "monastic-tradition"
382            | "sacred-oath"
383            | "ranger-archetype"
384            | "sorcerous-origin"
385            | "druid-circle"
386            | "primal-path"
387            | "martial-archetype"
388            | "otherworldly-patron" => Some(Ignored),
389            "pact-boon" => Some(Choosable(WarlockPact)),
390            "additional-fighting-style" => Some(Choosable(AdditionalFighterFightingStyle)),
391            "fighter-fighting-style" => Some(Choosable(FighterFightingStyle)),
392            "bonus-proficiencies" => Some(Choosable(BonusBardProficiency)),
393            "bonus-proficiency" => Some(Passive),
394            "additional-magical-secrets" | "bonus-cantrip" => Some(Ignored),
395            "channel-divinity-1-rest" | "channel-divinity-2-rest" | "channel-divinity-3-rest" => {
396                Some(Ignored)
397            }
398            //"magical-secrets-1" | "magical-secrets-2" | "magical-secrets-3" => Some(Choosable(ChooseTwoSpellForAnyClass)), TODO: Implement this
399            "magical-secrets-1" | "magical-secrets-2" | "magical-secrets-3" => Some(Ignored),
400            "mystic-arcanum-6th-level"
401            | "mystic-arcanum-7th-level"
402            | "mystic-arcanum-8th-level"
403            | "mystic-arcanum-9th-level" => Some(Choosable(ChooseOne6thLevelSpellFromWarlockList)),
404            "paladin-fighting-style" => Some(Choosable(PaladinFightingStyle)),
405            "primal-champion" => Some(Sheet(PrimalChampion)),
406            // TODO: Implement https://www.dnd5eapi.co/api/features/diamond-soul
407            "diamond-soul" => Some(Passive),
408            "arcane-recovery"
409            | "archdruid"
410            | "aura-improvements"
411            | "aura-of-courage"
412            | "aura-of-devotion"
413            | "aura-of-protection"
414            | "blessed-healer"
415            | "blindsense"
416            | "brutal-critical-1-dice"
417            | "brutal-critical-2-dice"
418            | "brutal-critical-3-dice"
419            | "danger-sense"
420            | "dark-ones-blessing"
421            | "dark-ones-own-luck"
422            | "destroy-undead-cr-1-or-below"
423            | "destroy-undead-cr-2-or-below"
424            | "destroy-undead-cr-3-or-below"
425            | "destroy-undead-cr-4-or-below"
426            | "destroy-undead-cr-1-2-or-below"
427            | "disciple-of-life"
428            | "divine-health"
429            | "draconic-resilience"
430            | "dragon-wings"
431            | "draconic-presence"
432            | "font-of-magic"
433            | "dragon-ancestor-black---acid-damage"
434            | "dragon-ancestor-blue---lightning-damage"
435            | "dragon-ancestor-brass---fire-damage"
436            | "dragon-ancestor-bronze---lightning-damage"
437            | "dragon-ancestor-copper---acid-damage"
438            | "dragon-ancestor-gold---fire-damage"
439            | "dragon-ancestor-green---poison-damage"
440            | "dragon-ancestor-red---fire-damage"
441            | "dragon-ancestor-silver---cold-damage"
442            | "dragon-ancestor-white---cold-damage"
443            | "druid-lands-stride"
444            | "druid-timeless-body"
445            | "druidic"
446            | "elusive"
447            | "empowered-evocation"
448            | "elemental-affinity"
449            | "fast-movement"
450            | "favored-enemy-1-type"
451            | "favored-enemy-2-types"
452            | "favored-enemy-3-enemies"
453            | "feral-instinct"
454            | "feral-senses"
455            | "fighter-fighting-style-archery"
456            | "fighter-fighting-style-protection"
457            | "fighter-fighting-style-defense"
458            | "fighter-fighting-style-dueling"
459            | "fighter-fighting-style-great-weapon-fighting"
460            | "fighter-fighting-style-two-weapon-fighting"
461            | "fighting-style-defense"
462            | "fighting-style-dueling"
463            | "fighting-style-great-weapon-fighting"
464            | "foe-slayer"
465            | "hurl-through-hell"
466            | "improved-critical"
467            | "improved-divine-smite"
468            | "indomitable-1-use"
469            | "indomitable-2-uses"
470            | "indomitable-3-uses"
471            | "indomitable-might"
472            | "ki-empowered-strikes"
473            | "jack-of-all-trades"
474            | "martial-arts"
475            | "monk-evasion"
476            | "monk-timeless-body"
477            | "natural-explorer-1-terrain-type"
478            | "natural-explorer-2-terrain-types"
479            | "natural-explorer-3-terrain-types"
480            | "purity-of-body"
481            | "purity-of-spirit"
482            | "natures-sanctuary"
483            | "natures-ward"
484            | "sculpt-spells"
485            | "ranger-lands-stride"
486            | "relentless-rage"
487            | "reliable-talent"
488            | "remarkable-athlete"
489            | "rogue-evasion"
490            | "superior-critical"
491            | "superior-inspiration"
492            | "supreme-healing"
493            | "supreme-sneak"
494            | "survivor"
495            | "thiefs-reflexes"
496            | "thieves-cant"
497            | "tongue-of-the-sun-and-moon"
498            | "tranquility"
499            | "unarmored-movement-1"
500            | "unarmored-movement-2"
501            | "use-magic-device"
502            | "wild-shape-cr-1-2-or-below-no-flying-speed"
503            | "wild-shape-cr-1-4-or-below-no-flying-or-swim-speed"
504            | "wild-shape-cr-1-or-below"
505            | "ki"
506            | "monk-unarmored-defense"
507            | "perfect-self"
508            | "slippery-mind"
509            | "mindless-rage"
510            | "barbarian-unarmored-defense"
511            | "divine-intervention-improvement"
512            | "persistent-rage"
513            | "evocation-savant"
514            | "overchannel"
515            | "potent-cantrip"
516            | "second-story-work"
517            | "primeval-awareness"
518            | "beast-spells" => Some(Passive),
519            // ignored until implementation?
520            "oath-spells" => Some(Ignored),
521            //x if x.starts_with("bard-expertise-") || x.starts_with("rogue-expertise-") => Some(Choosable(MultiplyTwoSkillProficiency)),
522            x if x.starts_with("bard-expertise-") || x.starts_with("rogue-expertise-") => {
523                Some(Ignored)
524            } // TODO: Implement this
525            x if x.starts_with("spellcasting-") => Some(Ignored),
526            // Ignore all eldritch invocations since they are unlocked using invocation known table
527            x if x.starts_with("eldritch-invocation-") => Some(Ignored),
528            // Ignore all circle-spells until implementation
529            x if x.starts_with("circle-spells-") => Some(Ignored),
530            // Ignore all circle of the land until implementation
531            x if x.starts_with("circle-of-the-land-") => Some(Ignored),
532            // Ignore all domain spells until implementation
533            x if x.starts_with("domain-spells-") => Some(Ignored),
534            x if x.starts_with("hunters-prey") => Some(Choosable(HuntersPrey)),
535            x if x.starts_with("defensive-tactics") => Some(Choosable(DefensiveTactics)),
536            x if x.starts_with("multiattack") => Some(Choosable(Multiattack)),
537            x if x.starts_with("ranger-fighting-style") => Some(Choosable(RangerFightingStyle)),
538            x if x.starts_with("superior-hunters-defense") => {
539                Some(Choosable(SuperiorHuntersDefense))
540            }
541            x if x.contains("ability-score-improvement") => {
542                Some(Choosable(AbilityScoreImprovement))
543            }
544            _ => None,
545        }
546    }
547}
548
549impl Classes {
550    pub(super) async fn new_day(&mut self) {
551        futures::stream::iter(self.0.values_mut())
552            .for_each_concurrent(None, |class| class.new_day())
553            .await;
554    }
555}
556
557impl Class {
558    pub(super) async fn new_day(&mut self) {
559        use crate::classes::ClassSpellCasting::*;
560
561        let index = self.index().to_string();
562
563        if let Some(spell_casting) = &mut self.1.spell_casting {
564            match spell_casting {
565                KnowledgePrepared {
566                    pending_preparation,
567                    spells_prepared_index,
568                    ..
569                }
570                | AlreadyKnowPrepared {
571                    pending_preparation,
572                    spells_prepared_index,
573                    ..
574                } => {
575                    *pending_preparation = true;
576                    spells_prepared_index.clear();
577                }
578                KnowledgeAlreadyPrepared { usable_slots, .. } => {
579                    if let Ok(Some(spellcasting_slots)) =
580                        get_spellcasting_slots(index.as_str(), self.1.level).await
581                    {
582                        *usable_slots = spellcasting_slots.into();
583                    }
584                }
585            }
586        }
587    }
588
589    pub async fn get_spellcasting_ability_index(&self) -> Result<String, ApiError> {
590        let op = SpellcastingAbilityQuery::build(SpellcastingAbilityQueryVariables {
591            index: Some(self.index().to_string()),
592        });
593
594        let ability_index = Client::new()
595            .post(GRAPHQL_API_URL.as_str())
596            .run_graphql(op)
597            .await?
598            .data
599            .ok_or(ApiError::Schema)?
600            .class
601            .ok_or(ApiError::Schema)?
602            .spellcasting
603            .ok_or(ApiError::Schema)?
604            .spellcasting_ability
605            .index;
606
607        Ok(ability_index)
608    }
609
610    pub async fn get_spellcasting_slots(&self) -> Result<Option<LevelSpellcasting>, ApiError> {
611        get_spellcasting_slots(self.index(), self.1.level).await
612    }
613
614    pub async fn set_level(
615        &mut self,
616        new_level: u8,
617    ) -> Result<Vec<ChoosableCustomLevelFeature>, ApiError> {
618        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
619            class: Some(StringFilter(self.index().to_string())),
620            level: Some(LevelFilter {
621                gt: Some(self.1.level),
622                lte: Some(new_level),
623                gte: None,
624            }),
625        });
626
627        let features = Client::new()
628            .post(GRAPHQL_API_URL.as_str())
629            .run_graphql(op)
630            .await?
631            .data
632            .ok_or(ApiError::Schema)?
633            .features
634            .ok_or(ApiError::Schema)?;
635
636        let mut pending_features = vec![];
637
638        features
639            .iter()
640            .filter_map(|feature| CustomLevelFeatureType::identify(feature.index.clone()))
641            .for_each(|feature| match feature {
642                CustomLevelFeatureType::Passive => {}
643                CustomLevelFeatureType::Choosable(feature) => {
644                    pending_features.push(feature);
645                }
646                CustomLevelFeatureType::Sheet(feature) => match feature {
647                    SheetLevelFeatureType::PrimalChampion => {
648                        self.1.abilities_modifiers.strength.score += 4;
649                        self.1.abilities_modifiers.dexterity.score += 4;
650                    }
651                },
652                Ignored => {}
653            });
654
655        self.1.level = new_level;
656
657        Ok(pending_features)
658    }
659
660    pub async fn get_levels_features(
661        &self,
662        from_level: Option<u8>,
663        passive: bool,
664    ) -> Result<Vec<String>, ApiError> {
665        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
666            class: Some(StringFilter(self.index().to_string())),
667            level: Some(LevelFilter {
668                gte: Some(from_level.unwrap_or(0)),
669                lte: Some(self.1.level),
670                gt: None,
671            }),
672        });
673
674        let features = Client::new()
675            .post(GRAPHQL_API_URL.as_str())
676            .run_graphql(op)
677            .await?
678            .data
679            .ok_or(ApiError::Schema)?
680            .features
681            .ok_or(ApiError::Schema)?;
682
683        // Remove all identifiable features
684        let mut features: Vec<String> = features
685            .into_iter()
686            .filter(
687                |feature| match CustomLevelFeatureType::identify(feature.index.clone()) {
688                    None => true,
689                    Some(custom_type) => match custom_type {
690                        CustomLevelFeatureType::Passive => passive,
691                        _ => false,
692                    },
693                },
694            )
695            .map(|feature| feature.index)
696            .collect();
697
698        let features: Vec<String> = {
699            lazy_static! {
700                static ref CR_REGEX: regex::Regex =
701                    regex::Regex::new(r"destroy-undead-cr-([0-9]+(?:-[0-9]+)?)\-or-below").unwrap();
702            }
703
704            let mut found = false;
705
706            features
707                .iter_mut()
708                .rev()
709                .filter(|feature| {
710                    if CR_REGEX.is_match(feature) {
711                        if found {
712                            false
713                        } else {
714                            found = true;
715                            true
716                        }
717                    } else {
718                        true
719                    }
720                })
721                .map(|feature| feature.clone())
722                .collect()
723        };
724
725        lazy_static! {
726            static ref DICE_REGEX: regex::Regex = regex::Regex::new(r"^(.+)-d(\d+)$").unwrap();
727        }
728
729        let mut grouped_features: HashMap<String, u32> = HashMap::new();
730        for feature in &features {
731            if let Some(caps) = DICE_REGEX.captures(feature) {
732                if caps.len() == 3 {
733                    let prefix = caps.get(1).unwrap().as_str().to_string();
734                    let dice_value = caps.get(2).unwrap().as_str().parse::<u32>().unwrap();
735
736                    let current_max = grouped_features.entry(prefix).or_insert(0);
737                    if dice_value > *current_max {
738                        *current_max = dice_value;
739                    }
740                }
741            }
742        }
743
744        let mut features: Vec<String> = features
745            .into_iter()
746            .filter(|feature| {
747                if let Some(caps) = DICE_REGEX.captures(feature) {
748                    let prefix = caps.get(1).unwrap().as_str();
749                    let dice_value = caps
750                        .get(2)
751                        .unwrap()
752                        .as_str()
753                        .parse::<u32>()
754                        .expect("Parsing dice value");
755
756                    if let Some(&max_dice) = grouped_features.get(prefix) {
757                        return dice_value == max_dice;
758                    }
759                }
760                true
761            })
762            .collect();
763
764        // Add the selected multiattack feature if it exists and we're not requesting passive features
765        if !passive {
766            if let Some(multiattack) = &self.1.multiattack {
767                features.push(multiattack.clone());
768            }
769        }
770
771        Ok(features)
772    }
773
774    pub fn apply_option(&mut self, option: ChoosableCustomLevelFeatureOption) {
775        use ChoosableCustomLevelFeatureOption::*;
776
777        match option {
778            StrengthPlusOne | DexterityPlusOne | ConstitutionPlusOne | IntelligencePlusOne
779            | WisdomPlusOne | CharismaPlusOne => self.increase_score(option),
780            BardProficiencyStrength
781            | BardProficiencyDexterity
782            | BardProficiencyConstitution
783            | BardProficiencyIntelligence
784            | BardProficiencyWisdom
785            | BardProficiencyCharisma => self.set_proficiency(option),
786            PactOfTheChain | PactOfTheBlade | PactOfTheTome => {
787                println!("Pact of the Chain, Blade or Tome not yet implemented");
788            }
789            HuntersPreyGiantKiller | HuntersPreyHordeBreaker | HuntersPreyColossusSlayer => {
790                self.1
791                    .hunters_prey
792                    .replace(option.as_index_str().to_string());
793            }
794            DefensiveTacticsSteelWill
795            | DefensiveTacticsEscapeTheHorde
796            | DefensiveTacticsMultiattackDefense => {
797                self.1
798                    .defensive_tactics
799                    .replace(option.as_index_str().to_string());
800            }
801            FighterFightingStyleArchery
802            | FighterFightingStyleDefense
803            | FighterFightingStyleDueling
804            | FighterFightingStyleGreatWeaponFighting
805            | FighterFightingStyleProtection
806            | FighterFightingStyleTwoWeaponFighting
807            | RangerFightingStyleArchery
808            | RangerFightingStyleDefense
809            | RangerFightingStyleDueling
810            | RangerFightingStyleTwoWeaponFighting
811            | FightingStyleDefense
812            | FightingStyleDueling
813            | FightingStyleGreatWeaponFighting
814            | FightingStyleProtection => {
815                if self.1.fighting_style.is_none() {
816                    self.1
817                        .fighting_style
818                        .replace(option.as_index_str().to_string());
819                } else {
820                    self.1
821                        .additional_fighting_style
822                        .replace(option.as_index_str().to_string());
823                }
824            }
825            MultiattackVolley | MultiattackWhirlwindAttack => {
826                self.1
827                    .multiattack
828                    .replace(option.as_index_str().to_string());
829            }
830            SuperiorHuntersDefenseEvasion
831            | SuperiorHuntersDefenseStandAgainstTheTide
832            | SuperiorHuntersDefenseUncannyDodge => {
833                self.1
834                    .superior_hunters_defense
835                    .replace(option.as_index_str().to_string());
836            }
837        }
838    }
839
840    fn increase_score(&mut self, option: ChoosableCustomLevelFeatureOption) {
841        match option {
842            ChoosableCustomLevelFeatureOption::StrengthPlusOne => {
843                self.1.abilities_modifiers.strength.score += 1;
844            }
845            ChoosableCustomLevelFeatureOption::DexterityPlusOne => {
846                self.1.abilities_modifiers.dexterity.score += 1;
847            }
848            ChoosableCustomLevelFeatureOption::ConstitutionPlusOne => {
849                self.1.abilities_modifiers.constitution.score += 1;
850            }
851            ChoosableCustomLevelFeatureOption::IntelligencePlusOne => {
852                self.1.abilities_modifiers.intelligence.score += 1;
853            }
854            ChoosableCustomLevelFeatureOption::WisdomPlusOne => {
855                self.1.abilities_modifiers.wisdom.score += 1;
856            }
857            ChoosableCustomLevelFeatureOption::CharismaPlusOne => {
858                self.1.abilities_modifiers.charisma.score += 1;
859            }
860            _ => {}
861        }
862    }
863
864    fn set_proficiency(&mut self, option: ChoosableCustomLevelFeatureOption) {
865        match option {
866            ChoosableCustomLevelFeatureOption::BardProficiencyStrength => {
867                self.1.abilities_modifiers.strength.proficiency = true;
868            }
869            ChoosableCustomLevelFeatureOption::BardProficiencyDexterity => {
870                self.1.abilities_modifiers.dexterity.proficiency = true;
871            }
872            ChoosableCustomLevelFeatureOption::BardProficiencyConstitution => {
873                self.1.abilities_modifiers.constitution.proficiency = true;
874            }
875            ChoosableCustomLevelFeatureOption::BardProficiencyIntelligence => {
876                self.1.abilities_modifiers.intelligence.proficiency = true;
877            }
878            ChoosableCustomLevelFeatureOption::BardProficiencyWisdom => {
879                self.1.abilities_modifiers.wisdom.proficiency = true;
880            }
881            ChoosableCustomLevelFeatureOption::BardProficiencyCharisma => {
882                self.1.abilities_modifiers.charisma.proficiency = true;
883            }
884            _ => {}
885        }
886    }
887}
888
889pub async fn get_spellcasting_slots(
890    index: &str,
891    level: u8,
892) -> Result<Option<LevelSpellcasting>, ApiError> {
893    let op = SpellcastingQuery::build(SpellcastingQueryVariables {
894        index: Some(format!("{}-{}", index, level)),
895    });
896
897    let spellcasting_slots = Client::new()
898        .post(GRAPHQL_API_URL.as_str())
899        .run_graphql(op)
900        .await?
901        .data
902        .ok_or(ApiError::Schema)?
903        .level
904        .ok_or(ApiError::Schema)?
905        .spellcasting;
906
907    Ok(spellcasting_slots)
908}