eunomia_gen 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ # Generator consists of a list of Items. When generating a result,
5
+ # an item is selected at random based on the item's weight.
6
+ class Generator
7
+ include Eunomia::HashHelpers
8
+
9
+ attr_reader :key,
10
+ :alts,
11
+ :meta,
12
+ :functions,
13
+ :tags,
14
+ :items,
15
+ :selector,
16
+ :sep
17
+
18
+ def initialize(hsh)
19
+ @key = field_or_raise(hsh, :key)
20
+ @functions = list_field(hsh, :functions)
21
+ @alts = hash_field(hsh, :alts)
22
+ @meta = meta_field(hsh)
23
+ @tags = tags_field(hsh)
24
+ @selector = Eunomia::Selector.new(field_or_nil(hsh, :rng))
25
+ @items = items_from(hsh)
26
+ raise "Generators must have items" if @items.empty?
27
+ end
28
+
29
+ def items_from(hsh)
30
+ list_field(hsh, :items).map do |item|
31
+ item = { segments: item } if item.is_a?(String)
32
+ Eunomia::Item.new(@key, item, @tags)
33
+ end
34
+ end
35
+
36
+ def alt_for(key, segment)
37
+ return segment unless key
38
+ return segment unless alts[key]
39
+
40
+ alts[key][segment] || segment
41
+ end
42
+
43
+ # Select items that have all the given tag values
44
+ def filter(tags)
45
+ return items if tags.empty?
46
+
47
+ items.select { |item| (tags - item.tags).empty? }
48
+ end
49
+
50
+ def generate(request)
51
+ items = filter(request.tags)
52
+ item = selector.select(items)
53
+ raise "No items found for #{key}" unless item
54
+
55
+ result = item.generate(request)
56
+ result.apply(alts, functions, locale: request.alt_key)
57
+ result.merge_meta(meta)
58
+ result
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module HashHelpers
5
+ def field_or_nil(hsh, key)
6
+ hsh[key.to_sym] || hsh[key.to_s]
7
+ end
8
+
9
+ def field_or_raise(hsh, key)
10
+ val = field_or_nil(hsh, key)
11
+ raise "Missing key: #{key}" unless val
12
+
13
+ val
14
+ end
15
+
16
+ def int_field(hsh, key, default = 0)
17
+ n = field_or_nil(hsh, key)
18
+ n.nil? ? default : n.to_i
19
+ end
20
+
21
+ def list_field(hsh, key)
22
+ field = field_or_nil(hsh, key)
23
+ return [] unless field
24
+
25
+ field.is_a?(String) ? field.split(/\s+/) : field
26
+ end
27
+
28
+ def tags_field(hsh)
29
+ tags = list_field(hsh, :tags)
30
+ return Set.new unless tags
31
+
32
+ Set.new(tags)
33
+ end
34
+
35
+ def alts_field(hsh)
36
+ hash_field(hsh, :alts)
37
+ end
38
+
39
+ def meta_field(hsh)
40
+ hash_field(hsh, :meta)
41
+ end
42
+
43
+ def hash_field(hsh, key)
44
+ field = field_or_nil(hsh, key)
45
+ return {} unless field
46
+
47
+ field.is_a?(Hash) ? field : {}
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ class Item
5
+ include Eunomia::HashHelpers
6
+
7
+ attr_reader :key,
8
+ :weight,
9
+ :value,
10
+ :tags,
11
+ :alts,
12
+ :meta,
13
+ :functions,
14
+ :segments
15
+
16
+ def initialize(key, hsh, add_tags = nil)
17
+ @key = key
18
+ @weight = int_field(hsh, :weight).clamp(1, 1000)
19
+ @value = int_field(hsh, :value).clamp(0, 1_000_000)
20
+ @tags = tags_field(hsh)
21
+ @tags += add_tags if add_tags
22
+ @alts = alts_field(hsh)
23
+ @meta = meta_field(hsh)
24
+ @functions = list_field(hsh, :functions)
25
+ @segments = scan(field_or_raise(hsh, :segments)).flatten
26
+ raise "Items must have segments" if @segments.empty?
27
+ end
28
+
29
+ def scan(obj)
30
+ return [] if obj.nil?
31
+ return Eunomia::Segment.build(obj.to_s) unless obj.is_a?(Array)
32
+
33
+ obj.map { |e| Eunomia::Segment.build(e) }.flatten
34
+ end
35
+
36
+ def alt_for(key, segment)
37
+ return segment unless key
38
+ return segment unless alts[key]
39
+
40
+ alts[key][segment] || segment
41
+ end
42
+
43
+ def generate(request)
44
+ result = Eunomia::Result.new(key, value: value)
45
+ segments.each { |seg| result.append(seg.generate(request)) }
46
+ result.apply(alts, functions, locale: request.alt_key)
47
+ result.merge_meta(meta)
48
+ result
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ class Request
5
+ attr_reader :key, :alt_key, :alts, :meta, :tags, :functions, :constants, :depth
6
+
7
+ def initialize(key, alts: {}, alt_key: nil, meta: {}, tags: [], functions: [], constants: {}, unique: false)
8
+ @key = key
9
+ @alt_key = alt_key
10
+ @alts = alts || {}
11
+ @meta = meta || {}
12
+ @tags = Set.new(tags || [])
13
+ @constants = constants || {}
14
+ @functions = functions || []
15
+ @depth = 0
16
+ @unique = unique ? Set.new : nil
17
+ end
18
+
19
+ def generate_unique?
20
+ !@unique.nil?
21
+ end
22
+
23
+ def alt_key?
24
+ !alt_key.nil?
25
+ end
26
+
27
+ def alt_for(segment)
28
+ alts[segment] || segment
29
+ end
30
+
31
+ def increase_depth
32
+ @depth += 1
33
+ raise "Depth exceeded" if @depth > 100
34
+ end
35
+
36
+ def generate
37
+ @depth = 0
38
+ gen = Eunomia.lookup(key)
39
+
40
+ 100.times do
41
+ result = gen.generate(self)
42
+ result.apply(alts, functions)
43
+ return result if !@unique || @unique.add?(result.to_s)
44
+ end
45
+
46
+ raise "Unable to find a unique result"
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ class Result
5
+ attr_reader :key, :display, :elements, :multiplier, :base_value, :meta
6
+
7
+ def initialize(key, value: 0, multiplier: 1)
8
+ @key = key
9
+ @base_value = value
10
+ @multiplier = multiplier
11
+ @elements = []
12
+ @meta = Hash.new {|h,k| h[k] = Set.new}
13
+ @display = ""
14
+ end
15
+
16
+ def append(obj)
17
+ if obj.is_a?(Element)
18
+ append_element(obj)
19
+ elsif obj.is_a?(Result)
20
+ append_result(obj)
21
+ elsif obj.is_a?(Separator)
22
+ append_separator(obj)
23
+ end
24
+ end
25
+
26
+ def append_element(element)
27
+ @elements << element
28
+ @display += element.to_s
29
+ @multiplier *= element.multiplier unless element.multiplier.nil?
30
+ @base_value += element.value unless element.value.nil?
31
+ merge_meta(element.meta)
32
+ self
33
+ end
34
+
35
+ def append_separator(separator)
36
+ @display += separator.to_s
37
+ self
38
+ end
39
+
40
+ def append_result(result)
41
+ element = Element.new(result.to_s, value: result.value, multiplier: result.multiplier, children: result.elements)
42
+ @elements << element
43
+ @display += element.to_s
44
+ @base_value += element.value
45
+ merge_meta(result.meta)
46
+ self
47
+ end
48
+
49
+ def merge_meta(m)
50
+ return unless m
51
+
52
+ m.each do |k, v|
53
+ v = [v] unless v.is_a?(Enumerable)
54
+ @meta[k] = Set.new(@meta[k] + v).to_a
55
+ end
56
+ end
57
+
58
+ def apply_translations(alts, locale: nil)
59
+ arr = to_s.split(/\s+/)
60
+ arr = arr.map do |segment|
61
+ hsh = alts[segment]
62
+ hsh = hsh.is_a?(Hash) ? hsh[locale] || hsh['*'] || segment : hsh || segment
63
+ end
64
+ end
65
+
66
+ def apply_functions(arr, functions)
67
+ Eunomia.apply(arr, functions)
68
+ end
69
+
70
+ def apply(alts, functions, locale: nil)
71
+ arr = apply_translations(alts, locale: locale)
72
+ arr = apply_functions(arr, functions)
73
+
74
+ @display = arr.join(' ')
75
+ self
76
+ end
77
+
78
+ def value
79
+ base_value * multiplier
80
+ end
81
+
82
+ def to_s
83
+ @display
84
+ end
85
+
86
+ def to_h
87
+ { display:, key:, value:, multiplier:, meta:, elements: elements&.map(&:to_h) }
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ # Common implementations of the value, multiplier, calc and generate methods
6
+ module Common
7
+ def value
8
+ 0
9
+ end
10
+
11
+ def multiplier
12
+ 1
13
+ end
14
+
15
+ def generate(_request)
16
+ calc # update values for dynamic segments
17
+ Eunomia::Element.new(text, value: value, multiplier: multiplier)
18
+ end
19
+
20
+ def calc; end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ class Constant
6
+ include Common
7
+
8
+ SEPARATOR_MATCHER = /\p{Space}|\p{Punct}/
9
+
10
+ attr_reader :text
11
+
12
+ def initialize(text)
13
+ @text = text
14
+ end
15
+
16
+ def generate(_request)
17
+ calc # update values for dynamic segments
18
+ Eunomia::Separator.new(text)
19
+ end
20
+
21
+ def self.build(scanner)
22
+ str = scanner.scan(SEPARATOR_MATCHER)
23
+ new(str) if str
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ # Dice generates a random number using dice notation (e.g. `[3d6]`)
6
+ # with common operators (e.g. `[3d6+2]` or `[4d10x2]`)
7
+ # All values are integers so using the division operator
8
+ # will be rounded down.
9
+ class Dice
10
+ include Common
11
+
12
+ DICE_MATCHER = %r{\[(\d+)?d(\d+)(?:([+x/-])(\d+))?\]}
13
+ ADD = "+"
14
+ MULTIPLY = "x"
15
+ SUBTRACT = "-"
16
+ DIVIDE = "/"
17
+ OPS = [ADD, MULTIPLY, SUBTRACT, DIVIDE].freeze
18
+
19
+ attr_reader :count, :range, :op, :constant
20
+
21
+ def initialize(count, range, op = ADD, constant = 0)
22
+ @count = count.to_i.clamp(1, 100)
23
+ @range = range.to_i.clamp(1, 1_000_000)
24
+ @op = OPS.include?(op) ? op : ADD
25
+ @constant = constant.to_i.clamp(0, 1_000_000)
26
+ end
27
+
28
+ def roll
29
+ @multiplier = nil
30
+ sum = 0
31
+ count.times { sum += (rand(range) + 1) }
32
+ sum
33
+ end
34
+
35
+ def calc
36
+ case op
37
+ when MULTIPLY
38
+ roll * constant
39
+ when DIVIDE
40
+ roll / constant
41
+ when SUBTRACT
42
+ roll - constant
43
+ when ADD
44
+ roll + constant
45
+ end
46
+ end
47
+
48
+ def text
49
+ @multiplier ||= calc
50
+ @multiplier.to_s
51
+ end
52
+
53
+ def multiplier
54
+ @multiplier ||= calc
55
+ end
56
+
57
+ def self.build(scanner)
58
+ str = scanner.scan(DICE_MATCHER)
59
+ return unless str
60
+
61
+ new(scanner[1], scanner[2], scanner[3], scanner[4])
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ class Number
6
+ include Common
7
+
8
+ NUMBER_MATCHER = /(\d+)/
9
+
10
+ attr_reader :text, :multipler
11
+
12
+ def initialize(number)
13
+ @multiplier = number.to_i
14
+ @text = number.to_s
15
+ end
16
+
17
+ def self.build(scanner)
18
+ str = scanner.scan(NUMBER_MATCHER)
19
+ new(str) if str
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ # Reference represents a link to a Generator defined with square brackets.
6
+ # A request may define a constant that is used in place of a generator to
7
+ # return a specific value. This can be identified with a label so that the
8
+ # substitution is only used in specific context. For example `[test]` would
9
+ # first look for a constant named `test` and if not found, get the generator
10
+ # from the store. `[test:label]` would only use the constant if the request
11
+ # has a constant key if `test:label` and, otherwise, use the generator
12
+ # called `test`.
13
+ class Reference
14
+ include Common
15
+
16
+ REFERENCE_MATCHER = /^\[([A-Za-z][A-Za-z0-9_-]+)(?::([A-Za-z0-9_-]+))?\]/
17
+
18
+ attr_reader :key, :label
19
+
20
+ def initialize(key, label = nil)
21
+ @key = key
22
+ @label = label
23
+ end
24
+
25
+ def label?
26
+ !label.nil?
27
+ end
28
+
29
+ def generate(request)
30
+ return Eunomia::Element.new(request.constants[lookup]) if request.constants.key?(lookup)
31
+
32
+ request.increase_depth
33
+ Eunomia.generate(key, request)
34
+ end
35
+
36
+ def lookup
37
+ @lookup ||= label.nil? ? key : "#{key}:#{label}"
38
+ end
39
+
40
+ def self.build(scanner)
41
+ str = scanner.scan(REFERENCE_MATCHER)
42
+ return unless str
43
+
44
+ new(scanner[1], scanner[2])
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ module Segment
5
+ # Text respresents a string of constant, non-numeric text.
6
+ class Text
7
+ include Common
8
+
9
+ TEXT_MATCHER = /\p{Alpha}[\p{Alnum}-]*/
10
+
11
+ attr_reader :text
12
+
13
+ def initialize(text)
14
+ @text = text
15
+ end
16
+
17
+ def self.build(scanner)
18
+ str = scanner.scan(TEXT_MATCHER)
19
+ new(str) if str
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ require_relative "segment/common"
6
+ require_relative "segment/dice"
7
+ require_relative "segment/constant"
8
+ require_relative "segment/text"
9
+ require_relative "segment/number"
10
+ require_relative "segment/reference"
11
+
12
+ module Eunomia
13
+ # Segment represents a single token in an `Item`.
14
+ module Segment
15
+ def self.build(str)
16
+ ss = StringScanner.new(str)
17
+ arr = []
18
+ until ss.eos?
19
+ seg = Eunomia::Segment::Dice.build(ss) ||
20
+ Eunomia::Segment::Reference.build(ss) ||
21
+ Eunomia::Segment::Number.build(ss) ||
22
+ Eunomia::Segment::Text.build(ss) ||
23
+ Eunomia::Segment::Constant.build(ss)
24
+ raise "Unable to parse #{ss.rest} (#{ss.rest_size})" unless seg
25
+
26
+ arr << seg
27
+ end
28
+ arr
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ # Selector defines a strategy for selecting an item from a list of items.
5
+ # The default behavior is to randomly generate a number between 0 and the
6
+ # sum of the weights of the items and select the first match.
7
+ #
8
+ # Alternately, a dice notation string can be provided to generate a random
9
+ # number based on the dice notation. The dice notation is shifted down based
10
+ # on the number of dice so that, for example, 2d6 will generate a number
11
+ # between 0 and 10.
12
+ #
13
+ # If the dice notation is less than the total weight of the items the
14
+ # items greater than the maxumum value are ignored. If the dice notation is
15
+ # greater than the total weight of the items the number is regenerated.
16
+ class Selector
17
+ DICE_MATCHER = /^(\d+)?d(\d+)?/
18
+
19
+ attr_reader :count, :range
20
+
21
+ def initialize(dice = nil)
22
+ @count = nil
23
+ @range = nil
24
+ return unless dice
25
+
26
+ m = DICE_MATCHER.match(dice)
27
+ return unless m
28
+
29
+ @count = m[1].to_i.clamp(1, 100)
30
+ @range = m[2].to_i.clamp(1, 10_000)
31
+ end
32
+
33
+ def select(items)
34
+ n = random(items)
35
+ items.each do |item|
36
+ n -= item.weight
37
+ return item if n.negative?
38
+ end
39
+ nil
40
+ end
41
+
42
+ def random(items)
43
+ max_weight = items.map(&:weight).sum
44
+ if count.nil? || count >= max_weight
45
+ rand(max_weight)
46
+ else
47
+ n = roll
48
+ n /= 2 while n >= max_weight
49
+ n
50
+ end
51
+ end
52
+
53
+ def roll
54
+ sum = 0
55
+ count.times { sum += rand(range) }
56
+ sum
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ class Separator
5
+ attr_reader :text
6
+
7
+ def initialize(text)
8
+ @text = text
9
+ end
10
+
11
+ def to_s
12
+ text
13
+ end
14
+
15
+ def to_h
16
+ { orig: text, text: text }
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ # Store holds all the generators. Generators are stored by key.
5
+ class Store
6
+ def initialize
7
+ @generators = {}
8
+ end
9
+
10
+ def lookup(key)
11
+ @generators[key] or raise Error, "Generator #{key} not found"
12
+ end
13
+
14
+ def keys
15
+ @generators.keys
16
+ end
17
+
18
+ def read(path)
19
+ text = File.read(path)
20
+ hsh_or_array = JSON.parse(text)
21
+ add(hsh_or_array)
22
+ end
23
+
24
+ def add(hsh_or_array)
25
+ return unless hsh_or_array
26
+
27
+ if hsh_or_array.is_a?(Array)
28
+ hsh_or_array.each do |item|
29
+ add(item)
30
+ end
31
+ else
32
+ gen = Eunomia::Generator.new(hsh_or_array)
33
+ @generators[gen.key] = gen
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Eunomia
4
+ VERSION = "0.1.0"
5
+ end