Skip to content
{{ message }}

Instantly share code, notes, and snippets.

# jeremyf/probability_swn_stabilize.rake

Last active Dec 28, 2020
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), "Round 1" => sprintf("%.3f", rounds), "Round 2" => sprintf("%.3f", rounds), "Round 3" => sprintf("%.3f", rounds), "Round 4" => sprintf("%.3f", rounds), "Round 5" => sprintf("%.3f", rounds) } 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
to join this conversation on GitHub. Already have an account? Sign in to comment