Class: Fable::Story

Inherits:
RuntimeObject show all
Defined in:
lib/fable/story.rb

Defined Under Namespace

Classes: CannotContinueError

Constant Summary collapse

CURRENT_INK_VERSION =
19
MINIMUM_COMPATIBLE_INK_VERSION =
18

Instance Attribute Summary collapse

Attributes inherited from RuntimeObject

#own_debug_metadata, #parent, #path

Instance Method Summary collapse

Methods inherited from RuntimeObject

#compact_path_string, #convert_path_to_relative, #copy, #debug_line_number_of_path, #debug_metadata, #indentation_string, #resolve_path, #root_content_container

Constructor Details

#initialize(original_object) ⇒ Story

Returns a new instance of Story.



19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/fable/story.rb', line 19

def initialize(original_object)
  super()
  self.external_functions = {}
  self.original_object = original_object
  self.state = StoryState.new(self)

  correct_ink_version?

  if original_object["root"].nil?
    raise ArgumentError, "no root object in ink"
  end

  @state_snapshot_at_last_newline = nil
  @recursive_content_count = 0

  if !original_object["listDefs"].empty?
    self.list_definitions = Serializer.convert_to_list_definitions(original_object["listDefs"])
  else
    self.list_definitions = Serializer.convert_to_list_definitions({})
  end
  self.main_content_container = Serializer.convert_to_runtime_object(original_object["root"])

  reset_state!
end

Instance Attribute Details

#allow_external_function_fallbacksObject Also known as: allow_external_function_fallbacks?

Returns the value of attribute allow_external_function_fallbacks.



9
10
11
# File 'lib/fable/story.rb', line 9

def allow_external_function_fallbacks
  @allow_external_function_fallbacks
end

#external_functionsObject

Returns the value of attribute external_functions.



9
10
11
# File 'lib/fable/story.rb', line 9

def external_functions
  @external_functions
end

#list_definitionsObject

Returns the value of attribute list_definitions.



9
10
11
# File 'lib/fable/story.rb', line 9

def list_definitions
  @list_definitions
end

#main_content_containerObject

Returns the value of attribute main_content_container.



9
10
11
# File 'lib/fable/story.rb', line 9

def main_content_container
  @main_content_container
end

#on_choose_path_stringObject

Returns the value of attribute on_choose_path_string.



9
10
11
# File 'lib/fable/story.rb', line 9

def on_choose_path_string
  @on_choose_path_string
end

#on_complete_evaluate_functionObject

Returns the value of attribute on_complete_evaluate_function.



9
10
11
# File 'lib/fable/story.rb', line 9

def on_complete_evaluate_function
  @on_complete_evaluate_function
end

#on_evaluate_functionObject

Returns the value of attribute on_evaluate_function.



9
10
11
# File 'lib/fable/story.rb', line 9

def on_evaluate_function
  @on_evaluate_function
end

#on_make_choiceObject

Returns the value of attribute on_make_choice.



9
10
11
# File 'lib/fable/story.rb', line 9

def on_make_choice
  @on_make_choice
end

#original_objectObject

Returns the value of attribute original_object.



9
10
11
# File 'lib/fable/story.rb', line 9

def original_object
  @original_object
end

#profilerObject

Returns the value of attribute profiler.



9
10
11
# File 'lib/fable/story.rb', line 9

def profiler
  @profiler
end

#stateObject

Returns the value of attribute state.



9
10
11
# File 'lib/fable/story.rb', line 9

def state
  @state
end

Instance Method Details

#add_error!(message, options = {is_warning: false, use_end_line_number: false}) ⇒ Object



1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
# File 'lib/fable/story.rb', line 1362

def add_error!(message, options = {is_warning: false, use_end_line_number: false})
   = 

  error_type_string = options[:is_warning] ? "WARNING" : "ERROR"

  if !.nil?
    line_number = options[:use_end_line_number] ? .end_line_number : .start_line_number
    message = "RUNTIME #{error_type_string}: '#{.file_name}' line #{line_number}: #{message}"
  elsif !state.current_pointer.null_pointer?
    message = "RUNTIME #{error_type_string}: (#{state.current_pointer.path}) #{message}"
  else
    message = "RUNTIME #{error_type_string}: #{message}"
  end

  state.add_error(message, is_warning: options[:is_warning])

  # In a broken state, we don't need to know about any other errors
  if !options[:is_warning]
    state.force_end!
  end
end

#assert!(condition, message = nil) ⇒ Object



1384
1385
1386
1387
1388
1389
1390
1391
1392
# File 'lib/fable/story.rb', line 1384

def assert!(condition, message = nil)
  if !condition
    if message.nil?
      message = "Story assert"
    end

    raise StoryError, "#{message} #{}"
  end
end

#bind_external_function(function_name, &block) ⇒ Object



1064
1065
1066
1067
1068
1069
1070
# File 'lib/fable/story.rb', line 1064

def bind_external_function(function_name, &block)
  if external_functions.has_key?(function_name)
    raise StoryError, "Function #{function_name} has already been bound."
  end

  external_functions[function_name] = block
end

#build_string_of_container(container) ⇒ Object



1196
1197
1198
# File 'lib/fable/story.rb', line 1196

def build_string_of_container(container)
  container.build_string_of_hierarchy(StringIO.new, 0, state.current_pointer.resolve!)
end

#build_string_of_hierarchyObject

Useful when debugging a (very short) story, to visualise the state of the story. Add this call as a watch and open the extended text. A left-arrow mark will denote the current point of the story. It’s only recommended that this is used on very short debug stories, since it can end up generate a large quantity of text otherwise.



1189
1190
1191
1192
1193
1194
# File 'lib/fable/story.rb', line 1189

def build_string_of_hierarchy
  result = StringIO.new
  main_content_container.build_string_of_hierarchy(result, 0, state.current_pointer.resolve!)
  result.rewind
  result.read
end

#calculate_newline_output_state_change(previous_text, current_text, previous_tag_count, current_tag_count) ⇒ Object



1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
# File 'lib/fable/story.rb', line 1123

