gamefic 3.4.0 → 3.6.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.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +1 -1
  4. data/lib/gamefic/action.rb +1 -0
  5. data/lib/gamefic/active/epic.rb +1 -0
  6. data/lib/gamefic/active.rb +4 -0
  7. data/lib/gamefic/chapter.rb +25 -42
  8. data/lib/gamefic/command.rb +49 -1
  9. data/lib/gamefic/dispatcher.rb +8 -31
  10. data/lib/gamefic/entity.rb +26 -0
  11. data/lib/gamefic/expression.rb +3 -0
  12. data/lib/gamefic/narrative.rb +9 -5
  13. data/lib/gamefic/node.rb +3 -5
  14. data/lib/gamefic/plot.rb +5 -0
  15. data/lib/gamefic/proxy/base.rb +27 -0
  16. data/lib/gamefic/proxy/config.rb +16 -0
  17. data/lib/gamefic/proxy/pick.rb +11 -0
  18. data/lib/gamefic/proxy/plot_pick.rb +11 -0
  19. data/lib/gamefic/proxy.rb +79 -0
  20. data/lib/gamefic/query/abstract.rb +12 -0
  21. data/lib/gamefic/query/base.rb +62 -16
  22. data/lib/gamefic/query/general.rb +6 -15
  23. data/lib/gamefic/query/result.rb +4 -1
  24. data/lib/gamefic/query/scoped.rb +3 -21
  25. data/lib/gamefic/query/text.rb +17 -15
  26. data/lib/gamefic/query.rb +1 -0
  27. data/lib/gamefic/response.rb +75 -34
  28. data/lib/gamefic/scanner/base.rb +44 -0
  29. data/lib/gamefic/scanner/fuzzy.rb +17 -0
  30. data/lib/gamefic/scanner/fuzzy_nesting.rb +14 -0
  31. data/lib/gamefic/scanner/nesting.rb +39 -0
  32. data/lib/gamefic/scanner/result.rb +60 -0
  33. data/lib/gamefic/scanner/strict.rb +31 -0
  34. data/lib/gamefic/scanner.rb +33 -111
  35. data/lib/gamefic/scope/descendants.rb +16 -0
  36. data/lib/gamefic/scope/family.rb +31 -8
  37. data/lib/gamefic/scope.rb +1 -0
  38. data/lib/gamefic/scriptable/actions.rb +4 -23
  39. data/lib/gamefic/scriptable/entities.rb +32 -17
  40. data/lib/gamefic/scriptable/plot_proxies.rb +29 -0
  41. data/lib/gamefic/scriptable/proxies.rb +31 -0
  42. data/lib/gamefic/scriptable/queries.rb +27 -8
  43. data/lib/gamefic/scriptable/scenes.rb +7 -1
  44. data/lib/gamefic/scriptable.rb +78 -16
  45. data/lib/gamefic/stage.rb +2 -2
  46. data/lib/gamefic/subplot.rb +33 -1
  47. data/lib/gamefic/version.rb +1 -1
  48. data/lib/gamefic.rb +1 -1
  49. metadata +17 -4
  50. data/lib/gamefic/composer.rb +0 -70
  51. data/lib/gamefic/scriptable/proxy.rb +0 -69
@@ -13,25 +13,23 @@ module Gamefic
13
13
  # @return [Boolean]
14
14
  attr_reader :ambiguous
15
15
 
16
+ attr_accessor :narrative
17
+
16
18
  # @raise [ArgumentError] if any of the arguments are nil
17
19
  #
18
20
  # @param arguments [Array<Object>]
19
21
  # @param ambiguous [Boolean]
20
- def initialize *arguments, ambiguous: false
22
+ # @param name [String]
23
+ def initialize *arguments, ambiguous: false, name: self.class.to_s
21
24
  raise ArgumentError, "nil argument in query" if arguments.any?(&:nil?)
22
25
 
23
26
  @arguments = arguments
