Skip to main content

edda_types/combat/
monsters.rs

1//! Adding monsters: what [`super::CombatCommand::AddMonsters`] does, once
2//! the server has the monster's stat block. They're named and numbered
3//! here, and their hit points rolled with the server's dice, so every
4//! frontend adds them the same way and two DMs adding goblins at once
5//! can't both make a "Goblin 3".
6
7use super::{CombatCommand, CombatError, CombatEvent, CombatState, Combatant, Dice, decide};
8use crate::monster::Monster;
9
10/// The events that add `count` of `monster` to the combat, at their
11/// maximum hit points or, with `roll`, rolled.
12pub fn add_monsters(
13    state: &CombatState,
14    monster: &Monster,
15    count: u16,
16    roll: bool,
17    dice: &mut dyn Dice,
18) -> Result<Vec<CombatEvent>, CombatError> {
19    if count == 0 {
20        return Err(CombatError::NothingToAdd);
21    }
22    let mut state = state.clone();
23    let mut events = Vec::new();
24    for name in monster_names(&monster.sheet.name, count, &state) {
25        let hit_points = match (roll, monster.sheet.hit_dice()) {
26            (true, Some(hit_dice)) => {
27                let mut unavailable = false;
28                let rolled = hit_dice.roll(|sides| {
29                    dice.roll(sides).unwrap_or_else(|| {
30                        unavailable = true;
31                        1
32                    })
33                });
34                if unavailable {
35                    return Err(CombatError::DiceUnavailable);
36                }
37                rolled
38            }
39            _ => monster.sheet.max_hit_points(),
40        };
41        let combatant = Combatant::from_monster(monster, name, hit_points);
42        for event in decide(&state, &CombatCommand::AddCombatant { combatant }, dice)? {
43            state.apply(&event);
44            events.push(event);
45        }
46    }
47    Ok(events)
48}
49
50/// Names for `count` more of a monster: just its name for the only one in
51/// the fight, otherwise numbered on from any already there ("Goblin 2").
52pub fn monster_names(base: &str, count: u16, state: &CombatState) -> Vec<String> {
53    let mut plain = false;
54    let mut highest = 0;
55    for combatant in &state.combatants {
56        if combatant.name == base {
57            plain = true;
58        } else if let Some(n) = combatant
59            .name
60            .strip_prefix(base)
61            .and_then(|rest| rest.strip_prefix(' '))
62            .and_then(|n| n.parse::<u16>().ok())
63        {
64            highest = highest.max(n);
65        }
66    }
67    if count == 1 && !plain && highest == 0 {
68        return vec![base.to_string()];
69    }
70    let first = highest.max(u16::from(plain)) + 1;
71    (first..first + count)
72        .map(|n| format!("{base} {n}"))
73        .collect()
74}
75
76#[cfg(test)]
77mod tests {
78    use chrono::Utc;
79    use uuid::Uuid;
80
81    use super::*;
82    use crate::{
83        combat::{CombatStatus, NoDice},
84        monster::MonsterSheet,
85    };
86
87    /// Dice that always roll `0`'s number.
88    struct Always(u16);
89
90    impl Dice for Always {
91        fn roll(&mut self, _sides: u16) -> Option<u16> {
92            Some(self.0)
93        }
94    }
95
96    fn goblin() -> Monster {
97        Monster {
98            id: Uuid::new_v4(),
99            campaign: None,
100            sheet: MonsterSheet {
101                name: "Goblin".into(),
102                hit_points: 7,
103                hit_dice: "2d6".into(),
104                ..Default::default()
105            },
106            version: 1,
107            created_at: Utc::now(),
108            updated_at: Utc::now(),
109        }
110    }
111
112    fn added(events: &[CombatEvent]) -> Vec<(String, u16)> {
113        events
114            .iter()
115            .filter_map(|e| match e {
116                CombatEvent::CombatantAdded { combatant } => {
117                    Some((combatant.name.clone(), combatant.hit_points.max))
118                }
119                _ => None,
120            })
121            .collect()
122    }
123
124    #[test]
125    fn monsters_join_numbered_at_their_maximum_or_rolled() {
126        let goblin = goblin();
127        let mut state = CombatState::default();
128        let events = add_monsters(&state, &goblin, 1, false, &mut NoDice).unwrap();
129        assert_eq!(added(&events), [("Goblin".to_string(), 12)]);
130        for e in &events {
131            state.apply(e);
132        }
133
134        let events = add_monsters(&state, &goblin, 2, true, &mut Always(3)).unwrap();
135        assert_eq!(
136            added(&events),
137            [("Goblin 2".to_string(), 6), ("Goblin 3".to_string(), 6)]
138        );
139        let combatant = |e: &CombatEvent| match e {
140            CombatEvent::CombatantAdded { combatant } => combatant.monster_id,
141            _ => None,
142        };
143        assert!(events.iter().all(|e| combatant(e) == Some(goblin.id)));
144    }
145
146    #[test]
147    fn rolling_needs_dice_and_some_must_be_added() {
148        let state = CombatState::default();
149        assert_eq!(
150            add_monsters(&state, &goblin(), 2, true, &mut NoDice),
151            Err(CombatError::DiceUnavailable)
152        );
153        assert_eq!(
154            add_monsters(&state, &goblin(), 0, false, &mut NoDice),
155            Err(CombatError::NothingToAdd)
156        );
157        let ended = CombatState {
158            status: CombatStatus::Ended,
159            ..Default::default()
160        };
161        assert_eq!(
162            add_monsters(&ended, &goblin(), 1, false, &mut NoDice),
163            Err(CombatError::Ended)
164        );
165    }
166
167    #[test]
168    fn monster_names_number_on_from_those_in_the_fight() {
169        let mut state = CombatState::default();
170        assert_eq!(monster_names("Rat", 1, &state), ["Rat"]);
171        assert_eq!(monster_names("Rat", 2, &state), ["Rat 1", "Rat 2"]);
172        for name in ["Goblin", "Goblin 4", "Goblin Boss"] {
173            state.apply(&CombatEvent::CombatantAdded {
174                combatant: Combatant::new(name, 7),
175            });
176        }
177        assert_eq!(monster_names("Goblin", 2, &state), ["Goblin 5", "Goblin 6"]);
178        assert_eq!(monster_names("Goblin Boss", 1, &state), ["Goblin Boss 2"]);
179    }
180}