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.
- checksums.yaml +7 -0
- data/LICENSE +623 -0
- data/README.md +188 -0
- data/Rakefile +58 -0
- data/config/database.yml +37 -0
- data/exe/inform.rb +6 -0
- data/game/config.yml +5 -0
- data/game/example.inf +90 -0
- data/game/example.rb +105 -0
- data/game/forms/example_form.rb +2 -0
- data/game/grammar/admin.inf.rb +185 -0
- data/game/grammar/builder.inf.rb +310 -0
- data/game/grammar/game_grammar.inf.rb +6 -0
- data/game/grammar/meta.inf.rb +41 -0
- data/game/languages/english.rb +571 -0
- data/game/models/example_model.rb +2 -0
- data/game/modules/example_module.rb +9 -0
- data/game/modules/parser_extensions.rb +264 -0
- data/game/rules/example_state.rb +2 -0
- data/game/scripts/example_script.rb +2 -0
- data/game/topics/example_topic.rb +2 -0
- data/game/verbs/game_verbs.rb +35 -0
- data/game/verbs/metaverbs.rb +2066 -0
- data/lib/story_teller/application.rb +82 -0
- data/lib/story_teller/cli.rb +35 -0
- data/lib/story_teller/color.rb +144 -0
- data/lib/story_teller/config.rb +61 -0
- data/lib/story_teller/curses_adapter.rb +30 -0
- data/lib/story_teller/database.rb +527 -0
- data/lib/story_teller/game/loader.rb +276 -0
- data/lib/story_teller/game.rb +22 -0
- data/lib/story_teller/inform/models.rb +42 -0
- data/lib/story_teller/inform/relational/link.rb +239 -0
- data/lib/story_teller/inform/relational/module.rb +203 -0
- data/lib/story_teller/inform/relational/object.rb +546 -0
- data/lib/story_teller/inform/relational/tag.rb +152 -0
- data/lib/story_teller/options.rb +151 -0
- data/lib/story_teller/persistence.rb +340 -0
- data/lib/story_teller/player_character.rb +99 -0
- data/lib/story_teller/privileges.rb +55 -0
- data/lib/story_teller/runtime.rb +381 -0
- data/lib/story_teller/snapshots.rb +412 -0
- data/lib/story_teller/terminal.rb +58 -0
- data/lib/story_teller/version.rb +24 -0
- data/lib/story_teller_cli.rb +34 -0
- 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
|