fifthed_sim 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +0 -1
  3. data/HelpWanted.md +23 -0
  4. data/README.md +144 -5
  5. data/bin/console +2 -8
  6. data/bin/playground +42 -0
  7. data/exe/diceroll +9 -0
  8. data/fifthed_sim.gemspec +4 -0
  9. data/lib/fifthed_sim/actor.rb +80 -0
  10. data/lib/fifthed_sim/attack.rb +86 -0
  11. data/lib/fifthed_sim/calculated_fixnum.rb +57 -0
  12. data/lib/fifthed_sim/compiler/parser.rb +46 -0
  13. data/lib/fifthed_sim/compiler/transform.rb +28 -0
  14. data/lib/fifthed_sim/compiler.rb +51 -0
  15. data/lib/fifthed_sim/damage.rb +54 -0
  16. data/lib/fifthed_sim/damage_types.rb +39 -0
  17. data/lib/fifthed_sim/dice_expression.rb +134 -0
  18. data/lib/fifthed_sim/distribution.rb +219 -20
  19. data/lib/fifthed_sim/nodes/addition_node.rb +75 -0
  20. data/lib/fifthed_sim/nodes/block_node.rb +46 -0
  21. data/lib/fifthed_sim/nodes/division_node.rb +26 -0
  22. data/lib/fifthed_sim/nodes/greater_node.rb +41 -0
  23. data/lib/fifthed_sim/nodes/less_node.rb +46 -0
  24. data/lib/fifthed_sim/nodes/multi_node.rb +135 -0
  25. data/lib/fifthed_sim/nodes/multiplication_node.rb +25 -0
  26. data/lib/fifthed_sim/nodes/number_node.rb +38 -0
  27. data/lib/fifthed_sim/nodes/roll_node.rb +88 -0
  28. data/lib/fifthed_sim/nodes/subtraction_node.rb +24 -0
  29. data/lib/fifthed_sim/roll_repl.rb +117 -0
  30. data/lib/fifthed_sim/spell.rb +74 -0
  31. data/lib/fifthed_sim/stat.rb +49 -0
  32. data/lib/fifthed_sim/stat_block.rb +57 -0
  33. data/lib/fifthed_sim/version.rb +3 -1
  34. data/lib/fifthed_sim.rb +28 -4
  35. metadata +74 -8
  36. data/lib/fifthed_sim/dice_calculation.rb +0 -88
  37. data/lib/fifthed_sim/dice_result.rb +0 -108
  38. data/lib/fifthed_sim/die_roll.rb +0 -66
  39. data/lib/fifthed_sim/helpers/average_comparison.rb +0 -14
