Skip to main content

edda_types/combat/
view.rs

1//! What a player's device sees of a combat. The party knows each other's
2//! hit points, but monsters' (and anyone else the DM adds) stay behind the
3//! DM's screen: a player sees only how hurt one looks ([`Vitality`]), and
4//! not its armor class.
5//!
6//! The combat service conceals the snapshot, the log and every event it
7//! sends to a player's device ([`Concealer`]). Concealed hit points still
8//! go up and down in steps (3 unhurt, 2 hurt, 1 bloodied, 0 down), so a
9//! client applies concealed events like any others, and "is it down?"
10//! means the same.
11
12use std::{collections::HashSet, fmt};
13
14use uuid::Uuid;
15
16use super::{CombatEvent, CombatState, Combatant, LoggedEvent};
17use crate::HitPoints;
18
19/// How hurt a combatant looks, without the numbers.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
21pub enum Vitality {
22    Down,
23    /// At half its hit points or fewer.
24    Bloodied,
25    Hurt,
26    Unhurt,
27}
28
29impl Vitality {
30    /// How hurt someone with these hit points looks.
31    pub fn of(hp: HitPoints) -> Self {
32        match hp.current {
33            0 => Vitality::Down,
34            current if u32::from(current) * 2 <= u32::from(hp.max) => Vitality::Bloodied,
35            current if current < hp.max => Vitality::Hurt,
36            _ => Vitality::Unhurt,
37        }
38    }
39
40    /// Concealed hit points: only this, in steps.
41    pub fn hit_points(self) -> HitPoints {
42        HitPoints {
43            max: 3,
44            current: self as u16,
45            temporary: 0,
46        }
47    }
48
49    /// What concealed hit points say.
50    pub fn from_concealed(hp: HitPoints) -> Self {
51        match hp.current {
52            0 => Vitality::Down,
53            1 => Vitality::Bloodied,
54            2 => Vitality::Hurt,
55            _ => Vitality::Unhurt,
56        }
57    }
58}
59
60impl fmt::Display for Vitality {
61    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
62        f.write_str(match self {
63            Vitality::Down => "down",
64            Vitality::Bloodied => "bloodied",
65            Vitality::Hurt => "hurt",
66            Vitality::Unhurt => "unhurt",
67        })
68    }
69}
70
71/// Whether a player's device sees only how hurt `combatant` looks: anyone
72/// who isn't a party character.
73fn behind_the_screen(combatant: &Combatant) -> bool {
74    combatant.character_id.is_none()
75}
76
77fn conceal(combatant: &mut Combatant) {
78    if behind_the_screen(combatant) && !combatant.concealed {
79        combatant.hit_points = Vitality::of(combatant.hit_points).hit_points();
80        combatant.armor_class = None;
81        combatant.concealed = true;
82    }
83}
84
85/// Conceals a combat for a player's device: its state, then the events
86/// that follow it. It remembers who's behind the screen, so an event about
87/// a combatant who has since left is concealed too.
88pub struct Concealer {
89    hidden: HashSet<Uuid>,
90}
91
92impl Concealer {
93    /// For a combat as it stands in `state` (not concealed yet).
94    pub fn new(state: &CombatState) -> Self {
95        let mut concealer = Self {
96            hidden: HashSet::new(),
97        };
98        concealer.learn(state.combatants.iter());
99        concealer
100    }
101
102    fn learn<'c>(&mut self, combatants: impl Iterator<Item = &'c Combatant>) {
103        self.hidden
104            .extend(combatants.filter(|c| behind_the_screen(c)).map(|c| c.id));
105    }
106
107    /// `state` as a player's device sees it.
108    pub fn state(&self, state: &CombatState) -> CombatState {
109        let mut state = state.clone();
110        state.combatants.iter_mut().for_each(conceal);
111        state
112    }
113
114    /// `event` as a player's device sees it.
115    pub fn event(&mut self, event: &LoggedEvent) -> LoggedEvent {
116        let mut event = event.clone();
117        match &mut event.event {
118            CombatEvent::CombatantAdded { combatant }
119            | CombatEvent::CombatantRestored { combatant } => {
120                self.learn(std::iter::once(&*combatant));
121                conceal(combatant);
122            }
123            CombatEvent::Damaged {
124                combatant,
125                hit_points,
126                ..
127            }
128            | CombatEvent::Healed {
129                combatant,
130                hit_points,
131                ..
132            }
133            | CombatEvent::TemporaryHitPointsSet {
134                combatant,
135                hit_points,
136            } if self.hidden.contains(combatant) => {
137                *hit_points = Vitality::of(*hit_points).hit_points();
138            }
139            _ => {}
140        }
141        event
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use chrono::Utc;
148
149    use super::*;
150
151    fn logged(seq: u64, event: CombatEvent) -> LoggedEvent {
152        LoggedEvent {
153            seq,
154            command_id: None,
155            actor: None,
156            at: Utc::now(),
157            event,
158        }
159    }
160
161    #[test]
162    fn players_see_how_hurt_monsters_look_and_everything_about_the_party() {
163        use Vitality::{Bloodied, Down, Hurt, Unhurt};
164
165        let mut goblin = Combatant::new("Goblin", 12);
166        goblin.armor_class = Some(15);
167        let mut vex = Combatant::new("Vex", 30);
168        vex.character_id = Some(Uuid::new_v4());
169        let mut state = CombatState::default();
170        state.apply(&CombatEvent::CombatantAdded {
171            combatant: vex.clone(),
172        });
173        let mut concealer = Concealer::new(&state);
174        let mut seen = concealer.state(&state);
175
176        let hit = |current| HitPoints {
177            max: 12,
178            current,
179            temporary: 0,
180        };
181        let events = [
182            CombatEvent::CombatantAdded {
183                combatant: goblin.clone(),
184            },
185            CombatEvent::Damaged {
186                combatant: goblin.id,
187                amount: 3,
188                hit_points: hit(9),
189            },
190            CombatEvent::Damaged {
191                combatant: goblin.id,
192                amount: 3,
193                hit_points: hit(6),
194            },
195            CombatEvent::Damaged {
196                combatant: vex.id,
197                amount: 4,
198                hit_points: HitPoints {
199                    max: 30,
200                    current: 26,
201                    temporary: 0,
202                },
203            },
204            CombatEvent::CombatantRemoved {
205                combatant: goblin.id,
206            },
207            CombatEvent::CombatantRestored {
208                combatant: Combatant {
209                    hit_points: hit(1),
210                    ..goblin.clone()
211                },
212            },
213            CombatEvent::Damaged {
214                combatant: goblin.id,
215                amount: 1,
216                hit_points: hit(0),
217            },
218        ];
219        let mut looks = Vec::new();
220        for (seq, event) in events.into_iter().enumerate() {
221            let shown = concealer.event(&logged(seq as u64, event));
222            seen.apply(&shown.event);
223            if let Some(g) = seen.combatant(goblin.id) {
224                assert!(g.concealed && g.armor_class.is_none());
225                looks.push(g.vitality());
226            }
227        }
228        assert_eq!(looks, [Unhurt, Hurt, Bloodied, Bloodied, Bloodied, Down]);
229        assert!(seen.combatant(goblin.id).unwrap().is_down());
230        let vex_seen = seen.combatant(vex.id).unwrap();
231        assert!(!vex_seen.concealed);
232        assert_eq!(vex_seen.hit_points.current, 26);
233    }
234
235    #[test]
236    fn bloodied_is_half_or_fewer() {
237        let hp = |current, max| HitPoints {
238            max,
239            current,
240            temporary: 0,
241        };
242        assert_eq!(Vitality::of(hp(7, 7)), Vitality::Unhurt);
243        assert_eq!(Vitality::of(hp(4, 7)), Vitality::Hurt);
244        assert_eq!(Vitality::of(hp(3, 7)), Vitality::Bloodied);
245        assert_eq!(Vitality::of(hp(0, 7)), Vitality::Down);
246        for v in [
247            Vitality::Down,
248            Vitality::Bloodied,
249            Vitality::Hurt,
250            Vitality::Unhurt,
251        ] {
252            assert_eq!(Vitality::from_concealed(v.hit_points()), v);
253        }
254    }
255}