fable 0.5.0
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/.circleci/config.yml +30 -0
- data/.gitignore +57 -0
- data/.ruby-version +1 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +30 -0
- data/LICENSE +21 -0
- data/README.md +2 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/bin/test +8 -0
- data/fable.gemspec +34 -0
- data/fable.sublime-project +8 -0
- data/lib/fable.rb +49 -0
- data/lib/fable/call_stack.rb +351 -0
- data/lib/fable/choice.rb +31 -0
- data/lib/fable/choice_point.rb +65 -0
- data/lib/fable/container.rb +218 -0
- data/lib/fable/control_command.rb +156 -0
- data/lib/fable/debug_metadata.rb +13 -0
- data/lib/fable/divert.rb +100 -0
- data/lib/fable/glue.rb +7 -0
- data/lib/fable/ink_list.rb +425 -0
- data/lib/fable/list_definition.rb +44 -0
- data/lib/fable/list_definitions_origin.rb +35 -0
- data/lib/fable/native_function_call.rb +324 -0
- data/lib/fable/native_function_operations.rb +149 -0
- data/lib/fable/observer.rb +205 -0
- data/lib/fable/path.rb +186 -0
- data/lib/fable/pointer.rb +42 -0
- data/lib/fable/profiler.rb +287 -0
- data/lib/fable/push_pop_type.rb +11 -0
- data/lib/fable/runtime_object.rb +159 -0
- data/lib/fable/search_result.rb +20 -0
- data/lib/fable/serializer.rb +560 -0
- data/lib/fable/state_patch.rb +47 -0
- data/lib/fable/story.rb +1447 -0
- data/lib/fable/story_state.rb +915 -0
- data/lib/fable/tag.rb +14 -0
- data/lib/fable/value.rb +334 -0
- data/lib/fable/variable_assignment.rb +20 -0
- data/lib/fable/variable_reference.rb +38 -0
- data/lib/fable/variables_state.rb +327 -0
- data/lib/fable/version.rb +3 -0
- data/lib/fable/void.rb +4 -0
- data/zork_mode.rb +23 -0
- metadata +149 -0
@@ -0,0 +1,915 @@
|
|
1
|
+
module Fable
|
2
|
+
# All story state information is included in the StoryState class,
|
3
|
+
# including global variables, read counts, the pointer to the current
|
4
|
+
# point in the story, the call stack (for tunnels, functions, etc),
|
5
|
+
# and a few other smaller bits and pieces. You can save the current
|
6
|
+
# state using the serialization functions
|
7
|
+
class StoryState
|
8
|
+
CURRENT_INK_SAVE_STATE_VERSION = 8
|
9
|
+
MINIMUM_COMPATIBLE_INK_LOAD_VERSION = 8
|
10
|
+
|
11
|
+
MULTIPLE_WHITESPACE_REGEX = /[ \t]{2,}/
|
12
|
+
|
13
|
+
attr_accessor :patch, :output_stream, :current_choices,
|
14
|
+
:current_errors, :current_warnings, :callstack,
|
15
|
+
:evaluation_stack, :diverted_pointer,
|
16
|
+
:current_turn_index, :story_seed, :previous_random,
|
17
|
+
:did_safe_exit, :story, :variables_state,
|
18
|
+
:current_text, :output_stream_text_dirty,
|
19
|
+
:current_tags, :output_stream_tags_dirty,
|
20
|
+
:visit_counts, :turn_indicies
|
21
|
+
|
22
|
+
alias_method :did_safe_exit?, :did_safe_exit
|
23
|
+
|
24
|
+
def initialize(story)
|
25
|
+
self.story = story
|
26
|
+
self.output_stream = []
|
27
|
+
self.output_stream_dirty!
|
28
|
+
|
29
|
+
self.evaluation_stack = []
|
30
|
+
|
31
|
+
self.callstack = CallStack.new(story)
|
32
|
+
self.variables_state = VariablesState.new(callstack, story.list_definitions)
|
33
|
+
|
34
|
+
self.visit_counts = {}
|
35
|
+
self.turn_indicies = {}
|
36
|
+
|
37
|
+
self.current_turn_index = -1
|
38
|
+
|
39
|
+
# Seed the shuffle random numbers
|
40
|
+
time_seed = Time.now.to_r * 1_000.0
|
41
|
+
self.story_seed = IntValue.new(Random.new(time_seed).rand(100))
|
42
|
+
self.previous_random = 0
|
43
|
+
|
44
|
+
self.current_choices = []
|
45
|
+
|
46
|
+
self.diverted_pointer = Pointer.null_pointer
|
47
|
+
self.current_pointer = Pointer.null_pointer
|
48
|
+
|
49
|
+
self.go_to_start!
|
50
|
+
end
|
51
|
+
|
52
|
+
# <summary>
|
53
|
+
# Gets the visit/read count of a particular Container at the given path.
|
54
|
+
# For a knot or stitch, that path string will be in the form:
|
55
|
+
#
|
56
|
+
# knot
|
57
|
+
# knot.stitch
|
58
|
+
#
|
59
|
+
# </summary>
|
60
|
+
# <returns>The number of times the specific knot or stitch has
|
61
|
+
# been enountered by the ink engine.</returns>
|
62
|
+
# <param name="pathString">The dot-separated path string of
|
63
|
+
# the specific knot or stitch.</param>
|
64
|
+
def visit_count_at_path_string(path_string)
|
65
|
+
if has_patch?
|
66
|
+
container = story.content_at_path(Path.new(path_string)).container
|
67
|
+
if container.nil?
|
68
|
+
raise Error, "Content at path not found: #{path_string}"
|
69
|
+
end
|
70
|
+
|
71
|
+
if patch.get_visit_count(container)
|
72
|
+
return patch.get_visit_count(container)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
return visit_counts[path_string] || 0
|
77
|
+
end
|
78
|
+
|
79
|
+
def visit_count_for_container(container)
|
80
|
+
if !container.visits_should_be_counted?
|
81
|
+
story.add_error!("Read count for target (#{container.name} - on #{container.debug_metadata}) unknown.")
|
82
|
+
return IntValue.new(0)
|
83
|
+
end
|
84
|
+
|
85
|
+
if has_patch? && patch.get_visit_count(container)
|
86
|
+
return IntValue.new(patch.get_visit_count(container))
|
87
|
+
end
|
88
|
+
|
89
|
+
container_path_string = container.path.to_s
|
90
|
+
return IntValue.new(visit_counts[container_path_string] || 0)
|
91
|
+
end
|
92
|
+
|
93
|
+
def increment_visit_count_for_container!(container)
|
94
|
+
if has_patch?
|
95
|
+
current_count = visit_count_for_container(container)
|
96
|
+
patch.set_visit_count(container, current_count.value + 1)
|
97
|
+
return
|
98
|
+
end
|
99
|
+
|
100
|
+
container_path_string = container.path.to_s
|
101
|
+
count = (visit_counts[container_path_string] || 0)
|
102
|
+
count += 1
|
103
|
+
visit_counts[container_path_string] = count
|
104
|
+
end
|
105
|
+
|
106
|
+
def record_turn_index_visit_to_container!(container)
|
107
|
+
if has_patch?
|
108
|
+
patch.set_turn_index(container, current_turn_index)
|
109
|
+
return
|
110
|
+
end
|
111
|
+
|
112
|
+
container_path_string = container.path.to_s
|
113
|
+
turn_indicies[container_path_string] = current_turn_index
|
114
|
+
end
|
115
|
+
|
116
|
+
def turns_since_for_container(container)
|
117
|
+
if !container.turn_index_should_be_counted?
|
118
|
+
story.add_error!("TURNS_SINCE() for target (#{container.name}) - on #{container.debug_metadata}) unknown.")
|
119
|
+
end
|
120
|
+
|
121
|
+
if has_patch? && patch.get_turn_index(container)
|
122
|
+
return (current_turn_index - patch.get_turn_index(container))
|
123
|
+
end
|
124
|
+
|
125
|
+
container_path_string = container.path.to_s
|
126
|
+
|
127
|
+
if turn_indicies[container_path_string]
|
128
|
+
return current_turn_index - turn_indicies[container_path_string]
|
129
|
+
else
|
130
|
+
return -1
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def callstack_depth
|
135
|
+
callstack.depth
|
136
|
+
end
|
137
|
+
|
138
|
+
def current_choices
|
139
|
+
# If we can continue generating text content rather than choices,
|
140
|
+
# then we reflect the choice list as being empty, since choices
|
141
|
+
# should always come at the end.
|
142
|
+
return [] if can_continue?
|
143
|
+
return @current_choices
|
144
|
+
end
|
145
|
+
|
146
|
+
def generated_choices
|
147
|
+
return @current_choices
|
148
|
+
end
|
149
|
+
|
150
|
+
def current_path_string
|
151
|
+
if current_pointer.null_pointer?
|
152
|
+
return nil
|
153
|
+
else
|
154
|
+
return current_pointer.path.to_s
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def current_pointer
|
159
|
+
callstack.current_element.current_pointer
|
160
|
+
end
|
161
|
+
|
162
|
+
def current_pointer=(value)
|
163
|
+
callstack.current_element.current_pointer = value
|
164
|
+
end
|
165
|
+
|
166
|
+
def previous_pointer
|
167
|
+
callstack.current_thread.previous_pointer
|
168
|
+
end
|
169
|
+
|
170
|
+
def previous_pointer=(value)
|
171
|
+
callstack.current_thread.previous_pointer = value
|
172
|
+
end
|
173
|
+
|
174
|
+
def can_continue?
|
175
|
+
!current_pointer.null_pointer? && !has_error?
|
176
|
+
end
|
177
|
+
|
178
|
+
def has_error?
|
179
|
+
!current_errors.nil? && current_errors.size > 0
|
180
|
+
end
|
181
|
+
|
182
|
+
def has_warning?
|
183
|
+
!current_warnings.nil? && current_warnings.size > 0
|
184
|
+
end
|
185
|
+
|
186
|
+
def current_text
|
187
|
+
if @output_stream_text_dirty
|
188
|
+
text_content = output_stream.select{|x| x.is_a?(StringValue)}.map(&:value).join
|
189
|
+
|
190
|
+
@current_text = clean_output_whitespace(text_content)
|
191
|
+
@output_stream_text_dirty = false
|
192
|
+
end
|
193
|
+
|
194
|
+
return @current_text
|
195
|
+
end
|
196
|
+
|
197
|
+
def current_tags
|
198
|
+
if @output_stream_tags_dirty
|
199
|
+
@current_tags = output_stream.select{|x| x.is_a?(Tag)}.map(&:text)
|
200
|
+
@output_stream_tags_dirty = false
|
201
|
+
end
|
202
|
+
|
203
|
+
return @current_tags
|
204
|
+
end
|
205
|
+
|
206
|
+
def in_expression_evaluation?
|
207
|
+
callstack.current_element.in_expression_evaluation?
|
208
|
+
end
|
209
|
+
|
210
|
+
def in_expression_evaluation=(value)
|
211
|
+
callstack.current_element.in_expression_evaluation = value
|
212
|
+
end
|
213
|
+
|
214
|
+
def in_string_evaluation?
|
215
|
+
@output_stream.reverse_each.any? do |item|
|
216
|
+
item.is_a?(ControlCommand) && item.command_type == :BEGIN_STRING_EVALUATION_MODE
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
def push_evaluation_stack(object)
|
221
|
+
# include metadata about the origin List for list values when they're used
|
222
|
+
# so that lower-level functions can make sure of the origin list to get
|
223
|
+
# Related items, or make comparisons with integer values
|
224
|
+
if object.is_a?(ListValue)
|
225
|
+
# Update origin when list has something to indicate the list origin
|
226
|
+
raw_list = object.value
|
227
|
+
if !raw_list.origin_names.nil?
|
228
|
+
if raw_list.origins.nil?
|
229
|
+
raw_list.origins = []
|
230
|
+
end
|
231
|
+
|
232
|
+
raw_list.origins.clear
|
233
|
+
|
234
|
+
raw_list.origin_names.each do |name|
|
235
|
+
list_definition = story.list_definitions.find_list(name)
|
236
|
+
if !raw_list.origins.include?(list_definition)
|
237
|
+
raw_list.origins << list_definition
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
evaluation_stack << object
|
244
|
+
end
|
245
|
+
|
246
|
+
def pop_evaluation_stack(number_of_items = nil)
|
247
|
+
if number_of_items.nil?
|
248
|
+
return evaluation_stack.pop
|
249
|
+
end
|
250
|
+
|
251
|
+
if number_of_items > evaluation_stack.size
|
252
|
+
raise Error, "trying to pop too many objects"
|
253
|
+
end
|
254
|
+
|
255
|
+
return evaluation_stack.pop(number_of_items)
|
256
|
+
end
|
257
|
+
|
258
|
+
def peek_evaluation_stack
|
259
|
+
return evaluation_stack.last
|
260
|
+
end
|
261
|
+
|
262
|
+
|
263
|
+
# <summary>
|
264
|
+
# Ends the current ink flow, unwrapping the callstack but without
|
265
|
+
# affecting any variables. Useful if the ink is (say) in the middle
|
266
|
+
# a nested tunnel, and you want it to reset so that you can divert
|
267
|
+
# elsewhere using choose_path_string. Otherwise, after finishing
|
268
|
+
# the content you diverted to, it would continue where it left off.
|
269
|
+
# Calling this is equivalent to calling -> END in ink.
|
270
|
+
# </summary>
|
271
|
+
def force_end!
|
272
|
+
callstack.reset!
|
273
|
+
@current_choices.clear
|
274
|
+
self.current_pointer = Pointer.null_pointer
|
275
|
+
self.previous_pointer = Pointer.null_pointer
|
276
|
+
self.did_safe_exit = true
|
277
|
+
end
|
278
|
+
|
279
|
+
# At the end of a function call, trim any whitespace from the end.
|
280
|
+
# We always trim the start and end of the text that a function produces.
|
281
|
+
# The start whitespace is discard as it is generated, and the end
|
282
|
+
# whitespace is trimmed in one go here when we pop the function.
|
283
|
+
def trim_whitespace_from_function_end!
|
284
|
+
assert!(callstack.current_element.type == PushPopType::TYPES[:function])
|
285
|
+
|
286
|
+
function_start_point = callstack.current_element.function_start_in_output_stream
|
287
|
+
|
288
|
+
# if the start point has become -1, it means that some non-whitespace
|
289
|
+
# text has been pushed, so it's safe to go as far back as we're able
|
290
|
+
if function_start_point == -1
|
291
|
+
function_start_point = 0
|
292
|
+
end
|
293
|
+
|
294
|
+
i = @output_stream.count - 1
|
295
|
+
|
296
|
+
# Trim whitespace from END of function call
|
297
|
+
while i >= function_start_point
|
298
|
+
object = output_stream[i]
|
299
|
+
break if object.is_a?(ControlCommand)
|
300
|
+
next if !object.is_a?(StringValue)
|
301
|
+
|
302
|
+
if object.is_newline? || object.is_inline_whitespace?
|
303
|
+
@output_stream.delete_at(i)
|
304
|
+
output_stream_dirty!
|
305
|
+
else
|
306
|
+
break
|
307
|
+
end
|
308
|
+
|
309
|
+
i -= 1
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
def pop_callstack(pop_type=nil)
|
314
|
+
# At the end of a function call, trim any whitespace from the end
|
315
|
+
if callstack.current_element.type == PushPopType::TYPES[:function]
|
316
|
+
trim_whitespace_from_function_end!
|
317
|
+
end
|
318
|
+
|
319
|
+
callstack.pop!(pop_type)
|
320
|
+
end
|
321
|
+
|
322
|
+
def pass_arguments_to_evaluation_stack(arguments)
|
323
|
+
if !arguments.nil?
|
324
|
+
arguments.each do |argument|
|
325
|
+
if !(argument.is_a?(Numeric) || argument.is_a?(String) || argument.is_a?(InkList))
|
326
|
+
raise ArgumentError, "ink arguments when calling evaluate_function/choose_path_string_with_parameters must be int, float, string, or InkList. Argument was #{argument.class.to_s}"
|
327
|
+
end
|
328
|
+
|
329
|
+
push_evaluation_stack(Value.create(argument))
|
330
|
+
end
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
def start_function_evaluation_from_game(function_container, arguments)
|
335
|
+
callstack.push(PushPopType::TYPES[:function_evaluation_from_game], output_stream_length_when_pushed: evaluation_stack.size)
|
336
|
+
callstack.current_element.current_pointer = Pointer.start_of(function_container)
|
337
|
+
pass_arguments_to_evaluation_stack(arguments)
|
338
|
+
end
|
339
|
+
|
340
|
+
def exit_function_evaluation_from_game?
|
341
|
+
if callstack.current_element.type == PushPopType::TYPES[:function_evaluation_from_game]
|
342
|
+
self.current_pointer = Pointer.null_pointer
|
343
|
+
self.did_safe_exit = true
|
344
|
+
return true
|
345
|
+
end
|
346
|
+
|
347
|
+
return false
|
348
|
+
end
|
349
|
+
|
350
|
+
def complete_function_evaluation_from_game
|
351
|
+
if callstack.current_element.type != PushPopType::TYPES[:function_evaluation_from_game]
|
352
|
+
raise Error, "Expected external function evaluation to be complete. Stack trace: #{callstack.call_stack_trace}"
|
353
|
+
end
|
354
|
+
|
355
|
+
original_evaluation_stack_height = callstack.current_element.evaluation_stack_height_when_pushed
|
356
|
+
|
357
|
+
# do we have a returned value?
|
358
|
+
# Potentially pop multiple values off the stack, in case we need to clean up after ourselves
|
359
|
+
# (e.g: caller of evaluate_function may have passed too many arguments, and we currently have no way
|
360
|
+
# to check for that)
|
361
|
+
returned_object = nil
|
362
|
+
while evaluation_stack.size > original_evaluation_stack_height
|
363
|
+
popped_object = pop_evaluation_stack
|
364
|
+
if returned_object.nil?
|
365
|
+
returned_object = popped_object
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
# Finally, pop the external function evaluation
|
370
|
+
pop_callstack(PushPopType::TYPES[:function_evaluation_from_game])
|
371
|
+
|
372
|
+
# What did we get back?
|
373
|
+
if !returned_object.nil?
|
374
|
+
if returned_object.is_a?(Void)
|
375
|
+
return nil
|
376
|
+
end
|
377
|
+
|
378
|
+
# DivertTargets get returned as the string of components
|
379
|
+
# (rather than a Path, which isn't public)
|
380
|
+
if returned_object.is_a?(DivertTargetValue)
|
381
|
+
return returned_object.value_object.to_s
|
382
|
+
end
|
383
|
+
|
384
|
+
# Other types can just have their exact object type.
|
385
|
+
# VariablePointers get returned as strings.
|
386
|
+
return returned_object.value_object
|
387
|
+
end
|
388
|
+
|
389
|
+
return nil
|
390
|
+
end
|
391
|
+
|
392
|
+
def output_stream_dirty!
|
393
|
+
@output_stream_text_dirty = true
|
394
|
+
@output_stream_tags_dirty = true
|
395
|
+
end
|
396
|
+
|
397
|
+
def go_to_start!
|
398
|
+
callstack.current_element.current_pointer = Pointer.start_of(story.main_content_container)
|
399
|
+
end
|
400
|
+
|
401
|
+
# Cleans inline whitespace in the following way:
|
402
|
+
# - Removes all whitespace from the start/end of line (including just before an \n)
|
403
|
+
# - Turns all consecutive tabs & space runs into single spaces (HTML-style)
|
404
|
+
def clean_output_whitespace(string)
|
405
|
+
x = ""
|
406
|
+
|
407
|
+
current_whitespace_start = -1
|
408
|
+
start_of_line = 0
|
409
|
+
|
410
|
+
string.each_char.with_index do |character, i|
|
411
|
+
is_inline_whitespace = (character == " " || character == "\t")
|
412
|
+
|
413
|
+
if is_inline_whitespace && current_whitespace_start == -1
|
414
|
+
current_whitespace_start = i
|
415
|
+
end
|
416
|
+
|
417
|
+
if !is_inline_whitespace
|
418
|
+
if(character != "\n" && (current_whitespace_start > 0) && current_whitespace_start != start_of_line)
|
419
|
+
x += " "
|
420
|
+
end
|
421
|
+
|
422
|
+
current_whitespace_start = -1
|
423
|
+
end
|
424
|
+
|
425
|
+
if character == "\n"
|
426
|
+
start_of_line = i + 1
|
427
|
+
end
|
428
|
+
|
429
|
+
if !is_inline_whitespace
|
430
|
+
x << character
|
431
|
+
end
|
432
|
+
end
|
433
|
+
|
434
|
+
return x
|
435
|
+
|
436
|
+
# x = string.each_line(chomp: true).map do |line|
|
437
|
+
# if line.empty?
|
438
|
+
# nil
|
439
|
+
# else
|
440
|
+
# line.strip.gsub(MULTIPLE_WHITESPACE_REGEX, ' ') + "\n"
|
441
|
+
# end
|
442
|
+
# end
|
443
|
+
# cleaned_string = x.compact.join("\n")
|
444
|
+
|
445
|
+
# cleaned_string
|
446
|
+
end
|
447
|
+
|
448
|
+
def has_patch?
|
449
|
+
!patch.nil?
|
450
|
+
end
|
451
|
+
|
452
|
+
|
453
|
+
# WARNING: Any RuntimeObject content referenced within the StoryState will be
|
454
|
+
# re-referenced rather than cloned. This is generally okay though, since
|
455
|
+
# RuntimeObjects are treated as immutable after they've been set up.
|
456
|
+
# (eg: We don't edit a StringValue after it's been created and added)
|
457
|
+
def copy_and_start_patching!
|
458
|
+
copy = self.class.new(story)
|
459
|
+
copy.patch = StatePatch.new(self.patch)
|
460
|
+
|
461
|
+
copy.output_stream += self.output_stream
|
462
|
+
copy.output_stream_dirty!
|
463
|
+
|
464
|
+
copy.current_choices += @current_choices
|
465
|
+
if has_error?
|
466
|
+
copy.current_errors = []
|
467
|
+
copy.current_errors += self.current_errors
|
468
|
+
end
|
469
|
+
|
470
|
+
if has_warning?
|
471
|
+
copy.current_warnings = []
|
472
|
+
copy.current_warnings += self.current_warnings
|
473
|
+
end
|
474
|
+
|
475
|
+
copy.callstack = CallStack.new(story).from_hash!(self.callstack.to_hash, story)
|
476
|
+
# reference copoy- exactly the same variable state!
|
477
|
+
# we're expected not to read it only while in patch mode
|
478
|
+
# (though the callstack will be modified)
|
479
|
+
copy.variables_state = self.variables_state
|
480
|
+
copy.variables_state.callstack = copy.callstack
|
481
|
+
copy.variables_state.patch = copy.patch
|
482
|
+
|
483
|
+
copy.evaluation_stack += self.evaluation_stack
|
484
|
+
|
485
|
+
if !self.diverted_pointer.null_pointer?
|
486
|
+
copy.diverted_pointer = self.diverted_pointer
|
487
|
+
end
|
488
|
+
|
489
|
+
copy.previous_pointer = self.previous_pointer
|
490
|
+
|
491
|
+
# Visit counts & turn indicies will be read-only, not modified
|
492
|
+
# while in patch mode
|
493
|
+
copy.visit_counts = self.visit_counts
|
494
|
+
copy.turn_indicies = self.turn_indicies
|
495
|
+
|
496
|
+
copy.current_turn_index = self.current_turn_index
|
497
|
+
copy.story_seed = self.story_seed
|
498
|
+
copy.previous_random = self.previous_random
|
499
|
+
|
500
|
+
copy.did_safe_exit = self.did_safe_exit
|
501
|
+
|
502
|
+
return copy
|
503
|
+
end
|
504
|
+
|
505
|
+
def restore_after_patch!
|
506
|
+
# VariablesState was being borrowed by the patched state, so restore it
|
507
|
+
# with our own callstack. patch will be nil normally, but if you're in the
|
508
|
+
# middle of a save, it may contain a patch for save purposes
|
509
|
+
variables_state.callstack = callstack
|
510
|
+
variables_state.patch = self.patch
|
511
|
+
end
|
512
|
+
|
513
|
+
def apply_any_patch!
|
514
|
+
return if self.patch.nil?
|
515
|
+
|
516
|
+
variables_state.apply_patch!
|
517
|
+
|
518
|
+
patch.visit_counts.each do |container, new_count|
|
519
|
+
self.visit_counts[container.path.to_s] = new_count
|
520
|
+
end
|
521
|
+
|
522
|
+
patch.turn_indicies.each do |container, new_count|
|
523
|
+
self.turn_indicies[container.path.to_s] = new_count
|
524
|
+
end
|
525
|
+
end
|
526
|
+
|
527
|
+
def reset_errors!
|
528
|
+
self.current_errors = nil
|
529
|
+
self.current_warnings = nil
|
530
|
+
end
|
531
|
+
|
532
|
+
def add_error(message, options = {is_warning: false})
|
533
|
+
if !options[:is_warning]
|
534
|
+
self.current_errors ||= []
|
535
|
+
self.current_errors << message
|
536
|
+
else
|
537
|
+
self.current_warnings ||= []
|
538
|
+
self.current_warnings << message
|
539
|
+
end
|
540
|
+
|
541
|
+
puts current_errors.inspect
|
542
|
+
puts current_warnings.inspect
|
543
|
+
end
|
544
|
+
|
545
|
+
def reset_output!(objects_to_add = nil)
|
546
|
+
self.output_stream = []
|
547
|
+
if !objects_to_add.nil?
|
548
|
+
self.output_stream += objects_to_add
|
549
|
+
end
|
550
|
+
|
551
|
+
output_stream_dirty!
|
552
|
+
end
|
553
|
+
|
554
|
+
def push_to_output_stream(object)
|
555
|
+
if object.is_a?(StringValue)
|
556
|
+
lines = try_splitting_head_tail_whitespace(object.value)
|
557
|
+
if !lines.nil?
|
558
|
+
lines.each do |line|
|
559
|
+
push_item_to_output_stream(line)
|
560
|
+
end
|
561
|
+
|
562
|
+
output_stream_dirty!
|
563
|
+
return
|
564
|
+
end
|
565
|
+
end
|
566
|
+
|
567
|
+
push_item_to_output_stream(object)
|
568
|
+
output_stream_dirty!
|
569
|
+
end
|
570
|
+
|
571
|
+
def pop_from_output_stream
|
572
|
+
results = output_stream.pop
|
573
|
+
output_stream_dirty!
|
574
|
+
return results
|
575
|
+
end
|
576
|
+
|
577
|
+
def push_item_to_output_stream(object)
|
578
|
+
include_in_output = true
|
579
|
+
|
580
|
+
case object
|
581
|
+
when Glue
|
582
|
+
# new glue, so chomp away any whitespace from the end of the stream
|
583
|
+
trim_newlines_from_output_stream!
|
584
|
+
include_in_output = true
|
585
|
+
when StringValue
|
586
|
+
# New text: do we really want to append it, if it's whitespace?
|
587
|
+
# Two different reasons for whitespace to be thrown away:
|
588
|
+
# - Function start/end trimming
|
589
|
+
# - User defined glue: <>
|
590
|
+
# We also need to know when to stop trimming, when there's no whitespace
|
591
|
+
|
592
|
+
# where does the current function call begin?
|
593
|
+
function_trim_index = -1
|
594
|
+
current_element = callstack.current_element
|
595
|
+
if current_element.type == PushPopType::TYPES[:function]
|
596
|
+
function_trim_index = current_element.function_start_in_output_stream
|
597
|
+
end
|
598
|
+
|
599
|
+
# Do 2 things:
|
600
|
+
# - Find latest glue
|
601
|
+
# - Check whether we're in the middle of string evaluation
|
602
|
+
# If we're in string evaluation within the current function, we don't want to
|
603
|
+
# trim back further than the length of the current string
|
604
|
+
glue_trim_index = -1
|
605
|
+
|
606
|
+
i = @output_stream.count - 1
|
607
|
+
while i >= 0
|
608
|
+
item_to_check = @output_stream[i]
|
609
|
+
if item_to_check.is_a?(Glue)
|
610
|
+
glue_trim_index = i
|
611
|
+
break
|
612
|
+
elsif ControlCommand.is_instance_of?(item_to_check, :BEGIN_STRING_EVALUATION_MODE)
|
613
|
+
if i >= function_trim_index
|
614
|
+
function_trim_index = -1
|
615
|
+
end
|
616
|
+
break
|
617
|
+
end
|
618
|
+
|
619
|
+
i -= 1
|
620
|
+
end
|
621
|
+
|
622
|
+
# Where is the most aggresive (earliest) trim point?
|
623
|
+
trim_index = -1
|
624
|
+
if glue_trim_index != -1 && function_trim_index != -1
|
625
|
+
trim_index = [glue_trim_index, function_trim_index].min
|
626
|
+
elsif glue_trim_index != -1
|
627
|
+
trim_index = glue_trim_index
|
628
|
+
else
|
629
|
+
trim_index = function_trim_index
|
630
|
+
end
|
631
|
+
|
632
|
+
# So, what are we trimming them?
|
633
|
+
if trim_index != -1
|
634
|
+
# While trimming, we want to throw all newlines away,
|
635
|
+
# Whether due to glue, or start of a function
|
636
|
+
if object.is_newline?
|
637
|
+
include_in_output = false
|
638
|
+
# Able to completely reset when normal text is pushed
|
639
|
+
elsif object.is_nonwhitespace?
|
640
|
+
if glue_trim_index > -1
|
641
|
+
remove_existing_glue!
|
642
|
+
end
|
643
|
+
|
644
|
+
# Tell all functionms in callstack that we have seen proper text,
|
645
|
+
# so trimming whitespace at the start is done
|
646
|
+
if function_trim_index > -1
|
647
|
+
callstack.elements.reverse_each do |element|
|
648
|
+
if element.type == PushPopType::TYPES[:function]
|
649
|
+
element.function_start_in_output_stream = -1
|
650
|
+
else
|
651
|
+
break
|
652
|
+
end
|
653
|
+
end
|
654
|
+
end
|
655
|
+
end
|
656
|
+
# De-duplicate newlines, and don't ever lead with a newline
|
657
|
+
elsif object.is_newline?
|
658
|
+
if output_stream_ends_in_newline? || !output_stream_contains_content?
|
659
|
+
include_in_output = false
|
660
|
+
end
|
661
|
+
end
|
662
|
+
end
|
663
|
+
|
664
|
+
if include_in_output
|
665
|
+
@output_stream << object
|
666
|
+
output_stream_dirty!
|
667
|
+
end
|
668
|
+
end
|
669
|
+
|
670
|
+
# At both the start and the end of the string, split out the new lines like so:
|
671
|
+
#
|
672
|
+
# " \n \n \n the string \n is awesome \n \n "
|
673
|
+
# ^-----------^ ^-------^
|
674
|
+
#
|
675
|
+
# Excess newlines are converted into single newlines, and spaces discarded.
|
676
|
+
# Outside spaces are significant and retained. "Interior" newlines within
|
677
|
+
# the main string are ignored, since this is for the purpose of gluing only.
|
678
|
+
#
|
679
|
+
# - If no splitting is necessary, null is returned.
|
680
|
+
# - A newline on its own is returned in a list for consistency.
|
681
|
+
def try_splitting_head_tail_whitespace(string)
|
682
|
+
head_first_newline_index = -1
|
683
|
+
head_last_newline_index = -1
|
684
|
+
|
685
|
+
string.each_char.each_with_index do |character, i|
|
686
|
+
if character == "\n"
|
687
|
+
if head_first_newline_index == -1
|
688
|
+
head_first_newline_index = i
|
689
|
+
end
|
690
|
+
|
691
|
+
head_last_newline_index = i
|
692
|
+
elsif character == " " || character == "\t"
|
693
|
+
next
|
694
|
+
else
|
695
|
+
break
|
696
|
+
end
|
697
|
+
end
|
698
|
+
|
699
|
+
tail_first_newline_index = -1
|
700
|
+
tail_last_newline_index = -1
|
701
|
+
string.reverse.each_char.each_with_index do |character, i|
|
702
|
+
if character == "\n"
|
703
|
+
if tail_last_newline_index == -1
|
704
|
+
tail_last_newline_index = i
|
705
|
+
end
|
706
|
+
|
707
|
+
tail_first_newline_index = i
|
708
|
+
elsif character == " " || character == "\t"
|
709
|
+
next
|
710
|
+
else
|
711
|
+
break
|
712
|
+
end
|
713
|
+
end
|
714
|
+
|
715
|
+
if head_first_newline_index == -1 && tail_last_newline_index == -1
|
716
|
+
return nil
|
717
|
+
end
|
718
|
+
|
719
|
+
list_texts = []
|
720
|
+
inner_string_start = 0
|
721
|
+
inner_string_end = string.length
|
722
|
+
|
723
|
+
if head_first_newline_index != -1
|
724
|
+
if head_first_newline_index > 0
|
725
|
+
leading_spaces = string[0, head_first_newline_index]
|
726
|
+
list_texts << leading_spaces
|
727
|
+
end
|
728
|
+
|
729
|
+
list_texts << "\n"
|
730
|
+
inner_string_start = head_last_newline_index + 1
|
731
|
+
end
|
732
|
+
|
733
|
+
if tail_last_newline_index != -1
|
734
|
+
inner_string_end = tail_first_newline_index
|
735
|
+
end
|
736
|
+
|
737
|
+
if inner_string_end > inner_string_start
|
738
|
+
inner_string_text = string[inner_string_start, (inner_string_end - inner_string_start)]
|
739
|
+
list_texts << inner_string_text
|
740
|
+
end
|
741
|
+
|
742
|
+
if tail_last_newline_index != -1 && tail_first_newline_index > head_last_newline_index
|
743
|
+
list_texts << "\n"
|
744
|
+
if tail_last_newline_index < (string.length -1)
|
745
|
+
number_of_spaces = (string.length - tail_last_newline_index) - 1
|
746
|
+
trailing_spaces = string[tail_last_newline_index + 1, number_of_spaces]
|
747
|
+
list_texts << trailing_spaces
|
748
|
+
end
|
749
|
+
end
|
750
|
+
|
751
|
+
return list_texts.map{|x| StringValue.new(x) }
|
752
|
+
end
|
753
|
+
|
754
|
+
def trim_newlines_from_output_stream!
|
755
|
+
remove_whitespace_from = -1
|
756
|
+
|
757
|
+
# Work back from the end, and try to find the point where we need to
|
758
|
+
# start removing content.
|
759
|
+
# - Simply work backwards to find the first newline in a string of whitespace
|
760
|
+
# e.g. This is the content \n \n\n
|
761
|
+
# ^---------^ whitespace to remove
|
762
|
+
# ^--- first while loop stops here
|
763
|
+
i = @output_stream.count - 1
|
764
|
+
while i >= 0
|
765
|
+
object = @output_stream[i]
|
766
|
+
if object.is_a?(ControlCommand) || (object.is_a?(StringValue) && object.is_nonwhitespace?)
|
767
|
+
break
|
768
|
+
elsif object.is_a?(StringValue) && object.is_newline?
|
769
|
+
remove_whitespace_from = i
|
770
|
+
end
|
771
|
+
|
772
|
+
i -= 1
|
773
|
+
end
|
774
|
+
|
775
|
+
# Remove the whitespace
|
776
|
+
if remove_whitespace_from >= 0
|
777
|
+
self.output_stream = self.output_stream[0..(remove_whitespace_from-1)]
|
778
|
+
end
|
779
|
+
|
780
|
+
output_stream_dirty!
|
781
|
+
end
|
782
|
+
|
783
|
+
# Only called when non-whitespace is appended
|
784
|
+
def remove_existing_glue!
|
785
|
+
@output_stream.each_with_index do |object, i|
|
786
|
+
if object.is_a?(Glue)
|
787
|
+
@output_stream.delete_at(i)
|
788
|
+
elsif object.is_a?(ControlCommand)
|
789
|
+
end
|
790
|
+
end
|
791
|
+
|
792
|
+
output_stream_dirty!
|
793
|
+
end
|
794
|
+
|
795
|
+
def output_stream_ends_in_newline?
|
796
|
+
return false if @output_stream.empty?
|
797
|
+
return @output_stream.last.is_a?(StringValue) && @output_stream.last.is_newline?
|
798
|
+
end
|
799
|
+
|
800
|
+
def output_stream_contains_content?
|
801
|
+
@output_stream.any?{|x| x.is_a?(StringValue) }
|
802
|
+
end
|
803
|
+
|
804
|
+
# Exports the current state to a hash that can be serialized in
|
805
|
+
# the JSON format
|
806
|
+
def to_hash
|
807
|
+
result = {}
|
808
|
+
|
809
|
+
has_choice_threads = false
|
810
|
+
|
811
|
+
self.current_choices.each do |choice|
|
812
|
+
choice.original_thread_index = choice.thread_at_generation.thread_index
|
813
|
+
if callstack.thread_with_index(choice.original_thread_index).nil?
|
814
|
+
if !has_choice_threads
|
815
|
+
has_choice_threads = true
|
816
|
+
result["choiceThreads"]= {}
|
817
|
+
end
|
818
|
+
|
819
|
+
result["choiceThreads"][choice.original_thread_index.to_s] = choice.thread_at_generation.to_hash
|
820
|
+
end
|
821
|
+
end
|
822
|
+
|
823
|
+
result["callstackThreads"] = callstack.to_hash
|
824
|
+
result["variablesState"] = variables_state.to_hash
|
825
|
+
result["evalStack"] = Serializer.convert_array_of_runtime_objects(self.evaluation_stack)
|
826
|
+
result["outputStream"] = Serializer.convert_array_of_runtime_objects(self.output_stream)
|
827
|
+
result["currentChoices"] = Serializer.convert_choices(@current_choices)
|
828
|
+
|
829
|
+
if !self.diverted_pointer.null_pointer?
|
830
|
+
result["currentDivertTarget"] = self.diverted_pointer.path.components_string
|
831
|
+
end
|
832
|
+
|
833
|
+
result["visitCounts"] = self.visit_counts
|
834
|
+
result["turnIndicies"] = self.turn_indicies
|
835
|
+
|
836
|
+
result["turnIdx"] = self.current_turn_index
|
837
|
+
result["story_seed"] = self.story_seed
|
838
|
+
result["previousRandom"] = self.previous_random
|
839
|
+
|
840
|
+
result["inkSaveVersion"] = CURRENT_INK_SAVE_STATE_VERSION
|
841
|
+
|
842
|
+
result["inkFormatVersion"] = Story::CURRENT_INK_VERSION
|
843
|
+
|
844
|
+
return result
|
845
|
+
end
|
846
|
+
|
847
|
+
# Load a previously saved state from a Hash
|
848
|
+
def from_hash!(loaded_state)
|
849
|
+
if loaded_state["inkSaveVersion"].nil?
|
850
|
+
raise Error, "ink save format incorrect, can't load."
|
851
|
+
end
|
852
|
+
|
853
|
+
if loaded_state["inkSaveVersion"] < MINIMUM_COMPATIBLE_INK_LOAD_VERSION
|
854
|
+
raise Error, "Ink save format isn't compatible with the current version (saw #{loaded_state["inkSaveVersion"]}, but minimum is #{MINIMUM_COMPATIBLE_INK_LOAD_VERSION}), so can't load."
|
855
|
+
end
|
856
|
+
|
857
|
+
self.callstack.from_hash!(loaded_state["callstackThreads"], story)
|
858
|
+
self.variables_state.from_hash!(loaded_state["variablesState"])
|
859
|
+
|
860
|
+
self.evaluation_stack = Serializer.convert_to_runtime_objects(loaded_state["evalStack"])
|
861
|
+
self.output_stream = Serializer.convert_to_runtime_objects(loaded_state["outputStream"])
|
862
|
+
self.output_stream_dirty!
|
863
|
+
|
864
|
+
self.current_choices = Serializer.convert_to_runtime_objects(loaded_state["currentChoices"])
|
865
|
+
|
866
|
+
if loaded_state.has_key?("currentDivertTarget")
|
867
|
+
divert_path = Path.new(loaded_state["currentDivertTarget"])
|
868
|
+
self.diverted_pointer = story.pointer_at_path(divert_path)
|
869
|
+
end
|
870
|
+
|
871
|
+
self.visit_counts = loaded_state["visitCounts"]
|
872
|
+
self.turn_indicies = loaded_state["turnIndicies"]
|
873
|
+
|
874
|
+
self.current_turn_index = loaded_state["turnIdx"]
|
875
|
+
self.story_seed = loaded_state["storySeed"]
|
876
|
+
|
877
|
+
self.previous_random = loaded_state["previousRandom"] || 0
|
878
|
+
|
879
|
+
|
880
|
+
saved_choice_threads = loaded_state["choiceThreads"] || {}
|
881
|
+
|
882
|
+
@current_choices.each do |choice|
|
883
|
+
found_active_thread = callstack.thread_with_index(choice.original_thread_index)
|
884
|
+
if !found_active_thread.nil?
|
885
|
+
choice.thread_at_generation = found_active_thread.copy
|
886
|
+
else
|
887
|
+
saved_choice_thread = saved_choice_threads[choice.original_thread_index.to_s]
|
888
|
+
choice.thread_at_generation = CallStack::Thread.new(saved_choice_thread, story)
|
889
|
+
end
|
890
|
+
end
|
891
|
+
end
|
892
|
+
|
893
|
+
def assert!(condition, message=nil)
|
894
|
+
story.assert!(condition, message)
|
895
|
+
end
|
896
|
+
|
897
|
+
# Don't make public since the method needs to be wrapped in a story for visit countind
|
898
|
+
def set_chosen_path(path, incrementing_turn_index)
|
899
|
+
# Changing direction, assume we need to clear current set of choices
|
900
|
+
@current_choices.clear
|
901
|
+
|
902
|
+
new_pointer = story.pointer_at_path(path)
|
903
|
+
|
904
|
+
if !new_pointer.null_pointer? && new_pointer.index == -1
|
905
|
+
new_pointer.index = 0
|
906
|
+
end
|
907
|
+
|
908
|
+
self.current_pointer = new_pointer
|
909
|
+
|
910
|
+
if incrementing_turn_index
|
911
|
+
self.current_turn_index += 1
|
912
|
+
end
|
913
|
+
end
|
914
|
+
end
|
915
|
+
end
|