text-gen 0.12.5 → 0.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c761d6858b40d475f4514ad9f309a32fbd00bc5ca1dca0be50ecd366cc7eb965
4
- data.tar.gz: c173f8bbe852bfd317f5c634fdd947a7c393089ab18e9467b5dc6a51b59c8736
3
+ metadata.gz: 2afbd1cd20f4be4fa1c6ebb125fda4894f46026a078b8b54f808ed1e9fb684e6
4
+ data.tar.gz: '043274280f1ff52837206196ee489932b64c85f1a2af037b9b29cf1127ae1236'
5
5
  SHA512:
6
- metadata.gz: 8832ee6825a82219769f67ece7a73c194a4ca2e4370290a633b0b571bbb98cc754243bd7737692249b880288a13482a7d80c77e741054c6aa4a3401899416f8a
7
- data.tar.gz: f78177137d036a701bf92ccae769dfdeddb6b54f9a3d18005f864dac34e25d30976683a640314b8529427de278b78dd642fe29b02749889c3cf722ee94c962e1
6
+ metadata.gz: cce97e373664b52db5752f18d0a672041246486fdd4848aeb74febf96f099543ac04ac97c5609ea71877ac867afa9a805a51862d5099ddad3dc74be81f9579ea
7
+ data.tar.gz: 5762c3a92aca626f66e5127ab9aef91bc7a14bce1111e44836f7b083b7cc35ee387b4f8f5f0ea4351d38fad789a3bdde015dfce7f81257f78740f5627870f8c6
data/CHANGELOG.md CHANGED
@@ -1,4 +1,7 @@
1
- ## [Unreleased]
1
+ ## [0.13.0] - 2026-06-20
2
+
3
+ - Builder items can be concatonated by using the `+` operator (e.g. `key1+key2`)
4
+ - Cleaned up the strategy behavior to default to "weigted" and renamed "random" to "sample"
2
5
 
3
6
  ## [0.1.0] - 2025-12-28
4
7
 
@@ -4,6 +4,8 @@ module Text
4
4
  module Gen
5
5
  # Keep the context for one run.
6
6
  class Context
7
+ KNOWN_STRATEGIES = Set.new(%w[random weighted sequence sample])
8
+
7
9
  attr_reader :store, :depth
8
10
 
9
11
  def initialize(store:, filters: nil, meta: nil, max_recursion: 10)
@@ -22,10 +24,19 @@ module Text
22
24
  def descend!(builder)
23
25
  @depth += 1
24
26
  @keys << builder["key"]
25
- @strategy_stack << (builder["strategy"] || "random").split(":", 2)
27
+ @strategy_stack << strategy_to_stack_item(builder)
26
28
  raise MaxRecursionError if @depth > @max_recursion
27
29
  end
28
30
 
31
+ def strategy_to_stack_item(builder)
32
+ strategy, modifier = builder.fetch("strategy", "weighted").split(":", 2)
33
+ unless KNOWN_STRATEGIES.include?(strategy)
34
+ modifier = strategy
35
+ strategy = "weighted"
36
+ end
37
+ [strategy, modifier]
38
+ end
39
+
29
40
  def ascend!
30
41
  @depth -= 1
31
42
  @keys.pop
@@ -50,7 +61,7 @@ module Text
50
61
  end
51
62
 
52
63
  def current_strategy
53
- @strategy_stack.last&.first || "random"
64
+ @strategy_stack.last&.first || "weighted"
54
65
  end
55
66
 
56
67
  def current_modifier
@@ -64,7 +75,7 @@ module Text
64
75
 
65
76
  def remembered(key = nil)
66
77
  key ||= current_key
67
- @remembered.select {|k, _r| k == key }.map(&:last)
78
+ @remembered.select { |k, _r| k == key }.map(&:last)
68
79
  end
69
80
 
70
81
  def remember_checkpoint
@@ -3,6 +3,8 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Capitalize the result text; first character only by default.
7
+ # Use `capitalize:force` to use the default ruby `capitalize` method
6
8
  class Capitalize < Base
