story-teller 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +623 -0
  3. data/README.md +188 -0
  4. data/Rakefile +58 -0
  5. data/config/database.yml +37 -0
  6. data/exe/inform.rb +6 -0
  7. data/game/config.yml +5 -0
  8. data/game/example.inf +90 -0
  9. data/game/example.rb +105 -0
  10. data/game/forms/example_form.rb +2 -0
  11. data/game/grammar/admin.inf.rb +185 -0
  12. data/game/grammar/builder.inf.rb +310 -0
  13. data/game/grammar/game_grammar.inf.rb +6 -0
  14. data/game/grammar/meta.inf.rb +41 -0
  15. data/game/languages/english.rb +571 -0
  16. data/game/models/example_model.rb +2 -0
  17. data/game/modules/example_module.rb +9 -0
  18. data/game/modules/parser_extensions.rb +264 -0
  19. data/game/rules/example_state.rb +2 -0
  20. data/game/scripts/example_script.rb +2 -0
  21. data/game/topics/example_topic.rb +2 -0
  22. data/game/verbs/game_verbs.rb +35 -0
  23. data/game/verbs/metaverbs.rb +2066 -0
  24. data/lib/story_teller/application.rb +82 -0
  25. data/lib/story_teller/cli.rb +35 -0
  26. data/lib/story_teller/color.rb +144 -0
  27. data/lib/story_teller/config.rb +61 -0
  28. data/lib/story_teller/curses_adapter.rb +30 -0
  29. data/lib/story_teller/database.rb +527 -0
  30. data/lib/story_teller/game/loader.rb +276 -0
  31. data/lib/story_teller/game.rb +22 -0
  32. data/lib/story_teller/inform/models.rb +42 -0
  33. data/lib/story_teller/inform/relational/link.rb +239 -0
  34. data/lib/story_teller/inform/relational/module.rb +203 -0
  35. data/lib/story_teller/inform/relational/object.rb +546 -0
  36. data/lib/story_teller/inform/relational/tag.rb +152 -0
  37. data/lib/story_teller/options.rb +151 -0
  38. data/lib/story_teller/persistence.rb +340 -0
  39. data/lib/story_teller/player_character.rb +99 -0
  40. data/lib/story_teller/privileges.rb +55 -0
  41. data/lib/story_teller/runtime.rb +381 -0
  42. data/lib/story_teller/snapshots.rb +412 -0
  43. data/lib/story_teller/terminal.rb +58 -0
  44. data/lib/story_teller/version.rb +24 -0
  45. data/lib/story_teller_cli.rb +34 -0
  46. metadata +158 -0
