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;
15
16use crate::abilities::Abilities;
17use crate::classes::Classes;
18
19lazy_static! {
20 pub static ref GRAPHQL_API_URL: String = std::env::var("DND_GRAPHQL_API_URL")
21 .unwrap_or_else(|_| "https://www.dnd5eapi.co/graphql/2014".to_string());
22}
23
24#[derive(Debug)]
25pub struct UnexpectedAbility;
26
27impl fmt::Display for UnexpectedAbility {
28 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
29 write!(f, "The ability isn't present in the character's abilities")
30 }
31}
32
33impl std::error::Error for UnexpectedAbility {}
34
35#[derive(Debug)]
36#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
37#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
38#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
39pub struct Character {
40 pub classes: Classes,
42 pub name: String,
43 pub age: u16,
44 pub race_index: String,
46 pub subrace_index: String,
48 pub alignment_index: String,
50 pub description: String,
52 pub background_index: String,
54 pub background_description: String,
56
57 experience_points: u32,
58
59 pub money: u32,
60
61 pub abilities_score: Abilities,
62
63 pub hp: u16,
65 #[serde(default = "default_hit_dice")]
66 pub hit_dice_result: u16,
67
68 pub inventory: HashMap<String, u16>,
69
70 pub other: Vec<String>,
71}
72
73fn default_hit_dice() -> u16 {
75 12
76}
77
78#[cfg(feature = "utoipa")]
79pub mod utoipa_addon {
80 use utoipa::openapi::OpenApi;
81 use utoipa::{Modify, PartialSchema, ToSchema};
82
83 pub struct ApiDocDndCharacterAddon;
84
85 impl Modify for ApiDocDndCharacterAddon {
86 fn modify(&self, openapi: &mut OpenApi) {
87 if let Some(components) = openapi.components.as_mut() {
88 components.schemas.insert(
89 super::classes::ClassProperties::name().to_string(),
90 super::classes::ClassProperties::schema(),
91 );
92 components.schemas.insert(
93 super::classes::ClassSpellCasting::name().to_string(),
94 super::classes::ClassSpellCasting::schema(),
95 );
96 components.schemas.insert(
97 super::classes::Class::name().to_string(),
98 super::classes::Class::schema(),
99 );
100 components
101 .schemas
102 .insert(super::Classes::name().to_string(), super::Classes::schema());
103 components.schemas.insert(
104 super::classes::UsableSlots::name().to_string(),
105 super::classes::UsableSlots::schema(),
106 );
107 components.schemas.insert(
108 super::Abilities::name().to_string(),
109 super::Abilities::schema(),
110 );
111 components.schemas.insert(
112 super::abilities::AbilityScore::name().to_string(),
113 super::abilities::AbilityScore::schema(),
114 );
115 components.schemas.insert(
116 super::Character::name().to_string(),
117 super::Character::schema(),
118 );
119 }
120 }
121 }
122}
123
124const LEVELS: [u32; 19] = [
125 300, 900, 2_700, 6_500, 14_000, 23_000, 34_000, 48_000, 64_000, 85_000, 100_000, 120_000,
126 140_000, 165_000, 195_000, 225_000, 265_000, 305_000, 355_000,
127];
128
129impl Character {
130 pub fn new(
131 main_class: String,
132 name: String,
133 age: u16,
134 race_index: String,
135 subrace_index: String,
136 alignment_index: String,
137 description: String,
138 background_index: String,
139 background_description: String,
140 ) -> Self {
141 Self {
142 classes: Classes::new(main_class),
143 name,
144 age,
145 race_index,
146 subrace_index,
147 alignment_index,
148 description,
149 background_index,
150 background_description,
151 experience_points: 0,
152 money: 0,
153 inventory: HashMap::new(),
154
155 abilities_score: Abilities::default(),
156 hp: 0,
157 hit_dice_result: 0,
158 other: vec![],
159 }
160 }
161
162 pub fn class_armor(&self) -> i8 {
163 let first_class = self.classes.0.iter().next().unwrap();
165 let class_name = first_class.0.as_str();
166
167 let abilities_score = self.compound_abilities();
168
169 let mut base = match class_name {
171 "monk" => {
172 10 + abilities_score.dexterity.modifier(0) + abilities_score.wisdom.modifier(0)
173 }
174 "draconic" => 13 + abilities_score.dexterity.modifier(0),
175 _ => 10 + abilities_score.dexterity.modifier(0),
176 };
177
178 let has_defense_style = first_class
180 .1
181 .1
182 .fighting_style
183 .as_ref()
184 .map(|s| s.contains("defense"))
185 .unwrap_or(false);
186
187 if has_defense_style {
189 base += 1;
190 }
191
192 base
193 }
194
195 pub fn level(&self) -> u8 {
197 LEVELS
198 .iter()
199 .filter(|&&x| x <= self.experience_points)
200 .count() as u8
201 + 1
202 }
203
204 pub fn experience_points(&self) -> u32 {
206 self.experience_points
207 }
208
209 pub fn add_experience(&mut self, experience: u32) -> u8 {
214 let previous_level = self.level();
216
217 let experience_to_add = LEVELS
219 .get(self.level() as usize - 1)
220 .map_or(experience, |&next_level_points| {
221 (next_level_points - self.experience_points).min(experience)
222 });
223
224 self.experience_points += experience_to_add;
226
227 let current_level = self.level();
229
230 current_level - previous_level
232 }
233
234 pub fn remove_item(
235 &mut self,
236 item: &str,
237 amount: Option<u16>,
238 ) -> anyhow::Result<(), anyhow::Error> {
239 if let Some(quantity) = self.inventory.get_mut(item) {
240 let quantity_to_remove = amount.unwrap_or(*quantity);
241
242 if *quantity <= quantity_to_remove {
243 self.inventory.remove(item);
244 } else {
245 *quantity -= quantity_to_remove;
246 }
247 } else {
248 bail!("Item not found")
249 }
250
251 Ok(())
252 }
253
254 pub fn add_item(&mut self, item: &str, amount: u16) {
255 if let Some(quantity) = self.inventory.get_mut(item) {
256 *quantity += amount;
257 } else {
258 self.inventory.insert(item.to_string(), amount);
259 }
260 }
261
262 pub fn alter_item_quantity(&mut self, item: &str, amount: i32) -> anyhow::Result<()> {
263 match amount.cmp(&0) {
264 Ordering::Greater => {
265 self.add_item(item, amount as u16);
266 Ok(())
267 }
268 Ordering::Less => self.remove_item(item, Some(amount.unsigned_abs() as u16)),
269 Ordering::Equal => {
270 bail!("Cannot alter quantity to 0")
271 }
272 }
273 }
274
275 pub fn compound_abilities(&self) -> Abilities {
276 self.classes
277 .0
278 .values()
279 .map(|class| class.1.abilities_modifiers.clone())
280 .sum::<Abilities>()
281 + self.abilities_score.clone()
282 }
283
284 pub fn max_hp(&self) -> u16 {
286 let constitution_ability: AbilityScore = self.compound_abilities().constitution;
287
288 let constitution_modifier = constitution_ability.modifier(0);
289
290 (constitution_modifier as i32)
291 .saturating_mul(self.level().into())
292 .saturating_add(self.hit_dice_result.into())
293 .max(0) as u16
294 }
295}