@@ -0,0 +1,54 @@
1
+ require_relative './damage_types'
2
+ require_relative './calculated_fixnum'
3
+ module FifthedSim
4
+ class Damage
5
+ using CalculatedFixnum
6
+
7
+ class DefinitionProxy
8
+ def initialize(&block)
9
+ @attrs = {}
10
+ instance_eval(&block)
11
+ end
12
+
13
+ attr_accessor :attrs
14
+
15
+ DAMAGE_TYPES.each do |type|
16
+ self.send(:define_method, type) do |arg|
17
+ @attrs[type] = DiceExpression(arg)
18
+ end
19
+ end
20
+ end
21
+
22
+ def self.define(&block)
23
+ h = DefinitionProxy.new(&block).attrs
24
+ self.new(h)
25
+ end
26
+
27
+ def initialize(hash)
28
+ @hash = hash
29
+ end
30
+
31
+ ##
32
+ # Obtain a dice roll of how much damage we're doing to a particular enemy
33
+ def to(enemy)
34
+ mapped = @hash.map do |k, v|
35
+ if enemy.immune_to?(k)
36
+ 0.to_dice_expression
37
+ elsif enemy.resistant_to?(k)
38
+ (v / 2)
39
+ else
40
+ v
41
+ end
42
+ end
43
+ if mapped.empty?
44
+ 0.to_dice_expression
45
+ else
46
+ mapped.inject{|memo, x| memo + x}
47
+ end
48
+ end
49
+
50
+ def raw
51
+ @hash.values.inject{|s, k| s + k}
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ module FifthedSim
2
+ class DamageTypes
3
+ def self.convert(t)
4
+ ret = case t
5
+ when String
6
+ t.to_sym
7
+ when Symbol
8
+ t
9
+ else
10
+ raise ArgumentError, "Cannot convert to damage type"
11
+ end
12
+ unless self.valid_damage_type?(ret)
13
+ raise InvalidDamageType, "#{ret} is not a type of damage"
14
+ end
15
+ ret
16
+ end
17
+
18
+ def self.valid_damage_type?(sym)
19
+ DAMAGE_TYPES.include? sym
20
+ end
21
+
22
+ class InvalidDamageType < StandardError
23
+ end
24
+ end
25
+
26
+ DAMAGE_TYPES = %i(slashing
27
+ bludgeoning
28
+ piercing
29
+ fire
30
+ cold
31
+ poison
32
+ acid
33
+ psychic
34
+ necrotic
35
+ radiant
36
+ lightning
37
+ thunder
38
+ force)
39
+ end
@@ -0,0 +1,134 @@
1
+ require 'rainbow'
2
+
3
+ module FifthedSim
4
+ ##
5
+ # This is an abstract dice expression class
6
+ class DiceExpression
7
+
8
+ def to_i
9
+ value
10
+ end
11
+
12
+ def to_f
13
+ value.to_f
14
+ end
15
+
16
+ def average
17
+ distribution.average
18
+ end
19
+
20
+ def +(other)
21
+ AdditionNode.new(self, other.to_dice_expression)
22
+ end
23
+
24
+ def -(other)
25
+ SubtractionNode.new(self, other.to_dice_expression)
26
+ end
27
+
28
+ def /(other)
29
+ DivisionNode.new(self, other.to_dice_expression)
30
+ end
31
+
32
+ def *(other)
33
+ MultiplicationNode.new(self, other.to_dice_expression)
34
+ end
35
+
36
+ def or_greater(other)
37
+ GreaterNode.new(self, other.to_dice_expression)
38
+ end
39
+
40
+ def or_least(other)
41
+ LessNode.new(self, other.to_dice_expression)
42
+ end
43
+
44
+ def percentile
45
+ distribution.percent_lower_equal(value)
46
+ end
47
+
48
+ def max
49
+ distribution.max
50
+ end
51
+
52
+ def min
53
+ distribution.min
54
+ end
55
+
56
+ ##
57
+ # Takes a block, which should take a single argument
58
+ # This block should return another DiceExpression type, based on the result of this DiceExpression.
59
+ def test_then(&block)
60
+ BlockNode.new(self, &block)
61
+ end
62
+
63
+ {"above_" => :>, "below_" => :<, "" => :==}.each do |k,v|
64
+ define_method "#{k}average?" do
65
+ value.public_send(v, average)
66
+ end
67
+ end
68
+
69
+ ##
70
+ # Get this difference of the average value and the current value.
71
+ # For example, if the average is 10 and we have a value of 20, it will return 10.
72
+ # Meanwhile, if the average is 10 and we have a value of 2, it will return -8.
73
+ def difference_from_average
74
+ value - average
75
+ end
76
+
77
+ def range
78
+ (min..max)
79
+ end
80
+
81
+ def to_dice_expression
82
+ self.dup
83
+ end
84
+
85
+ protected
86
+
87
+ def self.define_binary_op_equations(op)
88
+ self.send(:define_method, :value_equation) do |terminal: false|
89
+ lhs = instance_variable_get(:@lhs).value_equation(terminal: terminal)
90
+ rhs = instance_variable_get(:@rhs).value_equation(terminal: terminal)
91
+ "(#{lhs} #{op} #{rhs}"
92
+ end
93
+
94
+ self.send(:define_method, :expression_equation) do
95
+ lhs = instance_variable_get(:@lhs)
96
+ rhs = instance_variable_get(:@rhs)
97
+ "(#{lhs.expression_equation} #{op} #{rhs.expression_equation})"
98
+ end
99
+ end
100
+ end
101
+ end
102
+
103
+ ##
104
+ # Allow .to_dice_expression on Fixnum
105
+ class Fixnum
106
+ def to_dice_expression
107
+ FifthedSim::NumberNode.new(self)
108
+ end
109
+ end
110
+
111
+ class String
112
+ def to_dice_expression
113
+ FifthedSim::Compiler.compile(self)
114
+ end
115
+ end
116
+
117
+ ##
118
+ # C-style conversion, yay
119
+ def DiceExpression(arg)
120
+ return arg.to_dice_expression if arg.respond_to? :to_dice_expression
121
+ throw ArgumentError, "Cannot convert #{arg.class} to DiceExpression"
122
+ end
123
+
124
+
125
+ require_relative './nodes/multi_node'
126
+ require_relative './nodes/addition_node'
127
+ require_relative './nodes/roll_node'
128
+ require_relative './nodes/block_node'
129
+ require_relative './nodes/division_node'
130
+ require_relative './nodes/greater_node'
131
+ require_relative './nodes/less_node'
132
+ require_relative './nodes/number_node'
133
+ require_relative './nodes/multiplication_node'
134
+ require_relative './nodes/subtraction_node'
@@ -1,72 +1,271 @@
1
+ require 'set'
2
+
1
3
  module FifthedSim
