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