1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use super::{
5 CombatEvent, CombatState, CombatStatus, Combatant, Effect, EffectEndReason, Expiry, SpellSlot,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum CombatCommand {
13 AddCombatant {
16 combatant: Combatant,
17 },
18 AddMonsters {
24 monster: Uuid,
25 #[serde(default = "one")]
26 count: u16,
27 #[serde(default)]
28 roll: bool,
29 },
30 RemoveCombatant {
31 combatant: Uuid,
32 },
33 RollInitiative {
36 combatant: Uuid,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 roll: Option<u8>,
39 },
40 RollInitiativeForAll,
42 SetInitiative {
43 combatant: Uuid,
44 total: i16,
45 },
46 Start,
47 NextTurn,
48 Damage {
49 combatant: Uuid,
50 amount: u32,
51 },
52 Heal {
53 combatant: Uuid,
54 amount: u32,
55 },
56 SetTemporaryHitPoints {
57 combatant: Uuid,
58 amount: u16,
59 },
60 AddEffect {
61 effect: NewEffect,
62 },
63 RemoveEffect {
64 effect: Uuid,
65 },
66 CastSpell {
72 caster: Uuid,
73 spell: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 slot: Option<SpellSlot>,
76 #[serde(default)]
77 targets: Vec<Uuid>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 concentration: Option<Uuid>,
80 },
81 UseFeature {
84 combatant: Uuid,
85 feature: String,
86 #[serde(default = "one")]
87 amount: u16,
88 },
89 Pause,
92 Resume,
93 End,
94 Undo {
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 command: Option<Uuid>,
100 },
101}
102
103fn one() -> u16 {
104 1
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110pub struct NewEffect {
111 pub id: Uuid,
113 pub name: String,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub source: Option<Uuid>,
116 #[serde(default)]
117 pub targets: Vec<Uuid>,
118 #[serde(default)]
119 pub concentration: bool,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub rounds: Option<u32>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
129pub enum CombatError {
130 #[error("no combatant with id {0}")]
131 UnknownCombatant(Uuid),
132 #[error("no effect with id {0}")]
133 UnknownEffect(Uuid),
134 #[error("id {0} is already in use")]
135 DuplicateId(Uuid),
136 #[error("name must not be empty")]
137 EmptyName,
138 #[error("current hit points exceed maximum")]
139 InvalidHitPoints,
140 #[error("a d20 roll must be between 1 and 20, got {0}")]
141 InvalidRoll(u8),
142 #[error("everyone already has initiative")]
143 NothingToRoll,
144 #[error("add at least one")]
145 NothingToAdd,
146 #[error("{0} isn't yours; the DM or their player changes them")]
148 NotYours(String),
149 #[error("it isn't your turn")]
150 NotYourTurn,
151 #[error("only the DM can do that")]
152 DmOnly,
153 #[error("nobody has initiative yet")]
154 NoInitiative,
155 #[error("combat has not started")]
156 NotStarted,
157 #[error("combat has already started")]
158 AlreadyStarted,
159 #[error("combat is paused")]
160 Paused,
161 #[error("combat is not paused")]
162 NotPaused,
163 #[error("combat has ended")]
164 Ended,
165 #[error("this needs a dice roll from the server")]
167 DiceUnavailable,
168 #[error("only the server can decide this")]
170 ServerOnly,
171 #[error("you have nothing left to undo")]
172 NothingToUndo,
173 #[error("no command with id {0}")]
174 UnknownCommand(Uuid),
175 #[error("you can only undo your own changes")]
176 NotYourCommand,
177 #[error("that has already been undone")]
178 AlreadyUndone,
179 #[error("can't undo: {0}")]
180 CannotUndo(String),
181}
182
183pub trait Dice {
186 fn roll(&mut self, sides: u16) -> Option<u16>;
188
189 fn d20(&mut self) -> Option<u8> {
191 self.roll(20).and_then(|r| u8::try_from(r).ok())
192 }
193}
194
195pub struct NoDice;
197
198impl Dice for NoDice {
199 fn roll(&mut self, _sides: u16) -> Option<u16> {
200 None
201 }
202}
203
204pub fn decide(
208 state: &CombatState,
209 command: &CombatCommand,
210 dice: &mut dyn Dice,
211) -> Result<Vec<CombatEvent>, CombatError> {
212 if let CombatCommand::Undo { .. } | CombatCommand::AddMonsters { .. } = command {
213 return Err(CombatError::ServerOnly);
214 }
215 if state.status == CombatStatus::Ended {
216 return Err(CombatError::Ended);
217 }
218 if state.status == CombatStatus::Paused
219 && !matches!(command, CombatCommand::Resume | CombatCommand::End)
220 {
221 return Err(CombatError::Paused);
222 }
223 let mut out = Emitter {
224 state: state.clone(),
225 events: Vec::new(),
226 };
227 match command {
228 CombatCommand::AddCombatant { combatant } => out.add_combatant(combatant)?,
229 CombatCommand::RemoveCombatant { combatant } => out.remove(*combatant)?,
230 CombatCommand::RollInitiative { combatant, roll } => {
231 let roll = match roll {
232 Some(r) if (1..=20).contains(r) => *r,
233 Some(r) => return Err(CombatError::InvalidRoll(*r)),
234 None => dice.d20().ok_or(CombatError::DiceUnavailable)?,
235 };
236 out.roll_initiative(*combatant, roll)?;
237 }
238 CombatCommand::RollInitiativeForAll => out.roll_for_all(dice)?,
239 CombatCommand::SetInitiative { combatant, total } => {
240 out.combatant(*combatant)?;
241 out.emit(CombatEvent::InitiativeSet {
242 combatant: *combatant,
243 total: *total,
244 });
245 }
246 CombatCommand::Start => out.start()?,
247 CombatCommand::NextTurn => {
248 out.ensure_active()?;
249 let (next, wrapped) = out
250 .next_after(state.current_turn)
251 .ok_or(CombatError::NoInitiative)?;
252 out.start_turn(next, wrapped);
253 }
254 CombatCommand::Damage { combatant, amount } => out.damage(*combatant, *amount)?,
255 CombatCommand::Heal { combatant, amount } => out.heal(*combatant, *amount)?,
256 CombatCommand::SetTemporaryHitPoints { combatant, amount } => {
257 out.set_temporary(*combatant, *amount)?;
258 }
259 CombatCommand::AddEffect { effect } => out.add_effect(effect)?,
260 CombatCommand::RemoveEffect { effect } => {
261 state
262 .effect(*effect)
263 .ok_or(CombatError::UnknownEffect(*effect))?;
264 out.emit(CombatEvent::EffectEnded {
265 effect: *effect,
266 reason: EffectEndReason::Removed,
267 });
268 }
269 CombatCommand::CastSpell {
270 caster,
271 spell,
272 slot,
273 targets,
274 concentration,
275 } => out.cast_spell(*caster, spell, *slot, targets, *concentration)?,
276 CombatCommand::UseFeature {
277 combatant,
278 feature,
279 amount,
280 } => out.use_feature(*combatant, feature, *amount)?,
281 CombatCommand::Pause => {
282 out.ensure_active()?;
283 out.emit(CombatEvent::Paused);
284 }
285 CombatCommand::Resume => {
286 if state.status != CombatStatus::Paused {
287 return Err(CombatError::NotPaused);
288 }
289 out.emit(CombatEvent::Resumed);
290 }
291 CombatCommand::End => out.emit(CombatEvent::Ended),
292 CombatCommand::Undo { .. } | CombatCommand::AddMonsters { .. } => {
293 unreachable!("handled above")
294 }
295 }
296 Ok(out.events)
297}
298
299impl Emitter {
302 fn add_combatant(&mut self, combatant: &Combatant) -> Result<(), CombatError> {
303 if combatant.name.trim().is_empty() {
304 return Err(CombatError::EmptyName);
305 }
306 if combatant.hit_points.current > combatant.hit_points.max {
307 return Err(CombatError::InvalidHitPoints);
308 }
309 self.ensure_unused(combatant.id)?;
310 self.emit(CombatEvent::CombatantAdded {
311 combatant: combatant.clone(),
312 });
313 Ok(())
314 }
315
316 fn roll_for_all(&mut self, dice: &mut dyn Dice) -> Result<(), CombatError> {
317 let unrolled: Vec<_> = self
318 .state
319 .combatants
320 .iter()
321 .filter(|c| c.initiative.is_none())
322 .map(|c| c.id)
323 .collect();
324 if unrolled.is_empty() {
325 return Err(CombatError::NothingToRoll);
326 }
327 for id in unrolled {
328 let roll = dice.d20().ok_or(CombatError::DiceUnavailable)?;
329 self.roll_initiative(id, roll)?;
330 }
331 Ok(())
332 }
333
334 fn start(&mut self) -> Result<(), CombatError> {
335 if self.state.status != CombatStatus::Preparing {
336 return Err(CombatError::AlreadyStarted);
337 }
338 let first = self
339 .state
340 .turn_order()
341 .next()
342 .ok_or(CombatError::NoInitiative)?
343 .id;
344 self.emit(CombatEvent::Started);
345 self.start_turn(first, true);
346 Ok(())
347 }
348
349 fn damage(&mut self, combatant: Uuid, amount: u32) -> Result<(), CombatError> {
352 let before = self.combatant(combatant)?.hit_points;
353 let mut hp = before;
354 let absorbed = hp.temporary.min(clamp_u16(amount));
355 hp.temporary -= absorbed;
356 hp.current = hp.current.saturating_sub(clamp_u16(amount) - absorbed);
357 self.emit(CombatEvent::Damaged {
358 combatant,
359 amount,
360 hit_points: hp,
361 });
362 if before.current > 0 && hp.current == 0 {
363 self.end_concentration(combatant, EffectEndReason::ConcentrationBroken);
364 }
365 Ok(())
366 }
367
368 fn heal(&mut self, combatant: Uuid, amount: u32) -> Result<(), CombatError> {
369 let mut hp = self.combatant(combatant)?.hit_points;
370 hp.current = hp.current.saturating_add(clamp_u16(amount)).min(hp.max);
371 self.emit(CombatEvent::Healed {
372 combatant,
373 amount,
374 hit_points: hp,
375 });
376 Ok(())
377 }
378
379 fn set_temporary(&mut self, combatant: Uuid, amount: u16) -> Result<(), CombatError> {
380 let mut hp = self.combatant(combatant)?.hit_points;
381 hp.temporary = amount;
382 self.emit(CombatEvent::TemporaryHitPointsSet {
383 combatant,
384 hit_points: hp,
385 });
386 Ok(())
387 }
388
389 fn add_effect(&mut self, effect: &NewEffect) -> Result<(), CombatError> {
393 if effect.name.trim().is_empty() {
394 return Err(CombatError::EmptyName);
395 }
396 self.ensure_unused(effect.id)?;
397 for id in effect.source.iter().chain(&effect.targets) {
398 self.combatant(*id)?;
399 }
400 let expires = effect.rounds.map(|rounds| Expiry {
401 round: self.state.round + rounds,
402 at_turn_of: effect.source.or(self.state.current_turn),
403 });
404 if let (true, Some(source)) = (effect.concentration, effect.source) {
405 self.end_concentration(source, EffectEndReason::ConcentrationReplaced);
406 }
407 self.emit(CombatEvent::EffectAdded {
408 effect: Effect {
409 id: effect.id,
410 name: effect.name.clone(),
411 source: effect.source,
412 targets: effect.targets.clone(),
413 concentration: effect.concentration,
414 expires,
415 },
416 });
417 Ok(())
418 }
419
420 fn cast_spell(
423 &mut self,
424 caster: Uuid,
425 spell: &str,
426 slot: Option<SpellSlot>,
427 targets: &[Uuid],
428 concentration: Option<Uuid>,
429 ) -> Result<(), CombatError> {
430 if spell.trim().is_empty() {
431 return Err(CombatError::EmptyName);
432 }
433 for id in std::iter::once(&caster).chain(targets) {
434 self.combatant(*id)?;
435 }
436 self.emit(CombatEvent::SpellCast {
437 caster,
438 spell: spell.to_string(),
439 slot,
440 targets: targets.to_vec(),
441 });
442 if let Some(effect) = concentration {
443 self.ensure_unused(effect)?;
444 self.end_concentration(caster, EffectEndReason::ConcentrationReplaced);
445 self.emit(CombatEvent::EffectAdded {
446 effect: Effect {
447 id: effect,
448 name: spell.to_string(),
449 source: Some(caster),
450 targets: targets.to_vec(),
451 concentration: true,
452 expires: None,
453 },
454 });
455 }
456 Ok(())
457 }
458
459 fn use_feature(
460 &mut self,
461 combatant: Uuid,
462 feature: &str,
463 amount: u16,
464 ) -> Result<(), CombatError> {
465 if feature.trim().is_empty() {
466 return Err(CombatError::EmptyName);
467 }
468 self.combatant(combatant)?;
469 self.emit(CombatEvent::FeatureUsed {
470 combatant,
471 feature: feature.to_string(),
472 amount,
473 });
474 Ok(())
475 }
476}
477
478fn clamp_u16(n: u32) -> u16 {
479 u16::try_from(n).unwrap_or(u16::MAX)
480}
481
482pub(super) struct Emitter {
485 pub(super) state: CombatState,
486 pub(super) events: Vec<CombatEvent>,
487}
488
489impl Emitter {
490 pub(super) fn emit(&mut self, event: CombatEvent) {
491 self.state.apply(&event);
492 self.events.push(event);
493 }
494
495 fn combatant(&self, id: Uuid) -> Result<&Combatant, CombatError> {
496 self.state
497 .combatant(id)
498 .ok_or(CombatError::UnknownCombatant(id))
499 }
500
501 fn ensure_unused(&self, id: Uuid) -> Result<(), CombatError> {
502 if self.state.combatant(id).is_some() || self.state.effect(id).is_some() {
503 return Err(CombatError::DuplicateId(id));
504 }
505 Ok(())
506 }
507
508 fn ensure_active(&self) -> Result<(), CombatError> {
509 match self.state.status {
510 CombatStatus::Active => Ok(()),
511 CombatStatus::Preparing => Err(CombatError::NotStarted),
512 CombatStatus::Paused => Err(CombatError::Paused),
513 CombatStatus::Ended => Err(CombatError::Ended),
514 }
515 }
516
517 fn roll_initiative(&mut self, id: Uuid, roll: u8) -> Result<(), CombatError> {
518 let modifier = self.combatant(id)?.initiative_modifier;
519 self.emit(CombatEvent::InitiativeRolled {
520 combatant: id,
521 roll,
522 modifier,
523 total: i16::from(roll) + i16::from(modifier),
524 });
525 Ok(())
526 }
527
528 fn next_after(&self, current: Option<Uuid>) -> Option<(Uuid, bool)> {
531 let order: Vec<_> = self.state.turn_order().map(|c| c.id).collect();
532 let position = current.and_then(|id| order.iter().position(|&c| c == id));
533 match position {
534 Some(i) if i + 1 < order.len() => Some((order[i + 1], false)),
535 _ => order.first().map(|&first| (first, true)),
536 }
537 }
538
539 fn start_turn(&mut self, next: Uuid, new_round: bool) {
542 if new_round {
543 let round = self.state.round + 1;
544 self.emit(CombatEvent::RoundStarted { round });
545 self.expire(|state, expiry| {
547 expiry
548 .at_turn_of
549 .is_none_or(|id| state.combatant(id).is_none())
550 });
551 }
552 self.emit(CombatEvent::TurnStarted { combatant: next });
553 self.expire(|_, expiry| expiry.at_turn_of == Some(next));
554 }
555
556 fn expire(&mut self, matches: impl Fn(&CombatState, &Expiry) -> bool) {
557 let round = self.state.round;
558 let expired: Vec<_> = self
559 .state
560 .effects
561 .iter()
562 .filter(|e| {
563 e.expires
564 .is_some_and(|x| x.round <= round && matches(&self.state, &x))
565 })
566 .map(|e| e.id)
567 .collect();
568 for effect in expired {
569 self.emit(CombatEvent::EffectEnded {
570 effect,
571 reason: EffectEndReason::Expired,
572 });
573 }
574 }
575
576 fn end_concentration(&mut self, source: Uuid, reason: EffectEndReason) {
577 let concentrating: Vec<_> = self
578 .state
579 .effects
580 .iter()
581 .filter(|e| e.concentration && e.source == Some(source))
582 .map(|e| e.id)
583 .collect();
584 for effect in concentrating {
585 self.emit(CombatEvent::EffectEnded { effect, reason });
586 }
587 }
588
589 pub(super) fn remove(&mut self, id: Uuid) -> Result<(), CombatError> {
590 self.combatant(id)?;
591 let was_current = self.state.current_turn == Some(id);
592 let next = self.next_after(Some(id)).filter(|&(n, _)| n != id);
594 self.end_concentration(id, EffectEndReason::SourceRemoved);
595 self.emit(CombatEvent::CombatantRemoved { combatant: id });
596 if was_current
597 && self.state.status == CombatStatus::Active
598 && let Some((next, wrapped)) = next
599 {
600 self.start_turn(next, wrapped);
601 }
602 Ok(())
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 struct Loaded(Vec<u8>);
612
613 impl Dice for Loaded {
614 fn roll(&mut self, _sides: u16) -> Option<u16> {
615 Some(u16::from(self.0.remove(0)))
616 }
617 }
618
619 struct Fixture {
620 state: CombatState,
621 }
622
623 impl Fixture {
624 fn new() -> Self {
625 let mut state = CombatState::default();
626 state.apply(&CombatEvent::Created {
627 name: "Ambush".into(),
628 campaign: "Test".into(),
629 });
630 Self { state }
631 }
632
633 fn add(&mut self, name: &str, modifier: i8, hp: u16) -> Uuid {
634 let mut c = Combatant::new(name, hp);
635 c.initiative_modifier = modifier;
636 let id = c.id;
637 self.ok(CombatCommand::AddCombatant { combatant: c });
638 id
639 }
640
641 #[expect(
642 clippy::needless_pass_by_value,
643 reason = "tests read better passing commands by value"
644 )]
645 fn run(&mut self, command: CombatCommand) -> Result<Vec<CombatEvent>, CombatError> {
646 let events = decide(&self.state, &command, &mut NoDice)?;
647 for e in &events {
648 self.state.apply(e);
649 }
650 Ok(events)
651 }
652
653 fn ok(&mut self, command: CombatCommand) -> Vec<CombatEvent> {
654 self.run(command).unwrap()
655 }
656
657 fn init(&mut self, id: Uuid, total: i16) {
658 self.ok(CombatCommand::SetInitiative {
659 combatant: id,
660 total,
661 });
662 }
663
664 fn names(&self) -> Vec<&str> {
665 self.state.turn_order().map(|c| c.name.as_str()).collect()
666 }
667
668 fn current(&self) -> &str {
669 let id = self.state.current_turn.unwrap();
670 &self.state.combatant(id).unwrap().name
671 }
672
673 fn effect(
674 &mut self,
675 name: &str,
676 source: Uuid,
677 concentration: bool,
678 rounds: Option<u32>,
679 ) -> Uuid {
680 let id = Uuid::new_v4();
681 self.ok(CombatCommand::AddEffect {
682 effect: NewEffect {
683 id,
684 name: name.into(),
685 source: Some(source),
686 targets: vec![],
687 concentration,
688 rounds,
689 },
690 });
691 id
692 }
693 }
694
695 #[test]
696 fn initiative_order_breaks_ties_by_modifier_then_join_order() {
697 let mut f = Fixture::new();
698 let grog = f.add("Grog", 0, 50);
699 let vex = f.add("Vex", 4, 40);
700 let pike = f.add("Pike", 0, 30);
701 let goblin = f.add("Goblin", 2, 7);
702 f.init(grog, 15);
703 f.init(vex, 15);
704 f.init(pike, 15);
705 assert_eq!(f.names(), ["Vex", "Grog", "Pike"], "goblin not rolled yet");
706 f.init(goblin, 20);
707 assert_eq!(f.names(), ["Goblin", "Vex", "Grog", "Pike"]);
708 }
709
710 #[test]
711 fn server_rolls_use_dice_and_clients_cannot_predict_them() {
712 let mut f = Fixture::new();
713 let a = f.add("A", 3, 10);
714 let b = f.add("B", -1, 10);
715 let events = decide(
716 &f.state,
717 &CombatCommand::RollInitiativeForAll,
718 &mut Loaded(vec![12, 18]),
719 )
720 .unwrap();
721 assert_eq!(
722 events,
723 [
724 CombatEvent::InitiativeRolled {
725 combatant: a,
726 roll: 12,
727 modifier: 3,
728 total: 15
729 },
730 CombatEvent::InitiativeRolled {
731 combatant: b,
732 roll: 18,
733 modifier: -1,
734 total: 17
735 },
736 ]
737 );
738 assert_eq!(
739 f.run(CombatCommand::RollInitiativeForAll),
740 Err(CombatError::DiceUnavailable)
741 );
742 f.ok(CombatCommand::RollInitiative {
744 combatant: a,
745 roll: Some(20),
746 });
747 assert_eq!(f.state.combatant(a).unwrap().initiative, Some(23));
748 assert_eq!(
749 f.run(CombatCommand::RollInitiative {
750 combatant: a,
751 roll: Some(21)
752 }),
753 Err(CombatError::InvalidRoll(21))
754 );
755 }
756
757 #[test]
758 fn turns_advance_and_wrap_into_new_rounds() {
759 let mut f = Fixture::new();
760 let a = f.add("A", 0, 10);
761 let b = f.add("B", 0, 10);
762 assert_eq!(f.run(CombatCommand::Start), Err(CombatError::NoInitiative));
763 f.init(a, 10);
764 f.init(b, 5);
765 assert_eq!(f.run(CombatCommand::NextTurn), Err(CombatError::NotStarted));
766
767 f.ok(CombatCommand::Start);
768 assert_eq!((f.state.round, f.current()), (1, "A"));
769 f.ok(CombatCommand::NextTurn);
770 assert_eq!((f.state.round, f.current()), (1, "B"));
771 let events = f.ok(CombatCommand::NextTurn);
772 assert_eq!(events[0], CombatEvent::RoundStarted { round: 2 });
773 assert_eq!((f.state.round, f.current()), (2, "A"));
774 assert_eq!(
775 f.run(CombatCommand::Start),
776 Err(CombatError::AlreadyStarted)
777 );
778 }
779
780 #[test]
781 fn combatants_can_join_mid_combat() {
782 let mut f = Fixture::new();
783 let a = f.add("A", 0, 10);
784 let b = f.add("B", 0, 10);
785 f.init(a, 20);
786 f.init(b, 5);
787 f.ok(CombatCommand::Start);
788
789 let mut late = Combatant::new("Reinforcement", 10);
790 late.initiative = Some(12);
791 f.ok(CombatCommand::AddCombatant { combatant: late });
792 assert_eq!(f.names(), ["A", "Reinforcement", "B"]);
793 f.ok(CombatCommand::NextTurn);
794 assert_eq!(f.current(), "Reinforcement");
795 }
796
797 #[test]
798 fn removing_the_current_combatant_passes_the_turn() {
799 let mut f = Fixture::new();
800 let a = f.add("A", 0, 10);
801 let b = f.add("B", 0, 10);
802 let c = f.add("C", 0, 10);
803 f.init(a, 3);
804 f.init(b, 2);
805 f.init(c, 1);
806 f.ok(CombatCommand::Start);
807 f.ok(CombatCommand::NextTurn);
808 f.ok(CombatCommand::NextTurn);
809 assert_eq!(f.current(), "C");
810
811 f.ok(CombatCommand::RemoveCombatant { combatant: c });
813 assert_eq!((f.state.round, f.current()), (2, "A"));
814 f.ok(CombatCommand::RemoveCombatant { combatant: a });
816 assert_eq!((f.state.round, f.current()), (2, "B"));
817 f.ok(CombatCommand::RemoveCombatant { combatant: b });
818 assert_eq!(f.state.current_turn, None);
819 }
820
821 #[test]
822 fn damage_hits_temporary_hp_first_and_heal_caps_at_max() {
823 let mut f = Fixture::new();
824 let grog = f.add("Grog", 0, 50);
825 f.ok(CombatCommand::SetTemporaryHitPoints {
826 combatant: grog,
827 amount: 5,
828 });
829 f.ok(CombatCommand::Damage {
830 combatant: grog,
831 amount: 8,
832 });
833 let hp = f.state.combatant(grog).unwrap().hit_points;
834 assert_eq!((hp.current, hp.temporary), (47, 0));
835 f.ok(CombatCommand::Heal {
836 combatant: grog,
837 amount: 100,
838 });
839 assert_eq!(f.state.combatant(grog).unwrap().hit_points.current, 50);
840 f.ok(CombatCommand::Damage {
841 combatant: grog,
842 amount: u32::MAX,
843 });
844 assert!(f.state.combatant(grog).unwrap().is_down());
845 }
846
847 #[test]
848 fn concentration_is_replaced_and_broken() {
849 let mut f = Fixture::new();
850 let keyleth = f.add("Keyleth", 0, 30);
851 let moonbeam = f.effect("Moonbeam", keyleth, true, Some(10));
852 let barkskin = f.effect("Barkskin", keyleth, false, None);
853
854 let events = f.ok(CombatCommand::AddEffect {
855 effect: NewEffect {
856 id: Uuid::new_v4(),
857 name: "Faerie Fire".into(),
858 source: Some(keyleth),
859 targets: vec![],
860 concentration: true,
861 rounds: Some(10),
862 },
863 });
864 assert_eq!(
865 events[0],
866 CombatEvent::EffectEnded {
867 effect: moonbeam,
868 reason: EffectEndReason::ConcentrationReplaced
869 }
870 );
871
872 f.ok(CombatCommand::Damage {
873 combatant: keyleth,
874 amount: 30,
875 });
876 let names: Vec<_> = f.state.effects.iter().map(|e| e.name.as_str()).collect();
877 assert_eq!(names, ["Barkskin"], "only the concentration effect ends");
878 assert!(f.state.effect(barkskin).is_some());
879 }
880
881 #[test]
882 fn effects_expire_at_the_start_of_the_sources_turn() {
883 let mut f = Fixture::new();
884 let cleric = f.add("Cleric", 0, 10);
885 let orc = f.add("Orc", 0, 10);
886 f.init(cleric, 10);
887 f.init(orc, 5);
888 f.ok(CombatCommand::Start);
889 let bless = f.effect("Bless", cleric, true, Some(2));
892 f.ok(CombatCommand::NextTurn); f.ok(CombatCommand::NextTurn); f.ok(CombatCommand::NextTurn); assert!(f.state.effect(bless).is_some());
896 let events = f.ok(CombatCommand::NextTurn); assert!(events.contains(&CombatEvent::EffectEnded {
898 effect: bless,
899 reason: EffectEndReason::Expired
900 }));
901 assert!(f.state.effects.is_empty());
902 }
903
904 #[test]
905 fn effects_without_a_source_or_targets_count_the_active_combatants_turns() {
906 let mut f = Fixture::new();
909 let a = f.add("A", 0, 10);
910 let b = f.add("B", 0, 10);
911 f.init(a, 10);
912 f.init(b, 5);
913 f.ok(CombatCommand::Start);
914 f.ok(CombatCommand::NextTurn); let fire = Uuid::new_v4();
916 f.ok(CombatCommand::AddEffect {
917 effect: NewEffect {
918 id: fire,
919 name: "Fire".into(),
920 source: None,
921 targets: vec![],
922 concentration: false,
923 rounds: Some(1),
924 },
925 });
926 assert_eq!(
927 f.state.effect(fire).unwrap().expires.unwrap().at_turn_of,
928 Some(b)
929 );
930 f.ok(CombatCommand::NextTurn); assert!(
932 f.state.effect(fire).is_some(),
933 "lasts until B's turn comes round"
934 );
935 let events = f.ok(CombatCommand::NextTurn); assert!(events.contains(&CombatEvent::EffectEnded {
937 effect: fire,
938 reason: EffectEndReason::Expired
939 }));
940 }
941
942 #[test]
943 fn effects_of_departed_sources_expire_at_round_start() {
944 let mut f = Fixture::new();
945 let a = f.add("A", 0, 10);
946 let b = f.add("B", 0, 10);
947 f.init(a, 10);
948 f.init(b, 5);
949 f.ok(CombatCommand::Start);
950 let web = f.effect("Web", b, false, Some(1));
951 f.ok(CombatCommand::RemoveCombatant { combatant: b });
952 assert!(
953 f.state.effect(web).is_some(),
954 "not concentration, so it lingers"
955 );
956 f.ok(CombatCommand::NextTurn); assert!(f.state.effect(web).is_none());
958 }
959
960 #[test]
961 fn ended_combat_rejects_everything_and_ids_are_unique() {
962 let mut f = Fixture::new();
963 let a = f.add("A", 0, 10);
964 let mut dup = Combatant::new("Copy", 5);
965 dup.id = a;
966 assert_eq!(
967 f.run(CombatCommand::AddCombatant { combatant: dup }),
968 Err(CombatError::DuplicateId(a))
969 );
970 f.ok(CombatCommand::End);
971 assert_eq!(
972 f.run(CombatCommand::Heal {
973 combatant: a,
974 amount: 1
975 }),
976 Err(CombatError::Ended)
977 );
978 }
979
980 #[test]
981 fn spells_and_features_are_recorded_and_concentration_comes_into_play() {
982 let mut f = Fixture::new();
983 let keyleth = f.add("Keyleth", 0, 30);
984 let goblin = f.add("Goblin", 0, 7);
985 let moonbeam = f.effect("Moonbeam", keyleth, true, None);
986 let bless = Uuid::new_v4();
987 let events = f.ok(CombatCommand::CastSpell {
988 caster: keyleth,
989 spell: "Bless".into(),
990 slot: Some(SpellSlot::Level(1)),
991 targets: vec![goblin],
992 concentration: Some(bless),
993 });
994 assert_eq!(
995 events[0],
996 CombatEvent::SpellCast {
997 caster: keyleth,
998 spell: "Bless".into(),
999 slot: Some(SpellSlot::Level(1)),
1000 targets: vec![goblin],
1001 }
1002 );
1003 assert!(f.state.effect(moonbeam).is_none(), "concentration moved on");
1004 let effect = f.state.effect(bless).unwrap();
1005 assert_eq!((effect.source, effect.concentration), (Some(keyleth), true));
1006
1007 let events = f.ok(CombatCommand::CastSpell {
1008 caster: keyleth,
1009 spell: "Fire Bolt".into(),
1010 slot: None,
1011 targets: vec![goblin],
1012 concentration: None,
1013 });
1014 assert_eq!(events.len(), 1, "nothing comes into play");
1015 assert!(f.state.effect(bless).is_some(), "still concentrating");
1016 assert_eq!(
1017 f.run(CombatCommand::CastSpell {
1018 caster: keyleth,
1019 spell: "Fire Bolt".into(),
1020 slot: None,
1021 targets: vec![Uuid::nil()],
1022 concentration: None,
1023 }),
1024 Err(CombatError::UnknownCombatant(Uuid::nil()))
1025 );
1026
1027 let used = f.ok(CombatCommand::UseFeature {
1028 combatant: keyleth,
1029 feature: "Wild Shape".into(),
1030 amount: 1,
1031 });
1032 assert!(
1033 matches!(&used[0], CombatEvent::FeatureUsed { feature, .. } if feature == "Wild Shape")
1034 );
1035 }
1036
1037 #[test]
1038 fn paused_combat_only_resumes_or_ends() {
1039 let mut f = Fixture::new();
1040 let a = f.add("A", 0, 10);
1041 f.init(a, 10);
1042 f.ok(CombatCommand::Start);
1043 f.ok(CombatCommand::Pause);
1044 assert_eq!(f.state.status, CombatStatus::Paused);
1045 assert_eq!(f.run(CombatCommand::NextTurn), Err(CombatError::Paused));
1046 assert_eq!(f.run(CombatCommand::Pause), Err(CombatError::Paused));
1047 f.ok(CombatCommand::Resume);
1048 assert_eq!(f.state.status, CombatStatus::Active);
1049 assert_eq!(f.state.round, 1, "picks up where it left off");
1050 assert_eq!(f.run(CombatCommand::Resume), Err(CombatError::NotPaused));
1051 f.ok(CombatCommand::Pause);
1052 f.ok(CombatCommand::End);
1053 }
1054
1055 #[test]
1056 fn replaying_the_log_rebuilds_the_state() {
1057 let mut f = Fixture::new();
1058 let mut log = vec![CombatEvent::Created {
1059 name: "Ambush".into(),
1060 campaign: "Test".into(),
1061 }];
1062 let a = f.add("A", 0, 10);
1063 log.extend(
1064 decide(
1065 &CombatState::replay(&log),
1066 &CombatCommand::AddCombatant {
1067 combatant: f.state.combatant(a).unwrap().clone(),
1068 },
1069 &mut NoDice,
1070 )
1071 .unwrap(),
1072 );
1073 assert_eq!(CombatState::replay(&log), f.state);
1074 }
1075
1076 #[test]
1077 fn json_shapes() {
1078 let cmd: CombatCommand = serde_json::from_str(
1079 r#"{"type":"damage","combatant":"00000000-0000-0000-0000-000000000001","amount":7}"#,
1080 )
1081 .unwrap();
1082 assert!(matches!(cmd, CombatCommand::Damage { amount: 7, .. }));
1083 let roll: CombatCommand = serde_json::from_str(
1084 r#"{"type":"roll_initiative","combatant":"00000000-0000-0000-0000-000000000001"}"#,
1085 )
1086 .unwrap();
1087 assert!(matches!(
1088 roll,
1089 CombatCommand::RollInitiative { roll: None, .. }
1090 ));
1091 }
1092}