gamefic 3.0.0 → 3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6647dc3319080277826fecd19b8466cfbe82a42f4f4c68aeb89fa74bd0de4319
4
- data.tar.gz: 0dc0680cb42b981d6e93c12d03e95b4ffcce0a98acb0678595723ab2d5ae7db9
3
+ metadata.gz: 3900923aaef12a43321ce6f9cc5957953b12bba7d939e0a1666d8c6c75703855
4
+ data.tar.gz: 6023310c5e26e9632ed4c053a9bea619e1455dbb66966d82fc9e772044d19d3a
5
5
  SHA512:
6
- metadata.gz: 1ec19185367b63b0ebe352e620af403e0aef00bdd7afac7135801551568ef5c35c923b45092cb4d781cd8458bba71776b042efe9837a0e9d2f144141b6b8d606
7
- data.tar.gz: 300d2c30b05993ee4689f14ce8daeeec3585bfdf1cd7636ec6a931bfc568e8227a97771fdf6d20ec859080f3792b914a29ba1fc6a7246c9f9ef9eb563d81920c
6
+ metadata.gz: aea9a57211541f056c730707227020f2c35376d59cc4e69c3c7463e8a8bafa7b6fcd4585a99bf2ce235fdc4c583393f53e060554e06f6e5912358959305627eb
7
+ data.tar.gz: 5351ea7cc8b3ff4e01e064eb12a594dc2d928641b19e8593243da00b672780acff0d808d73eca1a80cf2e2139fd657b870fa7a5a5315e0a2a3998bc306db0c05
data/CHANGELOG.md CHANGED
@@ -1,4 +1,13 @@
1
- ## 3.0.0
1
+ ## 3.1.0 - April 8, 2024
2
+ - Dispatcher prioritizes strict token matches
3
+ - Scanner builds commands
4
+ - Tokenize expressions and execute commands
5
+ - Delete concluded subplots last in Plot#ready
6
+ - Fix plot conclusion check after subplots conclude
7
+ - Correct contexts for conclude and output blocks
8
+ - Reinstate Active#last_input
9
+
10
+ ## 3.0.0 - January 27, 2024
2
11
  - Instantiate subplots from snapshots
3
12
  - Split Action into Response and Action
4
13
  - Logging
@@ -13,8 +13,10 @@ module Gamefic
13
13
  # action's performance.
14
14
  #
15
15
  class Hook
16
+ # @param [Array<Symbol>]
16
17
  attr_reader :verbs
17
18
 
19
+ # @param [Proc]
18
20
  attr_reader :block
19
21
 
20
22
  def initialize *verbs, &block
@@ -45,11 +47,13 @@ module Gamefic
45
47
  @response = response
46
48
  end
47
49
 
50
+ # @return [self]
48
51
  def execute
49
- return if cancelled?
52
+ return self if cancelled? || executed?
50
53
 
51
54
  @executed = true
52
55
  response.execute actor, *arguments
56
+ self
53
57
  end
54
58
 
55
59
  # True if the response has been executed. False typically means that the
@@ -75,6 +79,10 @@ module Gamefic
75
79
  response.verb
76
80
  end
77
81
 
82
+ def narrative
83
+ response.narrative
84
+ end
85
+
78
86
  def meta?
79
87
  response.meta?
80
88
  end
@@ -47,9 +47,14 @@ module Gamefic
47
47
  narratives.one?
48
48
  end
49
49
 
50
- # @param name [Symbol]
51
- def conclusion? name
52
- select_scene(name).conclusion?
50
+ def syntaxes
51
+ rulebooks.flat_map(&:syntaxes)
52
+ end
53
+
54
+ def responses_for(*verbs)
55
+ rulebooks.to_a
56
+ .reverse
57
+ .flat_map { |rb| rb.responses_for(*verbs) }
53
58
  end
54
59
 
55
60
  # @param name [Symbol]
@@ -16,7 +16,7 @@ module Gamefic
16
16
 
17
17
  # @param actor [Active]
18
18
  # @param cue [Active::Cue]
19
- # @param props [Props::Default]
19
+ # @param props [Props::Default, nil]
20
20
  def initialize actor, cue, props = nil
21
21
  @actor = actor
22
22
  @cue = cue
@@ -31,12 +31,12 @@ module Gamefic
31
31
 
32
32
  # @return [Props::Default]
33
33
  def start
34
- actor.output[:scene] = scene.to_hash
34
+ props.output[:scene] = scene.to_hash
35
35
  scene.run_start_blocks actor, props
36
36
  scene.start actor, props
37
37
  # @todo See if this can be handled better