24
27
  @ambiguous = ambiguous
28
+ @name = name
25
29
  end
26
30
 
27
31
  # Get a query result for a given subject and token.
28
32
  #
29
- # @note This method is retained as a convenience for authors. Narratives
30
- # should use Composer to build commands, as it provides more precise
31
- # matching of tokens to valid response arguments. Authors can use
32
- # #query to find entities that match a token regardless of whether the
33
- # result matches an available response.
34
- #
35
33
  # @example
36
34
  # respond :reds do |actor|
37
35
  # reds = available(ambiguous: true).query(actor, 'red').match
@@ -41,19 +39,44 @@ module Gamefic
41
39
  # @param subject [Gamefic::Entity]
42
40
  # @param token [String]
43
41
  # @return [Result]
44
- def query(_subject, _token)
45
- raise "#query not implemented for #{self.class}"
42
+ def query(subject, token)
43
+ first_pass = Scanner.scan(span(subject), token)
44
+ if ambiguous?
45
+ ambiguous_result(first_pass.filter(*normalized_arguments))
46
+ elsif first_pass.match.one?
47
+ unambiguous_result(first_pass.filter(*normalized_arguments))
48
+ else
49
+ unambiguous_result(first_pass)
50
+ end
51
+ end
52
+ alias filter query
53
+
54
+ # Get an array of entities that match the arguments from the context of
55
+ # the subject.
56
+ #
57
+ # @param subject [Entity]
58
+ # @return [Array<Entity>]
59
+ def select subject
60
+ span(subject).that_are(*normalized_arguments)
46
61
  end
47
62
 
48
- # Get an array of entities that match the query from the context of the
49
- # subject.
63
+ # Get an array of entities that are candidates for selection from the
64
+ # context of the subject. These are the entities that #select will
65
+ # filter through query's arguments.
66
+ #
67
+ # Subclasses should override this method.
50
68
  #
51
69
  # @param subject [Entity]
52
70
  # @return [Array<Entity>]
53
- def select _subject
54
- raise "#select not implemented for #{self.class}"
71
+ def span _subject
72
+ []
55
73
  end
56
74
 
75
+ # True if the object is selectable by the subject.
76
+ #
77
+ # @param subject [Entity]
78
+ # @param object [Array<Entity>, Entity]
79
+ # @return [Boolean]
57
80
  def accept?(subject, object)
58
81
  available = select(subject)
59
82
  if ambiguous?
@@ -72,12 +95,20 @@ module Gamefic
72
95
  @ambiguous
73
96
  end
74
97
 
98
+ def name
99
+ @name || self.class.to_s
100
+ end
101
+
102
+ def inspect
103
+ "##{ambiguous? ? '*' : ''}#{name}(#{normalized_arguments.map(&:inspect).join(', ')})"
104
+ end
105
+
75
106
  private
76
107
 
77
108
  def calculate_precision
78
- @arguments.sum(@ambiguous ? -1000 : 0) do |arg|
109
+ normalized_arguments.sum(@ambiguous ? -1000 : 0) do |arg|
79
110
  case arg
80
- when Entity, Scriptable::Proxy::Agent
111
+ when Entity, Proxy, Proxy::Base
81
112
  1000
82
113
  when Class, Module
83
114
  class_depth(arg) * 100
@@ -107,7 +138,22 @@ module Gamefic
107
138
  def unambiguous_result scan
108
139
  return Result.new(nil, scan.token) unless scan.matched.one?
109
140
 
110
- Result.new(scan.matched.first, scan.remainder)
141
+ Result.new(scan.matched.first, scan.remainder, scan.strictness)
142
+ end
143
+
144
+ def normalized_arguments
145
+ @normalized_arguments ||= arguments.map do |arg|
146
+ case arg
147
+ when Proxy, Proxy::Base
148
+ arg.fetch(narrative)
149
+ when String
150
+ proc do |entity|
151
+ arg.keywords.all? { |word| entity.keywords.include?(word) }
152
+ end
153
+ else
154
+ arg
155
+ end
156
+ end
111
157
  end
