Skip to main content

edda_types/
monster.rs

1//! Monsters: full stat blocks, kept in a shared library (the SRD) and per
2//! campaign (homebrew), and turned into combatants when they join a fight.
3//!
4//! A stat block is plain JSON, like a character sheet, so monsters can be
5//! written by hand or converted from elsewhere (see `tools/`).
6
7use std::collections::BTreeMap;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::{Ability, AbilityScores, Edition, Skill};
14
15/// A stored monster: server-assigned metadata plus the stat block.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18pub struct Monster {
19    pub id: Uuid,
20    /// The campaign whose homebrew it is; `None` for the shared library,
21    /// which every campaign (of a matching edition) sees.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub campaign: Option<Uuid>,
24    #[serde(flatten)]
25    pub sheet: MonsterSheet,
26    pub version: u64,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29}
30
31/// For listing and completing names.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34pub struct MonsterSummary {
35    pub id: Uuid,
36    pub name: String,
37    pub size: String,
38    pub kind: String,
39    pub challenge: String,
40    pub hit_points: u16,
41    /// What its hit dice can come to at most, which it joins a fight with.
42    pub max_hit_points: u16,
43    /// Experience points, from its challenge rating if the stat block
44    /// doesn't say.
45    #[serde(default)]
46    pub xp: u32,
47    pub armor_class: u8,
48    pub source: String,
49    /// In the shared library rather than the campaign's own.
50    pub shared: bool,
51}
52
53impl From<&Monster> for MonsterSummary {
54    fn from(m: &Monster) -> Self {
55        let s = &m.sheet;
56        Self {
57            id: m.id,
58            name: s.name.clone(),
59            size: s.size.clone(),
60            kind: s.kind.clone(),
61            challenge: s.challenge.clone(),
62            hit_points: s.hit_points,
63            max_hit_points: s.max_hit_points(),
64            xp: s.experience_points(),
65            armor_class: s.armor_class,
66            source: s.source.clone(),
67            shared: m.campaign.is_none(),
68        }
69    }
70}
71
72/// A full stat block. Everything but the name has a default, so a file
73/// needs only what the monster actually has.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76#[serde(default)]
77pub struct MonsterSheet {
78    pub name: String,
79    /// "Medium", "Large"...
80    pub size: String,
81    /// The creature type: "humanoid (goblinoid)", "dragon"...
82    #[serde(rename = "type")]
83    pub kind: String,
84    pub alignment: String,
85    pub armor_class: u8,
86    /// What the AC comes from: "leather armor, shield".
87    #[serde(skip_serializing_if = "String::is_empty")]
88    pub armor: String,
89    /// The average, as printed.
90    pub hit_points: u16,
91    /// The hit dice, e.g. "2d6" or "18d10+36"; empty if there are none.
92    #[serde(skip_serializing_if = "String::is_empty")]
93    pub hit_dice: String,
94    /// "30 ft., fly 60 ft."
95    pub speed: String,
96    pub ability_scores: AbilityScores,
97    /// Overrides the Dexterity modifier (2024 stat blocks print it).
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub initiative: Option<i8>,
100    /// Saving throw bonuses that differ from the plain modifier.
101    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
102    pub saving_throws: BTreeMap<Ability, i8>,
103    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
104    pub skills: BTreeMap<Skill, i8>,
105    #[serde(skip_serializing_if = "Vec::is_empty")]
106    pub damage_vulnerabilities: Vec<String>,
107    #[serde(skip_serializing_if = "Vec::is_empty")]
108    pub damage_resistances: Vec<String>,
109    #[serde(skip_serializing_if = "Vec::is_empty")]
110    pub damage_immunities: Vec<String>,
111    #[serde(skip_serializing_if = "Vec::is_empty")]
112    pub condition_immunities: Vec<String>,
113    /// "darkvision 60 ft., passive Perception 9"
114    pub senses: String,
115    pub languages: String,
116    /// The challenge rating as printed: "0", "1/8", "1/4", "1/2", "1"...
117    pub challenge: String,
118    pub xp: u32,
119    pub proficiency_bonus: i8,
120    #[serde(skip_serializing_if = "Vec::is_empty")]
121    pub traits: Vec<Feature>,
122    #[serde(skip_serializing_if = "Vec::is_empty")]
123    pub actions: Vec<Feature>,
124    #[serde(skip_serializing_if = "Vec::is_empty")]
125    pub bonus_actions: Vec<Feature>,
126    #[serde(skip_serializing_if = "Vec::is_empty")]
127    pub reactions: Vec<Feature>,
128    #[serde(skip_serializing_if = "Vec::is_empty")]
129    pub legendary_actions: Vec<Feature>,
130    /// Where it's from: "SRD 5.1", "SRD 5.2", "Homebrew"...
131    pub source: String,
132    /// The rules it's written for; `None` suits either.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub edition: Option<Edition>,
135    #[serde(skip_serializing_if = "String::is_empty")]
136    pub notes: String,
137}
138
139impl Default for MonsterSheet {
140    fn default() -> Self {
141        Self {
142            name: String::new(),
143            size: "Medium".into(),
144            kind: String::new(),
145            alignment: String::new(),
146            armor_class: 10,
147            armor: String::new(),
148            hit_points: 1,
149            hit_dice: String::new(),
150            speed: "30 ft.".into(),
151            ability_scores: AbilityScores::default(),
152            initiative: None,
153            saving_throws: BTreeMap::new(),
154            skills: BTreeMap::new(),
155            damage_vulnerabilities: Vec::new(),
156            damage_resistances: Vec::new(),
157            damage_immunities: Vec::new(),
158            condition_immunities: Vec::new(),
159            senses: String::new(),
160            languages: String::new(),
161            challenge: "0".into(),
162            xp: 0,
163            proficiency_bonus: 2,
164            traits: Vec::new(),
165            actions: Vec::new(),
166            bonus_actions: Vec::new(),
167            reactions: Vec::new(),
168            legendary_actions: Vec::new(),
169            source: "Homebrew".into(),
170            edition: None,
171            notes: String::new(),
172        }
173    }
174}
175
176/// A trait, action, reaction or legendary action.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
179pub struct Feature {
180    pub name: String,
181    #[serde(default)]
182    pub description: String,
183}
184
185/// Why a stat block isn't valid.
186#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
187pub enum MonsterError {
188    #[error("name must not be empty")]
189    EmptyName,
190    #[error("hit points must be at least 1")]
191    NoHitPoints,
192    #[error("hit dice must look like 2d6 or 18d10+36, not {0:?}")]
193    InvalidHitDice(String),
194    #[error("ability scores must be between 1 and 30")]
195    AbilityScoreOutOfRange,
196}
197
198/// Hit dice: `count`d`sides` plus `bonus`.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub struct HitDice {
201    pub count: u16,
202    pub sides: u16,
203    pub bonus: i32,
204}
205
206impl HitDice {
207    /// Parses "2d6", "18d10+36", "4d8 - 4" (spaces allowed).
208    pub fn parse(text: &str) -> Option<Self> {
209        let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
210        let (count, rest) = text.split_once(['d', 'D'])?;
211        let (sides, bonus) = match rest.find(['+', '-']) {
212            Some(i) => (&rest[..i], rest[i..].parse().ok()?),
213            None => (rest, 0),
214        };
215        let dice = Self {
216            count: count.parse().ok()?,
217            sides: sides.parse().ok()?,
218            bonus,
219        };
220        (dice.count > 0 && dice.sides > 0).then_some(dice)
221    }
222
223    /// The most the dice can come to (at least 1).
224    pub fn max(self) -> u16 {
225        let total = i32::from(self.count) * i32::from(self.sides) + self.bonus;
226        clamp_hp(total)
227    }
228
229    /// Rolls them, with `die` giving each roll (from 1 to `sides`).
230    pub fn roll(self, mut die: impl FnMut(u16) -> u16) -> u16 {
231        let rolled: i32 = (0..self.count).map(|_| i32::from(die(self.sides))).sum();
232        clamp_hp(rolled + self.bonus)
233    }
234}
235
236fn clamp_hp(total: i32) -> u16 {
237    u16::try_from(total.max(1)).unwrap_or(u16::MAX)
238}
239
240impl MonsterSheet {
241    /// Checks the stat block: a name, hit points, hit dice that can be read,
242    /// and ability scores from 1 to 30.
243    pub fn validate(&self) -> Result<(), MonsterError> {
244        if self.name.trim().is_empty() {
245            return Err(MonsterError::EmptyName);
246        }
247        if self.hit_points == 0 {
248            return Err(MonsterError::NoHitPoints);
249        }
250        if !self.hit_dice.trim().is_empty() && HitDice::parse(&self.hit_dice).is_none() {
251            return Err(MonsterError::InvalidHitDice(self.hit_dice.clone()));
252        }
253        let in_range = |(_, score): (Ability, u8)| (1..=30).contains(&score);
254        if !self.ability_scores.iter().all(in_range) {
255            return Err(MonsterError::AbilityScoreOutOfRange);
256        }
257        Ok(())
258    }
259
260    /// Its hit dice, if it has any that can be read (`8d10+40`).
261    pub fn hit_dice(&self) -> Option<HitDice> {
262        HitDice::parse(&self.hit_dice)
263    }
264
265    /// The most hit points it can have: its hit dice's maximum, or the
266    /// printed number if it has no hit dice.
267    pub fn max_hit_points(&self) -> u16 {
268        self.hit_dice().map_or(self.hit_points, HitDice::max)
269    }
270
271    /// Its experience points, from its challenge rating if not given.
272    pub fn experience_points(&self) -> u32 {
273        match self.xp {
274            0 => crate::encounter::xp_for_challenge(&self.challenge),
275            xp => xp,
276        }
277    }
278
279    /// What it adds to initiative: `initiative` if given, otherwise its
280    /// Dexterity modifier.
281    pub fn initiative_modifier(&self) -> i8 {
282        self.initiative
283            .unwrap_or_else(|| self.ability_scores.modifier(Ability::Dexterity))
284    }
285
286    /// The challenge rating as a number, for sorting: "1/4" is 0.25.
287    pub fn challenge_value(&self) -> f32 {
288        challenge_value(&self.challenge)
289    }
290
291    /// Whether a campaign playing by `edition` should see it.
292    pub fn suits(&self, edition: Edition) -> bool {
293        self.edition.is_none_or(|e| e == edition)
294    }
295}
296
297/// "1/4" is 0.25, "5" is 5; anything else sorts first.
298pub fn challenge_value(challenge: &str) -> f32 {
299    match challenge.trim().split_once('/') {
300        Some((n, d)) => match (n.parse::<f32>(), d.parse::<f32>()) {
301            (Ok(n), Ok(d)) if d != 0.0 => n / d,
302            _ => -1.0,
303        },
304        None => challenge.trim().parse().unwrap_or(-1.0),
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn hit_dice_parse_and_give_their_maximum() {
314        let dice = HitDice::parse("18d10 + 36").unwrap();
315        assert_eq!(
316            dice,
317            HitDice {
318                count: 18,
319                sides: 10,
320                bonus: 36
321            }
322        );
323        assert_eq!(dice.max(), 216);
324        assert_eq!(HitDice::parse("2d6").unwrap().max(), 12);
325        assert_eq!(HitDice::parse("1d4-5").unwrap().max(), 1, "never below 1");
326        for bad in ["", "d6", "2d", "2x6", "0d6", "2d6+x"] {
327            assert_eq!(HitDice::parse(bad), None, "{bad:?}");
328        }
329    }
330
331    #[test]
332    fn rolling_uses_each_die_and_the_bonus() {
333        let dice = HitDice::parse("3d8+2").unwrap();
334        let mut rolls = vec![1, 8, 4].into_iter();
335        assert_eq!(
336            dice.roll(|sides| {
337                assert_eq!(sides, 8);
338                rolls.next().unwrap()
339            }),
340            15
341        );
342    }
343
344    #[test]
345    fn a_monster_needs_a_name_hit_points_and_sane_dice() {
346        let goblin = MonsterSheet {
347            name: "Goblin".into(),
348            hit_points: 7,
349            hit_dice: "2d6".into(),
350            ..Default::default()
351        };
352        assert_eq!(goblin.validate(), Ok(()));
353        assert_eq!(goblin.max_hit_points(), 12);
354        let no_dice = MonsterSheet {
355            hit_dice: String::new(),
356            ..goblin.clone()
357        };
358        assert_eq!(no_dice.max_hit_points(), 7, "the printed number");
359        let bad = MonsterSheet {
360            hit_dice: "two d six".into(),
361            ..goblin.clone()
362        };
363        assert!(matches!(
364            bad.validate(),
365            Err(MonsterError::InvalidHitDice(_))
366        ));
367        assert_eq!(
368            MonsterSheet::default().validate(),
369            Err(MonsterError::EmptyName)
370        );
371    }
372
373    #[test]
374    fn json_needs_only_what_the_monster_has() {
375        let goblin: MonsterSheet = serde_json::from_str(
376            r#"{
377                "name": "Goblin", "type": "humanoid (goblinoid)", "size": "Small",
378                "armor_class": 15, "hit_points": 7, "hit_dice": "2d6",
379                "ability_scores": { "strength": 8, "dexterity": 14 },
380                "skills": { "stealth": 6 },
381                "challenge": "1/4",
382                "actions": [{ "name": "Scimitar", "description": "+4 to hit" }]
383            }"#,
384        )
385        .unwrap();
386        assert_eq!(goblin.kind, "humanoid (goblinoid)");
387        assert_eq!(goblin.skills[&Skill::Stealth], 6);
388        assert_eq!(goblin.initiative_modifier(), 2);
389        assert!((goblin.challenge_value() - 0.25).abs() < f32::EPSILON);
390        assert_eq!(goblin.source, "Homebrew");
391        assert!(goblin.suits(Edition::Rules2014) && goblin.suits(Edition::Rules2024));
392        let back: MonsterSheet =
393            serde_json::from_str(&serde_json::to_string(&goblin).unwrap()).unwrap();
394        assert_eq!(back, goblin);
395    }
396}