gamefic 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
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
+ READER_METHODS = %i[messages options queue scene prompt last_prompt last_input].freeze
10
+ WRITER_METHODS = %i[messages= prompt= last_prompt= last_input=].freeze
11
+
12
+ attr_reader :raw_data
13
+
14
+ def initialize **data
15
+ @raw_data = {
16
+ messages: '',
17
+ options: [],
18
+ queue: [],
19
+ scene: {},
20
+ prompt: ''
21
+ }
22
+ merge! data
23
+ end
24
+
25
+ # @!attribute [rw] messages
26
+ # A text message to be displayed at the start of a scene.
27
+ #
28
+ # @return [String]
29
+
30
+ # @!attribute [rw] options
31
+ # An array of options to be presented to the player, e.g., in a
32
+ # MultipleChoice scene.
33
+ #
34
+ # @return [Array<String>]
35
+
36
+ # @!attribute [rw] queue
37
+ # An array of commands waiting to be executed.
38
+ #
39
+ # @return [Array<String>]
40
+
41
+ # @!attribute [rw] scene
42
+ # A hash containing the scene's :name and :type.
43
+ #
44
+ # @return [Hash]
45
+
46
+ # @!attribute [rw] [prompt]
47
+ # The input prompt to be displayed to the player.
48
+ #
49
+ # @return [String]
50
+
51
+ # @!attribute [rw] last_input
52
+ # The input received from the player in the previous scene.
53
+ #
54
+ # @return [String, nil]
55
+
56
+ # @!attribute [rw] last_prompt
57
+ # The input prompt from the previous scene.
58
+ #
59
+ # @return [String, nil]
60
+
61
+ # @param key [Symbol]
62
+ def [] key
63
+ raw_data[key]
64
+ end
65
+
66
+ # @param key [Symbol]
67
+ # @param value [Object]
68
+ def []= key, value
69
+ raw_data[key] = value
70
+ end
71
+
72
+ # @return [Hash]
73
+ def to_hash
74
+ raw_data.dup
75
+ end
76
+
77
+ def to_json _ = nil
78
+ raw_data.to_json
79
+ end
80
+
81
+ def merge! data
82
+ data.each { |key, val| self[key] = val }
83
+ end
84
+
85
+ def replace data
86
+ raw_data.replace data
87
+ end
88
+
89
+ def freeze
90
+ raw_data.freeze
91
+ super
92
+ end
93
+
94
+ def method_missing method, *args
95
+ return raw_data[method] if READER_METHODS.include?(method)
96
+
97
+ return raw_data[method.to_s[0..-2].to_sym] = args.first if WRITER_METHODS.include?(method)
98
+
99
+ super
100
+ end
101
+
102
+ def respond_to_missing?(method, _with_private = false)
103
+ READER_METHODS.include?(method) || WRITER_METHODS.include?(method)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Gamefic
2
4
  module Props
3
5
  # Props for Pause scenes.
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?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Gamefic
2
4
  # A module for matching objects to tokens.
3
5
  #
@@ -11,7 +13,7 @@ module Gamefic
11
13
  class Result
12
14
  # The scanned objects
13
15
  #
14
- # @return [Array<Object>]
16
+ # @return [Array<Entity>, String, Regexp]
15
17
  attr_reader :scanned
16
18
 
17
19
  # The scanned token
@@ -21,7 +23,7 @@ module Gamefic
21
23
 
22
24
  # The matched objects
23
25
  #
24
- # @return [Array<Object>]
26
+ # @return [Array<Entity>, String]
25
27
  attr_reader :matched
26
28
 
27
29
  # The remaining (unmatched) portion of the token
@@ -39,36 +41,53 @@ module Gamefic
39
41
 
40
42
  # Scan entities against a token.
41
43
  #
42
- # @param objects [Array<Gamefic::Entity>]
44
+ # @param selection [Array<Entity>, String, Regexp]
43
45
  # @param token [String]
44
46
  # @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
47
+ def self.scan selection, token
48
+ strict_result = strict(selection, token)
49
+ strict_result.matched.empty? ? fuzzy(selection, token) : strict_result
50
+ end
51
+
52
+ # @param selection [Array<Entity>, String, Regexp]
53
+ # @param token [String]
54
+ # @return [Result]
55
+ def self.strict selection, token
56
+ return Result.new(selection, token, '', token) unless selection.is_a?(Array)
57
+
58
+ scan_strict_or_fuzzy(selection, token, :select_strict)
59
+ end
60
+
61
+ # @param selection [Array<Entity>, String, Regexp]
62
+ # @param token [String]
63
+ # @return [Result]
64
+ def self.fuzzy selection, token
65
+ return scan_text(selection, token) unless selection.is_a?(Array)
66
+
67
+ scan_strict_or_fuzzy(selection, token, :select_fuzzy)
67
68
  end
68
69
 
69
70
  class << self
70
71
  private
71
72
 
73
+ def scan_strict_or_fuzzy objects, token, method
74
+ if nested?(token) && objects.all?(&:children)
75
+ denest(objects, token)
76
+ else
77
+ words = token.keywords
78
+ available = objects.clone
79
+ filtered = []
80
+ words.each_with_index do |word, idx|
81
+ tested = send(method, available, word)
82
+ return Result.new(objects, token, filtered, words[idx..].join(' ')) if tested.empty?
83
+
84
+ filtered = tested
85
+ available = filtered
86
+ end
87
+ Result.new(objects, token, filtered, '')
88
+ end
89
+ end
90
+
72
91
  def select_strict available, word
73
92
  available.select { |obj| obj.keywords.include?(word) }
74
93
  end
@@ -81,6 +100,16 @@ module Gamefic
81
100
  token.match(NEST_REGEXP)
82
101
  end
83
102
 
103
+ def scan_text selection, token
104
+ case selection
105
+ when Regexp
106
+ return Result.new(selection, token, token, '') if token =~ selection
107
+ else
108
+ return Result.new(selection, token, selection, token[selection.length..]) if token.start_with?(selection)
109
+ end
110
+ Result.new(selection, token, '', token)
111
+ end
112
+
84
113
  def denest(objects, token)
85
114
  parts = token.split(NEST_REGEXP)
86
115
  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
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Gamefic
2
4
  module Scriptable
3
5
  # Functions that provide proxies for referencing a narrative's entities
@@ -15,6 +17,15 @@ module Gamefic
15
17
  end
16
18
 
17
19
  def fetch container
20
+ result = safe_fetch(container)
21
+ raise ArgumentError, "Unable to fetch entity from proxy agent symbol `#{symbol}`" unless result
22
+
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def safe_fetch container
18
29
  if symbol.to_s =~ /^\d+$/
19
30
  Stage.run(container, symbol) { |sym| entities[sym] }
20
31
  elsif symbol.to_s.start_with?('@')
@@ -22,6 +33,8 @@ module Gamefic
22
33
  else
23
34
  Stage.run(container, symbol) { |sym| send(sym) }
24
35
  end
36
+ rescue NoMethodError
37
+ nil
25
38
  end
26
39
  end
27
40
 
@@ -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