1use std::collections::BTreeMap;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use crate::{Ability, AbilityScores, Edition, Skill};
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
17#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
18pub struct Monster {
19 pub id: Uuid,
20 #[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#[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 pub max_hit_points: u16,
43 #[serde(default)]
46 pub xp: u32,
47 pub armor_class: u8,
48 pub source: String,
49 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#[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 pub size: String,
81 #[serde(rename = "type")]
83 pub kind: String,
84 pub alignment: String,
85 pub armor_class: u8,
86 #[serde(skip_serializing_if = "String::is_empty")]
88 pub armor: String,
89 pub hit_points: u16,
91 #[serde(skip_serializing_if = "String::is_empty")]
93 pub hit_dice: String,
94 pub speed: String,
96 pub ability_scores: AbilityScores,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub initiative: Option<i8>,
100 #[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 pub senses: String,
115 pub languages: String,
116 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 pub source: String,
132 #[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#[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#[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#[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 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 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 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 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 pub fn hit_dice(&self) -> Option<HitDice> {
262 HitDice::parse(&self.hit_dice)
263 }
264
265 pub fn max_hit_points(&self) -> u16 {
268 self.hit_dice().map_or(self.hit_points, HitDice::max)
269 }
270
271 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 pub fn initiative_modifier(&self) -> i8 {
282 self.initiative
283 .unwrap_or_else(|| self.ability_scores.modifier(Ability::Dexterity))
284 }
285
286 pub fn challenge_value(&self) -> f32 {
288 challenge_value(&self.challenge)
289 }
290
291 pub fn suits(&self, edition: Edition) -> bool {
293 self.edition.is_none_or(|e| e == edition)
294 }
295}
296
297pub 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}