Skip to main content

edda_types/
slots.rs

1//! How many spell slots a character's classes give.
2//!
3//! Slots aren't entered by hand: they follow from the classes. A character
4//! with one spellcasting class uses that class's own table. With several,
5//! each class adds to a combined caster level (full casters their level,
6//! half and third casters a share of it) and the multiclass table gives one
7//! shared pool, usable for any of the classes' spells. Warlock Pact Magic
8//! is separate either way: its slots come from Warlock levels alone.
9
10use std::collections::BTreeMap;
11
12use crate::{CharacterSheet, ClassLevel, Edition, PactMagic, Pool};
13
14/// How a class gains spell slots.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16enum Progression {
17    /// Bard, Cleric, Druid, Sorcerer, Wizard.
18    Full,
19    /// Paladin, Ranger.
20    Half,
21    /// Artificer: half, but from 1st level and rounded up.
22    Artificer,
23    /// Eldritch Knight Fighters and Arcane Trickster Rogues.
24    Third,
25    /// Warlock: Pact Magic instead of spell slots.
26    Pact,
27}
28
29/// Classes are recognised by name (and for third casters, subclass);
30/// others, homebrew included, give no slots.
31fn 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
45/// Slots per slot level (1st to 9th) by caster level (1 to 20); the same
46/// in both editions.
47const 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
70/// The caster level whose row of [`SLOTS`] the classes get.
71fn 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        // One class: its own table. Half and third casters' tables match the
79        // shared one at their level divided and rounded up, from the level
80        // they start casting at.
81        [(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        // Several: a combined caster level. Paladins and Rangers count half
93        // their levels, rounded down in 2014 and up in 2024.
94        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
108/// Warlock levels give Pact Magic: (slot level, number of slots).
109fn 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    /// Sets the spell slots (and Pact Magic) the classes give, under the
127    /// campaign's `edition`. Slots already spent stay spent, up to the new
128    /// number.
129    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    /// Slot counts from 1st level up, as derived.
175    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        // Wizard 3 + Cleric 2: a 5th-level caster.
218        assert_eq!(
219            slots(&[class("Wizard", 3), class("Cleric", 2)], E24),
220            [4, 3, 2]
221        );
222        // Paladin 5 counts 2 in 2014 (round down), 3 in 2024 (round up).
223        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        // Third casters round down when multiclassing.
227        let knight_wizard = [sub("Fighter", "Eldritch Knight", 5), class("Wizard", 1)];
228        assert_eq!(slots(&knight_wizard, E24), [3]);
229        // A level that adds nothing still makes it a multiclass: Paladin 1
230        // counts 0 in 2014, leaving the Wizard's 1.
231        assert_eq!(slots(&[class("Paladin", 1), class("Wizard", 1)], E14), [2]);
232        // Classes without spells don't count.
233        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        // Hand-entered slots the classes don't give are dropped.
272        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}