Skip to main content

edda_types/
action.rs

1//! In-play state changes to a character sheet: casting spells, spending
2//! resources and resting. Defined here so every client and service shares
3//! exactly the same rules.
4
5use serde::{Deserialize, Serialize};
6
7use crate::{CharacterSheet, Pool, resources::RestKind};
8
9/// A change made in play, applied atomically by the character service:
10/// `POST /characters/{id}/actions`.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum CharacterAction {
15    /// Cast a known spell, expending the appropriate slot. Cantrips and
16    /// ritual casts don't use a slot.
17    CastSpell {
18        spell: String,
19        /// Slot level to cast at (upcasting). Defaults to the spell's level.
20        #[serde(default, skip_serializing_if = "Option::is_none")]
21        at_level: Option<u8>,
22        /// Use a Pact Magic slot instead of a regular one.
23        #[serde(default)]
24        pact: bool,
25        /// Cast as a ritual (spell must have the ritual tag).
26        #[serde(default)]
27        ritual: bool,
28    },
29    ExpendSpellSlot {
30        level: u8,
31    },
32    RestoreSpellSlot {
33        level: u8,
34    },
35    ExpendPactSlot,
36    RestorePactSlot,
37    UseResource {
38        name: String,
39        #[serde(default = "one")]
40        amount: u16,
41    },
42    RestoreResource {
43        name: String,
44        #[serde(default = "one")]
45        amount: u16,
46    },
47    Rest {
48        kind: RestKind,
49    },
50}
51
52fn one() -> u16 {
53    1
54}
55
56/// Why an action can't be applied.
57#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
58pub enum ActionError {
59    #[error("unknown spell: {0}")]
60    UnknownSpell(String),
61    #[error("{0} is not prepared")]
62    SpellNotPrepared(String),
63    #[error("{0} cannot be cast as a ritual")]
64    NotARitual(String),
65    #[error("{spell} is level {spell_level} and cannot be cast with a level {slot_level} slot")]
66    SlotTooLow {
67        spell: String,
68        spell_level: u8,
69        slot_level: u8,
70    },
71    #[error("no level {0} spell slots")]
72    NoSlotsOfLevel(u8),
73    #[error("no level {0} spell slots remaining")]
74    NoSlotsRemaining(u8),
75    #[error("character has no pact magic")]
76    NoPactMagic,
77    #[error("pact slots are level {pact_level}, not {requested}")]
78    PactLevelMismatch { pact_level: u8, requested: u8 },
79    #[error("no pact magic slots remaining")]
80    NoPactSlotsRemaining,
81    #[error("unknown resource: {0}")]
82    UnknownResource(String),
83    #[error("{name} has {remaining} uses remaining, {requested} requested")]
84    NotEnoughUses {
85        name: String,
86        remaining: u16,
87        requested: u16,
88    },
89}
90
91impl ActionError {
92    /// True when the action was well-formed but the character has run out
93    /// of whatever it needed. Lets clients distinguish "you're out of slots"
94    /// from "that spell doesn't exist".
95    pub fn is_exhausted(&self) -> bool {
96        matches!(
97            self,
98            ActionError::NoSlotsRemaining(_)
99                | ActionError::NoPactSlotsRemaining
100                | ActionError::NotEnoughUses { .. }
101        )
102    }
103}
104
105impl CharacterSheet {
106    /// Applies an action. On error the sheet is left unchanged.
107    pub fn apply(&mut self, action: &CharacterAction) -> Result<(), ActionError> {
108        match action {
109            CharacterAction::CastSpell {
110                spell,
111                at_level,
112                pact,
113                ritual,
114            } => self.cast_spell(spell, *at_level, *pact, *ritual),
115            CharacterAction::ExpendSpellSlot { level } => self.expend_slot(*level),
116            CharacterAction::RestoreSpellSlot { level } => {
117                self.slot_mut(*level)?.restore(1);
118                Ok(())
119            }
120            CharacterAction::ExpendPactSlot => self.expend_pact_slot(),
121            CharacterAction::RestorePactSlot => {
122                let pact = self.spellcasting.pact_magic.as_mut();
123                pact.ok_or(ActionError::NoPactMagic)?.slots.restore(1);
124                Ok(())
125            }
126            CharacterAction::UseResource { name, amount } => {
127                let resource = self.resource_mut(name)?;
128                let remaining = resource.uses.remaining();
129                if !resource.uses.expend(*amount) {
130                    return Err(ActionError::NotEnoughUses {
131                        name: resource.name.clone(),
132                        remaining,
133                        requested: *amount,
134                    });
135                }
136                Ok(())
137            }
138            CharacterAction::RestoreResource { name, amount } => {
139                self.resource_mut(name)?.uses.restore(*amount);
140                Ok(())
141            }
142            CharacterAction::Rest { kind } => {
143                self.rest(*kind);
144                Ok(())
145            }
146        }
147    }
148
149    /// Takes a rest: restores what recovers on it (and, on a long rest, all
150    /// hit points).
151    pub fn rest(&mut self, kind: RestKind) {
152        for resource in &mut self.resources {
153            if resource.recharge.recovers_on(kind) {
154                resource.uses.reset();
155            }
156        }
157        if let Some(pact) = &mut self.spellcasting.pact_magic {
158            pact.slots.reset();
159        }
160        if kind == RestKind::Long {
161            self.spellcasting.slots.values_mut().for_each(Pool::reset);
162            self.hit_points.current = self.hit_points.max;
163        }
164    }
165
166    fn cast_spell(
167        &mut self,
168        name: &str,
169        at_level: Option<u8>,
170        pact: bool,
171        ritual: bool,
172    ) -> Result<(), ActionError> {
173        let spell = self
174            .spellcasting
175            .find_spell(name)
176            .ok_or_else(|| ActionError::UnknownSpell(name.to_string()))?;
177        let (spell_name, spell_level) = (spell.name.clone(), spell.level);
178
179        if ritual {
180            // Rituals need neither a slot nor (for Wizards) preparation, so
181            // check the tag before anything else.
182            return if spell.ritual {
183                Ok(())
184            } else {
185                Err(ActionError::NotARitual(spell_name))
186            };
187        }
188        if !spell.prepared {
189            return Err(ActionError::SpellNotPrepared(spell_name));
190        }
191        if spell_level == 0 {
192            return Ok(());
193        }
194
195        if pact {
196            let pact_magic = self
197                .spellcasting
198                .pact_magic
199                .ok_or(ActionError::NoPactMagic)?;
200            let slot_level = pact_magic.slot_level;
201            if let Some(requested) = at_level.filter(|&l| l != slot_level) {
202                return Err(ActionError::PactLevelMismatch {
203                    pact_level: slot_level,
204                    requested,
205                });
206            }
207            if slot_level < spell_level {
208                return Err(ActionError::SlotTooLow {
209                    spell: spell_name,
210                    spell_level,
211                    slot_level,
212                });
213            }
214            return self.expend_pact_slot();
215        }
216
217        let slot_level = at_level.unwrap_or(spell_level);
218        if slot_level < spell_level {
219            return Err(ActionError::SlotTooLow {
220                spell: spell_name,
221                spell_level,
222                slot_level,
223            });
224        }
225        self.expend_slot(slot_level)
226    }
227
228    fn slot_mut(&mut self, level: u8) -> Result<&mut crate::Pool, ActionError> {
229        self.spellcasting
230            .slots
231            .get_mut(&level)
232            .filter(|s| s.max > 0)
233            .ok_or(ActionError::NoSlotsOfLevel(level))
234    }
235
236    fn expend_slot(&mut self, level: u8) -> Result<(), ActionError> {
237        if self.slot_mut(level)?.expend(1) {
238            Ok(())
239        } else {
240            Err(ActionError::NoSlotsRemaining(level))
241        }
242    }
243
244    fn expend_pact_slot(&mut self) -> Result<(), ActionError> {
245        let pact = self.spellcasting.pact_magic.as_mut();
246        if pact.ok_or(ActionError::NoPactMagic)?.slots.expend(1) {
247            Ok(())
248        } else {
249            Err(ActionError::NoPactSlotsRemaining)
250        }
251    }
252
253    fn resource_mut(&mut self, name: &str) -> Result<&mut crate::Resource, ActionError> {
254        self.resources
255            .iter_mut()
256            .find(|r| r.name.eq_ignore_ascii_case(name))
257            .ok_or_else(|| ActionError::UnknownResource(name.to_string()))
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::{Pool, Recharge, Resource, Spell, spellcasting::PactMagic};
265
266    fn spell(name: &str, level: u8) -> Spell {
267        Spell {
268            name: name.into(),
269            level,
270            class: None,
271            prepared: true,
272            concentration: false,
273            ritual: false,
274            notes: String::new(),
275        }
276    }
277
278    fn eldritch_knight_warlock() -> CharacterSheet {
279        let mut s = CharacterSheet {
280            name: "Test".into(),
281            ..Default::default()
282        };
283        s.spellcasting.spells = vec![
284            spell("Fire Bolt", 0),
285            spell("Shield", 1),
286            spell("Hex", 1),
287            Spell {
288                ritual: true,
289                prepared: false,
290                ..spell("Detect Magic", 1)
291            },
292        ];
293        s.spellcasting.slots.insert(1, Pool::new(2));
294        s.spellcasting.slots.insert(2, Pool::new(1));
295        s.spellcasting.pact_magic = Some(PactMagic {
296            slot_level: 2,
297            slots: Pool::new(1),
298        });
299        s.resources.push(Resource {
300            name: "Action Surge".into(),
301            uses: Pool::new(1),
302            recharge: Recharge::ShortRest,
303            notes: String::new(),
304        });
305        s.resources.push(Resource {
306            name: "Indomitable".into(),
307            uses: Pool::new(1),
308            recharge: Recharge::LongRest,
309            notes: String::new(),
310        });
311        s
312    }
313
314    fn cast(spell: &str) -> CharacterAction {
315        CharacterAction::CastSpell {
316            spell: spell.into(),
317            at_level: None,
318            pact: false,
319            ritual: false,
320        }
321    }
322
323    #[test]
324    fn casting_spends_slots_until_exhausted() {
325        let mut s = eldritch_knight_warlock();
326        s.apply(&cast("shield")).unwrap();
327        s.apply(&cast("Shield")).unwrap();
328        let err = s.apply(&cast("Shield")).unwrap_err();
329        assert_eq!(err, ActionError::NoSlotsRemaining(1));
330        assert!(err.is_exhausted());
331    }
332
333    #[test]
334    fn cantrips_and_rituals_are_free() {
335        let mut s = eldritch_knight_warlock();
336        let before = s.clone();
337        s.apply(&cast("Fire Bolt")).unwrap();
338        s.apply(&CharacterAction::CastSpell {
339            spell: "Detect Magic".into(),
340            at_level: None,
341            pact: false,
342            ritual: true,
343        })
344        .unwrap();
345        assert_eq!(s, before);
346        // Not prepared, so it can't be cast normally.
347        assert!(matches!(
348            s.apply(&cast("Detect Magic")),
349            Err(ActionError::SpellNotPrepared(_))
350        ));
351    }
352
353    #[test]
354    fn upcasting_and_pact_slots() {
355        let mut s = eldritch_knight_warlock();
356        let upcast = CharacterAction::CastSpell {
357            spell: "Hex".into(),
358            at_level: Some(2),
359            pact: false,
360            ritual: false,
361        };
362        s.apply(&upcast).unwrap();
363        assert_eq!(s.spellcasting.slots[&2].used, 1);
364        assert_eq!(s.spellcasting.slots[&1].used, 0);
365
366        let pact = CharacterAction::CastSpell {
367            spell: "Hex".into(),
368            at_level: None,
369            pact: true,
370            ritual: false,
371        };
372        s.apply(&pact).unwrap();
373        assert_eq!(s.apply(&pact), Err(ActionError::NoPactSlotsRemaining));
374    }
375
376    #[test]
377    fn short_rest_restores_short_rest_resources_and_pact_slots_only() {
378        let mut s = eldritch_knight_warlock();
379        for action in [
380            cast("Shield"),
381            CharacterAction::ExpendPactSlot,
382            CharacterAction::UseResource {
383                name: "action surge".into(),
384                amount: 1,
385            },
386            CharacterAction::UseResource {
387                name: "Indomitable".into(),
388                amount: 1,
389            },
390        ] {
391            s.apply(&action).unwrap();
392        }
393        assert!(matches!(
394            s.apply(&CharacterAction::UseResource {
395                name: "Action Surge".into(),
396                amount: 1
397            }),
398            Err(ActionError::NotEnoughUses { remaining: 0, .. })
399        ));
400
401        s.rest(RestKind::Short);
402        assert_eq!(s.resources[0].uses.used, 0, "action surge back");
403        assert_eq!(s.resources[1].uses.used, 1, "indomitable still spent");
404        assert_eq!(s.spellcasting.pact_magic.unwrap().slots.used, 0);
405        assert_eq!(s.spellcasting.slots[&1].used, 1, "regular slot still spent");
406
407        s.rest(RestKind::Long);
408        assert_eq!(s.resources[1].uses.used, 0);
409        assert_eq!(s.spellcasting.slots[&1].used, 0);
410    }
411
412    #[test]
413    fn failed_actions_leave_sheet_unchanged() {
414        let mut s = eldritch_knight_warlock();
415        let before = s.clone();
416        assert!(s.apply(&cast("Wish")).is_err());
417        assert!(
418            s.apply(&CharacterAction::UseResource {
419                name: "Action Surge".into(),
420                amount: 2
421            })
422            .is_err()
423        );
424        assert!(
425            s.apply(&CharacterAction::ExpendSpellSlot { level: 3 })
426                .is_err()
427        );
428        assert_eq!(s, before);
429    }
430
431    #[test]
432    fn action_json_shape() {
433        let action: CharacterAction =
434            serde_json::from_str(r#"{"type":"use_resource","name":"Action Surge"}"#).unwrap();
435        assert_eq!(
436            action,
437            CharacterAction::UseResource {
438                name: "Action Surge".into(),
439                amount: 1
440            }
441        );
442        let rest: CharacterAction =
443            serde_json::from_str(r#"{"type":"rest","kind":"short"}"#).unwrap();
444        assert_eq!(
445            rest,
446            CharacterAction::Rest {
447                kind: RestKind::Short
448            }
449        );
450    }
451}