112
158
  end
113
159
  end
@@ -14,22 +14,13 @@ module Gamefic
14
14
  # @param entities [Array, Proc]
15
15
  # @param arguments [Array<Object>]
16
16
  # @param ambiguous [Boolean]
17
- def initialize entities, *arguments, ambiguous: false
18
- super(*arguments, ambiguous: ambiguous)
17
+ def initialize entities, *arguments, ambiguous: false, name: nil
18
+ super(*arguments, ambiguous: ambiguous, name: name)
19
19
  @entities = entities
20
20
  end
21
21
 
22
- def select subject
23
- available_entities(subject).that_are(*@arguments)
24
- end
25
-
26
- def query subject, token
27
- filtered = available_entities(subject).that_are(*@arguments)
28
- return Result.new(token, nil) if filtered.include?(token)
29
-
30
- scan = Scanner.scan(filtered, token)
31
-
32
- ambiguous? ? ambiguous_result(scan) : unambiguous_result(scan)
22
+ def span subject
23
+ available_entities(subject)
33
24
  end
34
25
 
35
26
  private
@@ -37,9 +28,9 @@ module Gamefic
37
28
  def available_entities(subject)
38
29
  if @entities.is_a?(Proc)
39
30
  if @entities.arity.zero?
40
- @entities.call
31
+ Stage.run narrative, &@entities
41
32
  else
42
- @entities.call(subject)
33
+ Stage.run narrative, subject, &@entities
43
34
  end
44
35
  else
45
36
  @entities
@@ -11,9 +11,12 @@ module Gamefic
11
11
  # @return [String]
12
12
  attr_reader :remainder
13
13
 
14
- def initialize match, remainder
14
+ attr_reader :strictness
15
+
16
+ def initialize match, remainder, strictness = 0
15
17
  @match = match
16
18
  @remainder = remainder
19
+ @strictness = strictness
17
20
  end
18
21
  end
19
22
  end
@@ -10,36 +10,18 @@ module Gamefic
10
10
  attr_reader :scope
11
11
 
12
12
  # @param scope [Class<Gamefic::Scope::Base>]
13
- def initialize scope, *arguments, ambiguous: false
14
- super(*arguments, ambiguous: ambiguous)
13
+ def initialize scope, *arguments, ambiguous: false, name: nil
14
+ super(*arguments, ambiguous: ambiguous, name: name)
15
15
  @scope = scope
16
16
  end
17
17
 
18
- def select(subject)
18
+ def span(subject)
19
19
  @scope.matches(subject)
20
- .that_are(*@arguments)
21
- end
22
-
23
- # @return [Result]
24
- def query(subject, token)
25
- available = @scope.matches(subject)
26
- .that_are(*@arguments)
27
- return Result.new(token, nil) if available.include?(token)
28
-
29
- scan = Scanner.scan(available, token)
30
-
31
- return ambiguous_result(scan) if ambiguous?
32
-
33
- unambiguous_result(scan)
34
20
  end
35
21
 
36
22
  def precision
37
23
  @precision ||= @scope.precision + calculate_precision
38
24
  end
39
-
40
- def ambiguous?
41
- @ambiguous
42
- end
43
25
  end
44
26
  end
45
27
  end
@@ -4,16 +4,21 @@ module Gamefic
4
4
  module Query
5
5
  # A special query that handles text instead of entities.
6
6
  #
7
- class Text
7
+ class Text < Base
8
8
  # @param argument [String, Regexp]
9
- def initialize argument = /.*/
10
- @argument = argument
11
- validate
9
+ # @param name [String, nil]
10
+ def initialize argument = /.*/, name: self.class.name
11
+ super(argument, name: name)
12
+ validate_argument
13
+ end
14
+
15
+ def argument
16
+ arguments.first
12
17
  end
13
18
 
