Skip to main content

edda_types/
character.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5use crate::{resources::Resource, spellcasting::Spellcasting};
6
7/// A stored character: server-assigned metadata plus the editable sheet.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
10pub struct Character {
11    pub id: Uuid,
12    /// The campaign it belongs to; only that campaign's devices can see it.
13    pub campaign: Uuid,
14    /// The player devices that play it, by key (as `auth devices` shows
15    /// them): they may change it, and act for it in a fight. The DM's
16    /// devices may change any character.
17    #[serde(default)]
18    pub owners: Vec<String>,
19    #[serde(flatten)]
20    pub sheet: CharacterSheet,
21    /// Incremented on every change. Send it back in `If-Match` when replacing
22    /// the sheet so stale copies can't overwrite newer data.
23    pub version: u64,
24    pub created_at: DateTime<Utc>,
25    pub updated_at: DateTime<Utc>,
26}
27
28/// Lightweight view used when listing characters.
29#[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    /// The player devices that play it, by key.
39    #[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/// The editable contents of a character sheet. This is the request body for
60/// both creating and replacing a character.
61#[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    /// Limited-use class features and abilities (Action Surge, Rage, Ki...).
81    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    /// The highest total character level.
111    pub const MAX_LEVEL: u8 = 20;
112
113    /// Sum of all class levels (at least 1 for display purposes when empty).
114    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    /// The proficiency bonus for the character's total level.
120    pub fn proficiency_bonus(&self) -> i8 {
121        // A level of 1 to 20 gives +2 to +6.
122        2 + i8::try_from((self.total_level() - 1) / 4).unwrap_or(0)
123    }
124
125    /// The bonus to a saving throw: the ability's modifier, plus proficiency if proficient.
126    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    /// The bonus to a skill check: its ability's modifier, plus proficiency if proficient.
136    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    /// The spell save DC when casting with `ability`: 8 + proficiency + modifier.
146    pub fn spell_save_dc(&self, ability: Ability) -> i8 {
147        8 + self.proficiency_bonus() + self.ability_scores.modifier(ability)
148    }
149
150    /// The spell attack bonus when casting with `ability`: proficiency + modifier.
151    pub fn spell_attack_bonus(&self, ability: Ability) -> i8 {
152        self.proficiency_bonus() + self.ability_scores.modifier(ability)
153    }
154
155    /// Validates rules that serde can't express. Shared by server and clients
156    /// so frontends can give immediate feedback.
157    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/// Why a sheet isn't valid.
201#[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/// Levels in one class (with its subclass, and the ability it casts with, if any).
230#[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    /// Set for classes that cast spells (e.g. Intelligence for Wizard).
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub spellcasting_ability: Option<Ability>,
240}
241
242/// The six ability scores.
243#[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    /// The lowest an ability score can be.
270    pub const MIN: u8 = 1;
271    /// The highest an ability score can be.
272    pub const MAX: u8 = 30;
273
274    /// One ability's score.
275    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    /// One ability's modifier: (score - 10) / 2, rounded down.
287    pub fn modifier(&self, ability: Ability) -> i8 {
288        // Any score (0 to 255) gives -5 to +122.
289        i8::try_from((i16::from(self.get(ability)) - 10).div_euclid(2)).unwrap_or(i8::MAX)
290    }
291
292    /// Every ability with its score, in the usual order.
293    pub fn iter(&self) -> impl Iterator<Item = (Ability, u8)> + '_ {
294        Ability::ALL.into_iter().map(|a| (a, self.get(a)))
295    }
296}
297
298/// One of the six abilities.
299#[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    /// All six, in the usual order.
313    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/// One of the eighteen skills.
324#[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    /// The ability the skill is checked with.
350    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/// Current, maximum and temporary hit points.
373#[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/// Something the character carries.
383#[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        // Regression: slot levels are map keys, which `#[serde(flatten)]`
492        // on Character used to fail to deserialize.
493        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}