dnd_character/api/
classes.rs

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