Skip to main content

edda_types/combat/
mod.rs

1//! Combat encounters, modelled with event sourcing.
2//!
3//! Clients send [`CombatCommand`]s (intents). [`decide`] turns a command into
4//! [`CombatEvent`]s (facts), and [`CombatState::apply`] folds events into the
5//! current state. The server is authoritative: it alone decides events and
6//! fixes their order in the log. Because both functions live here, clients
7//! run them too, showing the outcome of their own commands immediately and
8//! reconciling when the server's events arrive.
9
10mod cost;
11mod monsters;
12mod permission;
13mod protocol;
14mod rules;
15pub mod undo;
16pub mod view;
17
18pub use cost::SheetCost;
19pub use monsters::{add_monsters, monster_names};
20pub use permission::player_may;
21pub use protocol::{
22    ClientMessage, CombatSnapshot, CombatSummary, CommandEnvelope, LoggedEvent, ServerMessage,
23};
24pub use rules::{CombatCommand, CombatError, Dice, NewEffect, NoDice, decide};
25pub use view::{Concealer, Vitality};
26
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30use crate::HitPoints;
31
32/// Where a combat stands: the result of applying its log, event by event.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35pub struct CombatState {
36    pub name: String,
37    /// The campaign this combat belongs to.
38    #[serde(default)]
39    pub campaign: String,
40    pub status: CombatStatus,
41    /// 0 until the combat starts.
42    pub round: u32,
43    /// Whose turn it is.
44    pub current_turn: Option<Uuid>,
45    /// Always kept in initiative order; combatants without initiative last.
46    pub combatants: Vec<Combatant>,
47    pub effects: Vec<Effect>,
48}
49
50/// Whether a combat is being set up, running, paused or over.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "snake_case")]
54pub enum CombatStatus {
55    /// Adding combatants and rolling initiative.
56    #[default]
57    Preparing,
58    Active,
59    /// Interrupted, e.g. at the end of a session; resumes exactly where it
60    /// left off, however much later.
61    Paused,
62    Ended,
63}
64
65/// Anyone in a combat: a character, a monster, or anyone else the DM adds.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct Combatant {
69    /// Chosen by the client that adds the combatant, so it can predict the
70    /// outcome exactly. The server rejects duplicates.
71    pub id: Uuid,
72    pub name: String,
73    /// Set when this combatant is a character from the character service.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub character_id: Option<Uuid>,
76    /// Set when this combatant is a monster from the monster service, so
77    /// its stat block can be shown.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub monster_id: Option<Uuid>,
80    #[serde(default)]
81    pub initiative_modifier: i8,
82    /// Initiative total. `None` until rolled or set.
83    #[serde(default)]
84    pub initiative: Option<i16>,
85    /// While [`Self::concealed`], only how hurt it looks: see [`Vitality`].
86    pub hit_points: HitPoints,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub armor_class: Option<u8>,
89    /// Its hit points and armor class are behind the DM's screen: a
90    /// player's device sees monsters like this (see [`view`]).
91    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
92    pub concealed: bool,
93}
94
95impl Combatant {
96    /// A combatant at full hit points, with no initiative yet.
97    pub fn new(name: impl Into<String>, max_hp: u16) -> Self {
98        Self {
99            id: Uuid::new_v4(),
100            name: name.into(),
101            character_id: None,
102            monster_id: None,
103            initiative_modifier: 0,
104            initiative: None,
105            hit_points: HitPoints {
106                max: max_hp,
107                current: max_hp,
108                temporary: 0,
109            },
110            armor_class: None,
111            concealed: false,
112        }
113    }
114
115    /// A combatant for a character sheet, using its current HP, AC and
116    /// Dexterity modifier.
117    pub fn from_character(character: &crate::Character) -> Self {
118        let sheet = &character.sheet;
119        Self {
120            id: Uuid::new_v4(),
121            name: sheet.name.clone(),
122            character_id: Some(character.id),
123            monster_id: None,
124            initiative_modifier: sheet.ability_scores.modifier(crate::Ability::Dexterity),
125            initiative: None,
126            hit_points: sheet.hit_points,
127            armor_class: Some(sheet.armor_class),
128            concealed: false,
129        }
130    }
131
132    /// A combatant for a monster, called `name` (e.g. "Goblin 2"), with
133    /// `hit_points` (its maximum, or rolled).
134    pub fn from_monster(monster: &crate::monster::Monster, name: String, hit_points: u16) -> Self {
135        let sheet = &monster.sheet;
136        Self {
137            id: Uuid::new_v4(),
138            name,
139            character_id: None,
140            monster_id: Some(monster.id),
141            initiative_modifier: sheet.initiative_modifier(),
142            initiative: None,
143            hit_points: HitPoints {
144                max: hit_points,
145                current: hit_points,
146                temporary: 0,
147            },
148            armor_class: Some(sheet.armor_class),
149            concealed: false,
150        }
151    }
152
153    /// Whether it's at 0 hit points.
154    pub fn is_down(&self) -> bool {
155        self.hit_points.current == 0
156    }
157
158    /// How hurt it looks, concealed or not.
159    pub fn vitality(&self) -> Vitality {
160        if self.concealed {
161            Vitality::from_concealed(self.hit_points)
162        } else {
163            Vitality::of(self.hit_points)
164        }
165    }
166}
167
168/// Anything in play with a duration or a target: spells, conditions, auras.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
171pub struct Effect {
172    pub id: Uuid,
173    pub name: String,
174    /// Who created it (e.g. the caster).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub source: Option<Uuid>,
177    #[serde(default)]
178    pub targets: Vec<Uuid>,
179    /// A creature can concentrate on one effect at a time, and loses
180    /// concentration when dropped to 0 HP.
181    #[serde(default)]
182    pub concentration: bool,
183    /// `None` lasts until removed.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub expires: Option<Expiry>,
186}
187
188/// When an effect ends: at the start of `at_turn_of`'s turn in `round`, or
189/// at the start of `round` if there's no such combatant (5e durations run
190/// from the caster's turn; for an effect nobody cast, from the turn it was
191/// put down in).
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
194pub struct Expiry {
195    pub round: u32,
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub at_turn_of: Option<Uuid>,
198}
199
200/// Something that happened in a combat. Events are facts: they carry
201/// outcomes (the rolled number, the resulting HP) so replaying the log never
202/// needs to re-roll or re-decide anything.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
205#[serde(tag = "type", rename_all = "snake_case")]
206pub enum CombatEvent {
207    Created {
208        name: String,
209        #[serde(default)]
210        campaign: String,
211    },
212    CombatantAdded {
213        combatant: Combatant,
214    },
215    CombatantRemoved {
216        combatant: Uuid,
217    },
218    InitiativeRolled {
219        combatant: Uuid,
220        roll: u8,
221        modifier: i8,
222        total: i16,
223    },
224    InitiativeSet {
225        combatant: Uuid,
226        total: i16,
227    },
228    Started,
229    RoundStarted {
230        round: u32,
231    },
232    TurnStarted {
233        combatant: Uuid,
234    },
235    Damaged {
236        combatant: Uuid,
237        amount: u32,
238        hit_points: HitPoints,
239    },
240    Healed {
241        combatant: Uuid,
242        amount: u32,
243        hit_points: HitPoints,
244    },
245    TemporaryHitPointsSet {
246        combatant: Uuid,
247        hit_points: HitPoints,
248    },
249    EffectAdded {
250        effect: Effect,
251    },
252    EffectEnded {
253        effect: Uuid,
254        reason: EffectEndReason,
255    },
256    /// A record only: the slot was spent on the caster's sheet.
257    SpellCast {
258        caster: Uuid,
259        spell: String,
260        #[serde(default, skip_serializing_if = "Option::is_none")]
261        slot: Option<SpellSlot>,
262        #[serde(default)]
263        targets: Vec<Uuid>,
264    },
265    /// A record; the combat service spends the use on the character's
266    /// sheet before logging it.
267    FeatureUsed {
268        combatant: Uuid,
269        feature: String,
270        amount: u16,
271    },
272    Paused,
273    Resumed,
274    Ended,
275    /// The command `command` was undone. The events after it, from the
276    /// same undo, put back what that command changed.
277    Undone {
278        command: Uuid,
279    },
280    /// A combatant put back as they were (or with their own command's
281    /// change reverted), joining again if they had left.
282    CombatantRestored {
283        combatant: Combatant,
284    },
285    /// An effect put back, in play again if it had ended.
286    EffectRestored {
287        effect: Effect,
288    },
289    /// Status, round and turn set back.
290    ProgressRestored {
291        status: CombatStatus,
292        round: u32,
293        current_turn: Option<Uuid>,
294    },
295}
296
297/// The slot a spell was cast with: a regular slot of some level, or a
298/// Pact Magic slot (whose level is fixed).
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
301#[serde(rename_all = "snake_case")]
302pub enum SpellSlot {
303    Level(u8),
304    Pact(u8),
305}
306
307impl SpellSlot {
308    /// The slot's level, regular or pact.
309    pub fn level(self) -> u8 {
310        match self {
311            SpellSlot::Level(level) | SpellSlot::Pact(level) => level,
312        }
313    }
314}
315
316/// Why an effect ended.
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
319#[serde(rename_all = "snake_case")]
320pub enum EffectEndReason {
321    Removed,
322    Expired,
323    /// The source started concentrating on something else.
324    ConcentrationReplaced,
325    /// The source dropped to 0 HP.
326    ConcentrationBroken,
327    /// The source left the combat.
328    SourceRemoved,
329    /// The command that added it was undone.
330    Undone,
331}
332
333impl CombatState {
334    /// Rebuilds a combat from its full event log.
335    pub fn replay<'a>(events: impl IntoIterator<Item = &'a CombatEvent>) -> Self {
336        let mut state = Self::default();
337        for event in events {
338            state.apply(event);
339        }
340        state
341    }
342
343    /// The combatant with `id`, if it's in the combat.
344    pub fn combatant(&self, id: Uuid) -> Option<&Combatant> {
345        self.combatants.iter().find(|c| c.id == id)
346    }
347
348    /// The effect with `id`, if it's in play.
349    pub fn effect(&self, id: Uuid) -> Option<&Effect> {
350        self.effects.iter().find(|e| e.id == id)
351    }
352
353    /// Combatants who take turns, in turn order.
354    pub fn turn_order(&self) -> impl Iterator<Item = &Combatant> {
355        self.combatants.iter().filter(|c| c.initiative.is_some())
356    }
357
358    /// Applies an event. Events are facts already validated by [`decide`],
359    /// so this never fails; references to unknown ids are ignored.
360    pub fn apply(&mut self, event: &CombatEvent) {
361        match event {
362            CombatEvent::Created { name, campaign } => {
363                self.name.clone_from(name);
364                self.campaign.clone_from(campaign);
365            }
366            CombatEvent::CombatantAdded { combatant } => {
367                self.combatants.push(combatant.clone());
368                self.sort();
369            }
370            CombatEvent::CombatantRemoved { combatant } => {
371                self.combatants.retain(|c| c.id != *combatant);
372                for effect in &mut self.effects {
373                    effect.targets.retain(|t| t != combatant);
374                }
375                if self.current_turn == Some(*combatant) {
376                    self.current_turn = None;
377                }
378            }
379            CombatEvent::InitiativeRolled {
380                combatant, total, ..
381            }
382            | CombatEvent::InitiativeSet { combatant, total } => {
383                if let Some(c) = self.combatant_mut(*combatant) {
384                    c.initiative = Some(*total);
385                }
386                self.sort();
387            }
388            CombatEvent::Started | CombatEvent::Resumed => self.status = CombatStatus::Active,
389            CombatEvent::RoundStarted { round } => self.round = *round,
390            CombatEvent::TurnStarted { combatant } => self.current_turn = Some(*combatant),
391            CombatEvent::Damaged {
392                combatant,
393                hit_points,
394                ..
395            }
396            | CombatEvent::Healed {
397                combatant,
398                hit_points,
399                ..
400            }
401            | CombatEvent::TemporaryHitPointsSet {
402                combatant,
403                hit_points,
404            } => {
405                if let Some(c) = self.combatant_mut(*combatant) {
406                    c.hit_points = *hit_points;
407                }
408            }
409            CombatEvent::EffectAdded { effect } => self.effects.push(effect.clone()),
410            CombatEvent::EffectEnded { effect, .. } => self.effects.retain(|e| e.id != *effect),
411            CombatEvent::SpellCast { .. }
412            | CombatEvent::FeatureUsed { .. }
413            | CombatEvent::Undone { .. } => {}
414            CombatEvent::Paused => self.status = CombatStatus::Paused,
415            CombatEvent::Ended => {
416                self.status = CombatStatus::Ended;
417                self.current_turn = None;
418            }
419            CombatEvent::CombatantRestored { combatant } => {
420                match self.combatant_mut(combatant.id) {
421                    Some(c) => *c = combatant.clone(),
422                    None => self.combatants.push(combatant.clone()),
423                }
424                self.sort();
425            }
426            CombatEvent::EffectRestored { effect } => {
427                match self.effects.iter_mut().find(|e| e.id == effect.id) {
428                    Some(e) => *e = effect.clone(),
429                    None => self.effects.push(effect.clone()),
430                }
431            }
432            CombatEvent::ProgressRestored {
433                status,
434                round,
435                current_turn,
436            } => {
437                self.status = *status;
438                self.round = *round;
439                self.current_turn = *current_turn;
440            }
441        }
442    }
443
444    fn combatant_mut(&mut self, id: Uuid) -> Option<&mut Combatant> {
445        self.combatants.iter_mut().find(|c| c.id == id)
446    }
447
448    /// Highest initiative first, ties to the higher modifier, then to
449    /// whoever joined first (the sort is stable). Deterministic, so clients
450    /// and server always agree on the order.
451    fn sort(&mut self) {
452        self.combatants.sort_by(|a, b| {
453            let key = |c: &Combatant| (c.initiative.is_some(), c.initiative, c.initiative_modifier);
454            key(b).cmp(&key(a))
455        });
456    }
457}