class Fable::Story

Constants

CURRENT_INK_VERSION
MINIMUM_COMPATIBLE_INK_VERSION

Attributes

allow_external_function_fallbacks[RW]
allow_external_function_fallbacks?[RW]
external_functions[RW]
list_definitions[RW]
main_content_container[RW]
on_choose_path_string[RW]
on_complete_evaluate_function[RW]
on_evaluate_function[RW]
on_make_choice[RW]
original_object[RW]
profiler[RW]
state[RW]

Public Class Methods

new(original_object) click to toggle source
Calls superclass method Fable::RuntimeObject::new
# 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

Public Instance Methods

add_error!(message, options = {is_warning: false, use_end_line_number: false}) click to toggle source
# File lib/fable/story.rb, line 1362
def add_error!(message, options = {is_warning: false, use_end_line_number: false})
  debug_metadata = current_debug_metadata

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

  if !debug_metadata.nil?
    line_number = options[:use_end_line_number] ? debug_metadata.end_line_number : debug_metadata.start_line_number
    message = "RUNTIME #{error_type_string}: '#{debug_metadata.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) click to toggle source
# File lib/fable/story.rb, line 1384
def assert!(condition, message = nil)
  if !condition
    if message.nil?
      message = "Story assert"
    end

    raise StoryError, "#{message} #{current_debug_metadata}"
  end
end
bind_external_function(function_name, &block) click to toggle source
# 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) click to toggle source
# 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_hierarchy() click to toggle source

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.

# 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) click to toggle source
# 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) click to toggle source
# 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?() click to toggle source
# File lib/fable/story.rb, line 94
def can_continue?
  state.can_continue?
end
choose_choice_index(choice_index) click to toggle source

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.

# 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) click to toggle source
# 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 = []) click to toggle source

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.

# 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) click to toggle source
# File lib/fable/story.rb, line 143
def content_at_path(path)
  main_content_container.content_at_path(path)
end
continue(&block) click to toggle source
# File lib/fable/story.rb, line 44
def continue(&block)
  validate_external_bindings!
  internal_continue(&block)
  return current_text
end
continue_maximially(&block) click to toggle source
# 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!() click to toggle source
# 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?() click to toggle source
# 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_choices() click to toggle source
# File lib/fable/story.rb, line 60
def current_choices
  return self.state.current_choices.select{|choice| !choice.invisible_default? }
end
current_debug_metadata() click to toggle source
# File lib/fable/story.rb, line 1394
def current_debug_metadata
  pointer = state.current_pointer

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

  # Move up callstack if possible
  state.callstack.elements.each do |element|
    pointer = element.current_pointer
    if !pointer.null_pointer? && pointer.resolve! != nil
      debug_metadata = pointer.resolve!.debug_metadata
      return debug_metadata if !debug_metadata.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.debug_metadata if !item.debug_metadata.nil?
  end

  return nil
end
current_errors() click to toggle source
# File lib/fable/story.rb, line 72
def current_errors
  return self.state.current_errors
end
current_line_number() click to toggle source
# File lib/fable/story.rb, line 1421
def current_line_number
  debug_metadata = current_debug_metadata
  return 0 if debug_metadata.nil?
  return debug_metadata.start_line_number
end
current_tags() click to toggle source
# File lib/fable/story.rb, line 68
def current_tags
  return self.state.current_tags
end
current_text() click to toggle source
# File lib/fable/story.rb, line 64
def current_text
  return self.state.current_text
end
current_warnings() click to toggle source
# File lib/fable/story.rb, line 76
def current_warnings
  return self.state.current_warnings
end
discard_snapshot!() click to toggle source
# 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}) click to toggle source
# 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) click to toggle source

Evaluates a function defined in ink

# 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_declaration() click to toggle source
# File lib/fable/story.rb, line 114
def global_declaration
  self.main_content_container.named_content["global decl"]
end
global_tags() click to toggle source
# File lib/fable/story.rb, line 1147
def global_tags
  tags_at_start_of_flow_container_with_path_string("")
end
has_errors?() click to toggle source
# File lib/fable/story.rb, line 84
def has_errors?
  return false if current_errors.nil?
  current_errors.any?
end
has_function?(function_name) click to toggle source
# File lib/fable/story.rb, line 973
def has_function?(function_name)
  !knot_container_with_name(function_name).nil?
end
has_warnings?() click to toggle source
# File lib/fable/story.rb, line 89
def has_warnings?
  return false if current_warnings.nil?
  current_warnings.any?
end
increment_content_pointer() click to toggle source
# 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_version() click to toggle source
# File lib/fable/story.rb, line 98
def ink_version
  original_object["inkVersion"]
end
internal_continue(&block) click to toggle source
# 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) click to toggle source
# File lib/fable/story.rb, line 147
def knot_container_with_name(name)
  main_content_container.named_content[name]
end
missing_external_bindings(container) click to toggle source
# 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!() click to toggle source
# 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_index() click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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.debug_metadata.source_name
        add_error!("Divert target doesn't exist: #{element.debug_metadata.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) click to toggle source
# 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) click to toggle source
# 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?() click to toggle source
# File lib/fable/story.rb, line 110
def profile?
  !self.profiler.nil?
end
remove_variable_observer(variable_name, &block) click to toggle source
# 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!() click to toggle source
# File lib/fable/story.rb, line 127
def reset_callstack!
  state.force_end!
end
reset_errors!() click to toggle source
# File lib/fable/story.rb, line 123
def reset_errors!
  state.reset_errors!
end
reset_globals!() click to toggle source
# 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!() click to toggle source
# File lib/fable/story.rb, line 118
def reset_state!
  self.state = StoryState.new(self)
  reset_globals!
end
restore_state_snapshot!() click to toggle source
# 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_profiling() click to toggle source
# File lib/fable/story.rb, line 102
def start_profiling
  self.profiler = Profiler.new
end
state_snapshot!() click to toggle source

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)

# 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!() click to toggle source
# 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_profiling() click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# 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_choice() click to toggle source
# 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) click to toggle source
# 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!() click to toggle source

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

# 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_state() click to toggle source
# File lib/fable/story.rb, line 80
def variables_state
  return self.state.variables_state
end
visit_changed_containers_due_to_divert() click to toggle source
# 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) click to toggle source
# 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) click to toggle source
# File lib/fable/story.rb, line 1358
def warning(message)
  add_error!(message, is_warning: true)
end