Skip to main content

edda_types/combat/
rules.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use super::{
5    CombatEvent, CombatState, CombatStatus, Combatant, Effect, EffectEndReason, Expiry, SpellSlot,
6};
7
8/// What a participant wants to happen. Turned into events by [`decide`].
9#[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    /// Join the combat, at any point. If `initiative` is already set the
14    /// combatant slots straight into the turn order.
15    AddCombatant {
16        combatant: Combatant,
17    },
18    /// Add `count` of a monster from the campaign's monsters, named and
19    /// numbered on from any already in the fight ("Goblin 3"), at their
20    /// maximum hit points or, with `roll`, rolled. Only the server can
21    /// decide this (it needs the stat block and dice); see
22    /// [`super::add_monsters`].
23    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    /// Roll d20 + modifier. Pass `roll` when the player rolled physical
34    /// dice; leave it out to have the server roll.
35    RollInitiative {
36        combatant: Uuid,
37        #[serde(default, skip_serializing_if = "Option::is_none")]
38        roll: Option<u8>,
39    },
40    /// Server rolls for everyone who hasn't got initiative yet.
41    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    /// Record a spell cast in the log. The slot is spent on the caster's
67    /// character sheet, separately; `slot` says which, for the record.
68    /// For a concentration spell, `concentration` is the id of the effect
69    /// that puts it into play on its targets (ending the caster's previous
70    /// concentration).
71    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    /// Use a limited feature (Rage, Action Surge...). For a character, the
82    /// combat service spends the uses on their sheet first.
83    UseFeature {
84        combatant: Uuid,
85        feature: String,
86        #[serde(default = "one")]
87        amount: u16,
88    },
89    /// Interrupt an active combat. While paused, only `Resume` and `End`
90    /// are accepted.
91    Pause,
92    Resume,
93    End,
94    /// Take back one of the sender's own earlier commands: `command`, or
95    /// when that's left out, their latest one not yet undone. Only the
96    /// server can decide this (it needs the log); see [`super::undo`].
97    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/// An effect for a command to put into play.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
110pub struct NewEffect {
111    /// Chosen by the client, like combatant ids.
112    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    /// Duration in rounds, counted in turns of the source, or (with no
121    /// source, like a fire on the floor) of whoever's turn it is when it's
122    /// added; `None` lasts until removed. Targets are optional too.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub rounds: Option<u32>,
125}
126
127/// Why the rules refuse a command.
128#[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    /// A player's command would change someone who isn't theirs.
147    #[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    /// Only seen client-side: the outcome depends on a server dice roll.
166    #[error("this needs a dice roll from the server")]
167    DiceUnavailable,
168    /// Only seen client-side: the outcome depends on the server's log.
169    #[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
183/// Source of d20 rolls. The server rolls real dice; clients predicting an
184/// outcome use [`NoDice`], so anything needing a roll waits for the server.
185pub trait Dice {
186    /// A roll of a die with `sides` sides.
187    fn roll(&mut self, sides: u16) -> Option<u16>;
188
189    /// A d20 roll.
190    fn d20(&mut self) -> Option<u8> {
191        self.roll(20).and_then(|r| u8::try_from(r).ok())
192    }
193}
194
195/// Dice that never roll: for predicting, where rolls wait for the server.
196pub struct NoDice;
197
198impl Dice for NoDice {
199    fn roll(&mut self, _sides: u16) -> Option<u16> {
200        None
201    }
202}
203
204/// Decides what events a command produces. Never returns an empty list: a
205/// command that would change nothing is an error, so every accepted command
206/// is visible in the log.
207pub 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
299/// What each command does, when it takes more than a line or two. Each
300/// starts from the state the command was decided against.
301impl 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    /// Temporary hit points take damage first. Dropping to 0 breaks
350    /// concentration.
351    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    /// A new concentration effect ends its source's old one. An effect
390    /// lasting some rounds expires at the start of its source's turn (or,
391    /// without a source, of whoever's turn it is now).
392    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    /// A cast with `concentration` also puts down the spell as an effect
421    /// (with that id), ending the caster's old one.
422    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
482/// Collects events while applying them to a scratch copy of the state, so
483/// later steps of a command see the effects of earlier ones.
484pub(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    /// The combatant whose turn follows `current`'s, and whether getting
529    /// there wraps round to a new round.
530    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    /// Starts `next`'s turn, beginning a new round first if `new_round`,
540    /// and ends whatever effects expire at that point.
541    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            // Effects whose source is gone expire at the round boundary.
546            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        // Work out who's next while the leaver is still in the order.
593        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    /// Dice that roll a fixed sequence.
611    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        // A physical roll the player supplies is predictable.
743        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        // C is last, so the turn wraps to A in a new round.
812        f.ok(CombatCommand::RemoveCombatant { combatant: c });
813        assert_eq!((f.state.round, f.current()), (2, "A"));
814        // A is first in the order; removing them must not start a round.
815        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        // Round 1, cleric's turn: Bless for 2 rounds ends at the start of
890        // the cleric's turn in round 3.
891        let bless = f.effect("Bless", cleric, true, Some(2));
892        f.ok(CombatCommand::NextTurn); // R1 orc
893        f.ok(CombatCommand::NextTurn); // R2 cleric
894        f.ok(CombatCommand::NextTurn); // R2 orc
895        assert!(f.state.effect(bless).is_some());
896        let events = f.ok(CombatCommand::NextTurn); // R3 cleric
897        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        // Regression: an effect nobody cast ended at the start of a round,
907        // whoever's turn it was when it was put down.
908        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); // round 1, B's turn
915        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); // round 2, A's turn
931        assert!(
932            f.state.effect(fire).is_some(),
933            "lasts until B's turn comes round"
934        );
935        let events = f.ok(CombatCommand::NextTurn); // round 2, B's turn
936        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); // round 2 starts
957        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}