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;
15use std::sync::{Arc, Mutex};
16
17use crate::abilities::Abilities;
18use crate::classes::Classes;
19
20#[cfg(feature = "serde")]
21mod abilities_score_serde {
22    use crate::abilities::Abilities;
23    use serde::{Deserialize, Deserializer, Serialize, Serializer};
24    use std::sync::{Arc, Mutex};
25
26    pub fn serialize<S>(
27        abilities: &Arc<Mutex<Abilities>>,
28        serializer: S,
29    ) -> Result<S::Ok, S::Error>
30    where
31        S: Serializer,
32    {
33        abilities.lock().unwrap().serialize(serializer)
34    }
35
36    pub fn deserialize<'de, D>(deserializer: D) -> Result<Arc<Mutex<Abilities>>, D::Error>
37    where
38        D: Deserializer<'de>,
39    {
40        let abilities = Abilities::deserialize(deserializer)?;
41        Ok(Arc::new(Mutex::new(abilities)))
42    }
43}
44
45#[cfg(feature = "serde")]
46mod classes_serde {
47    use crate::abilities::Abilities;
48    use crate::classes::Classes;
49    use serde::de::Error;
50    use serde::{Deserialize, Deserializer, Serialize, Serializer};
51    use std::sync::{Arc, Mutex};
52
53    pub fn serialize<S>(classes: &Classes, serializer: S) -> Result<S::Ok, S::Error>
54    where
55        S: Serializer,
56    {
57        classes.serialize(serializer)
58    }
59
60    pub fn deserialize<'de, D>(
61        deserializer: D,
62        abilities_ref: &Arc<Mutex<Abilities>>,
63    ) -> Result<Classes, D::Error>
64    where
65        D: Deserializer<'de>,
66    {
67        // First deserialize into a serde_json::Value
68        let value = serde_json::Value::deserialize(deserializer)
69            .map_err(|e| D::Error::custom(format!("Failed to deserialize classes: {}", e)))?;
70
71        // Use the custom deserializer that takes the shared abilities reference
72        Classes::deserialize_with_abilities(value, abilities_ref.clone())
73            .map_err(|e| D::Error::custom(format!("Failed to deserialize with abilities: {}", e)))
74    }
75}
76
77lazy_static! {
78    pub static ref GRAPHQL_API_URL: String = std::env::var("DND_GRAPHQL_API_URL")
79        .unwrap_or_else(|_| "https://www.dnd5eapi.co/graphql/2014".to_string());
80}
81
82#[derive(Debug)]
83pub struct UnexpectedAbility;
84
85impl fmt::Display for UnexpectedAbility {
86    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
87        write!(f, "The ability isn't present in the character's abilities")
88    }
89}
90
91impl std::error::Error for UnexpectedAbility {}
92
93#[derive(Debug)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
96#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
97#[cfg_attr(feature = "serde", serde(from = "CharacterDeserializeHelper"))]
98pub struct Character {
99    /// Indexes from https://www.dnd5eapi.co/api/classes/
100    pub classes: Classes,
101    pub name: String,
102    pub age: u16,
103    /// Index from https://www.dnd5eapi.co/api/races/
104    pub race_index: String,
105    /// Index from https://www.dnd5eapi.co/api/subraces/
106    pub subrace_index: String,
107    /// Index from https://www.dnd5eapi.co/api/alignments/
108    pub alignment_index: String,
109    /// Physical description
110    pub description: String,
111    /// Index from https://www.dnd5eapi.co/api/backgrounds/
112    pub background_index: String,
113    /// Background description
114    pub background_description: String,
115
116    experience_points: u32,
117
118    pub money: u32,
119
120    #[cfg_attr(feature = "serde", serde(with = "abilities_score_serde"))]
121    #[cfg_attr(feature = "utoipa", schema(value_type = Abilities))]
122    pub abilities_score: Arc<Mutex<Abilities>>,
123
124    //Health related stuff
125    pub hp: u16,
126    #[serde(default = "default_hit_dice")]
127    pub hit_dice_result: u16,
128
129    pub inventory: HashMap<String, u16>,
130
131    pub other: Vec<String>,
132}
133
134/// For parsing legacy support
135fn default_hit_dice() -> u16 {
136    12
137}
138
139#[cfg(feature = "serde")]
140#[derive(Deserialize)]
141#[serde(rename_all = "camelCase")]
142struct CharacterDeserializeHelper {
143    name: String,
144    age: u16,
145    race_index: String,
146    subrace_index: String,
147    alignment_index: String,
148    description: String,
149    background_index: String,
150    background_description: String,
151    experience_points: u32,
152    money: u32,
153    abilities_score: Abilities,
154    hp: u16,
155    #[serde(default = "default_hit_dice")]
156    hit_dice_result: u16,
157    inventory: HashMap<String, u16>,
158    other: Vec<String>,
159    #[serde(default)]
160    classes: serde_json::Value,
161}
162
163#[cfg(feature = "serde")]
164impl From<CharacterDeserializeHelper> for Character {
165    fn from(helper: CharacterDeserializeHelper) -> Self {
166        // Create the shared abilities reference
167        let abilities_score = Arc::new(Mutex::new(helper.abilities_score));
168
169        // Deserialize classes with the shared abilities reference
170        let classes =
171            match Classes::deserialize_with_abilities(helper.classes, abilities_score.clone()) {
172                Ok(classes) => classes,
173                Err(_) => Classes::default(),
174            };
175
176        Self {
177            classes,
178            name: helper.name,
179            age: helper.age,
180            race_index: helper.race_index,
181            subrace_index: helper.subrace_index,
182            alignment_index: helper.alignment_index,
183            description: helper.description,
184            background_index: helper.background_index,
185            background_description: helper.background_description,
186            experience_points: helper.experience_points,
187            money: helper.money,
188            abilities_score,
189            hp: helper.hp,
190            hit_dice_result: helper.hit_dice_result,
191            inventory: helper.inventory,
192            other: helper.other,
193        }
194    }
195}
196
197#[cfg(feature = "utoipa")]
198pub mod utoipa_addon {
199    use utoipa::openapi::OpenApi;
200    use utoipa::{Modify, PartialSchema, ToSchema};
201
202    pub struct ApiDocDndCharacterAddon;
203
204    impl Modify for ApiDocDndCharacterAddon {
205        fn modify(&self, openapi: &mut OpenApi) {
206            if let Some(components) = openapi.components.as_mut() {
207                components.schemas.insert(
208                    super::classes::ClassProperties::name().to_string(),
209                    super::classes::ClassProperties::schema(),
210                );
211                components.schemas.insert(
212                    super::classes::ClassSpellCasting::name().to_string(),
213                    super::classes::ClassSpellCasting::schema(),
214                );
215                components.schemas.insert(
216                    super::classes::Class::name().to_string(),
217                    super::classes::Class::schema(),
218                );
219                components
220                    .schemas
221                    .insert(super::Classes::name().to_string(), super::Classes::schema());
222                components.schemas.insert(
223                    super::classes::UsableSlots::name().to_string(),
224                    super::classes::UsableSlots::schema(),
225                );
226                components.schemas.insert(
227                    super::Abilities::name().to_string(),
228                    super::Abilities::schema(),
229                );
230                components.schemas.insert(
231                    super::abilities::AbilityScore::name().to_string(),
232                    super::abilities::AbilityScore::schema(),
233                );
234                components.schemas.insert(
235                    super::Character::name().to_string(),
236                    super::Character::schema(),
237                );
238            }
239        }
240    }
241}
242
243const LEVELS: [u32; 19] = [
244    300, 900, 2_700, 6_500, 14_000, 23_000, 34_000, 48_000, 64_000, 85_000, 100_000, 120_000,
245    140_000, 165_000, 195_000, 225_000, 265_000, 305_000, 355_000,
246];
247
248impl Character {
249    pub fn new(
250        main_class: String,
251        name: String,
252        age: u16,
253        race_index: String,
254        subrace_index: String,
255        alignment_index: String,
256        description: String,
257        background_index: String,
258        background_description: String,
259    ) -> Self {
260        // Create the shared abilities reference
261        let abilities_score = Arc::new(Mutex::new(Abilities::default()));
262
263        // Create classes with the default implementation
264        let mut classes = Classes::new(main_class);
265
266        // Update all class properties to use the shared abilities reference
267        for class in classes.0.values_mut() {
268            class.1.abilities_modifiers = abilities_score.clone();
269        }
270
271        Self {
272            classes,
273            name,
274            age,
275            race_index,
276            subrace_index,
277            alignment_index,
278            description,
279            background_index,
280            background_description,
281            experience_points: 0,
282            money: 0,
283            inventory: HashMap::new(),
284
285            abilities_score,
286            hp: 0,
287            hit_dice_result: 0,
288            other: vec![],
289        }
290    }
291
292    pub fn class_armor(&self) -> i8 {
293        // Get the first class and its name
294        let first_class = self.classes.0.iter().next().unwrap();
295        let class_name = first_class.0.as_str();
296
297        let abilities_score = self.abilities_score.lock().unwrap();
298
299        // Calculate the base armor class based on the class type
300        let mut base = match class_name {
301            "monk" => {
302                10 + abilities_score.dexterity.modifier(0) + abilities_score.wisdom.modifier(0)
303            }
304            "sorcerer" => 13 + abilities_score.dexterity.modifier(0),
305            "barbarian" => {
306                10 + abilities_score.dexterity.modifier(0)
307                    + abilities_score.constitution.modifier(0)
308            }
309            _ => 10 + abilities_score.dexterity.modifier(0),
310        };
311
312        // Check if the character has the "Fighting Style: Defense" feature
313        let has_defense_style = first_class
314            .1
315            .1
316            .fighting_style
317            .as_ref()
318            .map(|s| s.contains("defense"))
319            .unwrap_or(false)
320            || first_class
321                .1
322                .1
323                .additional_fighting_style
324                .as_ref()
325                .map(|s| s.contains("defense"))
326                .unwrap_or(false);
327
328        // Add bonus if the character has the defense fighting style
329        if has_defense_style {
330            base += 1;
331        }
332
333        base
334    }
335
336    /// Return current level of the character
337    pub fn level(&self) -> u8 {
338        LEVELS
339            .iter()
340            .filter(|&&x| x <= self.experience_points)
341            .count() as u8
342            + 1
343    }
344
345    /// Returns the experience points of the character
346    pub fn experience_points(&self) -> u32 {
347        self.experience_points
348    }
349
350    /// Returns the number of levels the character has earned
351    /// this means that you should add the returned value to a class level (this must be done manually to permit multiclassing)
352    /// # Arguments
353    /// * `experience` - The experience points to add to the character
354    pub fn add_experience(&mut self, experience: u32) -> u8 {
355        //Save the level before adding experience
356        let previous_level = self.level();
357
358        // Limit the experience gotten to the experience needed to reach the next level
359        let experience_to_add = LEVELS
360            .get(self.level() as usize - 1)
361            .map_or(experience, |&next_level_points| {
362                (next_level_points - self.experience_points).min(experience)
363            });
364
365        //Add the experience
366        self.experience_points += experience_to_add;
367
368        //Save the level after adding experience
369        let current_level = self.level();
370
371        //Return the number of levels earned
372        current_level - previous_level
373    }
374
375    pub fn remove_item(
376        &mut self,
377        item: &str,
378        amount: Option<u16>,
379    ) -> anyhow::Result<(), anyhow::Error> {
380        if let Some(quantity) = self.inventory.get_mut(item) {
381            let quantity_to_remove = amount.unwrap_or(*quantity);
382
383            if *quantity <= quantity_to_remove {
384                self.inventory.remove(item);
385            } else {
386                *quantity -= quantity_to_remove;
387            }
388        } else {
389            bail!("Item not found")
390        }
391
392        Ok(())
393    }
394
395    pub fn add_item(&mut self, item: &str, amount: u16) {
396        if let Some(quantity) = self.inventory.get_mut(item) {
397            *quantity += amount;
398        } else {
399            self.inventory.insert(item.to_string(), amount);
400        }
401    }
402
403    pub fn alter_item_quantity(&mut self, item: &str, amount: i32) -> anyhow::Result<()> {
404        match amount.cmp(&0) {
405            Ordering::Greater => {
406                self.add_item(item, amount as u16);
407                Ok(())
408            }
409            Ordering::Less => self.remove_item(item, Some(amount.unsigned_abs() as u16)),
410            Ordering::Equal => {
411                bail!("Cannot alter quantity to 0")
412            }
413        }
414    }
415
416    /// Calculate the maximum HP of the character based on constitution modifier and hit dice result
417    pub fn max_hp(&self) -> u16 {
418        let constitution_modifier = self.abilities_score.lock().unwrap().constitution.modifier(0);
419
420        (constitution_modifier as i32)
421            .saturating_mul(self.level().into())
422            .saturating_add(self.hit_dice_result.into())
423            .max(0) as u16
424    }
425}