14
19
  # @return [String, Regexp]
15
20
  def select(_subject)
16
- @argument
21
+ argument
17
22
  end
18
23
 
19
24
  def query _subject, token
@@ -23,6 +28,7 @@ module Gamefic
23
28
  Result.new(nil, token)
24
29
  end
25
30
  end
31
+ alias filter query
26
32
 
27
33
  def precision
28
34
  0
@@ -32,25 +38,21 @@ module Gamefic
32
38
  match? argument
33
39
  end
34
40
 
35
- def ambiguous?
36
- true
37
- end
38
-
39
41
  private
40
42
 
41
43
  def match? token
42
- return false unless token.is_a?(String)
44
+ return false unless token.is_a?(String) && !token.empty?
43
45
 
44
- case @argument
46
+ case argument
45
47
  when Regexp
46
- token =~ @argument
48
+ token =~ argument
47
49
  else
48
- token == @argument
50
+ token == argument
49
51
  end
50
52
  end
51
53
 
52
- def validate
53
- return if @argument.is_a?(String) || @argument.is_a?(Regexp)
54
+ def validate_argument
55
+ return if argument.is_a?(String) || argument.is_a?(Regexp)
54
56
 
55
57
  raise ArgumentError, 'Invalid text query argument'
56
58
  end
data/lib/gamefic/query.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'gamefic/query/base'
4
4
  require 'gamefic/query/general'
5
+ require 'gamefic/query/abstract'
5
6
  require 'gamefic/query/scoped'
6
7
  require 'gamefic/query/text'
7
8
  require 'gamefic/query/result'
@@ -8,19 +8,18 @@ module Gamefic
8
8
  # @return [Symbol]
9
9
  attr_reader :verb
10
10
 
11
- # @return [Array<Query::Base>]
11
+ # @return [Array<Query::Base, Query::Text>]
12
12
  attr_reader :queries
13
13
 
14
14
  # @param verb [Symbol]
15
15
  # @param narrative [Narrative]
16
- # @param queries [Array<Query::Base>]
16
+ # @param args [Array<Object>]
17
17
  # @param meta [Boolean]
18
18
  # @param block [Proc]
19
- def initialize verb, narrative, *queries, meta: false, &block
19
+ def initialize verb, narrative, *args, meta: false, &block
20
20
  @verb = verb
21
- @queries = map_queryable_objects(queries)
21
+ @queries = map_queries(args, narrative)
22
22
  @meta = meta
23
- @block = block
24
23
  @callback = Callback.new(narrative, block)
25
24
  end
26
25
 
@@ -57,53 +56,95 @@ module Gamefic
57
56
  # @param actor [Active]
58
57
  # @param command [Command]
59
58
  def accept? actor, command
60
- return false if command.verb != verb || command.arguments.length != queries.length
61
-
62
- queries.each_with_index do |query, idx|
63
- return false unless query.accept?(actor, command.arguments[idx])
64
- end
65
-
66
- true
59
+ command.verb == verb &&
60
+ command.arguments.length == queries.length &&
61
+ queries.zip(command.arguments).all? { |query, argument| query.accept?(actor, argument) }
67
62
  end
68
63
 
69
64
  def execute *args
70
- @callback.run *args
65
+ @callback.run(*args)
71
66
  end
72
67
 
73
68
  def precision
74
69
  @precision ||= calculate_precision
75
70
  end
76
71
 
72
+ # Turn an actor and an expression into a command by matching the
73
+ # expression's tokens to queries. Return nil if the expression
74
+ # could not be matched.
75
+ #
76
+ # @param actor [Actor]
77
+ # @param expression [Expression]
78
+ # @return [Command, nil]
79
+ def to_command actor, expression
80
+ return log_and_discard unless expression.verb == verb && expression.tokens.length <= queries.length
81
+
82
+ results = filter(actor, expression)
83
+ return log_and_discard unless results
84
+
85
+ Gamefic.logger.info "Accepted #{inspect}"
86
+ Command.new(
87
+ verb,
88
+ results.map(&:match),
89
+ results.sum(&:strictness),
90
+ precision
91
+ )
92
+ end
93
+
94
+ def inspect
95
+ "#<#{self.class} #{([verb] + queries).map(&:inspect).join(', ')}>"
96
+ end
97
+
77
98
  private
