Skip to content

Instantly share code, notes, and snippets.

@niezbop
Created September 20, 2023 08:31
Show Gist options
  • Save niezbop/bcd89d7caba6b037fd9874140b9ff618 to your computer and use it in GitHub Desktop.
Save niezbop/bcd89d7caba6b037fd9874140b9ff618 to your computer and use it in GitHub Desktop.
Pathfinder 2e: simulating the impact of incremental AC on damage reduction
# 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