dnd_character/api/
classes.rs

1use super::shared::schema;
2use crate::GRAPHQL_API_URL;
3use crate::api::classes::CustomLevelFeatureType::Ignored;
4use crate::api::shared::ApiError;
5use crate::classes::{Class, Classes, UsableSlots};
6use cynic::http::ReqwestExt;
7use cynic::{QueryBuilder, impl_scalar};
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    /// Used for
154    /// https://www.dnd5eapi.co/api/features/bard-expertise-1
155    /// https://www.dnd5eapi.co/api/features/bard-expertise-2
156    /// https://www.dnd5eapi.co/api/features/rogue-expertise-1
157    /// https://www.dnd5eapi.co/api/features/rogue-expertise-2
158    MultiplyTwoSkillProficiency,
159    /// https://www.dnd5eapi.co/api/features/magical-secrets-1
160    /// https://www.dnd5eapi.co/api/features/magical-secrets-2
161    /// https://www.dnd5eapi.co/api/features/magical-secrets-3
162    ChooseTwoSpellForAnyClass,
163    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-6th-level
164    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-7th-level
165    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-8th-level
166    /// https://www.dnd5eapi.co/api/features/mystic-arcanum-9th-level
167    ChooseOne6thLevelSpellFromWarlockList,
168    /// https://www.dnd5eapi.co/api/features/paladin-fighting-style
169    PaladinFightingStyle,
170    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/multiattack
171    Multiattack,
172    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/superior-hunters-defense
173    SuperiorHuntersDefense,
174    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/favored-enemy-1-type
175    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/favored-enemy-2-types
176    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/favored-enemy-3-enemies
177    RangerFavoredEnemyType,
178    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/natural-explorer-1-terrain-type
179    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/natural-explorer-2-terrain-types
180    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/natural-explorer-3-terrain-types
181    RangerTerrainType,
182    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/metamagic-1
183    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/metamagic-2
184    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/metamagic-3
185    Metamagic,
186    /// https://dnd5eapi.rpgmaster.ai/api/2014/features/dragon-ancestor
187    DragonAncestor,
188}
189
190#[derive(Clone, Debug)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
192#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
193pub enum ChoosableCustomLevelFeatureOption {
194    StrengthPlusOne,
195    DexterityPlusOne,
196    ConstitutionPlusOne,
197    IntelligencePlusOne,
198    WisdomPlusOne,
199    CharismaPlusOne,
200
201    PactOfTheChain,
202    PactOfTheBlade,
203    PactOfTheTome,
204
205    FighterFightingStyleArchery,
206    FighterFightingStyleDefense,
207    FighterFightingStyleDueling,
208    FighterFightingStyleGreatWeaponFighting,
209    FighterFightingStyleProtection,
210    FighterFightingStyleTwoWeaponFighting,
211
212    RangerFightingStyleArchery,
213    RangerFightingStyleDefense,
214    RangerFightingStyleDueling,
215    RangerFightingStyleTwoWeaponFighting,
216
217    RangerTerrainTypeArctic,
218    RangerTerrainTypeCoast,
219    RangerTerrainTypeDesert,
220    RangerTerrainTypeForest,
221    RangerTerrainTypeGrassland,
222    RangerTerrainTypeMountain,
223    RangerTerrainTypeSwamp,
224
225    RangerFavoredEnemyTypeAberrations,
226    RangerFavoredEnemyTypeBeasts,
227    RangerFavoredEnemyTypeCelestials,
228    RangerFavoredEnemyTypeConstructs,
229    RangerFavoredEnemyTypeDragons,
230    RangerFavoredEnemyTypeElementals,
231    RangerFavoredEnemyTypeFey,
232    RangerFavoredEnemyTypeFiends,
233    RangerFavoredEnemyTypeGiants,
234    RangerFavoredEnemyTypeMonstrosities,
235    RangerFavoredEnemyTypeOozes,
236    RangerFavoredEnemyTypePlants,
237    RangerFavoredEnemyTypeUndead,
238    RangerFavoredEnemyTypeHumanoids,
239
240    FightingStyleDefense,
241    FightingStyleDueling,
242    FightingStyleGreatWeaponFighting,
243    FightingStyleProtection,
244
245    HuntersPreyGiantKiller,
246    HuntersPreyHordeBreaker,
247    HuntersPreyColossusSlayer,
248
249    DefensiveTacticsSteelWill,
250    DefensiveTacticsEscapeTheHorde,
251    DefensiveTacticsMultiattackDefense,
252
253    MultiattackVolley,
254    MultiattackWhirlwindAttack,
255
256    SuperiorHuntersDefenseEvasion,
257    SuperiorHuntersDefenseStandAgainstTheTide,
258    SuperiorHuntersDefenseUncannyDodge,
259
260    MetamagicCarefulSpell,
261    MetamagicDistantSpell,
262    MetamagicEmpoweredSpell,
263    MetamagicExtendedSpell,
264    MetamagicHeightenedSpell,
265    MetamagicQuickenedSpell,
266    MetamagicSubtleSpell,
267    MetamagicTwinnedSpell,
268
269    #[serde(rename = "dragon-ancestor-black---acid-damage")]
270    DragonAncestorBlackAcidDamage,
271    #[serde(rename = "dragon-ancestor-blue---lightning-damage")]
272    DragonAncestorBlueLightningDamage,
273    #[serde(rename = "dragon-ancestor-brass---fire-damage")]
274    DragonAncestorBrassFireDamage,
275    #[serde(rename = "dragon-ancestor-bronze---lightning-damage")]
276    DragonAncestorBronzeLightningDamage,
277    #[serde(rename = "dragon-ancestor-copper---acid-damage")]
278    DragonAncestorCopperAcidDamage,
279    #[serde(rename = "dragon-ancestor-gold---fire-damage")]
280    DragonAncestorGoldFireDamage,
281    #[serde(rename = "dragon-ancestor-green---poison-damage")]
282    DragonAncestorGreenPoisonDamage,
283    #[serde(rename = "dragon-ancestor-red---fire-damage")]
284    DragonAncestorRedFireDamage,
285    #[serde(rename = "dragon-ancestor-silver---cold-damage")]
286    DragonAncestorSilverColdDamage,
287    #[serde(rename = "dragon-ancestor-white---cold-damage")]
288    DragonAncestorWhiteColdDamage,
289}
290
291impl ChoosableCustomLevelFeatureOption {
292    #[cfg(feature = "serde")]
293    pub fn as_index_str(&self) -> &str {
294        serde_variant::to_variant_name(self).unwrap()
295    }
296
297    #[cfg(feature = "serde")]
298    pub fn from_index_str(index: &str) -> Option<ChoosableCustomLevelFeatureOption> {
299        #[derive(serde::Deserialize)]
300        struct Helper {
301            value: ChoosableCustomLevelFeatureOption,
302        }
303
304        let json = json!({
305            "value": index
306        });
307
308        serde_json::from_value::<Helper>(json)
309            .map(|helper| helper.value)
310            .ok()
311    }
312}
313
314impl ChoosableCustomLevelFeature {
315    #[cfg(feature = "serde")]
316    pub fn as_index_str(&self) -> &str {
317        serde_variant::to_variant_name(self).unwrap()
318    }
319
320    pub fn to_options(&self) -> Vec<Vec<ChoosableCustomLevelFeatureOption>> {
321        use ChoosableCustomLevelFeatureOption::*;
322
323        match self {
324            ChoosableCustomLevelFeature::AbilityScoreImprovement => {
325                let ability_names = vec![
326                    StrengthPlusOne,
327                    DexterityPlusOne,
328                    ConstitutionPlusOne,
329                    IntelligencePlusOne,
330                    WisdomPlusOne,
331                    CharismaPlusOne,
332                ];
333
334                vec![ability_names.clone(), ability_names]
335            }
336            ChoosableCustomLevelFeature::WarlockPact => {
337                vec![vec![PactOfTheChain, PactOfTheBlade, PactOfTheTome]]
338            }
339            ChoosableCustomLevelFeature::AdditionalFighterFightingStyle
340            | ChoosableCustomLevelFeature::FighterFightingStyle => {
341                vec![vec![
342                    FighterFightingStyleArchery,
343                    FighterFightingStyleDefense,
344                    FighterFightingStyleDueling,
345                    FighterFightingStyleGreatWeaponFighting,
346                    FighterFightingStyleProtection,
347                    FighterFightingStyleTwoWeaponFighting,
348                ]]
349            }
350            ChoosableCustomLevelFeature::RangerFightingStyle => {
351                vec![vec![
352                    RangerFightingStyleArchery,
353                    RangerFightingStyleDefense,
354                    RangerFightingStyleDueling,
355                    RangerFightingStyleTwoWeaponFighting,
356                ]]
357            }
358            ChoosableCustomLevelFeature::MultiplyTwoSkillProficiency => {
359                // TODO: Implement this
360                vec![vec![]]
361            }
362            ChoosableCustomLevelFeature::ChooseTwoSpellForAnyClass => {
363                // TODO: Implement this
364                vec![vec![]]
365            }
366            ChoosableCustomLevelFeature::ChooseOne6thLevelSpellFromWarlockList => {
367                // TODO: Implement this when other warlock features are implemented
368                vec![vec![]]
369            }
370            ChoosableCustomLevelFeature::PaladinFightingStyle => {
371                vec![vec![
372                    FightingStyleDefense,
373                    FightingStyleDueling,
374                    FightingStyleGreatWeaponFighting,
375                    FightingStyleProtection,
376                ]]
377            }
378            ChoosableCustomLevelFeature::HuntersPrey => {
379                vec![vec![
380                    HuntersPreyGiantKiller,
381                    HuntersPreyHordeBreaker,
382                    HuntersPreyColossusSlayer,
383                ]]
384            }
385            ChoosableCustomLevelFeature::DefensiveTactics => {
386                vec![vec![
387                    DefensiveTacticsSteelWill,
388                    DefensiveTacticsEscapeTheHorde,
389                    DefensiveTacticsMultiattackDefense,
390                ]]
391            }
392            ChoosableCustomLevelFeature::Multiattack => {
393                vec![vec![MultiattackWhirlwindAttack, MultiattackVolley]]
394            }
395            ChoosableCustomLevelFeature::SuperiorHuntersDefense => {
396                vec![vec![
397                    SuperiorHuntersDefenseEvasion,
398                    SuperiorHuntersDefenseStandAgainstTheTide,
399                    SuperiorHuntersDefenseUncannyDodge,
400                ]]
401            }
402            ChoosableCustomLevelFeature::RangerFavoredEnemyType => {
403                vec![vec![
404                    RangerFavoredEnemyTypeAberrations,
405                    RangerFavoredEnemyTypeBeasts,
406                    RangerFavoredEnemyTypeCelestials,
407                    RangerFavoredEnemyTypeConstructs,
408                    RangerFavoredEnemyTypeDragons,
409                    RangerFavoredEnemyTypeElementals,
410                    RangerFavoredEnemyTypeFey,
411                    RangerFavoredEnemyTypeFiends,
412                    RangerFavoredEnemyTypeGiants,
413                    RangerFavoredEnemyTypeMonstrosities,
414                    RangerFavoredEnemyTypeOozes,
415                    RangerFavoredEnemyTypePlants,
416                    RangerFavoredEnemyTypeUndead,
417                    RangerFavoredEnemyTypeHumanoids,
418                ]]
419            }
420            ChoosableCustomLevelFeature::RangerTerrainType => {
421                vec![vec![
422                    RangerTerrainTypeArctic,
423                    RangerTerrainTypeCoast,
424                    RangerTerrainTypeDesert,
425                    RangerTerrainTypeForest,
426                    RangerTerrainTypeGrassland,
427                    RangerTerrainTypeMountain,
428                    RangerTerrainTypeSwamp,
429                ]]
430            }
431            ChoosableCustomLevelFeature::Metamagic => {
432                let all_metamagics = vec![
433                    MetamagicCarefulSpell,
434                    MetamagicDistantSpell,
435                    MetamagicEmpoweredSpell,
436                    MetamagicExtendedSpell,
437                    MetamagicHeightenedSpell,
438                    MetamagicQuickenedSpell,
439                    MetamagicSubtleSpell,
440                    MetamagicTwinnedSpell,
441                ];
442
443                vec![all_metamagics.clone(), all_metamagics]
444            }
445            ChoosableCustomLevelFeature::DragonAncestor => {
446                vec![vec![
447                    DragonAncestorBlackAcidDamage,
448                    DragonAncestorBlueLightningDamage,
449                    DragonAncestorBrassFireDamage,
450                    DragonAncestorBronzeLightningDamage,
451                    DragonAncestorCopperAcidDamage,
452                    DragonAncestorGoldFireDamage,
453                    DragonAncestorGreenPoisonDamage,
454                    DragonAncestorRedFireDamage,
455                    DragonAncestorSilverColdDamage,
456                    DragonAncestorWhiteColdDamage,
457                ]]
458            }
459        }
460    }
461}
462
463pub enum SheetLevelFeatureType {
464    /// https://www.dnd5eapi.co/api/features/primal-champion
465    PrimalChampion,
466}
467
468pub enum CustomLevelFeatureType {
469    Choosable(ChoosableCustomLevelFeature),
470    Sheet(SheetLevelFeatureType),
471    Passive,
472    Ignored,
473}
474
475impl CustomLevelFeatureType {
476    pub fn identify(index: String) -> Option<CustomLevelFeatureType> {
477        use ChoosableCustomLevelFeature::*;
478        use CustomLevelFeatureType::*;
479        use SheetLevelFeatureType::*;
480        match index.as_str() {
481            // Ignore all subclass choices since we have only one subclass per class
482            "bard-college"
483            | "divine-domain"
484            | "monastic-tradition"
485            | "sacred-oath"
486            | "ranger-archetype"
487            | "sorcerous-origin"
488            | "druid-circle"
489            | "primal-path"
490            | "martial-archetype"
491            | "roguish-archetype"
492            | "otherworldly-patron" => Some(Ignored),
493            "pact-boon" => Some(Choosable(WarlockPact)),
494            "additional-fighting-style" => Some(Choosable(AdditionalFighterFightingStyle)),
495            "fighter-fighting-style" => Some(Choosable(FighterFightingStyle)),
496            "bonus-proficiency" => Some(Passive),
497            "additional-magical-secrets" | "bonus-cantrip" => Some(Ignored),
498            "channel-divinity-1-rest" | "channel-divinity-2-rest" | "channel-divinity-3-rest" => {
499                Some(Ignored)
500            }
501            //"magical-secrets-1" | "magical-secrets-2" | "magical-secrets-3" => Some(Choosable(ChooseTwoSpellForAnyClass)), TODO: Implement this
502            "magical-secrets-1" | "magical-secrets-2" | "magical-secrets-3" => Some(Ignored),
503            "mystic-arcanum-6th-level"
504            | "mystic-arcanum-7th-level"
505            | "mystic-arcanum-8th-level"
506            | "mystic-arcanum-9th-level" => Some(Choosable(ChooseOne6thLevelSpellFromWarlockList)),
507            "paladin-fighting-style" => Some(Choosable(PaladinFightingStyle)),
508            "primal-champion" => Some(Sheet(PrimalChampion)),
509            // TODO: Implement https://www.dnd5eapi.co/api/features/diamond-soul
510            "diamond-soul" => Some(Passive),
511            "arcane-recovery"
512            | "arcane-tradition"
513            | "archdruid"
514            | "aura-improvements"
515            | "aura-of-courage"
516            | "aura-of-devotion"
517            | "aura-of-protection"
518            | "blessed-healer"
519            | "blindsense"
520            | "brutal-critical-1-dice"
521            | "brutal-critical-2-dice"
522            | "brutal-critical-3-dice"
523            | "danger-sense"
524            | "dark-ones-blessing"
525            | "dark-ones-own-luck"
526            | "destroy-undead-cr-1-or-below"
527            | "destroy-undead-cr-2-or-below"
528            | "destroy-undead-cr-3-or-below"
529            | "destroy-undead-cr-4-or-below"
530            | "destroy-undead-cr-1-2-or-below"
531            | "disciple-of-life"
532            | "divine-health"
533            | "draconic-resilience"
534            | "font-of-magic"
535            | "druid-lands-stride"
536            | "druid-timeless-body"
537            | "druidic"
538            | "elusive"
539            | "empowered-evocation"
540            | "fast-movement"
541            | "feral-instinct"
542            | "feral-senses"
543            | "foe-slayer"
544            | "hurl-through-hell"
545            | "improved-critical"
546            | "improved-divine-smite"
547            | "indomitable-1-use"
548            | "indomitable-2-uses"
549            | "indomitable-3-uses"
550            | "indomitable-might"
551            | "ki-empowered-strikes"
552            | "jack-of-all-trades"
553            | "martial-arts"
554            | "monk-evasion"
555            | "monk-timeless-body"
556            | "purity-of-body"
557            | "purity-of-spirit"
558            | "natures-sanctuary"
559            | "natures-ward"
560            | "sculpt-spells"
561            | "ranger-lands-stride"
562            | "relentless-rage"
563            | "reliable-talent"
564            | "remarkable-athlete"
565            | "rogue-evasion"
566            | "superior-critical"
567            | "superior-inspiration"
568            | "supreme-healing"
569            | "supreme-sneak"
570            | "survivor"
571            | "thiefs-reflexes"
572            | "thieves-cant"
573            | "tongue-of-the-sun-and-moon"
574            | "tranquility"
575            | "unarmored-movement-1"
576            | "unarmored-movement-2"
577            | "use-magic-device"
578            | "ki"
579            | "monk-unarmored-defense"
580            | "perfect-self"
581            | "slippery-mind"
582            | "mindless-rage"
583            | "barbarian-unarmored-defense"
584            | "divine-intervention-improvement"
585            | "persistent-rage"
586            | "evocation-savant"
587            | "overchannel"
588            | "potent-cantrip"
589            | "font-of-inspiration"
590            | "second-story-work"
591            | "primeval-awareness"
592            | "beast-spells" => Some(Passive),
593            // ignored until implementation?
594            "oath-spells" => Some(Ignored),
595            "natural-recovery" => Some(Ignored),
596            x if x.starts_with("metamagic-") => {
597                if x.len() == 11 {
598                    Some(Choosable(Metamagic))
599                } else {
600                    Some(Ignored)
601                }
602            }
603            "hunters-prey" => Some(Choosable(HuntersPrey)),
604            x if x.starts_with("hunters-prey-") => Some(Ignored),
605            "superior-hunters-defense" => Some(Choosable(SuperiorHuntersDefense)),
606            x if x.starts_with("superior-hunters-defenese-") => Some(Ignored),
607            //x if x.starts_with("bard-expertise-")|| x.starts_with("rogue-expertise-") => Some(Choosable(MultiplyTwoSkillProficiency)),
608            x if x.starts_with("bard-expertise-") || x.starts_with("rogue-expertise-") => {
609                Some(Ignored)
610            } // TODO: Implement this
611            x if x.starts_with("spellcasting-") => Some(Ignored),
612            // Ignore all eldritch invocations since they are unlocked using invocation known table
613            x if x.starts_with("eldritch-invocation") => Some(Ignored),
614            // Ignore all circle-spells until implementation
615            x if x.starts_with("circle-spells-") => Some(Ignored),
616            // Ignore all circle of the land until implementation
617            x if x.starts_with("circle-of-the-land") => Some(Ignored),
618            // Ignore all domain spells until implementation
619            x if x.starts_with("domain-spells-") => Some(Ignored),
620            // sorcery points not yet implemented
621            x if x.starts_with("flexible-casting-") => Some(Ignored),
622            "dragon-ancestor" => Some(Choosable(DragonAncestor)),
623            x if x.starts_with("dragon-ancestor-") => Some(Ignored),
624            "defensive-tactics" => Some(Choosable(DefensiveTactics)),
625            x if x.starts_with("defensive-tactics-") => Some(Ignored),
626            "multiattack" => Some(Choosable(Multiattack)),
627            x if x.starts_with("multiattack-") => Some(Ignored),
628            "ranger-fighting-style" => Some(Choosable(RangerFightingStyle)),
629            x if x.starts_with("favored-enemy-") => Some(Choosable(RangerFavoredEnemyType)),
630            x if x.starts_with("natural-explorer-") => Some(Choosable(RangerTerrainType)),
631            // Ignore pacts from patc-boon
632            x if x.starts_with("pact-of-the-") => Some(Ignored),
633            x if x.contains("ability-score-improvement") => {
634                Some(Choosable(AbilityScoreImprovement))
635            }
636            x if x.starts_with("fighting-style-") => Some(Ignored),
637            x if x.starts_with("fighter-fighting-style-") => Some(Ignored),
638            x if x.starts_with("ranger-fighting-style-") => Some(Ignored),
639            _ => None,
640        }
641    }
642}
643
644impl Classes {
645    pub(super) async fn new_day(&mut self) {
646        futures::stream::iter(self.0.values_mut())
647            .for_each_concurrent(None, |class| class.new_day())
648            .await;
649    }
650}
651
652impl Class {
653    pub(super) async fn new_day(&mut self) {
654        use crate::classes::ClassSpellCasting::*;
655
656        let index = self.index().to_string();
657
658        if let Some(spell_casting) = &mut self.1.spell_casting {
659            match spell_casting {
660                KnowledgePrepared {
661                    pending_preparation,
662                    spells_prepared_index,
663                    ..
664                }
665                | AlreadyKnowPrepared {
666                    pending_preparation,
667                    spells_prepared_index,
668                    ..
669                } => {
670                    *pending_preparation = true;
671                    spells_prepared_index.clear();
672                }
673                KnowledgeAlreadyPrepared { usable_slots, .. } => {
674                    if let Ok(Some(spellcasting_slots)) =
675                        get_spellcasting_slots(index.as_str(), self.1.level).await
676                    {
677                        *usable_slots = spellcasting_slots.into();
678                    }
679                }
680            }
681        }
682    }
683
684    pub async fn get_spellcasting_ability_index(&self) -> Result<String, ApiError> {
685        let op = SpellcastingAbilityQuery::build(SpellcastingAbilityQueryVariables {
686            index: Some(self.index().to_string()),
687        });
688
689        let ability_index = Client::new()
690            .post(GRAPHQL_API_URL.as_str())
691            .run_graphql(op)
692            .await?
693            .data
694            .ok_or(ApiError::Schema)?
695            .class
696            .ok_or(ApiError::Schema)?
697            .spellcasting
698            .ok_or(ApiError::Schema)?
699            .spellcasting_ability
700            .index;
701
702        Ok(ability_index)
703    }
704
705    pub async fn get_spellcasting_slots(&self) -> Result<Option<LevelSpellcasting>, ApiError> {
706        get_spellcasting_slots(self.index(), self.1.level).await
707    }
708
709    pub async fn set_level(
710        &mut self,
711        new_level: u8,
712    ) -> Result<Vec<ChoosableCustomLevelFeature>, ApiError> {
713        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
714            class: Some(StringFilter(self.index().to_string())),
715            level: Some(LevelFilter {
716                gt: Some(self.1.level),
717                lte: Some(new_level),
718                gte: None,
719            }),
720        });
721
722        let features = Client::new()
723            .post(GRAPHQL_API_URL.as_str())
724            .run_graphql(op)
725            .await?
726            .data
727            .ok_or(ApiError::Schema)?
728            .features
729            .ok_or(ApiError::Schema)?;
730
731        let mut pending_features = vec![];
732
733        features
734            .iter()
735            .filter_map(|feature| CustomLevelFeatureType::identify(feature.index.clone()))
736            .for_each(|feature| match feature {
737                CustomLevelFeatureType::Passive => {}
738                CustomLevelFeatureType::Choosable(feature) => {
739                    pending_features.push(feature);
740                }
741                CustomLevelFeatureType::Sheet(feature) => match feature {
742                    SheetLevelFeatureType::PrimalChampion => {
743                        self.1.abilities_modifiers.strength.score += 4;
744                        self.1.abilities_modifiers.constitution.score += 4;
745                    }
746                },
747                Ignored => {}
748            });
749
750        self.1.level = new_level;
751
752        Ok(pending_features)
753    }
754
755    pub async fn get_levels_features(
756        &self,
757        from_level: Option<u8>,
758        passive: bool,
759    ) -> Result<Vec<String>, ApiError> {
760        let op = LevelFeaturesQuery::build(LevelFeaturesQueryVariables {
761            class: Some(StringFilter(self.index().to_string())),
762            level: Some(LevelFilter {
763                gte: Some(from_level.unwrap_or(0)),
764                lte: Some(self.1.level),
765                gt: None,
766            }),
767        });
768
769        let features = Client::new()
770            .post(GRAPHQL_API_URL.as_str())
771            .run_graphql(op)
772            .await?
773            .data
774            .ok_or(ApiError::Schema)?
775            .features
776            .ok_or(ApiError::Schema)?;
777
778        // First convert features to String objects and filter out non-matching features
779        let features: Vec<String> = features
780            .into_iter()
781            .filter_map(
782                |feature| match CustomLevelFeatureType::identify(feature.index.clone()) {
783                    None => Some(feature.index),
784                    Some(custom_type) => match custom_type {
785                        CustomLevelFeatureType::Passive if passive => Some(feature.index),
786                        _ => None,
787                    },
788                },
789            )
790            .collect();
791
792        // Define all regexes at once
793        lazy_static! {
794            static ref CR_REGEX: regex::Regex =
795                regex::Regex::new(r"(.*)-cr-([0-9]+(?:-[0-9]+)?)-or-below.*").unwrap();
796            static ref DICE_REGEX: regex::Regex = regex::Regex::new(r"^(.+)-d(\d+)$").unwrap();
797            static ref DIE_DICE_REGEX: regex::Regex =
798                regex::Regex::new(r"^(.+)-(\d+)-(die|dice)$").unwrap();
799            static ref UNARMORED_MOVEMENT_REGEX: regex::Regex =
800                regex::Regex::new(r"^(unarmored-movement)-(\d+)$").unwrap();
801        }
802
803        // Track the highest values for each pattern type
804        let mut cr_features: HashMap<String, (f32, String)> = HashMap::new();
805        let mut dice_features: HashMap<String, u32> = HashMap::new();
806        let mut die_dice_features: HashMap<String, u32> = HashMap::new();
807        let mut unarmored_movement_features: HashMap<String, (u32, String)> = HashMap::new();
808
809        // First pass to collect all the pattern information
810        for feature in &features {
811            // Process CR pattern
812            if let Some(caps) = CR_REGEX.captures(feature) {
813                let prefix = caps.get(1).unwrap().as_str().to_string();
814                let cr_str = caps.get(2).unwrap().as_str();
815
816                // Parse CR value (handling fractions like "1-2" for 1/2)
817                let cr_value = if cr_str.contains('-') {
818                    let parts: Vec<&str> = cr_str.split('-').collect();
819                    if parts.len() == 2 {
820                        parts[0].parse::<f32>().unwrap_or(0.0)
821                            / parts[1].parse::<f32>().unwrap_or(1.0)
822                    } else {
823                        0.0
824                    }
825                } else {
826                    cr_str.parse::<f32>().unwrap_or(0.0)
827                };
828
829                // Update if this is higher CR for this prefix
830                if let Some((existing_cr, _)) = cr_features.get(&prefix) {
831                    if cr_value > *existing_cr {
832                        cr_features.insert(prefix, (cr_value, feature.clone()));
833                    }
834                } else {
835                    cr_features.insert(prefix, (cr_value, feature.clone()));
836                }
837                continue;
838            }
839
840            // Process dice-N pattern
841            if let Some(caps) = DICE_REGEX.captures(feature) {
842                let prefix = caps.get(1).unwrap().as_str().to_string();
843                let dice_value = caps.get(2).unwrap().as_str().parse::<u32>().unwrap_or(0);
844
845                let current_max = dice_features.entry(prefix).or_insert(0);
846                if dice_value > *current_max {
847                    *current_max = dice_value;
848                }
849                continue;
850            }
851
852            // Process N-die/dice pattern
853            if let Some(caps) = DIE_DICE_REGEX.captures(feature) {
854                let prefix = caps.get(1).unwrap().as_str().to_string();
855                let dice_value = caps.get(2).unwrap().as_str().parse::<u32>().unwrap_or(0);
856
857                let current_max = die_dice_features.entry(prefix).or_insert(0);
858                if dice_value > *current_max {
859                    *current_max = dice_value;
860                }
861            }
862
863            // Process unarmored-movement-N pattern
864            if let Some(caps) = UNARMORED_MOVEMENT_REGEX.captures(feature) {
865                let prefix = caps.get(1).unwrap().as_str().to_string();
866                let movement_value = caps.get(2).unwrap().as_str().parse::<u32>().unwrap_or(0);
867
868                // Update if this is a higher value for unarmored movement
869                if let Some((existing_value, _)) = unarmored_movement_features.get(&prefix) {
870                    if movement_value > *existing_value {
871                        unarmored_movement_features
872                            .insert(prefix, (movement_value, feature.clone()));
873                    }
874                } else {
875                    unarmored_movement_features.insert(prefix, (movement_value, feature.clone()));
876                }
877            }
878        }
879
880        // Second pass: Filter to keep only the highest value patterns
881        let mut filtered_features = Vec::new();
882        let mut has_improved_divine_smite = false;
883
884        // First check if improved-divine-smite exists
885        for feature in &features {
886            if feature == "improved-divine-smite" {
887                has_improved_divine_smite = true;
888                break;
889            }
890        }
891
892        for feature in features {
893            // Skip divine-smite if improved-divine-smite is present
894            if feature == "divine-smite" && has_improved_divine_smite {
895                continue;
896            }
897
898            // Handle CR pattern
899            if let Some(caps) = CR_REGEX.captures(&feature) {
900                let prefix = caps.get(1).unwrap().as_str().to_string();
901
902                if let Some((_, highest_feature)) = cr_features.get(&prefix) {
903                    if &feature == highest_feature {
904                        filtered_features.push(feature);
905                    }
906                }
907                continue;
908            }
909
910            // Handle dice pattern
911            if let Some(caps) = DICE_REGEX.captures(&feature) {
912                let prefix = caps.get(1).unwrap().as_str().to_string();
913                let dice_value = caps
914                    .get(2)
915                    .unwrap()
916                    .as_str()
917                    .parse::<u32>()
918                    .expect("Parsing dice value");
919
920                if let Some(&max_dice) = dice_features.get(&prefix) {
921                    if dice_value == max_dice {
922                        filtered_features.push(feature);
923                    }
924                }
925                continue;
926            }
927
928            // Handle die/dice pattern
929            if let Some(caps) = DIE_DICE_REGEX.captures(&feature) {
930                let prefix = caps.get(1).unwrap().as_str().to_string();
931                let dice_value = caps
932                    .get(2)
933                    .unwrap()
934                    .as_str()
935                    .parse::<u32>()
936                    .expect("Parsing die/dice value");
937
938                if let Some(&max_dice) = die_dice_features.get(&prefix) {
939                    if dice_value == max_dice {
940                        filtered_features.push(feature);
941                    }
942                }
943                continue;
944            }
945
946            // Handle unarmored-movement-N pattern
947            if let Some(caps) = UNARMORED_MOVEMENT_REGEX.captures(&feature) {
948                let prefix = caps.get(1).unwrap().as_str().to_string();
949
950                if let Some((_, highest_feature)) = unarmored_movement_features.get(&prefix) {
951                    if &feature == highest_feature {
952                        filtered_features.push(feature);
953                    }
954                }
955                continue;
956            }
957
958            // Regular feature, keep it
959            filtered_features.push(feature);
960        }
961
962        let mut features = filtered_features;
963
964        // Add the selected multiattack feature if it exists and we're not requesting passive features
965        if !passive {
966            if let Some(multiattack) = &self.1.multiattack {
967                features.push(multiattack.clone());
968            }
969            if let Some(hunters_prey) = &self.1.hunters_prey {
970                features.push(hunters_prey.clone());
971            }
972            if let Some(metamagic) = &self.1.sorcerer_metamagic {
973                features.append(&mut metamagic.clone());
974            }
975        }
976
977        Ok(features)
978    }
979
980    pub fn apply_option(&mut self, option: ChoosableCustomLevelFeatureOption) {
981        use ChoosableCustomLevelFeatureOption::*;
982
983        match option {
984            StrengthPlusOne | DexterityPlusOne | ConstitutionPlusOne | IntelligencePlusOne
985            | WisdomPlusOne | CharismaPlusOne => self.increase_score(option),
986            PactOfTheChain | PactOfTheBlade | PactOfTheTome => {
987                println!("Pact of the Chain, Blade or Tome not yet implemented");
988            }
989            HuntersPreyGiantKiller | HuntersPreyHordeBreaker | HuntersPreyColossusSlayer => {
990                self.1
991                    .hunters_prey
992                    .replace(option.as_index_str().to_string());
993            }
994            DefensiveTacticsSteelWill
995            | DefensiveTacticsEscapeTheHorde
996            | DefensiveTacticsMultiattackDefense => {
997                self.1
998                    .defensive_tactics
999                    .replace(option.as_index_str().to_string());
1000            }
1001            FighterFightingStyleArchery
1002            | FighterFightingStyleDefense
1003            | FighterFightingStyleDueling
1004            | FighterFightingStyleGreatWeaponFighting
1005            | FighterFightingStyleProtection
1006            | FighterFightingStyleTwoWeaponFighting
1007            | RangerFightingStyleArchery
1008            | RangerFightingStyleDefense
1009            | RangerFightingStyleDueling
1010            | RangerFightingStyleTwoWeaponFighting
1011            | FightingStyleDefense
1012            | FightingStyleDueling
1013            | FightingStyleGreatWeaponFighting
1014            | FightingStyleProtection => {
1015                if self.1.fighting_style.is_none() {
1016                    self.1
1017                        .fighting_style
1018                        .replace(option.as_index_str().to_string());
1019                } else {
1020                    self.1
1021                        .additional_fighting_style
1022                        .replace(option.as_index_str().to_string());
1023                }
1024            }
1025            MultiattackVolley | MultiattackWhirlwindAttack => {
1026                self.1
1027                    .multiattack
1028                    .replace(option.as_index_str().to_string());
1029            }
1030            SuperiorHuntersDefenseEvasion
1031            | SuperiorHuntersDefenseStandAgainstTheTide
1032            | SuperiorHuntersDefenseUncannyDodge => {
1033                self.1
1034                    .superior_hunters_defense
1035                    .replace(option.as_index_str().to_string());
1036            }
1037            RangerTerrainTypeArctic
1038            | RangerTerrainTypeCoast
1039            | RangerTerrainTypeDesert
1040            | RangerTerrainTypeForest
1041            | RangerTerrainTypeGrassland
1042            | RangerTerrainTypeMountain
1043            | RangerTerrainTypeSwamp => {
1044                self.1
1045                    .natural_explorer_terrain_type
1046                    .get_or_insert_with(Vec::new)
1047                    .push(option.as_index_str().to_string());
1048            }
1049            RangerFavoredEnemyTypeAberrations
1050            | RangerFavoredEnemyTypeBeasts
1051            | RangerFavoredEnemyTypeCelestials
1052            | RangerFavoredEnemyTypeConstructs
1053            | RangerFavoredEnemyTypeDragons
1054            | RangerFavoredEnemyTypeElementals
1055            | RangerFavoredEnemyTypeFey
1056            | RangerFavoredEnemyTypeFiends
1057            | RangerFavoredEnemyTypeGiants
1058            | RangerFavoredEnemyTypeMonstrosities
1059            | RangerFavoredEnemyTypeOozes
1060            | RangerFavoredEnemyTypePlants
1061            | RangerFavoredEnemyTypeUndead
1062            | RangerFavoredEnemyTypeHumanoids => {
1063                self.1
1064                    .ranger_favored_enemy_type
1065                    .get_or_insert_with(Vec::new)
1066                    .push(option.as_index_str().to_string());
1067            }
1068            MetamagicCarefulSpell
1069            | MetamagicDistantSpell
1070            | MetamagicEmpoweredSpell
1071            | MetamagicExtendedSpell
1072            | MetamagicHeightenedSpell
1073            | MetamagicQuickenedSpell
1074            | MetamagicSubtleSpell
1075            | MetamagicTwinnedSpell => {
1076                self.1
1077                    .sorcerer_metamagic
1078                    .get_or_insert_with(Vec::new)
1079                    .push(option.as_index_str().to_string());
1080            }
1081            DragonAncestorBlackAcidDamage
1082            | DragonAncestorBlueLightningDamage
1083            | DragonAncestorBrassFireDamage
1084            | DragonAncestorBronzeLightningDamage
1085            | DragonAncestorCopperAcidDamage
1086            | DragonAncestorGoldFireDamage
1087            | DragonAncestorGreenPoisonDamage
1088            | DragonAncestorRedFireDamage
1089            | DragonAncestorSilverColdDamage
1090            | DragonAncestorWhiteColdDamage => {
1091                self.1
1092                    .sorcerer_dragon_ancestor
1093                    .replace(option.as_index_str().to_string());
1094            }
1095        }
1096    }
1097
1098    fn increase_score(&mut self, option: ChoosableCustomLevelFeatureOption) {
1099        match option {
1100            ChoosableCustomLevelFeatureOption::StrengthPlusOne => {
1101                self.1.abilities_modifiers.strength.score += 1;
1102            }
1103            ChoosableCustomLevelFeatureOption::DexterityPlusOne => {
1104                self.1.abilities_modifiers.dexterity.score += 1;
1105            }
1106            ChoosableCustomLevelFeatureOption::ConstitutionPlusOne => {
1107                self.1.abilities_modifiers.constitution.score += 1;
1108            }
1109            ChoosableCustomLevelFeatureOption::IntelligencePlusOne => {
1110                self.1.abilities_modifiers.intelligence.score += 1;
1111            }
1112            ChoosableCustomLevelFeatureOption::WisdomPlusOne => {
1113                self.1.abilities_modifiers.wisdom.score += 1;
1114            }
1115            ChoosableCustomLevelFeatureOption::CharismaPlusOne => {
1116                self.1.abilities_modifiers.charisma.score += 1;
1117            }
1118            _ => {}
1119        }
1120    }
1121}
1122
1123pub async fn get_spellcasting_slots(
1124    index: &str,
1125    level: u8,
1126) -> Result<Option<LevelSpellcasting>, ApiError> {
1127    let op = SpellcastingQuery::build(SpellcastingQueryVariables {
1128        index: Some(format!("{}-{}", index, level)),
1129    });
1130
1131    let spellcasting_slots = Client::new()
1132        .post(GRAPHQL_API_URL.as_str())
1133        .run_graphql(op)
1134        .await?
1135        .data
1136        .ok_or(ApiError::Schema)?
1137        .level
1138        .ok_or(ApiError::Schema)?
1139        .spellcasting;
1140
1141    Ok(spellcasting_slots)
1142}