1use super::{CombatCommand, CombatError, CombatEvent, CombatState, Combatant, Dice, decide};
8use crate::monster::Monster;
9
10pub 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
50pub 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 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}