4
+ ##
5
+ # Models a probabilistic distribution.
2
6
  class Distribution
7
+ ##
8
+ # Get a distrubtion for a number.
9
+ # This will be a uniform distribution with P = 1 at this number and P = 0 elsewhere.
3
10
  def self.for_number(num)
4
- self.new({num => 1}, 1)
11
+ self.new({num => 1.0})
12
+ end
13
+
14
+ def self.for_range(rng)
15
+ size = rng.size.to_f
16
+ e = 1.0 / size
17
+ self.new(Hash[rng.map{|x| [x, e]}])
18
+ end
19
+
20
+ def self.for(obj)
21
+ case obj
22
+ when Fixnum
23
+ self.for_number(obj)
24
+ when Range
25
+ self.for_range(obj)
26
+ else
27
+ raise ArgumentError, "can't amke a distribution for that"
28
+ end
5
29
  end
6
30
 
7
- def initialize(map, total_possible)
31
+ ##
32
+ # We initialize class with a map of results to occurences, and a total number of possible different occurences.
33
+ # Generally, you will not ever initialize this yourself.
34
+ def initialize(map)
8
35
  keys = map.keys
9
36
  @max = keys.max
10
37
  @min = keys.min
11
- @map = map
12
- @total_possible = total_possible
38
+ @map = map.dup
39
+ @map.default = 0
13
40
  end
14
41
 
15
42
  attr_reader :total_possible,
16
43
  :min,
17
44
  :max
18
45
 
46
+
19
47
  def range
20
- (@max..@min)
48
+ (@min..@max)
21
49
  end
22
50
 
23
51
  def map
24
52
  @map.dup
25
53
  end
26
54
 
27
- def probability_map
28
- Hash[@map.map{|k, v| [k, v / @total_possible.to_f]}]
55
+ def average
56
+ map.map{|k, v| k * v}.inject(:+)
57
+ end
58
+
59
+ ##
60
+ # Obtain a new distribution of values.
61
+ # When block.call(value) for this distribution is true, we will allow
62
+ # values from the second distribution.
63
+ # Otherwise, the value will be zero.
64
+ #
65
+ # This is mostly used in hit calculation - AKA, if we're higher than an AC, then we hit, otherwise we do zero damage
66
+ def hit_when(other, &block)
67
+ hit_prob = map.map do |k, v|
68
+ if block.call(k)
69
+ v
70
+ else
71
+ nil
72
+ end
73
+ end.compact.inject(:+)
74
+ miss_prob = 1 - hit_prob
75
+ omap = other.map
76
+ h = Hash[omap.map{|k, v| [k, v * hit_prob]}]
77
+ h[0] = (h[0] || 0) + miss_prob
78
+ Distribution.new(h)
79
+ end
80
+
81
+ ##
82
+ # Takes a block or callable object.
83
+ # This function will call the callable with all possible outcomes of this distribution.
84
+ # The callable should return another distribution, representing the possible values when this possibility happens.
85
+ # This will then return a value of those possibilities.
86
+ #
87
+ # An example is probably helpful here.
88
+ # 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.
89
+ # If we want a distribution of possible outcomes of this attack, we can do:
90
+ #
91
+ # 1.d(20).distribution.results_when do |x|
92
+ # if x < 16
93
+ # Distribution.for_number(0)
94
+ # elseif x < 20
95
+ # 1.d(4).distribution
96
+ # else
97
+ # 2.d(4).distribution
98
+ # end
99
+ # end
100
+ def results_when(&block)
101
+ h = Hash.new{|h, k| h[k] = 0}
102
+ range.each do |v|
103
+ prob = @map[v]
104
+ o_dist = block.call(v)
105
+ o_dist.map.each do |k, v|
106
+ h[k] += (v * prob)
107
+ end
108
+ end
109
+ Distribution.new(h)
110
+ end
111
+
112
+ def percent_within(range)
113
+ percent_where{|x| range.contains? x}
114
+ end
115
+
116
+ def percent_where(&block)
117
+ @map.to_a
118
+ .keep_if{|(k, v)| block.call(k)}
119
+ .map{|(k, v)| v}
120
+ .inject(:+)
29
121
  end