def calculate_newline_output_state_change(previous_text, current_text, previous_tag_count, current_tag_count)
  newline_still_exists = (current_text.size >= previous_text.size) && (current_text[previous_text.size - 1] == "\n")

  if ((previous_tag_count == current_tag_count) &&(previous_text.size == current_text.size) && newline_still_exists)
    return :no_change
  end

  if !newline_still_exists
    return :newline_removed
  end

  if current_tag_count > previous_tag_count
    return :extended_beyond_newline
  end

  if !current_text[previous_text.size..].strip.empty?
    return :extended_beyond_newline
  end

  # There's new text, but it's just whitespace, so there's still potential
  # for glue to kill the newline
  return :no_change
end

#call_external_function(function_name, number_of_arguments) ⇒ Object



1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
# File 'lib/fable/story.rb', line 1026

def call_external_function(function_name, number_of_arguments)
  function = external_functions[function_name]
  if function.nil?
    if allow_external_function_fallbacks?
      fallback_function_container = knot_container_with_name(function_name)
      if fallback_function_container.nil?
        raise StoryError, "Trying to call external function #{function_name} which has not been bound, and fallback ink function cannot be found"
      end

      # Divert directly into the fallback function and we're done
      state.callstack.push(PushPopType::TYPES[:function], output_stream_length_when_pushed: state.output_stream.count)
      state.diverted_pointer = Pointer.start_of(fallback_function_container)
      return
    else
      raise StoryError, "Trying to call EXTERNAL function #{function_name}, which has not been defined (and ink fallbacks disabled)"
    end
  end

  arguments = []
  number_of_arguments.times{ arguments << state.pop_evaluation_stack }

  arguments.reverse!

  # Run the function
  result = function.call(*arguments.map{|x| x.value})

  if result.nil?
    result = Void
  else
    result = Value.create(result)
    if result.nil?
      raise StoryError, "Could not create ink value from returned object of type #{result.class}"
    end
  end

  state.push_evaluation_stack(result)
end

#can_continue?Boolean

Returns:

  • (Boolean)


94
95
96
# File 'lib/fable/story.rb', line 94

def can_continue?
  state.can_continue?
end

#choose_choice_index(choice_index) ⇒ Object

Chooses the Choice from the currentChoices list with the given index. Internally, this sets the current content path to that pointed to by the Choice, ready to continue story evaluation.



941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
# File 'lib/fable/story.rb', line 941

def choose_choice_index(choice_index)
  choice_to_choose = current_choices[choice_index]
  assert!(!choice_to_choose.nil?, "choice out of range")

  # Replace callstack with the one from the thread at the choosing point,
  # so that we can jump into the right place in the flow.
  # This is important in case the flow was forked by a new thread, which
  # can create multiple leading edges for the story, each of which has its
  # own content
  if !on_make_choice.nil?
    on_make_choice(choice_to_choose)
  end

  state.callstack.current_thread = choice_to_choose.thread_at_generation

  choose_path(choice_to_choose.target_path)
end

#choose_path(path, incrementing_turn_index = true) ⇒ Object



931
932
933
934
935
936
# File 'lib/fable/story.rb', line 931

def choose_path(path, incrementing_turn_index=true)
  state.set_chosen_path(path, incrementing_turn_index)

  # take note of newly visited containers for read counts, etc.
  visit_changed_containers_due_to_divert
end

#choose_path_string(path_string, reset_callstack = true, arguments = []) ⇒ Object

Change the current position of the story to the given path. From here you can call Continue() to evaluate the next line.

The path string is a dot-separated path as used internally by the engine. These examples should work:

myKnot
myKnot.myStitch

Note however that this won’t necessarily work:

myKnot.myStitch.myLabelledChoice

…because of the way that content is nested within a weave structure.

By default this will reset the callstack beforehand, which means that any tunnels, threads or functions you were in at the time of calling will be discarded. This is different from the behaviour of ChooseChoiceIndex, which will always keep the callstack, since the choices are known to come from the correct state, and known their source thread.

You have the option of passing false to the resetCallstack parameter if you don’t want this behaviour, and will leave any active threads, tunnels or function calls in-tact.

This is potentially dangerous! If you’re in the middle of a tunnel, it’ll redirect only the inner-most tunnel, meaning that when you tunnel-return using ‘->->’, it’ll return to where you were before. This may be what you want though. However, if you’re in the middle of a function, ChoosePathString will throw an exception.



905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
# File 'lib/fable/story.rb', line 905

def choose_path_string(path_string, reset_callstack=true, arguments = [])
  if !on_choose_path_string.nil?
    on_choose_path_string.call(path_string, arguments)
  end

  if reset_callstack
    reset_callstack!
  else
    # choose_path_string is potentially dangerous since you can call it
    # when the stack is pretty much in any state. Let's catch one of the
    # worst offenders
    if state.callstack.current_element == :POP_FUNCTION
      container = state.callstack.current_element.current_pointer.container
      function_detail = ""
      if !container.nil?
        function_detail = "(#{container.path.to_s})"
      end

      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}")
    end
  end

  state.pass_arguments_to_evaluation_stack(arguments)
  choose_path(Path.new(path_string))
end

#content_at_path(path) ⇒ Object



143
144
145
# File 'lib/fable/story.rb', line 143

def content_at_path(path)
  main_content_container.content_at_path(path)
end

#continue(&block) ⇒ Object



44
45
46
47
48
# File 'lib/fable/story.rb', line 44

def continue(&block)
  validate_external_bindings!
  internal_continue(&block)
  return current_text
end

#continue_maximially(&block) ⇒ Object



50
51
52
53
54
55
56
57
58
# File 'lib/fable/story.rb', line 50

def continue_maximially(&block)
  result = StringIO.new
  while can_continue?
    result << continue
  end

  result.rewind
  return result.read
end

#continue_single_step!Object



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/fable/story.rb', line 276

