Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
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