30
122
 
31
123
  def percent_exactly(num)
32
124
  return 0 if num < @min || num > @max
33
- @map[num] / @total_possible.to_f
125
+ @map[num] || 0
126
+ end
127
+
128
+ def variance
129
+ avg = average
130
+ @map.map do |k, v|
131
+ ((k - avg)**2) * v
132
+ end.inject(:+)
133
+ end
134
+
135
+ def std_dev
136
+ Math.sqrt(variance)
137
+ end
138
+
139
+ def percent_lower(n)
140
+ num = n - 1
141
+ return 0.0 if num < @min
142
+ return 1.0 if num > @max
143
+ @min.upto(num).map(&map_proc).inject(:+)
144
+ end
145
+
146
+ def percent_greater(n)
147
+ num = n + 1
148
+ return 0.0 if num > @max
149
+ return 1.0 if num < @min
150
+ num.upto(@max).map(&map_proc).inject(:+)
34
151
  end
35
152
 
36
- def percent_least(num)
37
- return 0 if num < @min
38
- return 1 if num >= @max
39
-
40
- n = @min.upto(num).map(&map_proc).inject(:+)
41
- n / @total_possible.to_f
153
+ def percent_lower_equal(num)
154
+ percent_lower(num + 1)
42
155
  end
43
156
 
157
+ def percent_greater_equal(num)
158
+ percent_greater(num - 1)
159
+ end
160
+
161
+ alias_method :percentile_of,
162
+ :percent_lower_equal
163
+
164
+
165
+
44
166
  def convolve(other)
45
167
  h = {}
46
168
  abs_min = [@min, other.min].min
47
169
  abs_max = [@max, other.max].max
48
170
  min_possible = @min + other.min
49
171
  max_possible = @max + other.max
50
- tp = @total_possible * other.total_possible
51
172
  # TODO: there has to be a less stupid way to do this right?
52
173
  v = min_possible.upto(max_possible).map do |val|
53
174
  sum = abs_min.upto(abs_max).map do |m|
54
175
  percent_exactly(m) * other.percent_exactly(val - m)
55
176
  end.inject(:+)
