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            "sorcerer" => 13 + abilities_score.dexterity.modifier(0),
175            "barbarian" => {
176                10 + abilities_score.dexterity.modifier(0)
177                    + abilities_score.constitution.modifier(0)
178            }
179            _ => 10 + abilities_score.dexterity.modifier(0),
180        };
181
182        // Check if the character has the "Fighting Style: Defense" feature
183        let has_defense_style = first_class
184            .1
185            .1
186            .fighting_style
187            .as_ref()
188            .map(|s| s.contains("defense"))
189            .unwrap_or(false)
190            || first_class
191                .1
192                .1
193                .additional_fighting_style
194                .as_ref()
195                .map(|s| s.contains("defense"))
196                .unwrap_or(false);
197
198        // Add bonus if the character has the defense fighting style
199        if has_defense_style {
200            base += 1;
201        }
202
203        base
204    }
205
206    /// Return current level of the character
207    pub fn level(&self) -> u8 {
208        LEVELS
209            .iter()
210            .filter(|&&x| x <= self.experience_points)
211            .count() as u8
212            + 1
213    }
214
215    /// Returns the experience points of the character
216    pub fn experience_points(&self) -> u32 {
217        self.experience_points
218    }
219
220    /// Returns the number of levels the character has earned
221    /// this means that you should add the returned value to a class level (this must be done manually to permit multiclassing)
222    /// # Arguments
223    /// * `experience` - The experience points to add to the character
224    pub fn add_experience(&mut self, experience: u32) -> u8 {
225        //Save the level before adding experience
226        let previous_level = self.level();
227
228        // Limit the experience gotten to the experience needed to reach the next level
229        let experience_to_add = LEVELS
230            .get(self.level() as usize - 1)
231            .map_or(experience, |&next_level_points| {
232                (next_level_points - self.experience_points).min(experience)
233            });
234
235        //Add the experience
236        self.experience_points += experience_to_add;
237
238        //Save the level after adding experience
239        let current_level = self.level();
240
241        //Return the number of levels earned
242        current_level - previous_level
243    }
244
245    pub fn remove_item(
246        &mut self,
247        item: &str,
248        amount: Option<u16>,
249    ) -> anyhow::Result<(), anyhow::Error> {
250        if let Some(quantity) = self.inventory.get_mut(item) {
251            let quantity_to_remove = amount.unwrap_or(*quantity);
252
253            if *quantity <= quantity_to_remove {
254                self.inventory.remove(item);
255            } else {
256                *quantity -= quantity_to_remove;
257            }
258        } else {
259            bail!("Item not found")
260        }
261
262        Ok(())
263    }
264
265    pub fn add_item(&mut self, item: &str, amount: u16) {
266        if let Some(quantity) = self.inventory.get_mut(item) {
267            *quantity += amount;
268        } else {
269            self.inventory.insert(item.to_string(), amount);
270        }
271    }
272
273    pub fn alter_item_quantity(&mut self, item: &str, amount: i32) -> anyhow::Result<()> {
274        match amount.cmp(&0) {
275            Ordering::Greater => {
276                self.add_item(item, amount as u16);
277                Ok(())
278            }
279            Ordering::Less => self.remove_item(item, Some(amount.unsigned_abs() as u16)),
280            Ordering::Equal => {
281                bail!("Cannot alter quantity to 0")
282            }
283        }
284    }
285
286    pub fn compound_abilities(&self) -> Abilities {
287        self.classes
288            .0
289            .values()
290            .map(|class| class.1.abilities_modifiers.clone())
291            .sum::<Abilities>()
292            + self.abilities_score.clone()
293    }
294
295    /// Calculate the maximum HP of the character based on constitution modifier and hit dice result
296    pub fn max_hp(&self) -> u16 {
297        let constitution_ability: AbilityScore = self.compound_abilities().constitution;
298
299        let constitution_modifier = constitution_ability.modifier(0);
300
301        (constitution_modifier as i32)
302            .saturating_mul(self.level().into())
303            .saturating_add(self.hit_dice_result.into())
304            .max(0) as u16
305    }
306}