1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::{resources::Resource, spellcasting::Spellcasting};
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10pub struct Character {
11 pub id: Uuid,
12 pub campaign: Uuid,
14 #[serde(default)]
18 pub owners: Vec<String>,
19 #[serde(flatten)]
20 pub sheet: CharacterSheet,
21 pub version: u64,
24 pub created_at: DateTime<Utc>,
25 pub updated_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct CharacterSummary {
32 pub id: Uuid,
33 pub name: String,
34 pub player_name: Option<String>,
35 pub race: String,
36 pub level: u8,
37 pub classes: Vec<ClassLevel>,
38 #[serde(default)]
40 pub owners: Vec<String>,
41 pub updated_at: DateTime<Utc>,
42}
43
44impl From<&Character> for CharacterSummary {
45 fn from(c: &Character) -> Self {
46 Self {
47 id: c.id,
48 name: c.sheet.name.clone(),
49 player_name: c.sheet.player_name.clone(),
50 race: c.sheet.race.clone(),
51 level: c.sheet.total_level(),
52 classes: c.sheet.classes.clone(),
53 owners: c.owners.clone(),
54 updated_at: c.updated_at,
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[serde(default)]
64pub struct CharacterSheet {
65 pub name: String,
66 pub player_name: Option<String>,
67 pub race: String,
68 pub background: String,
69 pub alignment: String,
70 pub classes: Vec<ClassLevel>,
71 pub experience_points: u32,
72 pub ability_scores: AbilityScores,
73 pub hit_points: HitPoints,
74 pub armor_class: u8,
75 pub speed: u16,
76 pub saving_throw_proficiencies: Vec<Ability>,
77 pub skill_proficiencies: Vec<Skill>,
78 pub inventory: Vec<Item>,
79 pub spellcasting: Spellcasting,
80 pub resources: Vec<Resource>,
82 pub notes: String,
83}
84
85impl Default for CharacterSheet {
86 fn default() -> Self {
87 Self {
88 name: String::new(),
89 player_name: None,
90 race: String::new(),
91 background: String::new(),
92 alignment: String::new(),
93 classes: Vec::new(),
94 experience_points: 0,
95 ability_scores: AbilityScores::default(),
96 hit_points: HitPoints::default(),
97 armor_class: 10,
98 speed: 30,
99 saving_throw_proficiencies: Vec::new(),
100 skill_proficiencies: Vec::new(),
101 inventory: Vec::new(),
102 spellcasting: Spellcasting::default(),
103 resources: Vec::new(),
104 notes: String::new(),
105 }
106 }
107}
108
109impl CharacterSheet {
110 pub const MAX_LEVEL: u8 = 20;
112
113 pub fn total_level(&self) -> u8 {
115 let sum: u32 = self.classes.iter().map(|c| u32::from(c.level)).sum();
116 u8::try_from(sum.clamp(1, u32::from(Self::MAX_LEVEL))).unwrap_or(Self::MAX_LEVEL)
117 }
118
119 pub fn proficiency_bonus(&self) -> i8 {
121 2 + i8::try_from((self.total_level() - 1) / 4).unwrap_or(0)
123 }
124
125 pub fn saving_throw(&self, ability: Ability) -> i8 {
127 let base = self.ability_scores.modifier(ability);
128 if self.saving_throw_proficiencies.contains(&ability) {
129 base + self.proficiency_bonus()
130 } else {
131 base
132 }
133 }
134
135 pub fn skill_bonus(&self, skill: Skill) -> i8 {
137 let base = self.ability_scores.modifier(skill.ability());
138 if self.skill_proficiencies.contains(&skill) {
139 base + self.proficiency_bonus()
140 } else {
141 base
142 }
143 }
144
145 pub fn spell_save_dc(&self, ability: Ability) -> i8 {
147 8 + self.proficiency_bonus() + self.ability_scores.modifier(ability)
148 }
149
150 pub fn spell_attack_bonus(&self, ability: Ability) -> i8 {
152 self.proficiency_bonus() + self.ability_scores.modifier(ability)
153 }
154
155 pub fn validate(&self) -> Result<(), ValidationError> {
158 if self.name.trim().is_empty() {
159 return Err(ValidationError::EmptyName);
160 }
161 for (ability, score) in self.ability_scores.iter() {
162 if !(AbilityScores::MIN..=AbilityScores::MAX).contains(&score) {
163 return Err(ValidationError::AbilityScoreOutOfRange { ability, score });
164 }
165 }
166 for class in &self.classes {
167 if class.name.trim().is_empty() {
168 return Err(ValidationError::EmptyClassName);
169 }
170 if class.level == 0 {
171 return Err(ValidationError::ZeroClassLevel(class.name.clone()));
172 }
173 }
174 let total: u32 = self.classes.iter().map(|c| u32::from(c.level)).sum();
175 if total > u32::from(Self::MAX_LEVEL) {
176 return Err(ValidationError::LevelTooHigh(total));
177 }
178 if self.hit_points.current > self.hit_points.max {
179 return Err(ValidationError::CurrentHpExceedsMax);
180 }
181 self.spellcasting.validate()?;
182 for (i, resource) in self.resources.iter().enumerate() {
183 if resource.name.trim().is_empty() {
184 return Err(ValidationError::EmptyResourceName);
185 }
186 if !resource.uses.is_valid() {
187 return Err(ValidationError::Overspent(resource.name.clone()));
188 }
189 let duplicate = self.resources[..i]
190 .iter()
191 .any(|r| r.name.eq_ignore_ascii_case(&resource.name));
192 if duplicate {
193 return Err(ValidationError::DuplicateResource(resource.name.clone()));
194 }
195 }
196 Ok(())
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
202pub enum ValidationError {
203 #[error("name must not be empty")]
204 EmptyName,
205 #[error("{ability:?} score {score} is outside 1..=30")]
206 AbilityScoreOutOfRange { ability: Ability, score: u8 },
207 #[error("class name must not be empty")]
208 EmptyClassName,
209 #[error("class {0} must have a level of at least 1")]
210 ZeroClassLevel(String),
211 #[error("total level {0} exceeds the maximum of 20")]
212 LevelTooHigh(u32),
213 #[error("current hit points exceed maximum hit points")]
214 CurrentHpExceedsMax,
215 #[error("spell name must not be empty")]
216 EmptySpellName,
217 #[error("spell {0} has invalid level {1} (must be 0-9)")]
218 InvalidSpellLevel(String, u8),
219 #[error("invalid spell slot level {0}")]
220 InvalidSlotLevel(u8),
221 #[error("resource name must not be empty")]
222 EmptyResourceName,
223 #[error("duplicate resource: {0}")]
224 DuplicateResource(String),
225 #[error("{0}: used exceeds max")]
226 Overspent(String),
227}
228
229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
232pub struct ClassLevel {
233 pub name: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub subclass: Option<String>,
236 pub level: u8,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub spellcasting_ability: Option<Ability>,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
245#[serde(default)]
246pub struct AbilityScores {
247 pub strength: u8,
248 pub dexterity: u8,
249 pub constitution: u8,
250 pub intelligence: u8,
251 pub wisdom: u8,
252 pub charisma: u8,
253}
254
255impl Default for AbilityScores {
256 fn default() -> Self {
257 Self {
258 strength: 10,
259 dexterity: 10,
260 constitution: 10,
261 intelligence: 10,
262 wisdom: 10,
263 charisma: 10,
264 }
265 }
266}
267
268impl AbilityScores {
269 pub const MIN: u8 = 1;
271 pub const MAX: u8 = 30;
273
274 pub fn get(&self, ability: Ability) -> u8 {
276 match ability {
277 Ability::Strength => self.strength,
278 Ability::Dexterity => self.dexterity,
279 Ability::Constitution => self.constitution,
280 Ability::Intelligence => self.intelligence,
281 Ability::Wisdom => self.wisdom,
282 Ability::Charisma => self.charisma,
283 }
284 }
285
286 pub fn modifier(&self, ability: Ability) -> i8 {
288 i8::try_from((i16::from(self.get(ability)) - 10).div_euclid(2)).unwrap_or(i8::MAX)
290 }
291
292 pub fn iter(&self) -> impl Iterator<Item = (Ability, u8)> + '_ {
294 Ability::ALL.into_iter().map(|a| (a, self.get(a)))
295 }
296}
297
298#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
300#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
301#[serde(rename_all = "snake_case")]
302pub enum Ability {
303 Strength,
304 Dexterity,
305 Constitution,
306 Intelligence,
307 Wisdom,
308 Charisma,
309}
310
311impl Ability {
312 pub const ALL: [Ability; 6] = [
314 Ability::Strength,
315 Ability::Dexterity,
316 Ability::Constitution,
317 Ability::Intelligence,
318 Ability::Wisdom,
319 Ability::Charisma,
320 ];
321}
322
323#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
325#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
326#[serde(rename_all = "snake_case")]
327pub enum Skill {
328 Acrobatics,
329 AnimalHandling,
330 Arcana,
331 Athletics,
332 Deception,
333 History,
334 Insight,
335 Intimidation,
336 Investigation,
337 Medicine,
338 Nature,
339 Perception,
340 Performance,
341 Persuasion,
342 Religion,
343 SleightOfHand,
344 Stealth,
345 Survival,
346}
347
348impl Skill {
349 pub fn ability(self) -> Ability {
351 use Ability::{Charisma, Dexterity, Intelligence, Strength, Wisdom};
352 match self {
353 Skill::Athletics => Strength,
354 Skill::Acrobatics | Skill::SleightOfHand | Skill::Stealth => Dexterity,
355 Skill::Arcana
356 | Skill::History
357 | Skill::Investigation
358 | Skill::Nature
359 | Skill::Religion => Intelligence,
360 Skill::AnimalHandling
361 | Skill::Insight
362 | Skill::Medicine
363 | Skill::Perception
364 | Skill::Survival => Wisdom,
365 Skill::Deception | Skill::Intimidation | Skill::Performance | Skill::Persuasion => {
366 Charisma
367 }
368 }
369 }
370}
371
372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
374#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
375#[serde(default)]
376pub struct HitPoints {
377 pub max: u16,
378 pub current: u16,
379 pub temporary: u16,
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
384#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
385pub struct Item {
386 pub name: String,
387 #[serde(default = "one")]
388 pub quantity: u32,
389 #[serde(default, skip_serializing_if = "String::is_empty")]
390 pub notes: String,
391}
392
393fn one() -> u32 {
394 1
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 fn sheet() -> CharacterSheet {
402 CharacterSheet {
403 name: "Vex".into(),
404 classes: vec![ClassLevel {
405 name: "Ranger".into(),
406 subclass: None,
407 level: 5,
408 spellcasting_ability: Some(Ability::Wisdom),
409 }],
410 ability_scores: AbilityScores {
411 dexterity: 17,
412 wisdom: 8,
413 ..Default::default()
414 },
415 hit_points: HitPoints {
416 max: 40,
417 current: 40,
418 temporary: 0,
419 },
420 saving_throw_proficiencies: vec![Ability::Dexterity],
421 skill_proficiencies: vec![Skill::Stealth],
422 ..Default::default()
423 }
424 }
425
426 #[test]
427 fn modifiers_round_down() {
428 let s = sheet();
429 assert_eq!(s.ability_scores.modifier(Ability::Dexterity), 3);
430 assert_eq!(s.ability_scores.modifier(Ability::Wisdom), -1);
431 assert_eq!(s.ability_scores.modifier(Ability::Strength), 0);
432 }
433
434 #[test]
435 fn proficiency_scales_with_level() {
436 let mut s = sheet();
437 assert_eq!(s.proficiency_bonus(), 3);
438 assert_eq!(s.skill_bonus(Skill::Stealth), 6);
439 assert_eq!(s.saving_throw(Ability::Dexterity), 6);
440 assert_eq!(s.spell_save_dc(Ability::Wisdom), 10);
441 assert_eq!(s.spell_attack_bonus(Ability::Dexterity), 6);
442 s.classes[0].level = 1;
443 assert_eq!(s.proficiency_bonus(), 2);
444 s.classes[0].level = 20;
445 assert_eq!(s.proficiency_bonus(), 6);
446 }
447
448 #[test]
449 fn validation_rejects_bad_sheets() {
450 assert!(sheet().validate().is_ok());
451
452 let mut s = sheet();
453 s.name = " ".into();
454 assert_eq!(s.validate(), Err(ValidationError::EmptyName));
455
456 let mut s = sheet();
457 s.ability_scores.strength = 0;
458 assert!(matches!(
459 s.validate(),
460 Err(ValidationError::AbilityScoreOutOfRange { .. })
461 ));
462
463 let mut s = sheet();
464 s.classes.push(ClassLevel {
465 name: "Rogue".into(),
466 subclass: None,
467 level: 16,
468 spellcasting_ability: None,
469 });
470 assert_eq!(s.validate(), Err(ValidationError::LevelTooHigh(21)));
471
472 let mut s = sheet();
473 s.resources = serde_json::from_str(
474 r#"[{"name":"Action Surge","max":1,"recharge":"short_rest"},{"name":"action surge","max":1}]"#,
475 )
476 .unwrap();
477 assert!(matches!(
478 s.validate(),
479 Err(ValidationError::DuplicateResource(_))
480 ));
481
482 let mut s = sheet();
483 s.spellcasting
484 .slots
485 .insert(1, crate::Pool { max: 2, used: 3 });
486 assert!(matches!(s.validate(), Err(ValidationError::Overspent(_))));
487 }
488
489 #[test]
490 fn character_round_trips_through_json() {
491 let mut sheet = sheet();
494 sheet.spellcasting.slots.insert(1, crate::Pool::new(4));
495 sheet
496 .spellcasting
497 .slots
498 .insert(3, crate::Pool { max: 2, used: 1 });
499 let character = Character {
500 campaign: Uuid::nil(),
501 id: Uuid::nil(),
502 owners: vec!["a-device-key".into()],
503 sheet,
504 version: 7,
505 created_at: Utc::now(),
506 updated_at: Utc::now(),
507 };
508 let json = serde_json::to_string(&character).unwrap();
509 let back: Character = serde_json::from_str(&json).unwrap();
510 assert_eq!(back, character);
511 }
512
513 #[test]
514 fn minimal_json_fills_defaults() {
515 let s: CharacterSheet = serde_json::from_str(r#"{"name":"Pike"}"#).unwrap();
516 assert_eq!(s.armor_class, 10);
517 assert_eq!(s.ability_scores.charisma, 10);
518 assert!(s.validate().is_ok());
519 }
520}