@@ -0,0 +1,276 @@
1
+ # lib/story_teller/game/loader.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: false
4
+
5
+ # Copyright Nels Nelson 2008-2025 but freely usable (see license)
6
+ #
7
+ # This file is part of the StoryTeller.
8
+ #
9
+ # The StoryTeller is free software: you can redistribute it and/or
10
+ # modify it under the terms of the GNU General Public License as published
11
+ # by the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # The StoryTeller is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with the StoryTeller. If not, see <http://www.gnu.org/licenses/>.
21
+
22
+ require 'yaml'
23
+
24
+ # The StoryTeller module
25
+ module StoryTeller
26
+ # The Game class
27
+ class Game
28
+ attr_reader :config, :config_file_path, :file_path, :path
29
+
30
+ def initialize(options)
31
+ @config = {}
32
+ @path, @file_path = game_paths(options)
33
+ @config_file_path = File.join(@path, options[:game_config_file_name])
34
+ @config = YAML.load_file(@config_file_path, symbolize_names: true)
35
+ @config[:game_file_path] = @file_path unless @file_path.nil?
36
+ end
37
+
38
+ def name
39
+ @config[:game_name]
40
+ end
41
+
42
+ def environment
43
+ (
44
+ ENV['GAME_ENVIRONMENT'] ||
45
+ @config[:environment] ||
46
+ StoryTeller::Engine.default_environment
47
+ ).to_sym
48
+ end
49
+
50
+ def environment=(env)
51
+ @config[:environment] = env.to_sym
52
+ end
53
+
54
+ private
55
+
56
+ def game_paths(options)
57
+ path = File.expand_path(options[:game_path])
58
+ path = StoryTeller::Config.default_game_dir_path unless game_config_exists?(path, options)
59
+ return [path, nil] if File.directory?(path)
60
+
61
+ [File.dirname(path), path]
62
+ end
63
+
64
+ def game_config_exists?(path, options)
65
+ config_dir = File.directory?(path) ? path : File.dirname(path)
66
+ File.exist?(File.join(config_dir, options[:game_config_file_name]))
67
+ end
68
+
69
+ def game_dir_path(path)
70
+ return path if File.directory?(path)
71
+ @config[:game_file_path] = path
72
+ @game_dir_path = File.expand_path(File.dirname(path))
73
+ return @game_dir_path if File.exist?(@config_file_path)
74
+ StoryTeller::Config.default_game_dir_path
75
+ end
76
+
77
+ class GameLoadError < StandardError; end
78
+
79
+ # The Game::Loader module loads a game from a given directory.
80
+ module Loader
81
+ PreGameComponents = %i[modules].freeze
82
+
83
+ def load_game(game_dir_path = game_path)
84
+ self.load_path = game_dir_path
85
+ load_game_subcomponents(pre_game_components)
86
+ load_game_files(top_level_game_path(game_dir_path))
87
+ load_library
88
+ load_game_subcomponents(post_game_components)
89
+ load_grammars
90
+ reset_constants
91
+ rescue StandardError => e
92
+ handle_load_failure(e)
93
+ raise
94
+ end
95
+
96
+ def top_level_game_path(game_dir_path)
97
+ return game_dir_path unless same_path?(game_dir_path, game.path)
98
+
99
+ game.file_path || game_dir_path
100
+ end
101
+
102
+ def same_path?(first, second)
103
+ File.expand_path(first) == File.expand_path(second)
104
+ end
105
+
106
+ def first_orphan_object_with_description_that_has_light
107
+ Inform::Object.all.find { |o| !o.description.nil? && o.has?(:light) }&.name
108
+ end
109
+
110
+ def load_path=(path)
111
+ @game_dir_path = path
112
+ $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path)
113
+ end
114
+
115
+ def load_game_subcomponents(components = game_components)
116
+ components.each { |component| load_game_sub(component) }
117
+ end
118
+
119
+ def pre_game_components
120
+ game_components & PreGameComponents
121
+ end
122
+
123
+ def post_game_components
124
+ game_components - pre_game_components
125
+ end
126
+
127
+ def reset_constants
128
+ reset_constant(:HDR_GAMESERIAL, game_serial)
129
+ reset_constant(:HDR_GAMERELEASE, release)
130
+ end
131
+
132
+ def game_serial
133
+ defined?(::Serial) ? ::Serial : File.mtime(@game_dir_path).strftime('%y%m%d')
134
+ end
135
+
136
+ def release
137
+ defined?(::Release) ? ::Release : 0
138
+ end
139
+
140
+ def load_game_sub(component, start = Time.now)
141
+ game_component_path = File.join(@game_dir_path, component.to_s)
142
+ Dir.glob(File.join(game_component_path, '*')).each do |file|
143
+ next unless File.file?(file)
144
+ log.debug "Loading file: #{file}"
145
+ require file
146
+ end
147
+ ensure
148
+ elapsed = (Time.now - start) * 1000
149
+ log.debug format("Loaded #{component} in %0.2f milliseconds", elapsed)
150
+ end
151
+
152
+ # def load_game_files(game_path = @game_dir_path)
153
+ # log.info "Loading story game #{game.name} from #{game_path}"
154
+ # Dir.glob(File.join(game_path, '*.rb')).each do |file|
155
+ # next unless File.file?(file)
156
+ # log.trace "Loading file: #{file}"
157
+ # require file
158
+ # end
159
+ # log.debug "Loaded story game #{game.name}"
160
+ # end
161
+
162
+ def load_game_files(game_path = @game_dir_path)
163
+ log.info "Loading story game #{game.name} from #{game_path}"
164
+
165
+ if preserve_persisted_world?
166
+ load_game_files_without_committing(game_path)
167
+ else
168
+ load_game_files_with_committing(game_path)
169
+ end
170
+
171
+ log.debug "Loaded story game #{game.name}"
172
+ end
173
+
174
+ def preserve_persisted_world?
175
+ options[:persist] && persistence.world_tree?
176
+ end
177
+
178
+ def load_game_files_with_committing(game_path)
179
+ game_files(game_path).each do |file|
180
+ log.trace "Loading file: #{file}"
181
+ require file
182
+ end
183
+ end
184
+
185
+ def game_files(game_path)
186
+ return [game_path] if File.file?(game_path)
187
+
188
+ Dir.glob(File.join(game_path, '*.rb')).select { |file| File.file?(file) }
189
+ end
190
+
191
+ def load_game_files_without_committing(game_path)
192
+ preserved_ids = persistence.world_object_ids
193
+
194
+ persistence.without_committing! do
195
+ load_game_files_with_committing(game_path)
196
+ end
197
+
198
+ refresh_preserved_world_objects(preserved_ids)
199
+ end
200
+
201
+ def refresh_preserved_world_objects(preserved_ids)
202
+ ObjectSpace.each_object(Inform::Object) do |object|
203
+ refresh_preserved_world_object(object, preserved_ids)
204
+ end
205
+ end
206
+
207
+ def refresh_preserved_world_object(object, preserved_ids)
208
+ return if object.id.nil?
209
+
210
+ unless preserved_ids.include?(object.id)
211
+ raise GameLoadError, "Source object is missing from preserved world: #{object.object_name}"
212
+ end
213
+
214
+ object.associations.clear if object.respond_to?(:associations)
215
+ object.refresh
216
+ end
217
+
218
+ def handle_load_failure(e)
219
+ case e.message
220
+ when /PG::UndefinedTable/
221
+ log.error "Fatal: Database initialization is required"
222
+ abort
223
+ else
224
+ log.error "Error loading game: #{e.class.name}: #{e.message}"
225
+ e.backtrace.each { |t| log.error t }
226
+ end
227
+ end
228
+
229
+ # Move to the front of the list any element matching the given condition
230
+ def prioritize_if(list, &condition)
231
+ element = list.find(&condition)
232
+ return list if element.nil?
233
+ list.unshift(list.delete(element))
234
+ end
235
+
236
+ EnsureFirst = [/emotes.inf/, /grammar.inf/].freeze
237
+
238
+ def grammar_files
239
+ files = Dir.glob(File.join(grammar_module_path, '*'))
240
+ EnsureFirst.each do |pattern|
241
+ prioritize_if(files) { |file_path| file_path.match?(pattern) }
242
+ end
243
+ files
244
+ end
245
+
246
+ GrammarParsedInfoMessage = 'Parsed grammar %<file>s in %0.2<elapsed>f milliseconds'.freeze
247
+
248
+ def load_grammars
249
+ grammar_files.each do |file_path|
250
+ start = Time.now
251
+ StoryTeller::Library::Loader.load_grammar_by_path(file_path)
252
+ elapsed = (Time.now - start) * 1000
253
+ log.debug format(GrammarParsedInfoMessage, file: File.basename(file_path), elapsed: elapsed)
254
+ end
255
+ end
256
+
257
+ def load_library
258
+ StoryTeller::Library::Loader.load_library
259
+ end
260
+
261
+ def load_game_states
262
+ states = game.config.fetch(:additional_promiscuous_states, []).map(&:to_sym)
263
+ Session.add_promiscuous_states(states)
264
+ end
265
+
266
+ def reload_game
267
+ load_game
268
+ end
269
+ end
270
+ # module Loader
271
+
272
+ StoryTeller::Runtime.include(StoryTeller::Game::Loader)
273
+ end
274
+ # class Game
275
+ end
276
+ # module StoryTeller
@@ -0,0 +1,22 @@
1
+ # lib/story_teller/game.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: false
4
+
5
+ # Copyright Nels Nelson 2008-2025 but freely usable (see license)
6
+ #
7
+ # This file is part of the StoryTeller.
8
+ #
9
+ # The StoryTeller is free software: you can redistribute it and/or
10
+ # modify it under the terms of the GNU General Public License as published
11
+ # by the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # The StoryTeller is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with the StoryTeller. If not, see <http://www.gnu.org/licenses/>.
21
+
22
+ require_relative 'game/loader'
@@ -0,0 +1,42 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # Copyright Nels Nelson 2008-2026 but freely usable (see license)
5
+ #
6
+ # This file is part of StoryTeller.
7
+ #
8
+ # The StoryTeller is free software: you can redistribute it and/or
9
+ # modify it under the terms of the GNU General Public License as published
10
+ # by the Free Software Foundation, either version 3 of the License, or
11
+ # (at your option) any later version.
12
+ #
13
+ # The StoryTeller is distributed in the hope that it will be useful,
14
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ # GNU General Public License for more details.
17
+ #
18
+ # You should have received a copy of the GNU General Public License
19
+ # along with the StoryTeller. If not, see <http://www.gnu.org/licenses/>.
20
+
21
+ require_relative 'relational/object'
22
+ require_relative 'relational/link'
23
+ require_relative 'relational/module'
24
+ require_relative 'relational/tag'
25
+
26
+ # module Inform
27
+ module Inform
28
+ # class Object
29
+ class Object
30
+ include StoryTeller::Builtins
31
+ include StoryTeller::IO if defined?(StoryTeller::IO)
32
+ include StoryTeller::Publisher if defined?(StoryTeller::Publisher)
33
+ include StoryTeller::Daemons if defined?(StoryTeller::Daemons)
34
+ include Inform::Context if defined?(Inform::Context)
35
+ include Inform::Events if defined?(Inform::Events)
36
+ include Inform::Prototypical if defined?(Inform::Prototypical)
37
+ prepend Inform::Linkable if defined?(Inform::Linkable)
38
+ include Inform::Taggable if defined?(Inform::Taggable)
39
+ include Inform::Modular if defined?(Inform::Modular)
40
+ include Inform::Genealogical if defined?(Inform::Genealogical)
41
+ end
42
+ end
@@ -0,0 +1,239 @@
1
+ # lib/story_teller/inform/relational/link.rb
2
+ # encoding: utf-8
3
+ # frozen_string_literal: false
4
+
5
+ # Copyright Nels Nelson 2008-2025 but freely usable (see license)
6
+ #
7
+ # This file is part of the Inform Runtime.
8
+ #
9
+ # The Inform Runtime is free software: you can redistribute it and/or
10
+ # modify it under the terms of the GNU General Public License as published
11
+ # by the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # The Inform Runtime is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with the Inform Runtime. If not, see <http://www.gnu.org/licenses/>.
21
+
22
+ # Link
23
+
24
+ if defined?(Sequel::Migration)
25
+ # The LinkSetup class
26
+ class LinkSetup < Sequel::Migration
27
+ # rubocop: disable Metrics/MethodLength
28
+ def up
29
+ # return if table_exists? :link
30
+ log.debug "#up"
31
+ create_table? :link do
32
+ primary_key :id
33
+ foreign_key :from_id, :object, on_delete: :cascade
34
+ foreign_key :to_id, :object, on_delete: :cascade
35
+ index :name
36
+ index :created_at
37
+
38
+ String :name
39
+ DateTime :created_at
40
+ DateTime :modified_at
41
+ end
42
+ end
43
+ # rubocop: enable Metrics/MethodLength
44
+
45
+ def down
46
+ drop_table :link if table_exists? :link
47
+ end
48
+ end
49
+ # class LinkSetup
50
+ end
51
+ # defined?(Sequel::Migration)
52
+
53
+ # module Inform
54
+ module Inform
55
+ # class Link
56
+ class Link < Sequel::Model
57
+ set_primary_key :id
58
+ def_column_accessor :created_at, :modified_at
59
+ def_column_accessor :name
60
+ many_to_one :from, class: Inform::Object, key: :from_id
61
+ many_to_one :to, class: Inform::Object, key: :to_id
62
+
63
+ LinkTemplate = '%<link_name>s -> %<to_name>s [%<to_identity>s]'.freeze
64
+ LinkMethods = %w[from_id to_id].freeze
65
+ MethodWriterTemplate = '%<method_name>s='.freeze
66
+
67
+ def initialize(values = {}, &block)
68
+ super(self.class.relational_values(values), &block)
69
+ end
70
+
71
+ def self.relational_values(values)
72
+ return values unless values.is_a?(Hash)
73
+
74
+ values = values.dup
75
+ from = values.delete(:from)
76
+ to = values.delete(:to)
77
+ values[:from_id] ||= persisted_object_id(from)
78
+ values[:to_id] ||= persisted_object_id(to)
79
+ values
80
+ end
81
+
82
+ # rubocop: disable Metrics/CyclomaticComplexity
83
+ def self.persisted_object_id(object)
84
+ return nil if object.nil?
85
+ return object.id if object.respond_to?(:id) && object.id
86
+ object.save_changes if object.respond_to?(:save_changes)
87
+ return object.id if object.respond_to?(:id) && object.id
88
+ return object.pk if object.respond_to?(:pk)
89
+
90
+ nil
91
+ end
92
+ # rubocop: enable Metrics/CyclomaticComplexity
93
+
94
+ def before_create
95
+ self.created_at ||= Time.now
96
+ super
97
+ end
98
+
99
+ def after_save
100
+ super
101
+ self.modified_at = Time.now.utc
102
+ end
103
+
104
+ def to_s
105
+ format(LinkTemplate, link_name: name, to_name: to&.name, to_identity: to&.identity)
106
+ end
107
+
108
+ def <=>(other)
109
+ self.name <=> other.name
110
+ end
111
+
112
+ def init_with(coder)
113
+ LinkMethods.each do |method_name|
114
+ method_symbol = format(MethodWriterTemplate, method_name: method_name).to_sym
115
+ self.send(method_symbol, coder[method_name]) if self.respond_to? method_symbol
116
+ end
117
+ self
118
+ end
119
+ end
120
+ end
121
+ # module Inform
122
+
123
+ # module Inform
124
+ module Inform
125
+ module_function
126
+
127
+ def link_klass
128
+ Inform::Link
129
+ end
130
+ end
131
+
132
+ # module Inform
133
+ module Inform
134
+ # module Linkable
135
+ module Linkable
136
+ def links
137
+ return Array::Empty unless self.respond_to?(:id)
138
+ return Array::Empty if self.id.nil?
139
+
140
+ Inform.link_klass.filter(from_id: self.id).all
141
+ rescue StandardError => _e
142
+ Array::Empty
143
+ end
144
+
145
+ def find_link(link_name)
146
+ return nil unless self.respond_to?(:id)
147
+ return nil if self.id.nil?
148
+
149
+ Inform.link_klass.first(name: link_name.to_s, from_id: self.id)
150
+ end
151
+
152
+ # rubocop: disable Metrics/MethodLength
153
+ def link(link_name, obj = nil)
154
+ save_link_endpoint(self)
155
+ save_link_endpoint(obj)
156
+
157
+ link = find_link(link_name)
158
+ return link if obj.nil?
159
+
160
+ if link.nil?
161
+ clauses = { name: link_name.to_s, from_id: self.id, to_id: obj.id }
162
+ link = Inform.link_klass.find_or_create(**clauses)
163
+ else
164
+ link.to_id = obj.id
165
+ link.save
166
+ end
167
+ link
168
+ end
169
+ # rubocop: enable Metrics/MethodLength
170
+
171
+ def linked?(link_name)
172
+ return false unless self.respond_to?(:id)
173
+ return false if self.id.nil?
174
+
175
+ Inform.link_klass.filter(name: link_name.to_s, from_id: self.id).count > 0
176
+ end
177
+ alias _key? linked?
178
+
179
+ def unlink(link_name)
180
+ link = find_link(link_name)
181
+ return nil if link.nil?
182
+
183
+ object = linked_object(link.to_id)
184
+ link.destroy
185
+ object
186
+ rescue Sequel::NoExistingObject => e
187
+ log.warn 'Error: ' + e.message
188
+ log.warn 'No such link: ' + link_name.to_s
189
+ nil
190
+ end
191
+ alias _unset_object unlink
192
+
193
+ def linkto(link_name)
194
+ link = find_link(link_name)
195
+ link.nil? ? nil : linked_object(link.to_id)
196
+ end
197
+ alias _get_object linkto
198
+
199
+ def _set_object(link_name, obj = nil)
200
+ return nil if obj.nil?
201
+
202
+ link(link_name, obj)
203
+ obj
204
+ end
205
+
206
+ def linksfrom(link_name = nil)
207
+ return Array::Empty unless self.respond_to?(:id)
208
+ return Array::Empty if self.id.nil?
209
+
210
+ dataset = Inform.link_klass.filter(to_id: self.id)
211
+ dataset = dataset.filter(name: link_name.to_s) unless link_name.nil?
212
+ dataset.all.map { |link| linked_object(link.from_id) }.compact
213
+ end
214
+
215
+ private
216
+
217
+ def save_link_endpoint(object)
218
+ return if object.nil?
219
+ return if object.respond_to?(:id) && object.id
220
+
221
+ if object.respond_to?(:save_changes)
222
+ object.save_changes
223
+ elsif object.respond_to?(:save)
224
+ object.save
225
+ end
226
+ end
227
+
228
+ def linked_object(id)
229
+ return nil if id.nil?
230
+ return Inform::Object[id] if defined?(Inform::Object) && Inform::Object.respond_to?(:[])
231
+ return Inform::Ephemeral::Object[id] if defined?(Inform::Ephemeral::Object) &&
232
+ Inform::Ephemeral::Object.respond_to?(:[])
233
+
234
+ nil
235
+ end
236
+ end
237
+ # module Linkable
238
+ end
239
+ # module Inform