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