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,47 @@
1
+ module Fable
2
+ class StatePatch
3
+ attr_accessor :globals, :changed_variables, :visit_counts, :turn_indicies
4
+
5
+ def initialize(state_patch_to_copy = nil)
6
+ if state_patch_to_copy.nil?
7
+ self.globals = {}
8
+ self.changed_variables = Set.new
9
+ self.visit_counts = {}
10
+ self.turn_indicies = {}
11
+ else
12
+ self.globals = Hash[state_patch_to_copy.globals]
13
+ self.changed_variables = state_patch_to_copy.changed_variables.dup
14
+ self.visit_counts = Hash[state_patch_to_copy.visit_counts]
15
+ self.turn_indicies = Hash[state_patch_to_copy.turn_indicies]
16
+ end
17
+ end
18
+
19
+ def get_global(name)
20
+ return self.globals[name]
21
+ end
22
+
23
+ def set_global(name, value)
24
+ self.globals[name] = value
25
+ end
26
+
27
+ def add_changed_variable(name)
28
+ self.changed_variables << name
29
+ end
30
+
31
+ def get_visit_count(container)
32
+ self.visit_counts[container]
33
+ end
34
+
35
+ def set_visit_count(container, count)
36
+ self.visit_counts[container] = count
37
+ end
38
+
39
+ def set_turn_index(container, count)
40
+ self.turn_indicies[container] = count
41
+ end
42
+
43
+ def get_turn_index(container)
44
+ self.turn_indicies[container]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,1447 @@
1
+ module Fable
2
+ class Story < RuntimeObject
3
+ class CannotContinueError < Error ; end
4
+
5
+
6
+ CURRENT_INK_VERSION = 19
7
+ MINIMUM_COMPATIBLE_INK_VERSION = 18
8
+
9
+ attr_accessor :original_object, :state, :profiler,
10
+ :list_definitions, :main_content_container,
11
+ :allow_external_function_fallbacks,
12
+ :on_make_choice, :external_functions,
13
+ :on_choose_path_string,
14
+ :on_evaluate_function, :on_complete_evaluate_function
15
+
16
+
17
+ alias_method :allow_external_function_fallbacks?, :allow_external_function_fallbacks
18
+
19
+ def initialize(original_object)
20
+ super()
21
+ self.external_functions = {}
22
+ self.original_object = original_object
23
+ self.state = StoryState.new(self)
24
+
25
+ correct_ink_version?
26
+
27
+ if original_object["root"].nil?
28
+ raise ArgumentError, "no root object in ink"
29
+ end
30
+
31
+ @state_snapshot_at_last_newline = nil
32
+ @recursive_content_count = 0
33
+
34
+ if !original_object["listDefs"].empty?
35
+ self.list_definitions = Serializer.convert_to_list_definitions(original_object["listDefs"])
36
+ else
37
+ self.list_definitions = Serializer.convert_to_list_definitions({})
38
+ end
39
+ self.main_content_container = Serializer.convert_to_runtime_object(original_object["root"])
40
+
41
+ reset_state!
42
+ end
43
+
44
+ def continue(&block)
45
+ validate_external_bindings!
46
+ internal_continue(&block)
47
+ return current_text
48
+ end
49
+
50
+ def continue_maximially(&block)
51
+ result = StringIO.new
52
+ while can_continue?
53
+ result << continue
54
+ end
55
+
56
+ result.rewind
57
+ return result.read
58
+ end
59
+
60
+ def current_choices
61
+ return self.state.current_choices.select{|choice| !choice.invisible_default? }
62
+ end
63
+
64
+ def current_text
65
+ return self.state.current_text
66
+ end
67
+
68
+ def current_tags
69
+ return self.state.current_tags
70
+ end
71
+
72
+ def current_errors
73
+ return self.state.current_errors
74
+ end
75
+
76
+ def current_warnings
77
+ return self.state.current_warnings
78
+ end
79
+
80
+ def variables_state
81
+ return self.state.variables_state
82
+ end
83
+
84
+ def has_errors?
85
+ return false if current_errors.nil?
86
+ current_errors.any?
87
+ end
88
+
89
+ def has_warnings?
90
+ return false if current_warnings.nil?
91
+ current_warnings.any?
92
+ end
93
+
94
+ def can_continue?
95
+ state.can_continue?
96
+ end
97
+
98
+ def ink_version
99
+ original_object["inkVersion"]
100
+ end
101
+
102
+ def start_profiling
103
+ self.profiler = Profiler.new
104
+ end
105
+
106
+ def stop_profiling
107
+ self.profiler = nil
108
+ end
109
+
110
+ def profile?
111
+ !self.profiler.nil?
112
+ end
113
+
114
+ def global_declaration
115
+ self.main_content_container.named_content["global decl"]
116
+ end
117
+
118
+ def reset_state!
119
+ self.state = StoryState.new(self)
120
+ reset_globals!
121
+ end
122
+
123
+ def reset_errors!
124
+ state.reset_errors!
125
+ end
126
+
127
+ def reset_callstack!
128
+ state.force_end!
129
+ end
130
+
131
+ def reset_globals!
132
+ if !global_declaration.nil?
133
+ original_pointer = state.current_pointer
134
+ choose_path(Path.new("global decl"), {incrementing_turn_index: false})
135
+
136
+ internal_continue
137
+ state.current_pointer = original_pointer
138
+ end
139
+
140
+ self.state.variables_state.snapshot_default_globals
141
+ end
142
+
143
+ def content_at_path(path)
144
+ main_content_container.content_at_path(path)
145
+ end
146
+
147
+ def knot_container_with_name(name)
148
+ main_content_container.named_content[name]
149
+ end
150
+
151
+ def pointer_at_path(path)
152
+ return Pointer.null_pointer if path.empty?
153
+
154
+ path_length_to_use = path.length
155
+ if path.components.last.is_index?
156
+ path_length_to_use = path.length - 1
157
+ result = main_content_container.content_at_path(path, partial_path_start: 0, partial_path_length: path_length_to_use)
158
+ new_pointer_container = result.container
159
+ new_pointer_index = path.components.last.index
160
+ new_pointer = Pointer.new(new_pointer_container, new_pointer_index)
161
+ else
162
+ result = main_content_container.content_at_path(path)
163
+ new_pointer = Pointer.new(result.container, -1)
164
+ end
165
+
166
+ if result.object.nil? || (result.object == main_content_container && path_length_to_use > 0)
167
+ raise StoryError, "Failed to find content at path '#{path.components_string}', and no approximation was possible."
168
+ elsif result.approximate?
169
+ warning("Failed to find content at path '#{path.components_string}', so it was approximated to '#{result.object.path.components_string}'")
170
+ end
171
+
172
+ return new_pointer
173
+ end
174
+
175
+ # Maximum Snapshot stack:
176
+ # - @state_snapshot_during_save -- not retained, but returned to game code
177
+ # - @state_snapshot_at_last_newline (has older patch)
178
+ # - @state (current, being patched)
179
+ def state_snapshot!
180
+ @state_snapshot_at_last_newline = self.state
181
+ self.state = state.copy_and_start_patching!
182
+ end
183
+
184
+ def restore_state_snapshot!
185
+ # Patched state had temporarily hijacked our variables_state and
186
+ # set its own callstack on it, so we need to restore that
187
+ # If we're in the middle of saving, we may also need to give the
188
+ # variables_state the old patch
189
+
190
+ @state_snapshot_at_last_newline.restore_after_patch!
191
+ self.state = @state_snapshot_at_last_newline
192
+ @state_snapshot_at_last_newline = nil
193
+ self.state.apply_any_patch!
194
+ end
195
+
196
+ def discard_snapshot!
197
+ self.state.apply_any_patch!
198
+ @state_snapshot_at_last_newline = nil
199
+ end
200
+
201
+ def internal_continue(&block)
202
+ profiler.pre_continue! if profile?
203
+
204
+ @recursive_content_count += 1
205
+
206
+ if !can_continue?
207
+ raise CannotContinueError, "make sure to check can_continue?"
208
+ end
209
+
210
+ state.did_safe_exit = false
211
+ state.reset_output!
212
+
213
+ # It's possible for ink to call game to call ink to call game etc
214
+ # In this case, we only want to batch observe variable changes
215
+ # for the outermost call.
216
+ if @recursive_content_count == 1
217
+ state.variables_state.batch_observing_variable_changes = true
218
+ end
219
+
220
+ output_stream_ends_in_newline = false
221
+
222
+ while can_continue?
223
+ begin
224
+ output_stream_ends_in_newline = continue_single_step!
225
+ rescue StoryError => e
226
+ add_error!(e.message, {use_end_line_number: e.use_end_line_number?})
227
+ break
228
+ end
229
+
230
+ break if output_stream_ends_in_newline
231
+ end
232
+
233
+
234
+ # 3 outcomes:
235
+ # - got a newline (finished this line of text)
236
+ # - can't continue (e.g: choices, or end of story)
237
+ # - error
238
+
239
+ if output_stream_ends_in_newline || !can_continue?
240
+ # Do we need to rewind, because we evaluated further than we should?
241
+ if !@state_snapshot_at_last_newline.nil?
242
+ restore_state_snapshot!
243
+ end
244
+
245
+ # Finished this section of content, or reached a choice point
246
+ if !can_continue?
247
+ if state.callstack.can_pop_thread?
248
+ add_error!("Thread available to pop, threads should always be flat by the end of evaluation?")
249
+ end
250
+
251
+ if state.generated_choices.empty? && !state.did_safe_exit? && @temporary_evaluation_container.nil?
252
+ if state.callstack.can_pop?(:tunnel)
253
+ add_error!("unexpectedly reached end of content. Do you need a '->->' to return from a tunnel?")
254
+ elsif state.callstack.can_pop?(:function)
255
+ add_error!("unexpectedly reached end of content. Do you need a '~ return'?")
256
+ elsif state.callstack.can_pop?
257
+ add_error!("ran out of content. Do you need a '-> DONE' or '-> END'?")
258
+ else
259
+ add_error!("unexpectedly reached end of content for unknown reason.")
260
+ end
261
+ end
262
+
263
+ state.did_safe_exit = false
264
+
265
+ if @recursive_content_count == 1
266
+ state.variables_state.batch_observing_variable_changes = false
267
+ end
268
+ end
269
+ end
270
+
271
+ @recursive_content_count -= 1
272
+
273
+ profiler.post_continue! if profile?
274
+ end
275
+
276
+ def continue_single_step!
277
+ profiler.pre_step! if profile?
278
+
279
+ step!
280
+
281
+ profiler.post_step! if profile?
282
+
283
+ if !can_continue? && !state.callstack.element_is_evaluate_from_game?
284
+ try_following_default_invisible_choice
285
+ end
286
+
287
+ profiler.pre_snapshot! if profile?
288
+
289
+ # Don't save/rewind during string evaluation, which is a special state
290
+ # used for choices
291
+ if !state.in_string_evaluation?
292
+ # we previously found a newline, but we're double-checking that it won't
293
+ # be removed` by glue
294
+ if !@state_snapshot_at_last_newline.nil?
295
+ change = calculate_newline_output_state_change(
296
+ @state_snapshot_at_last_newline.current_text, state.current_text,
297
+ @state_snapshot_at_last_newline.current_tags.size, state.current_tags.size
298
+ )
299
+
300
+ # The last time we saw a newline, it was definitely the end of the
301
+ # line, so we want to rewind to that point
302
+ if change == :extended_beyond_newline
303
+ restore_state_snapshot!
304
+
305
+ # Hit a newline for sure, we're done
306
+ return true
307
+ end
308
+
309
+ # Newline that previously existed is no longer value (eg: encountered glue)
310
+ if change == :newline_removed
311
+ discard_snapshot!
312
+ end
313
+ end
314
+
315
+ # Current content ends in a newline - approaching end of our evaluation
316
+
317
+ if state.output_stream_ends_in_newline?
318
+ # If we can continue evaluation for a bit:
319
+ # - create a snapshot in case we need to rewind
320
+ # We're going to keep stepping in case we see glue or some
321
+ # non-text content such as choices
322
+
323
+ if can_continue?
324
+ # Don't bother to record the state beyond the current newline
325
+ # example:
326
+ # e.g.:
327
+ # Hello world\n // record state at the end of here
328
+ # ~ complexCalculation() // don't actually need this unless it generates text
329
+
330
+ if @state_snapshot_at_last_newline.nil?
331
+ state_snapshot!
332
+ end
333
+ else
334
+ # we're about to exit since we can't continue, make sure we don't
335
+ # have an old state lying around
336
+ discard_snapshot!
337
+ end
338
+ end
339
+ end
340
+
341
+ profiler.post_snapshot! if profile?
342
+
343
+ return false
344
+ end
345
+
346
+ def step!
347
+ should_add_to_stream = true
348
+
349
+ # Get current content
350
+ pointer = state.current_pointer
351
+ return if pointer.null_pointer?
352
+
353
+
354
+ # Step directly into the first element of content in a container (if necessary)
355
+ container_to_enter = pointer.resolve!
356
+ while container_to_enter.is_a?(Container)
357
+ # Mark container as being entered
358
+ visit_container!(container_to_enter, at_start: true)
359
+
360
+ # no content? the most we can do is step past it
361
+ break if container_to_enter.content.empty?
362
+
363
+ pointer = Pointer.start_of(container_to_enter)
364
+ container_to_enter = pointer.resolve!
365
+ end
366
+
367
+ state.current_pointer = pointer
368
+
369
+ profiler.step!(state.callstack) if profile?
370
+ # is the current content object:
371
+ # - normal content
372
+ # - or a logic/flow statement? If so, do it
373
+ # Stop flow if we hit a stack pop when we're unable to pop
374
+ # (e.g: return/done statement in knot that was diverted to
375
+ # rather than called as a function)
376
+ current_content_object = pointer.resolve!
377
+ is_logic_or_flow_content = perform_logic_and_flow_control(current_content_object)
378
+
379
+ # Has flow been forced to end by flow control above?
380
+ if state.current_pointer.null_pointer?
381
+ return
382
+ end
383
+
384
+ if is_logic_or_flow_content
385
+ should_add_to_stream = false
386
+ end
387
+
388
+ # Is choice with condition?
389
+ if current_content_object.is_a?(ChoicePoint)
390
+ choice = process_choice(current_content_object)
391
+ if !choice.nil?
392
+ state.generated_choices << choice
393
+ end
394
+
395
+ current_content_object = nil
396
+ should_add_to_stream = false
397
+ end
398
+
399
+ # If the container has no content, then it will be the "content"
400
+ # itself, but we skip over it
401
+ if current_content_object.is_a?(Container)
402
+ should_add_to_stream = false
403
+ end
404
+
405
+ # content to add to the evaluation stack or output stream
406
+ if should_add_to_stream
407
+ # If we're pushing a variable pointer onto the evaluation stack,
408
+ # ensure that it's specific to our current (and possibly temporary)
409
+ # context index. And make a copy of the pointer so that we're not
410
+ # editing the original runtime object
411
+ if current_content_object.is_a?(VariablePointerValue)
412
+ variable_pointer = current_content_object
413
+ if variable_pointer.context_index == -1
414
+ # create a new object so we're not overwriting the story's own data
415
+ context_index = state.callstack.context_for_variable_named(variable_pointer.variable_name)
416
+ current_content_object = VariablePointerValue.new(variable_pointer.variable_name, context_index)
417
+ end
418
+ end
419
+
420
+ # expression evaluation content
421
+ if state.in_expression_evaluation?
422
+ state.push_evaluation_stack(current_content_object)
423
+ else
424
+ # output stream content
425
+ state.push_to_output_stream(current_content_object)
426
+ end
427
+ end
428
+
429
+ # Increment the content pointer, following diverts if necessary
430
+ next_content!
431
+
432
+ # Starting a thread should be done after the increment to the
433
+ # content pointer, so that when returning from the thread, it
434
+ # returns to the content after this instruction
435
+ if ControlCommand.is_instance_of?(current_content_object, :START_THREAD)
436
+ state.callstack.push_thread!
437
+ end
438
+ end
439
+
440
+ def visit_container!(container, options)
441
+ at_start = options[:at_start]
442
+
443
+ if !container.counting_at_start_only? || at_start
444
+ if container.visits_should_be_counted?
445
+ state.increment_visit_count_for_container!(container)
446
+ end
447
+
448
+ if container.turn_index_should_be_counted?
449
+ state.record_turn_index_visit_to_container!(container)
450
+ end
451
+ end
452
+ end
453
+
454
+ def visit_changed_containers_due_to_divert
455
+ previous_pointer = state.previous_pointer
456
+ pointer = state.current_pointer
457
+
458
+ # Unless we're pointing directly at a piece of content, we don't do
459
+ # counting here. Otherwise, the main stepping function will do the
460
+ # counting
461
+
462
+ return if pointer.null_pointer? || pointer.index == -1
463
+
464
+ # First, find the previously open set of containers
465
+ @previous_containers = []
466
+ if !previous_pointer.null_pointer?
467
+ previous_ancestor = previous_pointer.resolve! || previous_pointer.container
468
+ while !previous_ancestor.nil?
469
+ @previous_containers << previous_ancestor
470
+ previous_ancestor = previous_ancestor.parent
471
+ end
472
+ end
473
+
474
+ # If the new object is a container itself, it will be visted
475
+ # automatically at the next actual content step. However, we need to walk
476
+ # up the new ancestry to see if there are more new containers
477
+ current_child_of_container = pointer.resolve!
478
+
479
+ return if current_child_of_container.nil?
480
+
481
+ current_container_ancestor = current_child_of_container.parent
482
+
483
+ all_children_entered_at_start = true
484
+ while !current_container_ancestor.nil? && (!@previous_containers.include?(current_container_ancestor) || current_container_ancestor.counting_at_start_only?)
485
+ # check whether this ancestor container is being entered at the start
486
+ # by checking whether the child object is the first
487
+
488
+ entering_at_start = (
489
+ current_container_ancestor.content.size > 0 &&
490
+ current_child_of_container == current_container_ancestor.content[0] &&
491
+ all_children_entered_at_start
492
+ )
493
+
494
+ # Don't count it as entering at start if we're entering randomly
495
+ # somewhere within a Container B that happens to be nexted at index 0
496
+ # of Container A. It only counts if we're diverting directly to the
497
+ # first leaf node
498
+
499
+ all_children_entered_at_start = false if !entering_at_start
500
+
501
+ # Mark a visit to this container
502
+ visit_container!(current_container_ancestor, at_start: entering_at_start)
503
+
504
+ current_child_of_container = current_container_ancestor
505
+ current_container_ancestor = current_container_ancestor.parent
506
+ end
507
+ end
508
+
509
+ def process_choice(choice_point)
510
+ show_choice = true
511
+
512
+ # don't create choice if it doesn't pass the conditional
513
+ if choice_point.has_condition?
514
+ condition_value = state.pop_evaluation_stack
515
+ if !condition_value.truthy?
516
+ show_choice = false
517
+ end
518
+ end
519
+
520
+ start_text = ""
521
+ choice_only_text = ""
522
+
523
+ if choice_point.has_choice_only_content?
524
+ choice_only_text = state.pop_evaluation_stack
525
+ end
526
+
527
+ if choice_point.has_start_content?
528
+ start_text = state.pop_evaluation_stack
529
+ end
530
+
531
+ # Don't create the choice if the player has aready read this content
532
+ if choice_point.once_only?
533
+ if state.visit_count_for_container(choice_point.choice_target).value > 0
534
+ show_choice = false
535
+ end
536
+ end
537
+
538
+
539
+ # We go through the whole process of creating the choice above so
540
+ # that we consume the content for it, since otherwise it'll be
541
+ # shown on the output stream
542
+ return nil if !show_choice
543
+
544
+ choice = Choice.new
545
+ choice.target_path = choice_point.path_on_choice
546
+ choice.source_path = choice_point.path.to_s
547
+ choice.invisible_default = choice_point.invisible_default?
548
+
549
+ # We need to capture the state of the callstack at the point where
550
+ # the choice was generated, since after the generation of this choice
551
+ # we may go on to pop out from a tunnel (possible if the choice was
552
+ # wrapped in a conditional), or we may pop out from a thread, at which
553
+ # point that thread is discarded. Fork clones the thread, gives it a new
554
+ # ID, but without affecting the thread stack itself
555
+ choice.thread_at_generation = state.callstack.fork_thread!
556
+
557
+ # set the final text for the choice
558
+ choice.text = "#{start_text}#{choice_only_text}".strip
559
+
560
+ return choice
561
+ end
562
+
563
+ def perform_logic_and_flow_control(element)
564
+ return false if element.nil?
565
+
566
+ # Divert
567
+ if element.is_a?(Divert)
568
+ if element.is_conditional?
569
+ return true if !state.pop_evaluation_stack.truthy?
570
+ end
571
+
572
+ if element.has_variable_target?
573
+ variable_name = element.variable_divert_name
574
+ variable_value = state.variables_state.get_variable_with_name(variable_name)
575
+
576
+ if variable_value.nil?
577
+ add_error!("Tried to divert using a target from a variable that could not be found (#{variable_name})")
578
+ elsif !variable_value.is_a?(DivertTargetValue)
579
+ error_message = "Tried to divert to a target from a variable, but the variable (#{variable_name}) didn't contain a divert target, it "
580
+ if variable_value.to_i == 0
581
+ error_message += "was empty/null (the value 0)"
582
+ else
583
+ error_message == "was #{variable_value}"
584
+ end
585
+
586
+ add_error!(error_message)
587
+ end
588
+
589
+ state.diverted_pointer = pointer_at_path(variable_value.target_path)
590
+ elsif element.is_external?
591
+ call_external_function(element.target_path.to_s, element.external_arguments)
592
+ return true
593
+ else
594
+ state.diverted_pointer = pointer_at_path(element.target_path)
595
+ end
596
+
597
+ if element.pushes_to_stack?
598
+ state.callstack.push(
599
+ element.stack_push_type,
600
+ output_stream_length_when_pushed: state.output_stream.count
601
+ )
602
+ end
603
+
604
+ if state.diverted_pointer.nil? && !element.is_external?
605
+ if element && element.debug_metadata.source_name
606
+ add_error!("Divert target doesn't exist: #{element.debug_metadata.source_name}")
607
+ else
608
+ add_error!("Divert resolution failed: #{element}")
609
+ end
610
+ end
611
+
612
+ return true
613
+ end
614
+
615
+ if element.is_a?(ControlCommand)
616
+ case element.command_type
617
+ when :EVALUATION_START
618
+ assert!(!state.in_expression_evaluation?, "Already in expression evaluation?")
619
+ state.in_expression_evaluation = true
620
+ when :EVALUATION_END
621
+ assert!(state.in_expression_evaluation?, "Not in expression evaluation mode")
622
+ state.in_expression_evaluation = false
623
+ when :EVALUATION_OUTPUT
624
+ # if the expression turned out to be empty, there may not be
625
+ # anything on the stack
626
+ if state.evaluation_stack.size > 0
627
+ output = state.pop_evaluation_stack
628
+ if !output.is_a?(Void)
629
+ state.push_to_output_stream(StringValue.new(output.to_s))
630
+ end
631
+ end
632
+ when :NOOP
633
+ :NOOP
634
+ when :DUPLICATE_TOPMOST
635
+ state.push_evaluation_stack(state.peek_evaluation_stack)
636
+ when :POP_EVALUATED_VALUE
637
+ state.pop_evaluation_stack
638
+ when :POP_TUNNEL, :POP_FUNCTION
639
+ # Tunnel onwards is allowed to specify an optional override divert
640
+ # to go to immediately after returning: ->-> target
641
+ override_tunnel_return_target = nil
642
+
643
+ if element.command_type == :POP_TUNNEL
644
+ pop_type = PushPopType::TYPES[:tunnel]
645
+ elsif element.command_type == :POP_FUNCTION
646
+ pop_type = PushPopType::TYPES[:function]
647
+ end
648
+
649
+ if pop_type == PushPopType::TYPES[:tunnel]
650
+ override_tunnel_return_target = state.pop_evaluation_stack
651
+ if !override_tunnel_return_target.is_a?(DivertTargetValue)
652
+ assert!(override_tunnel_return_target.is_a?(Void), "Expected void if ->-> doesn't override target")
653
+ end
654
+ end
655
+
656
+ if state.exit_function_evaluation_from_game?
657
+ :NOOP
658
+ elsif state.callstack.current_element.type != pop_type || !state.callstack.can_pop?
659
+ types = {
660
+ PushPopType::TYPES[:function] => "function return statement (~return)",
661
+ PushPopType::TYPES[:tunnel] => "tunnel onwards statement (->->)"
662
+ }
663
+
664
+ expected = types[state.callstack.current_element.type]
665
+
666
+ if !state.callstack.can_pop?
667
+ expected = "end of flow (-> END or choice)"
668
+ end
669
+
670
+ add_error!("Found #{types[state.callstack.current_element.type]}, when expected #{expected}")
671
+ else
672
+ state.pop_callstack
673
+
674
+ # does tunnel onwards override by diverting to a new ->-> target?
675
+ if override_tunnel_return_target.is_a?(DivertTargetValue)
676
+ state.diverted_pointer = pointer_at_path(override_tunnel_return_target.target_path)
677
+ end
678
+ end
679
+ when :BEGIN_STRING_EVALUATION_MODE
680
+ state.push_to_output_stream(element)
681
+ assert!(state.in_expression_evaluation?, "Expected to be in an expression when evaluating a string")
682
+ state.in_expression_evaluation = false
683
+ when :END_STRING_EVALUATION_MODE
684
+ content_stack_for_string = []
685
+ item_from_output_stream = nil
686
+ while !ControlCommand.is_instance_of?(item_from_output_stream, :BEGIN_STRING_EVALUATION_MODE)
687
+ item_from_output_stream = state.pop_from_output_stream
688
+ if item_from_output_stream.is_a?(StringValue)
689
+ content_stack_for_string << item_from_output_stream
690
+ end
691
+ end
692
+
693
+ #return to expression evaluation (from content mode)
694
+ state.in_expression_evaluation = true
695
+ state.push_evaluation_stack(StringValue.new(content_stack_for_string.reverse.join.to_s))
696
+ when :PUSH_CHOICE_COUNT
697
+ state.push_evaluation_stack(IntValue.new(state.generated_choices.size))
698
+ when :TURNS
699
+ state.push_evaluation_stack(IntValue.new(state.current_turn_index + 1))
700
+ when :TURNS_SINCE, :READ_COUNT
701
+ target = state.pop_evaluation_stack
702
+ if !target.is_a?(DivertTargetValue)
703
+ extra_note =""
704
+ if value.is_a?(Numeric)
705
+ extra_note = ". Did you accidentally pass a read count ('knot_name') instead of a target ('-> knot_name')?"
706
+ end
707
+ add_error("TURNS SINCE expected a divert target (knot, stitch, label name), but saw #{target}#{extra_note}")
708
+ end
709
+
710
+ container = content_at_path(target.target_path).container
711
+
712
+ if !container.nil?
713
+ if element.command_type == :TURNS_SINCE
714
+ count = state.turns_since_for_container(container)
715
+ else
716
+ count = state.visit_count_for_container(container)
717
+ end
718
+ else
719
+ if element.command_type == :TURNS_SINCE
720
+ count = -1 #turn count, default to never/unknown
721
+ else
722
+ count = 0 #visit count, assume 0 to default to allowing entry
723
+ end
724
+
725
+ warning("Failed to find container for #{element} lookup at #{target.target}")
726
+ end
727
+
728
+ state.push_evaluation_stack(IntValue.new(count))
729
+ when :RANDOM
730
+ max_int = state.pop_evaluation_stack
731
+ min_int = state.pop_evaluation_stack
732
+
733
+ if !min_int.is_a?(Numeric)
734
+ add_error!("Invalid value for minimum parameter of RANDOM(min, max)")
735
+ end
736
+
737
+ if !max_int.is_a?(Numeric)
738
+ add_error!("Invalid value for maximum parameter of RANDOM(min, max)")
739
+ end
740
+
741
+ if min_int > max_int
742
+ add_error!("RANDOM was called with minimum as #{min_int} and maximum as #{max_int}. The maximum must be larger")
743
+ end
744
+
745
+ result_seed = state.story_seed + state.previous_random
746
+ random = new Random(result_seed)
747
+
748
+ next_random = random.rand(min_int, max_int)
749
+ state.push_evaluation_stack(IntValue.new(next_random))
750
+ # next random number, rather than keeping the random object around
751
+ state.previous_random = next_random
752
+ when :SEED_RANDOM
753
+ seed = state.pop_evaluation_stack
754
+ if seed.nil?
755
+ error!("Invalid value passed to SEED_RANDOM")
756
+ end
757
+
758
+ # Story seed affects both RANDOM & shuffle behavior
759
+ state.story_seed = seed
760
+ state.previous_random = 0
761
+
762
+ # SEED_RANDOM returns nothing
763
+ state.push_evaluation_stack(Void.new)
764
+ when :VISIT_INDEX
765
+ count = state.visit_count_for_container(state.current_pointer.container).value - 1
766
+ state.push_evaluation_stack(IntValue.new(count))
767
+ when :SEQUENCE_SHUFFLE_INDEX
768
+ state.push_evaluation_stack(IntValue.new(next_sequence_shuffle_index))
769
+ when :START_THREAD
770
+ :NOOP #handled in main step function
771
+ when :DONE
772
+ # we may exist in the context of the initial act of creating
773
+ # the thread, or in the context of evaluating the content
774
+ if state.callstack.can_pop_thread?
775
+ state.callstack.pop_thread!
776
+ else
777
+ # in normal flow, allow safe exit without warning
778
+ state.did_safe_exit = true
779
+ # stop flow in the current thread
780
+ state.current_pointer = Pointer.null_pointer
781
+ end
782
+ when :STORY_END
783
+ state.force_end!
784
+ when :LIST_FROM_INT
785
+ integer_value = state.pop_evaluation_stack
786
+ list_name = state.pop_evaluation_stack
787
+
788
+ if integer_value.nil?
789
+ raise StoryError, "Passed non-integer when creating a list element from a numerical value."
790
+ end
791
+
792
+ if list_definitions.find_list(list_name.value)
793
+ state.push_evaluation_stack(list_definitions.find_list(list_name.value).item_for_value(integer_value.value))
794
+ else
795
+ raise StoryError, "Failed to find LIST called #{list_name}"
796
+ end
797
+ when :LIST_RANGE
798
+ max = state.pop_evaluation_stack.value
799
+ min = state.pop_evaluation_stack.value
800
+ target_list = state.pop_evaluation_stack.value
801
+
802
+ if target_list.nil? || min.nil? || max.nil?
803
+ raise StoryError, "Expected list, minimum, and maximum for LIST_RANGE"
804
+ end
805
+
806
+ state.push_evaluation_stack(ListValue.new(target_list.list_with_subrange(min, max)))
807
+ when :LIST_RANDOM
808
+ list_value = state.pop_evaluation_stack
809
+ list = list_value.value
810
+
811
+ if list.nil?
812
+ raise StoryError, "Expected list for LIST_RANDOM"
813
+ end
814
+
815
+ # list was empty, return empty list
816
+ if list.count == 0
817
+ new_list = InkList.new
818
+ else
819
+ #non-empty source list
820
+ result_seed = state.story_seed.value + state.previous_random
821
+ random = Random.new(result_seed)
822
+ list_item_index = random.rand(list.count)
823
+
824
+ random_item_pair = list.list.to_a[list_item_index]
825
+
826
+ # Origin list is simply the origin of the one element
827
+ new_list = InkList.new_for_origin_definition_and_story(random_item_pair[0].origin_name, self)
828
+ new_list.list[random_item_pair[0]] = random_item_pair[1]
829
+
830
+ state.previous_random = list_item_index
831
+ end
832
+
833
+ state.push_evaluation_stack(ListValue.new(new_list))
834
+ else
835
+ add_error!("unhandled Control Command #{element}")
836
+ end
837
+
838
+ return true
839
+ end
840
+
841
+ # variable handling
842
+ case element
843
+ when VariableAssignment
844
+ state.variables_state.assign(element, state.pop_evaluation_stack)
845
+ return true
846
+ when VariableReference
847
+ if !element.path_for_count.nil?
848
+ count = state.visit_count_for_container(element.container_for_count)
849
+ found_value = count
850
+ else
851
+ found_value = state.variables_state.get_variable_with_name(element.name)
852
+
853
+ if found_value.nil?
854
+ warning("Variable not found: '#{element.name}'. Using default value of 0 (false). This can happen with temporary variables if the declaration hasn't yet been hit. Globals are always given a default value on load if a value doesn't exist in the save state.");
855
+ found_value = 0
856
+ end
857
+ end
858
+
859
+ state.push_evaluation_stack(found_value)
860
+ return true
861
+ end
862
+
863
+ if element.is_a?(NativeFunctionCall)
864
+ parameters = []
865
+ element.number_of_parameters.times{ parameters << state.pop_evaluation_stack }
866
+
867
+ state.push_evaluation_stack(element.call!(parameters))
868
+ return true
869
+ end
870
+
871
+ # no control content, so much be ordinary content
872
+ return false
873
+ end
874
+
875
+ # Change the current position of the story to the given path. From here you can
876
+ # call Continue() to evaluate the next line.
877
+ #
878
+ # The path string is a dot-separated path as used internally by the engine.
879
+ # These examples should work:
880
+ #
881
+ # myKnot
882
+ # myKnot.myStitch
883
+ #
884
+ # Note however that this won't necessarily work:
885
+ #
886
+ # myKnot.myStitch.myLabelledChoice
887
+ #
888
+ # ...because of the way that content is nested within a weave structure.
889
+ #
890
+ # By default this will reset the callstack beforehand, which means that any
891
+ # tunnels, threads or functions you were in at the time of calling will be
892
+ # discarded. This is different from the behaviour of ChooseChoiceIndex, which
893
+ # will always keep the callstack, since the choices are known to come from the
894
+ # correct state, and known their source thread.
895
+ #
896
+ # You have the option of passing false to the resetCallstack parameter if you
897
+ # don't want this behaviour, and will leave any active threads, tunnels or
898
+ # function calls in-tact.
899
+ #
900
+ # This is potentially dangerous! If you're in the middle of a tunnel,
901
+ # it'll redirect only the inner-most tunnel, meaning that when you tunnel-return
902
+ # using '->->', it'll return to where you were before. This may be what you
903
+ # want though. However, if you're in the middle of a function, ChoosePathString
904
+ # will throw an exception.
905
+ def choose_path_string(path_string, reset_callstack=true, arguments = [])
906
+ if !on_choose_path_string.nil?
907
+ on_choose_path_string.call(path_string, arguments)
908
+ end
909
+
910
+ if reset_callstack
911
+ reset_callstack!
912
+ else
913
+ # choose_path_string is potentially dangerous since you can call it
914
+ # when the stack is pretty much in any state. Let's catch one of the
915
+ # worst offenders
916
+ if state.callstack.current_element == :POP_FUNCTION
917
+ container = state.callstack.current_element.current_pointer.container
918
+ function_detail = ""
919
+ if !container.nil?
920
+ function_detail = "(#{container.path.to_s})"
921
+ end
922
+
923
+ raise StoryError("Story was running a function #{function_detail} when you called choose_path_string(#{path_string}) - this is almost certainly not not what you want! Full stack trace:\n#{state.callstack.callstack_trace}")
924
+ end
925
+ end
926
+
927
+ state.pass_arguments_to_evaluation_stack(arguments)
928
+ choose_path(Path.new(path_string))
929
+ end
930
+
931
+ def choose_path(path, incrementing_turn_index=true)
932
+ state.set_chosen_path(path, incrementing_turn_index)
933
+
934
+ # take note of newly visited containers for read counts, etc.
935
+ visit_changed_containers_due_to_divert
936
+ end
937
+
938
+ # Chooses the Choice from the currentChoices list with the given
939
+ # index. Internally, this sets the current content path to that
940
+ # pointed to by the Choice, ready to continue story evaluation.
941
+ def choose_choice_index(choice_index)
942
+ choice_to_choose = current_choices[choice_index]
943
+ assert!(!choice_to_choose.nil?, "choice out of range")
944
+
945
+ # Replace callstack with the one from the thread at the choosing point,
946
+ # so that we can jump into the right place in the flow.
947
+ # This is important in case the flow was forked by a new thread, which
948
+ # can create multiple leading edges for the story, each of which has its
949
+ # own content
950
+ if !on_make_choice.nil?
951
+ on_make_choice(choice_to_choose)
952
+ end
953
+
954
+ state.callstack.current_thread = choice_to_choose.thread_at_generation
955
+
956
+ choose_path(choice_to_choose.target_path)
957
+ end
958
+
959
+ def observe_variable(variable_name, &block)
960
+ self.variables_state.add_variable_observer(variable_name, &block)
961
+ end
962
+
963
+ def observe_variables(variable_names, &block)
964
+ variable_names.each do |variable_name|
965
+ self.observe_variable(variable_name, block)
966
+ end
967
+ end
968
+
969
+ def remove_variable_observer(variable_name, &block)
970
+ self.variables_state.remove_variable_observer(variable_name, &block)
971
+ end
972
+
973
+ def has_function?(function_name)
974
+ !knot_container_with_name(function_name).nil?
975
+ end
976
+
977
+ # Evaluates a function defined in ink
978
+ def evaluate_function(function_name, *arguments)
979
+ if !on_evaluate_function.nil?
980
+ on_evaluate_function(function_name, arguments)
981
+ end
982
+
983
+ if function_name.to_s.strip.empty?
984
+ raise StoryError, "Function is null, empty, or whitespace"
985
+ end
986
+
987
+ function_container = knot_container_with_name(function_name)
988
+ if function_container.nil?
989
+ raise StoryError, "Function does not exist: #{function_name}"
990
+ end
991
+
992
+ # Snapshot the output stream
993
+ output_stream_before = state.output_stream.dup
994
+ state.reset_output!
995
+
996
+ # State will temporarily replace the callstack in order to evaluate
997
+ state.start_function_evaluation_from_game(function_container, arguments)
998
+
999
+ # Evaluate the function, and collect the string output
1000
+ string_output = StringIO.new
1001
+ while can_continue?
1002
+ string_output << continue
1003
+ end
1004
+
1005
+ string_output.rewind
1006
+
1007
+ text_output = string_output.read
1008
+
1009
+ # Restore the output stream in case this was called during the main
1010
+ # Story Evaluation
1011
+ state.reset_output!(output_stream_before)
1012
+
1013
+ # Finish evaluation, and see whether anything was produced
1014
+ result = state.complete_function_evaluation_from_game
1015
+
1016
+ if !on_complete_evaluate_function.nil?
1017
+ on_complete_evaluate_function(function_name, arguments, text_output, result)
1018
+ end
1019
+
1020
+ return {
1021
+ result: result,
1022
+ text_output: text_output
1023
+ }
1024
+ end
1025
+
1026
+ def call_external_function(function_name, number_of_arguments)
1027
+ function = external_functions[function_name]
1028
+ if function.nil?
1029
+ if allow_external_function_fallbacks?
1030
+ fallback_function_container = knot_container_with_name(function_name)
1031
+ if fallback_function_container.nil?
1032
+ raise StoryError, "Trying to call external function #{function_name} which has not been bound, and fallback ink function cannot be found"
1033
+ end
1034
+
1035
+ # Divert directly into the fallback function and we're done
1036
+ state.callstack.push(PushPopType::TYPES[:function], output_stream_length_when_pushed: state.output_stream.count)
1037
+ state.diverted_pointer = Pointer.start_of(fallback_function_container)
1038
+ return
1039
+ else
1040
+ raise StoryError, "Trying to call EXTERNAL function #{function_name}, which has not been defined (and ink fallbacks disabled)"
1041
+ end
1042
+ end
1043
+
1044
+ arguments = []
1045
+ number_of_arguments.times{ arguments << state.pop_evaluation_stack }
1046
+
1047
+ arguments.reverse!
1048
+
1049
+ # Run the function
1050
+ result = function.call(*arguments.map{|x| x.value})
1051
+
1052
+ if result.nil?
1053
+ result = Void
1054
+ else
1055
+ result = Value.create(result)
1056
+ if result.nil?
1057
+ raise StoryError, "Could not create ink value from returned object of type #{result.class}"
1058
+ end
1059
+ end
1060
+
1061
+ state.push_evaluation_stack(result)
1062
+ end
1063
+
1064
+ def bind_external_function(function_name, &block)
1065
+ if external_functions.has_key?(function_name)
1066
+ raise StoryError, "Function #{function_name} has already been bound."
1067
+ end
1068
+
1069
+ external_functions[function_name] = block
1070
+ end
1071
+
1072
+ def unbind_external_function(function_name)
1073
+ if !external_functions.has_key?(function_name)
1074
+ raise StoryError, "Function #{function_name} has not been bound."
1075
+ end
1076
+
1077
+ external_functions.delete(function_name)
1078
+ end
1079
+
1080
+ # Check that all EXTERNAL ink functions have a valid function.
1081
+ # Note that this will automatically be called on the first call to continue
1082
+ def validate_external_bindings!
1083
+ missing_externals = missing_external_bindings(main_content_container)
1084
+
1085
+ has_validated_externals = true
1086
+
1087
+ if missing_externals.empty?
1088
+ return true
1089
+ else
1090
+ add_error!("ERROR: Missing function binding for the following: #{missing_externals.join(", ")}, #{allow_external_function_fallbacks? ? 'and no fallback ink functions found' : '(ink fallbacks disabled)'}")
1091
+ end
1092
+ end
1093
+
1094
+ def missing_external_bindings(container)
1095
+ missing_externals = []
1096
+ container.content.each do |item|
1097
+ if item.is_a?(Container)
1098
+ missing_externals += missing_external_bindings(item)
1099
+ return missing_externals
1100
+ end
1101
+
1102
+ if item.is_a?(Divert) && item.is_external?
1103
+ if !external_functions.has_key?(item.target_path_string)
1104
+ if allow_external_function_fallbacks?
1105
+ fallback_found = main_content_container.named_content.has_key?(item.target_path_string)
1106
+ if !fallback_found
1107
+ missing_externals << item.target_path_string
1108
+ end
1109
+ else
1110
+ missing_externals << item.target_path_string
1111
+ end
1112
+ end
1113
+ end
1114
+ end
1115
+
1116
+ container.named_content.each do |key, container|
1117
+ missing_externals += missing_external_bindings(container)
1118
+ end
1119
+
1120
+ return missing_externals
1121
+ end
1122
+
1123
+ def calculate_newline_output_state_change(previous_text, current_text, previous_tag_count, current_tag_count)
1124
+ newline_still_exists = (current_text.size >= previous_text.size) && (current_text[previous_text.size - 1] == "\n")
1125
+
1126
+ if ((previous_tag_count == current_tag_count) &&(previous_text.size == current_text.size) && newline_still_exists)
1127
+ return :no_change
1128
+ end
1129
+
1130
+ if !newline_still_exists
1131
+ return :newline_removed
1132
+ end
1133
+
1134
+ if current_tag_count > previous_tag_count
1135
+ return :extended_beyond_newline
1136
+ end
1137
+
1138
+ if !current_text[previous_text.size..].strip.empty?
1139
+ return :extended_beyond_newline
1140
+ end
1141
+
1142
+ # There's new text, but it's just whitespace, so there's still potential
1143
+ # for glue to kill the newline
1144
+ return :no_change
1145
+ end
1146
+
1147
+ def global_tags
1148
+ tags_at_start_of_flow_container_with_path_string("")
1149
+ end
1150
+
1151
+ def tags_for_content_at_path(path)
1152
+ tags_at_start_of_flow_container_with_path_string(path)
1153
+ end
1154
+
1155
+ def tags_at_start_of_flow_container_with_path_string(path_string)
1156
+ path = Path.new(path_string)
1157
+
1158
+ # Expected to be global story, knot or stitch
1159
+ flow_container = content_at_path(path).container
1160
+
1161
+ while true
1162
+ first_content = flow_container.content.first
1163
+ if first_content.is_a?(Container)
1164
+ flow_container = first_content
1165
+ else
1166
+ break
1167
+ end
1168
+ end
1169
+
1170
+ # Any initial tag objects count as the "main tags" associated with
1171
+ # that story/knot/stitch
1172
+ tags_to_return = []
1173
+ flow_container.content.each do |item|
1174
+ if item.is_a?(Tag)
1175
+ tags_to_return << item.text
1176
+ else
1177
+ break
1178
+ end
1179
+ end
1180
+
1181
+ return tags_to_return
1182
+ end
1183
+
1184
+ # Useful when debugging a (very short) story, to visualise the state of the
1185
+ # story. Add this call as a watch and open the extended text. A left-arrow mark
1186
+ # will denote the current point of the story.
1187
+ # It's only recommended that this is used on very short debug stories, since
1188
+ # it can end up generate a large quantity of text otherwise.
1189
+ def build_string_of_hierarchy
1190
+ result = StringIO.new
1191
+ main_content_container.build_string_of_hierarchy(result, 0, state.current_pointer.resolve!)
1192
+ result.rewind
1193
+ result.read
1194
+ end
1195
+
1196
+ def build_string_of_container(container)
1197
+ container.build_string_of_hierarchy(StringIO.new, 0, state.current_pointer.resolve!)
1198
+ end
1199
+
1200
+ def next_content!
1201
+ # setting previousContentObject is critical for visit_changed_containers_due_to_divert
1202
+ state.previous_pointer = state.current_pointer.clone
1203
+
1204
+ # Divert step?
1205
+ if !state.diverted_pointer.null_pointer?
1206
+ state.current_pointer = state.diverted_pointer.clone
1207
+ state.diverted_pointer = Pointer.null_pointer
1208
+
1209
+ # Internally uses state.previous_content_object and state.current_content_object
1210
+ visit_changed_containers_due_to_divert
1211
+
1212
+ # Diverted location has valid content?
1213
+ if !state.current_pointer.null_pointer?
1214
+ return
1215
+ end
1216
+
1217
+ # Otherwise, if diverted located doesn't have valid content,
1218
+ # Drop down and attempt to increment
1219
+ # This can happenm if the diverted path is intentionally jumping
1220
+ # to the end of a container - e.g: a Conditional that's re-joining
1221
+ end
1222
+
1223
+ successful_pointer_increment = increment_content_pointer
1224
+
1225
+ # Ran out of content? Try to auto-exit from a function, or
1226
+ # finish evaluating the content of a thread
1227
+ if !successful_pointer_increment
1228
+ did_pop = false
1229
+
1230
+ if state.callstack.can_pop?(:function)
1231
+ # debugger
1232
+ # Pop from the call stack
1233
+ state.pop_callstack(:function)
1234
+
1235
+ # This pop was due to dropping off the end of a function that didn't
1236
+ # return anything, so in this case we make sure the evaluator has
1237
+ # something to chomp on if it needs it
1238
+
1239
+ if state.in_expression_evaluation?
1240
+ state.push_evaluation_stack(Void.new)
1241
+ end
1242
+
1243
+ did_pop = true
1244
+ elsif state.callstack.can_pop_thread?
1245
+ state.callstack.pop_thread!
1246
+ did_pop = true
1247
+ else
1248
+ state.exit_function_evaluation_from_game?
1249
+ end
1250
+
1251
+ # Step past the point where we last called out
1252
+ if did_pop && !state.current_pointer.null_pointer?
1253
+ next_content!
1254
+ end
1255
+ end
1256
+ end
1257
+
1258
+ def increment_content_pointer
1259
+ successful_increment = true
1260
+
1261
+ pointer = state.callstack.current_element.current_pointer.clone
1262
+ pointer.index += 1
1263
+
1264
+ # Each time we step off the end, we fall out to the next container, all the
1265
+ # time we're in indexed rather than named content
1266
+ while pointer.index >= pointer.container.content.size
1267
+ successful_increment = false
1268
+
1269
+ next_ancestor = pointer.container.parent
1270
+ break if !next_ancestor.is_a?(Container)
1271
+
1272
+ index_in_ancestor = next_ancestor.content.index(pointer.container)
1273
+ break if index_in_ancestor.nil?
1274
+
1275
+ pointer = Pointer.new(next_ancestor, index_in_ancestor)
1276
+
1277
+ # Increment to the next content in outer container
1278
+ pointer.index += 1
1279
+ successful_increment = true
1280
+ end
1281
+
1282
+ pointer = Pointer.null_pointer if !successful_increment
1283
+
1284
+ state.callstack.current_element.current_pointer = pointer.clone
1285
+ return successful_increment
1286
+ end
1287
+
1288
+ def try_following_default_invisible_choice
1289
+ all_choices = state.current_choices
1290
+
1291
+ # Is a default invisible choice the ONLY choice?
1292
+ invisible_choices = all_choices.select{|choice| choice.invisible_default?}
1293
+ if invisible_choices.empty? || all_choices.size > invisible_choices.size
1294
+ return false
1295
+ end
1296
+
1297
+ choice = invisible_choices[0]
1298
+
1299
+ # Invisible choice may have been generate on a different thread, in which
1300
+ # case we need to restore it before we continue
1301
+ state.callstack.current_thread = choice.thread_at_generation
1302
+
1303
+ # If there's a chance that this state will be rolled back before the
1304
+ # invisible choice then make sure that the choice thread is left intact,
1305
+ # and it isn't re-entered in an old state
1306
+ if !@state_snapshot_at_last_newline.nil?
1307
+ state.callstack.current_thread = state.callstack.fork_thread!
1308
+ end
1309
+
1310
+ choose_path(choice.target_path, incrementing_turn_index: false)
1311
+
1312
+ return true
1313
+ end
1314
+
1315
+
1316
+ def next_sequence_shuffle_index
1317
+ number_of_elements = state.pop_evaluation_stack.value
1318
+
1319
+ if !number_of_elements.is_a?(Numeric)
1320
+ error!("Expected number of elements in sequence for shuffle index")
1321
+ return 0
1322
+ end
1323
+
1324
+ sequence_container = state.current_pointer.container
1325
+
1326
+ sequence_count = state.pop_evaluation_stack.value
1327
+ loop_index = sequence_count / number_of_elements
1328
+ iteration_index = sequence_count % number_of_elements
1329
+
1330
+ # Generate the same shuffle based on
1331
+ # - the hash of this container, to make sure it's consistent
1332
+ # - How many times the runtime has looped around this full shuffle
1333
+ sequence_hash = sequence_container.path.to_s.bytes.sum
1334
+
1335
+ randomizer_seed = sequence_hash + loop_index + state.story_seed.value
1336
+ randomizer = Random.new(randomizer_seed)
1337
+ unpicked_indicies = (0..(number_of_elements-1)).to_a
1338
+
1339
+ (0..iteration_index).to_a.each do |i|
1340
+ chosen = randomizer.rand(2147483647) % unpicked_indicies.size
1341
+ chosen_index = unpicked_indicies[chosen]
1342
+ unpicked_indicies.delete(chosen)
1343
+
1344
+ if i == iteration_index
1345
+ return chosen_index
1346
+ end
1347
+ end
1348
+
1349
+ raise StoryError, "Should never reach here"
1350
+ end
1351
+
1352
+ def error!(message, options = {use_end_line_number: false})
1353
+ exception = StoryError.new(message)
1354
+ exception.use_end_line_number = options[:use_end_line_number]
1355
+ raise exception
1356
+ end
1357
+
1358
+ def warning(message)
1359
+ add_error!(message, is_warning: true)
1360
+ end
1361
+
1362
+ def add_error!(message, options = {is_warning: false, use_end_line_number: false})
1363
+ debug_metadata = current_debug_metadata
1364
+
1365
+ error_type_string = options[:is_warning] ? "WARNING" : "ERROR"
1366
+
1367
+ if !debug_metadata.nil?
1368
+ line_number = options[:use_end_line_number] ? debug_metadata.end_line_number : debug_metadata.start_line_number
1369
+ message = "RUNTIME #{error_type_string}: '#{debug_metadata.file_name}' line #{line_number}: #{message}"
1370
+ elsif !state.current_pointer.null_pointer?
1371
+ message = "RUNTIME #{error_type_string}: (#{state.current_pointer.path}) #{message}"
1372
+ else
1373
+ message = "RUNTIME #{error_type_string}: #{message}"
1374
+ end
1375
+
1376
+ state.add_error(message, is_warning: options[:is_warning])
1377
+
1378
+ # In a broken state, we don't need to know about any other errors
1379
+ if !options[:is_warning]
1380
+ state.force_end!
1381
+ end
1382
+ end
1383
+
1384
+ def assert!(condition, message = nil)
1385
+ if !condition
1386
+ if message.nil?
1387
+ message = "Story assert"
1388
+ end
1389
+
1390
+ raise StoryError, "#{message} #{current_debug_metadata}"
1391
+ end
1392
+ end
1393
+
1394
+ def current_debug_metadata
1395
+ pointer = state.current_pointer
1396
+
1397
+ if !pointer.null_pointer?
1398
+ debug_metadata = pointer.resolve!.debug_metadata
1399
+ return debug_metadata if !debug_metadata.nil?
1400
+ end
1401
+
1402
+ # Move up callstack if possible
1403
+ state.callstack.elements.each do |element|
1404
+ pointer = element.current_pointer
1405
+ if !pointer.null_pointer? && pointer.resolve! != nil
1406
+ debug_metadata = pointer.resolve!.debug_metadata
1407
+ return debug_metadata if !debug_metadata.nil?
1408
+ end
1409
+ end
1410
+
1411
+ # Current/previous path may not be valid if we're just had an error, or
1412
+ # if we've simply run out of content. As a last resort, try to grab
1413
+ # something from the output stream
1414
+ state.output_stream.each do |item|
1415
+ return item.debug_metadata if !item.debug_metadata.nil?
1416
+ end
1417
+
1418
+ return nil
1419
+ end
1420
+
1421
+ def current_line_number
1422
+ debug_metadata = current_debug_metadata
1423
+ return 0 if debug_metadata.nil?
1424
+ return debug_metadata.start_line_number
1425
+ end
1426
+
1427
+ def correct_ink_version?
1428
+ if ink_version.nil?
1429
+ raise ArgumentError, "No ink vesion provided!"
1430
+ end
1431
+
1432
+ if ink_version > CURRENT_INK_VERSION
1433
+ raise ArgumentError, "Version of ink (#{ink_version}) is greater than what the engine supports (#{CURRENT_INK_VERSION})"
1434
+ end
1435
+
1436
+ if ink_version < CURRENT_INK_VERSION
1437
+ raise ArgumentError, "Version of ink (#{ink_version}) is less than what the engine supports (#{CURRENT_INK_VERSION})"
1438
+ end
1439
+
1440
+ if ink_version != CURRENT_INK_VERSION
1441
+ puts "WARNING: Version of ink (#{ink_version}) doesn't match engine's version (#{CURRENT_INK_VERSION})"
1442
+ end
1443
+
1444
+ true
1445
+ end
1446
+ end
1447
+ end