dnd_character/
lib.rs

1#[cfg(feature = "api")]
2pub mod api;
3
4pub mod abilities;
5pub mod classes;
6
7use abilities::AbilityScore;
8use anyhow::bail;
9use lazy_static::lazy_static;
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use std::cmp::Ordering;
13use std::collections::HashMap;
14use std::fmt;
15
16use crate::abilities::Abilities;
17use crate::classes::Classes;
18
19lazy_static! {
20    pub static ref GRAPHQL_API_URL: String = std::env::var("DND_GRAPHQL_API_URL")
21        .unwrap_or_else(|_| "https://www.dnd5eapi.co/graphql/2014".to_string());
22}
23
24#[derive(Debug)]
25pub struct UnexpectedAbility;
26
27impl fmt::Display for UnexpectedAbility {
28    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
29        write!(f, "The ability isn't present in the character's abilities")
30    }
31}
32
33impl std::error::Error for UnexpectedAbility {}
34
35#[derive(Debug)]
36#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
37#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
38#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
39pub struct Character {
40    /// Indexes from https://www.dnd5eapi.co/api/classes/
41    pub classes: Classes,
42    pub name: String,
43    pub age: u16,
44    /// Index from https://www.dnd5eapi.co/api/races/
45    pub race_index: String,
46    /// Index from https://www.dnd5eapi.co/api/subraces/
47    pub subrace_index: String,
48    /// Index from https://www.dnd5eapi.co/api/alignments/
49    pub alignment_index: String,
50    /// Physical description
51    pub description: String,
52    /// Index from https://www.dnd5eapi.co/api/backgrounds/
53    pub background_index: String,
54    /// Background description
55    pub background_description: String,
56
57    experience_points: u32,
58
59    pub money: u32,
60
61    pub abilities_score: Abilities,
62
63    //Health related stuff
64    pub hp: u16,
65    #[serde(default = "default_hit_dice")]
66    pub hit_dice_result: u16,
67
68    pub inventory: HashMap<String, u16>,
69
70    pub other: Vec<String>,
71}
72
73/// For parsing legacy support
74fn default_hit_dice() -> u16 {
75    12
76}
77
78#[cfg(feature = "utoipa")]
79pub mod utoipa_addon {
80    use utoipa::openapi::OpenApi;
81    use utoipa::{Modify, PartialSchema, ToSchema};
82
83    pub struct ApiDocDndCharacterAddon;
84
85    impl Modify for ApiDocDndCharacterAddon {
86        fn modify(&self, openapi: &mut OpenApi) {
87            if let Some(components) = openapi.components.as_mut() {
88                components.schemas.insert(
89                    super::classes::ClassProperties::name().to_string(),
90                    super::classes::ClassProperties::schema(),
91                );
92                components.schemas.insert(
93                    super::classes::ClassSpellCasting::name().to_string(),
94                    super::classes::ClassSpellCasting::schema(),
95                );
96                components.schemas.insert(
97                    super::classes::Class::name().to_string(),
98                    super::classes::Class::schema(),
99                );
100                components
101                    .schemas
102                    .insert(super::Classes::name().to_string(), super::Classes::schema());
103                components.schemas.insert(
104                    super::classes::UsableSlots::name().to_string(),
105                    super::classes::UsableSlots::schema(),
106                );
107                components.schemas.insert(
108                    super::Abilities::name().to_string(),
109                    super::Abilities::schema(),
110                );
111                components.schemas.insert(
112                    super::abilities::AbilityScore::name().to_string(),
113                    super::abilities::AbilityScore::schema(),
114                );
115                components.schemas.insert(
116                    super::Character::name().to_string(),
117                    super::Character::schema(),
118                );
119            }
120        }
121    }
122}
123
124const LEVELS: [u32; 19] = [
125    300, 900, 2_700, 6_500, 14_000, 23_000, 34_000, 48_000, 64_000, 85_000, 100_000, 120_000,
126    140_000, 165_000, 195_000, 225_000, 265_000, 305_000, 355_000,
127];
128
129impl Character {
130    pub fn new(
131        main_class: String,
132        name: String,
133        age: u16,
134        race_index: String,
135        subrace_index: String,
136        alignment_index: String,
137        description: String,
138        background_index: String,
139        background_description: String,
140    ) -> Self {
141        Self {
142            classes: Classes::new(main_class),
143            name,
144            age,
145            race_index,
146            subrace_index,
147            alignment_index,
148            description,
149            background_index,
150            background_description,
151            experience_points: 0,
152            money: 0,
153            inventory: HashMap::new(),
154
155            abilities_score: Abilities::default(),
156            hp: 0,
157            hit_dice_result: 0,
158            other: vec![],
159        }
160    }
161
162    pub fn class_armor(&self) -> i8 {
163        // Get the first class and its name
164        let first_class = self.classes.0.iter().next().unwrap();
165        let class_name = first_class.0.as_str();
166
167        let abilities_score = self.compound_abilities();
168
169        // Calculate the base armor class based on the class type
170        let mut base = match class_name {
171            "monk" => {
172                10 + abilities_score.dexterity.modifier(0) + abilities_score.wisdom.modifier(0)
173            }
174            "draconic" => 13 + abilities_score.dexterity.modifier(0),
175            _ => 10 + abilities_score.dexterity.modifier(0),
176        };
177
178        // Check if the character has the "Fighting Style: Defense" feature
179        let has_defense_style = first_class
180            .1
181             .1
182            .fighting_style
183            .as_ref()
184            .map(|s| s.contains("defense"))
185            .unwrap_or(false);
186
187        // Add bonus if the character has the defense fighting style
188        if has_defense_style {
189            base += 1;
190        }
191
192        base
193    }
194
195    /// Return current level of the character
196    pub fn level(&self) -> u8 {
197        LEVELS
198            .iter()
199            .filter(|&&x| x <= self.experience_points)
200            .count() as u8
201            + 1
202    }
203
204    /// Returns the experience points of the character
205    pub fn experience_points(&self) -> u32 {
206        self.experience_points
207    }
208
209    /// Returns the number of levels the character has earned
210    /// this means that you should add the returned value to a class level (this must be done manually to permit multiclassing)
211    /// # Arguments
212    /// * `experience` - The experience points to add to the character
213    pub fn add_experience(&mut self, experience: u32) -> u8 {
214        //Save the level before adding experience
215        let previous_level = self.level();
216
217        // Limit the experience gotten to the experience needed to reach the next level
218        let experience_to_add = LEVELS
219            .get(self.level() as usize - 1)
220            .map_or(experience, |&next_level_points| {
221                (next_level_points - self.experience_points).min(experience)
222            });
223
224        //Add the experience
225        self.experience_points += experience_to_add;
226
227        //Save the level after adding experience
228        let current_level = self.level();
229
230        //Return the number of levels earned
231        current_level - previous_level
232    }
233
234    pub fn remove_item(
235        &mut self,
236        item: &str,
237        amount: Option<u16>,
238    ) -> anyhow::Result<(), anyhow::Error> {
239        if let Some(quantity) = self.inventory.get_mut(item) {
240            let quantity_to_remove = amount.unwrap_or(*quantity);
241
242            if *quantity <= quantity_to_remove {
243                self.inventory.remove(item);
244            } else {
245                *quantity -= quantity_to_remove;
246            }
247        } else {
248            bail!("Item not found")
249        }
250
251        Ok(())
252    }
253
254    pub fn add_item(&mut self, item: &str, amount: u16) {
255        if let Some(quantity) = self.inventory.get_mut(item) {
256            *quantity += amount;
257        } else {
258            self.inventory.insert(item.to_string(), amount);
259        }
260    }
261
262    pub fn alter_item_quantity(&mut self, item: &str, amount: i32) -> anyhow::Result<()> {
263        match amount.cmp(&0) {
264            Ordering::Greater => {
265                self.add_item(item, amount as u16);
266                Ok(())
267            }
268            Ordering::Less => self.remove_item(item, Some(amount.unsigned_abs() as u16)),
269            Ordering::Equal => {
270                bail!("Cannot alter quantity to 0")
271            }
272        }
273    }
274
275    pub fn compound_abilities(&self) -> Abilities {
276        self.classes
277            .0
278            .values()
279            .map(|class| class.1.abilities_modifiers.clone())
280            .sum::<Abilities>()
281            + self.abilities_score.clone()
282    }
283
284    /// Calculate the maximum HP of the character based on constitution modifier and hit dice result
285    pub fn max_hp(&self) -> u16 {
286        let constitution_ability: AbilityScore = self.compound_abilities().constitution;
287
288        let constitution_modifier = constitution_ability.modifier(0);
289
290        (constitution_modifier as i32)
291            .saturating_mul(self.level().into())
292            .saturating_add(self.hit_dice_result.into())
293            .max(0) as u16
294    }
295}