1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::{Edition, monster::challenge_value};
17
18#[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#[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 pub party: Vec<Uuid>,
41 pub notes: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
47pub struct PlannedMonster {
48 pub monster: Uuid,
50 pub name: String,
52 pub count: u16,
53 pub xp: u32,
55 #[serde(default)]
57 pub roll: bool,
58}
59
60impl EncounterPlan {
61 pub fn monster_count(&self) -> u32 {
63 self.monsters.iter().map(|m| u32::from(m.count)).sum()
64 }
65
66 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
75pub 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#[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#[derive(Debug, Clone, PartialEq)]
105pub struct Difficulty {
106 pub edition: Edition,
107 pub xp: u32,
110 pub multiplier: f32,
112 pub thresholds: Vec<(&'static str, u32)>,
114 pub rating: Option<&'static str>,
116}
117
118const 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
142const 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
166const 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 let step = match party {
182 0..=2 => step + 1,
183 3..=5 => step,
184 _ => step - 1,
185 };
186 MULTIPLIERS[step]
187}
188
189fn 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
201pub 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 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 let pair = difficulty(&ambush, &[3, 3], Edition::Rules2014);
289 assert_eq!((pair.multiplier, pair.xp), (2.5, 1250));
290 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 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}