module Fable
# All story state information is included in the StoryState class,
# including global variables, read counts, the pointer to the current
# point in the story, the call stack (for tunnels, functions, etc),
# and a few other smaller bits and pieces. You can save the current
# state using the serialization functions
class StoryState
CURRENT_INK_SAVE_STATE_VERSION = 8
MINIMUM_COMPATIBLE_INK_LOAD_VERSION = 8
MULTIPLE_WHITESPACE_REGEX = /[ \t]{2,}/
attr_accessor :patch, :output_stream, :current_choices,
:current_errors, :current_warnings, :callstack,
:evaluation_stack, :diverted_pointer,
:current_turn_index, :story_seed, :previous_random,
:did_safe_exit, :story, :variables_state,
:current_text, :output_stream_text_dirty,
:current_tags, :output_stream_tags_dirty,
:visit_counts, :turn_indicies
alias_method :did_safe_exit?, :did_safe_exit
def initialize(story)
self.story = story
self.output_stream = []
self.output_stream_dirty!
self.evaluation_stack = []
self.callstack = CallStack.new(story)
self.variables_state = VariablesState.new(callstack, story.list_definitions)
self.visit_counts = {}
self.turn_indicies = {}
self.current_turn_index = -1
# Seed the shuffle random numbers
time_seed = Time.now.to_r * 1_000.0
self.story_seed = IntValue.new(Random.new(time_seed).rand(100))
self.previous_random = 0
self.current_choices = []
self.diverted_pointer = Pointer.null_pointer
self.current_pointer = Pointer.null_pointer
self.go_to_start!
end
#
# Gets the visit/read count of a particular Container at the given path.
# For a knot or stitch, that path string will be in the form:
#
# knot
# knot.stitch
#
#
# The number of times the specific knot or stitch has
# been enountered by the ink engine.
# The dot-separated path string of
# the specific knot or stitch.
def visit_count_at_path_string(path_string)
if has_patch?
container = story.content_at_path(Path.new(path_string)).container
if container.nil?
raise Error, "Content at path not found: #{path_string}"
end
if patch.get_visit_count(container)
return patch.get_visit_count(container)
end
end
return visit_counts[path_string] || 0
end
def visit_count_for_container(container)
if !container.visits_should_be_counted?
story.add_error!("Read count for target (#{container.name} - on #{container.debug_metadata}) unknown.")
return IntValue.new(0)
end
if has_patch? && patch.get_visit_count(container)
return IntValue.new(patch.get_visit_count(container))
end
container_path_string = container.path.to_s
return IntValue.new(visit_counts[container_path_string] || 0)
end
def increment_visit_count_for_container!(container)
if has_patch?
current_count = visit_count_for_container(container)
patch.set_visit_count(container, current_count.value + 1)
return
end
container_path_string = container.path.to_s
count = (visit_counts[container_path_string] || 0)
count += 1
visit_counts[container_path_string] = count
end
def record_turn_index_visit_to_container!(container)
if has_patch?
patch.set_turn_index(container, current_turn_index)
return
end
container_path_string = container.path.to_s
turn_indicies[container_path_string] = current_turn_index
end
def turns_since_for_container(container)
if !container.turn_index_should_be_counted?
story.add_error!("TURNS_SINCE() for target (#{container.name}) - on #{container.debug_metadata}) unknown.")
end
if has_patch? && patch.get_turn_index(container)
return (current_turn_index - patch.get_turn_index(container))
end
container_path_string = container.path.to_s
if turn_indicies[container_path_string]
return current_turn_index - turn_indicies[container_path_string]
else
return -1
end
end
def callstack_depth
callstack.depth
end
def current_choices
# If we can continue generating text content rather than choices,
# then we reflect the choice list as being empty, since choices
# should always come at the end.
return [] if can_continue?
return @current_choices
end
def generated_choices
return @current_choices
end
def current_path_string
if current_pointer.null_pointer?
return nil
else
return current_pointer.path.to_s
end
end
def current_pointer
callstack.current_element.current_pointer
end
def current_pointer=(value)
callstack.current_element.current_pointer = value
end
def previous_pointer
callstack.current_thread.previous_pointer
end
def previous_pointer=(value)
callstack.current_thread.previous_pointer = value
end
def can_continue?
!current_pointer.null_pointer? && !has_error?
end
def has_error?
!current_errors.nil? && current_errors.size > 0
end
def has_warning?
!current_warnings.nil? && current_warnings.size > 0
end
def current_text
if @output_stream_text_dirty
text_content = output_stream.select{|x| x.is_a?(StringValue)}.map(&:value).join
@current_text = clean_output_whitespace(text_content)
@output_stream_text_dirty = false
end
return @current_text
end
def current_tags
if @output_stream_tags_dirty
@current_tags = output_stream.select{|x| x.is_a?(Tag)}.map(&:text)
@output_stream_tags_dirty = false
end
return @current_tags
end
def in_expression_evaluation?
callstack.current_element.in_expression_evaluation?
end
def in_expression_evaluation=(value)
callstack.current_element.in_expression_evaluation = value
end
def in_string_evaluation?
@output_stream.reverse_each.any? do |item|
item.is_a?(ControlCommand) && item.command_type == :BEGIN_STRING_EVALUATION_MODE
end
end
def push_evaluation_stack(object)
# include metadata about the origin List for list values when they're used
# so that lower-level functions can make sure of the origin list to get
# Related items, or make comparisons with integer values
if object.is_a?(ListValue)
# Update origin when list has something to indicate the list origin
raw_list = object.value
if !raw_list.origin_names.nil?
if raw_list.origins.nil?
raw_list.origins = []
end
raw_list.origins.clear
raw_list.origin_names.each do |name|
list_definition = story.list_definitions.find_list(name)
if !raw_list.origins.include?(list_definition)
raw_list.origins << list_definition
end
end
end
end
evaluation_stack << object
end
def pop_evaluation_stack(number_of_items = nil)
if number_of_items.nil?
return evaluation_stack.pop
end
if number_of_items > evaluation_stack.size
raise Error, "trying to pop too many objects"
end
return evaluation_stack.pop(number_of_items)
end
def peek_evaluation_stack
return evaluation_stack.last
end
#
# Ends the current ink flow, unwrapping the callstack but without
# affecting any variables. Useful if the ink is (say) in the middle
# a nested tunnel, and you want it to reset so that you can divert
# elsewhere using choose_path_string. Otherwise, after finishing
# the content you diverted to, it would continue where it left off.
# Calling this is equivalent to calling -> END in ink.
#
def force_end!
callstack.reset!
@current_choices.clear
self.current_pointer = Pointer.null_pointer
self.previous_pointer = Pointer.null_pointer
self.did_safe_exit = true
end
# At the end of a function call, trim any whitespace from the end.
# We always trim the start and end of the text that a function produces.
# The start whitespace is discard as it is generated, and the end
# whitespace is trimmed in one go here when we pop the function.
def trim_whitespace_from_function_end!
assert!(callstack.current_element.type == PushPopType::TYPES[:function])
function_start_point = callstack.current_element.function_start_in_output_stream
# if the start point has become -1, it means that some non-whitespace
# text has been pushed, so it's safe to go as far back as we're able
if function_start_point == -1
function_start_point = 0
end
i = @output_stream.count - 1
# Trim whitespace from END of function call
while i >= function_start_point
object = output_stream[i]
break if object.is_a?(ControlCommand)
next if !object.is_a?(StringValue)
if object.is_newline? || object.is_inline_whitespace?
@output_stream.delete_at(i)
output_stream_dirty!
else
break
end
i -= 1
end
end
def pop_callstack(pop_type=nil)
# At the end of a function call, trim any whitespace from the end
if callstack.current_element.type == PushPopType::TYPES[:function]
trim_whitespace_from_function_end!
end
callstack.pop!(pop_type)
end
def pass_arguments_to_evaluation_stack(arguments)
if !arguments.nil?
arguments.each do |argument|
if !(argument.is_a?(Numeric) || argument.is_a?(String) || argument.is_a?(InkList))
raise ArgumentError, "ink arguments when calling evaluate_function/choose_path_string_with_parameters must be int, float, string, or InkList. Argument was #{argument.class.to_s}"
end
push_evaluation_stack(Value.create(argument))
end
end
end
def start_function_evaluation_from_game(function_container, arguments)
callstack.push(PushPopType::TYPES[:function_evaluation_from_game], output_stream_length_when_pushed: evaluation_stack.size)
callstack.current_element.current_pointer = Pointer.start_of(function_container)
pass_arguments_to_evaluation_stack(arguments)
end
def exit_function_evaluation_from_game?
if callstack.current_element.type == PushPopType::TYPES[:function_evaluation_from_game]
self.current_pointer = Pointer.null_pointer
self.did_safe_exit = true
return true
end
return false
end
def complete_function_evaluation_from_game
if callstack.current_element.type != PushPopType::TYPES[:function_evaluation_from_game]
raise Error, "Expected external function evaluation to be complete. Stack trace: #{callstack.call_stack_trace}"
end
original_evaluation_stack_height = callstack.current_element.evaluation_stack_height_when_pushed
# do we have a returned value?
# Potentially pop multiple values off the stack, in case we need to clean up after ourselves
# (e.g: caller of evaluate_function may have passed too many arguments, and we currently have no way
# to check for that)
returned_object = nil
while evaluation_stack.size > original_evaluation_stack_height
popped_object = pop_evaluation_stack
if returned_object.nil?
returned_object = popped_object
end
end
# Finally, pop the external function evaluation
pop_callstack(PushPopType::TYPES[:function_evaluation_from_game])
# What did we get back?
if !returned_object.nil?
if returned_object.is_a?(Void)
return nil
end
# DivertTargets get returned as the string of components
# (rather than a Path, which isn't public)
if returned_object.is_a?(DivertTargetValue)
return returned_object.value_object.to_s
end
# Other types can just have their exact object type.
# VariablePointers get returned as strings.
return returned_object.value_object
end
return nil
end
def output_stream_dirty!
@output_stream_text_dirty = true
@output_stream_tags_dirty = true
end
def go_to_start!
callstack.current_element.current_pointer = Pointer.start_of(story.main_content_container)
end
# Cleans inline whitespace in the following way:
# - Removes all whitespace from the start/end of line (including just before an \n)
# - Turns all consecutive tabs & space runs into single spaces (HTML-style)
def clean_output_whitespace(string)
x = ""
current_whitespace_start = -1
start_of_line = 0
string.each_char.with_index do |character, i|
is_inline_whitespace = (character == " " || character == "\t")
if is_inline_whitespace && current_whitespace_start == -1
current_whitespace_start = i
end
if !is_inline_whitespace
if(character != "\n" && (current_whitespace_start > 0) && current_whitespace_start != start_of_line)
x += " "
end
current_whitespace_start = -1
end
if character == "\n"
start_of_line = i + 1
end
if !is_inline_whitespace
x << character
end
end
return x
# x = string.each_line(chomp: true).map do |line|
# if line.empty?
# nil
# else
# line.strip.gsub(MULTIPLE_WHITESPACE_REGEX, ' ') + "\n"
# end
# end
# cleaned_string = x.compact.join("\n")
# cleaned_string
end
def has_patch?
!patch.nil?
end
# WARNING: Any RuntimeObject content referenced within the StoryState will be
# re-referenced rather than cloned. This is generally okay though, since
# RuntimeObjects are treated as immutable after they've been set up.
# (eg: We don't edit a StringValue after it's been created and added)
def copy_and_start_patching!
copy = self.class.new(story)
copy.patch = StatePatch.new(self.patch)
copy.output_stream += self.output_stream
copy.output_stream_dirty!
copy.current_choices += @current_choices
if has_error?
copy.current_errors = []
copy.current_errors += self.current_errors
end
if has_warning?
copy.current_warnings = []
copy.current_warnings += self.current_warnings
end
copy.callstack = CallStack.new(story).from_hash!(self.callstack.to_hash, story)
# reference copoy- exactly the same variable state!
# we're expected not to read it only while in patch mode
# (though the callstack will be modified)
copy.variables_state = self.variables_state
copy.variables_state.callstack = copy.callstack
copy.variables_state.patch = copy.patch
copy.evaluation_stack += self.evaluation_stack
if !self.diverted_pointer.null_pointer?
copy.diverted_pointer = self.diverted_pointer
end
copy.previous_pointer = self.previous_pointer
# Visit counts & turn indicies will be read-only, not modified
# while in patch mode
copy.visit_counts = self.visit_counts
copy.turn_indicies = self.turn_indicies
copy.current_turn_index = self.current_turn_index
copy.story_seed = self.story_seed
copy.previous_random = self.previous_random
copy.did_safe_exit = self.did_safe_exit
return copy
end
def restore_after_patch!
# VariablesState was being borrowed by the patched state, so restore it
# with our own callstack. patch will be nil normally, but if you're in the
# middle of a save, it may contain a patch for save purposes
variables_state.callstack = callstack
variables_state.patch = self.patch
end
def apply_any_patch!
return if self.patch.nil?
variables_state.apply_patch!
patch.visit_counts.each do |container, new_count|
self.visit_counts[container.path.to_s] = new_count
end
patch.turn_indicies.each do |container, new_count|
self.turn_indicies[container.path.to_s] = new_count
end
end
def reset_errors!
self.current_errors = nil
self.current_warnings = nil
end
def add_error(message, options = {is_warning: false})
if !options[:is_warning]
self.current_errors ||= []
self.current_errors << message
else
self.current_warnings ||= []
self.current_warnings << message
end
puts current_errors.inspect
puts current_warnings.inspect
end
def reset_output!(objects_to_add = nil)
self.output_stream = []
if !objects_to_add.nil?
self.output_stream += objects_to_add
end
output_stream_dirty!
end
def push_to_output_stream(object)
if object.is_a?(StringValue)
lines = try_splitting_head_tail_whitespace(object.value)
if !lines.nil?
lines.each do |line|
push_item_to_output_stream(line)
end
output_stream_dirty!
return
end
end
push_item_to_output_stream(object)
output_stream_dirty!
end
def pop_from_output_stream
results = output_stream.pop
output_stream_dirty!
return results
end
def push_item_to_output_stream(object)
include_in_output = true
case object
when Glue
# new glue, so chomp away any whitespace from the end of the stream
trim_newlines_from_output_stream!
include_in_output = true
when StringValue
# New text: do we really want to append it, if it's whitespace?
# Two different reasons for whitespace to be thrown away:
# - Function start/end trimming
# - User defined glue: <>
# We also need to know when to stop trimming, when there's no whitespace
# where does the current function call begin?
function_trim_index = -1
current_element = callstack.current_element
if current_element.type == PushPopType::TYPES[:function]
function_trim_index = current_element.function_start_in_output_stream
end
# Do 2 things:
# - Find latest glue
# - Check whether we're in the middle of string evaluation
# If we're in string evaluation within the current function, we don't want to
# trim back further than the length of the current string
glue_trim_index = -1
i = @output_stream.count - 1
while i >= 0
item_to_check = @output_stream[i]
if item_to_check.is_a?(Glue)
glue_trim_index = i
break
elsif ControlCommand.is_instance_of?(item_to_check, :BEGIN_STRING_EVALUATION_MODE)
if i >= function_trim_index
function_trim_index = -1
end
break
end
i -= 1
end
# Where is the most aggresive (earliest) trim point?
trim_index = -1
if glue_trim_index != -1 && function_trim_index != -1
trim_index = [glue_trim_index, function_trim_index].min
elsif glue_trim_index != -1
trim_index = glue_trim_index
else
trim_index = function_trim_index
end
# So, what are we trimming them?
if trim_index != -1
# While trimming, we want to throw all newlines away,
# Whether due to glue, or start of a function
if object.is_newline?
include_in_output = false
# Able to completely reset when normal text is pushed
elsif object.is_nonwhitespace?
if glue_trim_index > -1
remove_existing_glue!
end
# Tell all functionms in callstack that we have seen proper text,
# so trimming whitespace at the start is done
if function_trim_index > -1
callstack.elements.reverse_each do |element|
if element.type == PushPopType::TYPES[:function]
element.function_start_in_output_stream = -1
else
break
end
end
end
end
# De-duplicate newlines, and don't ever lead with a newline
elsif object.is_newline?
if output_stream_ends_in_newline? || !output_stream_contains_content?
include_in_output = false
end
end
end
if include_in_output
@output_stream << object
output_stream_dirty!
end
end
# At both the start and the end of the string, split out the new lines like so:
#
# " \n \n \n the string \n is awesome \n \n "
# ^-----------^ ^-------^
#
# Excess newlines are converted into single newlines, and spaces discarded.
# Outside spaces are significant and retained. "Interior" newlines within
# the main string are ignored, since this is for the purpose of gluing only.
#
# - If no splitting is necessary, null is returned.
# - A newline on its own is returned in a list for consistency.
def try_splitting_head_tail_whitespace(string)
head_first_newline_index = -1
head_last_newline_index = -1
string.each_char.each_with_index do |character, i|
if character == "\n"
if head_first_newline_index == -1
head_first_newline_index = i
end
head_last_newline_index = i
elsif character == " " || character == "\t"
next
else
break
end
end
tail_first_newline_index = -1
tail_last_newline_index = -1
string.reverse.each_char.each_with_index do |character, i|
if character == "\n"
if tail_last_newline_index == -1
tail_last_newline_index = i
end
tail_first_newline_index = i
elsif character == " " || character == "\t"
next
else
break
end
end
if head_first_newline_index == -1 && tail_last_newline_index == -1
return nil
end
list_texts = []
inner_string_start = 0
inner_string_end = string.length
if head_first_newline_index != -1
if head_first_newline_index > 0
leading_spaces = string[0, head_first_newline_index]
list_texts << leading_spaces
end
list_texts << "\n"
inner_string_start = head_last_newline_index + 1
end
if tail_last_newline_index != -1
inner_string_end = tail_first_newline_index
end
if inner_string_end > inner_string_start
inner_string_text = string[inner_string_start, (inner_string_end - inner_string_start)]
list_texts << inner_string_text
end
if tail_last_newline_index != -1 && tail_first_newline_index > head_last_newline_index
list_texts << "\n"
if tail_last_newline_index < (string.length -1)
number_of_spaces = (string.length - tail_last_newline_index) - 1
trailing_spaces = string[tail_last_newline_index + 1, number_of_spaces]
list_texts << trailing_spaces
end
end
return list_texts.map{|x| StringValue.new(x) }
end
def trim_newlines_from_output_stream!
remove_whitespace_from = -1
# Work back from the end, and try to find the point where we need to
# start removing content.
# - Simply work backwards to find the first newline in a string of whitespace
# e.g. This is the content \n \n\n
# ^---------^ whitespace to remove
# ^--- first while loop stops here
i = @output_stream.count - 1
while i >= 0
object = @output_stream[i]
if object.is_a?(ControlCommand) || (object.is_a?(StringValue) && object.is_nonwhitespace?)
break
elsif object.is_a?(StringValue) && object.is_newline?
remove_whitespace_from = i
end
i -= 1
end
# Remove the whitespace
if remove_whitespace_from >= 0
self.output_stream = self.output_stream[0..(remove_whitespace_from-1)]
end
output_stream_dirty!
end
# Only called when non-whitespace is appended
def remove_existing_glue!
@output_stream.each_with_index do |object, i|
if object.is_a?(Glue)
@output_stream.delete_at(i)
elsif object.is_a?(ControlCommand)
end
end
output_stream_dirty!
end
def output_stream_ends_in_newline?
return false if @output_stream.empty?
return @output_stream.last.is_a?(StringValue) && @output_stream.last.is_newline?
end
def output_stream_contains_content?
@output_stream.any?{|x| x.is_a?(StringValue) }
end
# Exports the current state to a hash that can be serialized in
# the JSON format
def to_hash
result = {}
has_choice_threads = false
self.current_choices.each do |choice|
choice.original_thread_index = choice.thread_at_generation.thread_index
if callstack.thread_with_index(choice.original_thread_index).nil?
if !has_choice_threads
has_choice_threads = true
result["choiceThreads"]= {}
end
result["choiceThreads"][choice.original_thread_index.to_s] = choice.thread_at_generation.to_hash
end
end
result["callstackThreads"] = callstack.to_hash
result["variablesState"] = variables_state.to_hash
result["evalStack"] = Serializer.convert_array_of_runtime_objects(self.evaluation_stack)
result["outputStream"] = Serializer.convert_array_of_runtime_objects(self.output_stream)
result["currentChoices"] = Serializer.convert_choices(@current_choices)
if !self.diverted_pointer.null_pointer?
result["currentDivertTarget"] = self.diverted_pointer.path.components_string
end
result["visitCounts"] = self.visit_counts
result["turnIndicies"] = self.turn_indicies
result["turnIdx"] = self.current_turn_index
result["story_seed"] = self.story_seed
result["previousRandom"] = self.previous_random
result["inkSaveVersion"] = CURRENT_INK_SAVE_STATE_VERSION
result["inkFormatVersion"] = Story::CURRENT_INK_VERSION
return result
end
# Load a previously saved state from a Hash
def from_hash!(loaded_state)
if loaded_state["inkSaveVersion"].nil?
raise Error, "ink save format incorrect, can't load."
end
if loaded_state["inkSaveVersion"] < MINIMUM_COMPATIBLE_INK_LOAD_VERSION
raise Error, "Ink save format isn't compatible with the current version (saw #{loaded_state["inkSaveVersion"]}, but minimum is #{MINIMUM_COMPATIBLE_INK_LOAD_VERSION}), so can't load."
end
self.callstack.from_hash!(loaded_state["callstackThreads"], story)
self.variables_state.from_hash!(loaded_state["variablesState"])
self.evaluation_stack = Serializer.convert_to_runtime_objects(loaded_state["evalStack"])
self.output_stream = Serializer.convert_to_runtime_objects(loaded_state["outputStream"])
self.output_stream_dirty!
self.current_choices = Serializer.convert_to_runtime_objects(loaded_state["currentChoices"])
if loaded_state.has_key?("currentDivertTarget")
divert_path = Path.new(loaded_state["currentDivertTarget"])
self.diverted_pointer = story.pointer_at_path(divert_path)
end
self.visit_counts = loaded_state["visitCounts"]
self.turn_indicies = loaded_state["turnIndicies"]
self.current_turn_index = loaded_state["turnIdx"]
self.story_seed = loaded_state["storySeed"]
self.previous_random = loaded_state["previousRandom"] || 0
saved_choice_threads = loaded_state["choiceThreads"] || {}
@current_choices.each do |choice|
found_active_thread = callstack.thread_with_index(choice.original_thread_index)
if !found_active_thread.nil?
choice.thread_at_generation = found_active_thread.copy
else
saved_choice_thread = saved_choice_threads[choice.original_thread_index.to_s]
choice.thread_at_generation = CallStack::Thread.new(saved_choice_thread, story)
end
end
end
def assert!(condition, message=nil)
story.assert!(condition, message)
end
# Don't make public since the method needs to be wrapped in a story for visit countind
def set_chosen_path(path, incrementing_turn_index)
# Changing direction, assume we need to clear current set of choices
@current_choices.clear
new_pointer = story.pointer_at_path(path)
if !new_pointer.null_pointer? && new_pointer.index == -1
new_pointer.index = 0
end
self.current_pointer = new_pointer
if incrementing_turn_index
self.current_turn_index += 1
end
end
end
end