dnd_character/api/
classes.rs

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