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