def continue_single_step!
  profiler.pre_step! if profile?

  step!

  profiler.post_step! if profile?

  if !can_continue? && !state.callstack.element_is_evaluate_from_game?
    try_following_default_invisible_choice
  end

  profiler.pre_snapshot! if profile?

  # Don't save/rewind during string evaluation, which is a special state
  # used for choices
  if !state.in_string_evaluation?
    # we previously found a newline, but we're double-checking that it won't
    # be removed` by glue
    if !@state_snapshot_at_last_newline.nil?
      change = calculate_newline_output_state_change(
        @state_snapshot_at_last_newline.current_text, state.current_text,
        @state_snapshot_at_last_newline.current_tags.size, state.current_tags.size
      )

      # The last time we saw a newline, it was definitely the end of the
      # line, so we want to rewind to that point
      if change == :extended_beyond_newline
        restore_state_snapshot!

        # Hit a newline for sure, we're done
        return true
      end

      # Newline that previously existed is no longer value (eg: encountered glue)
      if change == :newline_removed
        discard_snapshot!
      end
    end

    # Current content ends in a newline - approaching end of our evaluation

    if state.output_stream_ends_in_newline?
      # If we can continue evaluation for a bit:
      # - create a snapshot in case we need to rewind
      # We're going to keep stepping in case we see glue or some
      # non-text content such as choices

      if can_continue?
        # Don't bother to record the state beyond the current newline
        # example:
        # e.g.:
        # Hello world\n            // record state at the end of here
        # ~ complexCalculation()   // don't actually need this unless it generates text

        if @state_snapshot_at_last_newline.nil?
          state_snapshot!
        end
      else
        # we're about to exit since we can't continue, make sure we don't
        # have an old state lying around
        discard_snapshot!
      end
    end
  end

  profiler.post_snapshot! if profile?

  return false
end

#correct_ink_version?Boolean

Returns:

  • (Boolean)


1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
# File 'lib/fable/story.rb', line 1427

def correct_ink_version?
  if ink_version.nil?
    raise ArgumentError, "No ink vesion provided!"
  end

  if ink_version > CURRENT_INK_VERSION
    raise ArgumentError, "Version of ink (#{ink_version}) is greater than what the engine supports (#{CURRENT_INK_VERSION})"
  end

  if ink_version < CURRENT_INK_VERSION
    raise ArgumentError, "Version of ink (#{ink_version}) is less than what the engine supports (#{CURRENT_INK_VERSION})"
  end

  if ink_version != CURRENT_INK_VERSION
    puts "WARNING: Version of ink (#{ink_version}) doesn't match engine's version (#{CURRENT_INK_VERSION})"
  end

  true
end

#current_choicesObject



60
61
62
# File 'lib/fable/story.rb', line 60

def current_choices
  return self.state.current_choices.select{|choice| !choice.invisible_default? }
end

#current_debug_metadataObject



1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
# File 'lib/fable/story.rb', line 1394

def 
  pointer = state.current_pointer

  if !pointer.null_pointer?
     = pointer.resolve!.
    return  if !.nil?
  end

  # Move up callstack if possible
  state.callstack.elements.each do |element|
    pointer = element.current_pointer
    if !pointer.null_pointer? && pointer.resolve! != nil
       = pointer.resolve!.
      return  if !.nil?
    end
  end

  # Current/previous path may not be valid if we're just had an error, or
  # if we've simply run out of content. As a last resort, try to grab
  # something from the output stream
  state.output_stream.each do |item|
    return item. if !item..nil?
  end

  return nil
end

#current_errorsObject



72
73
74
# File 'lib/fable/story.rb', line 72

def current_errors
  return self.state.current_errors
end

#current_line_numberObject



1421
1422
1423
1424
1425
# File 'lib/fable/story.rb', line 1421

def current_line_number
   = 
  return 0 if .nil?
  return .start_line_number
end

#current_tagsObject



68
69
70
# File 'lib/fable/story.rb', line 68

def current_tags
  return self.state.current_tags
end

#current_textObject



64
65
66
# File 'lib/fable/story.rb', line 64

def current_text
  return self.state.current_text
end

#current_warningsObject



76
77
78
# File 'lib/fable/story.rb', line 76

def current_warnings
  return self.state.current_warnings
end

#discard_snapshot!Object



196
197
198
199
# File 'lib/fable/story.rb', line 196

def discard_snapshot!
  self.state.apply_any_patch!
  @state_snapshot_at_last_newline = nil
end

#error!(message, options = {use_end_line_number: false}) ⇒ Object



1352
1353
1354
1355
1356
# File 'lib/fable/story.rb', line 1352

def error!(message, options = {use_end_line_number: false})
  exception = StoryError.new(message)
  exception.use_end_line_number = options[:use_end_line_number]
  raise exception
end

#evaluate_function(function_name, *arguments) ⇒ Object

Evaluates a function defined in ink



978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
# File 'lib/fable/story.rb', line 978

def evaluate_function(function_name, *arguments)
  if !on_evaluate_function.nil?
    on_evaluate_function(function_name, arguments)
  end

  if function_name.to_s.strip.empty?
    raise StoryError, "Function is null, empty, or whitespace"
  end

  function_container = knot_container_with_name(function_name)
  if function_container.nil?
    raise StoryError, "Function does not exist: #{function_name}"
  end

  # Snapshot the output stream
  output_stream_before = state.output_stream.dup
  state.reset_output!

  # State will temporarily replace the callstack in order to evaluate
  state.start_function_evaluation_from_game(function_container, arguments)

  # Evaluate the function, and collect the string output
  string_output = StringIO.new
  while can_continue?
    string_output << continue
  end

  string_output.rewind

  text_output = string_output.read

  # Restore the output stream in case this was called during the main
  # Story Evaluation
  state.reset_output!(output_stream_before)

  # Finish evaluation, and see whether anything was produced
  result = state.complete_function_evaluation_from_game

  if !on_complete_evaluate_function.nil?
    on_complete_evaluate_function(function_name, arguments, text_output, result)
  end

  return {
    result: result,
    text_output: text_output
  }
end

#global_declarationObject



114
115
116
# File 'lib/fable/story.rb', line 114

def global_declaration
  self.main_content_container.named_content["global decl"]
end

#global_tagsObject



1147
1148
1149
# File 'lib/fable/story.rb', line 1147

def global_tags
  tags_at_start_of_flow_container_with_path_string("")
end

#has_errors?Boolean

Returns:

  • (Boolean)


84
85
86
87
# File 'lib/fable/story.rb', line 84

def has_errors?
  return false if current_errors.nil?
  current_errors.any?
end

#has_function?(function_name) ⇒ Boolean

Returns:

  • (Boolean)


973
974
975
# File 'lib/fable/story.rb', line 973

def has_function?(function_name)
  !knot_container_with_name(function_name).nil?
end

#has_warnings?Boolean

