fable 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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