Class: FifthedSim::Distribution

Inherits:
Object
  • Object
show all
Defined in:
lib/fifthed_sim/distribution.rb

Overview

Models a probabilistic distribution.

Constant Summary collapse

COMPARE_EPSILON =
0.00001

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(map) ⇒ Distribution

We initialize class with a map of results to occurences, and a total number of possible different occurences. Generally, you will not ever initialize this yourself.



34
35
36
37
38
39
40
# File 'lib/fifthed_sim/distribution.rb', line 34

def initialize(map)
  keys = map.keys
  @max = keys.max
  @min = keys.min
  @map = map.dup
  @map.default = 0
end

Instance Attribute Details

#maxObject (readonly)

Returns the value of attribute max.



42
43
44
# File 'lib/fifthed_sim/distribution.rb', line 42

def max
  @max
end

#minObject (readonly)

Returns the value of attribute min.



42
43
44
# File 'lib/fifthed_sim/distribution.rb', line 42

def min
  @min
end

#total_possibleObject (readonly)

Returns the value of attribute total_possible.



42
43
44
# File 'lib/fifthed_sim/distribution.rb', line 42

def total_possible
  @total_possible
end

Class Method Details

.for(obj) ⇒ Object



20
21
22
23
24
25
26
27
28
29
# File 'lib/fifthed_sim/distribution.rb', line 20

def self.for(obj)
  case obj
  when Fixnum
    self.for_number(obj)
  when Range
    self.for_range(obj)
  else
    raise ArgumentError, "can't amke a distribution for that"
  end
end

.for_number(num) ⇒ Object

Get a distrubtion for a number. This will be a uniform distribution with P = 1 at this number and P = 0 elsewhere.



10
11
12
# File 'lib/fifthed_sim/distribution.rb', line 10

def self.for_number(num)
  self.new({num => 1.0})
end

.for_range(rng) ⇒ Object



14
15
16
17
18
# File 'lib/fifthed_sim/distribution.rb', line 14

def self.for_range(rng)
  size = rng.size.to_f
  e = 1.0 / size
  self.new(Hash[rng.map{|x| [x, e]}])
end

Instance Method Details

#==(other) ⇒ Object



246
247
248
249
250
251
252
253
254
# File 'lib/fifthed_sim/distribution.rb', line 246

def ==(other)
  omap = other.map
  max_possible = (@max / other.min)
  same_keys = (Set.new(@map.keys) == Set.new(omap.keys))
  same_vals = @map.keys.each do |k|
    (@map[k] - other.map[k]).abs <= COMPARE_EPSILON
  end
  same_keys && same_vals
end

#averageObject



55
56
57
# File 'lib/fifthed_sim/distribution.rb', line 55

def average
  map.map{|k, v| k * v}.inject(:+)
end

#convolve(other) ⇒ Object



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/fifthed_sim/distribution.rb', line 166

def convolve(other)
  h = {}
  abs_min = [@min, other.min].min
  abs_max = [@max, other.max].max
  min_possible = @min + other.min
  max_possible = @max + other.max
  # TODO: there has to be a less stupid way to do this right?
  v = min_possible.upto(max_possible).map do |val|
    sum = abs_min.upto(abs_max).map do |m|
      percent_exactly(m) * other.percent_exactly(val - m)
    end.inject(:+)
    [val, sum]
  end
  self.class.new(Hash[v])
end

#convolve_divide(other) ⇒ Object

Get the distribution of a result from this distribution divided by one from another distribution. If the other distribution may contain zero this will break horribly.



198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/fifthed_sim/distribution.rb', line 198

def convolve_divide(other)
  throw ArgumentError, "Divisor may be zero" if other.min < 1
  h = Hash.new{|h, k| h[k] = 0}
  # We can do this faster using a sieve, but be lazy for now
  # TODO: Be less lazy
  range.each do |v1|
    other.range.each do |v2|
      h[v1 / v2] += percent_exactly(v1) * other.percent_exactly(v2)
    end
  end
  self.class.new(h)
end

#convolve_greater(other) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
# File 'lib/fifthed_sim/distribution.rb', line 222

def convolve_greater(other)
  h = Hash.new{|h, k| h[k] = 0}
  # for each value
  range.each do |s|
    (s..other.max).each do |e|
      h[e] += (other.percent_exactly(e) * percent_exactly(s))
    end
    h[s] += (other.percent_lower(s) * percent_exactly(s))
  end
  self.class.new(h)
end

#convolve_least(other) ⇒ Object



234
235
236
237
238
239
240
241
242
243
# File 'lib/fifthed_sim/distribution.rb', line 234

def convolve_least(other)
  h = Hash.new{|h, k| h[k] = 0}
  range.each do |s|
    (other.min..s).each do |e|
      h[e] += (other.percent_exactly(e) * percent_exactly(s))
    end
    h[s] += (other.percent_greater(s + 1) * percent_exactly(s))
  end
  self.class.new(h)
end

#convolve_multiply(other) ⇒ Object



211
212
213
214
215
216
217
218
219
# File 'lib/fifthed_sim/distribution.rb', line 211

