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,527 @@
1
+ # encoding: utf-8
2
+ # frozen_string_literal: false
3
+
4
+ # Copyright Nels Nelson 2008-2025 but freely usable (see license)
5
+ #
6
+ # This file is part of the 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 'open3'
22
+ require 'sequel'
23
+ require 'uri'
24
+
25
+ # The Sequel module
26
+ module Sequel
27
+ # The Sequel::Migration class
28
+ class Migration
29
+ def self.up
30
+ self.apply(Sequel::Model.db, :up)
31
+ end
32
+ def self.down
33
+ self.apply(Sequel::Model.db, :down)
34
+ end
35
+ def self.table_exists?(table)
36
+ Sequel::Model.db.table_exists?(table)
37
+ end
38
+ end
39
+ end
40
+
41
+ # The Databases module
42
+ module Databases
43
+ # The Databases::Helpers module
44
+ module Helpers
45
+ MigrationMethods = %i[down up].freeze
46
+ NamespaceDelimiterPattern = /::/.freeze
47
+ DatabasePattern = /Database/.freeze
48
+
49
+ def class_derived_database_name
50
+ self.class.name.split(NamespaceDelimiterPattern).last.sub(DatabasePattern, '')
51
+ end
52
+
53
+ def implicit_database_name
54
+ implicit_database_name = self.class.name
55
+ begin
56
+ implicit_database_name = underscore(demodulize(class_derived_database_name)).to_sym
57
+ rescue StandardError => e
58
+ log.warn "Failed to get implicit database name: #{e.message}"
59
+ end
60
+ implicit_database_name
61
+ end
62
+
63
+ def database
64
+ # implicit_database_name
65
+ underscore(Inform::Databases.instances.keys.first).to_sym
66
+ end
67
+
68
+ def execute(sql)
69
+ Sequel::Model.db[sql]
70
+ end
71
+
72
+ def run(sql)
73
+ Sequel::Model.db.run(sql)
74
+ end
75
+
76
+ def database_exist?(database_name = database)
77
+ result = execute("select count(*) > 0 from pg_catalog.pg_database where datname = '#{database_name}';")&.first
78
+ result&.values&.first == true
79
+ end
80
+
81
+ def user_exist?(username = database)
82
+ result = execute("select count(*) > 0 from pg_shadow where usename = '#{username}';")&.first
83
+ result&.values&.first == true
84
+ end
85
+
86
+ def connected_database
87
+ return nil unless Sequel::Model.db
88
+ Sequel::Model.db.opts[:uri].to_s.scan(/:\/\/\w+(:\d+|\/)(\w+)/).last
89
+ end
90
+
91
+ def connected_username
92
+ return nil unless Sequel::Model.db
93
+ Sequel::Model.db.opts[:uri].to_s.scan(/user=(\w+)/).first
94
+ end
95
+ end
96
+ end
97
+
98
+ if defined? GlobalSequelIdentityMap
99
+ # The Sequel module
100
+ module Sequel
101
+ # The Sequel::Plugins module
102
+ module Plugins
103
+ # The Sequel::Plugins::IdentityMap module
104
+ module IdentityMap
105
+ # The Sequel::Plugins::IdentityMap::ClassMethods module
106
+ module ClassMethods
107
+ # Returns the global thread-safe identity map.
108
+ def identity_map
109
+ log.debug "Getting identity map"
110
+ GlobalSequelIdentityMap
111
+ end
112
+
113
+ private
114
+
115
+ # Set the thread local identity map to the given value.
116
+ def identity_map=(v)
117
+ silence_warnings do
118
+ log.warn "Setting identity map"
119
+ Object.instance_eval { const_set :GlobalSequelIdentityMap, v }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+
128
+ # The DatabaseConnectionHelpers module
129
+ module DatabaseConnectionHelpers
130
+ DefaultConnectionOptions = { loggerLevel: 'OFF' }.freeze
131
+ DefaultConnectionPoolSize = 4
132
+ DefaultConnectionValidationTimeoutSeconds = 240
133
+ URLTemplate = '%<scheme>s://%<host>s%<colon>s%<port>s'.freeze
134
+ KeyValueTemplate = '%<key>s=%<value>s'.freeze
135
+ EmptyString = ''.freeze
136
+ AmpersandString = '&'.freeze
137
+ ColonString = ':'.freeze
138
+ ForwardSlashString = '/'.freeze
139
+ QuestionMarkString = '?'.freeze
140
+ JavaPattern = /java/i.freeze
141
+
142
+ @connection_attempts = 0
143
+
144
+ def self.connection_attempts
145
+ @connection_attempts
146
+ end
147
+
148
+ def self.increment_connection_attempts
149
+ @connection_attempts += 1
150
+ end
151
+
152
+ # Previously:
153
+ # url = "#{adapter}://#{host}/#{database}?user=#{username}"
154
+ # url << "&password=#{password}" unless password.nil? || password.empty?
155
+ # url << "&loggerLevel=OFF"
156
+ # rubocop: disable Metrics/AbcSize
157
+ # rubocop: disable Metrics/CyclomaticComplexity
158
+ # rubocop: disable Metrics/MethodLength
159
+ # rubocop: disable Metrics/PerceivedComplexity
160
+ def assemble_url
161
+ adapter = config.fetch('adapter')
162
+ adapter.gsub!(/^postgres/, 'jdbc:postgresql') if JavaPattern.match?(RUBY_PLATFORM)
163
+ host = config.fetch('host', nil)
164
+ port = config.fetch('port', nil)
165
+ colon = port.nil? ? EmptyString : ColonString
166
+ database = config.fetch('database', nil)
167
+ username = config.fetch('username', nil)
168
+ password = config.fetch('password', nil)
169
+ parameters = DefaultConnectionOptions.dup
170
+ parameters.merge!(user: username) unless username.nil?
171
+ parameters.merge!(password: password) unless password.nil? || password.empty?
172
+ parameter_values = parameters.map { |key, value| format(KeyValueTemplate, key: key, value: value) }
173
+ query = parameter_values.join(AmpersandString)
174
+ url = [format(URLTemplate, scheme: adapter, host: host, colon: colon, port: port)]
175
+ url << database unless database.nil? || database.empty?
176
+ url = [url.join(ForwardSlashString)]
177
+ url << query unless query.nil? || query.empty?
178
+ url.join(QuestionMarkString)
179
+ end
180
+ # rubocop: enable Metrics/AbcSize
181
+ # rubocop: enable Metrics/CyclomaticComplexity
182
+ # rubocop: enable Metrics/MethodLength
183
+ # rubocop: enable Metrics/PerceivedComplexity
184
+
185
+ DatabaseOrRoleDoesNotExistPattern = /(database|role) ".*" does not exist/.freeze
186
+
187
+ # rubocop: disable Metrics/AbcSize
188
+ # rubocop: disable Metrics/MethodLength
189
+ def connect(database = config.fetch('database', nil), url = assemble_url, connection_options = {})
190
+ log.debug "Connecting to database #{sanitize_url(url)}" if defined? log
191
+ DatabaseConnectionHelpers.increment_connection_attempts
192
+ connection_options[:max_connections] = config.fetch('pool', DefaultConnectionPoolSize)
193
+ connection = Sequel.connect(url, **connection_options)
194
+ connection.extension(:connection_validator)
195
+ connection.pool.connection_validation_timeout = DefaultConnectionValidationTimeoutSeconds
196
+ connection.test_connection
197
+ log.debug "Connected to database #{database}" if defined? log
198
+ Sequel::Model.require_valid_table = false
199
+ Sequel::Model.db = connection
200
+ rescue Sequel::DatabaseConnectionError => e
201
+ raise if e.cause.is_a?(PG::ConnectionBad) && DatabaseOrRoleDoesNotExistPattern.match?(e.cause.to_s)
202
+ error_message = e.message.gsub(/Java::OrgPostgresqlUtil::PSQLException: /, '')
203
+ error_message = "Error connecting to database: #{error_message}"
204
+ log.error error_message
205
+ abort
206
+ rescue Sequel::AdapterNotFound => e
207
+ error_message = "Adapter not found: #{e.message}"
208
+ log.error error_message
209
+ abort
210
+ rescue StandardError => e
211
+ error_message = "Unexpected error connecting to database: #{e.message}"
212
+ log.error error_message, e
213
+ abort
214
+ end
215
+ # rubocop: enable Metrics/AbcSize
216
+ # rubocop: enable Metrics/MethodLength
217
+
218
+ def sanitize_url(url)
219
+ url.gsub(/&?(password|loggerLevel)=[^&]+&?/, '').gsub(/\?$/, '')
220
+ end
221
+ end
222
+ # module DatabaseConnectionHelpers
223
+
224
+ # The Databases module
225
+ module Databases
226
+ # The Databases::Management module
227
+ module Management
228
+ include DatabaseConnectionHelpers
229
+
230
+ # rubocop: disable Metrics/AbcSize
231
+ # rubocop: disable Metrics/MethodLength
232
+ def reconnect_database(url)
233
+ log.debug "Reconnecting to database #{sanitize_url(url)}"
234
+ begin
235
+ Sequel::Model.db&.disconnect
236
+ rescue StandardError => e
237
+ log.warn "Encountered unexpected error disconnecting from database: #{e.message}"
238
+ end
239
+ Sequel::Model.db = Sequel.connect(url)
240
+ Sequel::Model.descendants.each do |model|
241
+ model_dataset = begin
242
+ model.dataset
243
+ rescue Sequel::Error => e
244
+ nil
245
+ end
246
+ model.db = Sequel::Model.db if model_dataset.nil?
247
+ end
248
+ end
249
+ # rubocop: enable Metrics/AbcSize
250
+ # rubocop: enable Metrics/MethodLength
251
+
252
+ # rubocop: disable Metrics/AbcSize
253
+ # rubocop: disable Metrics/MethodLength
254
+ def create_database(database_name = database, username = database)
255
+ return false if database_exist?(database_name)
256
+ begin
257
+ log.info "Creating database: #{database_name}"
258
+ create_user username unless user_exist?(username)
259
+ run "create database #{database_name} owner #{username}"
260
+ rescue StandardError => e
261
+ database_already_exists_error_pattern = /ERROR: database "#{database_name}" already exists/
262
+ if database_already_exists_error_pattern.match?(e.to_s)
263
+ unless defined? tried_once_already
264
+ drop_database database_name
265
+ tried_once_already = true # rubocop: disable Lint/UselessAssignment
266
+ retry
267
+ end
268
+ end
269
+ log.error "Unexpected error creating database: #{e.inspect}"
270
+ e.backtrace.each { |t| log.error t }
271
+ return false
272
+ end
273
+ end
274
+ # rubocop: enable Metrics/AbcSize
275
+ # rubocop: enable Metrics/MethodLength
276
+
277
+ CannotDropOpenDatabaseErrorPattern = /ERROR: cannot drop the currently open database/.freeze
278
+
279
+ # rubocop: disable Metrics/AbcSize
280
+ # rubocop: disable Metrics/MethodLength
281
+ def drop_database(database_name = database)
282
+ return false unless database_exist?(database_name)
283
+ begin
284
+ log.info "Dropping database: #{database_name}"
285
+ run "drop database #{database_name}"
286
+ rescue Sequel::DatabaseError => e
287
+ log.error "Unexpected error creating database: #{e.inspect}"
288
+ e.backtrace.each { |t| log.error t }
289
+ return false
290
+ rescue StandardError => e
291
+ if CannotDropOpenDatabaseErrorPattern.match?(e.to_s)
292
+ results = execute "select procpid from pg_stat_activity where datname = '#{database_name}'"
293
+ results.each do |row|
294
+ log.debug "You must first kill process with pid #{row['procpid']}"
295
+ command = format('sudo kill -9 %<procpid>s', procpid: row['procpid'])
296
+ log.debug "Execute: #{command}"
297
+ # `#{command}`
298
+ end
299
+ end
300
+ log.error "Unexpected error creating database: #{e.inspect}"
301
+ e.backtrace.each { |t| log.error t }
302
+ return false
303
+ end
304
+ end
305
+ # rubocop: enable Metrics/AbcSize
306
+ # rubocop: enable Metrics/MethodLength
307
+ end
308
+ # module Management
309
+ end
310
+ # module Databases
311
+
312
+ # The Databases module
313
+ module Databases
314
+ # The Utilities module
315
+ module Utilities
316
+ DefaultSchema = if JavaPattern.match?(RUBY_PLATFORM)
317
+ 'jdbc:postgresql'
318
+ else
319
+ 'postgresql'
320
+ end.freeze
321
+ DatabaseURITemplate = '%<schema>s://%<host>s%<port>s/%<database_name>s'.freeze
322
+ PortTemplate = ':%<port>s'.freeze
323
+ URITemplate = '%<uri>s?%<query_string>s'.freeze
324
+ EmptyString = ''.freeze
325
+ DatabaseConfig = Struct.new(
326
+ :schema, :host, :port, :database, :user, :password, :logger_level
327
+ )
328
+
329
+ def switch_database(database_name = database)
330
+ reconnect_database(database_url({database_name: database_name}))
331
+ true
332
+ end
333
+
334
+ def switch_user(username = database, database_name = nil)
335
+ url = database_url({username: username, database_name: database_name})
336
+ reconnect_database(url)
337
+ true
338
+ end
339
+
340
+ # Build a JDBC URL string from config + overrides.
341
+ #
342
+ # overrides: hash of config keys (schema, host, port, database, user, password, logger_level)
343
+ # exclude: array of query param keys as *external* names, e.g. [:loggerLevel]
344
+ # rubocop: disable Metrics/AbcSize
345
+ # rubocop: disable Metrics/MethodLength
346
+ def database_url(overrides = {}, exclude: [])
347
+ config = connection_config.merge(overrides)
348
+ base_url = format(
349
+ DatabaseURITemplate,
350
+ schema: config[:schema] || DefaultSchema,
351
+ host: config[:host],
352
+ port: config[:port] ? format(PortTemplate, port: config[:port]) : EmptyString,
353
+ database_name: config[:database]
354
+ )
355
+
356
+ query = {}
357
+ query[:user] = config[:user]
358
+ query[:password] = config[:password]
359
+ query[:loggerLevel] = config[:loggerLevel]
360
+ query.reject! { |k, v| v.nil? || exclude.include?(k) }
361
+
362
+ return base_url if query.empty?
363
+ query_string = URI.encode_www_form(query.transform_keys(&:to_s))
364
+ format(URITemplate, uri: base_url, query_string: query_string)
365
+ end
366
+ # rubocop: enable Metrics/AbcSize
367
+ # rubocop: enable Metrics/MethodLength
368
+
369
+ # rubocop: disable Metrics/AbcSize
370
+ # rubocop: disable Metrics/CyclomaticComplexity
371
+ def connection_config
372
+ opts = Sequel::Model.db.opts
373
+
374
+ DatabaseConfig.new(
375
+ schema: opts[:adapter] && opts[:adapter].to_s || DEFAULT_SCHEMA,
376
+ host: opts[:host] || "localhost",
377
+ port: opts[:port],
378
+ database: opts[:database] || database,
379
+ user: opts[:user] || database,
380
+ password: opts[:password] || opts[:user] || database,
381
+ logger_level: "OFF"
382
+ ).to_h
383
+ end
384
+ # rubocop: enable Metrics/AbcSize
385
+ # rubocop: enable Metrics/CyclomaticComplexity
386
+
387
+ # rubocop: disable Metrics/MethodLength
388
+ def create_user(username = database)
389
+ return false if user_exist?(username)
390
+ begin
391
+ log.debug "Creating user: #{username}"
392
+ run "create user #{username}"
393
+ run "alter role #{username} with password '#{username}'"
394
+ return true
395
+ rescue Sequel::DatabaseError => e
396
+ log.error e.inspect
397
+ return false
398
+ rescue StandardError => e
399
+ log.error e.inspect
400
+ return false
401
+ end
402
+ end
403
+ # rubocop: enable Metrics/MethodLength
404
+
405
+ # rubocop: disable Metrics/MethodLength
406
+ def delete_user(username = database)
407
+ return false unless user_exist?(username)
408
+ begin
409
+ log.debug "Dropping user: #{username}"
410
+ run "reassign owned by #{username} to postgres"
411
+ run "drop user #{username}"
412
+ rescue Sequel::DatabaseError => e
413
+ log.error e.inspect
414
+ return false
415
+ rescue StandardError => e
416
+ log.error e.inspect
417
+ return false
418
+ end
419
+ end
420
+ # rubocop: enable Metrics/MethodLength
421
+ end
422
+ # module Utilities
423
+ end
424
+ # module Databases
425
+
426
+ # The Sequel module
427
+ module Sequel
428
+ # The Sequel::Postgres module
429
+ module Postgres
430
+ if defined?(Sequel::Migration)
431
+ # The Sequel::Postgres::Bootstrap class
432
+ class Bootstrap < Sequel::Migration
433
+ include Sequel::Inflections
434
+ include Databases::Helpers
435
+ include Databases::Management
436
+ include Databases::Utilities
437
+
438
+ def up
439
+ create_user
440
+ create_database
441
+ switch_database
442
+ switch_user
443
+ end
444
+
445
+ def down
446
+ switch_database :postgres
447
+ drop_database
448
+ delete_user
449
+ end
450
+
451
+ def self.migrate
452
+ MigrationMethods.each do |method_name|
453
+ Inform::Databases.instances.each_value { |db| db.send(method_name) }
454
+ end
455
+ end
456
+
457
+ def self.up
458
+ Inform::Databases.instances.each_value { |db| db.send(:up) }
459
+ end
460
+ end
461
+ # class Bootstrap
462
+ end
463
+ # defined?(Sequel::Migration)
464
+ end
465
+ # module Postgres
466
+ end
467
+ # module Sequel
468
+
469
+ # The Inform module
470
+ module Inform
471
+ # The Databases module
472
+ module Databases
473
+ @instances = {}
474
+ def instances
475
+ @instances
476
+ end
477
+ module_function :instances
478
+ end
479
+ end
480
+
481
+ if defined?(Sequel::Postgres::Bootstrap)
482
+ # The Greenfield module
483
+ module Greenfield
484
+ # The Database abstraction class
485
+ class Database
486
+ attr_reader :bootstrap
487
+
488
+ def self.init(database_name)
489
+ Greenfield::Database.instance(database_name)
490
+ end
491
+
492
+ @instance_mutex = Mutex.new
493
+
494
+ def self.instance(*args)
495
+ return @instance unless @instance.nil?
496
+ @instance_mutex.synchronize do
497
+ @instance ||= new(*args)
498
+ end
499
+ @instance
500
+ end
501
+
502
+ private_class_method :new
503
+
504
+ def initialize(env = Game.environment)
505
+ log.debug "Initializing persistence layer for environment: #{env}"
506
+ log.debug caller[0..4]
507
+ env ||= environment
508
+ @config = database_config.fetch(env.to_s, {})
509
+ establish_database_connection
510
+ enable_plugins
511
+ end
512
+ REGISTRY = Struct.new(:memo).new(defined?(Java) ? java.util.concurrent.ConcurrentHashMap.new : {})
513
+ def initialize(database_name)
514
+ @bootstrap = Sequel::Postgres::Bootstrap.new(Sequel::Model.db)
515
+ Inform::Databases.instances[database_name] = self
516
+ end
517
+
518
+ private
519
+
520
+ def ensure_game_state_flushed
521
+ Sequel::Model.db.synchronize {} # rubocop: disable Lint/EmptyBlock
522
+ end
523
+ end
524
+ end
525
+ # module Greenfield
526
+ end
527
+ # defined?(Sequel::Postgres::Bootstrap)