78
99
 
100
+ def log_and_discard
101
+ Gamefic.logger.info "Discarded #{inspect}"
102
+ nil
103
+ end
104
+
105
+ def filter actor, expression
106
+ remainder = ''
107
+ result = queries.zip(expression.tokens)
108
+ .map do |query, token|
109
+ token = "#{remainder} #{token}".strip
110
+ result = query.filter(actor, token)
111
+ return nil unless result.match
112
+
113
+ remainder = result.remainder
114
+ result
115
+ end
116
+ result if remainder.empty?
117
+ end
118
+
79
119
  def generate_default_syntax
80
- user_friendly = verb.to_s.gsub(/_/, ' ')
81
- args = []
82
- used_names = []
83
- queries.each do |_c|
84
- num = 1
85
- new_name = ":var"
86
- while used_names.include? new_name
87
- num += 1
88
- new_name = ":var#{num}"
89
- end
90
- used_names.push new_name
91
- user_friendly += " #{new_name}"
92
- args.push new_name
93
- end
94
- Syntax.new(user_friendly.strip, "#{verb} #{args.join(' ')}".strip)
120
+ args = queries.length.times.map { |num| num.zero? ? ':var' : ":var#{num + 1}" }
121
+ tmpl = "#{verb} #{args.join(' ')}".strip
122
+ Syntax.new(tmpl.gsub('_', ' '), tmpl)
95
123
  end
96
124
 
97
125
  def calculate_precision
98
- total = 0
99
- queries.each { |q| total += q.precision }
100
- total -= 1000 if verb.nil?
126
+ total = queries.sum(&:precision)
127
+ total -= 1000 unless verb
101
128
  total
102
129
  end
103
130
 
104
- def map_queryable_objects queries
105
- # @todo Considering moving mapping from Actions to here
106
- queries
131
+ def map_queries args, narrative
132
+ args.map do |arg|
133
+ select_query(arg, narrative).tap { |qry| qry.narrative = narrative }
134
+ end
135
+ end
136
+
137
+ def select_query arg, narrative
138
+ case arg
139
+ when Entity, Class, Module, Proc, Proxy, Proxy::Base
140
+ narrative.available(arg)
141
+ when String, Regexp
142
+ narrative.plaintext(arg)
143
+ when Query::Base, Query::Text
144
+ arg
145
+ else
146
+ raise ArgumentError, "invalid argument in response: #{arg.inspect}"
147
+ end
107
148
  end
108
149
  end
