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 "sorcerer" => 13 + abilities_score.dexterity.modifier(0),
175 "barbarian" => {
176 10 + abilities_score.dexterity.modifier(0)
177 + abilities_score.constitution.modifier(0)
178 }
179 _ => 10 + abilities_score.dexterity.modifier(0),
180 };
181
182 let has_defense_style = first_class
184 .1
185 .1
186 .fighting_style
187 .as_ref()
188 .map(|s| s.contains("defense"))
189 .unwrap_or(false)
190 || first_class
191 .1
192 .1
193 .additional_fighting_style
194 .as_ref()
195 .map(|s| s.contains("defense"))
196 .unwrap_or(false);
197
198 if has_defense_style {
200 base += 1;
201 }
202
203 base
204 }
205
206 pub fn level(&self) -> u8 {
208 LEVELS
209 .iter()
210 .filter(|&&x| x <= self.experience_points)
211 .count() as u8
212 + 1
213 }
214
215 pub fn experience_points(&self) -> u32 {
217 self.experience_points
218 }
219
220 pub fn add_experience(&mut self, experience: u32) -> u8 {
225 let previous_level = self.level();
227
228 let experience_to_add = LEVELS
230 .get(self.level() as usize - 1)
231 .map_or(experience, |&next_level_points| {
232 (next_level_points - self.experience_points).min(experience)
233 });
234
235 self.experience_points += experience_to_add;
237
238 let current_level = self.level();
240
241 current_level - previous_level
243 }
244
245 pub fn remove_item(
246 &mut self,
247 item: &str,
248 amount: Option<u16>,
249 ) -> anyhow::Result<(), anyhow::Error> {
250 if let Some(quantity) = self.inventory.get_mut(item) {
251 let quantity_to_remove = amount.unwrap_or(*quantity);
252
253 if *quantity <= quantity_to_remove {
254 self.inventory.remove(item);
255 } else {
256 *quantity -= quantity_to_remove;
257 }
258 } else {
259 bail!("Item not found")
260 }
261
262 Ok(())
263 }
264
265 pub fn add_item(&mut self, item: &str, amount: u16) {
266 if let Some(quantity) = self.inventory.get_mut(item) {
267 *quantity += amount;
268 } else {
269 self.inventory.insert(item.to_string(), amount);
270 }
271 }
272
273 pub fn alter_item_quantity(&mut self, item: &str, amount: i32) -> anyhow::Result<()> {
274 match amount.cmp(&0) {
275 Ordering::Greater => {
276 self.add_item(item, amount as u16);
277 Ok(())
278 }
279 Ordering::Less => self.remove_item(item, Some(amount.unsigned_abs() as u16)),
280 Ordering::Equal => {
281 bail!("Cannot alter quantity to 0")
282 }
283 }
284 }
285
286 pub fn compound_abilities(&self) -> Abilities {
287 self.classes
288 .0
289 .values()
290 .map(|class| class.1.abilities_modifiers.clone())
291 .sum::<Abilities>()
292 + self.abilities_score.clone()
293 }
294
295 pub fn max_hp(&self) -> u16 {
297 let constitution_ability: AbilityScore = self.compound_abilities().constitution;
298
299 let constitution_modifier = constitution_ability.modifier(0);
300
301 (constitution_modifier as i32)
302 .saturating_mul(self.level().into())
303 .saturating_add(self.hit_dice_result.into())
304 .max(0) as u16
305 }
306}