Returns:

  • (Boolean)


89
90
91
92
# File 'lib/fable/story.rb', line 89

def has_warnings?
  return false if current_warnings.nil?
  current_warnings.any?
end

#increment_content_pointerObject



1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
# File 'lib/fable/story.rb', line 1258

def increment_content_pointer
  successful_increment = true

  pointer = state.callstack.current_element.current_pointer.clone
  pointer.index += 1

  # Each time we step off the end, we fall out to the next container, all the
  # time we're in indexed rather than named content
  while pointer.index >= pointer.container.content.size
    successful_increment = false

    next_ancestor = pointer.container.parent
    break if !next_ancestor.is_a?(Container)

    index_in_ancestor = next_ancestor.content.index(pointer.container)
    break if index_in_ancestor.nil?

    pointer = Pointer.new(next_ancestor, index_in_ancestor)

    # Increment to the next content in outer container
    pointer.index += 1
    successful_increment = true
  end

  pointer = Pointer.null_pointer if !successful_increment

  state.callstack.current_element.current_pointer = pointer.clone
  return successful_increment
end

#ink_versionObject



98
99
100
# File 'lib/fable/story.rb', line 98

def ink_version
  original_object["inkVersion"]
end

#internal_continue(&block) ⇒ Object



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/fable/story.rb', line 201

def internal_continue(&block)
  profiler.pre_continue! if profile?

  @recursive_content_count += 1

  if !can_continue?
    raise CannotContinueError, "make sure to check can_continue?"
  end

  state.did_safe_exit = false
  state.reset_output!

  # It's possible for ink to call game to call ink to call game etc
  # In this case, we only want to batch observe variable changes
  # for the outermost call.
  if @recursive_content_count == 1
    state.variables_state.batch_observing_variable_changes = true
  end

  output_stream_ends_in_newline = false

  while can_continue?
    begin
      output_stream_ends_in_newline = continue_single_step!
    rescue StoryError => e
      add_error!(e.message, {use_end_line_number: e.use_end_line_number?})
      break
    end

    break if output_stream_ends_in_newline
  end


  # 3 outcomes:
  # - got a newline (finished this line of text)
  # - can't continue (e.g: choices, or end of story)
  # - error

  if output_stream_ends_in_newline || !can_continue?
    # Do we need to rewind, because we evaluated further than we should?
    if !@state_snapshot_at_last_newline.nil?
      restore_state_snapshot!
    end

    # Finished this section of content, or reached a choice point
    if !can_continue?
      if state.callstack.can_pop_thread?
        add_error!("Thread available to pop, threads should always be flat by the end of evaluation?")
      end

      if state.generated_choices.empty? && !state.did_safe_exit? && @temporary_evaluation_container.nil?
        if state.callstack.can_pop?(:tunnel)
          add_error!("unexpectedly reached end of content. Do you need a '->->' to return from a tunnel?")
        elsif state.callstack.can_pop?(:function)
          add_error!("unexpectedly reached end of content. Do you need a '~ return'?")
        elsif state.callstack.can_pop?
          add_error!("ran out of content. Do you need a '-> DONE' or '-> END'?")
        else
          add_error!("unexpectedly reached end of content for unknown reason.")
        end
      end

      state.did_safe_exit = false

      if @recursive_content_count == 1
        state.variables_state.batch_observing_variable_changes = false
      end
    end
  end

  @recursive_content_count -= 1

  profiler.post_continue! if profile?
end

#knot_container_with_name(name) ⇒ Object



147
148
149
# File 'lib/fable/story.rb', line 147

def knot_container_with_name(name)
  main_content_container.named_content[name]
end

#missing_external_bindings(container) ⇒ Object



1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
# File 'lib/fable/story.rb', line 1094

def missing_external_bindings(container)
  missing_externals = []
  container.content.each do |item|
    if item.is_a?(Container)
      missing_externals += missing_external_bindings(item)
      return missing_externals
    end

    if item.is_a?(Divert) && item.is_external?
      if !external_functions.has_key?(item.target_path_string)
        if allow_external_function_fallbacks?
          fallback_found = main_content_container.named_content.has_key?(item.target_path_string)
          if !fallback_found
            missing_externals << item.target_path_string
          end
        else
          missing_externals << item.target_path_string
        end
      end
    end
  end

  container.named_content.each do |key, container|
    missing_externals += missing_external_bindings(container)
  end

  return missing_externals
end

#next_content!Object



1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
# File 'lib/fable/story.rb', line 1200

def next_content!
  # setting previousContentObject is critical for visit_changed_containers_due_to_divert
  state.previous_pointer = state.current_pointer.clone

  # Divert step?
  if !state.diverted_pointer.null_pointer?
    state.current_pointer = state.diverted_pointer.clone
    state.diverted_pointer = Pointer.null_pointer

    # Internally uses state.previous_content_object and state.current_content_object
    visit_changed_containers_due_to_divert

    # Diverted location has valid content?
    if !state.current_pointer.null_pointer?
      return
    end

    # Otherwise, if diverted located doesn't have valid content,
    # Drop down and attempt to increment
    # This can happenm if the diverted path is intentionally jumping
    # to the end of a container - e.g: a Conditional that's re-joining
  end

  successful_pointer_increment = increment_content_pointer

  # Ran out of content? Try to auto-exit from a function, or
  # finish evaluating the content of a thread
  if !successful_pointer_increment
    did_pop = false

    if state.callstack.can_pop?(:function)
      # debugger
      # Pop from the call stack
      state.pop_callstack(:function)

      # This pop was due to dropping off the end of a function that didn't
      # return anything, so in this case we make sure the evaluator has
      # something to chomp on if it needs it

      if state.in_expression_evaluation?
        state.push_evaluation_stack(Void.new)
      end

      did_pop = true
    elsif state.callstack.can_pop_thread?
      state.callstack.pop_thread!
      did_pop = true
    else
      state.exit_function_evaluation_from_game?
    end

    # Step past the point where we last called out
    if did_pop && !state.current_pointer.null_pointer?
      next_content!
    end
  end
end

#next_sequence_shuffle_indexObject

Raises:



1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
# File 'lib/fable/story.rb', line 1316

