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 let value = serde_json::Value::deserialize(deserializer)
72 .map_err(|e| D::Error::custom(format!("Failed to deserialize classes: {}", e)))?;
73
74 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 pub classes: Classes,
104 pub name: String,
105 pub age: u16,
106 pub race_index: String,
108 pub subrace_index: String,
110 pub alignment_index: String,
112 pub description: String,
114 pub background_index: String,
116 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 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
137fn 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 let abilities_score = Rc::new(RefCell::new(helper.abilities_score));
171
172 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 let abilities_score = Rc::new(RefCell::new(Abilities::default()));
265
266 let mut classes = Classes::new(main_class);
268
269 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 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 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 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 if has_defense_style {
333 base += 1;
334 }
335
336 base
337 }
338
339 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 pub fn experience_points(&self) -> u32 {
350 self.experience_points
351 }
352
353 pub fn add_experience(&mut self, experience: u32) -> u8 {
358 let previous_level = self.level();
360
361 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 self.experience_points += experience_to_add;
370
371 let current_level = self.level();
373
374 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 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}