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