def next_sequence_shuffle_index
  number_of_elements = state.pop_evaluation_stack.value

  if !number_of_elements.is_a?(Numeric)
    error!("Expected number of elements in sequence for shuffle index")
    return 0
  end

  sequence_container = state.current_pointer.container

  sequence_count = state.pop_evaluation_stack.value
  loop_index = sequence_count / number_of_elements
  iteration_index = sequence_count % number_of_elements

  # Generate the same shuffle based on
  # - the hash of this container, to make sure it's consistent
  # - How many times the runtime has looped around this full shuffle
  sequence_hash = sequence_container.path.to_s.bytes.sum

  randomizer_seed = sequence_hash + loop_index + state.story_seed.value
  randomizer = Random.new(randomizer_seed)
  unpicked_indicies = (0..(number_of_elements-1)).to_a

  (0..iteration_index).to_a.each do |i|
    chosen = randomizer.rand(2147483647) % unpicked_indicies.size
    chosen_index = unpicked_indicies[chosen]
    unpicked_indicies.delete(chosen)

    if i == iteration_index
      return chosen_index
    end
  end

  raise StoryError, "Should never reach here"
end

#observe_variable(variable_name, &block) ⇒ Object



959
960
961
# File 'lib/fable/story.rb', line 959

def observe_variable(variable_name, &block)
  self.variables_state.add_variable_observer(variable_name, &block)
end

#observe_variables(variable_names, &block) ⇒ Object



963
964
965
966
967
# File 'lib/fable/story.rb', line 963

def observe_variables(variable_names, &block)
  variable_names.each do |variable_name|
    self.observe_variable(variable_name, block)
  end
end

#perform_logic_and_flow_control(element) ⇒ Object



563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
# File 'lib/fable/story.rb', line 563

