Skip to content

Instantly share code, notes, and snippets.

@jeremyf
Last active December 28, 2020 22:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jeremyf/6600e8d877ba0fabd0d12b5c6ab0f881 to your computer and use it in GitHub Desktop.
Save jeremyf/6600e8d877ba0fabd0d12b5c6ab0f881 to your computer and use it in GitHub Desktop.
Calculate the probability of stabilization with treatment
desc "Calculate the probability of stabilization with treatment"
task :probability_swn_stabilize do
# This class encapsulates the probability calculations based on
# the given :distribution.
class Universe
def initialize(label:, distribution:)
@label = label
@distribution = distribution
@max = distribution.keys.max
@size = distribution.values.sum.to_f
end
attr_reader :label
# Given the :modified_target, what is the chance of a result
# equal to or greater than that target?
#
# @param modified_target [Integer] What is the roll we're looking
# for?
#
# @return [Float] the chance, with a range between 0 and 1.
def chance_of_gt_or_eq_to(modified_target)
@distribution.slice(*(modified_target..@max)).values.sum /
@size
end
# Given the :modified_target, what is the chance of a result
# equal to exactly the modified target?
#
# @param modified_target [Integer] What is the roll we're looking
# for?
#
# @return [Float] the chance, with a range between 0 and 1.
def chance_of_exactly(modified_target)
@distribution.fetch(modified_target, 0) / @size
end
end
# Think to your Settlers of Catan board game. The "dot" on each
# of the number chits is the chance in 36 of rolling that number
# on 2d6.
universe_2d6 = Universe.new(
label: "2d6",
distribution: {
2 => 1, 3 => 2, 4 => 3,
5 => 4, 6 => 5, 7 => 6,
8 => 5, 9 => 4, 10 => 3,
11 => 2, 12 => 1
})
# This universe is the distribution of all possible rolls in which
# we throw 3 six-sided dice and keep the best 2 values.
universe_3d6 = Universe.new(
label: "3d6",
distribution: {
2 => 1, 3 => 3, 4 => 7,
5 => 12, 6 => 19, 7 => 27,
8 => 34, 9 => 36, 10 => 34,
11 => 27, 12 => 16
})
# This class encapsulates the probability
#
# A key assumption in helping is that all helpers are helping with
# their preferred skill check, and thus the modified_difficulty is
# the same as the person performing the primary test.
#
# @note I tested this using a coin toss universe distribution
# (e.g. heads or tails)
class Helper
# @param number [Integer] the number of helpers
#
# @param universe [Universe] the universe of possible dice
# results for each of the helpers
def initialize(number:, universe:)
@number = number
@universe = universe
end
attr_reader :number
# @param modified_difficulty [Integer] the modified dice roll that
# the helpers are trying to achieve.
#
# @return [Float] the probability that one of the helpers
# succeeds.
def chance_someone_succeeds_at(modified_difficulty)
success = @universe.chance_of_gt_or_eq_to(modified_difficulty)
accumulate(success: success)
end
private
def accumulate(success:, accumulator: 0, number: @number)
return accumulator if number <= 0
chance_of_next_helper = 1 - accumulator
accumulator += chance_of_next_helper * success
accumulate(
success: success,
number: number - 1,
accumulator: accumulator)
end
end
# Originally extracted as a parameter object (because the
# `success_probability_for_given_check_or_later` method had too
# many parameters), this object helps encapsulate the data used
# to calculate each round's probability of success.
class Check
PARAMETERS = [
:universe,
:modified_difficulty,
:reroll,
:chance_we_need_this_round,
:round,
:helpers
]
def initialize(**kwargs)
PARAMETERS.each do |param|
instance_variable_set("@#{param}", kwargs.fetch(param))
end
end
# @return [Universe] The universe of possible modified dice rolls.
attr_reader :universe
# @return [Integer] The check's Difficulty Class (DC) plus all of
# the modifiers (excluding those for rounds since dropping to 0
# HP) affecting the 2d6 roll.
#
# @note for a Heal-2 medic with a Dex of +1 using a Lazurus
# Patch (DC 6) would have a modified_difficulty of
# 3. (e.g. 6 - 2 - 1 = 3)
#
# @note for an untrained medic with a Dex of -1 using a Lazurus
# Patch (DC 6) would have a modified_difficulty of
# 3. (e.g. 6 - (-1) - (-1) = 9)
attr_reader :modified_difficulty
# @return [Boolean] True if we allow a reroll.
attr_reader :reroll
# @return [Float] The probability that we need this round, range
# between 0.0 and 1.0.
attr_reader :chance_we_need_this_round
# @return [Integer] The round in which we made a check
attr_reader :round
# @return [Helper] Who are the helpers for this task
attr_reader :helpers
def next(**kwargs)
new_kwargs = {}
PARAMETERS.each do |param|
new_kwargs[param] = kwargs.fetch(param) do
instance_variable_get("@#{param}")
end
end
self.class.new(**new_kwargs)
end
# @return [Float]
def probability_of_success_this_round
universe.chance_of_gt_or_eq_to(modified_difficulty + round)
end
# @return [Float]
def chance_of_help_making_the_difference
universe.chance_of_exactly(modified_difficulty + round - 1) *
helpers.chance_someone_succeeds_at(modified_difficulty + round)
end
# @return [Float]
def probability_of_reroll_makes_difference(prob:)
return 0.0 unless reroll
(1 - prob) * prob
end
end
# @param check [Check] The current round's Check context.
#
# @param ttl [Integer] "Time to Live" - After this many rounds
# without treatment, a character dies.
#
# @return [Float] The probability of success in this round or all
# future rounds. Range between 0.0 and 1.0
#
# @note Per SWN rules, characters die after 6 rounds without
# treatment.
def success_probability_for_given_check_or_later(check, ttl: 6)
return 0 if check.round >= ttl
prob = check.probability_of_success_this_round
# Using the assumption that the best character is making the
# check, and everyone that could be helping is using their best
# check to help. With the assumption that everyone has the same
# modifier as the base check.
prob += check.chance_of_help_making_the_difference
# I believe the interaction of helpers and reroll is correct.
# We only attempt a re-roll if the helpers didn't succeed. And
# the re-roll has the same probability of success
prob += check.probability_of_reroll_makes_difference(prob: prob)
next_check = check.next(
round: check.round + 1,
reroll: false,
chance_we_need_this_round: (1 - prob)
)
check.chance_we_need_this_round * (
prob +
success_probability_for_given_check_or_later(next_check)
)
end
all_helpers = [
Helper.new(number: 0, universe: universe_2d6),
Helper.new(number: 1, universe: universe_2d6),
Helper.new(number: 2, universe: universe_2d6)
]
number_of_rounds = (0..5)
header_template = "| %-10s | %-4s | %7s | %6s" +
" | %5s" * number_of_rounds.size + " |"
divider_template = "|------------+------+---------+--------" +
("+-------" * number_of_rounds.size) + "|"
line_template = "| %-10s | %-4s | %-7d | %6s" +
" | %.3f" * number_of_rounds.size + " |"
header = sprintf(header_template, "Mod Diff", "Dice", "Helpers",
"Reroll",
*number_of_rounds.to_a.map {|i| "Rnd #{i}"})
puts header
puts divider_template
rows = []
(0..12).each do |modified_difficulty|
[universe_2d6, universe_3d6].each do |universe|
all_helpers.each do |helpers|
[false, true].each do |reroll|
rounds = number_of_rounds.map do |round|
check = Check.new(
reroll: reroll,
universe: universe,
modified_difficulty: modified_difficulty,
chance_we_need_this_round: 1.0,
helpers: helpers,
round: round
)
success_probability_for_given_check_or_later(check)
end
rows << {
"Modified Difficulty" => modified_difficulty,
"Dice Rolled" => universe.label,
"Helpers" => helpers.number,
"Reroll" => reroll,
"Round 0" => sprintf("%.3f", rounds[0]),
"Round 1" => sprintf("%.3f", rounds[1]),
"Round 2" => sprintf("%.3f", rounds[2]),
"Round 3" => sprintf("%.3f", rounds[3]),
"Round 4" => sprintf("%.3f", rounds[4]),
"Round 5" => sprintf("%.3f", rounds[5])
}
puts sprintf(line_template,
modified_difficulty,
universe.label,
helpers.number,
reroll,
*rounds)
end
end
end
end
# Below is used for writing a YAML file that I use to auto-generate
# the table for a blog post.
pwd = if defined?(TakeOnRules::PROJECT_PATH)
File.join(TakeOnRules::PROJECT_PATH, "data/probabilities")
else
Dir.pwd
end
column_names = [
"Modified Difficulty",
"Dice Rolled",
"Helpers",
"Reroll",
"Round 0",
"Round 1",
"Round 2",
"Round 3",
"Round 4",
"Round 5"
]
columns = []
column_names.each do |name|
columns << { "label" => name, "key" => name }
end
require 'psych'
File.open(File.join(pwd, "swn_stabilize.yml"), "w+") do |file|
file.puts(
Psych.dump(
"name" => 'Probability of Stabilization in SWN',
"columns" => columns,
"rows" => rows
)
)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment