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,151 @@
1
+ # lib/story_teller/options.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 'logger'
23
+ require 'optparse'
24
+
25
+ require_relative 'version'
26
+
27
+ # The StoryTeller module
28
+ module StoryTeller
29
+ class UserError < StandardError; end
30
+
31
+ # The Options module
32
+ module Options
33
+ # The ArgumentsParser
34
+ class ArgumentsParser
35
+ attr_reader :parser, :options
36
+
37
+ def initialize(args, defaults = {}, option_parser = OptionParser.new, **params)
38
+ @args = args
39
+ @parser = option_parser
40
+ @options = defaults
41
+ flags.each { |method_name| self.method(method_name).call }
42
+ @parser.parse!(args, **params)
43
+ end
44
+
45
+ def flags
46
+ @flags ||= %i[
47
+ banner game_path admin builder persist word_wrap log_level
48
+ help version]
49
+ end
50
+
51
+ # rubocop: disable Metrics/MethodLength
52
+ def banner
53
+ @parser.banner = "Usage: #{File.basename($PROGRAM_NAME)} [game_path] [options]"
54
+ @parser.separator ''
55
+ @parser.separator 'Arguments:'
56
+ @parser.separator ' game_path Path to game file or directory'
57
+ @parser.separator(
58
+ format(
59
+ ' Default: %<game_path>s',
60
+ game_path: @options[:game_path]
61
+ )
62
+ )
63
+ @parser.separator ''
64
+ @parser.separator 'Options:'
65
+ end
66
+ # rubocop: enable Metrics/MethodLength
67
+
68
+ def game_path
69
+ first = @args.first
70
+ return if first.nil?
71
+
72
+ path = File.expand_path(first)
73
+
74
+ # Only treat it as game_path if it exists
75
+ return unless File.exist?(path)
76
+
77
+ @options[:game_path] = path
78
+ @args.shift
79
+ end
80
+
81
+ def admin
82
+ @parser.on_tail('--admin', 'Set player character as admin; default: false') do
83
+ @options[:admin] = true
84
+ end
85
+ end
86
+
87
+ def builder
88
+ @parser.on_tail('--builder', 'Set player character as builder; default: false') do
89
+ @options[:builder] = true
90
+ end
91
+ end
92
+
93
+ def persist
94
+ desc = 'Persist the world tree across sessions; default: false'
95
+ @parser.on_tail('--persist', desc) do
96
+ @options[:persist] = true
97
+ @options[:reset_db_each_session] = false
98
+ end
99
+ end
100
+
101
+ def word_wrap
102
+ @parser.on('--word-wrap COLUMNS', Integer, 'Set default line width') do |columns|
103
+ @options[:word_wrap] = columns
104
+ end
105
+ end
106
+
107
+ def log_level
108
+ @parser.on_tail('-v', '--verbose', 'Increase verbosity') do
109
+ @options[:log_level] ||= Logger::INFO
110
+ @options[:log_level] -= 1
111
+ end
112
+ end
113
+
114
+ def help
115
+ @parser.on_tail('-?', '--help', 'Show this message') do
116
+ puts @parser
117
+ exit
118
+ end
119
+ end
120
+
121
+ def version
122
+ @parser.on_tail('--version', 'Show version') do
123
+ puts "#{$PROGRAM_NAME} version #{StoryTellerCli::VERSION}"
124
+ exit
125
+ end
126
+ end
127
+ end
128
+ # class ArgumentsParser
129
+
130
+ def demand(options, arg, positional = false)
131
+ return options[arg] unless options[arg].nil?
132
+ required_arg = positional ? "<#{arg}>" : "--#{arg.to_s.gsub(/_/, '-')}"
133
+ raise UserError, "Required argument: #{required_arg}"
134
+ end
135
+
136
+ def parse_arguments(args: ARGV, defaults: {}, _file_path: ARGF)
137
+ arguments_parser = ArgumentsParser.new(args, defaults)
138
+ demand(arguments_parser.options, :game_path)
139
+ arguments_parser.options
140
+ rescue OptionParser::InvalidArgument, OptionParser::InvalidOption,
141
+ OptionParser::MissingArgument, OptionParser::NeedlessArgument => e
142
+ puts e.message
143
+ puts arguments_parser.parser
144
+ exit
145
+ rescue OptionParser::AmbiguousOption => e
146
+ abort e.message
147
+ end
148
+ end
149
+ # module Options
150
+ end
151
+ # module StoryTeller
@@ -0,0 +1,340 @@
1
+ # lib/story_teller/persistence.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 'etc'
23
+ require 'logger'
24
+ require 'uri'
25
+ require 'yaml'
26
+
27
+ require 'sequel'
28
+
29
+ require_relative 'database'
30
+ require_relative 'snapshots'
31
+
32
+ Sequel.extension :migration
33
+ Sequel.extension :connection_validator
34
+
35
+ # Simplistic in-memory cache
36
+ class EphemeralCache < Hash
37
+ def get(key)
38
+ self[key]
39
+ end
40
+ def set(key, value, _ttl)
41
+ self[key] = value
42
+ end
43
+ end
44
+
45
+ # The Persistence class
46
+ class Persistence
47
+ unless defined?(Persistence::GlobalCache)
48
+ GlobalCache = defined?(Java) ? java.util.concurrent.ConcurrentHashMap.new : EphemeralCache.new
49
+ end
50
+ Plugins = {
51
+ after_initialize: [],
52
+ caching: [Persistence::GlobalCache],
53
+ json_serializer: [],
54
+ xml_serializer: []
55
+ # tactical_eager_loading: [],
56
+ # eager_each: []
57
+ }.freeze
58
+ end
59
+
60
+ # The SequelLoggers module
61
+ module SequelLoggers
62
+ DatabaseLogging = Struct.new(:memo).new({})
63
+
64
+ def add_logger(database, logger)
65
+ database.loggers << logger unless database.loggers.include?(logger)
66
+ end
67
+
68
+ def delete_logger(database, logger)
69
+ database.loggers.delete(logger) if database.loggers.include?(logger)
70
+ end
71
+
72
+ def set_log_level(database, level)
73
+ DatabaseLogging.memo[:preserved_log_levels][database] = database.sql_log_level
74
+ database.sql_log_level = level unless level.nil?
75
+ end
76
+
77
+ def enable_query_logging
78
+ DatabaseLogging.memo[:logger] ||= Logger.new($stdout)
79
+ DatabaseLogging.memo[:preserved_log_levels] ||= {}
80
+ logger = DatabaseLogging.memo[:logger]
81
+ Sequel::DATABASES.each do |database|
82
+ add_logger(database, logger)
83
+ set_log_level(database, :debug)
84
+ logger.debug "Enabled query logging for #{database}"
85
+ end
86
+ end
87
+
88
+ def disable_query_logging
89
+ logger = DatabaseLogging.memo[:logger]
90
+ Sequel::DATABASES.each do |database|
91
+ logger.debug "Disabling query logging for #{database}"
92
+ delete_logger(database, logger)
93
+ set_log_level(database, DatabaseLogging.memo[:preserved_log_levels][database])
94
+ end
95
+ end
96
+ end
97
+ # module SequelLoggers
98
+
99
+ # The SequelPlugins module
100
+ module SequelPlugins
101
+ def enable_plugins
102
+ Persistence::Plugins.each { |plugin, parameters| enable_plugin(plugin, parameters) }
103
+ end
104
+
105
+ def enable_plugin(plugin, parameters = [])
106
+ return Sequel::Model.plugin(plugin) if parameters.empty?
107
+ Sequel::Model.plugin(plugin, *parameters)
108
+ rescue LoadError => e
109
+ log.error e.message
110
+ end
111
+ end
112
+ # module SequelPlugins
113
+
114
+ # module DatabaseResetHelpers
115
+ module DatabaseResetHelpers
116
+ def reset!
117
+ db = Sequel::Model.db
118
+ tables = world_tables(db)
119
+ return if tables.empty?
120
+
121
+ db.from(*tables).truncate(cascade: true, restart: true)
122
+ end
123
+
124
+ def world_tree?
125
+ db = Sequel::Model.db
126
+ return false unless db.table_exists?(:object)
127
+
128
+ Inform::Object.dataset.any?
129
+ rescue Sequel::Error
130
+ false
131
+ end
132
+
133
+ def world_tables(db)
134
+ model_classes = Sequel::Model.descendants
135
+ tables = model_classes.filter { |model| concrete_world_model?(db, model) }
136
+ tables.map(&:table_name).uniq
137
+ end
138
+
139
+ def concrete_world_model?(db, model)
140
+ table_name = model.table_name
141
+ table_name && db.table_exists?(table_name)
142
+ rescue Sequel::Error
143
+ false
144
+ end
145
+
146
+ def without_committing!(&block)
147
+ Sequel::Model.db.transaction(rollback: :always, auto_savepoint: true, &block)
148
+ end
149
+
150
+ def world_object_ids
151
+ return Set.new unless Sequel::Model.db.table_exists?(:object)
152
+
153
+ Inform::Object.select_map(:id).to_set
154
+ rescue Sequel::Error
155
+ Set.new
156
+ end
157
+ end
158
+
159
+ # The StoryTeller module
160
+ module StoryTeller
161
+ # The Persistence class
162
+ class Persistence
163
+ include DatabaseConnectionHelpers
164
+ include DatabaseResetHelpers
165
+ include SequelLoggers
166
+ include SequelPlugins
167
+
168
+ attr_reader :config, :environment
169
+
170
+ DEFAULT_DATABASE_NAME = 'default'.freeze
171
+
172
+ def self.init(env = nil, database_name = DEFAULT_DATABASE_NAME)
173
+ instance(env, database_name)
174
+ end
175
+
176
+ @instance_mutex = Mutex.new
177
+
178
+ def self.instance(*args)
179
+ return @instance unless @instance.nil?
180
+ @instance_mutex.synchronize do
181
+ @instance ||= new(*args)
182
+ end
183
+ @instance
184
+ end
185
+
186
+ private_class_method :new
187
+
188
+ def initialize(env = nil, database_name = DEFAULT_DATABASE_NAME)
189
+ @environment = env
190
+ @database_name = database_name
191
+ log.debug "Initializing persistence layer for environment: #{@environment}"
192
+ caller[0..4].each { |t| log.trace t }
193
+ @config = database_config.fetch(@environment.to_s, {})
194
+ establish_database_connection
195
+ enable_plugins
196
+ enable_query_logging if ENV['ENABLE_SQL_LOGGING']
197
+ end
198
+
199
+ def establish_database_connection
200
+ connect
201
+ rescue Sequel::DatabaseConnectionError
202
+ log.warn "Database requires initialization"
203
+ init_database(@database_name)
204
+ retry if DatabaseConnectionHelpers.connection_attempts < 2
205
+ end
206
+
207
+ def database_config
208
+ @database_config ||= YAML.load_file(database_config_file_path)
209
+ end
210
+
211
+ def database_config_file_path
212
+ @database_config_file_path ||= File.expand_path(
213
+ File.join(StoryTeller::Runtime.project_dir_path, 'config', 'database.yml')
214
+ )
215
+ end
216
+
217
+ def init_database(database_name)
218
+ @config = database_config.fetch('default')
219
+ connect
220
+ @database = Greenfield::Database.init(database_name)
221
+ @database.bootstrap.up
222
+ @config = database_config.fetch(environment.to_s)
223
+ connect
224
+ end
225
+ end
226
+ # class Persistence
227
+ end
228
+ # module StoryTeller
229
+
230
+ # The StoryTeller module
231
+ module StoryTeller
232
+ # The ImplicitMigration module
233
+ module ImplicitMigration
234
+ NoDatabasePattern = %r{No database associated with Sequel::Model}.freeze
235
+ MigrationSetupTemplate = '%<model>sSetup'.freeze
236
+ ModuleNamespaceDelimiterPattern = /::/.freeze
237
+
238
+ def before_inherited(subclass)
239
+ return if self != Sequel::Model
240
+ descendants << subclass
241
+ log.debug "#{subclass} << #{self} [#{descendants}]"
242
+ maybe_migrate(subclass)
243
+ end
244
+
245
+ def after_inherited(_subclass)
246
+ examine_schema
247
+ end
248
+
249
+ # rubocop: disable Metrics/AbcSize
250
+ # rubocop: disable Metrics/MethodLength
251
+ def examine_schema
252
+ descendants.each do |model_class|
253
+ table_name = model_class.table_name
254
+ indexes = self.db.indexes(table_name)
255
+ columns = model_class.columns
256
+ associations = model_class.associations
257
+
258
+ log.debug "Table: #{table_name}"
259
+ log.debug "Columns: #{columns.join(', ')}"
260
+ log.debug "Indexes: #{indexes}"
261
+
262
+ associations.each do |assoc_name, assoc_data|
263
+ log.debug "Association: #{assoc_name} (#{assoc_data[:type]}) to #{assoc_data[:class_name]}"
264
+ end
265
+
266
+ log.debug "==========="
267
+ end
268
+ end
269
+ # rubocop: enable Metrics/AbcSize
270
+ # rubocop: enable Metrics/MethodLength
271
+
272
+ def maybe_migrate(subclass)
273
+ migration_class = migration(subclass)
274
+ log.debug "Found migration class: #{migration_class}"
275
+ migration_class&.up
276
+ rescue Sequel::Error => e
277
+ if NoDatabasePattern.match?(e.message)
278
+ StoryTeller::Persistence.instance.connect
279
+ retry
280
+ end
281
+ end
282
+
283
+ def migration(model)
284
+ names = format(MigrationSetupTemplate, model: model).split(ModuleNamespaceDelimiterPattern)
285
+ names.inject(Object) do |mod, class_name|
286
+ mod.const_get(class_name)
287
+ rescue StandardError => e
288
+ log.warn "Error getting reference to migration model class: #{e.message}"
289
+ next
290
+ end
291
+ end
292
+ end
293
+ # module ImplicitMigration
294
+ end
295
+ # module StoryTeller
296
+
297
+ # The StoryTeller module
298
+ module StoryTeller
299
+ # The InheritanceListener module
300
+ module InheritanceListener
301
+ # The ClassMethods module
302
+ module ClassMethods
303
+ include StoryTeller::ImplicitMigration
304
+
305
+ def inherited(subclass)
306
+ before_inherited(subclass)
307
+ super
308
+ after_inherited(subclass)
309
+ end
310
+
311
+ # Returns the list of Model descendants.
312
+ def descendants
313
+ @descendants ||= []
314
+ end
315
+ end
316
+
317
+ def self.included(base)
318
+ base.extend(ClassMethods)
319
+ end
320
+ end
321
+ # module InheritanceListener
322
+ end
323
+ # module StoryTeller
324
+
325
+ # The Sequel module
326
+ module Sequel
327
+ # The Sequel::Model class
328
+ class Model
329
+ include StoryTeller::InheritanceListener
330
+
331
+ def self.implicit_table_name
332
+ underscore(demodulize(name)).to_sym
333
+ end
334
+ end
335
+ end
336
+
337
+ StoryTeller::Persistence.init(
338
+ StoryTeller::Runtime.instance.game.environment,
339
+ StoryTeller::Runtime.instance.game.name
340
+ )
@@ -0,0 +1,99 @@
1
+ # lib/story_teller/player_character.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
+ # module StoryTeller
23
+ module StoryTeller
24
+ def self.PlayerCharacter(name)
25
+ Object(name, Character)
26
+ end
27
+
28
+ # class Character
29
+ class Character < Inform::Object
30
+ def initialize(name)
31
+ super
32
+ capacity 100
33
+ parse_name 0
34
+ orders 0
35
+ has :concealed, :animate, :proper, :transparent
36
+ end
37
+ def short_name; return L__M(:Miscellany, 18); end
38
+ def description; return L__M(:Miscellany, 19); end
39
+ def before; nil; end
40
+ def after; nil; end
41
+ def life; nil; end
42
+ def each_turn; nil; end
43
+ def time_out; nil; end
44
+ def describe; nil; end
45
+ def add_to_scope; nil; end
46
+ def number; 0; end
47
+ def before_implicit; nil; end
48
+ end
49
+ end
50
+
51
+ # module StoryTeller
52
+ module StoryTeller
53
+ # module PersistedStartupLocation
54
+ module PersistedStartupLocation
55
+ Key = :story_teller_persisted_startup_location
56
+
57
+ module_function
58
+
59
+ def with(location)
60
+ previous = Thread.current[Key]
61
+ Thread.current[Key] = location
62
+ yield
63
+ ensure
64
+ Thread.current[Key] = previous
65
+ end
66
+
67
+ def peek
68
+ Thread.current[Key]
69
+ end
70
+
71
+ def clear
72
+ Thread.current[Key] = nil
73
+ end
74
+ end
75
+ end
76
+
77
+ # module StoryTeller
78
+ module StoryTeller
79
+ # module PersistedStartupMove
80
+ module PersistedStartupMove
81
+ def move(object, destination)
82
+ persisted_destination = StoryTeller::PersistedStartupLocation.peek
83
+
84
+ if persisted_destination && startup_player_move?(object)
85
+ destination = persisted_destination
86
+ @location = persisted_destination
87
+ StoryTeller::PersistedStartupLocation.clear
88
+ end
89
+
90
+ super
91
+ end
92
+
93
+ private
94
+
95
+ def startup_player_move?(object)
96
+ object == @player || object == selfobj || object == StoryTeller::Engine.player_object
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,55 @@
1
+ # lib/story_teller/privileges.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
+ # module StoryTeller
23
+ module StoryTeller
24
+ # class InvocationIdentity
25
+ class InvocationIdentity
26
+ include StoryTeller::PrivilegedIdentity
27
+
28
+ attr_reader :runtime
29
+
30
+ def initialize(runtime)
31
+ @runtime = runtime
32
+ end
33
+
34
+ def identity
35
+ "runtime:#{runtime.identity}"
36
+ end
37
+
38
+ def privilege_label
39
+ 'this runtime session'
40
+ end
41
+
42
+ def name
43
+ privilege_label
44
+ end
45
+
46
+ def privileged?(privilege)
47
+ runtime.privileged?(privilege)
48
+ end
49
+
50
+ def to_s
51
+ privilege_label
52
+ end
53
+ end
54
+ end
55
+ # module StoryTeller