def perform_logic_and_flow_control(element)
  return false if element.nil?

  # Divert
  if element.is_a?(Divert)
    if element.is_conditional?
      return true if !state.pop_evaluation_stack.truthy?
    end

    if element.has_variable_target?
      variable_name = element.variable_divert_name
      variable_value = state.variables_state.get_variable_with_name(variable_name)

      if variable_value.nil?
        add_error!("Tried to divert using a target from a variable that could not be found (#{variable_name})")
      elsif !variable_value.is_a?(DivertTargetValue)
        error_message = "Tried to divert to a target from a variable, but the variable (#{variable_name}) didn't contain a divert target, it "
        if variable_value.to_i == 0
          error_message += "was empty/null (the value 0)"
        else
          error_message == "was #{variable_value}"
        end

        add_error!(error_message)
      end

      state.diverted_pointer = pointer_at_path(variable_value.target_path)
    elsif element.is_external?
      call_external_function(element.target_path.to_s, element.external_arguments)
      return true
    else
      state.diverted_pointer = pointer_at_path(element.target_path)
    end

    if element.pushes_to_stack?
      state.callstack.push(
        element.stack_push_type,
        output_stream_length_when_pushed: state.output_stream.count
      )
    end

    if state.diverted_pointer.nil? && !element.is_external?
      if element && element..source_name
        add_error!("Divert target doesn't exist: #{element..source_name}")
      else
        add_error!("Divert resolution failed: #{element}")
      end
    end

    return true
  end

  if element.is_a?(ControlCommand)
    case element.command_type
    when :EVALUATION_START
      assert!(!state.in_expression_evaluation?, "Already in expression evaluation?")
      state.in_expression_evaluation = true
    when :EVALUATION_END
      assert!(state.in_expression_evaluation?, "Not in expression evaluation mode")
      state.in_expression_evaluation = false
    when :EVALUATION_OUTPUT
      # if the expression turned out to be empty, there may not be
      # anything on the stack
      if state.evaluation_stack.size > 0
        output = state.pop_evaluation_stack
        if !output.is_a?(Void)
          state.push_to_output_stream(StringValue.new(output.to_s))
        end
      end
    when :NOOP
      :NOOP
    when :DUPLICATE_TOPMOST
      state.push_evaluation_stack(state.peek_evaluation_stack)
    when :POP_EVALUATED_VALUE
      state.pop_evaluation_stack
    when :POP_TUNNEL, :POP_FUNCTION
      # Tunnel onwards is allowed to specify an optional override divert
      # to go to immediately after returning: ->-> target
      override_tunnel_return_target = nil

      if element.command_type == :POP_TUNNEL
        pop_type = PushPopType::TYPES[:tunnel]
      elsif element.command_type == :POP_FUNCTION
        pop_type = PushPopType::TYPES[:function]
      end

      if pop_type == PushPopType::TYPES[:tunnel]
        override_tunnel_return_target = state.pop_evaluation_stack
        if !override_tunnel_return_target.is_a?(DivertTargetValue)
          assert!(override_tunnel_return_target.is_a?(Void), "Expected void if ->-> doesn't override target")
        end
      end

      if state.exit_function_evaluation_from_game?
        :NOOP
      elsif state.callstack.current_element.type != pop_type || !state.callstack.can_pop?
        types = {
          PushPopType::TYPES[:function] => "function return statement (~return)",
          PushPopType::TYPES[:tunnel] => "tunnel onwards statement (->->)"
        }

        expected = types[state.callstack.current_element.type]

        if !state.callstack.can_pop?
          expected = "end of flow (-> END or choice)"
        end

        add_error!("Found #{types[state.callstack.current_element.type]}, when expected #{expected}")
      else
        state.pop_callstack

        # does tunnel onwards override by diverting to a new ->-> target?
        if override_tunnel_return_target.is_a?(DivertTargetValue)
          state.diverted_pointer = pointer_at_path(override_tunnel_return_target.target_path)
        end
      end
    when :BEGIN_STRING_EVALUATION_MODE
      state.push_to_output_stream(element)
      assert!(state.in_expression_evaluation?, "Expected to be in an expression when evaluating a string")
      state.in_expression_evaluation = false
    when :END_STRING_EVALUATION_MODE
      content_stack_for_string = []
      item_from_output_stream = nil
      while !ControlCommand.is_instance_of?(item_from_output_stream, :BEGIN_STRING_EVALUATION_MODE)
        item_from_output_stream = state.pop_from_output_stream
        if item_from_output_stream.is_a?(StringValue)
          content_stack_for_string << item_from_output_stream
        end
      end

      #return to expression evaluation (from content mode)
      state.in_expression_evaluation = true
      state.push_evaluation_stack(StringValue.new(content_stack_for_string.reverse.join.to_s))
    when :PUSH_CHOICE_COUNT
      state.push_evaluation_stack(IntValue.new(state.generated_choices.size))
    when :TURNS
      state.push_evaluation_stack(IntValue.new(state.current_turn_index + 1))
    when :TURNS_SINCE, :READ_COUNT
      target = state.pop_evaluation_stack
      if !target.is_a?(DivertTargetValue)
        extra_note =""
        if value.is_a?(Numeric)
          extra_note = ". Did you accidentally pass a read count ('knot_name') instead of a target ('-> knot_name')?"
        end
        add_error("TURNS SINCE expected a divert target (knot, stitch, label name), but saw #{target}#{extra_note}")
      end

      container = content_at_path(target.target_path).container

      if !container.nil?
        if element.command_type == :TURNS_SINCE
          count = state.turns_since_for_container(container)
        else
          count = state.visit_count_for_container(container)
        end
      else
        if element.command_type == :TURNS_SINCE
          count = -1 #turn count, default to never/unknown
        else
          count = 0 #visit count, assume 0 to default to allowing entry
        end

        warning("Failed to find container for #{element} lookup at #{target.target}")
      end

      state.push_evaluation_stack(IntValue.new(count))
    when :RANDOM
      max_int = state.pop_evaluation_stack
      min_int = state.pop_evaluation_stack

      if !min_int.is_a?(Numeric)
        add_error!("Invalid value for minimum parameter of RANDOM(min, max)")
      end

      if !max_int.is_a?(Numeric)
        add_error!("Invalid value for maximum parameter of RANDOM(min, max)")
      end

      if min_int > max_int
        add_error!("RANDOM was called with minimum as #{min_int} and maximum as #{max_int}. The maximum must be larger")
      end

      result_seed = state.story_seed + state.previous_random
      random = new Random(result_seed)

      next_random = random.rand(min_int, max_int)
      state.push_evaluation_stack(IntValue.new(next_random))
      # next random number, rather than keeping the random object around
      state.previous_random = next_random
    when :SEED_RANDOM
      seed = state.pop_evaluation_stack
      if seed.nil?
        error!("Invalid value passed to SEED_RANDOM")
      end

      # Story seed affects both RANDOM & shuffle behavior
      state.story_seed = seed
      state.previous_random = 0

      # SEED_RANDOM returns nothing
      state.push_evaluation_stack(Void.new)
    when :VISIT_INDEX
      count = state.visit_count_for_container(state.current_pointer.container).value - 1
      state.push_evaluation_stack(IntValue.new(count))
    when :SEQUENCE_SHUFFLE_INDEX
      state.push_evaluation_stack(IntValue.new(next_sequence_shuffle_index))
    when :START_THREAD
      :NOOP #handled in main step function
    when :DONE
      # we may exist in the context of the initial act of creating
      # the thread, or in the context of evaluating the content
      if state.callstack.can_pop_thread?
        state.callstack.pop_thread!
      else
        # in normal flow, allow safe exit without warning
        state.did_safe_exit = true
        # stop flow in the current thread
        state.current_pointer = Pointer.null_pointer
      end
    when :STORY_END
      state.force_end!
    when :LIST_FROM_INT
      integer_value = state.pop_evaluation_stack
      list_name = state.pop_evaluation_stack

      if integer_value.nil?
        raise StoryError, "Passed non-integer when creating a list element from a numerical value."
      end

      if list_definitions.find_list(list_name.value)
        state.push_evaluation_stack(list_definitions.find_list(list_name.value).item_for_value(integer_value.value))
      else
        raise StoryError, "Failed to find LIST called #{list_name}"
      end
    when :LIST_RANGE
      max = state.pop_evaluation_stack.value
      min = state.pop_evaluation_stack.value
      target_list = state.pop_evaluation_stack.value

      if target_list.nil? || min.nil? || max.nil?
        raise StoryError, "Expected list, minimum, and maximum for LIST_RANGE"
      end

      state.push_evaluation_stack(ListValue.new(target_list.list_with_subrange(min, max)))
    when :LIST_RANDOM
      list_value = state.pop_evaluation_stack
      list = list_value.value

      if list.nil?
        raise StoryError, "Expected list for LIST_RANDOM"
      end

      # list was empty, return empty list
      if list.count == 0
        new_list = InkList.new
      else
        #non-empty source list
        result_seed = state.story_seed.value + state.previous_random
        random = Random.new(result_seed)
        list_item_index = random.rand(list.count)

        random_item_pair = list.list.to_a[list_item_index]

        # Origin list is simply the origin of the one element
        new_list = InkList.new_for_origin_definition_and_story(random_item_pair[0].origin_name, self)
        new_list.list[random_item_pair[0]] = random_item_pair[1]

        state.previous_random = list_item_index
      end

      state.push_evaluation_stack(ListValue.new(new_list))
    else
      add_error!("unhandled Control Command #{element}")
    end

    return true
  end

  # variable handling
  case element
  when VariableAssignment
    state.variables_state.assign(element, state.pop_evaluation_stack)
    return true
  when VariableReference
    if !element.path_for_count.nil?
      count = state.visit_count_for_container(element.container_for_count)
      found_value = count
    else
      found_value = state.variables_state.get_variable_with_name(element.name)

      if found_value.nil?
        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.");
        found_value = 0
      end
    end

    state.push_evaluation_stack(found_value)
    return true
  end

  if element.is_a?(NativeFunctionCall)
    parameters = []
    element.number_of_parameters.times{ parameters << state.pop_evaluation_stack }

    state.push_evaluation_stack(element.call!(parameters))
    return true
  end

  # no control content, so much be ordinary content
  return false
end

#pointer_at_path(path) ⇒ Object



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/fable/story.rb', line 151

