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 let value = serde_json::Value::deserialize(deserializer)
66 .map_err(|e| D::Error::custom(format!("Failed to deserialize classes: {}", e)))?;
67
68 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 pub classes: Classes,
98 pub name: String,
99 pub age: u16,
100 pub race_index: String,
102 pub subrace_index: String,
104 pub alignment_index: String,
106 pub description: String,
108 pub background_index: String,
110 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 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
131fn 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 let abilities_score = Arc::new(Mutex::new(helper.abilities_score));
165
166 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 let abilities_score = Arc::new(Mutex::new(Abilities::default()));
259
260 let mut classes = Classes::new(main_class);
262
263 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 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 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 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 if has_defense_style {
327 base += 1;
328 }
329
330 base
331 }
332
333 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 pub fn experience_points(&self) -> u32 {
344 self.experience_points
345 }
346
347 pub fn add_experience(&mut self, experience: u32) -> u8 {
352 let previous_level = self.level();
354
355 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 self.experience_points += experience_to_add;
364
365 let current_level = self.level();
367
368 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 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}