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,381 @@
1
+ # lib/story_teller/teller.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 'curses_adapter'
23
+
24
+ # module StoryTeller
25
+ module StoryTeller
26
+ # module InstanceMethods
27
+ module InstanceMethods
28
+ UndefinedMainPattern = %r{undefined method 'Main' for an instance of Object}.freeze
29
+
30
+ attr_accessor :main_object, :session
31
+
32
+ def default_config
33
+ @default_config ||= StoryTeller::Config.defaults.merge(@options).tap do |config|
34
+ config[:word_wrap] ||= StoryTeller::Terminal.word_wrap
35
+ end
36
+ end
37
+
38
+ def config_file_path
39
+ @config_file_path ||= game.config_file_path
40
+ end
41
+
42
+ def game_path
43
+ @game_path ||= game.path
44
+ end
45
+
46
+ def grammar_module_path
47
+ @grammar_module_path ||= File.join(game_path, @options[:game_grammar_module_name])
48
+ end
49
+
50
+ def game_file_path
51
+ @game_file_path ||= game.file_path
52
+ end
53
+
54
+ def game_dir_name
55
+ @game_dir_name ||= @options[:game_dir_name]
56
+ end
57
+
58
+ def game_components
59
+ @game_components ||= @options.fetch(:game_components, '').split.map(&:to_sym)
60
+ end
61
+
62
+ def project_dir_path
63
+ @project_dir_path ||= begin
64
+ story_dir_path = File.expand_path(__dir__)
65
+ lib_dir_path = File.expand_path(File.dirname(story_dir_path))
66
+ File.expand_path(File.dirname(lib_dir_path))
67
+ end
68
+ end
69
+
70
+ def invocation_properties
71
+ @invocation_properties ||= @options.fetch(:properties, '').split.map(&:to_sym)
72
+ end
73
+
74
+ def invocation_context
75
+ @invocation_context ||= Struct.new(*invocation_properties)
76
+ end
77
+
78
+ def configure!(options)
79
+ @options.merge!(options)
80
+ reset_config_cache
81
+ @game = StoryTeller::Game.new(@options)
82
+ self
83
+ end
84
+
85
+ def reset_config_cache
86
+ @config_file_path = nil
87
+ @default_config = nil
88
+ @game_components = nil
89
+ @game_dir_name = nil
90
+ @game_file_path = nil
91
+ @game_path = nil
92
+ @grammar_module_path = nil
93
+ end
94
+
95
+ def invoke_main
96
+ ::Object.new.send(:Main)
97
+ rescue NameError => e
98
+ if UndefinedMainPattern.match?(e.message)
99
+ log.fatal "Main() definition is missing"
100
+ else
101
+ log.fatal "Fatal error invoking Main(): #{e.message}"
102
+ end
103
+ abort 'Terminating'
104
+ end
105
+
106
+ def install_persisted_startup_location_hook
107
+ return if InformLibrary < StoryTeller::PersistedStartupMove
108
+
109
+ InformLibrary.prepend(StoryTeller::PersistedStartupMove)
110
+ end
111
+
112
+ def persist?
113
+ @options[:persist]
114
+ end
115
+
116
+ # TODO: Ensuring the location of the player object requires
117
+ # that this method be invoked before the move @player, @location
118
+ # operation in the InformLibrary#play method. This means that
119
+ # ensuring the location must take place either in the game-
120
+ # defined Initialise() method, or else it can happen in a
121
+ # provided LibraryExtension method, but in that case it would
122
+ # be at risk of being overridden by a game-defined Initialise()
123
+ # method.
124
+ # TODO: Figure out what the best approach here is. I think that
125
+ # if a multi-player game is to supply an entrypoint module, it
126
+ # will have to handle the location ensurance itself.
127
+ def ensuring_player_location(&block)
128
+ if persist?
129
+ install_persisted_startup_location_hook
130
+ persisted_location = StoryTeller::Engine.player_object&.location
131
+ StoryTeller::PersistedStartupLocation.with(persisted_location) { block.call }
132
+ else
133
+ block.call
134
+ location = location_candidate
135
+ raise "InformLibrary location may not be left unset" if location.nil?
136
+
137
+ inform_library&.PlayerTo(location, 1)
138
+ end
139
+ end
140
+
141
+ def location_candidate
142
+ return persisted_location_candidate if persist?
143
+
144
+ canonical_location_candidate
145
+ end
146
+
147
+ # rubocop: disable Metrics/CyclomaticComplexity
148
+ def canonical_location_candidate
149
+ inform_library&.location ||
150
+ inform_library&.player&.location ||
151
+ inform_library&.player&.spawn_point
152
+ end
153
+
154
+ def persisted_location_candidate
155
+ inform_library&.player&.location ||
156
+ inform_library&.location ||
157
+ inform_library&.player&.spawn_point
158
+ end
159
+ # rubocop: enable Metrics/CyclomaticComplexity
160
+
161
+ def restore_player_location
162
+ inform_library.PlayerTo(
163
+ inform_library.player.location,
164
+ -1 # Quietly
165
+ )
166
+ end
167
+
168
+ # Initially, inform_library defaults to using Inform::Parser::SelfObj.
169
+ # However, that SelfObj is by default an instance of an
170
+ # Inform::Ephemeral::Object. Of course, persistence is not really
171
+ # required for a play session of a single player story game, since
172
+ # the snapshot file save feature is implemented. Nevertheless, it
173
+ # is the intention of this application to demonstrate persistence of
174
+ # Inform-esque data into a PostgreSQL database. And so the default
175
+ # selfobj is overridden here with a non-ephemeral version.
176
+ def use_persistent_selfobj
177
+ require_relative 'player_character'
178
+
179
+ StoryTeller::Engine.player_object = StoryTeller::PlayerCharacter("(self object)")
180
+ end
181
+
182
+ def inform_library
183
+ @inform_library ||= StoryTeller::Engine.library
184
+ end
185
+
186
+ def identity
187
+ self.object_id
188
+ end
189
+
190
+ def invocation_identity
191
+ @invocation_identity ||= StoryTeller::InvocationIdentity.new(self)
192
+ end
193
+
194
+ def privileged_identities(privilege)
195
+ [invocation_identity].select { |identity| identity.privileged?(privilege) }
196
+ end
197
+
198
+ def manage_privileges
199
+ apply_privileges(self.class, privileges: StoryTeller::Privileges)
200
+ apply_privileges(InformLibrary, privileges: StoryTeller::Privileges) if defined?(InformLibrary)
201
+ apply_privileges(StoryTeller::IO::Session, privileges: StoryTeller::Privileges)
202
+
203
+ StoryTeller::PrivilegeGrants.mode = @options.fetch(:privilege_mode, :session_only)
204
+ apply_invocation_privileges(self)
205
+ end
206
+
207
+ def apply_invocation_privileges(subject)
208
+ StoryTeller::PrivilegeGrants.grant_session(subject, :admin) if @options[:admin]
209
+ StoryTeller::PrivilegeGrants.grant_session(subject, :builder) if @options[:builder]
210
+ end
211
+
212
+ def apply_privileges(klass, privileges: StoryTeller::Privileges)
213
+ return if klass.nil? || klass < privileges
214
+
215
+ klass.include(privileges)
216
+ end
217
+
218
+ def bind_session(inflib = inform_library)
219
+ @session ||= StoryTeller::IO::Session.new(
220
+ machine: inflib,
221
+ player: inflib.selfobj,
222
+ state: :playing,
223
+ settings: default_config
224
+ ).tap do |session|
225
+ apply_invocation_privileges(session)
226
+ end
227
+
228
+ @session.expose_to(inflib)
229
+ @session
230
+ end
231
+
232
+ def persistence
233
+ StoryTeller::Persistence.instance
234
+ end
235
+
236
+ def read
237
+ flush_session_output unless session.nil?
238
+ $stdin.gets(chomp: true)
239
+ end
240
+
241
+ def configure_output
242
+ return unless @options[:curses]
243
+
244
+ StoryTeller::IO.default_output = StoryTeller::CursesAdapter.new
245
+ end
246
+
247
+ def write_output(value)
248
+ return if value.nil? || value.empty?
249
+
250
+ output = StoryTeller::IO.default_output || $stdout
251
+
252
+ output.write(value)
253
+ $stdout.flush if output.equal?($stdout)
254
+ end
255
+
256
+ def flush_session_output
257
+ write_output(session.drain_output)
258
+ end
259
+
260
+ def read_eval_print_loop
261
+ loop do
262
+ prompt unless session.state == :more
263
+ flush_session_output
264
+ write_output(session.process(read, inform_library))
265
+ end
266
+ rescue Interrupt => e
267
+ write_output("\n#{e.class.name}\n")
268
+ end
269
+
270
+ def play_game
271
+ log.trace "#{self}#play_game"
272
+ configure_output
273
+ use_persistent_selfobj
274
+ bind_session
275
+ ensuring_player_location { invoke_main }
276
+ flush_session_output
277
+ read_eval_print_loop
278
+ end
279
+
280
+ def quit_game
281
+ write_output "[Hit enter to exit.]"
282
+ $stdin.getc
283
+ # Curses.getch
284
+ # Curses.close_screen
285
+ exit
286
+ end
287
+
288
+ def restart_game
289
+ L__M(:Restart, 2)
290
+ end
291
+
292
+ # rubocop: disable Metrics/AbcSize
293
+ # rubocop: disable Metrics/MethodLength
294
+ def save_game(callback = nil)
295
+ timestamp = Time.now.utc.strftime('%Y-%m-%d_%H%M%S')
296
+ filename = format(
297
+ '%<game_name>s_%<timestamp>s.sav',
298
+ game_name: game.name,
299
+ timestamp: timestamp
300
+ )
301
+ println "Enter a file name."
302
+ print "Default is \"#{filename}\": "
303
+ flush_session_output
304
+ response = $stdin.gets&.strip.to_s
305
+ filename = response unless response.empty?
306
+ StoryTeller::Snapshots.export_to_file(filename)
307
+ inform_library.println(callback&.call)
308
+ end
309
+ # rubocop: enable Metrics/AbcSize
310
+ # rubocop: enable Metrics/MethodLength
311
+
312
+ # rubocop: disable Metrics/AbcSize
313
+ # rubocop: disable Metrics/MethodLength
314
+ def restore_game(callback = nil)
315
+ filename = format('%s.sav', game.name)
316
+ files = Dir.glob(File.join(format('%s_*.sav', game.name)))
317
+ filename = files.max unless files.empty?
318
+ println "Enter a file name."
319
+ print "Default is \"#{filename}\": "
320
+ flush_session_output
321
+ response = $stdin.gets&.strip.to_s
322
+ filename = response unless response.empty?
323
+ StoryTeller::Snapshots.import_from_file(filename)
324
+ restore_player_location
325
+ inform_library.println(callback&.call)
326
+ end
327
+ # rubocop: enable Metrics/AbcSize
328
+ # rubocop: enable Metrics/MethodLength
329
+
330
+ def to_s
331
+ "#<#{self.class.name}:#{object_id}>"
332
+ end
333
+
334
+ def inspect
335
+ to_s
336
+ end
337
+ end
338
+ # module InstanceMethods
339
+
340
+ # module ClassMethods
341
+ module ClassMethods
342
+ def project_dir_path
343
+ @project_dir_path ||= begin
344
+ story_dir_path = File.expand_path(__dir__)
345
+ lib_dir_path = File.expand_path(File.dirname(story_dir_path))
346
+ File.expand_path(File.dirname(lib_dir_path))
347
+ end
348
+ end
349
+
350
+ def game_saves_dir_path
351
+ File.join(
352
+ StoryTeller::Runtime.project_dir_path,
353
+ '.' + StoryTeller::Game.config[:game_name] + '_saves')
354
+ end
355
+
356
+ def instance(options = nil)
357
+ @instance ||= new(StoryTeller::Config.defaults)
358
+ @instance.tap { |obj| obj.configure!(options) unless options.nil? }
359
+ end
360
+ end
361
+ # module StoryTellerClassMethods
362
+
363
+ # A Runtime runs a story game.
364
+ class Runtime
365
+ include StoryTeller::InstanceMethods
366
+ include StoryTeller::IO
367
+ include StoryTeller::Publisher
368
+
369
+ class << self
370
+ include StoryTeller::ClassMethods
371
+ end
372
+
373
+ attr_reader :game, :options
374
+
375
+ def initialize(options = StoryTeller::Config.defaults)
376
+ @options = {}
377
+ configure!(options)
378
+ end
379
+ end
380
+ end
381
+ # module StoryTeller