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