dnd_character/
lib.rs

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