D&D 5e Encounter Difficulty Calculator
0
encounter_difficulty.exs
1#!/usr/bin/env elixir
2
3defmodule EncounterDifficulty do
4 @moduledoc """
5 D&D 5e Encounter Difficulty Calculator
6
7 Calculates encounter difficulty based on party composition and monster CRs.
8 """
9
10 # XP Thresholds by Character Level: %{level => {easy, medium, hard, deadly}}
11 @xp_thresholds %{
12 1 => {25, 50, 75, 100},
13 2 => {50, 100, 150, 200},
14 3 => {75, 150, 225, 400},
15 4 => {125, 250, 375, 500},
16 5 => {250, 500, 750, 1100},
17 6 => {300, 600, 900, 1400},
18 7 => {350, 750, 1100, 1700},
19 8 => {450, 900, 1400, 2100},
20 9 => {550, 1100, 1600, 2400},
21 10 => {600, 1200, 1900, 2800},
22 11 => {800, 1600, 2400, 3600},
23 12 => {1000, 2000, 3000, 4500},
24 13 => {1100, 2200, 3400, 5100},
25 14 => {1250, 2500, 3800, 5700},
26 15 => {1400, 2800, 4300, 6400},
27 16 => {1600, 3200, 4800, 7200},
28 17 => {2000, 3900, 5900, 8800},
29 18 => {2100, 4200, 6300, 9500},
30 19 => {2400, 4900, 7300, 10900},
31 20 => {2800, 5700, 8500, 12700}
32 }
33
34 # XP by Challenge Rating
35 @cr_xp %{
36 "0" => 10,
37 "1/8" => 25,
38 "1/4" => 50,
39 "1/2" => 100,
40 "1" => 200,
41 "2" => 450,
42 "3" => 700,
43 "4" => 1100,
44 "5" => 1800,
45 "6" => 2300,
46 "7" => 2900,
47 "8" => 3900,
48 "9" => 5000,
49 "10" => 5900,
50 "11" => 7200,
51 "12" => 8400,
52 "13" => 10000,
53 "14" => 11500,
54 "15" => 13000,
55 "16" => 15000,
56 "17" => 18000,
57 "18" => 20000,
58 "19" => 22000,
59 "20" => 25000,
60 "21" => 33000,
61 "22" => 41000,
62 "23" => 50000,
63 "24" => 62000,
64 "25" => 75000,
65 "26" => 90000,
66 "27" => 105_000,
67 "28" => 120_000,
68 "29" => 135_000,
69 "30" => 155_000
70 }
71
72 @doc "Get the encounter multiplier based on number of monsters."
73 def get_multiplier(monster_count) do
74 cond do
75 monster_count == 1 -> 1.0
76 monster_count == 2 -> 1.5
77 monster_count in 3..6 -> 2.0
78 monster_count in 7..10 -> 2.5
79 monster_count in 11..14 -> 3.0
80 true -> 4.0
81 end
82 end
83
84 @doc "Normalize CR string for lookup."
85 def parse_cr(cr_str) do
86 cr_str = cr_str |> String.trim() |> String.downcase()
87
88 cond do
89 String.contains?(cr_str, "/") ->
90 cr_str
91
92 String.contains?(cr_str, ".") ->
93 case Float.parse(cr_str) do
94 {0.125, _} -> "1/8"
95 {0.25, _} -> "1/4"
96 {0.5, _} -> "1/2"
97 _ -> cr_str
98 end
99
100 true ->
101 cr_str
102 end
103 end
104
105 @doc """
106 Parse monster arguments in format 'count:CR' or 'CR' (assumes count=1).
107 Examples: '2:1', '1/4', '3:1/2', '5'
108 """
109 def parse_monsters(monster_args) do
110 Enum.map(monster_args, fn arg ->
111 {count, cr_str} = parse_monster_arg(arg)
112 cr = parse_cr(cr_str)
113
114 unless Map.has_key?(@cr_xp, cr) do
115 IO.puts(:stderr, "Error: Unknown CR '#{cr_str}'")
116 System.halt(1)
117 end
118
119 {count, cr}
120 end)
121 end
122
123 # TODO: Probably I can merge this with parse_count_value. I just need to consider the CR string case.
124 defp parse_monster_arg(arg) do
125 case String.split(arg, ":", parts: 2) do
126 [count, cr] -> {String.to_integer(count), cr}
127 [cr] -> {1, cr}
128 end
129 end
130
131 @doc """
132 Parse party arguments in format 'count:level' or 'level' (assumes count=1).
133 Examples: '2:5', '3', '1:10'
134 """
135 def parse_party(party_args) do
136 Enum.flat_map(party_args, fn arg ->
137 {count, level} = parse_count_value(arg)
138
139 if level < 1 or level > 20 do
140 IO.puts(:stderr, "Error: Invalid level #{level}. Must be 1-20.")
141 System.halt(1)
142 end
143
144 List.duplicate(level, count)
145 end)
146 end
147
148 defp parse_count_value(arg) do
149 case String.split(arg, ":", parts: 2) do
150 [count, value] ->
151 {String.to_integer(count), String.to_integer(value)}
152
153 [value] ->
154 {1, String.to_integer(value)}
155 end
156 end
157
158 @doc "Calculate total party thresholds for each difficulty."
159 def calculate_party_thresholds(party) do
160 Enum.reduce(party, {0, 0, 0, 0}, fn level, {easy, medium, hard, deadly} ->
161 {e, m, h, d} = @xp_thresholds[level]
162 {easy + e, medium + m, hard + h, deadly + d}
163 end)
164 end
165
166 @doc "Calculate encounter difficulty and XP."
167 def calculate_encounter(monsters, party) do
168 {total_monster_count, base_xp} =
169 Enum.reduce(monsters, {0, 0}, fn {count, cr}, {tc, xp} ->
170 {tc + count, xp + count * @cr_xp[cr]}
171 end)
172
173 multiplier = get_multiplier(total_monster_count)
174 adjusted_xp = trunc(base_xp * multiplier)
175
176 {easy, medium, hard, deadly} = calculate_party_thresholds(party)
177
178 difficulty =
179 cond do
180 adjusted_xp >= deadly -> "Deadly"
181 adjusted_xp >= hard -> "Hard"
182 adjusted_xp >= medium -> "Medium"
183 adjusted_xp >= easy -> "Easy"
184 true -> "Trivial"
185 end
186
187 xp_per_character = div(base_xp, length(party))
188
189 %{
190 difficulty: difficulty,
191 base_xp: base_xp,
192 adjusted_xp: adjusted_xp,
193 multiplier: multiplier,
194 xp_per_character: xp_per_character,
195 monster_count: total_monster_count,
196 party_size: length(party),
197 thresholds: %{
198 easy: easy,
199 medium: medium,
200 hard: hard,
201 deadly: deadly
202 }
203 }
204 end
205
206 def format_number(n) do
207 n
208 |> Integer.to_string()
209 |> String.reverse()
210 |> String.to_charlist()
211 |> Enum.chunk_every(3)
212 |> Enum.join(",")
213 |> String.reverse()
214 end
215
216 def print_usage do
217 IO.puts("""
218 D&D 5e Encounter Difficulty Calculator
219
220 Usage: elixir encounter_difficulty.exs -m MONSTERS -p PARTY
221
222 Options:
223 -m, --monsters Monsters in format 'count:CR' or just 'CR'. Examples: 2:1, 1/4, 3:1/2
224 -p, --party Party members in format 'count:level' or just 'level'. Examples: 2:5, 3, 1:10
225 -h, --help Show this help message
226
227 Examples:
228 elixir encounter_difficulty.exs -m 2:1 1:2 -p 2:2 1:3
229 2 CR 1 monsters and 1 CR 2 monster vs 2 level 2 and 1 level 3 characters
230
231 elixir encounter_difficulty.exs -m 1/4 1/4 1/2 -p 4:1
232 2 CR 1/4 and 1 CR 1/2 monster vs 4 level 1 characters
233
234 elixir encounter_difficulty.exs --monsters 3:5 --party 4:10
235 3 CR 5 monsters vs 4 level 10 characters
236 """)
237 end
238
239 def parse_args(args) do
240 case parse_args(args, %{monsters: [], party: [], current: nil}) do
241 :help ->
242 :help
243
244 %{monsters: monsters, party: party} = acc ->
245 %{acc | monsters: monsters, party: party}
246 end
247 end
248
249 defp parse_args([], acc), do: acc
250
251 defp parse_args(["-h" | _], _acc), do: :help
252 defp parse_args(["--help" | _], _acc), do: :help
253
254 defp parse_args(["-m" | rest], acc), do: parse_args(rest, %{acc | current: :monsters})
255 defp parse_args(["--monsters" | rest], acc), do: parse_args(rest, %{acc | current: :monsters})
256
257 defp parse_args(["-p" | rest], acc), do: parse_args(rest, %{acc | current: :party})
258 defp parse_args(["--party" | rest], acc), do: parse_args(rest, %{acc | current: :party})
259
260 defp parse_args([arg | rest], acc) do
261 case acc.current do
262 :monsters ->
263 parse_args(rest, %{acc | monsters: [arg | acc.monsters]})
264
265 :party ->
266 parse_args(rest, %{acc | party: [arg | acc.party]})
267
268 nil ->
269 IO.puts(:stderr, "Error: Unexpected argument '#{arg}'")
270 System.halt(1)
271 end
272 end
273
274 def main(args) do
275 case parse_args(args) do
276 :help ->
277 print_usage()
278
279 %{monsters: [], party: _} ->
280 IO.puts(:stderr, "Error: --monsters is required")
281 print_usage()
282 System.halt(1)
283
284 %{monsters: _, party: []} ->
285 IO.puts(:stderr, "Error: --party is required")
286 print_usage()
287 System.halt(1)
288
289 %{monsters: monster_args, party: party_args} ->
290 monsters = parse_monsters(monster_args)
291 party = parse_party(party_args)
292 result = calculate_encounter(monsters, party)
293
294 IO.puts("")
295 IO.puts(String.duplicate("=", 50))
296 IO.puts(" D&D 5e ENCOUNTER DIFFICULTY")
297 IO.puts(String.duplicate("=", 50))
298
299 IO.puts("\nParty: #{result.party_size} characters")
300 IO.puts("Monsters: #{result.monster_count} (x#{result.multiplier} multiplier)")
301
302 IO.puts("\n--- Party XP Thresholds ---")
303 IO.puts(" Easy: #{format_number(result.thresholds.easy)} XP")
304 IO.puts(" Medium: #{format_number(result.thresholds.medium)} XP")
305 IO.puts(" Hard: #{format_number(result.thresholds.hard)} XP")
306 IO.puts(" Deadly: #{format_number(result.thresholds.deadly)} XP")
307
308 IO.puts("\n--- Encounter ---")
309 IO.puts(" Base XP: #{format_number(result.base_xp)} XP")
310 IO.puts(" Adjusted XP: #{format_number(result.adjusted_xp)} XP (for difficulty)")
311
312 IO.puts("\n" <> String.duplicate("=", 50))
313 IO.puts(" DIFFICULTY: #{String.upcase(result.difficulty)}")
314 IO.puts(String.duplicate("=", 50))
315
316 IO.puts("\n Total XP Award: #{format_number(result.base_xp)} XP")
317 IO.puts(" XP per Character: #{format_number(result.xp_per_character)} XP")
318 IO.puts("")
319 end
320 end
321end
322
323EncounterDifficulty.main(System.argv())