7
9
  def result(context, result)
8
10
  return result if @depth && context.depth != @depth
@@ -3,9 +3,12 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Censor looks up a different builder and rejects the result if
7
+ # the result text is included in the
6
8
  class Censor < Base
7
9
  def result(context, result)
8
10
  return result if @depth && context.depth != @depth
11
+
9
12
  return result if key.nil?
10
13
 
11
14
  builder = context.store.fetch(key)
@@ -3,7 +3,7 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
- # Clears result meta
6
+ # Clear removes result meta
7
7
  class Clear < Base
8
8
  def result(context, result)
9
9
  return result if @depth && context.depth != @depth
@@ -9,7 +9,7 @@ module Text
9
9
  return result if key != context.current_key
10
10
 
11
11
  previous = context.remembered(key)
12
- return nil if previous.any? { |r| r.text.strip.downcase == result.text.strip.downcase }
12
+ return if previous.any? { |r| r.text.strip.downcase == result.text.strip.downcase }
13
13
 
14
14
  result
15
15
  end
@@ -3,6 +3,7 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Downcase returns the result text using the default lowercase rules
6
7
  class Downcase < Base
7
8
  def result(context, result)
8
9
  return result if @depth && context.depth != @depth
@@ -3,13 +3,15 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Exclude is the opposite of `match` and returns nil if
7
+ # the result metadata is matches the pattern in this filter.
6
8
  class Exclude < Base
7
9
  def result(context, result)
8
10
  return result if @depth && context.depth != @depth
9
- return nil if result.nil?
10
- return nil if value == "*" && result.meta.key?(key)
11
- return nil if key == "*" && result.meta.values.any? { |arr| arr.include?(value) }
12
- return nil if result.meta[key]&.include?(value)
11
+ return if result.nil?
12
+ return if value == "*" && result.meta.key?(key)
13
+ return if key == "*" && result.meta.values.any? { |arr| arr.include?(value) }
14
+ return if result.meta[key]&.include?(value)
13
15
 
14
16
  result
15
17
  end
@@ -3,6 +3,8 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Match returns `nil` unless the result metadata includes
7
+ # the given pattern.
6
8
  class Match < Base
7
9
  def result(context, result)
8
10
  return result if @depth && context.depth != @depth
@@ -3,6 +3,7 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Meta adds meta tags to the result
6
7
  class Meta < Base
7
8
  def result(context, result)
8
9
  return result if @depth && context.depth != @depth
@@ -30,7 +30,13 @@ module Text
30
30
  return result if @depth && context.depth != @depth
31
31
 
32
32
  text = result.text
33
- text = pluralize(text, result.multiplier)
33
+ text = if key && value
34
+ substitute(text, result.multiplier)
35
+ elsif key
36
+ pluralize_by_key(text, result.multiplier, result)
37
+ else
38
+ pluralize(text, result.multiplier)
39
+ end
34
40
  return result if text == result.text
35
41
 
36
42
  Result.from(result, text:, type: component_key)
@@ -38,27 +44,74 @@ module Text
38
44
 
39
45
  private
40
46
 