56
- [val, (sum * tp).to_i]
177
+ [val, sum]
178
+ end
179
+ self.class.new(Hash[v])
180
+ end
181
+
182
+ ##
183
+ # TODO: Optimize this
184
+ def convolve_subtract(other)
185
+ h = Hash.new{|h, k| h[k] = 0}
186
+ range.each do |v1|
187
+ other.range.each do |v2|
188
+ h[v1 - v2] += percent_exactly(v1) * other.percent_exactly(v2)
189
+ end
190
+ end
191
+ self.class.new(h)
192
+ end
193
+
194
+ ##
195
+ # Get the distribution of a result from this distribution divided by
196
+ # one from another distribution.
197
+ # If the other distribution may contain zero this will break horribly.
198
+ def convolve_divide(other)
199
+ throw ArgumentError, "Divisor may be zero" if other.min < 1
200
+ h = Hash.new{|h, k| h[k] = 0}
201
+ # We can do this faster using a sieve, but be lazy for now
202
+ # TODO: Be less lazy
203
+ range.each do |v1|
204
+ other.range.each do |v2|
205
+ h[v1 / v2] += percent_exactly(v1) * other.percent_exactly(v2)
206
+ end
207
+ end
208
+ self.class.new(h)
209
+ end
210
+
211
+ def convolve_multiply(other)
212
+ h = Hash.new{|h, k| h[k] = 0}
213
+ range.each do |v1|
214
+ other.range.each do |v2|
215
+ h[v1 * v2] += percent_exactly(v1) * other.percent_exactly(v2)
216
+ end
217
+ end
218
+ self.class.new(h)
219
+ end
220
+
221
+
222
+ def convolve_greater(other)
223
+ h = Hash.new{|h, k| h[k] = 0}
224
+ # for each value
225
+ range.each do |s|
226
+ (s..other.max).each do |e|
227
+ h[e] += (other.percent_exactly(e) * percent_exactly(s))
228
+ end
229
+ h[s] += (other.percent_lower(s) * percent_exactly(s))
57
230
  end
58
- self.class.new(Hash[v], tp)
231
+ self.class.new(h)
59
232
  end
60
233
 
234
+ def convolve_least(other)
235
+ h = Hash.new{|h, k| h[k] = 0}
236
+ range.each do |s|
237
+ (other.min..s).each do |e|
238
+ h[e] += (other.percent_exactly(e) * percent_exactly(s))
239
+ end
240
+ h[s] += (other.percent_greater(s + 1) * percent_exactly(s))
241
+ end
242
+ self.class.new(h)
243
+ end
244
+
245
+ COMPARE_EPSILON = 0.00001
61
246
  def ==(other)
62
- @map == other.map &&
63
- @total_possible == other.total_possible
247
+ omap = other.map
248
+ max_possible = (@max / other.min)
249
+ same_keys = (Set.new(@map.keys) == Set.new(omap.keys))
250
+ same_vals = @map.keys.each do |k|
251
+ (@map[k] - other.map[k]).abs <= COMPARE_EPSILON
252
+ end
253
+ same_keys && same_vals
254
+ end
255
+
256
+ def text_histogram(cols = 60)
257
+ max_width = @max.to_s.length
258
+ justwidth = max_width + 1
259
+ linewidth = (cols - justwidth)
260
+ range.map do |v|
261
+ "#{v}:".rjust(justwidth) + ("*" * (percent_exactly(v) * linewidth))
262
+ end.join("\n")
64
263
  end
65
264
 
66
265
  private
67
266
  def map_proc
68
267
  return Proc.new do |arg|
69
- res = @map[arg]
268
+ @map[arg]
70
269
  end
71
270
  end
72
271
  end
