Skip to main content

edda_types/
encounter.rs

1//! Planned encounters and how hard they are.
2//!
3//! A plan names monsters and how many of each, and the characters it's
4//! balanced for. Its difficulty follows the campaign's edition:
5//!
6//! - **2014** (DMG, "Creating a Combat Encounter"): the monsters' XP,
7//!   multiplied for how many there are, against each character's
8//!   Easy/Medium/Hard/Deadly threshold, summed over the party.
9//! - **2024** (DMG, "Combat Encounters"): the monsters' XP, as is, against
10//!   each character's Low/Moderate/High budget, summed over the party.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::{Edition, monster::challenge_value};
17
18/// A stored plan: server-assigned metadata plus the plan itself.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21pub struct Encounter {
22    pub id: Uuid,
23    pub campaign: Uuid,
24    #[serde(flatten)]
25    pub plan: EncounterPlan,
26    pub version: u64,
27    pub created_at: DateTime<Utc>,
28    pub updated_at: DateTime<Utc>,
29}
30
31/// A fight planned ahead: its monsters, who of the party it's for, and
32/// notes.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35#[serde(default)]
36pub struct EncounterPlan {
37    pub name: String,
38    pub monsters: Vec<PlannedMonster>,
39    /// The characters it's balanced for; empty means the whole party.
40    pub party: Vec<Uuid>,
41    pub notes: String,
42}
43
44/// Some number of one monster.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47pub struct PlannedMonster {
48    /// In the monster service.
49    pub monster: Uuid,
50    /// Its name when planned, shown even if it's since been deleted.
51    pub name: String,
52    pub count: u16,
53    /// Experience points each.
54    pub xp: u32,
55    /// Roll their hit points when the fight starts, instead of the maximum.
56    #[serde(default)]
57    pub roll: bool,
58}
59
60impl EncounterPlan {
61    /// How many monsters, all entries counted.
62    pub fn monster_count(&self) -> u32 {
63        self.monsters.iter().map(|m| u32::from(m.count)).sum()
64    }
65
66    /// The monsters' experience points, before any multiplier.
67    pub fn xp(&self) -> u32 {
68        self.monsters
69            .iter()
70            .map(|m| m.xp.saturating_mul(u32::from(m.count)))
71            .sum()
72    }
73}
74
75/// Experience points for a challenge rating ("1/4", "5"), for stat blocks
76/// that don't say; 0 for one that isn't a rating.
77pub fn xp_for_challenge(challenge: &str) -> u32 {
78    const BY_CR: [u32; 31] = [
79        10, 200, 450, 700, 1100, 1800, 2300, 2900, 3900, 5000, 5900, 7200, 8400, 10000, 11500,
80        13000, 15000, 18000, 20000, 22000, 25000, 33000, 41000, 50000, 62000, 75000, 90000,
81        105_000, 120_000, 135_000, 155_000,
82    ];
83    match challenge_value(challenge) {
84        cr if cr < 0.0 => 0,
85        cr if cr >= 1.0 => BY_CR.get(whole(cr)).copied().unwrap_or(0),
86        cr if cr >= 0.5 => 100,
87        cr if cr >= 0.25 => 50,
88        cr if cr >= 0.125 => 25,
89        _ => 10,
90    }
91}
92
93/// The whole part of a rating of at least 1.
94#[expect(
95    clippy::cast_possible_truncation,
96    clippy::cast_sign_loss,
97    reason = "ratings are small and positive here"
98)]
99fn whole(rating: f32) -> usize {
100    rating as usize
101}
102
103/// How hard an encounter is for a party, under one edition's rules.
104#[derive(Debug, Clone, PartialEq)]
105pub struct Difficulty {
106    pub edition: Edition,
107    /// The monsters' XP as compared with the thresholds: multiplied for
108    /// their number under the 2014 rules, as is under 2024's.
109    pub xp: u32,
110    /// For 2014, the multiplier used; 1 under 2024.
111    pub multiplier: f32,
112    /// The party's thresholds, lowest first, with their names.
113    pub thresholds: Vec<(&'static str, u32)>,
114    /// The highest threshold reached, if any.
115    pub rating: Option<&'static str>,
116}
117
118/// 2014: Easy, Medium, Hard and Deadly XP thresholds per character level.
119const THRESHOLDS_2014: [[u32; 4]; 20] = [
120    [25, 50, 75, 100],
121    [50, 100, 150, 200],
122    [75, 150, 225, 400],
123    [125, 250, 375, 500],
124    [250, 500, 750, 1100],
125    [300, 600, 900, 1400],
126    [350, 750, 1100, 1700],
127    [450, 900, 1400, 2100],
128    [550, 1100, 1600, 2400],
129    [600, 1200, 1900, 2800],
130    [800, 1600, 2400, 3600],
131    [1000, 2000, 3000, 4500],
132    [1100, 2200, 3400, 5100],
133    [1250, 2500, 3800, 5700],
134    [1400, 2800, 4300, 6400],
135    [1600, 3200, 4800, 7200],
136    [2000, 3900, 5900, 8800],
137    [2100, 4200, 6300, 9500],
138    [2400, 4900, 7300, 10900],
139    [2800, 5700, 8500, 12700],
140];
141
142/// 2024: Low, Moderate and High XP budgets per character level.
143const BUDGETS_2024: [[u32; 3]; 20] = [
144    [50, 75, 100],
145    [100, 150, 200],
146    [150, 225, 400],
147    [250, 375, 500],
148    [500, 750, 1100],
149    [600, 1000, 1400],
150    [750, 1300, 1700],
151    [1000, 1700, 2100],
152    [1300, 2000, 2600],
153    [1600, 2300, 3100],
154    [1900, 2900, 4100],
155    [2200, 3700, 4700],
156    [2600, 4200, 5400],
157    [2900, 4900, 6200],
158    [3300, 5400, 7800],
159    [3800, 6100, 9800],
160    [4500, 7200, 11700],
161    [5000, 8700, 14200],
162    [5500, 10700, 17200],
163    [6400, 13200, 22000],
164];
165
166/// 2014's encounter multipliers, by how many monsters (1, 2, 3–6, 7–10,
167/// 11–14, 15+), with one step either side for small and large parties.
168const MULTIPLIERS: [f32; 8] = [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
169
170fn multiplier(monsters: u32, party: usize) -> f32 {
171    let step = match monsters {
172        0 | 1 => 1,
173        2 => 2,
174        3..=6 => 3,
175        7..=10 => 4,
176        11..=14 => 5,
177        _ => 6,
178    };
179    // Fewer than three characters: the next multiplier up; six or more:
180    // the next down.
181    let step = match party {
182        0..=2 => step + 1,
183        3..=5 => step,
184        _ => step - 1,
185    };
186    MULTIPLIERS[step]
187}
188
189/// The party's thresholds: each character's, by level, summed.
190fn party_totals<const N: usize>(levels: &[u8], table: &[[u32; N]; 20]) -> [u32; N] {
191    let mut totals = [0; N];
192    for &level in levels {
193        let row = table[usize::from(level.clamp(1, 20)) - 1];
194        for (total, threshold) in totals.iter_mut().zip(row) {
195            *total += threshold;
196        }
197    }
198    totals
199}
200
201/// How hard `plan` is for characters of `levels`, under `edition`.
202pub fn difficulty(plan: &EncounterPlan, levels: &[u8], edition: Edition) -> Difficulty {
203    let (names, totals, multiplier): (&[&'static str], Vec<u32>, f32) = match edition {
204        Edition::Rules2014 => (
205            &["Easy", "Medium", "Hard", "Deadly"],
206            party_totals(levels, &THRESHOLDS_2014).to_vec(),
207            multiplier(plan.monster_count(), levels.len()),
208        ),
209        Edition::Rules2024 => (
210            &["Low", "Moderate", "High"],
211            party_totals(levels, &BUDGETS_2024).to_vec(),
212            1.0,
213        ),
214    };
215    #[expect(
216        clippy::cast_possible_truncation,
217        clippy::cast_sign_loss,
218        clippy::cast_precision_loss,
219        reason = "XP times a multiplier of at most 5 is positive, and far below \
220                  where f32 loses whole numbers"
221    )]
222    let xp = (plan.xp() as f32 * multiplier).round() as u32;
223    let thresholds: Vec<_> = names.iter().copied().zip(totals).collect();
224    let rating = thresholds
225        .iter()
226        .rev()
227        .find(|(_, threshold)| *threshold > 0 && xp >= *threshold)
228        .map(|(name, _)| *name);
229    Difficulty {
230        edition,
231        xp,
232        multiplier,
233        thresholds,
234        rating,
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn plan(monsters: &[(u16, u32)]) -> EncounterPlan {
243        EncounterPlan {
244            name: "Test".into(),
245            monsters: monsters
246                .iter()
247                .map(|&(count, xp)| PlannedMonster {
248                    monster: Uuid::new_v4(),
249                    name: "M".into(),
250                    count,
251                    xp,
252                    roll: false,
253                })
254                .collect(),
255            ..Default::default()
256        }
257    }
258
259    #[test]
260    fn challenge_ratings_give_experience_points() {
261        assert_eq!(xp_for_challenge("0"), 10);
262        assert_eq!(xp_for_challenge("1/8"), 25);
263        assert_eq!(xp_for_challenge("1/4"), 50);
264        assert_eq!(xp_for_challenge("1/2"), 100);
265        assert_eq!(xp_for_challenge("5"), 1800);
266        assert_eq!(xp_for_challenge("30"), 155_000);
267        assert_eq!(xp_for_challenge("lots"), 0);
268    }
269
270    #[test]
271    fn rules_2014_multiply_for_numbers_against_the_party_thresholds() {
272        // The DMG's example: four 3rd-level characters (Medium 600, Hard
273        // 900, Deadly 1600) against a bugbear and three hobgoblins:
274        // 200 + 3 * 100 = 500 XP, times 2 for four monsters = 1000: Hard.
275        let ambush = plan(&[(1, 200), (3, 100)]);
276        let d = difficulty(&ambush, &[3, 3, 3, 3], Edition::Rules2014);
277        assert_eq!(
278            d.thresholds,
279            [
280                ("Easy", 300),
281                ("Medium", 600),
282                ("Hard", 900),
283                ("Deadly", 1600)
284            ]
285        );
286        assert_eq!((d.multiplier, d.xp, d.rating), (2.0, 1000, Some("Hard")));
287        // Two characters: the next multiplier up.
288        let pair = difficulty(&ambush, &[3, 3], Edition::Rules2014);
289        assert_eq!((pair.multiplier, pair.xp), (2.5, 1250));
290        // Six: the next down; one monster for six is halved.
291        let lone = difficulty(&plan(&[(1, 200)]), &[1; 6], Edition::Rules2014);
292        assert_eq!((lone.multiplier, lone.xp, lone.rating), (0.5, 100, None));
293    }
294
295    #[test]
296    fn rules_2024_compare_plain_xp_with_the_party_budget() {
297        // Four 5th-level characters: Low 2000, Moderate 3000, High 4400.
298        let ogres = plan(&[(2, 1100)]);
299        let d = difficulty(&ogres, &[5, 5, 5, 5], Edition::Rules2024);
300        assert_eq!(
301            d.thresholds,
302            [("Low", 2000), ("Moderate", 3000), ("High", 4400)]
303        );
304        assert_eq!((d.multiplier, d.xp, d.rating), (1.0, 2200, Some("Low")));
305        let more = difficulty(&plan(&[(5, 1100)]), &[5, 5, 5, 5], Edition::Rules2024);
306        assert_eq!(more.rating, Some("High"));
307        let none = difficulty(&ogres, &[], Edition::Rules2024);
308        assert_eq!(none.rating, None, "no party, no rating");
309    }
310}