1use std::collections::BTreeMap;
11
12use crate::{CharacterSheet, ClassLevel, Edition, PactMagic, Pool};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum Progression {
17 Full,
19 Half,
21 Artificer,
23 Third,
25 Pact,
27}
28
29fn progression(class: &ClassLevel) -> Option<Progression> {
32 let name = class.name.trim().to_lowercase();
33 let subclass = class.subclass.as_deref().unwrap_or("").to_lowercase();
34 match name.as_str() {
35 "bard" | "cleric" | "druid" | "sorcerer" | "wizard" => Some(Progression::Full),
36 "paladin" | "ranger" => Some(Progression::Half),
37 "artificer" => Some(Progression::Artificer),
38 "warlock" => Some(Progression::Pact),
39 "fighter" if subclass.contains("eldritch knight") => Some(Progression::Third),
40 "rogue" if subclass.contains("arcane trickster") => Some(Progression::Third),
41 _ => None,
42 }
43}
44
45const SLOTS: [[u16; 9]; 20] = [
48 [2, 0, 0, 0, 0, 0, 0, 0, 0],
49 [3, 0, 0, 0, 0, 0, 0, 0, 0],
50 [4, 2, 0, 0, 0, 0, 0, 0, 0],
51 [4, 3, 0, 0, 0, 0, 0, 0, 0],
52 [4, 3, 2, 0, 0, 0, 0, 0, 0],
53 [4, 3, 3, 0, 0, 0, 0, 0, 0],
54 [4, 3, 3, 1, 0, 0, 0, 0, 0],
55 [4, 3, 3, 2, 0, 0, 0, 0, 0],
56 [4, 3, 3, 3, 1, 0, 0, 0, 0],
57 [4, 3, 3, 3, 2, 0, 0, 0, 0],
58 [4, 3, 3, 3, 2, 1, 0, 0, 0],
59 [4, 3, 3, 3, 2, 1, 0, 0, 0],
60 [4, 3, 3, 3, 2, 1, 1, 0, 0],
61 [4, 3, 3, 3, 2, 1, 1, 0, 0],
62 [4, 3, 3, 3, 2, 1, 1, 1, 0],
63 [4, 3, 3, 3, 2, 1, 1, 1, 0],
64 [4, 3, 3, 3, 2, 1, 1, 1, 1],
65 [4, 3, 3, 3, 3, 1, 1, 1, 1],
66 [4, 3, 3, 3, 3, 2, 1, 1, 1],
67 [4, 3, 3, 3, 3, 2, 2, 1, 1],
68];
69
70fn caster_level(classes: &[ClassLevel], edition: Edition) -> u8 {
72 let casters: Vec<(Progression, u8)> = classes
73 .iter()
74 .filter_map(|c| progression(c).map(|p| (p, c.level)))
75 .filter(|(p, _)| *p != Progression::Pact)
76 .collect();
77 let level = match casters.as_slice() {
78 [(progression, level)] => {
82 let level = *level;
83 match progression {
84 Progression::Full => level,
85 Progression::Half if edition == Edition::Rules2014 && level < 2 => 0,
86 Progression::Half | Progression::Artificer => level.div_ceil(2),
87 Progression::Third if level < 3 => 0,
88 Progression::Third => level.div_ceil(3),
89 Progression::Pact => 0,
90 }
91 }
92 many => many
95 .iter()
96 .map(|&(progression, level)| match progression {
97 Progression::Full => level,
98 Progression::Half if edition == Edition::Rules2014 => level / 2,
99 Progression::Half | Progression::Artificer => level.div_ceil(2),
100 Progression::Third => level / 3,
101 Progression::Pact => 0,
102 })
103 .sum(),
104 };
105 level.min(20)
106}
107
108fn pact_slots(classes: &[ClassLevel]) -> Option<(u8, u16)> {
110 let level: u8 = classes
111 .iter()
112 .filter(|c| progression(c) == Some(Progression::Pact))
113 .map(|c| c.level)
114 .sum();
115 let count = match level {
116 0 => return None,
117 1 => 1,
118 2..=10 => 2,
119 11..=16 => 3,
120 _ => 4,
121 };
122 Some((level.div_ceil(2).min(5), count))
123}
124
125impl CharacterSheet {
126 pub fn derive_slots(&mut self, edition: Edition) {
130 let row = match caster_level(&self.classes, edition) {
131 0 => [0; 9],
132 level => SLOTS[usize::from(level) - 1],
133 };
134 let casting = &mut self.spellcasting;
135 let old = std::mem::take(&mut casting.slots);
136 casting.slots = (1u8..)
137 .zip(row)
138 .filter(|&(_, max)| max > 0)
139 .map(|(level, max)| {
140 let used = old.get(&level).map_or(0, |p| p.used.min(max));
141 (level, Pool { max, used })
142 })
143 .collect::<BTreeMap<_, _>>();
144 casting.pact_magic = pact_slots(&self.classes).map(|(slot_level, max)| {
145 let used = casting.pact_magic.map_or(0, |p| p.slots.used.min(max));
146 PactMagic {
147 slot_level,
148 slots: Pool { max, used },
149 }
150 });
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn class(name: &str, level: u8) -> ClassLevel {
159 ClassLevel {
160 name: name.into(),
161 subclass: None,
162 level,
163 spellcasting_ability: None,
164 }
165 }
166
167 fn sub(name: &str, subclass: &str, level: u8) -> ClassLevel {
168 ClassLevel {
169 subclass: Some(subclass.into()),
170 ..class(name, level)
171 }
172 }
173
174 fn slots(classes: &[ClassLevel], edition: Edition) -> Vec<u16> {
176 let mut sheet = CharacterSheet {
177 classes: classes.to_vec(),
178 ..Default::default()
179 };
180 sheet.derive_slots(edition);
181 sheet.spellcasting.slots.values().map(|p| p.max).collect()
182 }
183
184 use Edition::{Rules2014 as E14, Rules2024 as E24};
185
186 #[test]
187 fn single_class_casters_use_their_own_table() {
188 assert_eq!(slots(&[class("Wizard", 1)], E24), [2]);
189 assert_eq!(slots(&[class("Cleric", 5)], E24), [4, 3, 2]);
190 assert_eq!(
191 slots(&[class("Druid", 20)], E14),
192 [4, 3, 3, 3, 3, 2, 2, 1, 1]
193 );
194 assert_eq!(slots(&[class("Ranger", 5)], E24), [4, 2]);
195 assert_eq!(slots(&[class("Paladin", 3)], E14), [3]);
196 assert_eq!(slots(&[class("Paladin", 20)], E24), [4, 3, 3, 3, 2]);
197 assert_eq!(slots(&[sub("Fighter", "Eldritch Knight", 7)], E24), [4, 2]);
198 assert_eq!(slots(&[sub("Rogue", "Arcane Trickster", 3)], E14), [2]);
199 assert!(slots(&[sub("Fighter", "Champion", 7)], E24).is_empty());
200 assert!(slots(&[class("Barbarian", 10)], E24).is_empty());
201 assert!(
202 slots(&[class("Blood Hunter", 10)], E24).is_empty(),
203 "homebrew"
204 );
205 }
206
207 #[test]
208 fn the_editions_differ_on_when_half_casters_start() {
209 assert!(slots(&[class("Ranger", 1)], E14).is_empty());
210 assert_eq!(slots(&[class("Ranger", 1)], E24), [2]);
211 assert_eq!(slots(&[class("Artificer", 1)], E14), [2], "always from 1st");
212 assert!(slots(&[sub("Fighter", "Eldritch Knight", 2)], E24).is_empty());
213 }
214
215 #[test]
216 fn multiclass_casters_share_one_pool_from_their_combined_level() {
217 assert_eq!(
219 slots(&[class("Wizard", 3), class("Cleric", 2)], E24),
220 [4, 3, 2]
221 );
222 let paladin_sorcerer = [class("Paladin", 5), class("Sorcerer", 3)];
224 assert_eq!(slots(&paladin_sorcerer, E14), [4, 3, 2]);
225 assert_eq!(slots(&paladin_sorcerer, E24), [4, 3, 3]);
226 let knight_wizard = [sub("Fighter", "Eldritch Knight", 5), class("Wizard", 1)];
228 assert_eq!(slots(&knight_wizard, E24), [3]);
229 assert_eq!(slots(&[class("Paladin", 1), class("Wizard", 1)], E14), [2]);
232 assert_eq!(
234 slots(&[class("Wizard", 3), class("Fighter", 5)], E24),
235 [4, 2]
236 );
237 }
238
239 #[test]
240 fn warlock_pact_magic_stays_separate() {
241 let mut sheet = CharacterSheet {
242 classes: vec![class("Warlock", 5), class("Sorcerer", 3)],
243 ..Default::default()
244 };
245 sheet.derive_slots(E24);
246 let pact = sheet.spellcasting.pact_magic.unwrap();
247 assert_eq!((pact.slot_level, pact.slots.max), (3, 2));
248 let shared: Vec<_> = sheet.spellcasting.slots.values().map(|p| p.max).collect();
249 assert_eq!(shared, [4, 2], "the Sorcerer's own table");
250
251 sheet.classes = vec![class("Warlock", 11)];
252 sheet.derive_slots(E24);
253 let pact = sheet.spellcasting.pact_magic.unwrap();
254 assert_eq!((pact.slot_level, pact.slots.max), (5, 3));
255 assert!(sheet.spellcasting.slots.is_empty());
256
257 sheet.classes = vec![class("Fighter", 1)];
258 sheet.derive_slots(E24);
259 assert_eq!(sheet.spellcasting.pact_magic, None);
260 }
261
262 #[test]
263 fn spent_slots_stay_spent_up_to_the_new_number() {
264 let mut sheet = CharacterSheet {
265 classes: vec![class("Wizard", 5)],
266 ..Default::default()
267 };
268 sheet.derive_slots(E24);
269 sheet.spellcasting.slots.get_mut(&1).unwrap().used = 4;
270 sheet.spellcasting.slots.get_mut(&3).unwrap().used = 1;
271 sheet.spellcasting.slots.insert(9, Pool::new(5));
273
274 sheet.classes[0].level = 1;
275 sheet.derive_slots(E24);
276 assert_eq!(
277 sheet.spellcasting.slots,
278 BTreeMap::from([(1, Pool { max: 2, used: 2 })])
279 );
280 sheet.classes[0].level = 5;
281 sheet.derive_slots(E24);
282 assert_eq!(sheet.spellcasting.slots[&1], Pool { max: 4, used: 2 });
283 assert_eq!(sheet.spellcasting.slots[&3], Pool { max: 2, used: 0 });
284 }
285}