1use serde::{Deserialize, Serialize};
6
7use crate::{CharacterSheet, Pool, resources::RestKind};
8
9#[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 CastSpell {
18 spell: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 at_level: Option<u8>,
22 #[serde(default)]
24 pact: bool,
25 #[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#[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 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 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 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 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 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}