38
- actor.epic.rulebooks.each { |rlbk| rlbk.run_player_output_blocks actor, actor.output }
39
- actor.output.merge!({
38
+ actor.epic.rulebooks.each { |rlbk| rlbk.run_player_output_blocks actor, props.output }
39
+ props.output.merge!({
40
40
  messages: actor.messages,
41
41
  queue: actor.queue
42
42
  })
@@ -47,7 +47,7 @@ module Gamefic
47
47
  def finish
48
48
  actor.flush
49
49
  scene.finish(actor, props)
50
- actor.output.replace(last_prompt: props.prompt, last_input: props.input)
50
+ props.output.replace(last_prompt: props.prompt, last_input: props.input)
51
51
  scene.run_finish_blocks actor, props
52
52
  end
53
53
 
@@ -21,6 +21,9 @@ module Gamefic
21
21
  # @return [Active::Cue, nil]
22
22
  attr_reader :next_cue
23
23
 
24
+ # @return [String, nil]
25
+ attr_reader :last_input
26
+
24
27
  # @return [Symbol, nil]
25
28
  def next_scene
26
29
  next_cue&.scene
@@ -48,12 +51,22 @@ module Gamefic
48
51
  @queue ||= []
49
52
  end
50
53
 
51
- # A hash of data that will be sent to the user. The output is typically
52
- # sent after a scene has started and before the user is prompted for input.
54
+ # Data that will be sent to the user. The output is typically sent after a
55
+ # scene has started and before the user is prompted for input.
56
+ #
57
+ # The output object attached to the actor is always frozen. Authors should
58
+ # use on_player_output blocks to modify output to be sent to the user.
53
59
  #
54
- # @return [Hash]
60
+ # @return [Props::Output]
55
61
  def output
56
- @output ||= {}
62
+ @output ||= Props::Output.new.freeze
63
+ end
64
+
65
+ # The output from the previous turn.
66
+ #
67
+ # @return [Props::Output]
68
+ def last_output
69
+ @last_output ||= output
57
70
  end
58
71
 
59
72
  # Perform a command.
@@ -65,11 +78,10 @@ module Gamefic
65
78
  # character.perform "take the key"
66
79
  #
67
80
  # @param command [String]
68
- # @return [void]
81
+ # @return [Action, nil]
69
82
  def perform(command)
70
83
  dispatchers.push Dispatcher.dispatch(self, command)
71
- dispatchers.last.execute
72
- dispatchers.pop
84
+ dispatchers.last.execute.tap { dispatchers.pop }
73
85
  end
74
86
 
75
87
  # Quietly perform a command.
@@ -95,11 +107,10 @@ module Gamefic
95
107
  #
96
108
  # @param verb [Symbol]
97
109
  # @param params [Array]
98
- # @return [Gamefic::Action]
110
+ # @return [Action, nil]
99
111
  def execute(verb, *params)
100
112
  dispatchers.push Dispatcher.dispatch_from_params(self, verb, params)
101
- dispatchers.last.execute
102
- dispatchers.pop
113
+ dispatchers.last.execute.tap { dispatchers.pop }
103
114
  end
104
115
 
105
116
  # Proceed to the next Action in the current stack.
@@ -125,9 +136,9 @@ module Gamefic
125
136
  # end
126
137
  # end
127
138
  #
128
- # @return [void]
139
+ # @return [Action, nil]
129
140
  def proceed
130
- dispatchers.last&.proceed&.execute
141
+ dispatchers.last&.proceed
131
142
  end
132
143
 
133
144
  # Cue a scene to start in the next turn.
@@ -146,17 +157,22 @@ module Gamefic
146
157
  end
147
158
  alias prepare cue
148
159
 
160
+ # @return [void]
149
161
  def start_take
150
162
  ensure_cue
151
163
  @last_cue = @next_cue
152
164
  cue :default_scene
153
165
  @props = Take.start(self, @last_cue)
166
+ @last_output = self.output
167
+ @output = @props.output.dup.freeze
154
168
  end
155
169
 
170
+ # @return [void]
156
171
  def finish_take
157
172
  return unless @last_cue
158
173
 
159
174
  Take.finish(self, @last_cue, @props)
175
+ @last_input = @props.input
160
176
  end
161
177
 
162
178
  # Restart the scene from the most recent cue.
@@ -175,6 +191,7 @@ module Gamefic
175
191
  #
176
192
  # @param new_scene [Symbol]
177
193
  # @oaram context [Hash] Additional scene data
194
+ # @return [Cue]
178
195
  def conclude scene, **context
179
196
  cue scene, **context
180
197
  available = epic.select_scene(scene)
@@ -186,7 +203,7 @@ module Gamefic
186
203
  # True if the actor is ready to leave the game.
187
204
  #
188
205
  def concluding?
189
- epic.empty? || (@last_cue && epic.conclusion?(@last_cue.scene))
206
+ epic.empty? || @props&.scene&.type == 'Conclusion'
190
207
  end
191
208
 
192
209
  def accessible?
@@ -1,31 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gamefic
4
- # A decomposition of a text-based command into its verb and arguments.
5
- #
6
- # Commands are typically derived from tokenization against syntaxes.
4
+ # A concrete representation of an input as a verb and an array of arguments.
7
5
  #
8
6
  class Command
9
7
  # @return [Symbol]
10
8
  attr_reader :verb
11
9
 
12
- # @return [Array<String>]
10
+ # @return [Array<Array<Entity>, Entity, String>]
13
11
  attr_reader :arguments
14
12
 
13
+ # @param verb [Symbol]
14
+ # @param arguments [Array<Array<Entity>, Entity, String>]
15
15
  def initialize verb, arguments
16
16
  @verb = verb
17
17
  @arguments = arguments
18
18
  end
19
-
20
- # Compare two syntaxes for the purpose of ordering them by relevance while
21
- # dispatching.
22
- #
23
- def compare other
24
- if verb == other.verb
25
- other.arguments.compact.length <=> arguments.compact.length
26
- else
27
- (other.verb ? 1 : 0) <=> (verb ? 1 : 0)
28
- end
29
- end
30
19
  end
31
20
  end
@@ -0,0 +1,68 @@
1
+ module Gamefic
2
+ # A function module for creating commands from expressions.
3
+ #
4
+ module Composer
5
+ # Create a command from the first expression that matches a response.
6
+ #
7
+ # @param actor [Actor]
8
+ # @param expressions [Array<Expression>]
9
+ # @return [Command]
10
+ def self.compose actor, expressions
11
+ %i[strict fuzzy].each do |method|
12
+ result = match_expressions_to_response actor, expressions, method
13
+ return result if result
14
+ end
15
+ Command.new(nil, [])
16
+ end
17
+
18
+ class << self
19
+ private
20
+
21
+ def match_expressions_to_response actor, expressions, method
22
+ expressions.each do |expression|
23
+ result = match_response_arguments actor, expression, method
24
+ return result if result
25
+ end
26
+ nil
27
+ end
28
+
29
+ def match_response_arguments actor, expression, method
30
+ actor.epic.responses_for(expression.verb).each do |response|
31
+ next unless response.queries.length >= expression.tokens.length
32
+
33
+ result = match_query_arguments(actor, expression, response, method)
34
+ return result if result
35
+ end
36
+ nil
37
+ end
38
+
39
+ def match_query_arguments actor, expression, response, method
40
+ remainder = response.verb ? '' : expression.verb.to_s
41
+ arguments = []
42
+ response.queries.each_with_index do |query, idx|
43
+ result = Scanner.send(method, query.select(actor), "#{remainder} #{expression.tokens[idx]}".strip)
44
+ break unless validate_result_from_query(result, query)
45
+
46
+ if query.ambiguous?
47
+ arguments.push result.matched
48
+ else
49
+ arguments.push result.matched.first
50
+ end
51
+ remainder = result.remainder
52
+ end
53
+
54
+ return nil if arguments.length != response.queries.length || remainder != ''
55
+
56
+ Command.new(response.verb, arguments)
57
+ end
58
+
59
+ # @param result [Scanner::Result]
60
+ # @param query [Query::Base]
61
+ def validate_result_from_query result, query
62
+ return false if result.matched.empty?
63
+
64
+ result.matched.length == 1 || query.ambiguous?
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,63 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gamefic
4
- # The action selector for character commands.
4
+ # The action executor for character commands.
5
5
  #
6
6
  class Dispatcher
7
7
  # @param actor [Actor]
8
- # @param commands [Array<Command>]
9
- # @param responses [Array<Response>]
10
- def initialize actor, commands = [], responses = []
8
+ # @param command [Command]
9
+ def initialize actor, command
11
10
  @actor = actor
12
- @commands = commands
13
- @responses = responses
11
+ @command = command
14
12
  @executed = false
13
+ @finalized = false
15
14
  end
16
15
 
17
16
  # Run the dispatcher.
18
17
  #
18
+ # @return [Action, nil]
19
19
  def execute
20
20
  return if @executed
21
21
 
22
- action = proceed
22
+ @executed = true
23
+ action = next_action
23
24
  return unless action
24
25
 
25
- @executed = action.arguments
26
26
  run_before_action_hooks action
27
27
  return if action.cancelled?
28
28
 
29
29
  action.execute
30
30
  run_after_action_hooks action
31
+ action
31
32
  end
32
33
 
33
- # Get the next executable action.
34
+ # Execute the next available action.
35
+ #
36
+ # Actors should run #execute first.
34
37
  #
35
38
  # @return [Action, nil]
36
39
  def proceed
37
- while (response = responses.shift)
38
- commands.each do |cmd|
39
- action = response.attempt(actor, cmd)
40
- next unless action && arguments_match?(action.arguments)
40
+ return unless @executed
41
41
 
42
- return action
43
- end
44
- end
45
- nil # Without this, return value in Opal is undefined
42
+ next_action&.execute
46
43
  end
47
44
 
48
45
  # @param actor [Active]
49
46
  # @param input [String]
50
47
  # @return [Dispatcher]
51
48
  def self.dispatch actor, input
52
- commands = Syntax.tokenize(input, actor.epic.rulebooks.flat_map(&:syntaxes))
53
- verbs = commands.map(&:verb).uniq
54
- responses = actor.epic
55
- .rulebooks
56
- .to_a
57
- .reverse
58
- .flat_map { |pb| pb.responses_for(*verbs) }
59
- .reject(&:hidden?)
60
- new(actor, commands, responses)
49
+ expressions = Syntax.tokenize(input, actor.epic.syntaxes)
50
+ command = Composer.compose(actor, expressions)
51
+ new(actor, command)
61
52
  end
62
53
 
63
54
  # @param actor [Active]
@@ -66,12 +57,7 @@ module Gamefic
66
57
  # @return [Dispatcher]
67
58
  def self.dispatch_from_params actor, verb, params
68
59
  command = Command.new(verb, params)
69
- responses = actor.epic
70
- .rulebooks
71
- .to_a
72
- .reverse
73
- .flat_map { |pb| pb.responses_for(verb) }
74
- new(actor, [command], responses)
60
+ new(actor, command)
75
61
  end
76
62
 
77
63
  protected
@@ -79,27 +65,48 @@ module Gamefic
79
65
  # @return [Actor]
80
66
  attr_reader :actor
81
67
 
82
- # @return [Array<Command>]
83
- attr_reader :commands
68
+ # @return [Command]
69
+ attr_reader :command
84
70
 
85
71
  # @return [Array<Response>]
86
- attr_reader :responses
72
+ def responses
73
+ @responses ||= actor.epic.responses_for(command.verb)
74
+ end
87
75
 
88
76
  private
89
77
 
90
- # After the first action gets selected, subsequent actions need to use the
91
- # same arguments.
92
- #
93
- def arguments_match? arguments
94
- !@executed || arguments == @executed
78
+ # @return [Action, nil]
79
+ def next_action
80
+ while (response = responses.shift)
81
+ next if response.queries.length < @command.arguments.length
82
+
83
+ return Action.new(actor, @command.arguments, response) if response.accept?(actor, @command)
84
+ end
85
+ finalize
95
86
  end
96
87
 
88
+ # @return [void]
97
89
  def run_before_action_hooks action
98
90
  actor.epic.rulebooks.flat_map { |rlbk| rlbk.run_before_actions action }
99
91
  end
100
92
 
93
+ # @return [void]
101
94
  def run_after_action_hooks action
102
95
  actor.epic.rulebooks.flat_map { |rlbk| rlbk.run_after_actions action }
103
96
  end
97
+
98
+ # If the dispatcher proceeds through all possible responses, it can fall
99
+ # back to a nil response as a catchall for commands that could not be
100
+ # completed.
101
+ #
102
+ # @return [Action, nil]
103
+ def finalize
104
+ return nil if @finalized
105
+
106
+ @finalized = true
107
+ @command = Command.new(nil, ["#{command.verb} #{command.arguments.join(' ').strip}"])
108
+ @responses = actor.epic.responses_for(nil)
109
+ next_action
110
+ end
104
111
  end
105
112
  end
@@ -9,7 +9,6 @@ module Gamefic
9
9
  class Entity
10
10
  include Describable
11
11
  include Node
12
- # include Messaging
13
12
 
14
13
  def initialize **args
15
14
  klass = self.class
@@ -57,14 +56,13 @@ module Gamefic
57
56
  end
58
57
 
59
58
  class << self
60
- # Set or update the default values for new instances.
59
+ # Set or update the default attributes for new instances.
61
60
  #
62
- # @param attrs [Hash] The attributes to be merged into the defaults.
63
- def set_default attrs = {}
61
+ def set_default **attrs
64
62
  default_attributes.merge! attrs
65
63
  end
66
64
 
67
- # A hash of default values for attributes when creating an instance.
65
+ # A hash of default attributes when creating an instance.
68
66
  #
69
67
  # @return [Hash]
70
68
  def default_attributes
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ # A tokenization of an input from available syntaxes.
5
+ #
6
+ class Expression
7
+ # @return [Symbol]
8
+ attr_reader :verb
9
+
10
+ # @return [Array<String>]
11
+ attr_reader :tokens
12
+
13
+ # @param verb [Symbol, nil]
14
+ # @param tokens [Array<String>]
15
+ def initialize verb, tokens
16
+ @verb = verb
17
+ @tokens = tokens
18
+ end
19
+
20
+ # Compare two syntaxes for the purpose of ordering them by relevance while
21
+ # dispatching.
22
+ #
23
+ def compare other
24
+ if verb == other.verb
25
+ other.tokens.compact.length <=> tokens.compact.length
26
+ else
27
+ (other.verb ? 1 : 0) <=> (verb ? 1 : 0)
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/gamefic/plot.rb CHANGED
@@ -9,8 +9,8 @@ module Gamefic
9
9
  super
10
10
  subplots.each(&:ready)
11
11
  players.each(&:start_take)
12
- subplots.delete_if(&:concluding?)
13
12
  players.select(&:concluding?).each { |plyr| rulebook.run_player_conclude_blocks plyr }
13
+ subplots.delete_if(&:concluding?)
14
14
  end
15
15
 
16
16
  def update
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Gamefic
4
4
  module Props
5
+ SceneData = Struct.new(:name, :type)
6
+
5
7
  # A collection of data related to a scene. Scenes define which Props class
6
8
  # they use. Props can be accessed in a scene's on_start and on_finish
7
9
  # callbacks.
@@ -25,17 +27,23 @@ module Gamefic
25
27
  attr_reader :context
26
28
  alias data context
27
29
 
28
- # @param scene [Scene, nil]
30
+ # @return [SceneData]
31
+ attr_reader :scene
32
+
33
+ # @param scene [Scene]
29
34
  # @param context [Hash]
30
- def initialize name, type, **context
31
- @scene_name = name
32
- @scene_type = type
35
+ def initialize scene, **context
36
+ @scene = SceneData.new(scene.name, scene.type)
33
37
  @context = context
34
38
  end
35
39
 
36
40
  def prompt
37
41
  @prompt ||= '>'
38
42
  end
43
+
44
+ def output
45
+ @output ||= Props::Output.new
46
+ end
39
47
  end
40
48
  end
41
49
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gamefic
4
+ module Props
5
+ # A container for output sent to players with a hash interface for custom
6
+ # data.
7
+ #
8
+ class Output
9
+ # @return [String, nil]
10
+ attr_reader :last_input
11
+
12
+ # @return [String, nil]
13
+ attr_reader :last_prompt
14
+
15
+ def initialize **data
16
+ @raw_data = {
17
+ messages: '',
18
+ options: [],
19
+ queue: [],
20
+ scene: {},
21
+ prompt: ''
22
+ }
23
+ merge! data
24
+ end
25
+
26
+ # @return [String]
27
+ def messages
28
+ raw_data[:messages]
29
+ end
30
+
31
+ # @return [Array<String>]
32
+ def options
33
+ raw_data[:options]
34
+ end
35
+
36
+ # @return [Array<String>]
37
+ def queue
38
+ raw_data[:queue]
39
+ end
40
+
41
+ # @todo Should this be a concrete class?
42
+ # @return [Hash]
43
+ def scene
44
+ raw_data[:scene]
45
+ end
46
+
47
+ # @return [String]
48
+ def prompt
49
+ raw_data[:prompt]
50
+ end
51
+
52
+ def [] key
53
+ raw_data[key]
54
+ end
55
+
56
+ def []= key, value
57
+ raw_data[key] = value
58
+ end
59
+
60
+ # @return [Hash]
61
+ def to_hash
62
+ raw_data.dup
63
+ end
64
+
65
+ def to_json _ = nil
66
+ raw_data.to_json
67
+ end
68
+
69
+ def merge! data
70
+ data.each { |key, val| self[key] = val }
71
+ end
72
+
73
+ def replace data
74
+ raw_data.replace data
75
+ end
76
+
77
+ private
78
+
79
+ attr_reader :raw_data
80
+ end
81
+ end
82
+ end
data/lib/gamefic/props.rb CHANGED
@@ -6,5 +6,6 @@ module Gamefic
6
6
  require 'gamefic/props/multiple_choice'
7
7
  require 'gamefic/props/pause'
8
8
  require 'gamefic/props/yes_or_no'
9
+ require 'gamefic/props/output'
9
10
  end
10
11
  end
@@ -24,6 +24,12 @@ module Gamefic
24
24
  @ambiguous = ambiguous
25
25
  end
26
26
 
27
+ # @deprecated Queries should only be used to select entities that are
28
+ # eligible to be response arguments. After a text command is tokenized
29
+ # into an array of expressions, the composer builds the command that
30
+ # the dispatcher uses to execute actions. The #accept? method verifies
31
+ # that the command's arguments match the response's queries.
32
+ #
27
33
  # @param subject [Gamefic::Entity]
28
34
  # @param token [String]
29
35
  # @return [Result]
@@ -31,6 +37,24 @@ module Gamefic
31
37
  raise "#query not implemented for #{self.class}"
32
38
  end
33
39
 
40
+ # Get an array of entities that match the query from the context of the
41
+ # subject.
42
+ #
43
+ # @param subject [Entity]
44
+ # @return [Array<Entity>]
45
+ def select subject
46
+ raise "#select not implemented for #{self.class}"
47
+ end
48
+
49
+ def accept?(subject, object)
50
+ available = select(subject)
51
+ if ambiguous?
52
+ object & available == object
53
+ else
54
+ available.include?(object)
55
+ end
56
+ end
57
+
34
58
  # @return [Integer]
35
59
  def precision
36
60
  @precision ||= calculate_precision
@@ -19,6 +19,10 @@ module Gamefic
19
19
  @entities = entities
20
20
  end
21
21
 
22
+ def select subject
23
+ available_entities(subject).that_are(*@arguments)
24
+ end
25
+
22
26
  def query subject, token
23
27
  filtered = available_entities(subject).that_are(*@arguments)
24
28
  return Result.new(token, nil) if filtered.include?(token)
@@ -16,6 +16,11 @@ module Gamefic
16
16
  @scope = scope
17
17
  end
18
18
 
19
+ def select(subject)
20
+ @scope.matches(subject)
21
+ .that_are(*@arguments)
22
+ end
23
+
19
24
  # @return [Result]
20
25
  def query(subject, token)
21
26
  available = @scope.matches(subject)
@@ -5,12 +5,17 @@ module Gamefic
5
5
  # A special query that handles text instead of entities.
6
6
  #
7
7
  class Text
8
- # @param argument [String, Regexp, nil]
9
- def initialize argument = nil
8
+ # @param argument [String, Regexp]
9
+ def initialize argument = /.*/
10
10
  @argument = argument
11
11
  validate
12
12
  end
13
13
 
14
+ # @return [String, Regexp]
15
+ def select(_subject)
16
+ @argument
17
+ end
18
+
14
19
  def query _subject, token
15
20
  if match? token
16
21
  Result.new(token, '')
@@ -23,11 +28,17 @@ module Gamefic
23
28
  0
24
29
  end
25
30
 
31
+ def accept? _subject, argument
32
+ match? argument
33
+ end
34
+
35
+ def ambiguous?
36
+ true
37
+ end
38
+
26
39
  private
27
40
 
28
41
  def match? token
29
- return true if @argument.nil?
30
-
31
42
  case @argument
32
43
  when Regexp
33
44
  token =~ @argument
@@ -37,7 +48,7 @@ module Gamefic
37
48
  end
38
49
 
39
50
  def validate
40
- return if @argument.nil? || @argument.is_a?(String) || @argument.is_a?(Regexp)
51
+ return if @argument.is_a?(String) || @argument.is_a?(Regexp)
41
52
 
42
53
  raise ArgumentError, 'Invalid text query argument'
43
54
  end
@@ -1,4 +1,4 @@
1
- # frozen_literal_string: true
1
+ # frozen_string_literal: true
2
2
 
3
3
  module Gamefic
4
4
  # A proc to be executed in response to a command that matches its verb and
@@ -11,17 +11,17 @@ module Gamefic
11
11
  # @return [Array<Query::Base>]
12
12
  attr_reader :queries
13
13
 
14
- # @return [Proc]
15
- # attr_reader :block
14
+ # @return [Narrative]
15
+ attr_reader :narrative
16
16
 
17
17
  # @param verb [Symbol]
18
- # @param stage [Object]
18
+ # @param narrative [Narrative]
19
19
  # @param queries [Array<Query::Base>]
20
20
  # @param meta [Boolean]
21
21
  # @param block [Proc]
22
- def initialize verb, stage, *queries, meta: false, &block
22
+ def initialize verb, narrative, *queries, meta: false, &block
23
23
  @verb = verb
24
- @stage = stage
24
+ @narrative = narrative
25
25
  @queries = map_queryable_objects(queries)
26
26
  @meta = meta
27
27
  @block = block
@@ -48,35 +48,29 @@ module Gamefic
48
48
  #
49
49
  # @param actor [Entity]
50
50
  # @param command [Command]
51
- # @param with_hooks [Boolean]
52
51
  # @return [Action, nil]
53
52
  def attempt actor, command
54
- return nil if command.verb != verb
53
+ return nil unless accept?(actor, command)
55
54
 
56
- tokens = command.arguments.clone
57
- result = []
58
- remainder = ''
59
-
60
- queries.each do |qd|
61
- token = tokens.shift
62
- txt = "#{remainder} #{token}".strip
63
- return nil if txt.empty?
64
-
65
- response = qd.query(actor, txt)
66
- return nil if response.match.nil?
55
+ Action.new(actor, command.arguments, self)
56
+ end
67
57
 
68
- result.push response.match
58
+ # True if the Response can be executed for the given actor and command.
59
+ #
60
+ # @param actor [Active]
61
+ # @param command [Command]
62
+ def accept? actor, command
63
+ return false if command.verb != verb || command.arguments.length != queries.length
69
64
 
70
- remainder = response.remainder
65
+ queries.each_with_index do |query, idx|
66
+ return false unless query.accept?(actor, command.arguments[idx])
71
67
  end
72
68
 
73
- return nil unless tokens.empty? && remainder.empty?
74
-
75
- Action.new(actor, result, self)
69
+ true
76
70
  end
77
71
 
78
72
  def execute *args
79
- Stage.run(@stage, *args, &@block)
73
+ Stage.run(narrative, *args, &@block)
80
74
  end
81
75
 
82
76
  def precision
@@ -34,16 +34,16 @@ module Gamefic
34
34
  self
35
35
  end
36
36
 
37
- # @return [Proc]
37
+ # @return [void]
38
38
  def on_ready &block
39
39
  @ready_blocks.push block
40
40
  end
41
41
 
42
42
  # @yieldparam [Actor]
43
- # @return [Proc]
43
+ # @return [void]
44
44
  def on_player_ready &block
45
45
  @ready_blocks.push(proc do
46
- players.each { |plyr| block.call plyr }
46
+ players.each { |plyr| instance_exec plyr, &block }
47
47
  end)
48
48
  end
49
49
 
@@ -53,24 +53,24 @@ module Gamefic
53
53
 
54
54
  def on_player_update &block
55
55
  @update_blocks.push(proc do
56
- players.each { |plyr| block.call plyr }
56
+ players.each { |plyr| instance_exec plyr, &block }
57
57
  end)
58
58
  end
59
59
 
60
- # @return [Proc]
60
+ # @return [void]
61
61
  def on_conclude &block
62
62
  @conclude_blocks.push block
63
63
  end
64
64
 
65
65
  # @yieldparam [Actor]
66
- # @return [Proc]
66
+ # @return [void]
67
67
  def on_player_conclude &block
68
68
  @player_conclude_blocks.push block
69
69
  end
70
70
 
71
71
  # @yieldparam [Actor]
72
72
  # @yieldparam [Hash]
73
- # @return [Proc]
73
+ # @return [void]
74
74
  def on_player_output &block
75
75
  @player_output_blocks.push block
76
76
  end
@@ -116,11 +116,11 @@ module Gamefic
116
116
  end
117
117
 
118
118
  def run_player_conclude_blocks player
119
- events.player_conclude_blocks.each { |blk| Stage.run(narrative) { blk.call(player) } }
119
+ events.player_conclude_blocks.each { |blk| Stage.run(narrative, player, &blk) }
120
120
  end
121
121
 
122
122
  def run_player_output_blocks player, output
123
- events.player_output_blocks.each { |blk| Stage.run(narrative) { blk.call(player, output) } }
123
+ events.player_output_blocks.each { |blk| Stage.run(narrative, player, output, &blk) }
124
124
  end
125
125
 
126
126
  def empty?
@@ -11,7 +11,7 @@ module Gamefic
11
11
  class Result
12
12
  # The scanned objects
13
13
  #
14
- # @return [Array<Object>]
14
+ # @return [Array<Entity>, String, Regexp]
15
15
  attr_reader :scanned
16
16
 
17
17
  # The scanned token
@@ -21,7 +21,7 @@ module Gamefic
21
21
 
22
22
  # The matched objects
23
23
  #
24
- # @return [Array<Object>]
24
+ # @return [Array<Entity>, String]
25
25
  attr_reader :matched
26
26
 
27
27
  # The remaining (unmatched) portion of the token
@@ -39,36 +39,53 @@ module Gamefic
39
39
 
40
40
  # Scan entities against a token.
41
41
  #
42
- # @param objects [Array<Gamefic::Entity>]
42
+ # @param selection [Array<Entity>, String, Regexp]
43
43
  # @param token [String]
44
44
  # @return [Result]
45
- def self.scan objects, token
46
- # @note Theoretically, scanned objects only have to implement two
47
- # methods:
48
- # * #keywords => [Array<String>]
49
- # * #children => [Array<#keywords, #children>]
50
-
51
- words = token.keywords
52
- available = objects.clone
53
- filtered = []
54
- if nested?(token) && objects.all?(&:children)
55
- denest(objects, token)
56
- else
57
- words.each_with_index do |word, idx|
58
- tested = select_strict(available, word)
59
- tested = select_fuzzy(available, word) if tested.empty?
60
- return Result.new(objects, token, filtered, words[idx..].join(' ')) if tested.empty?
61
-
62
- filtered = tested
63
- available = filtered
64
- end
65
- Result.new(objects, token, filtered, '')
66
- end
45
+ def self.scan selection, token
46
+ strict_result = strict(selection, token)
47
+ strict_result.matched.empty? ? fuzzy(selection, token) : strict_result
48
+ end
49
+
50
+ # @param selection [Array<Entity>, String, Regexp]
51
+ # @param token [String]
52
+ # @return [Result]
53
+ def self.strict selection, token
54
+ return Result.new(selection, token, '', token) unless selection.is_a?(Array)
55
+
56
+ scan_strict_or_fuzzy(selection, token, :select_strict)
57
+ end
58
+
59
+ # @param selection [Array<Entity>, String, Regexp]
60
+ # @param token [String]
61
+ # @return [Result]
62
+ def self.fuzzy selection, token
63
+ return scan_text(selection, token) unless selection.is_a?(Array)
64
+
65
+ scan_strict_or_fuzzy(selection, token, :select_fuzzy)
67
66
  end
68
67
 
69
68
  class << self
70
69
  private
71
70
 
71
+ def scan_strict_or_fuzzy objects, token, method
72
+ if nested?(token) && objects.all?(&:children)
73
+ denest(objects, token)
74
+ else
75
+ words = token.keywords
76
+ available = objects.clone
77
+ filtered = []
78
+ words.each_with_index do |word, idx|
79
+ tested = send(method, available, word)
80
+ return Result.new(objects, token, filtered, words[idx..].join(' ')) if tested.empty?
81
+
82
+ filtered = tested
83
+ available = filtered
84
+ end
85
+ Result.new(objects, token, filtered, '')
86
+ end
87
+ end
88
+
72
89
  def select_strict available, word
73
90
  available.select { |obj| obj.keywords.include?(word) }
74
91
  end
@@ -81,6 +98,16 @@ module Gamefic
81
98
  token.match(NEST_REGEXP)
82
99
  end
83
100
 
101
+ def scan_text selection, token
102
+ case selection
103
+ when Regexp
104
+ return Result.new(selection, token, token, '') if token =~ selection
105
+ else
106
+ return Result.new(selection, token, selection, token[selection.length..]) if token.start_with?(selection)
107
+ end
108
+ Result.new(selection, token, '', token)
109
+ end
110
+
84
111
  def denest(objects, token)
85
112
  parts = token.split(NEST_REGEXP)
86
113
  current = parts.pop
@@ -30,7 +30,7 @@ module Gamefic
30
30
  end
31
31
 
32
32
  def new_props(**context)
33
- self.class.props_class.new(name, type, **context)
33
+ self.class.props_class.new(self, **context)
34
34
  end
35
35
 
36
36
  def on_start &block
@@ -45,8 +45,8 @@ module Gamefic
45
45
  # @param props [Props::Default]
46
46
  # @return [void]
47
47
  def start actor, props
48
- actor.output[:scene] = to_hash
49
- actor.output[:prompt] = props.prompt
48
+ props.output[:scene] = to_hash
49
+ props.output[:prompt] = props.prompt
50
50
  end
51
51
 
52
52
  # @param actor [Gamefic::Actor]
@@ -10,7 +10,7 @@ module Gamefic
10
10
 
11
11
  def start actor, props
12
12
  super
13
- actor.output[:options] = props.options
13
+ props.output[:options] = props.options
14
14
  end
15
15
 
16
16
  def finish actor, props
@@ -55,7 +55,10 @@ module Gamefic
55
55
  # @param description [String]
56
56
  # @return [Gamefic::Entity, nil]
57
57
  def pick description
58
- Gamefic::Query::General.new(entities).query(nil, description).match
58
+ result = Scanner.scan(entities, description)
59
+ return nil unless result.matched.one?
60
+
61
+ result.matched.first
59
62
  end
60
63
 
61
64
  # Same as #pick, but raise an error if a unique match could not be found.
@@ -63,13 +66,13 @@ module Gamefic
63
66
  # @param description [String]
64
67
  # @return [Gamefic::Entity, nil]
65
68
  def pick! description
66
- ary = Gamefic::Query::General.new(entities, ambiguous: true).query(nil, description).match
69
+ result = Scanner.scan(entities, description)
67
70
 
68
- raise "no entity matching '#{description}'" if ary.nil?
71
+ raise "no entity matching '#{description}'" if result.matched.empty?
69
72
 
70
- raise "multiple entities matching '#{description}': #{ary.join_and}" unless ary.one?
73
+ raise "multiple entities matching '#{description}': #{result.matched.join_and}" unless result.matched.one?
71
74
 
72
- ary.first
75
+ result.matched.first
73
76
  end
74
77
  end
75
78
  end
@@ -15,6 +15,15 @@ module Gamefic
15
15
  end
16
16
 
17
17
  def fetch container
18
+ result = safe_fetch(container)
19
+ raise ArgumentError, "Unable to fetch entity from proxy agent symbol `#{symbol}`" unless result
20
+
21
+ result
22
+ end
23
+
24
+ private
25
+
26
+ def safe_fetch container
18
27
  if symbol.to_s =~ /^\d+$/
19
28
  Stage.run(container, symbol) { |sym| entities[sym] }
20
29
  elsif symbol.to_s.start_with?('@')
@@ -22,6 +31,8 @@ module Gamefic
22
31
  else
23
32
  Stage.run(container, symbol) { |sym| send(sym) }
24
33
  end
34
+ rescue NoMethodError
35
+ nil
25
36
  end
26
37
  end
27
38
 
@@ -63,9 +63,9 @@ module Gamefic
63
63
  # any text it finds in the command. A successful query returns the
64
64
  # corresponding text instead of an entity.
65
65
  #
66
- # @param arg [String, Regrxp] The string or regular expression to match
66
+ # @param arg [String, Regexp] The string or regular expression to match
67
67
  # @return [Query::Text]
68
- def plaintext arg = nil
68
+ def plaintext arg = /.*/
69
69
  Query::Text.new arg
70
70
  end
71
71
  end
@@ -32,8 +32,8 @@ module Gamefic
32
32
  # @param block [Proc]
33
33
  # @yieldparam [Scene]
34
34
  # @return [Symbol]
35
- def block name, klass = Scene::Default, on_start: nil, on_finish: nil, &block
36
- rulebook.scenes.add klass.new(name, rulebook.narrative, on_start: on_start, on_finish: on_finish, &block)
35
+ def block name, klass = Scene::Default, on_start: nil, on_finish: nil, &blk
36
+ rulebook.scenes.add klass.new(name, rulebook.narrative, on_start: on_start, on_finish: on_finish, &blk)
37
37
  name
38
38
  end
39
39
  alias scene block
@@ -77,14 +77,14 @@ module Gamefic
77
77
  # @yieldparam [Actor]
78
78
  # @yieldparam [Props::MultipleChoice]
79
79
  # @return [Symbol]
80
- def multiple_choice name, choices = [], prompt = 'What is your choice?', &block
80
+ def multiple_choice name, choices = [], prompt = 'What is your choice?', &blk
81
81
  block name,
82
82
  Scene::MultipleChoice,
83
83
  on_start: proc { |_actor, props|
84
84
  props.prompt = prompt
85
85
  props.options.concat choices
86
86
  },
87
- on_finish: block
87
+ on_finish: blk
88
88
  end
89
89
 
90
90
  # Create a yes-or-no scene.
@@ -105,13 +105,13 @@ module Gamefic
105
105
  # @yieldparam [Actor]
106
106
  # @yieldparam [Props::YesOrNo]
107
107
  # @return [Symbol]
108
- def yes_or_no name, prompt = 'Answer:', &block
108
+ def yes_or_no name, prompt = 'Answer:', &blk
109
109
  block name,
110
110
  Scene::YesOrNo,
111
111
  on_start: proc { |_actor, props|
112
112
  props.prompt = prompt
113
113
  },
114
- on_finish: block
114
+ on_finish: blk
115
115
  end
116
116
 
117
117
  # Create a scene that pauses the game.
@@ -27,10 +27,18 @@ module Gamefic
27
27
  Marshal.load(binary).tap do |plot|
28
28
  plot.hydrate
29
29
  # @todo Opal marshal dumps are not idempotent
30
- next if RUBY_ENGINE == 'opal' || Snapshot.save(plot) == snapshot
30
+ next if RUBY_ENGINE == 'opal' || match?(plot, snapshot)
31
31
 
32
32
  Logging.logger.warn "Scripts modified #{plot.class} data. Snapshot may not have restored properly"
33
33
  end
34
34
  end
35
+
36
+ # True if the plot's state matches the snapshot.
37
+ #
38
+ # @param plot [Plot]
39
+ # @param snapshot [String]
40
+ def self.match?(plot, snapshot)
41
+ save(plot) == snapshot
42
+ end
35
43
  end
36
44
  end
@@ -61,12 +61,12 @@ module Gamefic
61
61
  # Convert a String into a Command.
62
62
  #
63
63
  # @param text [String]
64
- # @return [Command, nil]
64
+ # @return [Expression, nil]
65
65
  def tokenize text
66
66
  match = text&.match(template.regexp)
67
67
  return nil unless match
68
68
 
69
- Command.new(verb, match_to_args(match))
69
+ Expression.new(verb, match_to_args(match))
70
70
  end
71
71
 
72
72
  # Determine if the specified text matches the syntax's expected pattern.
@@ -94,7 +94,7 @@ module Gamefic
94
94
  #
95
95
  # @param text [String] The text to tokenize.
96
96
  # @param syntaxes [Array<Syntax>] The syntaxes to use.
97
- # @return [Array<Command>] The tokenized commands.
97
+ # @return [Array<Expression>] The tokenized expressions.
98
98
  def self.tokenize text, syntaxes
99
99
  syntaxes
100
100
  .map { |syn| syn.tokenize(text) }
@@ -109,7 +109,7 @@ module Gamefic
109
109
  end
110
110
 
111
111
  # @param string [String]
112
- # @return [String, nil]
112
+ # @return [Symbol, nil]
113
113
  def self.literal_or_nil string
114
114
  string.start_with?(':') ? nil : string.to_sym
115
115
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gamefic
4
- VERSION = '3.0.0'
4
+ VERSION = '3.1.0'
5
5
  end
data/lib/gamefic.rb CHANGED
@@ -11,6 +11,7 @@ require 'gamefic/rulebook'
11
11
  require 'gamefic/query'
12
12
  require 'gamefic/scanner'
13
13
  require 'gamefic/scope'
14
+ require 'gamefic/expression'
14
15
  require 'gamefic/command'
15
16
  require 'gamefic/action'
16
17
  require 'gamefic/props'
@@ -27,6 +28,7 @@ require 'gamefic/node'
27
28
  require 'gamefic/describable'
28
29
  require 'gamefic/messenger'
29
30
  require 'gamefic/entity'
31
+ require 'gamefic/composer'
30
32
  require 'gamefic/dispatcher'
31
33
  require 'gamefic/active'
32
34
  require 'gamefic/active/cue'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gamefic
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Fred Snyder
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-01-27 00:00:00.000000000 Z
11
+ date: 2024-04-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: opal
@@ -136,11 +136,13 @@ files:
136
136
  - lib/gamefic/actor.rb
137
137
  - lib/gamefic/block.rb
138
138
  - lib/gamefic/command.rb
139
+ - lib/gamefic/composer.rb
139
140
  - lib/gamefic/core_ext/array.rb
140
141
  - lib/gamefic/core_ext/string.rb
141
142
  - lib/gamefic/describable.rb
142
143
  - lib/gamefic/dispatcher.rb
143
144
  - lib/gamefic/entity.rb
145
+ - lib/gamefic/expression.rb
144
146
  - lib/gamefic/logging.rb
145
147
  - lib/gamefic/messenger.rb
146
148
  - lib/gamefic/narrative.rb
@@ -149,6 +151,7 @@ files:
149
151
  - lib/gamefic/props.rb
150
152
  - lib/gamefic/props/default.rb
151
153
  - lib/gamefic/props/multiple_choice.rb
154
+ - lib/gamefic/props/output.rb
152
155
  - lib/gamefic/props/pause.rb
153
156
  - lib/gamefic/props/yes_or_no.rb
154
157
  - lib/gamefic/query.rb