def pointer_at_path(path)
  return Pointer.null_pointer if path.empty?

  path_length_to_use = path.length
  if path.components.last.is_index?
    path_length_to_use = path.length - 1
    result = main_content_container.content_at_path(path, partial_path_start: 0, partial_path_length: path_length_to_use)
    new_pointer_container = result.container
    new_pointer_index = path.components.last.index
    new_pointer = Pointer.new(new_pointer_container, new_pointer_index)
  else
    result = main_content_container.content_at_path(path)
    new_pointer = Pointer.new(result.container, -1)
  end

  if result.object.nil? || (result.object == main_content_container && path_length_to_use > 0)
    raise StoryError, "Failed to find content at path '#{path.components_string}', and no approximation was possible."
  elsif result.approximate?
    warning("Failed to find content at path '#{path.components_string}', so it was approximated to '#{result.object.path.components_string}'")
  end

  return new_pointer
end

#process_choice(choice_point) ⇒ Object



509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
# File 'lib/fable/story.rb', line 509

def process_choice(choice_point)
  show_choice = true

  # don't create choice if it doesn't pass the conditional
  if choice_point.has_condition?
    condition_value = state.pop_evaluation_stack
    if !condition_value.truthy?
      show_choice = false
    end
  end

  start_text = ""
  choice_only_text = ""

  if choice_point.has_choice_only_content?
    choice_only_text = state.pop_evaluation_stack
  end

  if choice_point.has_start_content?
    start_text = state.pop_evaluation_stack
  end

  # Don't create the choice if the player has aready read this content
  if choice_point.once_only?
    if state.visit_count_for_container(choice_point.choice_target).value > 0
      show_choice = false
    end
  end


  # We go through the whole process of creating the choice above so
  # that we consume the content for it, since otherwise it'll be
  # shown on the output stream
  return nil if !show_choice

  choice = Choice.new
  choice.target_path = choice_point.path_on_choice
  choice.source_path = choice_point.path.to_s
  choice.invisible_default = choice_point.invisible_default?

  # We need to capture the state of the callstack at the point where
  # the choice was generated, since after the generation of this choice
  # we may go on to pop out from a tunnel (possible if the choice was
  # wrapped in a conditional), or we may pop out from a thread, at which
  # point that thread is discarded. Fork clones the thread, gives it a new
  # ID, but without affecting the thread stack itself
  choice.thread_at_generation = state.callstack.fork_thread!

  # set the final text for the choice
  choice.text = "#{start_text}#{choice_only_text}".strip

  return choice
end

#profile?Boolean

Returns:

  • (Boolean)


110
111
112
# File 'lib/fable/story.rb', line 110

def profile?
  !self.profiler.nil?
end

#remove_variable_observer(variable_name, &block) ⇒ Object



969
970
971
# File 'lib/fable/story.rb', line 969

def remove_variable_observer(variable_name, &block)
  self.variables_state.remove_variable_observer(variable_name, &block)
end

#reset_callstack!Object



127
128
129
# File 'lib/fable/story.rb', line 127

def reset_callstack!
  state.force_end!
end

#reset_errors!Object



123
124
125
# File 'lib/fable/story.rb', line 123

def reset_errors!
  state.reset_errors!
end

#reset_globals!Object



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/fable/story.rb', line 131

def reset_globals!
  if !global_declaration.nil?
    original_pointer = state.current_pointer
    choose_path(Path.new("global decl"), {incrementing_turn_index: false})

    internal_continue
    state.current_pointer = original_pointer
  end

  self.state.variables_state.snapshot_default_globals
end

#reset_state!Object



118
119
120
121
# File 'lib/fable/story.rb', line 118

def reset_state!
  self.state = StoryState.new(self)
  reset_globals!
end

#restore_state_snapshot!Object



184
185
186
187
188
189
190
191
192
193
194
# File 'lib/fable/story.rb', line 184

def restore_state_snapshot!
  # Patched state had temporarily hijacked our variables_state and
  # set its own callstack on it, so we need to restore that
  # If we're in the middle of saving, we may also need to give the
  # variables_state the old patch

  @state_snapshot_at_last_newline.restore_after_patch!
  self.state = @state_snapshot_at_last_newline
  @state_snapshot_at_last_newline = nil
  self.state.apply_any_patch!
end

#start_profilingObject



102
103
104
# File 'lib/fable/story.rb', line 102

def start_profiling
  self.profiler = Profiler.new
end

#state_snapshot!Object

Maximum Snapshot stack:

  • @state_snapshot_during_save – not retained, but returned to game code

  • @state_snapshot_at_last_newline (has older patch)

  • @state (current, being patched)



179
180
181
182
# File 'lib/fable/story.rb', line 179

def state_snapshot!
  @state_snapshot_at_last_newline = self.state
  self.state = state.copy_and_start_patching!
end

#step!Object



346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'lib/fable/story.rb', line 346

def step!
  should_add_to_stream = true

  # Get current content
  pointer = state.current_pointer
  return if pointer.null_pointer?


  # Step directly into the first element of content in a container (if necessary)
  container_to_enter = pointer.resolve!
  while container_to_enter.is_a?(Container)
    # Mark container as being entered
    visit_container!(container_to_enter, at_start: true)

    # no content? the most we can do is step past it
    break if container_to_enter.content.empty?

    pointer = Pointer.start_of(container_to_enter)
    container_to_enter = pointer.resolve!
  end

  state.current_pointer = pointer

  profiler.step!(state.callstack) if profile?
  # is the current content object:
  # - normal content
  # - or a logic/flow statement? If so, do it
  # Stop flow if we hit a stack pop when we're unable to pop
  # (e.g: return/done statement in knot that was diverted to
  # rather than called as a function)
  current_content_object = pointer.resolve!
  is_logic_or_flow_content = perform_logic_and_flow_control(current_content_object)

  # Has flow been forced to end by flow control above?
  if state.current_pointer.null_pointer?
    return
  end

  if is_logic_or_flow_content
    should_add_to_stream = false
  end

  # Is choice with condition?
  if current_content_object.is_a?(ChoicePoint)
    choice = process_choice(current_content_object)
    if !choice.nil?
      state.generated_choices << choice
    end

    current_content_object = nil
    should_add_to_stream = false
  end

  # If the container has no content, then it will be the "content"
  # itself, but we skip over it
  if current_content_object.is_a?(Container)
    should_add_to_stream = false
  end

  # content to add to the evaluation stack or output stream
  if should_add_to_stream
    # If we're pushing a variable pointer onto the evaluation stack,
    # ensure that it's specific to our current (and possibly temporary)
    # context index. And make a copy of the pointer so that we're not
    # editing the original runtime object
    if current_content_object.is_a?(VariablePointerValue)
      variable_pointer = current_content_object
      if variable_pointer.context_index == -1
        # create a new object so we're not overwriting the story's own data
        context_index = state.callstack.context_for_variable_named(variable_pointer.variable_name)
        current_content_object = VariablePointerValue.new(variable_pointer.variable_name, context_index)
      end
    end

    # expression evaluation content
    if state.in_expression_evaluation?
      state.push_evaluation_stack(current_content_object)
    else
      # output stream content
      state.push_to_output_stream(current_content_object)
    end
  end

  # Increment the content pointer, following diverts if necessary
  next_content!

  # Starting a thread should be done after the increment to the
  # content pointer, so that when returning from the thread, it
  # returns to the content after this instruction
  if ControlCommand.is_instance_of?(current_content_object, :START_THREAD)
    state.callstack.push_thread!
  end
