gamefic 3.0.0 → 3.1.0

Sign up to get free protection for your applications and to get access to all the features.
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