D&D 5e Encounter Difficulty Calculator
0
encounter_difficulty.exs
323 lines 9.1 kB view raw
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())