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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +30 -0
  3. data/.gitignore +57 -0
  4. data/.ruby-version +1 -0
  5. data/CODE_OF_CONDUCT.md +74 -0
  6. data/Gemfile +7 -0
  7. data/Gemfile.lock +30 -0
  8. data/LICENSE +21 -0
  9. data/README.md +2 -0
  10. data/Rakefile +10 -0
  11. data/bin/console +14 -0
  12. data/bin/setup +8 -0
  13. data/bin/test +8 -0
  14. data/fable.gemspec +34 -0
  15. data/fable.sublime-project +8 -0
  16. data/lib/fable.rb +49 -0
  17. data/lib/fable/call_stack.rb +351 -0
  18. data/lib/fable/choice.rb +31 -0
  19. data/lib/fable/choice_point.rb +65 -0
  20. data/lib/fable/container.rb +218 -0
  21. data/lib/fable/control_command.rb +156 -0
  22. data/lib/fable/debug_metadata.rb +13 -0
  23. data/lib/fable/divert.rb +100 -0
  24. data/lib/fable/glue.rb +7 -0
  25. data/lib/fable/ink_list.rb +425 -0
  26. data/lib/fable/list_definition.rb +44 -0
  27. data/lib/fable/list_definitions_origin.rb +35 -0
  28. data/lib/fable/native_function_call.rb +324 -0
  29. data/lib/fable/native_function_operations.rb +149 -0
  30. data/lib/fable/observer.rb +205 -0
  31. data/lib/fable/path.rb +186 -0
  32. data/lib/fable/pointer.rb +42 -0
  33. data/lib/fable/profiler.rb +287 -0
  34. data/lib/fable/push_pop_type.rb +11 -0
  35. data/lib/fable/runtime_object.rb +159 -0
  36. data/lib/fable/search_result.rb +20 -0
  37. data/lib/fable/serializer.rb +560 -0
  38. data/lib/fable/state_patch.rb +47 -0
  39. data/lib/fable/story.rb +1447 -0
  40. data/lib/fable/story_state.rb +915 -0
  41. data/lib/fable/tag.rb +14 -0
  42. data/lib/fable/value.rb +334 -0
  43. data/lib/fable/variable_assignment.rb +20 -0
  44. data/lib/fable/variable_reference.rb +38 -0
  45. data/lib/fable/variables_state.rb +327 -0
  46. data/lib/fable/version.rb +3 -0
  47. data/lib/fable/void.rb +4 -0
  48. data/zork_mode.rb +23 -0
  49. 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