109
150
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # A base class for scanners that match tokens to entities.
6
+ #
7
+ class Base
8
+ # @return [Array<Entity>]
9
+ attr_reader :selection
10
+
11
+ # @return [String]
12
+ attr_reader :token
13
+
14
+ # @param selection [Array<Entity>]
15
+ # @param token [String]
16
+ def initialize selection, token
17
+ @selection = selection
18
+ @token = token
19
+ end
20
+
21
+ # @return [Result]
22
+ def scan
23
+ unmatched_result
24
+ end
25
+
26
+ # @param selection [Array<Entity>]
27
+ # @param token [String]
28
+ # @return [Result]
29
+ def self.scan selection, token
30
+ new(selection, token).scan
31
+ end
32
+
33
+ private
34
+
35
+ def unmatched_result
36
+ Result.unmatched(selection, token, self.class)
37
+ end
38
+
39
+ def matched_result matched, remainder
40
+ Result.new(selection, token, matched, remainder, self.class)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # Fuzzy token matching.
6
+ #
7
+ # An entity will match a word in a fuzzy scan if it matches the beginning
8
+ # of one of the entity's keywords, e.g., `pen` is a fuzzy token match for
9
+ # the keyword `pencil`.
10
+ #
11
+ class Fuzzy < Strict
12
+ def match_word available, word
13
+ available.select { |obj| obj.keywords.any? { |wrd| wrd.start_with?(word) } }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # Fuzzy scanning for entities inside of other entities, e.g., `soc in draw`
6
+ # would match `sock in drawer`.
7
+ #
8
+ class FuzzyNesting < Nesting
9
+ def subprocessor
10
+ Fuzzy
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # Strict scanning for entities inside of other entities, e.g., `sock inside drawer`.
6
+ #
7
+ class Nesting < Base
8
+ NEST_REGEXP = / in | on | of | from | inside | inside of | from inside | off | out | out of /.freeze
9
+
10
+ def subprocessor
11
+ Strict
12
+ end
13
+
14
+ def scan
15
+ return unmatched_result unless token =~ NEST_REGEXP
16
+
17
+ denest selection, token
18
+ end
19
+
20
+ private
21
+
22
+ def denest objects, token
23
+ near = objects
24
+ far = objects
25
+ parts = token.split(NEST_REGEXP)
26
+ until parts.empty?
27
+ current = parts.pop
28
+ last_result = subprocessor.scan(near, current)
29
+ last_result = subprocessor.scan(far, current) if last_result.matched.empty? && near != far
30
+ return unmatched_result if last_result.matched.empty? || last_result.matched.length > 1
31
+
32
+ near = last_result.matched.first.children & objects
33
+ far = last_result.matched.first.flatten & objects
34
+ end
35
+ last_result
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # The result of an attempt to scan objects against a token in a Scanner. It
6
+ # provides an array of matching objects, the text that matched them, and the
7
+ # text that remains unmatched.
8
+ #
9
+ class Result
10
+ # The scanned objects
11
+ #
12
+ # @return [Array<Entity>, String, Regexp]
13
+ attr_reader :scanned
14
+
15
+ # The scanned token
16
+ #
17
+ # @return [String]
18
+ attr_reader :token
19
+
20
+ # The matched objects
21
+ #
22
+ # @return [Array<Entity>, String]
23
+ attr_reader :matched
24
+ alias match matched
25
+
26
+ # The remaining (unmatched) portion of the token
27
+ #
28
+ # @return [String]
29
+ attr_reader :remainder
30
+
31
+ attr_reader :processor
32
+
33
+ def initialize scanned, token, matched, remainder, processor
34
+ @scanned = scanned
35
+ @token = token
36
+ @matched = matched
37
+ @remainder = remainder
38
+ @processor = processor
39
+ end
40
+
41
+ def strictness
42
+ @strictness ||= Scanner.strictness(processor)
43
+ end
44
+
45
+ def filter *args
46
+ Scanner::Result.new(
47
+ scanned,
48
+ token,
49
+ match.that_are(*args),
50
+ remainder,
51
+ processor
52
+ )
53
+ end
54
+
55
+ def self.unmatched scanned, token, processor
56
+ new(scanned, token, [], token, processor)
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Scanner
5
+ # Strict token matching.
6
+ #
7
+ # An entity will only match a word in a strict scan if the entire word
8
+ # matches one of the entity's keywords.
9
+ #
10
+ class Strict < Base
11
+ # @return [Result]
12
+ def scan
13
+ words = token.keywords
14
+ available = selection.clone
15
+ filtered = []
16
+ words.each_with_index do |word, idx|
17
+ tested = match_word(available, word)
18
+ return matched_result(filtered, words[idx..].join(' ')) if tested.empty?
19
+
20
+ filtered = tested
21
+ available = filtered
22
+ end
23
+ matched_result filtered, ''
24
+ end
25
+
26
+ def match_word available, word
27
+ available.select { |obj| obj.keywords.include?(word) }
28
+ end
29
+ end
30
+ end
31
+ end