end

#stop_profilingObject



106
107
108
# File 'lib/fable/story.rb', line 106

def stop_profiling
  self.profiler = nil
end

#tags_at_start_of_flow_container_with_path_string(path_string) ⇒ Object



1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
# File 'lib/fable/story.rb', line 1155

def tags_at_start_of_flow_container_with_path_string(path_string)
  path = Path.new(path_string)

  # Expected to be global story, knot or stitch
  flow_container = content_at_path(path).container

  while true
    first_content = flow_container.content.first
    if first_content.is_a?(Container)
      flow_container = first_content
    else
      break
    end
  end

  # Any initial tag objects count as the "main tags" associated with
  # that story/knot/stitch
  tags_to_return = []
  flow_container.content.each do |item|
    if item.is_a?(Tag)
      tags_to_return << item.text
    else
      break
    end
  end

  return tags_to_return
end

#tags_for_content_at_path(path) ⇒ Object



1151
1152
1153
# File 'lib/fable/story.rb', line 1151

def tags_for_content_at_path(path)
  tags_at_start_of_flow_container_with_path_string(path)
end

#try_following_default_invisible_choiceObject



1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
# File 'lib/fable/story.rb', line 1288

def try_following_default_invisible_choice
  all_choices = state.current_choices

  # Is a default invisible choice the ONLY choice?
  invisible_choices = all_choices.select{|choice| choice.invisible_default?}
  if invisible_choices.empty? || all_choices.size > invisible_choices.size
    return false
  end

  choice = invisible_choices[0]

  # Invisible choice may have been generate on a different thread, in which
  # case we need to restore it before we continue
  state.callstack.current_thread = choice.thread_at_generation

  # If there's a chance that this state will be rolled back before the
  # invisible choice then make sure that the choice thread is left intact,
  # and it isn't re-entered in an old state
  if !@state_snapshot_at_last_newline.nil?
    state.callstack.current_thread = state.callstack.fork_thread!
  end

  choose_path(choice.target_path, incrementing_turn_index: false)

  return true
end

#unbind_external_function(function_name) ⇒ Object



1072
1073
1074
1075
1076
1077
1078
# File 'lib/fable/story.rb', line 1072

def unbind_external_function(function_name)
  if !external_functions.has_key?(function_name)
    raise StoryError, "Function #{function_name} has not been bound."
  end

  external_functions.delete(function_name)
end

#validate_external_bindings!Object

Check that all EXTERNAL ink functions have a valid function. Note that this will automatically be called on the first call to continue



1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
# File 'lib/fable/story.rb', line 1082

def validate_external_bindings!
  missing_externals = missing_external_bindings(main_content_container)

  has_validated_externals = true

  if missing_externals.empty?
    return true
  else
    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)'}")
  end
end

#variables_stateObject



80
81
82
# File 'lib/fable/story.rb', line 80

def variables_state
  return self.state.variables_state
end

#visit_changed_containers_due_to_divertObject



454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
# File 'lib/fable/story.rb', line 454

def visit_changed_containers_due_to_divert
  previous_pointer = state.previous_pointer
  pointer = state.current_pointer

  # Unless we're pointing directly at a piece of content, we don't do
  # counting here. Otherwise, the main stepping function will do the
  # counting

  return if pointer.null_pointer? || pointer.index == -1

  # First, find the previously open set of containers
  @previous_containers = []
  if !previous_pointer.null_pointer?
    previous_ancestor = previous_pointer.resolve! || previous_pointer.container
    while !previous_ancestor.nil?
      @previous_containers << previous_ancestor
      previous_ancestor = previous_ancestor.parent
    end
  end

  # If the new object is a container itself, it will be visted
  # automatically at the next actual content step. However, we need to walk
  # up the new ancestry to see if there are more new containers
  current_child_of_container = pointer.resolve!

  return if current_child_of_container.nil?

  current_container_ancestor = current_child_of_container.parent

  all_children_entered_at_start = true
  while !current_container_ancestor.nil? && (!@previous_containers.include?(current_container_ancestor) || current_container_ancestor.counting_at_start_only?)
    # check whether this ancestor container is being entered at the start
    # by checking whether the child object is the first

    entering_at_start = (
      current_container_ancestor.content.size > 0 &&
      current_child_of_container == current_container_ancestor.content[0] &&
      all_children_entered_at_start
    )

    # Don't count it as entering at start if we're entering randomly
    # somewhere within a Container B that happens to be nexted at index 0
    # of Container A. It only counts if we're diverting directly to the
    # first leaf node

    all_children_entered_at_start = false if !entering_at_start

    # Mark a visit to this container
    visit_container!(current_container_ancestor, at_start: entering_at_start)

    current_child_of_container = current_container_ancestor
    current_container_ancestor = current_container_ancestor.parent
  end
end

#visit_container!(container, options) ⇒ Object



440
441
442
443
444
445
446
447
448
449
450
451
452
# File 'lib/fable/story.rb', line 440

def visit_container!(container, options)
  at_start = options[:at_start]

  if !container.counting_at_start_only? || at_start
    if container.visits_should_be_counted?
      state.increment_visit_count_for_container!(container)
    end

    if container.turn_index_should_be_counted?
      state.record_turn_index_visit_to_container!(container)
    end
  end
end

#warning(message) ⇒ Object



1358
1359
1360
# File 'lib/fable/story.rb', line 1358

def warning(message)
  add_error!(message, is_warning: true)
end