@@ -0,0 +1,75 @@
1
+ require_relative '../dice_expression'
2
+ require_relative '../calculated_fixnum'
3
+
4
+ module FifthedSim
5
+ class AdditionNode < DiceExpression
6
+ using CalculatedFixnum
7
+
8
+ def initialize(*values)
9
+ values.each(&method(:check_type))
10
+ @components = values.flatten
11
+ end
12
+
13
+ def +(other)
14
+ check_type(other)
15
+ self.class.new(*@components, other)
16
+ end
17
+
18
+ def value
19
+ # Symbol::& uses send, so refinements would break
20
+ # How sad
21
+ @components.map{|x| x.value}.inject(:+)
22
+ end
23
+
24
+ def dice
25
+ self.class.new(*@components.find_all{|x| x.is_a?(RollNode)})
26
+ end
27
+
28
+ def average
29
+ @components.map{|x| x.average}.inject(:+)
30
+ end
31
+
32
+ def has_critfail?
33
+ @components.any?{|x| x.has_critfail?}
34
+ end
35
+
36
+ def has_crit?
37
+ @components.any?{|x| x.has_crit?}
38
+ end
39
+
40
+ def distribution
41
+ # TODO: Maybe figure out how to minimize convolution expense?
42
+ @components.map{|x| x.distribution}.inject do |memo, p|
43
+ memo.convolve(p)
44
+ end
45
+ end
46
+
47
+ def reroll
48
+ self.class.new(*@components.map{|x| x.reroll})
49
+ end
50
+
51
+
52
+ def value_equation(terminal: false)
53
+ arglist = @components.map do |c|
54
+ c.value_equation(terminal: terminal)
55
+ end.inject{|m, x| m + "+ #{x}"}
56
+ "(#{arglist})"
57
+ end
58
+
59
+ def expression_equation
60
+ "(" + @components.map do |c|
61
+ c.expression_equation
62
+ end.inject{|m, x| m + "+ #{x}"} + ")"
63
+ end
64
+
65
+ private
66
+ ALLOWED_TYPES = [DiceExpression,
67
+ Fixnum]
68
+
69
+ def check_type(obj)
70
+ unless ALLOWED_TYPES.any?{|x| obj.kind_of?(x)}
71
+ raise TypeError, "#{obj.inspect} is not a DiceExpression"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,46 @@
1
+ require_relative '../dice_expression'
2
+ require_relative '../calculated_fixnum'
3
+ require_relative '../distribution'
4
+
5
+ module FifthedSim
6
+ class BlockNode < DiceExpression
7
+ using CalculatedFixnum
8
+
9
+ def initialize(arg, &block)
10
+ @arg = arg
11
+ @block = block
12
+ @current_expression = block.call(arg.value)
13
+ @current_value = @current_expression.value
14
+ end
15
+
16
+ def value
17
+ @current_value
18
+ end
19
+
20
+ def reroll
21
+ self.class.new(@arg.reroll, &@block)
22
+ end
23
+
24
+ def distribution
25
+ @arg.distribution.results_when(&distribution_block)
26
+ end
27
+
28
+ def value_equation(terminal: false)
29
+ arg = @arg.value_equation(terminal: terminal)
30
+ ce = @current_expression.value_equation(terminal: terminal)
31
+ "blockNode(#{arg} => #{ce})"
32
+ end
33
+
34
+ protected
35
+
36
+ def distribution_block
37
+ h = Hash[@arg.range.map do |k|
38
+ [k, @block.call(k).distribution]
39
+ end]
40
+ require 'pry'
41
+ proc do |x|
42
+ h.fetch(x)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,26 @@
1
+ require_relative '../dice_expression'
2
+ require_relative '../calculated_fixnum'
3
+ module FifthedSim
4
+ class DivisionNode < DiceExpression
5
+ using CalculatedFixnum
6
+
7
+ def initialize(num, div)
8
+ @lhs = num
9
+ @rhs = div
10
+ end
11
+
12
+ def value
13
+ @lhs.value / @rhs.value
14
+ end
15
+
16
+ def reroll
17
+ self.class.new(@lhs.reroll, @rhs.reroll)
18
+ end
19
+
20
+ def distribution
21
+ @lhs.distribution.convolve_divide(@rhs.distribution)
22
+ end
23
+
24
+ define_binary_op_equations "/"
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ require_relative '../dice_expression'
2
+ require_relative '../calculated_fixnum'
3
+
4
+ module FifthedSim
5
+ class GreaterNode < DiceExpression
6
+ using CalculatedFixnum
7
+ def initialize(lhs, rhs)
8
+ @lhs, @rhs = lhs, rhs
9
+ end
10
+
11
+ def value
12
+ [@lhs.value, @rhs.value].max
13
+ end
14
+
15
+ def distribution
16
+ @lhs.distribution.convolve_greater(@rhs.distribution)
17
+ end
18
+
19
+ def reroll
20
+ self.class.new(@lhs.reroll, @rhs.reroll)
21
+ end
22
+
23
+ def min
24
+ [@lhs.min, @rhs.min].max
25
+ end
26
+
27
+ def max
28
+ [@lhs.max, @rhs.max].max
29
+ end
30
+
31
+ def value_equation(terminal: false)
32
+ lhs = @lhs.value_equation(terminal: terminal)
33
+ rhs = @rhs.value_equation(terminal: terminal)
34
+ "max(#{lhs}, #{rhs}"
35
+ end
36
+
37
+ def expression_equation
38
+ "max(#{lhs.expression_equation}, #{rhs.expression_equation})"
39
+ end
40
+ end
41
+ end