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            // ignored until implementation?
481            "oath-spells" => Some(Ignored),
482            //x if x.starts_with("bard-expertise-") || x.starts_with("rogue-expertise-") => Some(Choosable(MultiplyTwoSkillProficiency)),
483            x if x.starts_with("bard-expertise-") || x.starts_with("rogue-expertise-") => {
484                Some(Ignored)
485            } // TODO: Implement this
486            x if x.starts_with("spellcasting-") => Some(Ignored),
487            // Ignore all eldritch invocations since they are unlocked using invocation known table
488            x if x.starts_with("eldritch-invocation-") => Some(Ignored),
489            // Ignore all circle-spells until implementation
490            x if x.starts_with("circle-spells-") => Some(Ignored),
491            // Ignore all circle of the land until implementation
492            x if x.starts_with("circle-of-the-land-") => Some(Ignored),
493            // Ignore all domain spells until implementation
494            x if x.starts_with("domain-spells-") => Some(Ignored),
495            x if x.contains("ability-score-improvement") => {
496                Some(Choosable(AbilityScoreImprovement))
497            }
498            _ => None,
499        }
500    }
501}
502
503impl Classes {
504    pub(super) async fn new_day(&mut self) {
505        futures::stream::iter(self.0.values_mut())
506            .for_each_concurrent(None, |class| class.new_day())
507            .await;
508    }
509}
510
511impl Class {
512    pub(super) async fn new_day(&mut self) {
513        use crate::classes::ClassSpellCasting::*;
514
515        let index = self.index().to_string();
516
517        if let Some(spell_casting) = &mut self.1.spell_casting {
518            match spell_casting {
519                KnowledgePrepared {
520                    pending_preparation,
521                    spells_prepared_index,
522                    ..
523                }
524                | AlreadyKnowPrepared {
525                    pending_preparation,
526                    spells_prepared_index,
527                    ..
528                } => {
529                    *pending_preparation = true;
530                    spells_prepared_index.clear();
531                }
532                KnowledgeAlreadyPrepared { usable_slots, .. } => {
533                    if let Ok(Some(spellcasting_slots)) =
534                        get_spellcasting_slots(index.as_str(), self.1.level).await
535                    {
536                        *usable_slots = spellcasting_slots.into();
537                    }
538                }
539            }
540        }
541    }
542
543    pub async fn get_spellcasting_ability_index(&self) -> Result<String, ApiError> {
544        let op = SpellcastingAbilityQuery::build(SpellcastingAbilityQueryVariables {
545            index: Some(self.index().to_string()),
546        });
547
548        let ability_index = Client::new()
549            .post(GRAPHQL_API_URL.as_str())
550            .run_graphql(op)
551            .await?
552            .data
553            .ok_or(ApiError::Schema)?
554            .class
555            .ok_or(ApiError::Schema)?
556            .spellcasting
557            .ok_or(ApiError::Schema)?
558            .spellcasting_ability
559            .index;
560
561        Ok(ability_index)
562    }
563
564    pub async fn get_spellcasting_slots(&self) -> Result<Option<LevelSpellcasting>, ApiError> {
565        get_spellcasting_slots(self.index(), self.1.level).await
566    }
567
568    pub async fn set_level(
569        &mut self,
570        new_level: u8,
571    ) -> Result<Vec<ChoosableCustomLevelFeature>, ApiError> {
572        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
573            class: Some(StringFilter(self.index().to_string())),
574            level: Some(LevelFilter {
575                gte: Some(self.1.level),
576                lte: Some(new_level),
577            }),
578        });
579
580        let features = Client::new()
581            .post(GRAPHQL_API_URL.as_str())
582            .run_graphql(op)
583            .await?
584            .data
585            .ok_or(ApiError::Schema)?
586            .features
587            .ok_or(ApiError::Schema)?;
588
589        let mut pending_features = vec![];
590
591        features
592            .iter()
593            .filter_map(|feature| CustomLevelFeatureType::identify(feature.index.clone()))
594            .for_each(|feature| match feature {
595                CustomLevelFeatureType::Passive => {}
596                CustomLevelFeatureType::Choosable(feature) => {
597                    pending_features.push(feature);
598                }
599                CustomLevelFeatureType::Sheet(feature) => match feature {
600                    SheetLevelFeatureType::PrimalChampion => {
601                        self.1.abilities_modifiers.strength.score += 4;
602                        self.1.abilities_modifiers.dexterity.score += 4;
603                    }
604                },
605                Ignored => {}
606            });
607
608        self.1.level = new_level;
609
610        Ok(pending_features)
611    }
612
613    pub async fn get_levels_features(
614        &self,
615        from_level: Option<u8>,
616        passive: bool,
617    ) -> Result<Vec<String>, ApiError> {
618        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
619            class: Some(StringFilter(self.index().to_string())),
620            level: Some(LevelFilter {
621                gte: Some(from_level.unwrap_or(0)),
622                lte: Some(self.1.level),
623            }),
624        });
625
626        let features = Client::new()
627            .post(GRAPHQL_API_URL.as_str())
628            .run_graphql(op)
629            .await?
630            .data
631            .ok_or(ApiError::Schema)?
632            .features
633            .ok_or(ApiError::Schema)?;
634
635        // Remove all identifiable features
636        let mut features: Vec<String> = features
637            .into_iter()
638            .filter(
639                |feature| match CustomLevelFeatureType::identify(feature.index.clone()) {
640                    None => true,
641                    Some(custom_type) => match custom_type {
642                        CustomLevelFeatureType::Passive => passive,
643                        _ => false,
644                    },
645                },
646            )
647            .map(|feature| feature.index)
648            .collect();
649
650        let features: Vec<String> = {
651            lazy_static! {
652                static ref CR_REGEX: regex::Regex =
653                    regex::Regex::new(r"destroy-undead-cr-([0-9]+(?:-[0-9]+)?)\-or-below").unwrap();
654            }
655
656            let mut found = false;
657
658            features
659                .iter_mut()
660                .rev()
661                .filter(|feature| {
662                    if CR_REGEX.is_match(feature) {
663                        if found {
664                            false
665                        } else {
666                            found = true;
667                            true
668                        }
669                    } else {
670                        true
671                    }
672                })
673                .map(|feature| feature.clone())
674                .collect()
675        };
676
677        lazy_static! {
678            static ref DICE_REGEX: regex::Regex = regex::Regex::new(r"^(.+)-d(\d+)$").unwrap();
679        }
680
681        let mut grouped_features: HashMap<String, u32> = HashMap::new();
682        for feature in &features {
683            if let Some(caps) = DICE_REGEX.captures(feature) {
684                if caps.len() == 3 {
685                    let prefix = caps.get(1).unwrap().as_str().to_string();
686                    let dice_value = caps.get(2).unwrap().as_str().parse::<u32>().unwrap();
687
688                    let current_max = grouped_features.entry(prefix).or_insert(0);
689                    if dice_value > *current_max {
690                        *current_max = dice_value;
691                    }
692                }
693            }
694        }
695
696        let features = features
697            .into_iter()
698            .filter(|feature| {
699                if let Some(caps) = DICE_REGEX.captures(feature) {
700                    let prefix = caps.get(1).unwrap().as_str();
701                    let dice_value = caps
702                        .get(2)
703                        .unwrap()
704                        .as_str()
705                        .parse::<u32>()
706                        .expect("Parsing dice value");
707
708                    if let Some(&max_dice) = grouped_features.get(prefix) {
709                        return dice_value == max_dice;
710                    }
711                }
712                true
713            })
714            .collect();
715
716        Ok(features)
717    }
718
719    pub fn apply_option(&mut self, option: ChoosableCustomLevelFeatureOption) {
720        use ChoosableCustomLevelFeatureOption::*;
721
722        match option {
723            StrengthPlusOne | DexterityPlusOne | ConstitutionPlusOne | IntelligencePlusOne
724            | WisdomPlusOne | CharismaPlusOne => self.increase_score(option),
725            BardProficiencyStrength
726            | BardProficiencyDexterity
727            | BardProficiencyConstitution
728            | BardProficiencyIntelligence
729            | BardProficiencyWisdom
730            | BardProficiencyCharisma => self.set_proficiency(option),
731            PactOfTheChain | PactOfTheBlade | PactOfTheTome => {
732                println!("Pact of the Chain, Blade or Tome not yet implemented");
733            }
734            FighterFightingStyleArchery
735            | FighterFightingStyleDefense
736            | FighterFightingStyleDueling
737            | FighterFightingStyleGreatWeaponFighting
738            | FighterFightingStyleProtection
739            | FighterFightingStyleTwoWeaponFighting
740            | RangerFightingStyleArchery
741            | RangerFightingStyleDefense
742            | RangerFightingStyleDueling
743            | RangerFightingStyleTwoWeaponFighting
744            | FightingStyleDefense
745            | FightingStyleDueling
746            | FightingStyleGreatWeaponFighting
747            | FightingStyleProtection => {
748                if self.1.fighting_style.is_none() {
749                    self.1
750                        .fighting_style
751                        .replace(option.as_index_str().to_string());
752                } else {
753                    self.1
754                        .additional_fighting_style
755                        .replace(option.as_index_str().to_string());
756                }
757            }
758        }
759    }
760
761    fn increase_score(&mut self, option: ChoosableCustomLevelFeatureOption) {
762        match option {
763            ChoosableCustomLevelFeatureOption::StrengthPlusOne => {
764                self.1.abilities_modifiers.strength.score += 1;
765            }
766            ChoosableCustomLevelFeatureOption::DexterityPlusOne => {
767                self.1.abilities_modifiers.dexterity.score += 1;
768            }
769            ChoosableCustomLevelFeatureOption::ConstitutionPlusOne => {
770                self.1.abilities_modifiers.constitution.score += 1;
771            }
772            ChoosableCustomLevelFeatureOption::IntelligencePlusOne => {
773                self.1.abilities_modifiers.intelligence.score += 1;
774            }
775            ChoosableCustomLevelFeatureOption::WisdomPlusOne => {
776                self.1.abilities_modifiers.wisdom.score += 1;
777            }
778            ChoosableCustomLevelFeatureOption::CharismaPlusOne => {
779                self.1.abilities_modifiers.charisma.score += 1;
780            }
781            _ => {}
782        }
783    }
784
785    fn set_proficiency(&mut self, option: ChoosableCustomLevelFeatureOption) {
786        match option {
787            ChoosableCustomLevelFeatureOption::BardProficiencyStrength => {
788                self.1.abilities_modifiers.strength.proficiency = true;
789            }
790            ChoosableCustomLevelFeatureOption::BardProficiencyDexterity => {
791                self.1.abilities_modifiers.dexterity.proficiency = true;
792            }
793            ChoosableCustomLevelFeatureOption::BardProficiencyConstitution => {
794                self.1.abilities_modifiers.constitution.proficiency = true;
795            }
796            ChoosableCustomLevelFeatureOption::BardProficiencyIntelligence => {
797                self.1.abilities_modifiers.intelligence.proficiency = true;
798            }
799            ChoosableCustomLevelFeatureOption::BardProficiencyWisdom => {
800                self.1.abilities_modifiers.wisdom.proficiency = true;
801            }
802            ChoosableCustomLevelFeatureOption::BardProficiencyCharisma => {
803                self.1.abilities_modifiers.charisma.proficiency = true;
804            }
805            _ => {}
806        }
807    }
808}
809
810pub async fn get_spellcasting_slots(
811    index: &str,
812    level: u8,
813) -> Result<Option<LevelSpellcasting>, ApiError> {
814    let op = SpellcastingQuery::build(SpellcastingQueryVariables {
815        index: Some(format!("{}-{}", index, level)),
816    });
817
818    let spellcasting_slots = Client::new()
819        .post(GRAPHQL_API_URL.as_str())
820        .run_graphql(op)
821        .await?
822        .data
823        .ok_or(ApiError::Schema)?
824        .level
825        .ok_or(ApiError::Schema)?
826        .spellcasting;
827
828    Ok(spellcasting_slots)
829}