47
+ # Splits a string into alternating word and non-word tokens, preserving all characters.
48
+ # e.g. "5 swords." => ["5", " ", "swords", "."]
49
+ def tokenize(str)
50
+ str.scan(/\w+|\W+/)
51
+ end
52
+
53
+ def pluralize_by_key(str, multiplier, result)
54
+ tokens = tokenize(str)
55
+ word_tokens = tokens.select { |t| t.match?(/\w/) }
56
+ idx = word_tokens.rindex { |s| to_num(s) }
57
+ num = multiplier > 1 ? multiplier : (idx ? to_num(word_tokens[idx]) : nil)
58
+ return str if num.nil? || num <= 1
59
+
60
+ component = find_component(result, key)
61
+ return str unless component
62
+
63
+ component_text = component.text
64
+ return str if component_text.nil? || component_text.strip.empty?
65
+
66
+ component_tokens = tokenize(component_text)
67
+ last_word_idx = component_tokens.rindex { |t| t.match?(/\w/) }
68
+ return str unless last_word_idx
69
+
70
+ dc = component_tokens[last_word_idx].downcase
71
+ component_tokens[last_word_idx] = exceptions(dc) || single_letter(dc) || others(dc) || ends_with_y(dc) || simple(dc)
72
+ str.sub(component_text, component_tokens.join)
73
+ end
74
+
75
+ def find_component(result, search_key)
76
+ return result if result.type == search_key
77
+
78
+ result.components.each do |c|
79
+ found = find_component(c, search_key)
80
+ return found if found
81
+ end
82
+ nil
83
+ end
84
+
85
+ def substitute(str, multiplier = 1)
86
+ tokens = tokenize(str)
87
+ word_tokens = tokens.select { |t| t.match?(/\w/) }
88
+ idx = word_tokens.rindex { |s| to_num(s) }
89
+ num = multiplier > 1 ? multiplier : (idx ? to_num(word_tokens[idx]) : nil)
90
+ return str if num.nil? || num <= 1
91
+
92
+ tokens.map! { |t| t.match?(/\A#{Regexp.escape(key)}\z/i) ? value : t }
93
+ tokens.join
94
+ end
95
+
41
96
  def pluralize(str, multiplier = 1)
42
97
  return str if str.empty?
43
98
 
44
- arr = str.split(/\s+/)
45
- return str if arr.length < 2
99
+ tokens = tokenize(str)
100
+ word_tokens = tokens.select { |t| t.match?(/\w/) }
101
+ return str if word_tokens.length < 2
46
102
 
47
- idx = arr.rindex { |s| to_num(s) }
103
+ idx = word_tokens.rindex { |s| to_num(s) }
48
104
 
49
105
  # Use the multiplier if available, otherwise parse from text
50
- num = if multiplier > 1
51
- multiplier
52
- else
53
- (idx ? to_num(arr[idx]) : nil)
54
- end
106
+ num = multiplier > 1 ? multiplier : (idx ? to_num(word_tokens[idx]) : nil)
55
107
 
56
108
  return str if num.nil? || num <= 1
57
- return str if idx && idx >= arr.length - 1
109
+ return str if idx && idx >= word_tokens.length - 1
58
110
 
59
- dc = arr[-1].downcase
60
- arr[-1] = exceptions(dc) || single_letter(dc) || others(dc) || ends_with_y(dc) || simple(dc)
61
- arr.join(" ")
111
+ last_word_idx = tokens.rindex { |t| t.match?(/\w/) }
112
+ dc = tokens[last_word_idx].downcase
113
+ tokens[last_word_idx] = exceptions(dc) || single_letter(dc) || others(dc) || ends_with_y(dc) || simple(dc)
114
+ tokens.join
62
115
  end
63
116
 
64
117
  def to_num(str)
@@ -3,6 +3,8 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Reject reduces the items in the array to **only** those without
7
+ # the given metadata. This is the opposite of `select`.
6
8
  class Reject < Base
7
9
  def items(_context, items)
8
10
  items.select do |item|
@@ -3,8 +3,11 @@
3
3
  module Text
4
4
  module Gen
5
5
  module Filter
6
+ # Select reduces the items in the array to **only** those with passing
7
+ # metadata. This happens before generation as opposed to `match` which
8
+ # applies a pass/fail to the result.
6
9
  class Select < Base
7
- def apply(items)
10
+ def items(_context, items)
8
11
  items.select do |item|
9
12
  pass_select?(item["meta"])
10
13
  end
@@ -68,12 +68,12 @@ module Text
68
68
  return if items.empty?
69
69
 
70
70
  case context.current_strategy
71
+ when "sample"
72
+ run_random_item(context, items)
71
73
  when "sequence"
72
74
  run_item_sequence(context, items)
73
- when "weighted"
74
- run_weighted_items(context, items)
75
75
  else
76
- run_random_item(context, items)
76
+ run_weighted_items(context, items)
77
77
  end
78
78
  end
79
79
 
@@ -94,7 +94,7 @@ module Text
94
94
  end
95
95
 
96
96
  def run_weighted_items(context, items)
97
- total_weight = items.sum { |item| [item.fetch("weight", 1).to_i, 1].max }
97
+ total_weight = calculate_total_weight(context, items)
98
98
  dice = context.current_modifier
99
99
  rand_weight = if dice.nil? || dice.empty? || dice == "*"
100
100
  rand(total_weight)
@@ -104,6 +104,7 @@ module Text
104
104
  end
105
105
  return if rand_weight > total_weight
106
106
 
107
+
107
108
  current_weight = 0
108
109
  item = items.find do |item|
109
110
  current_weight += [item.fetch("weight", 1).to_i, 1].max
@@ -201,6 +202,36 @@ module Text
201
202
  end
202
203
  end
203
204
 
205
+ def calculate_weight(context, item)
206
+ weight = item["weight"]
207
+ if weight == "*"
208
+ reference = item["segments"]&.find { |s| s["type"] == "reference" }
209
+ key = reference&.fetch("text")
210
+ weight = lookup_and_count_items(context, key)
211
+ end
212
+
213
+ weight.to_i < 1 ? 1 : weight.to_i
214
+ end
215
+
216
+ def lookup_and_count_items(context, key)
217
+ return 0 unless key
218
+
219
+ builder = context.store.fetch(key)
220
+ return 0 unless builder
221
+
222
+ builder["items"]&.size || 1
223
+ end
224
+
225
+ def calculate_total_weight(context, items)
226
+ sum = 0
227
+ items.each do |item|
228
+ weight = calculate_weight(context, item)
229
+ item["weight"] = weight
230
+ sum += weight
231
+ end
232
+ sum
233
+ end
234
+
204
235
  def random_from_dice(text)
205
236
  rolled = DiceNomShim.roll(text)
206
237
  parsed = JSON.parse(rolled).first["lhs"]
@@ -6,6 +6,7 @@ module Text
6
6
  # to save time on database lookups or transformations.
7
7
  class Store
8
8
  NOT_FOUND_BUILDER = {
9
+ "strategy" => "sample",
9
10
  "filters" => [],
10
11
  "meta" => {},
11
12
  "items" => []
@@ -33,12 +34,30 @@ module Text
33
34
  builder = find(key)
34
35
  return builder if builder
35
36
 
36
- builder = @lookup.call(key)
37
- return not_found(key) unless builder
37
+ keys = key.split("+")
38
+ builders = keys.map do |key|
39
+ builder = @lookup.call(key)
40
+ builder = not_found(key) unless builder
41
+ builder
42
+ end
38
43
 
44
+ builder = merge_builders(builders) if builders.length > 1
39
45
  add(key, builder.merge("key" => key.to_s.downcase))
40
46
  end
41
47
 
48
+ def merge_builders(builders)
49
+ all_filters = builders.map { |b| b["filters"] }.flatten
50
+ all_meta = builders.inject({}) { |b, acc| acc = Text::Gen::Meta.merge_meta(acc, b["meta"]) }
51
+ all_items = builders.map { |b| b["items"] }.flatten
52
+ strategy = builders.all? {|b| b["strategy"] == "weighted" } ? "weighted" : "sample"
53
+ {
54
+ "strategy" => strategy,
55
+ "filters" => all_filters,
56
+ "meta" => all_meta,
57
+ "items" => all_items,
58
+ }
59
+ end
60
+
42
61
  def not_found(key)
43
62
  hsh = NOT_FOUND_BUILDER.dup
44
63
  hsh["key"] = key
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Text
4
4
  module Gen
5
- VERSION = "0.12.5"
5
+ VERSION = "0.13.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: text-gen
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.5
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - G Palmer