def convolve_multiply(other)
  h = Hash.new{|h, k| h[k] = 0}
  range.each do |v1|
    other.range.each do |v2|
      h[v1 * v2] += percent_exactly(v1) * other.percent_exactly(v2)
    end
  end
  self.class.new(h)
end

#convolve_subtract(other) ⇒ Object

TODO: Optimize this



184
185
186
187
188
189
190
191
192
# File 'lib/fifthed_sim/distribution.rb', line 184

def convolve_subtract(other)
  h = Hash.new{|h, k| h[k] = 0}
  range.each do |v1|
    other.range.each do |v2|
      h[v1 - v2] += percent_exactly(v1) * other.percent_exactly(v2)
    end
  end
  self.class.new(h)
end

#hit_when(other, &block) ⇒ Object

Obtain a new distribution of values. When block.call(value) for this distribution is true, we will allow values from the second distribution. Otherwise, the value will be zero.

This is mostly used in hit calculation - AKA, if we’re higher than an AC, then we hit, otherwise we do zero damage



66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/fifthed_sim/distribution.rb', line 66

def hit_when(other, &block)
  hit_prob = map.map do |k, v|
    if block.call(k)
      v
    else
      nil
    end
  end.compact.inject(:+)
  miss_prob = 1 - hit_prob
  omap = other.map
  h = Hash[omap.map{|k, v| [k, v * hit_prob]}]
  h[0] = (h[0] || 0) + miss_prob
  Distribution.new(h)
end

#mapObject



51
52
53
# File 'lib/fifthed_sim/distribution.rb', line 51

def map
  @map.dup
end

#percent_exactly(num) ⇒ Object



123
124
125
126
# File 'lib/fifthed_sim/distribution.rb', line 123

def percent_exactly(num)
  return 0 if num < @min || num > @max
  @map[num] || 0
end

#percent_greater(n) ⇒ Object



146
147
148
149
150
151
# File 'lib/fifthed_sim/distribution.rb', line 146

def percent_greater(n)
  num = n + 1
  return 0.0 if num > @max
  return 1.0 if num < @min
  num.upto(@max).map(&map_proc).inject(:+)
end

#percent_greater_equal(num) ⇒ Object



157
158
159
# File 'lib/fifthed_sim/distribution.rb', line 157

def percent_greater_equal(num)
  percent_greater(num - 1)
end

#percent_lower(n) ⇒ Object



139
140
141
142
143
144
# File 'lib/fifthed_sim/distribution.rb', line 139

def percent_lower(n)
  num = n - 1
  return 0.0 if num < @min
  return 1.0 if num > @max
  @min.upto(num).map(&map_proc).inject(:+)
end

#percent_lower_equal(num) ⇒ Object Also known as: percentile_of



153
154
155
# File 'lib/fifthed_sim/distribution.rb', line 153

def percent_lower_equal(num)
  percent_lower(num + 1)
end

#percent_where(&block) ⇒ Object



116
117
118
119
120
121
# File 'lib/fifthed_sim/distribution.rb', line 116

def percent_where(&block)
  @map.to_a
    .keep_if{|(k, v)| block.call(k)}
    .map{|(k, v)| v}
    .inject(:+)
end

#percent_within(range) ⇒ Object



112
113
114
# File 'lib/fifthed_sim/distribution.rb', line 112

def percent_within(range)
  percent_where{|x| range.contains? x}
end

#rangeObject



47
48
49
# File 'lib/fifthed_sim/distribution.rb', line 47

def range
  (@min..@max)
end

#results_when(&block) ⇒ Object

Takes a block or callable object. This function will call the callable with all possible outcomes of this distribution. The callable should return another distribution, representing the possible values when this possibility happens. This will then return a value of those possibilities.

An example is probably helpful here. Let’s consider the case where a monster with +0 to hit is attacking a creature with AC 16 for 1d4 damage, and crits on a 20. If we want a distribution of possible outcomes of this attack, we can do:

1.d(20).distribution.results_when do |x|
  if x < 16
    Distribution.for_number(0)
  elseif x < 20
    1.d(4).distribution
  else
    2.d(4).distribution
  end
end


100
101
102
103
104
105
106
107
108
109
110
# File 'lib/fifthed_sim/distribution.rb', line 100

def results_when(&block)
  h = Hash.new{|h, k| h[k] = 0}
  range.each do |v|
    prob = @map[v]
    o_dist = block.call(v)
    o_dist.map.each do |k, v|
      h[k] += (v * prob)
    end
  end
  Distribution.new(h)
end

#std_devObject



135
136
137
# File 'lib/fifthed_sim/distribution.rb', line 135

def std_dev
  Math.sqrt(variance)
end

#text_histogram(cols = 60) ⇒ Object



256
257
258
259
260
261
262
263
# File 'lib/fifthed_sim/distribution.rb', line 256

def text_histogram(cols = 60)
  max_width = @max.to_s.length
  justwidth = max_width + 1
  linewidth = (cols - justwidth)
  range.map do |v|
    "#{v}:".rjust(justwidth) + ("*" * (percent_exactly(v) * linewidth))
  end.join("\n")
end

#varianceObject



128
129
130
131
132
133
# File 'lib/fifthed_sim/distribution.rb', line 128

def variance
  avg = average
  @map.map do |k, v|
    ((k - avg)**2) * v
  end.inject(:+)
end