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