Created
September 20, 2023 08:31
-
-
Save niezbop/bcd89d7caba6b037fd9874140b9ff618 to your computer and use it in GitHub Desktop.
Pathfinder 2e: simulating the impact of incremental AC on damage reduction
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# Results at https://docs.google.com/spreadsheets/d/1UTI_vAOxolvXNJzb4RLoP-jfdYI5wBJFph7WYs3tvxM?usp=sharing | |
require 'csv' | |
class Die | |
attr_accessor :size | |
def initialize(size) | |
raise ArgumentError, "Die size must be an integer" unless size.kind_of? Integer | |
raise ArgumentError, "Die size can't be zero" if size == 0 | |
@size = size | |
@probabilities = (1..size).map { |i| [i, (1.0 / size) ] }.to_h | |
end | |
def probability_of(value) | |
return 0 unless probabilities.has_key?(value) | |
return probabilities[value] | |
end | |
def probability_greater_than(value) | |
return 0 if value > size | |
[1.0, (value..size).reduce(0) { |prob, i| prob + probability_of(i) }].min | |
end | |
private | |
attr_accessor :probabilities | |
end | |
# From https://2e.aonprd.com/Rules.aspx?ID=1017 | |
STRIKE_ATTACK_BONUS = { | |
0 => [10, 8, 6, 4], | |
1 => [11, 9, 7, 5], | |
2 => [13, 11, 9, 7], | |
3 => [14, 12, 10, 8], | |
4 => [16, 14, 12, 9], | |
5 => [17, 15, 13, 11], | |
6 => [19, 17, 15, 12], | |
7 => [20, 18, 16, 13], | |
8 => [22, 20, 18, 15], | |
9 => [23, 21, 19, 16], | |
10 => [25, 23, 21, 17], | |
11 => [27, 24, 22, 19], | |
12 => [28, 26, 24, 20], | |
13 => [29, 27, 25, 21], | |
14 => [31, 29, 27, 23], | |
15 => [32, 30, 28, 24], | |
16 => [34, 32, 30, 25], | |
17 => [35, 33, 31, 27], | |
18 => [37, 35, 33, 28], | |
19 => [38, 36, 34, 29], | |
20 => [40, 38, 36, 31] | |
}.freeze | |
FLAT_WEIGHTS = [0, 1, 1, 1].freeze | |
REAL_WEIGHTS = [1, 2, 4, 4].freeze | |
arguments = ARGV | |
if arguments.size < 2 | |
puts "Usage: calculate_damage_reduction_from_increment.rb CURRENT_AC LEVEL <INCREMENT:1>" | |
exit -1 | |
end | |
def has_flag?(arguments, *flags) | |
!flags.reduce([]) {|output, flag| output << arguments.delete(flag) }.compact.empty? | |
end | |
VERBOSE = has_flag?(arguments, '-v', '--verbose').freeze | |
WEIGHTED = has_flag?(arguments, '-w', '--weighted').freeze | |
RAW = has_flag?(arguments, '-r', '--raw').freeze | |
GENERATE_MATRIX = has_flag?(arguments, '-g', '--generate').freeze | |
def verbose(*message) | |
puts message if VERBOSE | |
end | |
current_ac = arguments[0].to_i.freeze | |
level = arguments[1].to_i.freeze | |
increment = (arguments[2].nil? ? 1 : arguments[2]).to_i.freeze | |
unless STRIKE_ATTACK_BONUS.has_key?(level) | |
puts "Level #{level} not supported" | |
exit -1 | |
end | |
D20 = Die.new(20).freeze | |
def probability_of_hit(ac, strike) | |
D20.probability_greater_than(ac - strike) | |
end | |
def probability_of_crit(ac, strike) | |
# Crit in PF2e is either successful nat20 hit or AC+10 hit | |
# Union probability is P(A ∪ B) = P(A) + P(B) - P(A ∩ B) | |
# P(A ∩ B) is the probability of successful hit on a nat20 roll, and AC+10 hit (we can omit successful hit as it is a weaker condition than AC+10 hit) | |
D20.probability_of(20) * (strike + 20 >= ac ? 1 : 0) + # P(A) is the probability of a successful hit on a nat20 roll | |
probability_of_hit(ac + 10, strike) - # P(B) is the probability of a AC+10 hit | |
D20.probability_of(20) * (strike + 20 >= ac + 10 ? 1 : 0) # P(A ∩ B) is the probability of successful hit on a nat20 roll, and AC+10 hit (we can omit successful hit as it is a weaker condition than AC+10 hit) | |
end | |
def average_reduction_from_increment_at_level(ac, increment, level, metric, weights = FLAT_WEIGHTS) | |
verbose("+-- #{metric}") | |
strike_bonuses = STRIKE_ATTACK_BONUS[level] | |
strike_range = (strike_bonuses.last..strike_bonuses.first).to_a | |
range_weights = strike_range.map do |strike| | |
# Get the weights of the two closest neighbours | |
neighbouring_weights = strike_bonuses.each_with_index.map do |bonus, index| | |
distance = (bonus - strike).abs | |
weight = weights[index] | |
[weight, distance, bonus] | |
end | |
.sort_by { |w,d,_b| d } | |
.first(2) | |
total_distance = neighbouring_weights.map { |_w,d,_b| d }.sum | |
next neighbouring_weights.map { |w,d| w * (total_distance - d) }.sum.to_f / total_distance | |
end | |
average = strike_range.zip(range_weights).map do |strike, weight| | |
verbose("+ STRIKE BONUS +#{strike}") | |
before_increment = send(metric, ac, strike) | |
verbose("#{metric} AC #{ac}: #{before_increment}") | |
after_increment = send(metric, ac + increment, strike) | |
verbose("#{metric} AC #{ac + increment}: #{after_increment}") | |
flat_reduction = before_increment - after_increment | |
weighted_reduction = flat_reduction * weight | |
verbose("Reduction: #{flat_reduction} (weighted to #{weighted_reduction})") | |
weighted_reduction | |
end.sum(0.0) / range_weights.sum | |
average | |
end | |
weights = WEIGHTED ? REAL_WEIGHTS : FLAT_WEIGHTS | |
average_hit_chance_reduction = average_reduction_from_increment_at_level(current_ac, increment, level, :probability_of_hit, weights) | |
average_crit_chance_reduction = average_reduction_from_increment_at_level(current_ac, increment, level, :probability_of_crit, weights) | |
puts RAW ? average_hit_chance_reduction : "#{(average_hit_chance_reduction * 100.0).round}% average hit chance reduction" | |
puts RAW ? average_crit_chance_reduction : "#{(average_crit_chance_reduction * 100.0).round}% average crit chance reduction" | |
exit unless GENERATE_MATRIX | |
AC_RANGE = (10..50).freeze | |
LEVELS = (1..20).freeze | |
to_generate = { | |
hit_chance: { metrics: [:probability_of_hit] }, | |
crit_chance: { metrics: [:probability_of_crit] }, | |
damage_reduction: { metrics: [:probability_of_hit, :probability_of_crit] } | |
} | |
begin | |
# Setup CSV | |
to_generate.each do |key, config| | |
config[:csv_file] = CSV.open("#{key}_matrix.csv", 'w', col_sep: ';') | |
config[:csv_file] << LEVELS.to_a.unshift('AC \ Level') # Header | |
end | |
# Iterate through AC rows | |
AC_RANGE.each do |ac| | |
to_generate.each do |_key, config| | |
level_values = LEVELS.map do |level| | |
metric_values = config[:metrics].map { |metric| average_reduction_from_increment_at_level(ac, increment, level, metric, weights) } | |
next (metric_values.sum * 100.0).round(2).to_s.gsub('.', ',') | |
end | |
config[:csv_file] << level_values.unshift(ac) | |
end | |
end | |
ensure | |
to_generate.each { |_key, config| config[:csv_file].close unless config[:csv_file].nil? } | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment